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

151 lines
4.3 KiB
Go
Raw Permalink Normal View History

2026-08-13 02:22:24 +00:00
package usecase
import (
"context"
"fmt"
"strings"
"apps/backend/internal/module/radar/domain"
)
func (s *Service) BuildProductQueryPlan(ctx context.Context, ownerUID int64, productID string) (*domain.QueryPlan, error) {
m, err := s.GetDemandMap(ctx, ownerUID, productID)
if err != nil {
return nil, err
}
label := ""
if s.ProductSource != nil {
if product, perr := s.ProductSource.GetProduct(ctx, ownerUID, productID); perr != nil {
return nil, perr
} else if product != nil {
label = product.Label
}
}
return BuildQueryPlan(m, label)
}
// BuildQueryPlan compiles only demand language into short, independent
// searches. Product/brand names are context and can never become a query by
// themselves. The result is deterministic, which makes a plan safe to cache
// and easy to audit alongside its DemandMap versions.
func BuildQueryPlan(m *domain.DemandMap, productLabel string) (*domain.QueryPlan, error) {
if m == nil {
return nil, fmt.Errorf("%w: demand map required", domain.ErrValidation)
}
if m.State != "ready" {
return nil, fmt.Errorf("%w: demand map is %s", domain.ErrValidation, m.State)
}
productLabel = strings.ToLower(domain.NormalizeSearchTerm(productLabel))
include := func(list []domain.DemandMapPhrase) []domain.DemandMapPhrase {
2026-08-13 07:54:25 +00:00
out := make([]domain.DemandMapPhrase, 0, len(list))
for _, p := range list {
if !p.Enabled {
continue
}
variants := domain.SearchableTermVariants(p.Text, true)
if len(variants) > domain.MaxSearchableVariantsPerTerm {
variants = variants[:domain.MaxSearchableVariantsPerTerm]
}
basis := strings.TrimSpace(p.BasisText)
if basis == "" {
basis = strings.TrimSpace(p.Text)
}
for _, text := range variants {
if text == "" || strings.ToLower(text) == productLabel {
continue
}
cp := p
cp.Text = text
cp.BasisText = basis
out = append(out, cp)
}
}
return out
}
excludePhrases := func(list []domain.DemandMapPhrase) []domain.DemandMapPhrase {
2026-08-13 02:22:24 +00:00
out := make([]domain.DemandMapPhrase, 0, len(list))
for _, p := range list {
if !p.Enabled {
continue
}
text := domain.NormalizeSearchTerm(p.Text)
2026-08-13 07:54:25 +00:00
if text == "" {
2026-08-13 02:22:24 +00:00
continue
}
p.Text = text
out = append(out, p)
}
return out
}
pains, scenarios := include(m.PainPhrases), include(m.ScenarioPhrases)
outcomes, solutions := include(m.DesiredOutcomes), include(m.SolutionSignals)
2026-08-13 07:54:25 +00:00
exclusions := excludePhrases(m.ExclusionSignals)
2026-08-13 02:22:24 +00:00
groups := make([]domain.QueryPlanGroup, 0, domain.MaxExploreTerms)
seen := map[string]bool{}
add := func(parts ...domain.DemandMapPhrase) {
2026-08-13 07:54:25 +00:00
if len(groups) >= domain.MaxExploreTerms {
return
}
2026-08-13 02:22:24 +00:00
terms := make([]string, 0, len(parts))
basisKinds := make([]string, 0, len(parts))
basisTexts := make([]string, 0, len(parts))
for _, p := range parts {
if p.Text == "" {
continue
}
terms = append(terms, p.Text)
basisKinds = append(basisKinds, p.Kind)
basisTexts = append(basisTexts, p.BasisText)
}
query := domain.NormalizeSearchTerm(strings.Join(terms, " "))
if !domain.IsThreadsSearchable(query) || seen[strings.ToLower(query)] {
return
}
seen[strings.ToLower(query)] = true
exclude := make([]string, 0, len(exclusions))
for _, p := range exclusions {
exclude = append(exclude, p.Text)
}
groups = append(groups, domain.QueryPlanGroup{Query: query, Include: append([]string(nil), terms...), Exclude: exclude, BasisKinds: basisKinds, BasisTexts: basisTexts})
}
for _, pain := range pains {
add(pain)
2026-08-13 07:54:25 +00:00
if len(groups) >= domain.MaxExploreTerms {
break
}
2026-08-13 02:22:24 +00:00
for _, scenario := range scenarios {
add(pain, scenario)
if len(groups) >= domain.MaxExploreTerms {
break
}
}
2026-08-13 07:54:25 +00:00
if len(groups) >= domain.MaxExploreTerms {
break
}
2026-08-13 02:22:24 +00:00
for _, outcome := range outcomes {
add(pain, outcome)
if len(groups) >= domain.MaxExploreTerms {
break
}
}
if len(groups) >= domain.MaxExploreTerms {
break
}
}
if len(groups) == 0 {
for _, scenario := range scenarios {
for _, solution := range solutions {
add(scenario, solution)
if len(groups) >= domain.MaxExploreTerms {
break
}
}
}
}
if len(groups) == 0 {
return nil, fmt.Errorf("%w: demand map has no searchable phrases", domain.ErrValidation)
}
return &domain.QueryPlan{DemandInputVersion: m.DemandInputVersion, MapVersion: m.MapVersion, Groups: groups}, nil
}