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
|
|||
|
|
}
|