thread-master/apps/backend/internal/module/radar/usecase/explore.go

182 lines
5.6 KiB
Go
Raw Permalink Normal View History

2026-08-09 07:57:35 +00:00
package usecase
import (
"context"
"fmt"
"strings"
"apps/backend/internal/module/radar/domain"
)
// ExploreResult is the outcome of an on-demand explore run (no watch subscription).
type ExploreResult struct {
SweepID string
HitCount int
JudgedCount int
CreatedCount int
2026-08-13 02:22:24 +00:00
MatchedCount int
MergedCount int
2026-08-09 07:57:35 +00:00
TruncatedCount int
CreditsUsed int
}
/*
ExploreOpportunities runs an immediate search five-question judge pipeline
for a short list of Threads-searchable terms (contract B / plan T3).
Reuses FetchCandidates path (HitFetch fan-out) and ProcessCandidates (quota,
dedupe, reasons). Does not bypass daily opportunity caps. Not async.
*/
func (s *Service) ExploreOpportunities(ctx context.Context, ownerUID int64, rawTerms []string) (*ExploreResult, error) {
2026-08-13 02:22:24 +00:00
return s.exploreOpportunities(ctx, ownerUID, rawTerms, "", "")
}
func (s *Service) ExploreProductOpportunities(ctx context.Context, ownerUID int64, rawTerms []string, brandID, productID string) (*ExploreResult, error) {
brandID, productID = strings.TrimSpace(brandID), strings.TrimSpace(productID)
if (brandID == "") != (productID == "") {
return nil, fmt.Errorf("%w: brand_id and product_id must be provided together", domain.ErrValidation)
}
if brandID == "" {
return s.exploreOpportunities(ctx, ownerUID, rawTerms, "", "")
}
if _, err := s.LoadProductContext(ctx, ownerUID, brandID, productID); err != nil {
return nil, err
}
return s.exploreOpportunities(ctx, ownerUID, rawTerms, brandID, productID)
}
func (s *Service) exploreOpportunities(ctx context.Context, ownerUID int64, rawTerms []string, brandID, productID string) (*ExploreResult, error) {
2026-08-09 07:57:35 +00:00
if ownerUID <= 0 {
return nil, fmt.Errorf("%w: owner_uid required", domain.ErrValidation)
}
terms, err := validateExploreTerms(rawTerms)
if err != nil {
return nil, err
}
// Profile optional at gate — judge tolerates nil profile (same as manual import).
profile, _ := s.Repo.GetServiceProfile(ctx, ownerUID)
// Synthetic watch so FetchCandidates / ProcessCandidates can carry terms.
w := &domain.RadarWatch{
ID: "",
OwnerUID: ownerUID,
Terms: terms,
Status: domain.WatchActive,
}
2026-08-13 02:22:24 +00:00
var productContext *ProductContextSnapshot
if brandID != "" {
product, err := s.LoadProductContext(ctx, ownerUID, brandID, productID)
if err != nil {
return nil, err
}
productContext = product
w.ContextMode, w.BrandID, w.ProductID = domain.WatchContextProduct, brandID, productID
w.BrandNameSnapshot, w.ProductLabelSnapshot = product.BrandName, product.ProductLabel
}
if productContext != nil {
if dm, derr := s.GetDemandMap(ctx, ownerUID, productID); derr == nil {
if plan, perr := BuildQueryPlan(dm, productContext.ProductLabel); perr == nil && plan != nil {
2026-08-13 07:54:25 +00:00
w = mergeFetchWatch(w, plan)
2026-08-13 02:22:24 +00:00
}
}
}
2026-08-09 07:57:35 +00:00
// Cap hits for sync explore (≤20); AI judge still subject to daily quota inside ProcessCandidates.
const exploreHitLimit = 20
cands, path, fetchCredits, ferr := s.FetchCandidates(ctx, ownerUID, w, exploreHitLimit)
if ferr != nil {
return nil, ferr
}
sw, err := s.BeginSweepRecord(ctx, ownerUID, "", "", path)
if err != nil {
return nil, err
}
if path != "" {
_ = s.setSweepPath(ctx, sw.ID, path)
}
2026-08-13 02:22:24 +00:00
rawHitCount := len(cands)
cands, prefilter := PrefilterCandidates(cands, w, productContext)
2026-08-09 07:57:35 +00:00
_, _ = s.Repo.UpdateSweep(ctx, sw.ID, domain.SweepDelta{
2026-08-13 02:22:24 +00:00
HitCount: rawHitCount,
CreditsUsed: fetchCredits,
CreditSearch: fetchCredits,
DedupedCount: prefilter.Deduped, PrefilterPassCount: prefilter.Pass,
PrefilterReviewCount: prefilter.Review, PrefilterRejectedCount: prefilter.Rejected,
2026-08-09 07:57:35 +00:00
})
2026-08-13 07:54:25 +00:00
created, _, judged, truncated, failed, judgeCredits, perr := s.ProcessCandidates(
2026-08-09 07:57:35 +00:00
ctx, ownerUID, w, profile, sw.ID, cands, nil,
)
if perr != nil {
reason := "判定流程失敗:" + perr.Error()
end := domain.NowNano()
_, _ = s.Repo.UpdateSweep(ctx, sw.ID, domain.SweepDelta{FailedReason: &reason, EndedAt: end})
return nil, perr
}
end := domain.NowNano()
var failPtr *string
if failed > 0 && created == 0 && judged > 0 {
r := fmt.Sprintf("%d 筆判定失敗", failed)
failPtr = &r
}
sw, _ = s.Repo.UpdateSweep(ctx, sw.ID, domain.SweepDelta{
CreditsUsed: judgeCredits,
2026-08-13 02:22:24 +00:00
CreditJudge: judgeCredits,
2026-08-09 07:57:35 +00:00
EndedAt: end,
FailedReason: failPtr,
})
credits := fetchCredits + judgeCredits
if sw != nil {
// UpdateSweep may have accumulated credits differently; prefer stored total when available.
if sw.CreditsUsed > 0 {
credits = sw.CreditsUsed
}
}
return &ExploreResult{
SweepID: sw.ID,
HitCount: len(cands),
JudgedCount: judged,
CreatedCount: created,
2026-08-13 02:22:24 +00:00
MatchedCount: sw.MatchEvaluatedCount,
MergedCount: sw.MatchMergedCount,
2026-08-09 07:57:35 +00:00
TruncatedCount: truncated,
CreditsUsed: credits,
}, nil
}
func validateExploreTerms(raw []string) ([]string, error) {
if len(raw) == 0 {
return nil, fmt.Errorf("%w: terms required (1%d)", domain.ErrValidation, domain.MaxExploreTerms)
}
if len(raw) > domain.MaxExploreTerms {
return nil, fmt.Errorf("%w: at most %d terms", domain.ErrValidation, domain.MaxExploreTerms)
}
out := make([]string, 0, len(raw))
seen := map[string]bool{}
for _, r := range raw {
term := domain.NormalizeSearchTerm(r)
if term == "" {
return nil, fmt.Errorf("%w: empty term", domain.ErrValidation)
}
if !domain.IsThreadsSearchable(term) {
return nil, fmt.Errorf("%w: term %q is not Threads-searchable (≤2 tokens, CJK 24 chars, ≤12 total, no punctuation)", domain.ErrValidation, term)
}
key := strings.ToLower(term)
if seen[key] {
continue
}
seen[key] = true
out = append(out, term)
}
if len(out) == 0 {
return nil, fmt.Errorf("%w: terms required", domain.ErrValidation)
}
return out, nil
}