thread-master/apps/backend/internal/module/scout/domain/domain.go

274 lines
11 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 (
"errors"
"strings"
"time"
)
var (
ErrNotFound = errors.New("scout not found")
ErrForbidden = errors.New("scout forbidden")
ErrValidation = errors.New("scout validation")
ErrNoCrawlerSession = errors.New("crawler session required when dev_mode enabled")
ErrCrawlerSessionExpired = errors.New("crawler session expired")
ErrTopicRemoved = errors.New("ScoutTopic CRUD removed")
ErrHasProducts = errors.New("brand has products; remove products first")
ErrIllegalRunStatus = errors.New("illegal scout run status transition")
)
func NowNano() int64 { return time.Now().UTC().UnixNano() }
const (
PathAPI = "api"
PathCrawler = "crawler"
OutreachNew = "new"
OutreachDrafted = "drafted"
OutreachPublished = "published"
OutreachSkipped = "skipped"
OutreachQueued = "queued"
ModeProduct = "product"
ModeTheme = "theme"
ModeActivity = "activity"
ModeDemand = "demand"
ModeProvider = "provider"
RunQueued = "queued"
RunRunning = "running"
RunSucceeded = "succeeded"
RunFailed = "failed"
RunCancelled = "cancelled"
ShortfallSourceExhausted = "source_exhausted"
ShortfallDuplicateExhausted = "duplicate_exhausted"
ShortfallRelevanceExhausted = "relevance_exhausted"
ShortfallSourceUnavailable = "source_unavailable"
ShortfallLimitReached = "limit_reached"
ClassificationSeekingHelp = "seeking_help"
ClassificationSeekingRecommendation = "seeking_recommendation"
ClassificationProviderOffer = "provider_offer"
ClassificationDiscussion = "discussion"
ClassificationAsking = "asking"
ClassificationAnnouncement = "announcement"
ClassificationNoise = "noise"
ClassificationProviderDirect = "provider_direct"
ClassificationProviderRecommended = "provider_recommended"
)
const LegacyRunPrefix = "legacy:"
func LegacyRunID(themeKey string) string {
if themeKey == "" {
themeKey = "uncategorized"
}
return LegacyRunPrefix + themeKey
}
func LegacyThemeKey(runID string) (string, bool) {
if !strings.HasPrefix(runID, LegacyRunPrefix) {
return "", false
}
key := strings.TrimPrefix(runID, LegacyRunPrefix)
if key == "uncategorized" {
return "", true
}
return key, true
}
type Brand struct {
ID string `bson:"_id" json:"id"`
OwnerUID int64 `bson:"owner_uid" json:"owner_uid"`
DisplayName string `bson:"display_name" json:"display_name"`
Brief string `bson:"brief" json:"brief"`
TargetAudience string `bson:"target_audience,omitempty" json:"target_audience,omitempty"`
Goals string `bson:"goals,omitempty" json:"goals,omitempty"`
// Learning — growth-loop 資產複利
LearningSummary string `bson:"learning_summary,omitempty" json:"learning_summary,omitempty"`
LearningVersion int `bson:"learning_version,omitempty" json:"learning_version,omitempty"`
LearnedFromPostsCount int `bson:"learned_from_posts_count,omitempty" json:"learned_from_posts_count,omitempty"`
LastLearnedAt int64 `bson:"last_learned_at,omitempty" json:"last_learned_at,omitempty"`
CreatedAt int64 `bson:"created_at" json:"created_at"`
UpdatedAt int64 `bson:"updated_at" json:"updated_at"`
}
type Product struct {
ID string `bson:"_id" json:"id"`
OwnerUID int64 `bson:"owner_uid" json:"owner_uid"`
BrandID string `bson:"brand_id" json:"brand_id"`
Label string `bson:"label" json:"label"`
ProductContext string `bson:"product_context" json:"product_context"`
MatchTags []string `bson:"match_tags" json:"match_tags"`
PainPoints []string `bson:"pain_points" json:"pain_points"`
ProviderCapabilityTerms []string `bson:"provider_capability_terms" json:"provider_capability_terms"`
ProviderExcludeTerms []string `bson:"provider_exclude_terms" json:"provider_exclude_terms"`
PlacementURL string `bson:"placement_url,omitempty" json:"placement_url,omitempty"`
CreatedAt int64 `bson:"created_at" json:"created_at"`
UpdatedAt int64 `bson:"updated_at" json:"updated_at"`
}
type ActiveBrand struct {
OwnerUID int64 `bson:"_id" json:"owner_uid"`
BrandID string `bson:"brand_id" json:"brand_id"`
UpdatedAt int64 `bson:"updated_at" json:"updated_at"`
}
type RunBrief struct {
Intent string `json:"intent"`
Mode string `json:"mode"`
BrandID string `json:"brand_id,omitempty"`
ProductID string `json:"product_id,omitempty"`
ProductLabel string `json:"product_label,omitempty"`
Pains []string `json:"pains"`
Tags []string `json:"tags"`
Periphery []string `json:"periphery"`
ScanTerms []string `json:"scan_terms"`
PlacementNote string `json:"placement_note,omitempty"`
ResponseStance string `json:"response_stance,omitempty"`
ThemeKey string `json:"theme_key,omitempty"`
ThemeLabel string `json:"theme_label,omitempty"`
ProductContext string `json:"product_context,omitempty"`
// TargetCount is the desired hit count for this scan (topic daily goal).
// When >0, RunScanFromBrief may top up (boost per-query, then secondary path).
TargetCount int `json:"target_count,omitempty"`
}
// Run is one immutable scan attempt. theme_key groups intent only; ID keeps
// repeated scans of the same theme independent and auditable.
type Run struct {
ID string `bson:"_id" json:"id"`
OwnerUID int64 `bson:"owner_uid" json:"owner_uid"`
JobID string `bson:"job_id" json:"job_id"`
ThemeKey string `bson:"theme_key" json:"theme_key"`
ThemeLabel string `bson:"theme_label" json:"theme_label"`
Intent string `bson:"intent" json:"intent"`
Mode string `bson:"mode" json:"mode"`
BrandID string `bson:"brand_id,omitempty" json:"brand_id,omitempty"`
TargetCount int `bson:"target_count" json:"target_count"`
Status string `bson:"status" json:"status"`
SearchedCount int `bson:"searched_count" json:"searched_count"`
DuplicateCount int `bson:"duplicate_count" json:"duplicate_count"`
IrrelevantCount int `bson:"irrelevant_count" json:"irrelevant_count"`
EligibleCount int `bson:"eligible_count" json:"eligible_count"`
PendingCount int `bson:"pending_count" json:"pending_count"`
ShortfallCount int `bson:"shortfall_count" json:"shortfall_count"`
ShortfallReasons []string `bson:"shortfall_reasons" json:"shortfall_reasons"`
CreatedAt int64 `bson:"created_at" json:"created_at"`
StartedAt int64 `bson:"started_at,omitempty" json:"started_at,omitempty"`
CompletedAt int64 `bson:"completed_at,omitempty" json:"completed_at,omitempty"`
Error string `bson:"error,omitempty" json:"error,omitempty"`
}
func NormalizeTargetCount(n int) int {
if n <= 0 {
return 20
}
if n > 40 {
return 40
}
return n
}
func NewRun(id, jobID string, ownerUID int64, brief RunBrief, now int64) *Run {
if now <= 0 {
now = NowNano()
}
return &Run{
ID: id, OwnerUID: ownerUID, JobID: jobID, ThemeKey: brief.ThemeKey,
ThemeLabel: brief.ThemeLabel, Intent: brief.Intent, Mode: brief.Mode,
BrandID: brief.BrandID, TargetCount: NormalizeTargetCount(brief.TargetCount),
Status: RunQueued, ShortfallReasons: []string{}, CreatedAt: now,
}
}
func IsRunTerminal(status string) bool {
return status == RunSucceeded || status == RunFailed || status == RunCancelled
}
func CanTransitionRun(from, to string) bool {
switch from {
case RunQueued:
return to == RunRunning || to == RunFailed || to == RunCancelled
case RunRunning:
return to == RunSucceeded || to == RunFailed || to == RunCancelled
case RunSucceeded, RunFailed, RunCancelled:
return false
default:
return false
}
}
func (r *Run) Transition(to string, now int64) error {
if r == nil || !CanTransitionRun(r.Status, to) {
return ErrIllegalRunStatus
}
if now <= 0 {
now = NowNano()
}
r.Status = to
if to == RunRunning && r.StartedAt == 0 {
r.StartedAt = now
}
if IsRunTerminal(to) {
r.CompletedAt = now
}
return nil
}
type Post struct {
ID string `bson:"_id" json:"id"`
RunID string `bson:"run_id,omitempty" json:"run_id,omitempty"`
ExternalID string `bson:"external_id,omitempty" json:"external_id,omitempty"`
Permalink string `bson:"permalink,omitempty" json:"permalink,omitempty"`
OwnerUID int64 `bson:"owner_uid" json:"owner_uid"`
BrandID string `bson:"brand_id,omitempty" json:"brand_id,omitempty"`
Author string `bson:"author" json:"author"`
Text string `bson:"text" json:"text"`
SearchTag string `bson:"search_tag" json:"search_tag"`
Opportunity string `bson:"opportunity" json:"opportunity"`
OutreachStatus string `bson:"outreach_status" json:"outreach_status"`
DraftText string `bson:"draft_text,omitempty" json:"draft_text,omitempty"`
OutboxID string `bson:"outbox_id,omitempty" json:"outbox_id,omitempty"`
Score int `bson:"score" json:"score"`
Classification string `bson:"classification,omitempty" json:"classification,omitempty"`
MatchedProductID string `bson:"matched_product_id,omitempty" json:"matched_product_id,omitempty"`
MatchedProductLabel string `bson:"matched_product_label,omitempty" json:"matched_product_label,omitempty"`
MatchReason string `bson:"match_reason,omitempty" json:"match_reason,omitempty"`
ScoutMode string `bson:"scout_mode,omitempty" json:"scout_mode,omitempty"`
IntentSnippet string `bson:"intent_snippet,omitempty" json:"intent_snippet,omitempty"`
ThemeKey string `bson:"theme_key,omitempty" json:"theme_key,omitempty"`
ThemeLabel string `bson:"theme_label,omitempty" json:"theme_label,omitempty"`
ScanPath string `bson:"scan_path,omitempty" json:"scan_path,omitempty"` // api|crawler
// PostedAt = 原文發文時間unix ns未知時為 0。列表以 score 優先,
// 再用 PostedAtCreatedAtID 做穩定 tie-breaker。
PostedAt int64 `bson:"posted_at,omitempty" json:"posted_at,omitempty"`
CreatedAt int64 `bson:"created_at" json:"created_at"`
}
type Homework struct {
ThemeKey string `bson:"_id" json:"theme_key"`
OwnerUID int64 `bson:"owner_uid" json:"owner_uid"`
ThemeLabel string `bson:"theme_label" json:"theme_label"`
Purpose string `bson:"purpose" json:"purpose"`
Brief RunBrief `bson:"brief" json:"brief"`
CreatedAt int64 `bson:"created_at" json:"created_at"`
}
type CrawlerSession struct {
OwnerUID int64 `bson:"_id" json:"owner_uid"`
StorageStateEnc string `bson:"storage_state_enc" json:"storage_state_enc"`
UpdatedAt int64 `bson:"updated_at" json:"updated_at"`
ExpiresAt int64 `bson:"expires_at" json:"expires_at"`
}
type ImportDraft struct {
Label string `json:"label"`
ProductContext string `json:"product_context"`
PainPoints []string `json:"pain_points"`
MatchTags []string `json:"match_tags"`
PlacementURL string `json:"placement_url"`
SourceNote string `json:"source_note"`
}