296 lines
10 KiB
Go
296 lines
10 KiB
Go
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
|
||
}
|