diff --git a/apps/api/cmd/seed-a1/category_prompts.go b/apps/api/cmd/seed-a1/category_prompts.go index f241827..dc625fe 100644 --- a/apps/api/cmd/seed-a1/category_prompts.go +++ b/apps/api/cmd/seed-a1/category_prompts.go @@ -148,9 +148,10 @@ func applyCategoryPrompts(ctx context.Context, pg *pgxpool.Pool, companyID uuid. byNorm := make(map[string]string, len(file.Entries)) byUnique := make(map[string]string, len(file.Entries)) - // Overlay sets the shared JSON-compatible enhance user template (not legacy HTML). + // Overlay sets the shared role-sectioned enhance user template + // (aiprompts.CategoryEnhanceUserTemplate — title/description/meta/attributes). // Seed JSON selects which categories get a prompt (prefer unique_id, else name). - // Unique title/description formulas stay in title_template / description_template. + // Unique title/description/meta formulas stay in title_template / description_template. // Stored under "sl" + "*" so prompt->>'sl' and LangPromptAny both resolve; // language stays via {{language}}. canonical := prepareCategoryPrompt(aiprompts.CategoryEnhanceUserTemplate) diff --git a/apps/api/internal/aiprompts/kinds.go b/apps/api/internal/aiprompts/kinds.go index 92d2980..bdae133 100644 --- a/apps/api/internal/aiprompts/kinds.go +++ b/apps/api/internal/aiprompts/kinds.go @@ -26,7 +26,9 @@ type Variable struct { // Catalog of supported {{variables}} (only these are substituted; unknown tokens stay literal). var VariableCatalog = []Variable{ {Name: "name", Label: "Product name", Description: "Current product title", Keys: []string{KeyProductEnhance, KeySEOMeta}}, - {Name: "description", Label: "Description", Description: "Current product description", Keys: []string{KeyProductEnhance, KeySEOMeta}}, + // description = product HTML body input (not SEO meta_description). Template key + // stays "description" so stored tenant prompts keep working (renaming would be BREAKING). + {Name: "description", Label: "Product description", Description: "Current product description (HTML body). Not SEO meta_description.", Keys: []string{KeyProductEnhance, KeySEOMeta}}, {Name: "category", Label: "Category", Description: "Resolved category name", Keys: []string{KeyProductEnhance, KeySEOMeta}}, {Name: "attrs", Label: "Attributes", Description: "Compact JSON of product attributes", Keys: []string{KeyProductEnhance}}, {Name: "gtin", Label: "GTIN", Description: "Product GTIN / barcode when present", Keys: []string{KeyProductEnhance}}, @@ -48,20 +50,38 @@ type DefaultTemplate struct { } // CategoryEnhanceUserTemplate is the shared per-category (and built-in) enhance USER -// message. Includes {{name}} {{description}} {{attrs}} {{category}} {{language}}. -// Compatible with the enhance system JSON schema {"name","description"} — not a -// competing HTML marketing document. Title/description formulas stay in -// title_template / description_template and are appended at render time as plain -// text instructions (see processing.AppendFormulaConstraints). -// Used by local A1/Demo prompt repair and seed-a1 overlays. -const CategoryEnhanceUserTemplate = `Your reply is parsed as JSON {"name":"string","description":"string"} only (system schema). Write name and description in {{language}} (do not hardcode a language). -- name: short retail title; follow any Title formula constraints that follow; use Attrs -- description: when a Description formula follows, emit ONE HTML string covering each section in order (tags matching type: h1/h2/h3/h4, p, ul); otherwise prefer 1-3 factual paragraphs as ONE string with limited HTML (
Durable widget for everyday use. Clear specs, ready to ship.
","meta_title":"Acme Widget Pro | Durable Daily Use","meta_description":"Shop Acme Widget Pro for reliable everyday performance. Clear specs and fast delivery.","attrs":{"brand":"Acme","product_model":"Widget Pro"}} {{brand_voice}}`, UserTemplate: CategoryEnhanceUserTemplate, }, { Key: KeySEOMeta, Label: "SEO meta title & description", - Description: "Used when applying AI SEO meta to a product.", + Description: "Used when applying AI SEO meta to a product (standalone path). Product enhance already emits meta_* when run.", SystemTemplate: `SEO meta writer for ecommerce. Rules: - Reply with ONLY JSON (no markdown) - Schema: {"meta_title":"string","meta_description":"string"} - meta_title: 50-60 chars, product + benefit -- meta_description: 120-155 chars, factual +- meta_description: 120-155 chars, factual plain text (not HTML product description) - Write meta_title and meta_description in {{language}} Example: {"meta_title":"Acme Widget Pro | Durable Daily Use","meta_description":"Shop Acme Widget Pro for reliable everyday performance. Clear specs and fast delivery."} {{brand_voice}}`, - UserTemplate: `Name: {{name}} + UserTemplate: `--- Meta --- +Role: meta. Reply with JSON meta_title and meta_description only (standalone seo_meta path). +Name: {{name}} Category: {{category}} -Desc: {{description}}`, +Description: {{description}} +--- End Meta ---`, }, { Key: KeyCampaignEmail, diff --git a/apps/api/internal/aiprompts/kinds_test.go b/apps/api/internal/aiprompts/kinds_test.go index 1392fc0..c5e945a 100644 --- a/apps/api/internal/aiprompts/kinds_test.go +++ b/apps/api/internal/aiprompts/kinds_test.go @@ -22,8 +22,14 @@ func TestCategoryEnhanceUserTemplateHasRequiredVars(t *testing.T) { if strings.Contains(lower, "100 besed") || strings.Contains(lower, "gpt predloga") { t.Fatal("template must not demand legacy long HTML marketing docs") } - if !strings.Contains(CategoryEnhanceUserTemplate, `{"name":"string","description":"string"}`) { - t.Fatal("template should reference JSON schema shape") + if !strings.Contains(CategoryEnhanceUserTemplate, `{"name":"string","description":"string","meta_title":"string","meta_description":"string","attrs":{}}`) { + t.Fatal("template should reference enhance JSON schema including meta_* and attrs") + } + if !strings.Contains(strings.ToLower(CategoryEnhanceUserTemplate), `build json "attrs"`) { + t.Fatal("Attributes role must ask for JSON attrs extraction/enhancement") + } + if !CategoryEnhanceHasRoleSections(CategoryEnhanceUserTemplate) { + t.Fatal("canonical template must be role-sectioned for title/description/meta/attributes") } if !strings.Contains(lower, "{{language}}") { t.Fatal("language must come from {{language}}") diff --git a/apps/api/internal/aiprompts/roles.go b/apps/api/internal/aiprompts/roles.go new file mode 100644 index 0000000..24c233e --- /dev/null +++ b/apps/api/internal/aiprompts/roles.go @@ -0,0 +1,51 @@ +package aiprompts + +import "strings" + +// Pipeline prompt roles mirror legacy Descrybe steps (categorize → title → +// description[+meta] → attributes). Categorize stays a separate Completer call +// (ProductCategorize*). category.prompt (CategoryEnhanceUserTemplate) is the +// shared enhance USER overlay, sectioned for title/description/meta/attrs so +// seed/repair and title/meta/attrs formula work (AppendFormulaConstraints) share +// one skeleton. Enhance JSON includes meta_* (legacy bundled meta); KeySEOMeta +// remains the standalone SEO path. +const ( + RoleCategorize = "categorize" // pick taxonomy unique_id (processing.ProductCategorize*) + RoleTitle = "title" // JSON name / title formula + RoleDescription = "description" // JSON description / description formula + RoleMeta = "meta" // JSON meta_* in enhance + KeySEOMeta standalone + RoleAttributes = "attributes" // JSON attrs object + allowlist/formula key guidance +) + +// Section markers match legacy enhance-product "--- Section ---" / "--- Meta Title ---" +// style so stored category prompts stay human-readable and machine-detectable. +const ( + SectionTitleStart = "--- Title ---" + SectionTitleEnd = "--- End Title ---" + SectionDescriptionStart = "--- Description ---" + SectionDescriptionEnd = "--- End Description ---" + SectionMetaStart = "--- Meta ---" + SectionMetaEnd = "--- End Meta ---" + SectionAttributesStart = "--- Attributes ---" + SectionAttributesEnd = "--- End Attributes ---" +) + +// CategoryPromptRoles lists the role ids used across categorize + enhance + SEO. +var CategoryPromptRoles = []string{ + RoleCategorize, + RoleTitle, + RoleDescription, + RoleMeta, + RoleAttributes, +} + +// CategoryEnhanceHasRoleSections reports whether a stored categories.prompt +// value uses the structured title/description/meta/attributes section markers +// from CategoryEnhanceUserTemplate (seed/repair canonical shape). +func CategoryEnhanceHasRoleSections(prompt string) bool { + lower := strings.ToLower(prompt) + return strings.Contains(lower, strings.ToLower(SectionTitleStart)) && + strings.Contains(lower, strings.ToLower(SectionDescriptionStart)) && + strings.Contains(lower, strings.ToLower(SectionMetaStart)) && + strings.Contains(lower, strings.ToLower(SectionAttributesStart)) +} diff --git a/apps/api/internal/aiprompts/roles_test.go b/apps/api/internal/aiprompts/roles_test.go new file mode 100644 index 0000000..f86bb40 --- /dev/null +++ b/apps/api/internal/aiprompts/roles_test.go @@ -0,0 +1,79 @@ +package aiprompts + +import ( + "strings" + "testing" +) + +func TestCategoryEnhanceHasRoleSections(t *testing.T) { + t.Parallel() + if !CategoryEnhanceHasRoleSections(CategoryEnhanceUserTemplate) { + t.Fatal("canonical CategoryEnhanceUserTemplate must have title/description/meta/attributes sections") + } + if CategoryEnhanceHasRoleSections("legacy HTML prompt") { + t.Fatal("unstructured prompt must not report role sections") + } + if CategoryEnhanceHasRoleSections("--- Title ---\nonly title") { + t.Fatal("partial sections must not pass") + } +} + +func TestCategoryPromptRolesOrder(t *testing.T) { + t.Parallel() + want := []string{RoleCategorize, RoleTitle, RoleDescription, RoleMeta, RoleAttributes} + if len(CategoryPromptRoles) != len(want) { + t.Fatalf("roles len=%d want %d", len(CategoryPromptRoles), len(want)) + } + for i, w := range want { + if CategoryPromptRoles[i] != w { + t.Fatalf("roles[%d]=%q want %q", i, CategoryPromptRoles[i], w) + } + } +} + +func TestCategoryEnhanceUserTemplateRoleMarkers(t *testing.T) { + t.Parallel() + tpl := CategoryEnhanceUserTemplate + for _, marker := range []string{ + SectionTitleStart, SectionTitleEnd, + SectionDescriptionStart, SectionDescriptionEnd, + SectionMetaStart, SectionMetaEnd, + SectionAttributesStart, SectionAttributesEnd, + } { + if !strings.Contains(tpl, marker) { + t.Fatalf("template missing %q", marker) + } + } + lower := strings.ToLower(tpl) + for _, role := range []string{"role: title", "role: description", "role: meta", "role: attributes"} { + if !strings.Contains(lower, role) { + t.Fatalf("template missing %q", role) + } + } + // HeuristicCompleter / labeledPromptValue depend on these product context labels. + for _, label := range []string{"Name: {{name}}", "Description: {{description}}", "Category: {{category}}", "Attrs: {{attrs}}"} { + if !strings.Contains(tpl, label) { + t.Fatalf("template missing product label %q", label) + } + } + if !strings.Contains(tpl, "meta_title") || !strings.Contains(tpl, "meta_description") { + t.Fatal("template must keep enhance meta_* schema (parallel title/meta work)") + } + if !strings.Contains(tpl, `"attrs":{}`) { + t.Fatal("template must keep enhance attrs schema (parallel attributes work)") + } +} + +func TestSEOMetaUserTemplateHasMetaSection(t *testing.T) { + t.Parallel() + def, ok := DefaultFor(KeySEOMeta) + if !ok { + t.Fatal("missing seo_meta default") + } + if !strings.Contains(def.UserTemplate, SectionMetaStart) { + t.Fatal("seo_meta user template should use --- Meta --- section") + } + if !strings.Contains(strings.ToLower(def.UserTemplate), "role: meta") { + t.Fatal("seo_meta user template should declare Role: meta") + } +} diff --git a/apps/api/internal/catalog/fix_catalog_test.go b/apps/api/internal/catalog/fix_catalog_test.go index 5a9c47c..2696d92 100644 --- a/apps/api/internal/catalog/fix_catalog_test.go +++ b/apps/api/internal/catalog/fix_catalog_test.go @@ -14,6 +14,9 @@ func TestCategoryEnhanceTemplateHasAttrs(t *testing.T) { if !strings.Contains(tpl, "{{attrs}}") { t.Fatalf("template missing {{attrs}}: %q", tpl) } + if !aiprompts.CategoryEnhanceHasRoleSections(tpl) { + t.Fatal("repaired template must include title/description/meta/attributes sections") + } if !aiprompts.CategoryEnhancePromptNeedsRepair("legacy HTML prompt without attrs") { t.Fatal("expected legacy prompt to need repair") } diff --git a/apps/api/internal/catalog/prompt_repair.go b/apps/api/internal/catalog/prompt_repair.go index 844f809..87707a4 100644 --- a/apps/api/internal/catalog/prompt_repair.go +++ b/apps/api/internal/catalog/prompt_repair.go @@ -32,14 +32,15 @@ type RepairCategoryEnhancePromptsResult struct { } // RepairA1DemoCategoryEnhancePrompts replaces legacy HTML marketing categories.prompt -// values for A1 Slovenija + Platform Demo with aiprompts.CategoryEnhanceUserTemplate. +// values for A1 Slovenija + Platform Demo with aiprompts.CategoryEnhanceUserTemplate +// (role-sectioned title/description/meta/attributes USER overlay). // // LOCAL repair only (idempotent): // - Writes JSON-compatible user overlays under both "sl" (prompt->>'sl') and "*" // (LangPromptAny) so language stays via {{language}}, not hardcoded-only copy. // - Touches ONLY categories.prompt — never title_template / description_template -// (unique name/description formulas stay intact; AppendFormulaConstraints encodes -// them as plain-text instructions at enhance render time). +// (unique name/description/meta formulas stay intact; AppendFormulaConstraints +// encodes them as plain-text instructions at enhance render time). // - dryRun=true: count WouldUpdate only; dryRun=false: apply and set Updated. // // Entrypoint: go run ./cmd/repair-category-prompts (-dry-run | -apply). diff --git a/apps/api/internal/httpapi/v1_openapi.go b/apps/api/internal/httpapi/v1_openapi.go index 6a6e18c..f8fce6e 100644 --- a/apps/api/internal/httpapi/v1_openapi.go +++ b/apps/api/internal/httpapi/v1_openapi.go @@ -6278,9 +6278,10 @@ components: type: object description: | One COMPLETED legacy process line (A1 / public contract). Successful items - expose category as categories.unique_id, a plain-text description string - (never a JSON array; HTML stripped), SEO meta_title / meta_description, - optional eprel object or null, clean attributes, images, and dual-mode ids. + 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 + 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). required: @@ -6344,8 +6345,10 @@ components: type: string nullable: true description: | - Plain-text product body description. Always a string — never a one-element - JSON array. Feed HTML tags are stripped; newlines may remain between paragraphs. + Product body description as a string — never a one-element JSON array. + When the category description_template requires multi-section markup, + this field retains formula HTML tags (h1/h2/h3/h4, p, ul). Feed-only + plain text remains plain; meta_description stays plain SEO text. attributes: type: object additionalProperties: true diff --git a/apps/api/internal/processing/ai.go b/apps/api/internal/processing/ai.go index 789748d..f5283a0 100644 --- a/apps/api/internal/processing/ai.go +++ b/apps/api/internal/processing/ai.go @@ -89,7 +89,9 @@ type ProductInput struct { EnhanceSystemTemplate string EnhanceUserTemplate string // CategoryEnhancePrompt overrides EnhanceUserTemplate when non-empty - // (resolved for the active language before enhance). + // (resolved for the active language before enhance). Applied to BOTH name + // and description via user framing + system overlay; formulas still win + // for structure. CategoryEnhancePrompt string // CategoryPromptsByLang maps lower(name|unique_id) → lang → category override prompt // (JSON/overlay text with {{attrs}}/{{category}}; works with repaired A1 overlays). diff --git a/apps/api/internal/processing/attrs_enhance_test.go b/apps/api/internal/processing/attrs_enhance_test.go new file mode 100644 index 0000000..480938a --- /dev/null +++ b/apps/api/internal/processing/attrs_enhance_test.go @@ -0,0 +1,120 @@ +package processing + +import ( + "context" + "encoding/json" + "strings" + "testing" +) + +func TestAttrsFromEnhanceObj_andMerge(t *testing.T) { + t.Parallel() + obj := map[string]any{ + "name": "Monitor", + "attrs": map[string]any{ + "diagonala_zaslona": "27\"", + "zavora": "junk", + "brand": "Acme", + }, + } + got := attrsFromEnhanceObj(obj) + if got["brand"] != "Acme" || got["diagonala_zaslona"] != "27\"" { + t.Fatalf("attrsFromEnhanceObj=%v", got) + } + allowed := map[string]struct{}{ + "diagonala_zaslona": {}, + "vrsta_panela": {}, + } + merged := mergeEnhanceAttrsInto(map[string]any{ + "brand": "Acme", + "product_model": "X1", + }, got, allowed) + if merged["diagonala_zaslona"] != "27\"" { + t.Fatalf("expected remapped/validated diagonala, got %v", merged) + } + if _, ok := merged["zavora"]; ok { + t.Fatalf("zavora must be dropped by allowlist: %v", merged) + } + if merged["brand"] != "Acme" || merged["product_model"] != "X1" { + t.Fatalf("core/base attrs must remain: %v", merged) + } + if mergeEnhanceAttrsInto(map[string]any{"a": 1}, nil, allowed) != nil { + t.Fatal("empty llm attrs must yield nil (no change)") + } +} + +func TestEnhanceAttrsFromRaw_roundTrip(t *testing.T) { + t.Parallel() + meta := map[string]any{"status": "ok"} + attachEnhanceAttrs(meta, map[string]any{"brand": "Bosch", "nosilnost": "40 kg"}) + got := enhanceAttrsFromRaw(meta) + if got["brand"] != "Bosch" || got["nosilnost"] != "40 kg" { + t.Fatalf("round-trip attrs=%v", got) + } +} + +func TestRunSteps_enhanceMergesValidatedAttrs(t *testing.T) { + t.Parallel() + payload := mustJSON(map[string]any{ + "name": "Acme UltraView 27", + "description": "27 inch IPS monitor for desk work with clear specs.
Zložljive ANC slušalke.
") || !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, "