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 { 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 { out := make([]domain.DemandMapPhrase, 0, len(list)) for _, p := range list { if !p.Enabled { continue } text := domain.NormalizeSearchTerm(p.Text) if text == "" { 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) exclusions := excludePhrases(m.ExclusionSignals) groups := make([]domain.QueryPlanGroup, 0, domain.MaxExploreTerms) seen := map[string]bool{} add := func(parts ...domain.DemandMapPhrase) { if len(groups) >= domain.MaxExploreTerms { return } 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) if len(groups) >= domain.MaxExploreTerms { break } for _, scenario := range scenarios { add(pain, scenario) if len(groups) >= domain.MaxExploreTerms { break } } if len(groups) >= domain.MaxExploreTerms { break } 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 }