thread-master/apps/backend/internal/module/radar/usecase/suggest_test.go

319 lines
9.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 TestSuggestWorksWithoutServiceProfile(t *testing.T) {
svc := New(repository.NewMemory())
ai := &stubAI{reply: suggestReply}
svc.AI = ai
list, err := svc.SuggestWatchTerms(context.Background(), 42, 0)
if err != nil {
t.Fatalf("suggest without profile: %v", err)
}
if len(list) == 0 {
t.Fatal("want generic suggestions when profile is missing")
}
if ai.calls != 1 {
t.Fatalf("AI calls = %d, want 1", ai.calls)
}
if !strings.Contains(ai.lastPrompt, "尚未填服務檔案") {
t.Fatal("prompt should say the profile is missing instead of inventing one")
}
}
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) {
// 沒理由、太短、重複丟掉;過長 include 收成可搜短詞,不再整條丟。
svc, _, ctx := suggestService(t, `[
{"term":"婚攝 求推薦","reason":"在找攝影師的人常這樣問","usage":"include"},
{"term":"沒有理由的詞","reason":" ","usage":"include"},
{"term":"a","reason":"太短","usage":"include"},
{"term":"台北 婚攝 推薦 價格","reason":"三詞以上不合 Threads 規則","usage":"include"},
{"term":"婚攝 求推薦","reason":"重複","usage":"include"}
]`)
list, err := svc.SuggestWatchTerms(ctx, 42, 0)
if err != nil {
t.Fatalf("suggest: %v", err)
}
if len(list) != 2 || list[0].Term != "婚攝 求推薦" || list[1].Term != "台北 婚攝" {
t.Fatalf("got %+v, want shortened multi-token plus the original short term", list)
}
for _, item := range list {
if item.Usage == domain.SuggestUsageInclude && !domain.IsThreadsSearchable(item.Term) {
t.Fatalf("include not searchable: %+v", item)
}
}
}
// 模型愛加開場白或 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 TestSuggestFallsBackOnUnusableReply(t *testing.T) {
svc, _, ctx := suggestService(t, "我不知道要建議什麼")
list, err := svc.SuggestWatchTerms(ctx, 42, 0)
if err != nil {
t.Fatalf("unusable AI reply should fall back: %v", err)
}
if len(list) == 0 {
t.Fatal("fallback returned nothing")
}
}
func TestSuggestFallsBackWhenAIFails(t *testing.T) {
svc, ai, ctx := suggestService(t, "")
ai.err = errors.New("provider down")
list, err := svc.SuggestWatchTerms(ctx, 42, 0)
if err != nil {
t.Fatalf("AI failure should fall back: %v", err)
}
if len(list) == 0 {
t.Fatal("fallback returned nothing")
}
}
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 metersource 標 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 失敗改走 fallback使用者仍拿到詞但沒真正呼叫模型所以要退點。
func TestSuggestReleasesCreditWhenAIFails(t *testing.T) {
svc, ai, ctx := suggestService(t, "")
ai.err = errors.New("provider down")
usage := platformUsage(42)
svc.Usage = usage
list, err := svc.SuggestWatchTerms(ctx, 42, 0)
if err != nil {
t.Fatalf("AI failure should fall back: %v", err)
}
if len(list) == 0 {
t.Fatal("fallback returned nothing")
}
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 fallback call", len(events))
}
}
func TestSuggestWithoutAIClientUsesFallback(t *testing.T) {
svc := New(repository.NewMemory())
ctx := context.Background()
list, err := svc.SuggestWatchTerms(ctx, 42, 0)
if err != nil {
t.Fatalf("missing AI client should fall back: %v", err)
}
if len(list) == 0 {
t.Fatal("fallback returned nothing")
}
found := false
for _, item := range list {
if item.Term == "求推薦" {
found = true
break
}
}
if !found {
t.Fatalf("fallback missing 求推薦: %+v", list)
}
}