137 lines
3.8 KiB
Go
137 lines
3.8 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
|
|||
|
|
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 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
|
|||
|
|
}
|