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

127 lines
4.6 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 string) domain.DemandMapPhrase {
return domain.DemandMapPhrase{Text: strings.TrimSpace(text), Kind: kind, BasisKind: basisKind, BasisText: product.Label, Origin: "product", Enabled: strings.TrimSpace(text) != ""}
}
phrases := func(items []string, kind, basisKind string) []domain.DemandMapPhrase {
out := make([]domain.DemandMapPhrase, 0, len(items))
for _, item := range items {
if strings.TrimSpace(item) != "" {
out = append(out, mapPhrase(item, kind, basisKind))
}
}
return out
}
pain := phrases(product.PainPoints, "pain", "pain_point")
scenario := phrases([]string{product.ProductContext}, "scenario", "product_context")
outcomes := phrases(product.MatchTags, "outcome", "match_tag")
solution := phrases(product.ProviderCapabilityTerms, "solution", "provider_capability")
exclusions := phrases(product.ProviderExcludeTerms, "exclusion", "provider_exclude")
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]
}