From 92f046b54267df2395d3525c6f254e4b991892c4 Mon Sep 17 00:00:00 2001
From: GreenEclipse Durable widget for everyday use. Clear specs, ready to ship. 27 inch IPS monitor for desk work with clear specs. Zložljive ANC slušalke. ") || !strings.Contains(got, "Acme UltraView 27
",
+ "meta_title": "Acme UltraView 27 | IPS",
+ "meta_description": "Shop Acme UltraView 27 IPS monitor.",
+ "attrs": map[string]any{
+ "diagonala_zaslona": "27\"",
+ "vrsta_panela": "IPS",
+ "zavora": "must-drop",
+ },
+ })
+ e := &Engine{
+ Completer: stubCompleter{fn: func(system, user string) (Completion, error) {
+ return Completion{Text: payload, TotalTokens: 12, Raw: map[string]any{"ok": true}}, nil
+ }},
+ Vector: NoopVectorCategorizer{},
+ }
+ out, err := e.RunSteps(context.Background(), "co", ProductInput{
+ Name: "UltraView 27 Gaming Monitor",
+ Description: "A solid IPS desk monitor with factory specs.",
+ Mapped: map[string]any{
+ "category_unique_id": "28",
+ "name": "UltraView 27 Gaming Monitor",
+ "attributes": map[string]any{
+ "brand": "Acme",
+ },
+ },
+ CategoryNamesByUID: map[string]string{"28": "Monitorji"},
+ CategoryUniqueID: "28",
+ CategoryAttrKeys: map[string]map[string]struct{}{
+ "28": {"diagonala_zaslona": {}, "vrsta_panela": {}},
+ },
+ CategoryEnhancePrompt: "Focus on panel technology for monitors.",
+ Language: "en",
+ }, "full", nil, StepPolicy{AllowAI: true})
+ if err != nil {
+ t.Fatal(err)
+ }
+ pa := out.ProcessedAttributes
+ if pa["diagonala_zaslona"] != "27\"" || pa["vrsta_panela"] != "IPS" {
+ t.Fatalf("expected LLM attrs merged: %v notes=%v gpt=%v", pa, out.Notes, out.GPTResponse)
+ }
+ if _, ok := pa["zavora"]; ok {
+ t.Fatalf("zavora must not survive allowlist: %v", pa)
+ }
+ if pa["brand"] != "Acme" {
+ t.Fatalf("feed brand must remain: %v", pa)
+ }
+ if out.FieldSources["attributes"] != "ai_enhance" {
+ t.Fatalf("field source=%v", out.FieldSources["attributes"])
+ }
+ if !strings.Contains(out.ProcessedName, "UltraView") {
+ t.Fatalf("name=%q", out.ProcessedName)
+ }
+}
+
+func mustJSON(v any) string {
+ b, err := json.Marshal(v)
+ if err != nil {
+ panic(err)
+ }
+ return string(b)
+}
diff --git a/apps/api/internal/processing/catalog_fix.go b/apps/api/internal/processing/catalog_fix.go
index 82ec2b4..2ab0ed9 100644
--- a/apps/api/internal/processing/catalog_fix.go
+++ b/apps/api/internal/processing/catalog_fix.go
@@ -211,9 +211,10 @@ func FixCatalogHygieneWithIDs(ctx context.Context, pool *pgxpool.Pool, companyID
return out, ids, nil
}
-// NormalizeProcessedDescriptions rewrites processed_description to the plain-text
-// form produced by PlainDescriptionFromAny (unwraps JSON arrays, strips HTML). When
-// processed_description is empty, normalizes from description. Returns rows updated.
+// NormalizeProcessedDescriptions rewrites processed_description to a single
+// string form via DescriptionFromAny (unwraps JSON arrays, preserves formula
+// HTML). When processed_description is empty, normalizes from description.
+// Returns rows updated.
func NormalizeProcessedDescriptions(ctx context.Context, pool *pgxpool.Pool, companyID uuid.UUID) (int, error) {
if pool == nil {
return 0, fmt.Errorf("normalize descriptions: nil pool")
@@ -245,7 +246,7 @@ func NormalizeProcessedDescriptions(ctx context.Context, pool *pgxpool.Pool, com
if raw == "" {
continue
}
- plain := plainDescriptionFromStored(raw)
+ plain := descriptionFromStored(raw)
if plain == "" || plain == strings.TrimSpace(processedDesc) {
continue
}
@@ -265,7 +266,26 @@ func NormalizeProcessedDescriptions(ctx context.Context, pool *pgxpool.Pool, com
return updated, nil
}
+// descriptionFromStored handles DB text that may itself be a JSON array/string,
+// preserving formula HTML tags for process/API consumers.
+func descriptionFromStored(raw string) string {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return ""
+ }
+ if strings.HasPrefix(raw, "[") || strings.HasPrefix(raw, "{") {
+ var decoded any
+ if err := json.Unmarshal([]byte(raw), &decoded); err == nil {
+ if s := DescriptionFromAny(decoded); s != "" {
+ return s
+ }
+ }
+ }
+ return DescriptionFromAny(raw)
+}
+
// plainDescriptionFromStored handles DB text that may itself be a JSON array/string.
+// Strips HTML — prefer descriptionFromStored when formula markup must be kept.
func plainDescriptionFromStored(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
diff --git a/apps/api/internal/processing/categorize_ai.go b/apps/api/internal/processing/categorize_ai.go
index 078e475..de9bf77 100644
--- a/apps/api/internal/processing/categorize_ai.go
+++ b/apps/api/internal/processing/categorize_ai.go
@@ -82,7 +82,8 @@ func formatAvailableCategoriesList(opts []categoryOption) string {
return strings.TrimSpace(b.String())
}
-// ProductCategorizeSystem is the built-in system prompt for taxonomy selection.
+// ProductCategorizeSystem is the built-in system prompt for taxonomy selection
+// (aiprompts.RoleCategorize — pick company unique_id; separate from enhance overlay).
const ProductCategorizeSystem = `Product categorization expert.
Rules:
- Reply with ONLY a single JSON object (no markdown, no prose, no reasoning)
diff --git a/apps/api/internal/processing/enhance_log_test.go b/apps/api/internal/processing/enhance_log_test.go
index c4c6066..9f8c7d9 100644
--- a/apps/api/internal/processing/enhance_log_test.go
+++ b/apps/api/internal/processing/enhance_log_test.go
@@ -80,6 +80,9 @@ func TestA1StyleDescriptionFormulaInResolvedPrompts(t *testing.T) {
if !strings.Contains(sysTpl, "Description formula") {
t.Fatalf("system missing formula override: %s", sysTpl)
}
+ if !strings.Contains(sysTpl, `When the user message includes a "Title formula"`) {
+ t.Fatalf("system missing title formula override: %s", sysTpl)
+ }
if !strings.Contains(userTpl, "Description formula") || !strings.Contains(userTpl, "- h1: Naziv izdelka") || !strings.Contains(userTpl, "- h2: Podnaslov prednosti") {
t.Fatalf("user missing description formula: %s", userTpl)
}
diff --git a/apps/api/internal/processing/formula_prompt.go b/apps/api/internal/processing/formula_prompt.go
index ee28794..9d19f7c 100644
--- a/apps/api/internal/processing/formula_prompt.go
+++ b/apps/api/internal/processing/formula_prompt.go
@@ -3,38 +3,146 @@ package processing
import (
"encoding/json"
"fmt"
+ "sort"
"strconv"
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/company"
)
-// AppendFormulaConstraints appends language-agnostic title/description formula
+// AppendFormulaConstraints appends language-agnostic title/description/meta formula
// guidance to the enhance user template (before {{var}} render). Empty templates
// are no-ops. Shared skeleton stays in company/built-in prompts; formulas only
// constrain structure for the active language via {{language}} elsewhere.
+// 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 {
userTpl = strings.TrimSpace(userTpl)
- titleBlock := FormatTitleFormulaConstraint(titleTemplate)
- descBlock := FormatDescriptionFormulaConstraint(descriptionTemplate)
- if titleBlock == "" && descBlock == "" {
+ blocks := []string{
+ FormatTitleFormulaConstraint(titleTemplate),
+ FormatDescriptionFormulaConstraint(descriptionTemplate),
+ FormatMetaFormulaConstraint(descriptionTemplate),
+ }
+ var joined strings.Builder
+ for _, block := range blocks {
+ if block == "" {
+ continue
+ }
+ if joined.Len() > 0 {
+ joined.WriteString("\n\n")
+ }
+ joined.WriteString(block)
+ }
+ if joined.Len() == 0 {
return userTpl
}
+ if userTpl == "" {
+ return joined.String()
+ }
+ return userTpl + "\n\n" + joined.String()
+}
+
+// MaxAttrAllowlistPromptKeys caps Allowed attribute keys listed in enhance prompts.
+const MaxAttrAllowlistPromptKeys = 40
+
+// AppendAttributeConstraints appends category_attributes allowlist + title-formula
+// variable keys so the model can emit JSON "attrs" validated via AttrsForEnhance.
+// No-op when both allowlist and formula keys are empty.
+func AppendAttributeConstraints(userTpl string, allowed map[string]struct{}, titleTemplate any) string {
+ userTpl = strings.TrimSpace(userTpl)
+ block := FormatAttributeAllowlistConstraint(allowed, titleTemplate)
+ if block == "" {
+ return userTpl
+ }
+ if userTpl == "" {
+ return block
+ }
+ if strings.Contains(userTpl, `Allowed attribute keys (JSON "attrs" object`) {
+ return userTpl
+ }
+ return userTpl + "\n\n" + block
+}
+
+// FormatAttributeAllowlistConstraint lists category allowlist keys and title-formula
+// attr slots for the Attributes enhance role. When allowed is nil, only formula
+// keys are listed (unit-test / sanitize-only paths).
+func FormatAttributeAllowlistConstraint(allowed map[string]struct{}, titleTemplate any) string {
+ keys := preferredAttrKeyList(allowed, MaxAttrAllowlistPromptKeys)
+ formulaKeys := titleFormulaAttrKeys(titleTemplate)
+ if len(keys) == 0 && len(formulaKeys) == 0 {
+ return ""
+ }
var b strings.Builder
- if userTpl != "" {
- b.WriteString(userTpl)
- b.WriteString("\n\n")
+ b.WriteString("Allowed attribute keys (JSON \"attrs\" object — remap feed labels onto these; omit unknowns):\n")
+ if len(keys) > 0 {
+ b.WriteString("- category: ")
+ b.WriteString(strings.Join(keys, ", "))
+ b.WriteByte('\n')
+ } else {
+ b.WriteString("- category: (core characteristics only — brand, product_model, dims, …)\n")
}
- if titleBlock != "" {
- b.WriteString(titleBlock)
- if descBlock != "" {
- b.WriteString("\n\n")
+ if len(formulaKeys) > 0 {
+ b.WriteString("- title formula slots: ")
+ b.WriteString(strings.Join(formulaKeys, ", "))
+ b.WriteByte('\n')
+ }
+ b.WriteString("Fill missing keys only from Name/Description/Category/Attrs evidence; never invent specs or zero dimensions.")
+ return strings.TrimSpace(b.String())
+}
+
+func preferredAttrKeyList(allowed map[string]struct{}, maxKeys int) []string {
+ if len(allowed) == 0 || maxKeys <= 0 {
+ return nil
+ }
+ prefer := preferredAllowedKeys(allowed)
+ seen := map[string]struct{}{}
+ out := make([]string, 0, len(prefer))
+ for _, k := range prefer {
+ k = strings.TrimSpace(k)
+ if k == "" {
+ continue
}
+ if _, ok := seen[k]; ok {
+ continue
+ }
+ seen[k] = struct{}{}
+ out = append(out, k)
}
- if descBlock != "" {
- b.WriteString(descBlock)
+ sort.Strings(out)
+ if len(out) > maxKeys {
+ out = out[:maxKeys]
}
- return b.String()
+ return out
+}
+
+func titleFormulaAttrKeys(template any) []string {
+ elements, _, ok := parseTitleFormula(template)
+ if !ok || len(elements) == 0 {
+ return nil
+ }
+ seen := map[string]struct{}{}
+ var out []string
+ for _, el := range elements {
+ if el.Type != "variable" {
+ continue
+ }
+ k := strings.TrimSpace(el.Value)
+ if k == "" {
+ continue
+ }
+ canon := canonicalizeAttrKey(k)
+ if canon == "" {
+ canon = strings.ToLower(k)
+ }
+ if _, ok := seen[canon]; ok {
+ continue
+ }
+ seen[canon] = struct{}{}
+ out = append(out, canon)
+ }
+ sort.Strings(out)
+ return out
}
// FormatTitleFormulaConstraint turns categories.title_template into enhance-prompt
@@ -70,6 +178,30 @@ func FormatTitleFormulaConstraint(template any) string {
return strings.TrimSpace(b.String())
}
+// FormatMetaFormulaConstraint turns description_template.metaTitle / metaDescription
+// (cats.json / A1 SEO instructions) into enhance-prompt constraints distinct from
+// HTML description sections.
+func FormatMetaFormulaConstraint(template any) string {
+ metaTitle, metaDesc := parseMetaFormulaInstructions(template)
+ if metaTitle == "" && metaDesc == "" {
+ return ""
+ }
+ var b strings.Builder
+ b.WriteString("SEO meta formula (REQUIRED — distinct from description HTML; plain text only):\n")
+ b.WriteString("Emit meta_title and meta_description as separate JSON fields in {{language}}.\n")
+ if metaTitle != "" {
+ fmt.Fprintf(&b, "- meta_title (50-60 chars): %s\n", metaTitle)
+ } else {
+ b.WriteString("- meta_title: 50-60 chars, product name + main benefit or use case\n")
+ }
+ if metaDesc != "" {
+ fmt.Fprintf(&b, "- meta_description (120-155 chars): %s\n", metaDesc)
+ } else {
+ b.WriteString("- meta_description: 120-155 chars, factual SEO snippet; never HTML\n")
+ }
+ return strings.TrimSpace(b.String())
+}
+
// FormatDescriptionFormulaConstraint turns categories.description_template sections
// into bullet instructions for the enhance user prompt.
func FormatDescriptionFormulaConstraint(template any) string {
@@ -120,6 +252,74 @@ func AppendDescriptionFormulaSystemOverride(systemTpl string, descriptionTemplat
return systemTpl + "\n" + descriptionFormulaSystemOverride
}
+// titleFormulaSystemOverride is appended to the enhance system template when a
+// category title_template is present so company/built-in "short retail title"
+// rules cannot ignore the Title formula (mirrors description formula override).
+const titleFormulaSystemOverride = `When the user message includes a "Title formula", obey that structure for "name" over any shorter "short retail title" guidance: build name from Attrs in the given element order, keep literal text as written, write name in {{language}}. Never ignore the Title formula.`
+
+// AppendTitleFormulaSystemOverride strengthens the system prompt when a title
+// formula is active. No-op when template is empty/unparseable.
+func AppendTitleFormulaSystemOverride(systemTpl string, titleTemplate any) string {
+ if FormatTitleFormulaConstraint(titleTemplate) == "" {
+ return strings.TrimSpace(systemTpl)
+ }
+ systemTpl = strings.TrimSpace(systemTpl)
+ if systemTpl == "" {
+ return titleFormulaSystemOverride
+ }
+ if strings.Contains(systemTpl, `When the user message includes a "Title formula"`) {
+ return systemTpl
+ }
+ return systemTpl + "\n" + titleFormulaSystemOverride
+}
+
+// categoryEnhanceSystemOverlay is appended when categories.prompt (enhance overlay)
+// is active so free-form category copy drives title, description, AND attributes —
+// not description alone — while Title/Description formulas keep structural precedence.
+const categoryEnhanceSystemOverlay = `When the user message includes category guidance, apply it to "name", "description", and "attrs" (tone/focus for the title and body, plus attribute extraction onto Allowed attribute keys). Structural Title/Description formula blocks and Allowed attribute keys in the user message still take precedence when present.`
+
+// AppendCategoryEnhanceSystemOverlay strengthens the system prompt when a
+// per-category enhance overlay (categories.prompt) is set. No-op when empty.
+func AppendCategoryEnhanceSystemOverlay(systemTpl, categoryPrompt string) string {
+ if strings.TrimSpace(categoryPrompt) == "" {
+ return strings.TrimSpace(systemTpl)
+ }
+ systemTpl = strings.TrimSpace(systemTpl)
+ if systemTpl == "" {
+ return categoryEnhanceSystemOverlay
+ }
+ marker := `apply it to "name", "description", and "attrs"`
+ if strings.Contains(systemTpl, marker) {
+ return systemTpl
+ }
+ // Upgrade older name+description-only overlay without stacking duplicates.
+ legacy := `apply it to BOTH "name" and "description"`
+ if strings.Contains(systemTpl, legacy) {
+ return strings.Replace(systemTpl, legacy, marker, 1)
+ }
+ return systemTpl + "\n" + categoryEnhanceSystemOverlay
+}
+
+// metaFormulaSystemOverride is appended when description_template carries metaTitle
+// / metaDescription instructions so enhance emits SEO fields separately from HTML.
+const metaFormulaSystemOverride = `When the user message includes an "SEO meta formula", emit meta_title and meta_description as plain SEO text (not HTML) distinct from description. Obey the character guidance in the formula.`
+
+// AppendMetaFormulaSystemOverride strengthens the system prompt when meta formula
+// instructions are present on description_template. No-op when absent.
+func AppendMetaFormulaSystemOverride(systemTpl string, descriptionTemplate any) string {
+ if FormatMetaFormulaConstraint(descriptionTemplate) == "" {
+ return strings.TrimSpace(systemTpl)
+ }
+ systemTpl = strings.TrimSpace(systemTpl)
+ if systemTpl == "" {
+ return metaFormulaSystemOverride
+ }
+ if strings.Contains(systemTpl, "SEO meta formula") {
+ return systemTpl
+ }
+ return systemTpl + "\n" + metaFormulaSystemOverride
+}
+
// descriptionSatisfiesFormula reports whether desc includes the HTML tags required
// by categories.description_template sections. No formula → always true.
func descriptionSatisfiesFormula(desc string, template any) bool {
@@ -219,6 +419,27 @@ func parseDescriptionFormulaSections(template any) ([]descriptionFormulaSection,
return out, len(out) > 0
}
+// parseMetaFormulaInstructions reads metaTitle / metaDescription instruction
+// strings from description_template (camelCase as stored by the category UI).
+func parseMetaFormulaInstructions(template any) (metaTitle, metaDescription string) {
+ if template == nil {
+ return "", ""
+ }
+ obj, err := asObjectMap(template)
+ if err != nil || obj == nil {
+ return "", ""
+ }
+ metaTitle = strings.TrimSpace(stringFromAny(obj["metaTitle"]))
+ if metaTitle == "" {
+ metaTitle = strings.TrimSpace(stringFromAny(obj["meta_title"]))
+ }
+ metaDescription = strings.TrimSpace(stringFromAny(obj["metaDescription"]))
+ if metaDescription == "" {
+ metaDescription = strings.TrimSpace(stringFromAny(obj["meta_description"]))
+ }
+ return metaTitle, metaDescription
+}
+
// categoryFormulasFor resolves title/description templates for a category key
// (unique_id or name). Explicit ProductInput fields win over the job cache map.
func categoryFormulasFor(in ProductInput, category string) (title, description any) {
diff --git a/apps/api/internal/processing/formula_prompt_test.go b/apps/api/internal/processing/formula_prompt_test.go
index 0034a2a..ce3ed1c 100644
--- a/apps/api/internal/processing/formula_prompt_test.go
+++ b/apps/api/internal/processing/formula_prompt_test.go
@@ -76,6 +76,118 @@ func TestAppendDescriptionFormulaSystemOverride(t *testing.T) {
}
}
+func TestAppendTitleFormulaSystemOverride(t *testing.T) {
+ t.Parallel()
+ title := map[string]any{
+ "separator": " ",
+ "elements": []any{
+ map[string]any{"type": "variable", "value": "brand"},
+ map[string]any{"type": "text", "value": "Pro"},
+ },
+ }
+ sys, user := resolveProductPromptTemplates(ProductInput{
+ EnhanceSystemTemplate: "Retail product copywriter.\n- name: short retail title",
+ EnhanceUserTemplate: "Name: {{name}}",
+ TitleTemplate: title,
+ })
+ if !strings.Contains(user, "Title formula") || !strings.Contains(user, "attr [brand]") {
+ t.Fatalf("missing user title formula: %s", user)
+ }
+ if !strings.Contains(sys, `When the user message includes a "Title formula"`) || !strings.Contains(sys, "short retail title") {
+ t.Fatalf("system should keep company text and append title override: %s", sys)
+ }
+ if AppendTitleFormulaSystemOverride("plain", nil) != "plain" {
+ t.Fatal("empty title formula must be no-op")
+ }
+ dup := AppendTitleFormulaSystemOverride(sys, title)
+ if strings.Count(dup, `When the user message includes a "Title formula"`) != 1 {
+ t.Fatalf("title override must be idempotent: %s", dup)
+ }
+}
+
+func TestAppendCategoryEnhanceSystemOverlay(t *testing.T) {
+ t.Parallel()
+ if AppendCategoryEnhanceSystemOverlay("plain", "") != "plain" {
+ t.Fatal("empty category prompt must be no-op")
+ }
+ got := AppendCategoryEnhanceSystemOverlay("Retail system.", "Focus on panel tech.")
+ if !strings.Contains(got, "Retail system.") || !strings.Contains(got, `apply it to "name", "description", and "attrs"`) {
+ t.Fatalf("expected category system overlay: %s", got)
+ }
+ if AppendCategoryEnhanceSystemOverlay(got, "again") != got {
+ t.Fatal("category overlay must be idempotent")
+ }
+ legacy := AppendCategoryEnhanceSystemOverlay("Retail.", "x")
+ // Force-inject legacy marker then upgrade.
+ legacy = "Retail.\nWhen the user message includes category guidance, apply it to BOTH \"name\" and \"description\" (tone)."
+ upgraded := AppendCategoryEnhanceSystemOverlay(legacy, "Focus")
+ if !strings.Contains(upgraded, `apply it to "name", "description", and "attrs"`) {
+ t.Fatalf("legacy overlay should upgrade to attrs: %s", upgraded)
+ }
+}
+
+func TestFormatAttributeAllowlistConstraint(t *testing.T) {
+ t.Parallel()
+ allowed := map[string]struct{}{
+ "diagonala_zaslona": {},
+ "vrsta_panela": {},
+ }
+ title := map[string]any{
+ "separator": " ",
+ "elements": []any{
+ map[string]any{"type": "variable", "value": "brand"},
+ map[string]any{"type": "variable", "value": "product_model"},
+ },
+ }
+ got := FormatAttributeAllowlistConstraint(allowed, title)
+ if !strings.Contains(got, "Allowed attribute keys") {
+ t.Fatalf("missing header: %s", got)
+ }
+ if !strings.Contains(got, "diagonala_zaslona") || !strings.Contains(got, "vrsta_panela") {
+ t.Fatalf("missing category keys: %s", got)
+ }
+ if !strings.Contains(got, "brand") || !strings.Contains(got, "product_model") {
+ t.Fatalf("missing formula keys: %s", got)
+ }
+ if FormatAttributeAllowlistConstraint(nil, nil) != "" {
+ t.Fatal("empty allowlist+formula must be no-op")
+ }
+ appended := AppendAttributeConstraints("Attrs: {{attrs}}", allowed, title)
+ if !strings.Contains(appended, "Allowed attribute keys") || !strings.HasPrefix(appended, "Attrs:") {
+ t.Fatalf("append failed: %s", appended)
+ }
+ if AppendAttributeConstraints(appended, allowed, title) != appended {
+ t.Fatal("attribute constraints must be idempotent")
+ }
+}
+
+func TestResolveProductPromptTemplates_includesAttributeAllowlist(t *testing.T) {
+ t.Parallel()
+ _, user := resolveProductPromptTemplates(ProductInput{
+ EnhanceUserTemplate: "Name: {{name}}",
+ CategoryUniqueID: "50",
+ CategoryAttrKeys: map[string]map[string]struct{}{
+ "50": {"diagonala_zaslona": {}, "vrsta_panela": {}},
+ },
+ TitleTemplate: map[string]any{
+ "separator": " ",
+ "elements": []any{
+ map[string]any{"type": "variable", "value": "brand"},
+ },
+ },
+ CategoryEnhancePrompt: "Focus on panel tech for monitors.",
+ })
+ if !strings.Contains(user, "Allowed attribute keys") {
+ t.Fatalf("missing attribute allowlist in user tpl: %s", user)
+ }
+ if !strings.Contains(user, "diagonala_zaslona") {
+ t.Fatalf("missing category attr key: %s", user)
+ }
+ if !strings.Contains(user, "Focus on panel tech") {
+ t.Fatalf("missing category overlay: %s", user)
+ }
+}
+
func TestDescriptionSatisfiesFormula(t *testing.T) {
t.Parallel()
tpl := map[string]any{
@@ -119,8 +231,10 @@ func TestAppendFormulaConstraints_injectedIntoResolve(t *testing.T) {
"sections": []any{
map[string]any{"type": "p", "instructions": "Factual summary"},
},
+ "metaTitle": "Include product name and main benefit (50-60 chars)",
+ "metaDescription": "Write a short Meta Description spotlighting key specs (120-155 chars)",
}
- _, user := resolveProductPromptTemplates(ProductInput{
+ sys, user := resolveProductPromptTemplates(ProductInput{
EnhanceUserTemplate: "Name: {{name}}\nAttrs: {{attrs}}",
TitleTemplate: title,
DescriptionTemplate: desc,
@@ -134,6 +248,12 @@ func TestAppendFormulaConstraints_injectedIntoResolve(t *testing.T) {
if !strings.Contains(user, "Description formula") || !strings.Contains(user, "- p: Factual summary") {
t.Fatalf("missing description formula: %s", user)
}
+ if !strings.Contains(user, "SEO meta formula") || !strings.Contains(user, "meta_title (50-60 chars)") {
+ t.Fatalf("missing SEO meta formula: %s", user)
+ }
+ if !strings.Contains(sys, "SEO meta formula") {
+ t.Fatalf("system missing meta override: %s", sys)
+ }
// Render substitutes {{language}} in formula blocks.
_, rendered := RenderProductEnhancePrompts(
"Write in {{language}}.", user,
@@ -148,6 +268,29 @@ func TestAppendFormulaConstraints_injectedIntoResolve(t *testing.T) {
}
}
+func TestFormatMetaFormulaConstraint(t *testing.T) {
+ t.Parallel()
+ got := FormatMetaFormulaConstraint(map[string]any{
+ "metaTitle": "Create a concise Meta Title with product name",
+ "metaDescription": "Write a short Meta Description for search results",
+ "sections": []any{
+ map[string]any{"type": "p", "instructions": "Body"},
+ },
+ })
+ if !strings.Contains(got, "SEO meta formula") {
+ t.Fatalf("missing header: %s", got)
+ }
+ if !strings.Contains(got, "meta_title (50-60 chars): Create a concise Meta Title") {
+ t.Fatalf("missing meta_title: %s", got)
+ }
+ if !strings.Contains(got, "meta_description (120-155 chars): Write a short Meta Description") {
+ t.Fatalf("missing meta_description: %s", got)
+ }
+ if FormatMetaFormulaConstraint(map[string]any{"sections": []any{}}) != "" {
+ t.Fatal("sections-only template must not invent SEO meta formula")
+ }
+}
+
func TestCategoryFormulasFor_explicitWins(t *testing.T) {
t.Parallel()
in := ProductInput{
diff --git a/apps/api/internal/processing/legacy_convert.go b/apps/api/internal/processing/legacy_convert.go
index f2b654c..7f91347 100644
--- a/apps/api/internal/processing/legacy_convert.go
+++ b/apps/api/internal/processing/legacy_convert.go
@@ -19,7 +19,8 @@ func isDescriptionFieldKey(key string) bool {
// PlainDescriptionFromAny coerces legacy description shapes to a single plain
// string: string, {#text}, or arrays of those (joined with newlines). HTML is
-// stripped via v1PlainDescription so poll/store never keep ["…"] or markup blobs.
+// stripped via v1PlainDescription — use for meta/SEO and feed normalize only.
+// V1 process poll / formula bodies use DescriptionFromAny (keeps markup).
func PlainDescriptionFromAny(v any) string {
if v == nil {
return ""
@@ -59,6 +60,49 @@ func PlainDescriptionFromAny(v any) string {
}
}
+// DescriptionFromAny coerces legacy description shapes to a single string while
+// preserving formula HTML (h1/h2/p/ul/…). Unwraps JSON arrays / #text wrappers
+// but does not strip tags — used by V1 process item projection and catalog
+// normalize when enhance/formula HTML must reach clients and stay in DB.
+func DescriptionFromAny(v any) string {
+ if v == nil {
+ return ""
+ }
+ switch t := v.(type) {
+ case string:
+ return v1PreserveDescription(t)
+ case []string:
+ parts := make([]string, 0, len(t))
+ for _, s := range t {
+ if p := v1PreserveDescription(s); p != "" {
+ parts = append(parts, p)
+ }
+ }
+ return strings.Join(parts, "\n")
+ case []any:
+ parts := make([]string, 0, len(t))
+ for _, item := range t {
+ if p := DescriptionFromAny(item); p != "" {
+ parts = append(parts, p)
+ }
+ }
+ return strings.Join(parts, "\n")
+ case map[string]any:
+ for _, k := range []string{"#text", "text", "value", "description", "body"} {
+ if p := DescriptionFromAny(t[k]); p != "" {
+ return p
+ }
+ }
+ return ""
+ default:
+ s := strings.TrimSpace(fmt.Sprint(t))
+ if s == "" || s == "
World", "Second"})
@@ -16,6 +19,24 @@ func TestPlainDescriptionFromAny_arrayAndHTML(t *testing.T) {
}
}
+func TestDescriptionFromAny_preservesFormulaHTML(t *testing.T) {
+ htmlDesc := `Anker Soundcore Space One Pro
`
+ got := DescriptionFromAny(htmlDesc)
+ if !strings.Contains(got, "") || !strings.Contains(got, "
") {
+ t.Fatalf("expected formula HTML preserved, got %q", got)
+ }
+ // Array unwrap should still keep tags.
+ got = DescriptionFromAny([]any{htmlDesc})
+ if !strings.Contains(got, "
") || !strings.Contains(got, "
") {
+ t.Fatalf("PlainDescriptionFromAny should strip tags, got %q", plain)
+ }
+}
+
func TestNormalizeMapped_descriptionArrayBecomesPlainString(t *testing.T) {
got := NormalizeMapped(map[string]any{
"description": []any{"Line1 Camel desc with enough characters for a real SEO snippet about the product. Durable widget for everyday use. Zložljive ANC slušalke z bogatim zvokom. ", " ") {
+ t.Fatalf("meta_description should be plain, got %q", md)
+ }
+}
diff --git a/apps/api/internal/seo/templates.go b/apps/api/internal/seo/templates.go
index 1002272..aa33aa1 100644
--- a/apps/api/internal/seo/templates.go
+++ b/apps/api/internal/seo/templates.go
@@ -81,7 +81,7 @@ func FillMetaAI(ctx context.Context, completer processing.Completer, p ProductIn
system := strings.TrimSpace(aiprompts.Render(sysTpl, vars))
user := strings.TrimSpace(aiprompts.Render(userTpl, vars))
if user == "" {
- user = fmt.Sprintf("Name: %s\nCategory: %s\nDesc: %s",
+ user = fmt.Sprintf("Name: %s\nCategory: %s\nDescription: %s",
name, p.Category, truncateRunes(desc, processing.MaxProductDescRunes))
}
A", "Line2"},
diff --git a/apps/api/internal/processing/llm_json.go b/apps/api/internal/processing/llm_json.go
index 86558ff..e83c927 100644
--- a/apps/api/internal/processing/llm_json.go
+++ b/apps/api/internal/processing/llm_json.go
@@ -212,7 +212,7 @@ func ProductEnhanceUser(category, name, description string, attrs map[string]any
b.WriteString(SanitizeText(category))
b.WriteString("\nName: ")
b.WriteString(SanitizeText(truncateRunes(name, 200)))
- b.WriteString("\nDesc: ")
+ b.WriteString("\nDescription: ")
b.WriteString(SanitizeText(truncateRunes(description, MaxProductDescRunes)))
compact := CompactAttrs(attrs, MaxAttrKeys)
if len(compact) > 0 {
diff --git a/apps/api/internal/processing/meta.go b/apps/api/internal/processing/meta.go
index 4f4c3ac..54df67a 100644
--- a/apps/api/internal/processing/meta.go
+++ b/apps/api/internal/processing/meta.go
@@ -1,6 +1,7 @@
package processing
import (
+ "fmt"
"regexp"
"strings"
"unicode"
@@ -148,3 +149,58 @@ func metaBrandFromAttrs(bags ...map[string]any) string {
}
return ""
}
+
+// metaFieldsFromEnhanceObj extracts plain SEO meta from an enhance JSON object.
+// Accepts snake_case and camelCase keys (legacy A1 Acme Widget Pro
") {
+ t.Fatalf("description should stay HTML: %q", out.ProcessedDescription)
+ }
+ if strings.Contains(out.MetaDescription, "<") {
+ t.Fatalf("meta_description must be plain: %q", out.MetaDescription)
+ }
+}
+
func TestV1PollMetaFallback_usesTemplateWhenEmpty(t *testing.T) {
t.Parallel()
title := "Cordless Drill"
diff --git a/apps/api/internal/processing/openai.go b/apps/api/internal/processing/openai.go
index e72648d..5137ee5 100644
--- a/apps/api/internal/processing/openai.go
+++ b/apps/api/internal/processing/openai.go
@@ -663,7 +663,19 @@ func (h HeuristicCompleter) Complete(_ context.Context, system, user string) (Co
if desc == "" || descriptionEchoesTitle(desc, name) || isWeakPriorEnhanceDescription(desc, name) {
desc = inventHeuristicDescription(system, user, name)
}
- b, _ := json.Marshal(map[string]string{"name": name, "description": desc})
+ payload := map[string]any{"name": name, "description": desc}
+ if strings.Contains(systemL, "meta_title") {
+ payload["meta_title"] = truncateRunes(name, 60)
+ payload["meta_description"] = truncateRunes(stripMetaTags(desc), 155)
+ }
+ if strings.Contains(systemL, `"attrs"`) {
+ if attrs := CompactAttrs(parseAttrsFromPrompt(user), MaxAttrKeys); len(attrs) > 0 {
+ payload["attrs"] = attrs
+ } else {
+ payload["attrs"] = map[string]any{}
+ }
+ }
+ b, _ := json.Marshal(payload)
text = string(b)
case strings.Contains(systemL, "attributes") && strings.Contains(systemL, "json"):
text = `{"material":"unknown","brand":"unknown"}`
diff --git a/apps/api/internal/processing/pipeline.go b/apps/api/internal/processing/pipeline.go
index 8f4f03b..6ebc0d2 100644
--- a/apps/api/internal/processing/pipeline.go
+++ b/apps/api/internal/processing/pipeline.go
@@ -1591,8 +1591,32 @@ func (p *Pipeline) processOne(ctx context.Context, companyID, jobID uuid.UUID, i
if len(result.ProcessedAttributes) == 0 {
result.ProcessedAttributes = result.Attributes
}
- // Free template SEO meta (no FillMetaAI / no extra credits).
- result.MetaTitle, result.MetaDescription = fillMetaFromResult(result)
+ // Free template SEO meta when enhance did not emit meta_* (no FillMetaAI / no extra credits).
+ if strings.TrimSpace(result.MetaTitle) == "" || strings.TrimSpace(result.MetaDescription) == "" {
+ mt, md := fillMetaFromResult(result)
+ if strings.TrimSpace(result.MetaTitle) == "" {
+ result.MetaTitle = mt
+ }
+ if strings.TrimSpace(result.MetaDescription) == "" {
+ result.MetaDescription = md
+ }
+ }
+ // Keep primary localized meta in sync with row-level fields used by upsert.
+ if primary := company.NormalizeLanguage(language); primary != "" && result.LocalizedContent != nil {
+ lf := company.FieldsForLanguage(result.LocalizedContent, primary)
+ changed := false
+ if lf.MetaTitle == "" && result.MetaTitle != "" {
+ lf.MetaTitle = result.MetaTitle
+ changed = true
+ }
+ if lf.MetaDescription == "" && result.MetaDescription != "" {
+ lf.MetaDescription = result.MetaDescription
+ changed = true
+ }
+ if changed {
+ result.LocalizedContent[primary] = lf
+ }
+ }
attrsJSON, procAttrsJSON, gptJSON, sourcesJSON, err := marshalProcessOnePayload(result)
if err != nil {
diff --git a/apps/api/internal/processing/prompt_fallback_test.go b/apps/api/internal/processing/prompt_fallback_test.go
index 342d229..1c1a038 100644
--- a/apps/api/internal/processing/prompt_fallback_test.go
+++ b/apps/api/internal/processing/prompt_fallback_test.go
@@ -17,12 +17,18 @@ func TestResolvePromptFallbackChain(t *testing.T) {
CategoryEnhancePrompt: "cat-sl",
Language: "sl",
})
- if sys != "sys" {
- t.Fatalf("sys=%q", sys)
+ if !strings.Contains(sys, "sys") {
+ t.Fatalf("sys=%q want company system kept", sys)
}
- if !strings.HasPrefix(user, "cat-sl") || !strings.Contains(user, "{{attrs}}") {
+ if !strings.Contains(sys, `apply it to "name", "description", and "attrs"`) {
+ t.Fatalf("sys=%q want category overlay for title+description+attrs", sys)
+ }
+ if !strings.Contains(user, "cat-sl") || !strings.Contains(user, "{{attrs}}") {
t.Fatalf("sys=%q user=%q want cat-sl + attrs", sys, user)
}
+ if !strings.Contains(user, "applies to name, description, and attrs") {
+ t.Fatalf("user=%q want title framing", user)
+ }
// Empty category → company template.
_, user = resolveProductPromptTemplates(ProductInput{
diff --git a/apps/api/internal/processing/prompt_render.go b/apps/api/internal/processing/prompt_render.go
index b002dbe..82388a2 100644
--- a/apps/api/internal/processing/prompt_render.go
+++ b/apps/api/internal/processing/prompt_render.go
@@ -10,9 +10,10 @@ import (
func resolveProductPromptTemplates(in ProductInput) (systemTpl, userTpl string) {
systemTpl = strings.TrimSpace(in.EnhanceSystemTemplate)
userTpl = strings.TrimSpace(in.EnhanceUserTemplate)
+ catPrompt := strings.TrimSpace(in.CategoryEnhancePrompt)
// Per-category prompt wins for the user message (company system keeps JSON schema / brand).
- if cat := strings.TrimSpace(in.CategoryEnhancePrompt); cat != "" {
- userTpl = ensureCategoryEnhanceUserContext(cat)
+ if catPrompt != "" {
+ userTpl = ensureCategoryEnhanceUserContext(catPrompt)
}
def, ok := aiprompts.DefaultFor(aiprompts.KeyProductEnhance)
if ok {
@@ -25,9 +26,17 @@ 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)
+ // Category attribute allowlist + title-formula keys guide JSON "attrs" extraction.
+ allowed := enhanceAllowedAttrKeys(in, in.CategoryUniqueID)
+ userTpl = AppendAttributeConstraints(userTpl, allowed, in.TitleTemplate)
// Company/built-in system prompts often say "1-2 sentences"; when a category
// description formula exists, override that so process matches A1 category defs.
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)
+ // categories.prompt applies to name, description, and attrs (not description-only).
+ systemTpl = AppendCategoryEnhanceSystemOverlay(systemTpl, catPrompt)
return systemTpl, userTpl
}
@@ -45,12 +54,36 @@ func templateHasVar(tpl, name string) bool {
return false
}
-// ensureCategoryEnhanceUserContext appends standard product context placeholders when a
-// category override omits {{attrs}} (common in DB category prompts). Does not rewrite
-// category copy that already includes attrs.
+// categoryEnhanceUserOverlayPrefix frames free-form categories.prompt text so the
+// model applies it to name, description, and attrs (not description alone). Canonical
+// CategoryEnhanceUserTemplate already covers all roles — skip double-framing there.
+const categoryEnhanceUserOverlayPrefix = "Category guidance (applies to name, description, and attrs; obey Title/Description formulas and Allowed attribute keys when present):\n"
+
+func categoryEnhancePromptAlreadyCoversTitle(userTpl string) bool {
+ lower := strings.ToLower(userTpl)
+ if strings.Contains(lower, "applies to name, description, and attrs") {
+ return true
+ }
+ if strings.Contains(lower, "applies to name and description") {
+ return true
+ }
+ // Repaired / built-in overlay: explicit name + Title formula bullets / role sections.
+ return strings.Contains(lower, `"name"`) && (strings.Contains(lower, "title formula") || strings.Contains(lower, "--- title ---"))
+}
+
+// ensureCategoryEnhanceUserContext frames category overlay for title+description,
+// then appends standard product context placeholders when a category override omits
+// {{attrs}} (common in DB category prompts). Does not rewrite category copy that
+// already includes attrs (aside from optional title framing).
func ensureCategoryEnhanceUserContext(userTpl string) string {
userTpl = strings.TrimSpace(userTpl)
- if userTpl == "" || templateHasVar(userTpl, "attrs") {
+ if userTpl == "" {
+ return userTpl
+ }
+ if !categoryEnhancePromptAlreadyCoversTitle(userTpl) {
+ userTpl = categoryEnhanceUserOverlayPrefix + userTpl
+ }
+ if templateHasVar(userTpl, "attrs") {
return userTpl
}
var b strings.Builder
@@ -71,7 +104,7 @@ func ensureCategoryEnhanceUserContext(userTpl string) string {
appendLine("Name: {{name}}")
}
if !templateHasVar(userTpl, "description") {
- appendLine("Desc: {{description}}")
+ appendLine("Description: {{description}}")
}
appendLine("Attrs: {{attrs}}")
return b.String()
diff --git a/apps/api/internal/processing/prompt_render_test.go b/apps/api/internal/processing/prompt_render_test.go
index 9a18e51..75812d3 100644
--- a/apps/api/internal/processing/prompt_render_test.go
+++ b/apps/api/internal/processing/prompt_render_test.go
@@ -15,11 +15,17 @@ func TestResolveProductPromptTemplates_categoryOverridesUser(t *testing.T) {
EnhanceUserTemplate: "company user",
CategoryEnhancePrompt: "category user {{description}}",
})
- if sys != "sys {{brand_voice}}" {
- t.Fatalf("system=%q", sys)
+ if !strings.Contains(sys, "sys {{brand_voice}}") {
+ t.Fatalf("system=%q want company system kept", sys)
}
- if !strings.HasPrefix(user, "category user {{description}}") {
- t.Fatalf("user=%q want category override prefix", user)
+ if !strings.Contains(sys, `apply it to "name", "description", and "attrs"`) {
+ t.Fatalf("system=%q want category title+description+attrs overlay", sys)
+ }
+ if !strings.Contains(user, "category user {{description}}") {
+ t.Fatalf("user=%q want category override text", user)
+ }
+ if !strings.Contains(user, "applies to name, description, and attrs") {
+ t.Fatalf("user=%q want title+description+attrs framing", user)
}
if !strings.Contains(user, "{{attrs}}") {
t.Fatalf("user=%q want injected {{attrs}}", user)
@@ -34,8 +40,68 @@ func TestResolveProductPromptTemplates_categoryWithAttrsUnchanged(t *testing.T)
_, user := resolveProductPromptTemplates(ProductInput{
CategoryEnhancePrompt: "Write copy.\nAttrs: {{attrs}}\nName: {{name}}",
})
- if user != "Write copy.\nAttrs: {{attrs}}\nName: {{name}}" {
- t.Fatalf("user=%q want unchanged when attrs present", user)
+ if !strings.Contains(user, "Write copy.\nAttrs: {{attrs}}\nName: {{name}}") {
+ t.Fatalf("user=%q want category text preserved when attrs present", user)
+ }
+ if !strings.Contains(user, "applies to name, description, and attrs") {
+ t.Fatalf("user=%q want title framing on free-form overlay", user)
+ }
+}
+
+func TestResolveProductPromptTemplates_categoryCanonicalSkipsFrame(t *testing.T) {
+ t.Parallel()
+ _, user := resolveProductPromptTemplates(ProductInput{
+ CategoryEnhancePrompt: aiprompts.CategoryEnhanceUserTemplate,
+ })
+ if strings.Count(user, "applies to name, description, and attrs") != 0 ||
+ strings.Count(user, "applies to name and description") != 0 {
+ t.Fatalf("canonical template must not get double framing: %q", user)
+ }
+ if !strings.Contains(user, "Title formula") || !strings.Contains(user, `"name"`) {
+ t.Fatalf("canonical template should retain name guidance: %q", user)
+ }
+}
+
+func TestResolveProductPromptTemplates_categoryWithTitleFormula(t *testing.T) {
+ t.Parallel()
+ title := map[string]any{
+ "separator": " ",
+ "elements": []any{
+ map[string]any{"type": "variable", "value": "brand"},
+ map[string]any{"type": "variable", "value": "product_model"},
+ },
+ }
+ desc := map[string]any{
+ "sections": []any{
+ map[string]any{"type": "p", "instructions": "Factual summary"},
+ },
+ }
+ sys, user := resolveProductPromptTemplates(ProductInput{
+ EnhanceSystemTemplate: "Retail copywriter.\n- name: short retail title\n- description: 1-2 sentences",
+ CategoryEnhancePrompt: "Emphasize energy class and Slovenian retail tone.",
+ TitleTemplate: title,
+ DescriptionTemplate: desc,
+ })
+ if !strings.Contains(user, "Emphasize energy class") {
+ t.Fatalf("missing category prompt: %s", user)
+ }
+ if !strings.Contains(user, "applies to name, description, and attrs") {
+ t.Fatalf("missing category title framing: %s", user)
+ }
+ if !strings.Contains(user, "Title formula") || !strings.Contains(user, "attr [brand]") {
+ t.Fatalf("missing title formula: %s", user)
+ }
+ if !strings.Contains(user, "Description formula") || !strings.Contains(user, "- p: Factual summary") {
+ t.Fatalf("missing description formula: %s", user)
+ }
+ if !strings.Contains(sys, `When the user message includes a "Title formula"`) {
+ t.Fatalf("missing title system override: %s", sys)
+ }
+ if !strings.Contains(sys, "Description formula") {
+ t.Fatalf("missing description system override: %s", sys)
+ }
+ if !strings.Contains(sys, `apply it to "name", "description", and "attrs"`) {
+ t.Fatalf("missing category system overlay: %s", sys)
}
}
diff --git a/apps/api/internal/processing/sanitize.go b/apps/api/internal/processing/sanitize.go
index 69387d0..267fdd4 100644
--- a/apps/api/internal/processing/sanitize.go
+++ b/apps/api/internal/processing/sanitize.go
@@ -10,6 +10,11 @@ import (
const maxPromptFieldRunes = 4000
+// maxOutputFieldRunes bounds stored / polled model text (titles + multi-section
+// formula HTML descriptions). Must stay well above MaxTokensEnhance (~16k tokens)
+// so SanitizeOutput does not chop JSON completion bodies or A1 HTML mid-string.
+const maxOutputFieldRunes = 120000
+
var controlOrInject = regexp.MustCompile(`(?i)(ignore\s+(all\s+)?(previous|prior|above)|disregard\s+(all\s+)?(previous|prior)|forget\s+(all\s+)?(previous|prior)|system\s*:|assistant\s*:|<\s*/?\s*script)`)
// SanitizeText strips control chars, truncates, and soft-neutralizes prompt-injection phrases.
@@ -31,6 +36,8 @@ func SanitizeText(s string) string {
}
// SanitizeOutput keeps model text printable and bounded for storage/UI.
+// Uses a higher rune cap than SanitizeText so formula HTML and full enhance
+// JSON completions are not truncated at 4k.
func SanitizeOutput(s string) string {
s = strings.TrimSpace(s)
if s == "" {
@@ -43,7 +50,7 @@ func SanitizeOutput(s string) string {
b.WriteRune(r)
}
}
- return truncateRunes(b.String(), maxPromptFieldRunes)
+ return truncateRunes(b.String(), maxOutputFieldRunes)
}
func truncateRunes(s string, max int) string {
diff --git a/apps/api/internal/processing/steps.go b/apps/api/internal/processing/steps.go
index cdecef3..93dcac7 100644
--- a/apps/api/internal/processing/steps.go
+++ b/apps/api/internal/processing/steps.go
@@ -303,6 +303,8 @@ func (e *Engine) RunSteps(ctx context.Context, companyID string, in ProductInput
CategoryEnhancePrompt: catPrompt,
TitleTemplate: titleTpl,
DescriptionTemplate: descTpl,
+ AllowedAttrKeys: in.AllowedAttrKeys,
+ CategoryAttrKeys: in.CategoryAttrKeys,
PriorEnhanceHash: priorHash,
PriorProcessedName: priorName,
PriorProcessedDescription: priorDesc,
@@ -355,6 +357,7 @@ func (e *Engine) RunSteps(ctx context.Context, companyID string, in ProductInput
}
}
weakDesc := descriptionNeedsEnhanceRepair(desc, descTpl, name)
+ enhanceMetaTitle, enhanceMetaDesc := enhanceMetaFromRaw(raw)
// Only persist enhance_input_hash for quality ok / hash-skip unchanged.
// Never copy input_hash from error/passthrough/thin/synthesized meta.
persistHash := ""
@@ -380,8 +383,8 @@ func (e *Engine) RunSteps(ctx context.Context, companyID string, in ProductInput
ProcessedName: name,
ProcessedDescription: desc,
EnhanceInputHash: persistHash,
- MetaTitle: company.FieldsForLanguage(localized, lang).MetaTitle,
- MetaDescription: company.FieldsForLanguage(localized, lang).MetaDescription,
+ MetaTitle: enhanceMetaTitle,
+ MetaDescription: enhanceMetaDesc,
}
// Preserve existing meta when re-enhancing titles only.
// Never keep a bare "|
/entity markup.
+// string (meta/SEO, feed normalize). Handles JSON array/string encodings and
+// strips HTML/
/entity markup. Do not use for V1 item.description when formula
+// HTML must be preserved — use v1PreserveDescription / DescriptionFromAny.
func v1PlainDescription(s string) string {
s = strings.TrimSpace(s)
if s == "" {
diff --git a/apps/api/internal/processing/v1_process_item.go b/apps/api/internal/processing/v1_process_item.go
index 8aba772..ae445dc 100644
--- a/apps/api/internal/processing/v1_process_item.go
+++ b/apps/api/internal/processing/v1_process_item.go
@@ -183,9 +183,9 @@ func ScoreV1ProcessCompletedItem(item V1ProcessJobItem, opts ScoreV1ProcessItemO
}
// EnforceV1ProcessCompletedItem fills LegacyProcessItem projection gaps for a
-// successful item: plain nonempty description when title exists, meta_*, clean
-// attributes, eprel object|null, and image key shapes. Category must already be
-// set by the caller when mapped provides a unique_id.
+// successful item: nonempty description when title exists (formula HTML
+// preserved), meta_*, clean attributes, eprel object|null, and image key shapes.
+// Category must already be set by the caller when mapped provides a unique_id.
//
// allowed is the company attribute_key set (canonicalized). When nil, only
// coreCharacteristicAttrKeys are kept (never leak feed junk like zavora).
@@ -224,8 +224,9 @@ func EnforceV1ProcessCompletedItem(item V1ProcessJobItem, language string, allow
catLabel = cat
}
- desc, _ := plainDescriptionFromItem(item)
+ desc, _ := descriptionFromItem(item)
// Empty, weak, or title-echo copy must be replaced — never leave description==title.
+ // Formula HTML that satisfies multi-section templates is kept as-is.
if title != "" && (desc == "" || isWeakPriorEnhanceDescription(desc, title) || descriptionEchoesTitle(desc, title)) {
if synth := synthesizeDescriptionFromTitle(title, catLabel, language, attrs); synth != "" {
desc = synth
@@ -324,6 +325,38 @@ func stringFromItem(item V1ProcessJobItem, key string) string {
return strings.TrimSpace(stringFromAny(item[key]))
}
+func descriptionFromItem(item V1ProcessJobItem) (string, bool) {
+ if item == nil {
+ return "", true
+ }
+ raw := item["description"]
+ if raw == nil {
+ return "", true
+ }
+ switch raw.(type) {
+ case string:
+ return DescriptionFromAny(raw), true
+ case []any, []string:
+ // Legacy mistake: description as array — coerce to string, keep HTML.
+ if s := DescriptionFromAny(raw); s != "" {
+ return s, true
+ }
+ return "", false
+ case map[string]any:
+ if s := DescriptionFromAny(raw); s != "" {
+ return s, true
+ }
+ return "", false
+ default:
+ s := strings.TrimSpace(fmt.Sprint(raw))
+ if s == "" || s == "Anker Soundcore Space One Pro
`
+ item := V1ProcessJobItem{
+ "ean": "1",
+ "status": "processed",
+ "id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
+ "processed_product_id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
+ "raw_product_id": "cccccccc-cccc-cccc-cccc-cccccccccccc",
+ "title": "Anker Soundcore Space One Pro",
+ "description": htmlDesc,
+ "category": "48",
+ "category_name": "Slušalke",
+ "attributes": map[string]any{"brand": "Anker"},
+ "eprel": nil,
+ }
+ out := EnforceV1ProcessCompletedItem(item, "sl", nil)
+ desc, _ := out["description"].(string)
+ for _, tag := range []string{"", "
"} {
+ if !strings.Contains(desc, tag) {
+ t.Fatalf("EnforceV1 must keep formula HTML %q, got %q", tag, desc)
+ }
+ }
+ // meta_description must stay plain (no tags).
+ md := fmt.Sprint(out["meta_description"])
+ if strings.Contains(md, "
") || strings.Contains(md, "