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

126 lines
3.9 KiB
Go
Raw Normal View History

2026-08-03 05:52:02 +00:00
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]) + "…"
}