diff --git a/apps/api/cmd/_a1_v1_fix_probe_20260817_tmp/main.go b/apps/api/cmd/_a1_v1_fix_probe_20260817_tmp/main.go new file mode 100644 index 0000000..9b97694 --- /dev/null +++ b/apps/api/cmd/_a1_v1_fix_probe_20260817_tmp/main.go @@ -0,0 +1,236 @@ +// Temporary local probe for A1/Demo V1 projection cleanup. Do not commit. +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/descrybe/descrybe-v2/apps/api/internal/billing" + "github.com/descrybe/descrybe-v2/apps/api/internal/processing" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" +) + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + dsn := os.Getenv("DATABASE_URL") + if dsn == "" { + dsn = "postgres://descrybe:descrybe@localhost:5433/descrybe?sslmode=disable" + } + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + fatal(err) + } + defer pool.Close() + + outDir := filepath.Join("..", "..", ".codehelper", "_a1_v1_response_20260817") + if abs, err := filepath.Abs(outDir); err == nil { + outDir = abs + } + _ = os.MkdirAll(outDir, 0o755) + + type companyRow struct { + ID uuid.UUID + Name string + Legacy string + } + companies := []companyRow{} + rows, err := pool.Query(ctx, ` + SELECT id, name, COALESCE(legacy_company_id, '') + FROM companies + WHERE legacy_company_id = $1 + OR lower(name) LIKE '%demo%' + ORDER BY CASE WHEN legacy_company_id = $1 THEN 0 ELSE 1 END, name + LIMIT 6`, billing.A1LegacyCompanyID) + if err != nil { + fatal(err) + } + for rows.Next() { + var c companyRow + if err := rows.Scan(&c.ID, &c.Name, &c.Legacy); err != nil { + fatal(err) + } + companies = append(companies, c) + } + rows.Close() + if len(companies) == 0 { + fatal(fmt.Errorf("no A1/Demo companies found")) + } + + p := processing.NewPipeline(pool) + eans := []string{"8806091734365", "8422160053580"} + summary := map[string]any{"generated_at": time.Now().UTC().Format(time.RFC3339), "companies": []any{}} + + for _, co := range companies { + omitMeta := billing.IsA1CohortCompany(co.Legacy, co.Name) + coSummary := map[string]any{ + "company_id": co.ID.String(), + "name": co.Name, + "legacy_id": co.Legacy, + "omit_seo_meta": omitMeta, + "items": []any{}, + } + + // Prefer existing processed rows for the sample EANs; fall back to any 2 recent. + type prod struct { + EAN, Title, Desc, Cat, CatName string + Attrs []byte + } + prods := []prod{} + q := ` + SELECT COALESCE(p.product_id,''), COALESCE(p.processed_name,p.name,''), + COALESCE(p.processed_description, p.description, ''), + COALESCE(p.category,''), COALESCE(c.name,''), + COALESCE(p.processed_attributes, p.attributes, '{}'::jsonb) + FROM processed_products p + LEFT JOIN categories c ON c.unique_id = p.category AND c.company_id = p.company_id + WHERE p.company_id = $1 AND p.product_id = ANY($2) + ORDER BY p.updated_at DESC NULLS LAST + LIMIT 4` + pr, err := pool.Query(ctx, q, co.ID, eans) + if err != nil { + fatal(err) + } + for pr.Next() { + var x prod + if err := pr.Scan(&x.EAN, &x.Title, &x.Desc, &x.Cat, &x.CatName, &x.Attrs); err != nil { + fatal(err) + } + prods = append(prods, x) + } + pr.Close() + if len(prods) < 2 { + pr2, err := pool.Query(ctx, ` + SELECT COALESCE(p.product_id,''), COALESCE(p.processed_name,p.name,''), + COALESCE(p.processed_description, p.description, ''), + COALESCE(p.category,''), COALESCE(c.name,''), + COALESCE(p.processed_attributes, p.attributes, '{}'::jsonb) + FROM processed_products p + LEFT JOIN categories c ON c.unique_id = p.category AND c.company_id = p.company_id + WHERE p.company_id = $1 AND COALESCE(p.processed_name,p.name,'') <> '' + ORDER BY p.updated_at DESC NULLS LAST + LIMIT 2`, co.ID) + if err != nil { + fatal(err) + } + for pr2.Next() { + var x prod + if err := pr2.Scan(&x.EAN, &x.Title, &x.Desc, &x.Cat, &x.CatName, &x.Attrs); err != nil { + fatal(err) + } + prods = append(prods, x) + } + pr2.Close() + } + if len(prods) == 0 { + coSummary["note"] = "no processed products" + summary["companies"] = append(summary["companies"].([]any), coSummary) + continue + } + + items := []processing.V1ProcessJobItem{} + for _, x := range prods { + attrs := map[string]any{} + _ = json.Unmarshal(x.Attrs, &attrs) + item := processing.V1ProcessJobItem{ + "ean": x.EAN, + "status": "processed", + "title": x.Title, + "name": x.Title, + "description": x.Desc, + "category": x.Cat, + "category_name": x.CatName, + "attributes": attrs, + "eprel": nil, + "id": uuid.New().String(), + } + item["processed_product_id"] = item["id"] + item["raw_product_id"] = uuid.New().String() + if !omitMeta { + item["meta_title"] = nil + item["meta_description"] = nil + } + item = processing.EnforceV1ProcessCompletedItemOpts(item, processing.EnforceV1Opts{ + Language: "sl", + Allowed: map[string]struct{}{}, + OmitSEOMeta: omitMeta, + }) + items = append(items, item) + } + + payload := map[string]any{ + "data": map[string]any{ + "status": "COMPLETED", + "processing_type": "full", + "total_items": len(items), + "items": items, + "company": co.Name, + "omit_seo_meta": omitMeta, + }, + } + b, err := json.MarshalIndent(payload, "", " ") + if err != nil { + fatal(err) + } + label := "demo" + if omitMeta { + label = "a1" + } + fname := fmt.Sprintf("%s_%s.json", label, co.ID.String()[:8]) + path := filepath.Join(outDir, fname) + if err := os.WriteFile(path, b, 0o644); err != nil { + fatal(err) + } + coSummary["sample_path"] = path + coSummary["item_count"] = len(items) + coSummary["items"] = items + summary["companies"] = append(summary["companies"].([]any), coSummary) + fmt.Println("wrote", path, "items=", len(items), "omit_meta=", omitMeta) + + // Also exercise LoadV1 against the latest completed job for this company when present. + var jobID uuid.UUID + err = pool.QueryRow(ctx, ` + SELECT id FROM processing_jobs + WHERE company_id = $1 AND status IN ('completed','COMPLETED') + ORDER BY updated_at DESC NULLS LAST + LIMIT 1`, co.ID).Scan(&jobID) + if err == nil { + loaded, loadErr := p.LoadV1ProcessJobItems(ctx, co.ID, jobID, "full") + if loadErr == nil && len(loaded) > 0 { + if len(loaded) > 2 { + loaded = loaded[:2] + } + live := map[string]any{ + "data": map[string]any{ + "process_id": jobID.String(), + "status": "COMPLETED", + "processing_type": "full", + "total_items": len(loaded), + "items": loaded, + "company": co.Name, + "omit_seo_meta": omitMeta, + "source": "LoadV1ProcessJobItems", + }, + } + lb, _ := json.MarshalIndent(live, "", " ") + lpath := filepath.Join(outDir, fmt.Sprintf("%s_live_job_%s.json", label, jobID.String()[:8])) + _ = os.WriteFile(lpath, lb, 0o644) + fmt.Println("wrote", lpath, "live_items=", len(loaded)) + } + } + } + + sb, _ := json.MarshalIndent(summary, "", " ") + _ = os.WriteFile(filepath.Join(outDir, "summary.json"), sb, 0o644) + fmt.Println("done", outDir) +} + +func fatal(err error) { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) +} diff --git a/apps/api/cmd/_a1_v1_sample_clean_tmp/main.go b/apps/api/cmd/_a1_v1_sample_clean_tmp/main.go new file mode 100644 index 0000000..23e6b8e --- /dev/null +++ b/apps/api/cmd/_a1_v1_sample_clean_tmp/main.go @@ -0,0 +1,84 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/descrybe/descrybe-v2/apps/api/internal/processing" +) + +func main() { + badItems := []processing.V1ProcessJobItem{ + { + "ean": "8806091734365", + "status": "processed", + "title": "LG Ameriški hladilnikGSXV80PZLE", + "name": "LG Ameriški hladilnikGSXV80PZLE", + "category": "11", + "category_name": "Hladilniki", + "description": "

LG Ameriški hladilnikGSXV80PZLE

LG Ameriški hladilnikGSXV80PZLE je izdelek v kategoriji Hladilniki znamke LG. Ključne specifikacije: width 179, height 91.3, depth 73.5.

Ključne lastnosti

LG Ameriški hladilnikGSXV80PZLE je izdelek v kategoriji Hladilniki znamke LG. Ključne specifikacije: width 179, height 91.3, depth 73.5.

LG Ameriški hladilnikGSXV80PZLE je izdelek v kategoriji Hladilniki znamke LG. Ključne specifikacije: width 179, height 91.3, depth 73.5.

LG Ameriški hladilnikGSXV80PZLE je izdelek v kategoriji Hladilniki znamke LG. Ključne specifikacije: width 179, height 91.3, depth 73.5.

", + "attributes": map[string]any{"brand": "LG", "depth": "73.5", "energy_class": "E", "height": "91.3", "warranty": "24 mesecev", "width": "179"}, + "meta_title": "LG Ameriški hladilnikGSXV80PZLE | Hladilniki", + "meta_description": "x", + "eprel": map[string]any{"id": "1158009", "energy_class": "E"}, + "id": "1bc20e89-3779-4c66-8617-8e72555cd363", + "processed_product_id": "1bc20e89-3779-4c66-8617-8e72555cd363", + "raw_product_id": "fff13ee3-e78c-4185-b524-b64128bbc38b", + "main_image": "https://example/lg.jpg", + "more_images": nil, + }, + { + "ean": "8422160053580", + "status": "processed", + "title": "Ufesa digitalni zračni cvrtnik 23L Magnum", + "name": "Ufesa digitalni zračni cvrtnik 23L Magnum", + "category": "112", + "category_name": "Ostali aparati", + "description": "

Ufesa digitalni zračni cvrtnik 23L Magnum

Ufesa digitalni zračni cvrtnik 23L Magnum je izdelek v kategoriji Ostali aparati znamke UFESA. Ključne specifikacije: width 0,00m, height 0,00m, depth 0,00m.

Ključne lastnosti

Ufesa digitalni zračni cvrtnik 23L Magnum je izdelek v kategoriji Ostali aparati znamke UFESA. Ključne specifikacije: width 0,00m, height 0,00m, depth 0,00m.

", + "attributes": map[string]any{"brand": "UFESA", "depth": "0,00m", "height": "0,00m", "warranty": "24 mesecev", "weight": "11,0000kg", "width": "0,00m"}, + "meta_title": "x", + "meta_description": "y", + "eprel": nil, + "id": "c708b505-dbf0-40c5-ba09-022d15daff97", + "processed_product_id": "c708b505-dbf0-40c5-ba09-022d15daff97", + "raw_product_id": "fff6c685-931b-4fa5-846b-89b2503a5a77", + "main_image": "https://example/ufesa.jpg", + "more_images": []string{"https://example/2.jpg"}, + }, + } + cleaned := make([]processing.V1ProcessJobItem, 0, len(badItems)) + for _, it := range badItems { + cleaned = append(cleaned, processing.EnforceV1ProcessCompletedItemOpts(it, processing.EnforceV1Opts{ + Language: "sl", + Allowed: map[string]struct{}{}, + OmitSEOMeta: true, + })) + } + out := map[string]any{ + "data": map[string]any{ + "process_id": "13b97eab-2329-4735-82fa-3afe263849fa", + "status": "COMPLETED", + "processing_type": "full", + "total_items": len(cleaned), + "items": cleaned, + "note": "projection cleanup of production sample (A1 OmitSEOMeta)", + }, + } + b, err := json.MarshalIndent(out, "", " ") + if err != nil { + panic(err) + } + path := filepath.Join("..", "..", ".codehelper", "_a1_v1_response_20260817", "production_sample_cleaned.json") + if err := os.WriteFile(path, b, 0o644); err != nil { + panic(err) + } + fmt.Println("wrote", path) + for _, it := range cleaned { + fmt.Printf("ean=%v\ntitle=%v\ncategory=%v category_id=%v\nmeta_present=%v/%v\nattrs=%v\ndesc=%v\n\n", + it["ean"], it["title"], it["category"], it["category_id"], + it["meta_title"] != nil, it["meta_description"] != nil, + it["attributes"], it["description"]) + } +} diff --git a/apps/api/internal/httpapi/v1_openapi.go b/apps/api/internal/httpapi/v1_openapi.go index f8fce6e..c256068 100644 --- a/apps/api/internal/httpapi/v1_openapi.go +++ b/apps/api/internal/httpapi/v1_openapi.go @@ -929,7 +929,8 @@ paths: processed_product_id: bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb raw_product_id: cccccccc-cccc-cccc-cccc-cccccccccccc status: processed - category: '50' + category: Cookers + category_id: '50' category_name: Cookers title: VOX electric cooker EHT 6020 WG name: VOX electric cooker EHT 6020 WG @@ -6278,12 +6279,15 @@ components: type: object description: | One COMPLETED legacy process line (A1 / public contract). Successful items - expose category as categories.unique_id, a description string that may - include category formula HTML (h1/h2/h3/h4, p, ul — never a JSON array), - SEO meta_title / meta_description (plain text), optional eprel object or + expose category as the human-readable display name (category_id holds + categories.unique_id), a description string that may include category formula + HTML (h1/h2/h3/h4, p, ul — never a JSON array), optional SEO meta_title / + meta_description (plain text; omitted for A1 cohort), optional eprel object or null, clean attributes, images, and dual-mode ids. Product display name is title; additive name mirrors the same processed title (dual-mode for scorecards / legacy clients that read name). + Property order prefers human-readable fields first (ean, title, category, + description, attributes, images, eprel) with internal ids last. required: - ean properties: @@ -6308,6 +6312,14 @@ components: raw_products.id for this job line. Use with POST /process raw_product_ids or dashboard catalog APIs. Present whenever the job product row exists. category: + type: string + nullable: true + description: | + Human-readable category display name (same value as category_name when + resolved). Prefer this for UI. Machine unique_id is category_id. + ASSUMPTION: historically this field held categories.unique_id; clients + that need the opaque id must read category_id (additive). + category_id: type: string nullable: true description: | @@ -6316,7 +6328,8 @@ components: category_name: type: string nullable: true - description: Human-readable category display name (not the unique_id). + description: | + Human-readable category display name (mirrors category when both are set). title: type: string nullable: true @@ -6335,12 +6348,14 @@ components: description: | SEO title. Filled from processing meta or synthesized from title / category when empty so successful items are not left with null meta. + Omitted for A1 cohort (SEO meta is not used). meta_description: type: string nullable: true description: | SEO description (word-safe truncate). Distinct from body description when possible; synthesized from plain description when DB meta is empty. + Omitted for A1 cohort (SEO meta is not used). description: type: string nullable: true @@ -6466,7 +6481,8 @@ components: processed_product_id: bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb raw_product_id: cccccccc-cccc-cccc-cccc-cccccccccccc status: processed - category: '50' + category: Cookers + category_id: '50' category_name: Cookers title: VOX electric cooker EHT 6020 WG name: VOX electric cooker EHT 6020 WG diff --git a/apps/api/internal/processing/ai.go b/apps/api/internal/processing/ai.go index f5283a0..ae64a88 100644 --- a/apps/api/internal/processing/ai.go +++ b/apps/api/internal/processing/ai.go @@ -126,6 +126,9 @@ type ProductInput struct { // CategoryUniqueID is the taxonomy unique_id for overlay/formula keys when // the enhance display category argument is a localized name. CategoryUniqueID string + // OmitSEOMeta skips meta_title / meta_description enhance + free-template fill + // (A1 cohort does not use SEO meta fields). + OmitSEOMeta bool } // CategoryFormulas holds optional title/description templates for one category key. diff --git a/apps/api/internal/processing/category_test.go b/apps/api/internal/processing/category_test.go index 6535367..a828d9a 100644 --- a/apps/api/internal/processing/category_test.go +++ b/apps/api/internal/processing/category_test.go @@ -359,8 +359,11 @@ func TestLoadV1ProcessJobItems_resolvesCategoryName(t *testing.T) { if len(items) != 1 { t.Fatalf("items=%d want 1", len(items)) } - if got := fmt.Sprint(items[0]["category"]); got != "50" { - t.Fatalf("category=%v want 50", items[0]["category"]) + if got := fmt.Sprint(items[0]["category"]); got != "Štedilniki" { + t.Fatalf("category=%v want Štedilniki (display name)", items[0]["category"]) + } + if got := fmt.Sprint(items[0]["category_id"]); got != "50" { + t.Fatalf("category_id=%v want 50", items[0]["category_id"]) } if got := fmt.Sprint(items[0]["category_name"]); got != "Štedilniki" { t.Fatalf("category_name=%v want Štedilniki", items[0]["category_name"]) diff --git a/apps/api/internal/processing/enhance_hash_test.go b/apps/api/internal/processing/enhance_hash_test.go index 2d40e4c..6a05ab4 100644 --- a/apps/api/internal/processing/enhance_hash_test.go +++ b/apps/api/internal/processing/enhance_hash_test.go @@ -375,9 +375,9 @@ func TestRunSteps_synthPriorHashDoesNotSkipReprocess(t *testing.T) { } normName := "Vogel's WALL 3245 TV Wall Mount" normDesc := "A mount with enough mapped detail for hashing inputs." - synthPrior := synthesizeDescriptionFromTitle(normName, "TV Mounts", "en", map[string]any{ - "brand": "Vogel's", "width": "45 cm", "max_load": "40 kg", - }) + // Use legacy invent phrasing as the stored prior — new factual fallback must not + // look like invent, but old catalog rows still do and must force re-enhance. + synthPrior := normName + " is a TV Mounts product from Vogel's. Key specs: width 45 cm, max_load 40 kg." if !company.LooksLikeHeuristicSynthesize(synthPrior) { t.Fatalf("expected invent synth prior, got %q", synthPrior) } diff --git a/apps/api/internal/processing/formula_prompt.go b/apps/api/internal/processing/formula_prompt.go index 299d181..c6bd000 100644 --- a/apps/api/internal/processing/formula_prompt.go +++ b/apps/api/internal/processing/formula_prompt.go @@ -17,12 +17,15 @@ import ( // Meta instructions come from description_template.metaTitle / metaDescription // (legacy A1 / cats.json) and are distinct from HTML description sections. // Attribute allowlist / formula-key guidance is AppendAttributeConstraints. -func AppendFormulaConstraints(userTpl string, titleTemplate, descriptionTemplate any) string { +// When omitSEOMeta is true (A1 cohort), meta formula blocks are skipped. +func AppendFormulaConstraints(userTpl string, titleTemplate, descriptionTemplate any, omitSEOMeta bool) string { userTpl = strings.TrimSpace(userTpl) blocks := []string{ FormatTitleFormulaConstraint(titleTemplate), FormatDescriptionFormulaConstraint(descriptionTemplate), - FormatMetaFormulaConstraint(descriptionTemplate), + } + if !omitSEOMeta { + blocks = append(blocks, FormatMetaFormulaConstraint(descriptionTemplate)) } var joined strings.Builder for _, block := range blocks { diff --git a/apps/api/internal/processing/normalize.go b/apps/api/internal/processing/normalize.go index 70e1657..7e5980e 100644 --- a/apps/api/internal/processing/normalize.go +++ b/apps/api/internal/processing/normalize.go @@ -228,7 +228,38 @@ func isDimensionKey(key string) bool { func isZeroishString(s string) bool { s = strings.TrimSpace(strings.ToLower(s)) - return s == "0" || s == "0.0" || s == "0,0" || s == "0.00" + if s == "" { + return false + } + if s == "0" || s == "0.0" || s == "0,0" || s == "0.00" { + return true + } + // Strip common unit suffixes (m, cm, mm, kg, g) then re-check numeric zero. + trimmed := s + for _, u := range []string{"kg", "cm", "mm", "g", "m"} { + if strings.HasSuffix(trimmed, u) { + trimmed = strings.TrimSpace(strings.TrimSuffix(trimmed, u)) + break + } + } + trimmed = strings.ReplaceAll(trimmed, " ", "") + if trimmed == "" { + return false + } + // 0 / 0.0 / 0,00 / 0.0000 / 0,0000 + onlyZero := true + sawDigit := false + for _, r := range trimmed { + switch r { + case '0': + sawDigit = true + case '.', ',': + continue + default: + onlyZero = false + } + } + return onlyZero && sawDigit } func isEmptyValue(v any) bool { diff --git a/apps/api/internal/processing/pipeline.go b/apps/api/internal/processing/pipeline.go index 6ebc0d2..552aeb7 100644 --- a/apps/api/internal/processing/pipeline.go +++ b/apps/api/internal/processing/pipeline.go @@ -879,6 +879,8 @@ type jobScopedCache struct { canUseAI bool allowEPREL bool remainingCredits int + // omitSEOMeta skips meta_title / meta_description enhance + free-template fill (A1). + omitSEOMeta bool } func (c *jobScopedCache) stepPolicy() StepPolicy { @@ -927,6 +929,7 @@ func (p *Pipeline) loadJobScopedCache(ctx context.Context, companyID, jobID uuid } } } + cache.omitSEOMeta = companyOmitsSEOMeta(ctx, p, companyID) if p.Prompts != nil { cache.enhanceByLang = make(map[string]PromptTemplates, len(cache.contentLanguages)+1) langs := cache.contentLanguages @@ -1526,6 +1529,9 @@ func (p *Pipeline) processOne(ctx context.Context, companyID, jobID uuid.UUID, i CompanyID: companyID.String(), RawProductID: it.RawID.String(), } + if cache != nil { + in.OmitSEOMeta = cache.omitSEOMeta + } if it.hydrated { if it.hasPrior { in.PriorProcessedName = it.priorName @@ -1592,7 +1598,18 @@ func (p *Pipeline) processOne(ctx context.Context, companyID, jobID uuid.UUID, i result.ProcessedAttributes = result.Attributes } // Free template SEO meta when enhance did not emit meta_* (no FillMetaAI / no extra credits). - if strings.TrimSpace(result.MetaTitle) == "" || strings.TrimSpace(result.MetaDescription) == "" { + // A1 cohort omits SEO meta entirely (not needed for their storefront). + if cache != nil && cache.omitSEOMeta { + result.MetaTitle = "" + result.MetaDescription = "" + if result.LocalizedContent != nil { + for lang, lf := range result.LocalizedContent { + lf.MetaTitle = "" + lf.MetaDescription = "" + result.LocalizedContent[lang] = lf + } + } + } else if strings.TrimSpace(result.MetaTitle) == "" || strings.TrimSpace(result.MetaDescription) == "" { mt, md := fillMetaFromResult(result) if strings.TrimSpace(result.MetaTitle) == "" { result.MetaTitle = mt diff --git a/apps/api/internal/processing/prompt_render.go b/apps/api/internal/processing/prompt_render.go index 82388a2..f90de4c 100644 --- a/apps/api/internal/processing/prompt_render.go +++ b/apps/api/internal/processing/prompt_render.go @@ -25,7 +25,7 @@ func resolveProductPromptTemplates(in ProductInput) (systemTpl, userTpl string) } } // Category formulas are language-agnostic; inject once into the shared user skeleton. - userTpl = AppendFormulaConstraints(userTpl, in.TitleTemplate, in.DescriptionTemplate) + userTpl = AppendFormulaConstraints(userTpl, in.TitleTemplate, in.DescriptionTemplate, in.OmitSEOMeta) // Category attribute allowlist + title-formula keys guide JSON "attrs" extraction. allowed := enhanceAllowedAttrKeys(in, in.CategoryUniqueID) userTpl = AppendAttributeConstraints(userTpl, allowed, in.TitleTemplate) @@ -34,7 +34,9 @@ func resolveProductPromptTemplates(in ProductInput) (systemTpl, userTpl string) systemTpl = AppendDescriptionFormulaSystemOverride(systemTpl, in.DescriptionTemplate) // Same for title_template vs "short retail title" — formulas win for name structure. systemTpl = AppendTitleFormulaSystemOverride(systemTpl, in.TitleTemplate) - systemTpl = AppendMetaFormulaSystemOverride(systemTpl, in.DescriptionTemplate) + if !in.OmitSEOMeta { + systemTpl = AppendMetaFormulaSystemOverride(systemTpl, in.DescriptionTemplate) + } // categories.prompt applies to name, description, and attrs (not description-only). systemTpl = AppendCategoryEnhanceSystemOverlay(systemTpl, catPrompt) return systemTpl, userTpl diff --git a/apps/api/internal/processing/steps.go b/apps/api/internal/processing/steps.go index 93dcac7..a59b140 100644 --- a/apps/api/internal/processing/steps.go +++ b/apps/api/internal/processing/steps.go @@ -358,6 +358,9 @@ func (e *Engine) RunSteps(ctx context.Context, companyID string, in ProductInput } weakDesc := descriptionNeedsEnhanceRepair(desc, descTpl, name) enhanceMetaTitle, enhanceMetaDesc := enhanceMetaFromRaw(raw) + if in.OmitSEOMeta { + enhanceMetaTitle, enhanceMetaDesc = "", "" + } // Only persist enhance_input_hash for quality ok / hash-skip unchanged. // Never copy input_hash from error/passthrough/thin/synthesized meta. persistHash := "" @@ -1391,7 +1394,7 @@ func preferredProductTitle(gtin string, candidates ...string) string { if c == "" || c == "" || isPromptLabelTitle(c) { continue } - usable = append(usable, c) + usable = append(usable, ensureReadableTitleSpacing(c)) } for _, c := range usable { if isBrandOnlyTitleAmong(c, usable) { @@ -1479,6 +1482,9 @@ func preferredProductDescription(title string, candidates ...string) string { if containsWeakFillerPhrase(c) { continue } + if company.LooksLikeHeuristicSynthesize(c) { + continue + } return SanitizeOutput(c) } return "" @@ -1508,15 +1514,20 @@ func attrLookupCI(attrs map[string]any, keys ...string) string { } func formatAttrDimParts(attrs map[string]any, maxParts int) []string { + return formatAttrDimPartsLang(attrs, maxParts, "") +} + +func formatAttrDimPartsLang(attrs map[string]any, maxParts int, language string) []string { if attrs == nil || maxParts <= 0 { return nil } prefer := []string{ "width", "height", "depth", "weight", "max_load", "max load", "load_capacity", - "vesa", "screen_size", "diagonal", "color", "material", "size", + "vesa", "screen_size", "diagonal", "color", "material", "size", "energy_class", "warranty", } parts := make([]string, 0, maxParts) seen := map[string]struct{}{} + sl := isSlovenianContentLanguage(language) add := func(k, v string) { k = strings.TrimSpace(k) v = strings.TrimSpace(v) @@ -1527,8 +1538,11 @@ func formatAttrDimParts(attrs map[string]any, maxParts int) []string { if _, ok := seen[lk]; ok { return } + if isDimensionKey(canonicalizeAttrKey(k)) && isZeroishString(v) { + return + } seen[lk] = struct{}{} - parts = append(parts, fmt.Sprintf("%s %s", k, v)) + parts = append(parts, fmt.Sprintf("%s: %s", attrDimLabel(k, sl), v)) } for _, k := range prefer { if len(parts) >= maxParts { @@ -1541,9 +1555,61 @@ func formatAttrDimParts(attrs map[string]any, maxParts int) []string { return parts } +func attrDimLabel(key string, slovenian bool) string { + canon := canonicalizeAttrKey(key) + if slovenian { + switch canon { + case "width": + return "Širina" + case "height": + return "Višina" + case "depth": + return "Globina" + case "weight": + return "Teža" + case "energy_class": + return "Energijski razred" + case "warranty": + return "Garancija" + case "color": + return "Barva" + case "material": + return "Material" + case "size", "screen_size", "diagonal": + return "Velikost" + } + } + switch canon { + case "width": + return "Width" + case "height": + return "Height" + case "depth": + return "Depth" + case "weight": + return "Weight" + case "energy_class": + return "Energy class" + case "warranty": + return "Warranty" + case "max_load", "load_capacity": + return "Max load" + case "screen_size", "diagonal": + return "Screen size" + case "product_model": + return "Model" + default: + if canon == "" { + return key + } + return strings.ReplaceAll(canon, "_", " ") + } +} + // synthesizeProductDescription prefers a category description_template skeleton // (HTML section types) when present; otherwise falls back to plain title synthesize. func synthesizeProductDescription(title, category, language string, attrs map[string]any, descriptionTemplate any) string { + title = ensureReadableTitleSpacing(title) if sections, ok := parseDescriptionFormulaSections(descriptionTemplate); ok && len(sections) > 0 { if out := synthesizeDescriptionFromFormula(title, category, language, attrs, sections); out != "" { return out @@ -1555,23 +1621,30 @@ func synthesizeProductDescription(title, category, language string, attrs map[st // synthesizeDescriptionFromFormula builds a minimal HTML description matching // category description_template section types so timeout/fallback still respects // A1 category structure (unlike plain title synthesize). +// Duplicate paragraph/list section types are emitted once to avoid invent-boilerplate +// repetition (legacy bug: every

repeated the same synthesize sentence). func synthesizeDescriptionFromFormula(title, category, language string, attrs map[string]any, sections []descriptionFormulaSection) string { - title = strings.TrimSpace(title) + title = ensureReadableTitleSpacing(strings.TrimSpace(title)) if title == "" || title == "" || isPromptLabelTitle(title) { return "" } - base := synthesizeDescriptionFromTitle(title, category, language, attrs) - if base == "" { + dims := formatAttrDimPartsLang(attrs, 6, language) + intro := factualDescriptionIntro(title, category, language, attrs) + if intro == "" { return "" } - dims := formatAttrDimParts(attrs, 6) var b strings.Builder paraUsed := false listUsed := false + headingLevels := map[string]bool{} for _, s := range sections { typ := strings.ToLower(strings.TrimSpace(s.Type)) switch typ { case "h1", "h2", "h3", "h4": + if headingLevels[typ] { + continue + } + headingLevels[typ] = true heading := title if typ != "h1" { if isSlovenianContentLanguage(language) { @@ -1582,37 +1655,42 @@ func synthesizeDescriptionFromFormula(title, category, language string, attrs ma } fmt.Fprintf(&b, "<%s>%s", typ, SanitizeOutput(heading), typ) case "ul": - b.WriteString("