182 lines
5.6 KiB
Go
182 lines
5.6 KiB
Go
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 2–4 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
|
||
}
|