package usecase import ( "context" "fmt" "strings" "apps/backend/internal/module/radar/domain" usageDomain "apps/backend/internal/module/usage/domain" ) func (s *Service) ListReplies(ctx context.Context, ownerUID int64, opportunityID string) ([]*domain.ReplyVariant, error) { if _, err := s.GetOpportunity(ctx, ownerUID, opportunityID); err != nil { return nil, err } return s.Repo.ListReplies(ctx, ownerUID, opportunityID) } /* GenerateReply creates one reply variant with forbidden-word filter. DM variants are copy-only: never claim auto-send (spec 送出閘). */ func (s *Service) GenerateReply(ctx context.Context, ownerUID int64, opportunityID, variant string) (_ *domain.ReplyVariant, err error) { if !domain.IsReplyVariant(variant) { return nil, fmt.Errorf("%w: unknown variant %q", domain.ErrValidation, variant) } o, err := s.GetOpportunity(ctx, ownerUID, opportunityID) if err != nil { return nil, err } profile, _ := s.Repo.GetServiceProfile(ctx, ownerUID) charge, err := s.bill(ctx, ownerUID, usageDomain.MeterAICopy, "雷達回覆生成", "radar.reply") if err != nil { return nil, err } defer charge.Settle(ctx, &err) text, err := s.completeAI(ctx, ownerUID, replyPrompt(profile, o, variant)) if err != nil { // heuristic fallback text = heuristicReply(profile, o, variant) } text = strings.TrimSpace(text) if profile != nil { text = filterForbidden(text, profile.Forbidden) } if text == "" { return nil, fmt.Errorf("%w: empty reply after forbidden filter", domain.ErrValidation) } r := &domain.ReplyVariant{ ID: domain.NewID(), OwnerUID: ownerUID, OpportunityID: opportunityID, Variant: variant, Text: text, CreatedAt: domain.NowNano(), } if err := r.Normalize(); err != nil { return nil, err } if err := s.Repo.SaveReply(ctx, r); err != nil { return nil, err } return r, nil } func replyPrompt(profile *domain.ServiceProfile, o *domain.Opportunity, variant string) string { var b strings.Builder b.WriteString("為以下商機寫一則繁中台灣語氣的回覆草稿,只輸出正文。\n") b.WriteString("版本:" + variant + "\n") if variant == domain.ReplyDM { b.WriteString("這是私訊草稿,使用者會手動複製送出,不要寫「已自動送出」。\n") } if profile != nil { if len(profile.Forbidden) > 0 { b.WriteString("禁止使用:" + strings.Join(profile.Forbidden, "、") + "\n") } if profile.ToneNote != "" { b.WriteString("語氣:" + profile.ToneNote + "\n") } } b.WriteString("貼文:\n" + o.Text + "\n") return b.String() } func heuristicReply(profile *domain.ServiceProfile, o *domain.Opportunity, variant string) string { svc := "我們的服務" if profile != nil && len(profile.Services) > 0 { svc = profile.Services[0].Name } switch variant { case domain.ReplyNoSales: return "看到你的需求了,先分享一點實務上常見的做法給你參考,有問題再問我。" case domain.ReplyProfessional: return fmt.Sprintf("您好,關於「%s」,我這邊有%s的經驗,若方便可再補充你的時間與預算範圍。", trimRunes(o.Text, 40), svc) case domain.ReplyHumorous: return "懂你的痛苦!這題我遇過幾次,願意的話我可以幫你釐清怎麼選比較不踩雷。" case domain.ReplyDM: return fmt.Sprintf("你好,我做%s,看到你的貼文想私訊給你更完整的說明(請手動貼上傳送)。", svc) default: return fmt.Sprintf("嗨,看到你在找相關協助。我這邊有%s可以幫忙,若還在比較歡迎問我細節。", svc) } } func filterForbidden(text string, forbidden []string) string { out := text for _, f := range forbidden { f = strings.TrimSpace(f) if f == "" { continue } out = strings.ReplaceAll(out, f, "…") } return out } func trimRunes(s string, n int) string { r := []rune(s) if len(r) <= n { return s } return string(r[:n]) + "…" }