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

482 lines
17 KiB
Go
Raw Permalink 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"
)
// ReviewState is the inbox workflow and deliberately remains separate from
// Opportunity status (accepted still belongs to CRM).
const (
ReviewPending = "pending"
ReviewCompleted = "completed"
ReviewRemoved = "removed"
)
const (
RemovalPainMismatch = "pain_mismatch"
RemovalProviderAd = "provider_or_ad"
RemovalStale = "stale"
RemovalAlreadySolved = "already_solved"
RemovalDuplicate = "duplicate"
RemovalOther = "other"
RemovalLegacy = "legacy_unknown"
)
// 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"
// OppSourceManualImport使用者貼 ThreadsFacebook 貼文網址或 CSV 批次匯入spec §4.11 P1
OppSourceManualImport = "manual_import"
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"`
}
type ProductPrimaryOverride struct {
ActorUID int64 `bson:"actor_uid" json:"actor_uid"`
Reason string `bson:"reason" json:"reason"`
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"`
PrimaryBrandID string `bson:"primary_brand_id,omitempty" json:"primary_brand_id,omitempty"`
PrimaryProductID string `bson:"primary_product_id,omitempty" json:"primary_product_id,omitempty"`
PrimaryBrandName string `bson:"primary_brand_name,omitempty" json:"primary_brand_name,omitempty"`
PrimaryProductLabel string `bson:"primary_product_label,omitempty" json:"primary_product_label,omitempty"`
PrimaryProductFitScore int `bson:"primary_product_fit_score,omitempty" json:"primary_product_fit_score,omitempty"`
PrimaryProductFitBand string `bson:"primary_product_fit_band,omitempty" json:"primary_product_fit_band,omitempty"`
PrimaryProductOverridden bool `bson:"primary_product_overridden,omitempty" json:"primary_product_overridden,omitempty"`
PrimaryProductOverride *ProductPrimaryOverride `bson:"primary_product_override,omitempty" json:"primary_product_override,omitempty"`
ProductMatches []*ProductMatch `bson:"product_matches,omitempty" json:"product_matches,omitempty"`
ReviewState string `bson:"review_state,omitempty" json:"review_state,omitempty"`
PreviousReviewState string `bson:"previous_review_state,omitempty" json:"previous_review_state,omitempty"`
RemovalReason string `bson:"removal_reason,omitempty" json:"removal_reason,omitempty"`
RemovalNote string `bson:"removal_note,omitempty" json:"removal_note,omitempty"`
RemovedAt int64 `bson:"removed_at,omitempty" json:"removed_at,omitempty"`
RemovedBy int64 `bson:"removed_by,omitempty" json:"removed_by,omitempty"`
LastMatchedAt int64 `bson:"last_matched_at,omitempty" json:"last_matched_at,omitempty"`
PriorityScore int `bson:"priority_score,omitempty" json:"priority_score,omitempty"`
PriorityBand string `bson:"priority_band,omitempty" json:"priority_band,omitempty"`
PainFitScore int `bson:"pain_fit_score,omitempty" json:"pain_fit_score,omitempty"`
DemandIntentScore int `bson:"demand_intent_score,omitempty" json:"demand_intent_score,omitempty"`
EvidenceQualityScore int `bson:"evidence_quality_score,omitempty" json:"evidence_quality_score,omitempty"`
FreshnessScore int `bson:"freshness_score,omitempty" json:"freshness_score,omitempty"`
DemandEvidence []string `bson:"demand_evidence,omitempty" json:"demand_evidence,omitempty"`
DemandInputVersion string `bson:"demand_input_version,omitempty" json:"demand_input_version,omitempty"`
DemandMapVersion int64 `bson:"demand_map_version,omitempty" json:"demand_map_version,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
PostedFrom int64
PostedTo int64
Page int
PageSize int
BrandID string
ProductID string
FitBand string
MatchState string
Sort string
ReviewState string
TimeScope string
PriorityBand string
}
type ReviewStatePatch struct {
State string
RemovalReason string
RemovalNote string
DuplicateOpportunityID string
RemovedBy int64
}
func IsReviewState(s string) bool {
return s == ReviewPending || s == ReviewCompleted || s == ReviewRemoved
}
func IsRemovalReason(s string) bool {
switch s {
case RemovalPainMismatch, RemovalProviderAd, RemovalStale, RemovalAlreadySolved, RemovalDuplicate, RemovalOther, RemovalLegacy:
return true
default:
return false
}
}
// 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, OppSourceManualImport, 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)
seenProducts := map[string]bool{}
for _, match := range o.ProductMatches {
if err := match.ValidateForWrite(); err != nil {
return err
}
if seenProducts[match.ProductID] {
return fmt.Errorf("%w: duplicate product match %q", ErrValidation, match.ProductID)
}
seenProducts[match.ProductID] = true
}
if o.PrimaryProductID != "" {
var primary *ProductMatch
for _, match := range o.ProductMatches {
if match.ProductID == o.PrimaryProductID {
primary = match
break
}
}
if primary == nil {
return fmt.Errorf("%w: primary product must exist in product_matches", ErrValidation)
}
if (!primary.Eligible || primary.Excluded) && o.PrimaryProductOverride == nil {
return fmt.Errorf("%w: weak or excluded primary requires override", ErrValidation)
}
}
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()
}
// DisplayLocation is the product default timezone (Harbor Desk is Taipei-first).
func DisplayLocation() *time.Location {
return time.FixedZone("Asia/Taipei", 8*60*60)
}
// LocalDayBounds returns [start, end) unix ns for the local calendar day that contains at.
func LocalDayBounds(at int64, loc *time.Location) (start, end int64) {
if loc == nil {
loc = DisplayLocation()
}
if at <= 0 {
at = NowNano()
}
t := time.Unix(0, at).In(loc)
day := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc)
return day.UnixNano(), day.Add(24 * time.Hour).UnixNano()
}
// InInboxTimeScope is true when a row belongs on today/7d.
// Posted-in-range is the primary meaning; a just-finished sweep must still
// surface older or undated posts via last_matched_at / created_at, otherwise
// the default pending+today inbox looks empty after patrol.
func InInboxTimeScope(postedAt, lastMatchedAt, createdAt, from, to int64) bool {
if from <= 0 && to <= 0 {
return true
}
in := func(ts int64) bool {
if ts <= 0 {
return false
}
if from > 0 && ts < from {
return false
}
if to > 0 && ts >= to {
return false
}
return true
}
if in(postedAt) {
return true
}
discovered := lastMatchedAt
if discovered <= 0 {
discovered = createdAt
}
return in(discovered)
}