126 lines
3.9 KiB
Go
126 lines
3.9 KiB
Go
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]) + "…"
|
||
}
|