2026-08-03 05:52:02 +00:00
|
|
|
|
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 = `[
|
2026-08-09 07:57:35 +00:00
|
|
|
|
{"term":"婚攝 求推薦","reason":"直接在找婚禮攝影的人常這樣問","usage":"include"},
|
|
|
|
|
|
{"term":"台北 婚攝","reason":"帶地區的人通常已在比較廠商","usage":"include"},
|
|
|
|
|
|
{"term":"徵婚攝","reason":"這是同業徵才不是客戶需求","usage":"exclude"}
|
2026-08-03 05:52:02 +00:00
|
|
|
|
]`
|
|
|
|
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-19 07:37:44 +00:00
|
|
|
|
func TestSuggestWorksWithoutServiceProfile(t *testing.T) {
|
2026-08-03 05:52:02 +00:00
|
|
|
|
svc := New(repository.NewMemory())
|
|
|
|
|
|
ai := &stubAI{reply: suggestReply}
|
|
|
|
|
|
svc.AI = ai
|
|
|
|
|
|
|
2026-08-19 07:37:44 +00:00
|
|
|
|
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")
|
2026-08-03 05:52:02 +00:00
|
|
|
|
}
|
2026-08-19 07:37:44 +00:00
|
|
|
|
if ai.calls != 1 {
|
|
|
|
|
|
t.Fatalf("AI calls = %d, want 1", ai.calls)
|
2026-08-03 05:52:02 +00:00
|
|
|
|
}
|
2026-08-19 07:37:44 +00:00
|
|
|
|
if !strings.Contains(ai.lastPrompt, "尚未填服務檔案") {
|
|
|
|
|
|
t.Fatal("prompt should say the profile is missing instead of inventing one")
|
2026-08-03 05:52:02 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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) {
|
2026-08-13 07:54:25 +00:00
|
|
|
|
// 沒理由、太短、重複丟掉;過長 include 收成可搜短詞,不再整條丟。
|
2026-08-03 05:52:02 +00:00
|
|
|
|
svc, _, ctx := suggestService(t, `[
|
2026-08-09 07:57:35 +00:00
|
|
|
|
{"term":"婚攝 求推薦","reason":"在找攝影師的人常這樣問","usage":"include"},
|
2026-08-03 05:52:02 +00:00
|
|
|
|
{"term":"沒有理由的詞","reason":" ","usage":"include"},
|
|
|
|
|
|
{"term":"a","reason":"太短","usage":"include"},
|
2026-08-09 07:57:35 +00:00
|
|
|
|
{"term":"台北 婚攝 推薦 價格","reason":"三詞以上不合 Threads 規則","usage":"include"},
|
|
|
|
|
|
{"term":"婚攝 求推薦","reason":"重複","usage":"include"}
|
2026-08-03 05:52:02 +00:00
|
|
|
|
]`)
|
|
|
|
|
|
|
|
|
|
|
|
list, err := svc.SuggestWatchTerms(ctx, 42, 0)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("suggest: %v", err)
|
|
|
|
|
|
}
|
2026-08-13 07:54:25 +00:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
2026-08-03 05:52:02 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 模型愛加開場白或 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))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-19 07:37:44 +00:00
|
|
|
|
// 模型回空話時改走通用短詞,不要讓「建議關鍵字」整顆按鈕壞掉。
|
|
|
|
|
|
func TestSuggestFallsBackOnUnusableReply(t *testing.T) {
|
2026-08-03 05:52:02 +00:00
|
|
|
|
svc, _, ctx := suggestService(t, "我不知道要建議什麼")
|
|
|
|
|
|
|
2026-08-19 07:37:44 +00:00
|
|
|
|
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")
|
2026-08-03 05:52:02 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-19 07:37:44 +00:00
|
|
|
|
func TestSuggestFallsBackWhenAIFails(t *testing.T) {
|
2026-08-03 05:52:02 +00:00
|
|
|
|
svc, ai, ctx := suggestService(t, "")
|
|
|
|
|
|
ai.err = errors.New("provider down")
|
|
|
|
|
|
|
2026-08-19 07:37:44 +00:00
|
|
|
|
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")
|
2026-08-03 05:52:02 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-19 07:37:44 +00:00
|
|
|
|
// AI 失敗改走 fallback:使用者仍拿到詞,但沒真正呼叫模型,所以要退點。
|
2026-08-03 05:52:02 +00:00
|
|
|
|
func TestSuggestReleasesCreditWhenAIFails(t *testing.T) {
|
|
|
|
|
|
svc, ai, ctx := suggestService(t, "")
|
|
|
|
|
|
ai.err = errors.New("provider down")
|
|
|
|
|
|
usage := platformUsage(42)
|
|
|
|
|
|
svc.Usage = usage
|
|
|
|
|
|
|
2026-08-19 07:37:44 +00:00
|
|
|
|
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")
|
2026-08-03 05:52:02 +00:00
|
|
|
|
}
|
|
|
|
|
|
events, err := usage.ListEvents(ctx, 42, usageDomain.CurrentMonthKey(), "all", 0)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("list events: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(events) != 0 {
|
2026-08-19 07:37:44 +00:00
|
|
|
|
t.Fatalf("charged %d events for a fallback call", len(events))
|
2026-08-03 05:52:02 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-19 07:37:44 +00:00
|
|
|
|
func TestSuggestWithoutAIClientUsesFallback(t *testing.T) {
|
2026-08-03 05:52:02 +00:00
|
|
|
|
svc := New(repository.NewMemory())
|
|
|
|
|
|
ctx := context.Background()
|
|
|
|
|
|
|
2026-08-19 07:37:44 +00:00
|
|
|
|
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)
|
2026-08-03 05:52:02 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|