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

182 lines
5.6 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 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
MatchedCount int
MergedCount int
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) {
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) {
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,
}
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 {
w = mergeFetchWatch(w, plan)
}
}
}
// 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)
}
rawHitCount := len(cands)
cands, prefilter := PrefilterCandidates(cands, w, productContext)
_, _ = s.Repo.UpdateSweep(ctx, sw.ID, domain.SweepDelta{
HitCount: rawHitCount,
CreditsUsed: fetchCredits,
CreditSearch: fetchCredits,
DedupedCount: prefilter.Deduped, PrefilterPassCount: prefilter.Pass,
PrefilterReviewCount: prefilter.Review, PrefilterRejectedCount: prefilter.Rejected,
})
created, _, judged, truncated, failed, judgeCredits, perr := s.ProcessCandidates(
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,
CreditJudge: judgeCredits,
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,
MatchedCount: sw.MatchEvaluatedCount,
MergedCount: sw.MatchMergedCount,
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
}