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

296 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}