fix
This commit is contained in:
@@ -37,13 +37,14 @@ func ClearWeakEnhanceHashes(ctx context.Context, pool *pgxpool.Pool, companyID u
|
|||||||
|
|
||||||
// BackfillCategoriesFromMapped copies mapped_data category unique_ids onto
|
// BackfillCategoriesFromMapped copies mapped_data category unique_ids onto
|
||||||
// processed_products.category when the processed category is empty/"none".
|
// processed_products.category when the processed category is empty/"none".
|
||||||
// Uses Go-side categoryUniqueIDFromMaps (nested/array shapes) and validates
|
// Uses Go-side categoryUniqueIDFromMaps (nested/array shapes), coerces display
|
||||||
// against the company taxonomy when categories exist.
|
// names onto taxonomy unique_ids, and validates against the company taxonomy
|
||||||
|
// when categories exist.
|
||||||
func BackfillCategoriesFromMapped(ctx context.Context, pool *pgxpool.Pool, companyID uuid.UUID) (updated int, err error) {
|
func BackfillCategoriesFromMapped(ctx context.Context, pool *pgxpool.Pool, companyID uuid.UUID) (updated int, err error) {
|
||||||
if pool == nil {
|
if pool == nil {
|
||||||
return 0, fmt.Errorf("nil pool")
|
return 0, fmt.Errorf("nil pool")
|
||||||
}
|
}
|
||||||
valid, err := loadCompanyCategoryUniqueIDs(ctx, pool, companyID)
|
valid, namesByUID, err := loadCompanyCategoryTaxonomy(ctx, pool, companyID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
@@ -87,10 +88,10 @@ func BackfillCategoriesFromMapped(ctx context.Context, pool *pgxpool.Pool, compa
|
|||||||
if cat == "" {
|
if cat == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if len(valid) > 0 {
|
if resolved := resolveCompanyCategoryUniqueID(cat, namesByUID, valid); resolved != "" {
|
||||||
if _, ok := valid[cat]; !ok {
|
cat = resolved
|
||||||
continue
|
} else if len(valid) > 0 {
|
||||||
}
|
continue
|
||||||
}
|
}
|
||||||
ct, err := pool.Exec(ctx, `
|
ct, err := pool.Exec(ctx, `
|
||||||
UPDATE processed_products
|
UPDATE processed_products
|
||||||
@@ -121,25 +122,36 @@ func BackfillCategoriesFromMapped(ctx context.Context, pool *pgxpool.Pool, compa
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadCompanyCategoryUniqueIDs(ctx context.Context, pool *pgxpool.Pool, companyID uuid.UUID) (map[string]struct{}, error) {
|
func loadCompanyCategoryUniqueIDs(ctx context.Context, pool *pgxpool.Pool, companyID uuid.UUID) (map[string]struct{}, error) {
|
||||||
out := map[string]struct{}{}
|
valid, _, err := loadCompanyCategoryTaxonomy(ctx, pool, companyID)
|
||||||
|
return valid, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadCompanyCategoryTaxonomy(ctx context.Context, pool *pgxpool.Pool, companyID uuid.UUID) (map[string]struct{}, map[string]string, error) {
|
||||||
|
valid := map[string]struct{}{}
|
||||||
|
namesByUID := map[string]string{}
|
||||||
rows, err := pool.Query(ctx, `
|
rows, err := pool.Query(ctx, `
|
||||||
SELECT unique_id FROM categories
|
SELECT unique_id, name FROM categories
|
||||||
WHERE company_id = $1 AND COALESCE(NULLIF(btrim(unique_id), ''), '') <> ''`, companyID)
|
WHERE company_id = $1 AND COALESCE(NULLIF(btrim(unique_id), ''), '') <> ''`, companyID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var uid string
|
var uid, name string
|
||||||
if err := rows.Scan(&uid); err != nil {
|
if err := rows.Scan(&uid, &name); err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
uid = strings.TrimSpace(uid)
|
uid = strings.TrimSpace(uid)
|
||||||
if uid != "" {
|
name = strings.TrimSpace(name)
|
||||||
out[uid] = struct{}{}
|
if uid == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
valid[uid] = struct{}{}
|
||||||
|
if name != "" {
|
||||||
|
namesByUID[uid] = name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out, rows.Err()
|
return valid, namesByUID, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// FixCatalogHygiene clears poisoned enhance hashes and optionally backfills categories.
|
// FixCatalogHygiene clears poisoned enhance hashes and optionally backfills categories.
|
||||||
|
|||||||
@@ -79,6 +79,8 @@ func categoryUniqueIDFromAny(v any) string {
|
|||||||
for _, k := range []string{
|
for _, k := range []string{
|
||||||
"unique_id", "category_unique_id", "categoryUniqueId",
|
"unique_id", "category_unique_id", "categoryUniqueId",
|
||||||
"#text", "text", "id", "value", "code",
|
"#text", "text", "id", "value", "code",
|
||||||
|
// Last: display name only (resolved to unique_id later via taxonomy).
|
||||||
|
"name",
|
||||||
} {
|
} {
|
||||||
if s := categoryUniqueIDFromAny(t[k]); s != "" {
|
if s := categoryUniqueIDFromAny(t[k]); s != "" {
|
||||||
return s
|
return s
|
||||||
@@ -163,8 +165,70 @@ func applyCategoryFromMapped(out *StepResult, maps ...map[string]any) {
|
|||||||
out.FieldSources["category"] = "mapped"
|
out.FieldSources["category"] = "mapped"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveCompanyCategoryUniqueID maps a feed/vector/prior category token onto a
|
||||||
|
// company taxonomy unique_id. Accepts an already-valid unique_id, or a display
|
||||||
|
// name (case-insensitive) from namesByUID. When valid is non-empty, the result
|
||||||
|
// must be in that set. Returns "" when unresolvable.
|
||||||
|
func resolveCompanyCategoryUniqueID(raw string, namesByUID map[string]string, valid map[string]struct{}) string {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" || strings.EqualFold(raw, "none") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if len(valid) > 0 {
|
||||||
|
if _, ok := valid[raw]; ok {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
} else if _, ok := namesByUID[raw]; ok {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
if len(namesByUID) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for uid, name := range namesByUID {
|
||||||
|
uid = strings.TrimSpace(uid)
|
||||||
|
if uid == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(strings.TrimSpace(name), raw) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(valid) == 0 {
|
||||||
|
return uid
|
||||||
|
}
|
||||||
|
if _, ok := valid[uid]; ok {
|
||||||
|
return uid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// coerceCategoryToCompanyUniqueID rewrites out.Category from a display name (or
|
||||||
|
// other alias) onto the taxonomy unique_id before filterCategoryIfInvalid.
|
||||||
|
// Without this, feed/vector category names are wiped even when they match categories.name.
|
||||||
|
func coerceCategoryToCompanyUniqueID(out *StepResult, namesByUID map[string]string, valid map[string]struct{}) {
|
||||||
|
if out == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cat := strings.TrimSpace(out.Category)
|
||||||
|
if cat == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolved := resolveCompanyCategoryUniqueID(cat, namesByUID, valid)
|
||||||
|
if resolved == "" || resolved == cat {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out.Category = SanitizeText(resolved)
|
||||||
|
if out.FieldSources == nil {
|
||||||
|
out.FieldSources = map[string]any{}
|
||||||
|
}
|
||||||
|
if _, ok := out.FieldSources["category"]; !ok {
|
||||||
|
out.FieldSources["category"] = "name_coerced"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// filterCategoryIfInvalid clears Category when the company has a known unique_id set
|
// filterCategoryIfInvalid clears Category when the company has a known unique_id set
|
||||||
// and the value is not in that set. Empty valid set means "skip validation" (tests / no taxonomy).
|
// and the value is not in that set. Empty valid set means "skip validation" (tests / no taxonomy).
|
||||||
|
// Call coerceCategoryToCompanyUniqueID first so display names are not dropped.
|
||||||
func filterCategoryIfInvalid(out *StepResult, valid map[string]struct{}) {
|
func filterCategoryIfInvalid(out *StepResult, valid map[string]struct{}) {
|
||||||
if out == nil || len(valid) == 0 {
|
if out == nil || len(valid) == 0 {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ func TestCategoryUniqueIDFromAny(t *testing.T) {
|
|||||||
{map[string]any{"category_unique_id": 48}, "48"},
|
{map[string]any{"category_unique_id": 48}, "48"},
|
||||||
{map[string]any{"#text": "120"}, "120"},
|
{map[string]any{"#text": "120"}, "120"},
|
||||||
{[]any{map[string]any{"unique_id": "7"}}, "7"},
|
{[]any{map[string]any{"unique_id": "7"}}, "7"},
|
||||||
{map[string]any{"name": "OnlyName"}, ""},
|
// Nested name-only is a candidate token (resolved to unique_id later).
|
||||||
|
{map[string]any{"name": "OnlyName"}, "OnlyName"},
|
||||||
}
|
}
|
||||||
for i, tc := range cases {
|
for i, tc := range cases {
|
||||||
if got := categoryUniqueIDFromAny(tc.in); got != tc.want {
|
if got := categoryUniqueIDFromAny(tc.in); got != tc.want {
|
||||||
@@ -139,6 +140,60 @@ func TestFilterCategoryIfInvalid(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCoerceCategoryToCompanyUniqueID_nameToUID(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
names := map[string]string{"50": "Štedilniki", "28": "TV mounts"}
|
||||||
|
valid := map[string]struct{}{"50": {}, "28": {}}
|
||||||
|
|
||||||
|
out := StepResult{Category: "Štedilniki"}
|
||||||
|
coerceCategoryToCompanyUniqueID(&out, names, valid)
|
||||||
|
if out.Category != "50" {
|
||||||
|
t.Fatalf("name coerce: Category=%q want 50", out.Category)
|
||||||
|
}
|
||||||
|
filterCategoryIfInvalid(&out, valid)
|
||||||
|
if out.Category != "50" {
|
||||||
|
t.Fatalf("after filter: Category=%q want 50", out.Category)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case-insensitive name match.
|
||||||
|
out = StepResult{Category: "tv mounts"}
|
||||||
|
coerceCategoryToCompanyUniqueID(&out, names, valid)
|
||||||
|
if out.Category != "28" {
|
||||||
|
t.Fatalf("case-insensitive coerce: Category=%q want 28", out.Category)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already a unique_id — unchanged.
|
||||||
|
out = StepResult{Category: "50"}
|
||||||
|
coerceCategoryToCompanyUniqueID(&out, names, valid)
|
||||||
|
if out.Category != "50" {
|
||||||
|
t.Fatalf("uid passthrough: Category=%q", out.Category)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown token — left for filter to clear.
|
||||||
|
out = StepResult{Category: "not-a-category"}
|
||||||
|
coerceCategoryToCompanyUniqueID(&out, names, valid)
|
||||||
|
if out.Category != "not-a-category" {
|
||||||
|
t.Fatalf("unknown should stay until filter: %q", out.Category)
|
||||||
|
}
|
||||||
|
filterCategoryIfInvalid(&out, valid)
|
||||||
|
if out.Category != "" {
|
||||||
|
t.Fatalf("unknown should clear: %q", out.Category)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCategoryUniqueIDFromAny_nestedNameOnly(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
got := categoryUniqueIDFromAny(map[string]any{"name": "Štedilniki"})
|
||||||
|
if got != "Štedilniki" {
|
||||||
|
t.Fatalf("got %q want Štedilniki", got)
|
||||||
|
}
|
||||||
|
// unique_id still wins over name.
|
||||||
|
got = categoryUniqueIDFromAny(map[string]any{"unique_id": "50", "name": "Štedilniki"})
|
||||||
|
if got != "50" {
|
||||||
|
t.Fatalf("got %q want 50", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadV1ProcessJobItems_resolvesCategoryName(t *testing.T) {
|
func TestLoadV1ProcessJobItems_resolvesCategoryName(t *testing.T) {
|
||||||
dsn := os.Getenv("DATABASE_URL")
|
dsn := os.Getenv("DATABASE_URL")
|
||||||
if dsn == "" {
|
if dsn == "" {
|
||||||
|
|||||||
@@ -1462,6 +1462,17 @@ func (p *Pipeline) processOne(ctx context.Context, companyID, jobID uuid.UUID, i
|
|||||||
if cat := categoryUniqueIDFromMaps(enriched, raw); cat != "" {
|
if cat := categoryUniqueIDFromMaps(enriched, raw); cat != "" {
|
||||||
enriched["category"] = cat
|
enriched["category"] = cat
|
||||||
}
|
}
|
||||||
|
// Resolve display names → taxonomy unique_id before RunSteps so category
|
||||||
|
// formulas, enhance overlays, and attribute allowlists key correctly.
|
||||||
|
if cache != nil {
|
||||||
|
if resolved := resolveCompanyCategoryUniqueID(
|
||||||
|
stringFromAny(enriched["category"]),
|
||||||
|
cache.categoryNamesByUID,
|
||||||
|
cache.categoryUniqueIDs,
|
||||||
|
); resolved != "" {
|
||||||
|
enriched["category"] = resolved
|
||||||
|
}
|
||||||
|
}
|
||||||
if gtin == "" {
|
if gtin == "" {
|
||||||
gtin = stringFromMap(enriched, "gtin", "ean", "EAN", "upc")
|
gtin = stringFromMap(enriched, "gtin", "ean", "EAN", "upc")
|
||||||
}
|
}
|
||||||
@@ -1528,8 +1539,10 @@ func (p *Pipeline) processOne(ctx context.Context, companyID, jobID uuid.UUID, i
|
|||||||
return false, 0, StepResult{}, err
|
return false, 0, StepResult{}, err
|
||||||
}
|
}
|
||||||
// Persist mapped unique_id only when it exists in the company taxonomy.
|
// Persist mapped unique_id only when it exists in the company taxonomy.
|
||||||
// Empty taxonomy set skips filtering (unit tests / companies without categories).
|
// Coerce display names (feed/vector) → unique_id before filtering so A1-style
|
||||||
|
// name tokens are not wiped. Empty taxonomy set skips filtering (unit tests).
|
||||||
if cache != nil {
|
if cache != nil {
|
||||||
|
coerceCategoryToCompanyUniqueID(&result, cache.categoryNamesByUID, cache.categoryUniqueIDs)
|
||||||
filterCategoryIfInvalid(&result, cache.categoryUniqueIDs)
|
filterCategoryIfInvalid(&result, cache.categoryUniqueIDs)
|
||||||
syncCategoryName(&result, cache.categoryNamesByUID)
|
syncCategoryName(&result, cache.categoryNamesByUID)
|
||||||
}
|
}
|
||||||
@@ -1577,7 +1590,7 @@ func (p *Pipeline) processOne(ctx context.Context, companyID, jobID uuid.UUID, i
|
|||||||
cache.noteCreditDebit(p.Billing.EstimateDebit(ctx, "product_processing", result.TotalTokens))
|
cache.noteCreditDebit(p.Billing.EstimateDebit(ctx, "product_processing", result.TotalTokens))
|
||||||
}
|
}
|
||||||
|
|
||||||
processedID, err := p.upsertProcessedProduct(ctx, tx, companyID, it.RawID, gtin, result, attrsJSON, procAttrsJSON, gptJSON, sourcesJSON, providerMode)
|
processedID, err := p.upsertProcessedProduct(ctx, tx, companyID, it.RawID, gtin, result, attrsJSON, procAttrsJSON, gptJSON, sourcesJSON, providerMode, language)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, 0, result, err
|
return false, 0, result, err
|
||||||
}
|
}
|
||||||
@@ -1705,13 +1718,18 @@ func (p *Pipeline) upsertProcessedProduct(
|
|||||||
result StepResult,
|
result StepResult,
|
||||||
attrsJSON, procAttrsJSON, gptJSON, sourcesJSON []byte,
|
attrsJSON, procAttrsJSON, gptJSON, sourcesJSON []byte,
|
||||||
providerMode string,
|
providerMode string,
|
||||||
|
primaryLang string,
|
||||||
) (uuid.UUID, error) {
|
) (uuid.UUID, error) {
|
||||||
localized := result.LocalizedContent
|
localized := result.LocalizedContent
|
||||||
if localized == nil {
|
if localized == nil {
|
||||||
localized = company.LocalizedContent{}
|
localized = company.LocalizedContent{}
|
||||||
}
|
}
|
||||||
if len(localized) == 0 && (strings.TrimSpace(result.ProcessedName) != "" || strings.TrimSpace(result.ProcessedDescription) != "") {
|
if len(localized) == 0 && (strings.TrimSpace(result.ProcessedName) != "" || strings.TrimSpace(result.ProcessedDescription) != "") {
|
||||||
localized[company.DefaultLanguage] = company.LocalizedFields{
|
lang := company.NormalizeLanguage(primaryLang)
|
||||||
|
if lang == "" || lang == company.LangPromptAny || !company.IsAllowedLanguage(lang) {
|
||||||
|
lang = company.DefaultLanguage
|
||||||
|
}
|
||||||
|
localized[lang] = company.LocalizedFields{
|
||||||
ProcessedName: result.ProcessedName,
|
ProcessedName: result.ProcessedName,
|
||||||
ProcessedDescription: result.ProcessedDescription,
|
ProcessedDescription: result.ProcessedDescription,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ func TestUpsertProcessedProduct_concurrentIdempotent(t *testing.T) {
|
|||||||
for i := 0; i < n; i++ {
|
for i := 0; i < n; i++ {
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
id, err := p.upsertProcessedProduct(ctx, nil, companyID, rawID, gtin, result, emptyJSON, emptyJSON, emptyJSON, emptyJSON, AIProviderInternal)
|
id, err := p.upsertProcessedProduct(ctx, nil, companyID, rawID, gtin, result, emptyJSON, emptyJSON, emptyJSON, emptyJSON, AIProviderInternal, "en")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errCh <- err
|
errCh <- err
|
||||||
return
|
return
|
||||||
|
|||||||
Reference in New Issue
Block a user