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

296 lines
10 KiB
Go
Raw Normal View History

2026-08-03 05:52:02 +00:00
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 "23 天內,時效尚可"
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
}