package usecase import ( "context" "encoding/json" "fmt" "strings" "apps/backend/internal/module/radar/domain" usageDomain "apps/backend/internal/module/usage/domain" ) // JudgeResult is the five-question output for one candidate. type JudgeResult struct { Status string IntentScore int IntentBand string Reasons []domain.OpportunityReason RegionDetected string RegionMatch string FreshnessHours int MatchedService string RejectReason string } /* JudgeCandidate produces intent score / band / five reasons / region_match. Hard rejects: non-authentic classification, provider_offer/announcement/noise, age > 14d. Meter: ai_research / radar.judge when AI path is used; heuristic path still bills once if Usage set. */ func (s *Service) JudgeCandidate(ctx context.Context, ownerUID int64, profile *domain.ServiceProfile, watch *domain.RadarWatch, cand *domain.CandidatePost) (res *JudgeResult, credits int, err error) { if cand == nil { return nil, 0, fmt.Errorf("%w: candidate required", domain.ErrValidation) } now := domain.NowNano() hours := domain.FreshnessHoursSince(cand.PostedAt, now) // Hard reject: stale if domain.IsStaleHardReject(cand.PostedAt, now) { return hardReject("貼文發布已超過 14 天", hours, profile, watch, cand), 0, nil } // Hard reject: classification switch cand.Classification { case "provider_offer", "announcement", "noise": return hardReject("分類為"+cand.Classification+",非真實需求", hours, profile, watch, cand), 0, nil } charge, berr := s.bill(ctx, ownerUID, usageDomain.MeterAIResearch, "雷達五問判定", "radar.judge") if berr != nil { return nil, 0, berr } defer charge.Settle(ctx, &err) credits = usageDomain.MeterCost(usageDomain.MeterAIResearch) // Prefer structured AI; fall back to deterministic heuristic for tests / offline. if raw, aerr := s.completeAI(ctx, ownerUID, judgePrompt(profile, watch, cand)); aerr == nil { if parsed, perr := parseJudgeJSON(raw); perr == nil && parsed != nil { if err := domain.ValidateReasons(parsed.Reasons); err == nil { parsed.FreshnessHours = hours if parsed.Status == "" { if parsed.IntentScore >= domain.BandMidMinScore { parsed.Status = domain.OppQualified } else { parsed.Status = domain.OppQualified // low still qualified listing } } if parsed.IntentBand == "" { parsed.IntentBand = domain.BandFromScore(parsed.IntentScore) } return parsed, credits, nil } } } // Heuristic judge (also used when AI output is incomplete). res = heuristicJudge(profile, watch, cand, hours) if err := domain.ValidateReasons(res.Reasons); err != nil { // Treat incomplete as judge failure (caller skips persist). return nil, credits, err } return res, credits, nil } func hardReject(reason string, hours int, profile *domain.ServiceProfile, watch *domain.RadarWatch, cand *domain.CandidatePost) *JudgeResult { areas := serviceAreasFor(profile, watch) detected := domain.DetectRegionCodes(cand.Text + " " + cand.Title) remote := profile != nil && profile.RemoteOk match, regionScore := domain.MatchRegion(detected, areas, remote) freshScore := domain.FreshnessScore(hours) reasons := []domain.OpportunityReason{ {Dimension: domain.DimAuthenticity, Score: 0, Reason: reason}, {Dimension: domain.DimIntent, Score: 0, Reason: "硬否決後不計購買意圖"}, {Dimension: domain.DimRegion, Score: regionScore, Reason: regionReason(match, detected)}, {Dimension: domain.DimFreshness, Score: freshScore, Reason: freshnessReason(hours)}, {Dimension: domain.DimFit, Score: 0, Reason: "硬否決後不計服務匹配"}, } score := domain.SumReasonScores(reasons) return &JudgeResult{ Status: domain.OppRejected, IntentScore: score, IntentBand: domain.BandFromScore(score), Reasons: reasons, RegionDetected: firstOrEmpty(detected), RegionMatch: match, FreshnessHours: hours, RejectReason: reason, } } func heuristicJudge(profile *domain.ServiceProfile, watch *domain.RadarWatch, cand *domain.CandidatePost, hours int) *JudgeResult { text := strings.ToLower(cand.Text + " " + cand.Title) areas := serviceAreasFor(profile, watch) detected := domain.DetectRegionCodes(cand.Text + " " + cand.Title) remote := profile != nil && profile.RemoteOk match, regionScore := domain.MatchRegion(detected, areas, remote) // authenticity authScore := 10 authReason := "貼文語氣偏討論,真實需求訊號中等" if hasAnySub(text, "求推薦", "有人推薦", "推薦嗎", "徵", "找", "需要", "請問") { authScore = domain.WeightAuthenticity authReason = "貼文明確在找服務或求推薦,像真實需求" } if hasAnySub(text, "接案", "檔期", "價格表") { authScore = 5 authReason = "語氣像同業供給,真實需求較弱" } // intent intentScore := 12 intentReason := "有興趣但購買意圖不明顯" if hasAnySub(text, "推薦", "預算", "報價", "價格", "多少錢", "档期", "檔期", "什麼時候") { intentScore = domain.WeightIntent intentReason = "提到價格/檔期/求推薦,購買意圖高" } // freshness freshScore := domain.FreshnessScore(hours) // fit fitScore := 4 fitReason := "與服務項目關聯有限" matchedService := "" if profile != nil { for _, svc := range profile.Services { name := strings.ToLower(svc.Name) if name != "" && strings.Contains(text, name) { fitScore = domain.WeightFit fitReason = "貼文提到服務項目「" + svc.Name + "」" matchedService = svc.Name break } } if matchedService == "" && len(profile.Services) > 0 { // soft match via watch terms if cand.MatchedTerm != "" { fitScore = 7 fitReason = "觸發關鍵字與服務檔案相關" matchedService = profile.Services[0].Name } } } // hard reject authenticity if clearly not demand if authScore <= 5 && hasAnySub(text, "接案中", "歡迎洽詢我") { return hardReject("非真實需求(同業供給語氣)", hours, profile, watch, cand) } // region mismatch hard reject when not remote if match == domain.RegionMismatch && !remote { return hardReject("服務地區明確不符且不可遠端", hours, profile, watch, cand) } reasons := []domain.OpportunityReason{ {Dimension: domain.DimAuthenticity, Score: authScore, Reason: authReason}, {Dimension: domain.DimIntent, Score: intentScore, Reason: intentReason}, {Dimension: domain.DimRegion, Score: regionScore, Reason: regionReason(match, detected)}, {Dimension: domain.DimFreshness, Score: freshScore, Reason: freshnessReason(hours)}, {Dimension: domain.DimFit, Score: fitScore, Reason: fitReason}, } score := domain.SumReasonScores(reasons) return &JudgeResult{ Status: domain.OppQualified, IntentScore: score, IntentBand: domain.BandFromScore(score), Reasons: reasons, RegionDetected: firstOrEmpty(detected), RegionMatch: match, FreshnessHours: hours, MatchedService: matchedService, } } func serviceAreasFor(profile *domain.ServiceProfile, watch *domain.RadarWatch) []string { if watch != nil && len(watch.Regions) > 0 { return watch.Regions } if profile != nil { return profile.ServiceAreas } return nil } func regionReason(match string, detected []string) string { switch match { case domain.RegionMatch: return "貼文地區與服務範圍相符(" + strings.Join(detected, ",") + ")" case domain.RegionMismatch: return "貼文地區不在服務範圍(" + strings.Join(detected, ",") + ")" default: return "貼文未提地區,不做縣市猜測" } } func freshnessReason(hours int) string { switch { case hours <= 24: return "24 小時內發布,時效佳" case hours <= 72: return "2–3 天內,時效尚可" case hours <= domain.MaxFreshnessDays*24: return "已超過三天,時效偏低" default: return "超過 14 天" } } func firstOrEmpty(ss []string) string { if len(ss) == 0 { return "" } return ss[0] } func judgePrompt(profile *domain.ServiceProfile, watch *domain.RadarWatch, cand *domain.CandidatePost) string { var b strings.Builder b.WriteString("你是台灣在地服務業的商機判定助理。依五問為貼文打分,只輸出 JSON。\n") b.WriteString("五維度權重:authenticity 30、intent 30、region 15、freshness 15、fit 10。\n") b.WriteString("region_match 只能是 match|mismatch|unknown,未提地區必須 unknown,禁止猜縣市。\n") if profile != nil { b.WriteString("服務項目:") for i, s := range profile.Services { if i > 0 { b.WriteString("、") } b.WriteString(s.Name) } b.WriteString("\n地區:" + strings.Join(profile.ServiceAreas, ",") + "\n") if profile.RemoteOk { b.WriteString("可遠端。\n") } } if watch != nil { b.WriteString("觸發關鍵字:" + strings.Join(watch.Terms, "、") + "\n") } b.WriteString("貼文:\n" + cand.Text + "\n") b.WriteString(`輸出:{"status":"qualified|rejected","intent_score":0-100,"reasons":[{"dimension":"authenticity|intent|region|freshness|fit","score":0,"reason":"人話"}],"region_detected":"","region_match":"unknown","matched_service":"","reject_reason":""}`) return b.String() } func parseJudgeJSON(raw string) (*JudgeResult, error) { start := strings.Index(raw, "{") end := strings.LastIndex(raw, "}") if start < 0 || end <= start { return nil, fmt.Errorf("no json object") } var tmp struct { Status string `json:"status"` IntentScore int `json:"intent_score"` Reasons []domain.OpportunityReason `json:"reasons"` RegionDetected string `json:"region_detected"` RegionMatch string `json:"region_match"` MatchedService string `json:"matched_service"` RejectReason string `json:"reject_reason"` } if err := json.Unmarshal([]byte(raw[start:end+1]), &tmp); err != nil { return nil, err } score := tmp.IntentScore if score == 0 && len(tmp.Reasons) > 0 { score = domain.SumReasonScores(tmp.Reasons) } status := tmp.Status if status == "" { status = domain.OppQualified } return &JudgeResult{ Status: status, IntentScore: score, IntentBand: domain.BandFromScore(score), Reasons: tmp.Reasons, RegionDetected: tmp.RegionDetected, RegionMatch: tmp.RegionMatch, MatchedService: tmp.MatchedService, RejectReason: tmp.RejectReason, }, nil }