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
|
|
|
|
|
}
|