290 lines
9.1 KiB
Go
290 lines
9.1 KiB
Go
|
|
package usecase
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"context"
|
|||
|
|
"errors"
|
|||
|
|
"fmt"
|
|||
|
|
"strings"
|
|||
|
|
"testing"
|
|||
|
|
|
|||
|
|
"apps/backend/internal/module/radar/domain"
|
|||
|
|
"apps/backend/internal/module/radar/repository"
|
|||
|
|
usageDomain "apps/backend/internal/module/usage/domain"
|
|||
|
|
usageRepo "apps/backend/internal/module/usage/repository"
|
|||
|
|
usageUC "apps/backend/internal/module/usage/usecase"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
type stubAI struct {
|
|||
|
|
reply string
|
|||
|
|
err error
|
|||
|
|
lastPrompt string
|
|||
|
|
calls int
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *stubAI) Complete(_ context.Context, _, _, prompt string) (string, error) {
|
|||
|
|
s.calls++
|
|||
|
|
s.lastPrompt = prompt
|
|||
|
|
return s.reply, s.err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *stubAI) CompleteStream(ctx context.Context, apiKey, model, prompt string, _ func(string) error) (string, error) {
|
|||
|
|
return s.Complete(ctx, apiKey, model, prompt)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *stubAI) ListModels(context.Context, string) ([]string, error) { return nil, nil }
|
|||
|
|
|
|||
|
|
type stubPainTerms struct {
|
|||
|
|
terms []string
|
|||
|
|
err error
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s stubPainTerms) PainTerms(context.Context, int64) ([]string, error) {
|
|||
|
|
return s.terms, s.err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// platformUsage 給一個用平台點數的會員,這樣扣點與退點都真的會落到用量帳上。
|
|||
|
|
func platformUsage(uid int64) *usageUC.Service {
|
|||
|
|
key := func(meter string) string { return fmt.Sprintf("%d:%s", uid, meter) }
|
|||
|
|
return usageUC.New(usageRepo.NewMemory(), &usageUC.StaticResolver{Map: map[string]string{
|
|||
|
|
key(usageDomain.MeterAICopy): usageDomain.KeyModePlatform,
|
|||
|
|
key(usageDomain.MeterAIResearch): usageDomain.KeyModePlatform,
|
|||
|
|
}})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func suggestService(t *testing.T, reply string) (*Service, *stubAI, context.Context) {
|
|||
|
|
t.Helper()
|
|||
|
|
ai := &stubAI{reply: reply}
|
|||
|
|
svc := New(repository.NewMemory())
|
|||
|
|
svc.AI = ai
|
|||
|
|
ctx := context.Background()
|
|||
|
|
if _, err := svc.UpsertServiceProfile(ctx, 42, sampleProfile()); err != nil {
|
|||
|
|
t.Fatalf("seed profile: %v", err)
|
|||
|
|
}
|
|||
|
|
return svc, ai, ctx
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const suggestReply = `[
|
|||
|
|
{"term":"台北 婚攝 推薦","reason":"直接在找婚禮攝影的人常這樣問","usage":"include"},
|
|||
|
|
{"term":"婚禮 攝影 價格","reason":"問價格通常已經在比較廠商","usage":"include"},
|
|||
|
|
{"term":"徵 婚攝","reason":"這是同業徵才不是客戶需求","usage":"exclude"}
|
|||
|
|
]`
|
|||
|
|
|
|||
|
|
// RW-03:有服務檔案就回得出建議,每則都要有理由。
|
|||
|
|
func TestSuggestWatchTermsReturnsReasonedSuggestions(t *testing.T) {
|
|||
|
|
svc, ai, ctx := suggestService(t, suggestReply)
|
|||
|
|
|
|||
|
|
list, err := svc.SuggestWatchTerms(ctx, 42, 0)
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("suggest: %v", err)
|
|||
|
|
}
|
|||
|
|
if len(list) != 3 {
|
|||
|
|
t.Fatalf("got %d suggestions, want 3", len(list))
|
|||
|
|
}
|
|||
|
|
for _, s := range list {
|
|||
|
|
if strings.TrimSpace(s.Reason) == "" {
|
|||
|
|
t.Fatalf("suggestion %q has no reason", s.Term)
|
|||
|
|
}
|
|||
|
|
if s.Usage != domain.SuggestUsageInclude && s.Usage != domain.SuggestUsageExclude {
|
|||
|
|
t.Fatalf("suggestion %q has usage %q", s.Term, s.Usage)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if list[2].Usage != domain.SuggestUsageExclude {
|
|||
|
|
t.Fatalf("exclude suggestion lost its usage: %+v", list[2])
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// prompt 必須帶上服務檔案的內容,否則建議跟這個人的生意無關。
|
|||
|
|
if !strings.Contains(ai.lastPrompt, "婚禮攝影") {
|
|||
|
|
t.Fatal("prompt missing the member's service items")
|
|||
|
|
}
|
|||
|
|
if !strings.Contains(ai.lastPrompt, "臺北市") {
|
|||
|
|
t.Fatal("prompt missing the member's service areas")
|
|||
|
|
}
|
|||
|
|
if !strings.Contains(ai.lastPrompt, "保證接到案") {
|
|||
|
|
t.Fatal("prompt missing the forbidden words")
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestSuggestDoesNotCreateWatches(t *testing.T) {
|
|||
|
|
svc, _, ctx := suggestService(t, suggestReply)
|
|||
|
|
|
|||
|
|
if _, err := svc.SuggestWatchTerms(ctx, 42, 0); err != nil {
|
|||
|
|
t.Fatalf("suggest: %v", err)
|
|||
|
|
}
|
|||
|
|
_, total, err := svc.ListWatches(ctx, 42, domain.WatchListFilter{})
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("list: %v", err)
|
|||
|
|
}
|
|||
|
|
if total != 0 {
|
|||
|
|
t.Fatalf("suggest created %d watches; it must only propose", total)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 沒有服務檔案就沒有依據,寧可明確拒絕也不要憑空猜關鍵字。
|
|||
|
|
func TestSuggestRequiresServiceProfile(t *testing.T) {
|
|||
|
|
svc := New(repository.NewMemory())
|
|||
|
|
ai := &stubAI{reply: suggestReply}
|
|||
|
|
svc.AI = ai
|
|||
|
|
|
|||
|
|
_, err := svc.SuggestWatchTerms(context.Background(), 42, 0)
|
|||
|
|
if !errors.Is(err, domain.ErrValidation) {
|
|||
|
|
t.Fatalf("err = %v, want ErrValidation", err)
|
|||
|
|
}
|
|||
|
|
if !strings.Contains(err.Error(), "service-profile") {
|
|||
|
|
t.Fatalf("error must point at the service profile, got %q", err)
|
|||
|
|
}
|
|||
|
|
if ai.calls != 0 {
|
|||
|
|
t.Fatal("AI was called without a service profile")
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestSuggestRespectsLimit(t *testing.T) {
|
|||
|
|
svc, ai, ctx := suggestService(t, suggestReply)
|
|||
|
|
|
|||
|
|
list, err := svc.SuggestWatchTerms(ctx, 42, 2)
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("suggest: %v", err)
|
|||
|
|
}
|
|||
|
|
if len(list) != 2 {
|
|||
|
|
t.Fatalf("got %d suggestions, want the requested 2", len(list))
|
|||
|
|
}
|
|||
|
|
if !strings.Contains(ai.lastPrompt, "最多 2 則") {
|
|||
|
|
t.Fatal("limit was not passed to the prompt")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if _, err := svc.SuggestWatchTerms(ctx, 42, 999); err != nil {
|
|||
|
|
t.Fatalf("oversized limit should clamp, not fail: %v", err)
|
|||
|
|
}
|
|||
|
|
if !strings.Contains(ai.lastPrompt, "最多 20 則") {
|
|||
|
|
t.Fatalf("limit was not clamped to %d", domain.MaxSuggestions)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestSuggestDropsUnusableItems(t *testing.T) {
|
|||
|
|
// 沒理由、太短、重複的項目都要丟掉,而不是補一句假理由湊數。
|
|||
|
|
svc, _, ctx := suggestService(t, `[
|
|||
|
|
{"term":"婚攝 推薦","reason":"在找攝影師的人常這樣問","usage":"include"},
|
|||
|
|
{"term":"沒有理由的詞","reason":" ","usage":"include"},
|
|||
|
|
{"term":"a","reason":"太短","usage":"include"},
|
|||
|
|
{"term":"婚攝 推薦","reason":"重複","usage":"include"}
|
|||
|
|
]`)
|
|||
|
|
|
|||
|
|
list, err := svc.SuggestWatchTerms(ctx, 42, 0)
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("suggest: %v", err)
|
|||
|
|
}
|
|||
|
|
if len(list) != 1 || list[0].Term != "婚攝 推薦" {
|
|||
|
|
t.Fatalf("got %+v, want only the one usable suggestion", list)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 模型愛加開場白或 code fence,這種回應也要吃得下。
|
|||
|
|
func TestSuggestToleratesProseAroundJSON(t *testing.T) {
|
|||
|
|
svc, _, ctx := suggestService(t, "好的,以下是建議:\n```json\n"+suggestReply+"\n```\n希望有幫助!")
|
|||
|
|
|
|||
|
|
list, err := svc.SuggestWatchTerms(ctx, 42, 0)
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("suggest: %v", err)
|
|||
|
|
}
|
|||
|
|
if len(list) != 3 {
|
|||
|
|
t.Fatalf("got %d suggestions from a fenced reply, want 3", len(list))
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 解析不出任何東西時要報錯:空清單會被讀成「你的服務沒有關鍵字可監控」。
|
|||
|
|
func TestSuggestFailsLoudlyOnUnusableReply(t *testing.T) {
|
|||
|
|
svc, _, ctx := suggestService(t, "我不知道要建議什麼")
|
|||
|
|
|
|||
|
|
_, err := svc.SuggestWatchTerms(ctx, 42, 0)
|
|||
|
|
if !errors.Is(err, domain.ErrValidation) {
|
|||
|
|
t.Fatalf("err = %v, want ErrValidation", err)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestSuggestSurfacesAIFailure(t *testing.T) {
|
|||
|
|
svc, ai, ctx := suggestService(t, "")
|
|||
|
|
ai.err = errors.New("provider down")
|
|||
|
|
|
|||
|
|
if _, err := svc.SuggestWatchTerms(ctx, 42, 0); err == nil {
|
|||
|
|
t.Fatal("AI failure was swallowed")
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestSuggestUsesPainTermsAsMaterialWhenAvailable(t *testing.T) {
|
|||
|
|
svc, ai, ctx := suggestService(t, suggestReply)
|
|||
|
|
svc.PainTerms = stubPainTerms{terms: []string{"找不到有檔期的攝影師"}}
|
|||
|
|
|
|||
|
|
if _, err := svc.SuggestWatchTerms(ctx, 42, 0); err != nil {
|
|||
|
|
t.Fatalf("suggest: %v", err)
|
|||
|
|
}
|
|||
|
|
if !strings.Contains(ai.lastPrompt, "找不到有檔期的攝影師") {
|
|||
|
|
t.Fatal("existing pain terms were not used as prompt material")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 素材拿不到只影響品質,不該讓整個請求失敗。
|
|||
|
|
svc.PainTerms = stubPainTerms{err: errors.New("scout unavailable")}
|
|||
|
|
if _, err := svc.SuggestWatchTerms(ctx, 42, 0); err != nil {
|
|||
|
|
t.Fatalf("pain term failure must not break suggest: %v", err)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/*
|
|||
|
|
計費對照(spec §5.5):走既有 ai_copy meter,source 標 radar.suggest。
|
|||
|
|
|
|||
|
|
source 前綴是 P1 價格校準的唯一資料來源,標錯就等於這次雷達的成本無法歸因。
|
|||
|
|
*/
|
|||
|
|
func TestSuggestRecordsAiCopyUsageWithRadarSource(t *testing.T) {
|
|||
|
|
svc, _, ctx := suggestService(t, suggestReply)
|
|||
|
|
usage := platformUsage(42)
|
|||
|
|
svc.Usage = usage
|
|||
|
|
|
|||
|
|
if _, err := svc.SuggestWatchTerms(ctx, 42, 0); err != nil {
|
|||
|
|
t.Fatalf("suggest: %v", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
events, err := usage.ListEvents(ctx, 42, usageDomain.CurrentMonthKey(), "all", 0)
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("list events: %v", err)
|
|||
|
|
}
|
|||
|
|
if len(events) != 1 {
|
|||
|
|
t.Fatalf("got %d usage events, want exactly 1", len(events))
|
|||
|
|
}
|
|||
|
|
if events[0].Meter != usageDomain.MeterAICopy {
|
|||
|
|
t.Fatalf("meter = %q, want %q", events[0].Meter, usageDomain.MeterAICopy)
|
|||
|
|
}
|
|||
|
|
if events[0].Source != "radar.suggest" {
|
|||
|
|
t.Fatalf("source = %q, want radar.suggest", events[0].Source)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// AI 失敗要退點:扣了點卻沒拿到東西是最難解釋的帳。
|
|||
|
|
func TestSuggestReleasesCreditWhenAIFails(t *testing.T) {
|
|||
|
|
svc, ai, ctx := suggestService(t, "")
|
|||
|
|
ai.err = errors.New("provider down")
|
|||
|
|
usage := platformUsage(42)
|
|||
|
|
svc.Usage = usage
|
|||
|
|
|
|||
|
|
if _, err := svc.SuggestWatchTerms(ctx, 42, 0); err == nil {
|
|||
|
|
t.Fatal("AI failure was swallowed")
|
|||
|
|
}
|
|||
|
|
events, err := usage.ListEvents(ctx, 42, usageDomain.CurrentMonthKey(), "all", 0)
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("list events: %v", err)
|
|||
|
|
}
|
|||
|
|
if len(events) != 0 {
|
|||
|
|
t.Fatalf("charged %d events for a failed call", len(events))
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestSuggestWithoutAIClientAsksForKey(t *testing.T) {
|
|||
|
|
svc := New(repository.NewMemory())
|
|||
|
|
ctx := context.Background()
|
|||
|
|
if _, err := svc.UpsertServiceProfile(ctx, 42, sampleProfile()); err != nil {
|
|||
|
|
t.Fatalf("seed profile: %v", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
_, err := svc.SuggestWatchTerms(ctx, 42, 0)
|
|||
|
|
if !errors.Is(err, domain.ErrValidation) {
|
|||
|
|
t.Fatalf("err = %v, want ErrValidation pointing at the AI key", err)
|
|||
|
|
}
|
|||
|
|
}
|