139 lines
5.0 KiB
Go
139 lines
5.0 KiB
Go
package usecase
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
|
|
"apps/backend/internal/module/radar/domain"
|
|
)
|
|
|
|
func (s *Service) GetDemandMap(ctx context.Context, ownerUID int64, productID string) (*domain.DemandMap, error) {
|
|
if ownerUID <= 0 || strings.TrimSpace(productID) == "" {
|
|
return nil, fmt.Errorf("%w: owner and product are required", domain.ErrValidation)
|
|
}
|
|
value, err := s.Repo.GetDemandMap(ctx, ownerUID, productID)
|
|
if err == nil {
|
|
return value, nil
|
|
}
|
|
if err != domain.ErrNotFound {
|
|
return nil, err
|
|
}
|
|
if s.ProductSource == nil {
|
|
return nil, fmt.Errorf("%w: product catalog unavailable", domain.ErrNotReady)
|
|
}
|
|
product, err := s.ProductSource.GetProduct(ctx, ownerUID, productID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if product == nil {
|
|
return nil, domain.ErrNotFound
|
|
}
|
|
value = baselineDemandMap(product)
|
|
return s.Repo.SaveDemandMap(ctx, value, 0)
|
|
}
|
|
|
|
func (s *Service) UpdateDemandMap(ctx context.Context, ownerUID int64, value *domain.DemandMap, expectedVersion int64) (*domain.DemandMap, error) {
|
|
if value == nil || ownerUID <= 0 || strings.TrimSpace(value.ProductID) == "" {
|
|
return nil, fmt.Errorf("%w: owner and product are required", domain.ErrValidation)
|
|
}
|
|
if value.OwnerUID != 0 && value.OwnerUID != ownerUID {
|
|
return nil, domain.ErrForbidden
|
|
}
|
|
value.OwnerUID = ownerUID
|
|
if expectedVersion < 1 {
|
|
return nil, fmt.Errorf("%w: expected map version required", domain.ErrValidation)
|
|
}
|
|
current, err := s.GetDemandMap(ctx, ownerUID, value.ProductID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
value.DemandInputVersion = current.DemandInputVersion
|
|
value.SourceBasis = append([]string(nil), current.SourceBasis...)
|
|
if value.AIEnrichedAt == 0 {
|
|
value.AIEnrichedAt = current.AIEnrichedAt
|
|
}
|
|
value.MapVersion = current.MapVersion
|
|
value.UpdatedAt = domain.NowNano()
|
|
return s.Repo.SaveDemandMap(ctx, value, expectedVersion)
|
|
}
|
|
|
|
func baselineDemandMap(product *ProductCatalogProduct) *domain.DemandMap {
|
|
mapPhrase := func(text, kind, basisKind, basisText string) domain.DemandMapPhrase {
|
|
return domain.DemandMapPhrase{Text: strings.TrimSpace(text), Kind: kind, BasisKind: basisKind, BasisText: basisText, Origin: "product", Enabled: strings.TrimSpace(text) != ""}
|
|
}
|
|
phrases := func(items []string, kind, basisKind string, include bool) []domain.DemandMapPhrase {
|
|
out := make([]domain.DemandMapPhrase, 0, len(items))
|
|
for _, item := range items {
|
|
raw := strings.TrimSpace(item)
|
|
if raw == "" {
|
|
continue
|
|
}
|
|
if !include {
|
|
out = append(out, mapPhrase(raw, kind, basisKind, product.Label))
|
|
continue
|
|
}
|
|
variants := domain.SearchableTermVariants(raw, true)
|
|
if len(variants) > domain.MaxSearchableVariantsPerTerm {
|
|
variants = variants[:domain.MaxSearchableVariantsPerTerm]
|
|
}
|
|
for _, term := range variants {
|
|
out = append(out, mapPhrase(term, kind, basisKind, raw))
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
pain := phrases(product.PainPoints, "pain", "pain_point", true)
|
|
scenario := phrases([]string{product.ProductContext}, "scenario", "product_context", true)
|
|
outcomes := phrases(product.MatchTags, "outcome", "match_tag", true)
|
|
solution := phrases(product.ProviderCapabilityTerms, "solution", "provider_capability", true)
|
|
exclusions := phrases(product.ProviderExcludeTerms, "exclusion", "provider_exclude", false)
|
|
state := "incomplete"
|
|
if len(pain) > 0 && len(scenario) > 0 && len(solution) > 0 {
|
|
state = "ready"
|
|
}
|
|
version := "demand-" + DemandInputFingerprint(product)
|
|
return &domain.DemandMap{
|
|
ID: "", OwnerUID: product.OwnerUID, ProductID: product.ID,
|
|
DemandInputVersion: version, MapVersion: 1, State: state,
|
|
PainPhrases: pain, ScenarioPhrases: scenario, DesiredOutcomes: outcomes,
|
|
SolutionSignals: solution, ExclusionSignals: exclusions,
|
|
SourceBasis: []string{"product:" + product.ID}, CustomPhrases: []domain.DemandMapPhrase{},
|
|
UpdatedAt: domain.NowNano(),
|
|
}
|
|
}
|
|
|
|
// DemandInputFingerprint intentionally excludes display-only product fields
|
|
// (name, image, price). A wording edit that changes what the provider solves
|
|
// must create a new input version; a catalog rename must not invalidate a
|
|
// user's reviewed query plan.
|
|
func DemandInputFingerprint(product *ProductCatalogProduct) string {
|
|
if product == nil {
|
|
return "unknown"
|
|
}
|
|
normalize := func(values []string) []string {
|
|
out := make([]string, 0, len(values))
|
|
for _, value := range values {
|
|
value = strings.ToLower(strings.Join(strings.Fields(strings.TrimSpace(value)), " "))
|
|
if value != "" {
|
|
out = append(out, value)
|
|
}
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
parts := []string{
|
|
strings.TrimSpace(product.BrandID),
|
|
strings.ToLower(strings.Join(strings.Fields(strings.TrimSpace(product.ProductContext)), " ")),
|
|
strings.Join(normalize(product.PainPoints), "\x1f"),
|
|
strings.Join(normalize(product.MatchTags), "\x1f"),
|
|
strings.Join(normalize(product.ProviderCapabilityTerms), "\x1f"),
|
|
strings.Join(normalize(product.ProviderExcludeTerms), "\x1f"),
|
|
}
|
|
sum := sha256.Sum256([]byte(strings.Join(parts, "\x1e")))
|
|
return hex.EncodeToString(sum[:])[:16]
|
|
}
|