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

328 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 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()
}