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]) + "…"
|
|||
|
|
}
|