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

137 lines
3.8 KiB
Go
Raw 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
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) {
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,
}
// 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)
}
_, _ = s.Repo.UpdateSweep(ctx, sw.ID, domain.SweepDelta{
HitCount: len(cands),
CreditsUsed: fetchCredits,
})
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,
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,
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
}