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)
|
||
}
|
||
}
|