thread-master/apps/backend/internal/module/radar/domain/opportunity.go

328 lines
10 KiB
Go
Raw Normal View History

2026-08-03 05:52:02 +00:00
package domain
import (
"fmt"
"strings"
"time"
)
// Opportunity status — spec §3.2.
const (
OppJudging = "judging"
OppQualified = "qualified"
OppRejected = "rejected"
OppAccepted = "accepted"
OppDismissed = "dismissed"
)
// Intent band thresholds are locked by spec §3.3 (80 / 50). Do not change here.
const (
BandHigh = "high"
BandMid = "mid"
BandLow = "low"
BandHighMinScore = 80
BandMidMinScore = 50
)
// Region match is three-state; unknown is not mismatch (OP-04).
const (
RegionMatch = "match"
RegionMismatch = "mismatch"
RegionUnknown = "unknown"
)
// Opportunity sources.
const (
OppSourceThreads = "threads"
OppSourceManual = "manual"
OppSourceScoutPromote = "scout_promote"
)
// Five judge dimensions — reasons[] must include every one (OP-06).
const (
DimAuthenticity = "authenticity"
DimIntent = "intent"
DimRegion = "region"
DimFreshness = "freshness"
DimFit = "fit"
)
// RequiredReasonDimensions is the fixed set that must all be present to persist.
var RequiredReasonDimensions = []string{
DimAuthenticity,
DimIntent,
DimRegion,
DimFreshness,
DimFit,
}
// OpportunityReason is one scored dimension of the five-question judge.
type OpportunityReason struct {
Dimension string `bson:"dimension" json:"dimension"`
Score int `bson:"score" json:"score"`
Reason string `bson:"reason" json:"reason"`
}
// OpportunityOverride records a human band/status correction for later calibration.
type OpportunityOverride struct {
FromBand string `bson:"from_band,omitempty" json:"from_band,omitempty"`
ToBand string `bson:"to_band,omitempty" json:"to_band,omitempty"`
FromStatus string `bson:"from_status,omitempty" json:"from_status,omitempty"`
ToStatus string `bson:"to_status,omitempty" json:"to_status,omitempty"`
ActorUID int64 `bson:"actor_uid" json:"actor_uid"`
At int64 `bson:"at" json:"at"`
}
/*
Opportunity 是經五問判定後的商機
同一 owner external_id 唯一 watch 命中同一貼文只留一筆新觸發 term
併入 matched_terms不重跑判定SW-06
*/
type Opportunity struct {
ID string `bson:"_id" json:"id"`
OwnerUID int64 `bson:"owner_uid" json:"owner_uid"`
WatchID string `bson:"watch_id,omitempty" json:"watch_id,omitempty"`
Source string `bson:"source" json:"source"`
SourceScoutPostID string `bson:"source_scout_post_id,omitempty" json:"source_scout_post_id,omitempty"`
ExternalID string `bson:"external_id" json:"external_id"`
Permalink string `bson:"permalink" json:"permalink"`
AuthorHandle string `bson:"author_handle" json:"author_handle"`
Text string `bson:"text" json:"text"`
PostedAt int64 `bson:"posted_at" json:"posted_at"`
Status string `bson:"status" json:"status"`
IntentScore int `bson:"intent_score" json:"intent_score"`
IntentBand string `bson:"intent_band" json:"intent_band"`
Reasons []OpportunityReason `bson:"reasons" json:"reasons"`
RegionDetected string `bson:"region_detected,omitempty" json:"region_detected,omitempty"`
RegionMatch string `bson:"region_match" json:"region_match"`
FreshnessHours int `bson:"freshness_hours" json:"freshness_hours"`
MatchedService string `bson:"matched_service,omitempty" json:"matched_service,omitempty"`
MatchedTerms []string `bson:"matched_terms" json:"matched_terms"`
RejectReason string `bson:"reject_reason,omitempty" json:"reject_reason,omitempty"`
Override *OpportunityOverride `bson:"override,omitempty" json:"override,omitempty"`
ContactID string `bson:"contact_id,omitempty" json:"contact_id,omitempty"`
CreatedAt int64 `bson:"created_at" json:"created_at"`
UpdatedAt int64 `bson:"updated_at" json:"updated_at"`
}
// OpportunityListFilter 支援 bandstatuswatch日期區間。
// Status 與 Statuses 擇一Statuses 非空時用 $in否則 Status 做單值比對。
type OpportunityListFilter struct {
Band string
Status string
Statuses []string // 多狀態;$in今日頁qualifiedaccepteddismissed
WatchID string
CreatedFrom int64 // inclusive, unix ns; 0 = no lower bound
CreatedTo int64 // exclusive, unix ns; 0 = no upper bound
Page int
PageSize int
}
// BandFromScore maps intent_score → band with locked 8050 thresholds.
func BandFromScore(score int) string {
if score >= BandHighMinScore {
return BandHigh
}
if score >= BandMidMinScore {
return BandMid
}
return BandLow
}
func IsOppStatus(s string) bool {
switch s {
case OppJudging, OppQualified, OppRejected, OppAccepted, OppDismissed:
return true
}
return false
}
func IsIntentBand(s string) bool {
switch s {
case BandHigh, BandMid, BandLow:
return true
}
return false
}
func IsRegionMatch(s string) bool {
switch s {
case RegionMatch, RegionMismatch, RegionUnknown:
return true
}
return false
}
func IsOppSource(s string) bool {
switch s {
case OppSourceThreads, OppSourceManual, OppSourceScoutPromote:
return true
}
return false
}
/*
CanTransitionOpportunity 實作 spec §3.2
accepteddismissed 為使用者終態rejected qualified 僅經覆寫
judging 之後不可回到 judging
*/
func CanTransitionOpportunity(from, to string) bool {
if from == to {
return true
}
switch from {
case OppJudging:
return to == OppQualified || to == OppRejected
case OppQualified:
return to == OppAccepted || to == OppDismissed
case OppRejected:
return to == OppQualified
default:
return false
}
}
func (o *Opportunity) Transition(to string) error {
if !IsOppStatus(to) {
return fmt.Errorf("%w: unknown opportunity status %q", ErrValidation, to)
}
if !CanTransitionOpportunity(o.Status, to) {
return fmt.Errorf("%w: cannot change opportunity from %s to %s", ErrValidation, o.Status, to)
}
o.Status = to
o.UpdatedAt = NowNano()
return nil
}
// ApplyBandFromScore sets IntentBand from IntentScore (locked thresholds).
func (o *Opportunity) ApplyBandFromScore() {
o.IntentBand = BandFromScore(o.IntentScore)
}
/*
ValidateReasons 要求五維度齊全每條有非空白人話理由OP-06
缺任一維度 明確錯誤repository 不得寫入
*/
func ValidateReasons(reasons []OpportunityReason) error {
if len(reasons) == 0 {
return fmt.Errorf("%w: reasons required (need all five dimensions)", ErrValidation)
}
seen := map[string]bool{}
for _, r := range reasons {
dim := strings.TrimSpace(r.Dimension)
if dim == "" {
return fmt.Errorf("%w: reasons entry missing dimension", ErrValidation)
}
if !isReasonDimension(dim) {
return fmt.Errorf("%w: unknown reason dimension %q", ErrValidation, dim)
}
if seen[dim] {
return fmt.Errorf("%w: duplicate reason dimension %q", ErrValidation, dim)
}
if strings.TrimSpace(r.Reason) == "" {
return fmt.Errorf("%w: reasons[%s] needs a human-readable reason", ErrValidation, dim)
}
seen[dim] = true
}
for _, dim := range RequiredReasonDimensions {
if !seen[dim] {
return fmt.Errorf("%w: reasons missing dimension %q (need all five)", ErrValidation, dim)
}
}
return nil
}
func isReasonDimension(s string) bool {
switch s {
case DimAuthenticity, DimIntent, DimRegion, DimFreshness, DimFit:
return true
}
return false
}
// NormalizeMatchedTerms lower-cases, trims, and de-duplicates terms.
func NormalizeMatchedTerms(in []string) []string {
out := make([]string, 0, len(in))
seen := map[string]bool{}
for _, raw := range in {
t := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(raw, "\u3000", " ")))
t = strings.Join(strings.Fields(t), " ")
if t == "" || seen[t] {
continue
}
seen[t] = true
out = append(out, t)
}
return out
}
// MergeMatchedTerms returns ab with stable order (a first, then new from b).
func MergeMatchedTerms(a, b []string) []string {
return NormalizeMatchedTerms(append(append([]string{}, a...), b...))
}
/*
ValidateForWrite 在首次寫入完整更新前檢查契約欄位
Upsert 命中既有列時只併 term不走這條既有資料已通過判定
*/
func (o *Opportunity) ValidateForWrite() error {
if o.OwnerUID <= 0 {
return fmt.Errorf("%w: owner_uid required", ErrValidation)
}
if strings.TrimSpace(o.ExternalID) == "" {
return fmt.Errorf("%w: external_id required", ErrValidation)
}
if o.Source == "" {
o.Source = OppSourceThreads
}
if !IsOppSource(o.Source) {
return fmt.Errorf("%w: unknown opportunity source %q", ErrValidation, o.Source)
}
if o.Status == "" {
o.Status = OppJudging
}
if !IsOppStatus(o.Status) {
return fmt.Errorf("%w: unknown opportunity status %q", ErrValidation, o.Status)
}
// judging 尚未出分時允許缺 reasons一旦進入 qualifiedrejected 必須五條齊全。
if o.Status != OppJudging {
if err := ValidateReasons(o.Reasons); err != nil {
return err
}
} else if len(o.Reasons) > 0 {
// 若呼叫端已帶 reasons仍驗一次避免半成品入庫。
if err := ValidateReasons(o.Reasons); err != nil {
return err
}
}
if o.RegionMatch == "" {
o.RegionMatch = RegionUnknown
}
if !IsRegionMatch(o.RegionMatch) {
return fmt.Errorf("%w: unknown region_match %q", ErrValidation, o.RegionMatch)
}
if o.IntentBand == "" && (o.Status == OppQualified || o.Status == OppRejected || o.Status == OppAccepted || o.Status == OppDismissed) {
o.ApplyBandFromScore()
}
if o.IntentBand != "" && !IsIntentBand(o.IntentBand) {
return fmt.Errorf("%w: unknown intent_band %q", ErrValidation, o.IntentBand)
}
o.MatchedTerms = NormalizeMatchedTerms(o.MatchedTerms)
return nil
}
// UTCDayBounds returns [start, end) unix ns for the UTC calendar day that contains at.
func UTCDayBounds(at int64) (start, end int64) {
if at <= 0 {
at = NowNano()
}
t := time.Unix(0, at).UTC()
day := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
return day.UnixNano(), day.Add(24 * time.Hour).UnixNano()
}