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

116 lines
3.5 KiB
Go

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
}
text := domain.NormalizeSearchTerm(p.Text)
if text == "" || strings.ToLower(text) == productLabel || !domain.IsThreadsSearchable(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 := include(m.ExclusionSignals)
groups := make([]domain.QueryPlanGroup, 0, domain.MaxExploreTerms)
seen := map[string]bool{}
add := func(parts ...domain.DemandMapPhrase) {
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)
for _, scenario := range scenarios {
add(pain, scenario)
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
}