This commit is contained in:
2026-08-16 23:42:00 +02:00
parent 6e77154228
commit 92f046b542
32 changed files with 1443 additions and 105 deletions
+3 -2
View File
@@ -148,9 +148,10 @@ func applyCategoryPrompts(ctx context.Context, pg *pgxpool.Pool, companyID uuid.
byNorm := make(map[string]string, len(file.Entries)) byNorm := make(map[string]string, len(file.Entries))
byUnique := 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). // 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; // Stored under "sl" + "*" so prompt->>'sl' and LangPromptAny both resolve;
// language stays via {{language}}. // language stays via {{language}}.
canonical := prepareCategoryPrompt(aiprompts.CategoryEnhanceUserTemplate) canonical := prepareCategoryPrompt(aiprompts.CategoryEnhanceUserTemplate)
+50 -24
View File
@@ -26,7 +26,9 @@ type Variable struct {
// Catalog of supported {{variables}} (only these are substituted; unknown tokens stay literal). // Catalog of supported {{variables}} (only these are substituted; unknown tokens stay literal).
var VariableCatalog = []Variable{ var VariableCatalog = []Variable{
{Name: "name", Label: "Product name", Description: "Current product title", Keys: []string{KeyProductEnhance, KeySEOMeta}}, {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: "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: "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}}, {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 // CategoryEnhanceUserTemplate is the shared per-category (and built-in) enhance USER
// message. Includes {{name}} {{description}} {{attrs}} {{category}} {{language}}. // message, sectioned by role (title / description / meta / attributes) using the
// Compatible with the enhance system JSON schema {"name","description"} — not a // same "--- Section ---" markers as legacy enhance-product. Includes {{name}}
// competing HTML marketing document. Title/description formulas stay in // {{description}} {{attrs}} {{category}} {{language}}. Compatible with enhance
// title_template / description_template and are appended at render time as plain // JSON {"name","description","meta_title","meta_description","attrs"} —
// text instructions (see processing.AppendFormulaConstraints). // description is formula HTML product body; meta_* are plain SEO fields (legacy
// Used by local A1/Demo prompt repair and seed-a1 overlays. // A1 <metaDescription> / cats.json metaTitle+metaDescription); attrs is the
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). // category-allowlisted attribute map. Title/description/meta formulas stay in
- name: short retail title; follow any Title formula constraints that follow; use Attrs // title_template / description_template and are appended at render time (see
- 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 (<h2><p><ul><li>) — do NOT emit a competing full HTML document, <name>/<metaDescription> blocks, or separate schema // processing.AppendFormulaConstraints / AppendAttributeConstraints). Categorize
// (unique_id) is RoleCategorize / ProductCategorize* — not this overlay. Used by
// local A1/Demo prompt repair and seed-a1 overlays.
const CategoryEnhanceUserTemplate = `Your reply is parsed as JSON {"name":"string","description":"string","meta_title":"string","meta_description":"string","attrs":{}} only (system schema). Write all string fields in {{language}} (do not hardcode a language).
Category: {{category}} --- Title ---
Role: title. Build JSON "name": short retail title; follow any Title formula constraints that follow; use Attrs.
Name: {{name}} Name: {{name}}
Desc: {{description}} --- End Title ---
Attrs: {{attrs}}`
--- Description ---
Role: description. Build JSON "description": product body HTML only (not SEO meta). 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 (<h2><p><ul><li>) — do NOT emit a competing full HTML document or wrap the whole reply in <name>/<metaDescription> tags.
Description: {{description}}
--- End Description ---
--- Meta ---
Role: meta. Build JSON "meta_title" and "meta_description" as plain SEO text (never HTML). meta_title: 50-60 chars; meta_description: 120-155 chars; follow any SEO meta formula that follows; never copy the full description HTML into meta_description.
--- End Meta ---
--- Attributes ---
Role: attributes. Build JSON "attrs" as an object of attribute_key → value strings. Prefer Allowed attribute keys / Title formula attr slots that follow; remap near-miss labels onto those keys; fill missing keys only from Name/Description/Category/Attrs evidence; never invent specs; omit unknown keys; never invent dimensions.
Category: {{category}}
Attrs: {{attrs}}
--- End Attributes ---`
// CategoryEnhancePromptNeedsRepair reports whether a stored categories.prompt value // CategoryEnhancePromptNeedsRepair reports whether a stored categories.prompt value
// should be replaced by CategoryEnhanceUserTemplate (idempotent equality check). // should be replaced by CategoryEnhanceUserTemplate (idempotent equality check).
@@ -77,38 +97,44 @@ func CategoryEnhancePromptNeedsRepair(prompt string) bool {
var BuiltInDefaults = []DefaultTemplate{ var BuiltInDefaults = []DefaultTemplate{
{ {
Key: KeyProductEnhance, Key: KeyProductEnhance,
Label: "Product title & description", Label: "Product title, description, SEO meta & attributes",
Description: "Used when processing products (AI enhance step). Categorize (taxonomy pick when category is empty) is a separate pipeline step before this.", Description: "Used when processing products (AI enhance step). Produces title, HTML description, meta_*, and attrs. Categorize (taxonomy pick when category is empty) is a separate pipeline step before this.",
SystemTemplate: `Retail product copywriter. SystemTemplate: `Retail product copywriter.
Rules: Rules:
- Reply with ONLY JSON (no markdown) - Reply with ONLY JSON (no markdown)
- Schema: {"name":"string","description":"string"} - Schema: {"name":"string","description":"string","meta_title":"string","meta_description":"string","attrs":{}}
- name: short retail title - name: short retail title
- description: follow any Description formula in the user message (structured HTML sections); otherwise 1-3 factual paragraphs; never copy name as description - description: product body HTML only — follow any Description formula in the user message (structured HTML sections); otherwise 1-3 factual paragraphs; never copy name as description; never put SEO meta here
- When Desc is empty or the same as Name, write from Category and Attrs only (do not invent specs); still obey a Description formula when present - meta_title: plain SEO title 50-60 chars (follow any SEO meta formula)
- Write name and description in {{language}} - meta_description: plain SEO snippet 120-155 chars (follow any SEO meta formula); never HTML
- attrs: object of attribute_key → value; only Allowed attribute keys / formula attr slots from the user message; remap near-miss labels; fill gaps from evidence only; omit unknowns; do not invent specs
- When Description is empty or the same as Name, write description from Category and Attrs only (do not invent specs); still obey a Description formula when present
- Write name, description, meta_title, and meta_description in {{language}}
Example: Example:
{"name":"Acme Widget Pro","description":"Durable widget for everyday use. Clear specs, ready to ship."} {"name":"Acme Widget Pro","description":"<p>Durable widget for everyday use. Clear specs, ready to ship.</p>","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}}`, {{brand_voice}}`,
UserTemplate: CategoryEnhanceUserTemplate, UserTemplate: CategoryEnhanceUserTemplate,
}, },
{ {
Key: KeySEOMeta, Key: KeySEOMeta,
Label: "SEO meta title & description", 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. SystemTemplate: `SEO meta writer for ecommerce.
Rules: Rules:
- Reply with ONLY JSON (no markdown) - Reply with ONLY JSON (no markdown)
- Schema: {"meta_title":"string","meta_description":"string"} - Schema: {"meta_title":"string","meta_description":"string"}
- meta_title: 50-60 chars, product + benefit - 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}} - Write meta_title and meta_description in {{language}}
Example: Example:
{"meta_title":"Acme Widget Pro | Durable Daily Use","meta_description":"Shop Acme Widget Pro for reliable everyday performance. Clear specs and fast delivery."} {"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}}`, {{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}} Category: {{category}}
Desc: {{description}}`, Description: {{description}}
--- End Meta ---`,
}, },
{ {
Key: KeyCampaignEmail, Key: KeyCampaignEmail,
+8 -2
View File
@@ -22,8 +22,14 @@ func TestCategoryEnhanceUserTemplateHasRequiredVars(t *testing.T) {
if strings.Contains(lower, "100 besed") || strings.Contains(lower, "gpt predloga") { if strings.Contains(lower, "100 besed") || strings.Contains(lower, "gpt predloga") {
t.Fatal("template must not demand legacy long HTML marketing docs") t.Fatal("template must not demand legacy long HTML marketing docs")
} }
if !strings.Contains(CategoryEnhanceUserTemplate, `{"name":"string","description":"string"}`) { if !strings.Contains(CategoryEnhanceUserTemplate, `{"name":"string","description":"string","meta_title":"string","meta_description":"string","attrs":{}}`) {
t.Fatal("template should reference JSON schema shape") 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}}") { if !strings.Contains(lower, "{{language}}") {
t.Fatal("language must come from {{language}}") t.Fatal("language must come from {{language}}")
+51
View File
@@ -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))
}
+79
View File
@@ -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")
}
}
@@ -14,6 +14,9 @@ func TestCategoryEnhanceTemplateHasAttrs(t *testing.T) {
if !strings.Contains(tpl, "{{attrs}}") { if !strings.Contains(tpl, "{{attrs}}") {
t.Fatalf("template missing {{attrs}}: %q", tpl) 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") { if !aiprompts.CategoryEnhancePromptNeedsRepair("legacy HTML prompt without attrs") {
t.Fatal("expected legacy prompt to need repair") t.Fatal("expected legacy prompt to need repair")
} }
+4 -3
View File
@@ -32,14 +32,15 @@ type RepairCategoryEnhancePromptsResult struct {
} }
// RepairA1DemoCategoryEnhancePrompts replaces legacy HTML marketing categories.prompt // 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): // LOCAL repair only (idempotent):
// - Writes JSON-compatible user overlays under both "sl" (prompt->>'sl') and "*" // - Writes JSON-compatible user overlays under both "sl" (prompt->>'sl') and "*"
// (LangPromptAny) so language stays via {{language}}, not hardcoded-only copy. // (LangPromptAny) so language stays via {{language}}, not hardcoded-only copy.
// - Touches ONLY categories.prompt — never title_template / description_template // - Touches ONLY categories.prompt — never title_template / description_template
// (unique name/description formulas stay intact; AppendFormulaConstraints encodes // (unique name/description/meta formulas stay intact; AppendFormulaConstraints
// them as plain-text instructions at enhance render time). // encodes them as plain-text instructions at enhance render time).
// - dryRun=true: count WouldUpdate only; dryRun=false: apply and set Updated. // - dryRun=true: count WouldUpdate only; dryRun=false: apply and set Updated.
// //
// Entrypoint: go run ./cmd/repair-category-prompts (-dry-run | -apply). // Entrypoint: go run ./cmd/repair-category-prompts (-dry-run | -apply).
+8 -5
View File
@@ -6278,9 +6278,10 @@ components:
type: object type: object
description: | description: |
One COMPLETED legacy process line (A1 / public contract). Successful items One COMPLETED legacy process line (A1 / public contract). Successful items
expose category as categories.unique_id, a plain-text description string expose category as categories.unique_id, a description string that may
(never a JSON array; HTML stripped), SEO meta_title / meta_description, include category formula HTML (h1/h2/h3/h4, p, ul — never a JSON array),
optional eprel object or null, clean attributes, images, and dual-mode ids. 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 Product display name is title; additive name mirrors the same processed title
(dual-mode for scorecards / legacy clients that read name). (dual-mode for scorecards / legacy clients that read name).
required: required:
@@ -6344,8 +6345,10 @@ components:
type: string type: string
nullable: true nullable: true
description: | description: |
Plain-text product body description. Always a string — never a one-element Product body description as a string — never a one-element JSON array.
JSON array. Feed HTML tags are stripped; newlines may remain between paragraphs. 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: attributes:
type: object type: object
additionalProperties: true additionalProperties: true
+3 -1
View File
@@ -89,7 +89,9 @@ type ProductInput struct {
EnhanceSystemTemplate string EnhanceSystemTemplate string
EnhanceUserTemplate string EnhanceUserTemplate string
// CategoryEnhancePrompt overrides EnhanceUserTemplate when non-empty // 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 CategoryEnhancePrompt string
// CategoryPromptsByLang maps lower(name|unique_id) → lang → category override prompt // CategoryPromptsByLang maps lower(name|unique_id) → lang → category override prompt
// (JSON/overlay text with {{attrs}}/{{category}}; works with repaired A1 overlays). // (JSON/overlay text with {{attrs}}/{{category}}; works with repaired A1 overlays).
@@ -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": "<h2>Acme UltraView 27</h2><p>27 inch IPS monitor for desk work with clear specs.</p><ul><li>27 inch</li><li>IPS panel</li></ul>",
"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)
}
+24 -4
View File
@@ -211,9 +211,10 @@ func FixCatalogHygieneWithIDs(ctx context.Context, pool *pgxpool.Pool, companyID
return out, ids, nil return out, ids, nil
} }
// NormalizeProcessedDescriptions rewrites processed_description to the plain-text // NormalizeProcessedDescriptions rewrites processed_description to a single
// form produced by PlainDescriptionFromAny (unwraps JSON arrays, strips HTML). When // string form via DescriptionFromAny (unwraps JSON arrays, preserves formula
// processed_description is empty, normalizes from description. Returns rows updated. // 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) { func NormalizeProcessedDescriptions(ctx context.Context, pool *pgxpool.Pool, companyID uuid.UUID) (int, error) {
if pool == nil { if pool == nil {
return 0, fmt.Errorf("normalize descriptions: nil pool") return 0, fmt.Errorf("normalize descriptions: nil pool")
@@ -245,7 +246,7 @@ func NormalizeProcessedDescriptions(ctx context.Context, pool *pgxpool.Pool, com
if raw == "" { if raw == "" {
continue continue
} }
plain := plainDescriptionFromStored(raw) plain := descriptionFromStored(raw)
if plain == "" || plain == strings.TrimSpace(processedDesc) { if plain == "" || plain == strings.TrimSpace(processedDesc) {
continue continue
} }
@@ -265,7 +266,26 @@ func NormalizeProcessedDescriptions(ctx context.Context, pool *pgxpool.Pool, com
return updated, nil 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. // 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 { func plainDescriptionFromStored(raw string) string {
raw = strings.TrimSpace(raw) raw = strings.TrimSpace(raw)
if raw == "" { if raw == "" {
@@ -82,7 +82,8 @@ func formatAvailableCategoriesList(opts []categoryOption) string {
return strings.TrimSpace(b.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. const ProductCategorizeSystem = `Product categorization expert.
Rules: Rules:
- Reply with ONLY a single JSON object (no markdown, no prose, no reasoning) - Reply with ONLY a single JSON object (no markdown, no prose, no reasoning)
@@ -80,6 +80,9 @@ func TestA1StyleDescriptionFormulaInResolvedPrompts(t *testing.T) {
if !strings.Contains(sysTpl, "Description formula") { if !strings.Contains(sysTpl, "Description formula") {
t.Fatalf("system missing formula override: %s", sysTpl) 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") { 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) t.Fatalf("user missing description formula: %s", userTpl)
} }
+235 -14
View File
@@ -3,38 +3,146 @@ package processing
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"sort"
"strconv" "strconv"
"strings" "strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/company" "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 // guidance to the enhance user template (before {{var}} render). Empty templates
// are no-ops. Shared skeleton stays in company/built-in prompts; formulas only // are no-ops. Shared skeleton stays in company/built-in prompts; formulas only
// constrain structure for the active language via {{language}} elsewhere. // 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 { func AppendFormulaConstraints(userTpl string, titleTemplate, descriptionTemplate any) string {
userTpl = strings.TrimSpace(userTpl) userTpl = strings.TrimSpace(userTpl)
titleBlock := FormatTitleFormulaConstraint(titleTemplate) blocks := []string{
descBlock := FormatDescriptionFormulaConstraint(descriptionTemplate) FormatTitleFormulaConstraint(titleTemplate),
if titleBlock == "" && descBlock == "" { 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 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 var b strings.Builder
if userTpl != "" { b.WriteString("Allowed attribute keys (JSON \"attrs\" object — remap feed labels onto these; omit unknowns):\n")
b.WriteString(userTpl) if len(keys) > 0 {
b.WriteString("\n\n") 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 != "" { if len(formulaKeys) > 0 {
b.WriteString(titleBlock) b.WriteString("- title formula slots: ")
if descBlock != "" { b.WriteString(strings.Join(formulaKeys, ", "))
b.WriteString("\n\n") 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
} }
if descBlock != "" { prefer := preferredAllowedKeys(allowed)
b.WriteString(descBlock) seen := map[string]struct{}{}
out := make([]string, 0, len(prefer))
for _, k := range prefer {
k = strings.TrimSpace(k)
if k == "" {
continue
} }
return b.String() if _, ok := seen[k]; ok {
continue
}
seen[k] = struct{}{}
out = append(out, k)
}
sort.Strings(out)
if len(out) > maxKeys {
out = out[:maxKeys]
}
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 // FormatTitleFormulaConstraint turns categories.title_template into enhance-prompt
@@ -70,6 +178,30 @@ func FormatTitleFormulaConstraint(template any) string {
return strings.TrimSpace(b.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 // FormatDescriptionFormulaConstraint turns categories.description_template sections
// into bullet instructions for the enhance user prompt. // into bullet instructions for the enhance user prompt.
func FormatDescriptionFormulaConstraint(template any) string { func FormatDescriptionFormulaConstraint(template any) string {
@@ -120,6 +252,74 @@ func AppendDescriptionFormulaSystemOverride(systemTpl string, descriptionTemplat
return systemTpl + "\n" + descriptionFormulaSystemOverride 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 // descriptionSatisfiesFormula reports whether desc includes the HTML tags required
// by categories.description_template sections. No formula → always true. // by categories.description_template sections. No formula → always true.
func descriptionSatisfiesFormula(desc string, template any) bool { func descriptionSatisfiesFormula(desc string, template any) bool {
@@ -219,6 +419,27 @@ func parseDescriptionFormulaSections(template any) ([]descriptionFormulaSection,
return out, len(out) > 0 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 // categoryFormulasFor resolves title/description templates for a category key
// (unique_id or name). Explicit ProductInput fields win over the job cache map. // (unique_id or name). Explicit ProductInput fields win over the job cache map.
func categoryFormulasFor(in ProductInput, category string) (title, description any) { func categoryFormulasFor(in ProductInput, category string) (title, description any) {
@@ -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) { func TestDescriptionSatisfiesFormula(t *testing.T) {
t.Parallel() t.Parallel()
tpl := map[string]any{ tpl := map[string]any{
@@ -119,8 +231,10 @@ func TestAppendFormulaConstraints_injectedIntoResolve(t *testing.T) {
"sections": []any{ "sections": []any{
map[string]any{"type": "p", "instructions": "Factual summary"}, 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}}", EnhanceUserTemplate: "Name: {{name}}\nAttrs: {{attrs}}",
TitleTemplate: title, TitleTemplate: title,
DescriptionTemplate: desc, DescriptionTemplate: desc,
@@ -134,6 +248,12 @@ func TestAppendFormulaConstraints_injectedIntoResolve(t *testing.T) {
if !strings.Contains(user, "Description formula") || !strings.Contains(user, "- p: Factual summary") { if !strings.Contains(user, "Description formula") || !strings.Contains(user, "- p: Factual summary") {
t.Fatalf("missing description formula: %s", user) 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. // Render substitutes {{language}} in formula blocks.
_, rendered := RenderProductEnhancePrompts( _, rendered := RenderProductEnhancePrompts(
"Write in {{language}}.", user, "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) { func TestCategoryFormulasFor_explicitWins(t *testing.T) {
t.Parallel() t.Parallel()
in := ProductInput{ in := ProductInput{
+45 -1
View File
@@ -19,7 +19,8 @@ func isDescriptionFieldKey(key string) bool {
// PlainDescriptionFromAny coerces legacy description shapes to a single plain // PlainDescriptionFromAny coerces legacy description shapes to a single plain
// string: string, {#text}, or arrays of those (joined with newlines). HTML is // 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 { func PlainDescriptionFromAny(v any) string {
if v == nil { if v == nil {
return "" 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 == "<nil>" {
return ""
}
return v1PreserveDescription(s)
}
}
// coerceScalarText flattens arrays / #text wrappers into a trimmed string without // coerceScalarText flattens arrays / #text wrappers into a trimmed string without
// HTML stripping (names, brands, stock labels, …). // HTML stripping (names, brands, stock labels, …).
func coerceScalarText(v any) string { func coerceScalarText(v any) string {
@@ -1,6 +1,9 @@
package processing package processing
import "testing" import (
"strings"
"testing"
)
func TestPlainDescriptionFromAny_arrayAndHTML(t *testing.T) { func TestPlainDescriptionFromAny_arrayAndHTML(t *testing.T) {
got := PlainDescriptionFromAny([]any{"Hello<br>World", "Second"}) got := PlainDescriptionFromAny([]any{"Hello<br>World", "Second"})
@@ -16,6 +19,24 @@ func TestPlainDescriptionFromAny_arrayAndHTML(t *testing.T) {
} }
} }
func TestDescriptionFromAny_preservesFormulaHTML(t *testing.T) {
htmlDesc := `<h1>Anker Soundcore Space One Pro</h1><p>Zložljive ANC slušalke.</p><ul><li>Bluetooth</li></ul>`
got := DescriptionFromAny(htmlDesc)
if !strings.Contains(got, "<h1>") || !strings.Contains(got, "<p>") || !strings.Contains(got, "<ul>") {
t.Fatalf("expected formula HTML preserved, got %q", got)
}
// Array unwrap should still keep tags.
got = DescriptionFromAny([]any{htmlDesc})
if !strings.Contains(got, "<h1>") || !strings.Contains(got, "</p>") {
t.Fatalf("array unwrap should keep HTML, got %q", got)
}
// Plain path still strips (meta/SEO / feed normalize).
plain := PlainDescriptionFromAny(htmlDesc)
if strings.Contains(plain, "<h1>") || strings.Contains(plain, "<p>") {
t.Fatalf("PlainDescriptionFromAny should strip tags, got %q", plain)
}
}
func TestNormalizeMapped_descriptionArrayBecomesPlainString(t *testing.T) { func TestNormalizeMapped_descriptionArrayBecomesPlainString(t *testing.T) {
got := NormalizeMapped(map[string]any{ got := NormalizeMapped(map[string]any{
"description": []any{"Line1<br/>A", "Line2"}, "description": []any{"Line1<br/>A", "Line2"},
+1 -1
View File
@@ -212,7 +212,7 @@ func ProductEnhanceUser(category, name, description string, attrs map[string]any
b.WriteString(SanitizeText(category)) b.WriteString(SanitizeText(category))
b.WriteString("\nName: ") b.WriteString("\nName: ")
b.WriteString(SanitizeText(truncateRunes(name, 200))) b.WriteString(SanitizeText(truncateRunes(name, 200)))
b.WriteString("\nDesc: ") b.WriteString("\nDescription: ")
b.WriteString(SanitizeText(truncateRunes(description, MaxProductDescRunes))) b.WriteString(SanitizeText(truncateRunes(description, MaxProductDescRunes)))
compact := CompactAttrs(attrs, MaxAttrKeys) compact := CompactAttrs(attrs, MaxAttrKeys)
if len(compact) > 0 { if len(compact) > 0 {
+56
View File
@@ -1,6 +1,7 @@
package processing package processing
import ( import (
"fmt"
"regexp" "regexp"
"strings" "strings"
"unicode" "unicode"
@@ -148,3 +149,58 @@ func metaBrandFromAttrs(bags ...map[string]any) string {
} }
return "" return ""
} }
// metaFieldsFromEnhanceObj extracts plain SEO meta from an enhance JSON object.
// Accepts snake_case and camelCase keys (legacy A1 <metaDescription> / cats.json).
// Strips HTML and rejects prompt-leakage titles; empty when missing.
func metaFieldsFromEnhanceObj(obj map[string]any) (title, description string) {
if obj == nil {
return "", ""
}
title = strings.TrimSpace(SanitizeOutput(fmt.Sprint(obj["meta_title"])))
if title == "" || title == "<nil>" {
title = strings.TrimSpace(SanitizeOutput(fmt.Sprint(obj["metaTitle"])))
}
description = strings.TrimSpace(SanitizeOutput(fmt.Sprint(obj["meta_description"])))
if description == "" || description == "<nil>" {
description = strings.TrimSpace(SanitizeOutput(fmt.Sprint(obj["metaDescription"])))
}
if title == "<nil>" {
title = ""
}
if description == "<nil>" {
description = ""
}
title = stripMetaTags(title)
description = stripMetaTags(description)
if isPromptLabelTitle(title) || isPromptLeakageTitle(title) {
title = ""
}
if isPromptLabelTitle(description) || isPromptLeakageTitle(description) {
description = ""
}
if title != "" {
title = truncateMetaRunes(title, metaTitleMaxChars)
}
if description != "" {
description = truncateMetaDescription(description, metaDescriptionMaxChars)
}
return title, description
}
// enhanceMetaFromRaw reads meta_title / meta_description stashed on enhance raw meta.
func enhanceMetaFromRaw(raw any) (title, description string) {
m, ok := raw.(map[string]any)
if !ok || m == nil {
return "", ""
}
title = strings.TrimSpace(fmt.Sprint(m["meta_title"]))
description = strings.TrimSpace(fmt.Sprint(m["meta_description"]))
if title == "<nil>" {
title = ""
}
if description == "<nil>" {
description = ""
}
return title, description
}
+61
View File
@@ -1,6 +1,7 @@
package processing package processing
import ( import (
"context"
"strings" "strings"
"testing" "testing"
"unicode/utf8" "unicode/utf8"
@@ -82,6 +83,66 @@ func TestTruncateMetaDescription_collapsesSpace(t *testing.T) {
} }
} }
func TestMetaFieldsFromEnhanceObj_snakeAndCamel(t *testing.T) {
t.Parallel()
mt, md := metaFieldsFromEnhanceObj(map[string]any{
"meta_title": "Acme Widget Pro | Daily Use",
"meta_description": "Shop Acme Widget Pro for reliable everyday performance.",
})
if mt != "Acme Widget Pro | Daily Use" {
t.Fatalf("meta_title=%q", mt)
}
if !strings.Contains(md, "Acme Widget Pro") {
t.Fatalf("meta_description=%q", md)
}
mt, md = metaFieldsFromEnhanceObj(map[string]any{
"metaTitle": "<b>Camel Title</b>",
"metaDescription": "<p>Camel desc with enough characters for a real SEO snippet about the product.</p>",
})
if strings.Contains(mt, "<") || mt == "" {
t.Fatalf("camel meta_title should be plain: %q", mt)
}
if strings.Contains(md, "<") || md == "" {
t.Fatalf("camel meta_description should be plain: %q", md)
}
mt, md = metaFieldsFromEnhanceObj(map[string]any{
"meta_title": "short retail title; follow any Title formula",
})
if mt != "" || md != "" {
t.Fatalf("leakage meta must be dropped: %q %q", mt, md)
}
}
func TestRunSteps_enhancePersistsMetaFields(t *testing.T) {
e := &Engine{Completer: stubCompleter{fn: func(system, user string) (Completion, error) {
return Completion{Text: `{
"name":"Acme Widget Pro",
"description":"<h2>Acme Widget Pro</h2><p>Durable widget for everyday use.</p>",
"meta_title":"Acme Widget Pro | Durable Daily Use",
"meta_description":"Shop Acme Widget Pro for reliable everyday performance. Clear specs and fast delivery."
}`, TotalTokens: 12}, nil
}}}
out, err := e.RunSteps(context.Background(), "c1", ProductInput{
Mapped: map[string]any{"name": "Raw", "description": "old", "category": "tools"},
Language: "en",
}, "full", nil, StepPolicy{AllowAI: true})
if err != nil {
t.Fatal(err)
}
if out.MetaTitle != "Acme Widget Pro | Durable Daily Use" {
t.Fatalf("MetaTitle=%q", out.MetaTitle)
}
if !strings.Contains(out.MetaDescription, "Shop Acme Widget Pro") {
t.Fatalf("MetaDescription=%q", out.MetaDescription)
}
if !strings.Contains(out.ProcessedDescription, "<h2>") {
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) { func TestV1PollMetaFallback_usesTemplateWhenEmpty(t *testing.T) {
t.Parallel() t.Parallel()
title := "Cordless Drill" title := "Cordless Drill"
+13 -1
View File
@@ -663,7 +663,19 @@ func (h HeuristicCompleter) Complete(_ context.Context, system, user string) (Co
if desc == "" || descriptionEchoesTitle(desc, name) || isWeakPriorEnhanceDescription(desc, name) { if desc == "" || descriptionEchoesTitle(desc, name) || isWeakPriorEnhanceDescription(desc, name) {
desc = inventHeuristicDescription(system, user, 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) text = string(b)
case strings.Contains(systemL, "attributes") && strings.Contains(systemL, "json"): case strings.Contains(systemL, "attributes") && strings.Contains(systemL, "json"):
text = `{"material":"unknown","brand":"unknown"}` text = `{"material":"unknown","brand":"unknown"}`
+26 -2
View File
@@ -1591,8 +1591,32 @@ func (p *Pipeline) processOne(ctx context.Context, companyID, jobID uuid.UUID, i
if len(result.ProcessedAttributes) == 0 { if len(result.ProcessedAttributes) == 0 {
result.ProcessedAttributes = result.Attributes result.ProcessedAttributes = result.Attributes
} }
// Free template SEO meta (no FillMetaAI / no extra credits). // Free template SEO meta when enhance did not emit meta_* (no FillMetaAI / no extra credits).
result.MetaTitle, result.MetaDescription = fillMetaFromResult(result) 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) attrsJSON, procAttrsJSON, gptJSON, sourcesJSON, err := marshalProcessOnePayload(result)
if err != nil { if err != nil {
@@ -17,12 +17,18 @@ func TestResolvePromptFallbackChain(t *testing.T) {
CategoryEnhancePrompt: "cat-sl", CategoryEnhancePrompt: "cat-sl",
Language: "sl", Language: "sl",
}) })
if sys != "sys" { if !strings.Contains(sys, "sys") {
t.Fatalf("sys=%q", 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) 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. // Empty category → company template.
_, user = resolveProductPromptTemplates(ProductInput{ _, user = resolveProductPromptTemplates(ProductInput{
+40 -7
View File
@@ -10,9 +10,10 @@ import (
func resolveProductPromptTemplates(in ProductInput) (systemTpl, userTpl string) { func resolveProductPromptTemplates(in ProductInput) (systemTpl, userTpl string) {
systemTpl = strings.TrimSpace(in.EnhanceSystemTemplate) systemTpl = strings.TrimSpace(in.EnhanceSystemTemplate)
userTpl = strings.TrimSpace(in.EnhanceUserTemplate) userTpl = strings.TrimSpace(in.EnhanceUserTemplate)
catPrompt := strings.TrimSpace(in.CategoryEnhancePrompt)
// Per-category prompt wins for the user message (company system keeps JSON schema / brand). // Per-category prompt wins for the user message (company system keeps JSON schema / brand).
if cat := strings.TrimSpace(in.CategoryEnhancePrompt); cat != "" { if catPrompt != "" {
userTpl = ensureCategoryEnhanceUserContext(cat) userTpl = ensureCategoryEnhanceUserContext(catPrompt)
} }
def, ok := aiprompts.DefaultFor(aiprompts.KeyProductEnhance) def, ok := aiprompts.DefaultFor(aiprompts.KeyProductEnhance)
if ok { 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. // 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)
// 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 // Company/built-in system prompts often say "1-2 sentences"; when a category
// description formula exists, override that so process matches A1 category defs. // description formula exists, override that so process matches A1 category defs.
systemTpl = AppendDescriptionFormulaSystemOverride(systemTpl, in.DescriptionTemplate) 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 return systemTpl, userTpl
} }
@@ -45,12 +54,36 @@ func templateHasVar(tpl, name string) bool {
return false return false
} }
// ensureCategoryEnhanceUserContext appends standard product context placeholders when a // categoryEnhanceUserOverlayPrefix frames free-form categories.prompt text so the
// category override omits {{attrs}} (common in DB category prompts). Does not rewrite // model applies it to name, description, and attrs (not description alone). Canonical
// category copy that already includes attrs. // 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 { func ensureCategoryEnhanceUserContext(userTpl string) string {
userTpl = strings.TrimSpace(userTpl) 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 return userTpl
} }
var b strings.Builder var b strings.Builder
@@ -71,7 +104,7 @@ func ensureCategoryEnhanceUserContext(userTpl string) string {
appendLine("Name: {{name}}") appendLine("Name: {{name}}")
} }
if !templateHasVar(userTpl, "description") { if !templateHasVar(userTpl, "description") {
appendLine("Desc: {{description}}") appendLine("Description: {{description}}")
} }
appendLine("Attrs: {{attrs}}") appendLine("Attrs: {{attrs}}")
return b.String() return b.String()
@@ -15,11 +15,17 @@ func TestResolveProductPromptTemplates_categoryOverridesUser(t *testing.T) {
EnhanceUserTemplate: "company user", EnhanceUserTemplate: "company user",
CategoryEnhancePrompt: "category user {{description}}", CategoryEnhancePrompt: "category user {{description}}",
}) })
if sys != "sys {{brand_voice}}" { if !strings.Contains(sys, "sys {{brand_voice}}") {
t.Fatalf("system=%q", sys) t.Fatalf("system=%q want company system kept", sys)
} }
if !strings.HasPrefix(user, "category user {{description}}") { if !strings.Contains(sys, `apply it to "name", "description", and "attrs"`) {
t.Fatalf("user=%q want category override prefix", user) 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}}") { if !strings.Contains(user, "{{attrs}}") {
t.Fatalf("user=%q want injected {{attrs}}", user) t.Fatalf("user=%q want injected {{attrs}}", user)
@@ -34,8 +40,68 @@ func TestResolveProductPromptTemplates_categoryWithAttrsUnchanged(t *testing.T)
_, user := resolveProductPromptTemplates(ProductInput{ _, user := resolveProductPromptTemplates(ProductInput{
CategoryEnhancePrompt: "Write copy.\nAttrs: {{attrs}}\nName: {{name}}", CategoryEnhancePrompt: "Write copy.\nAttrs: {{attrs}}\nName: {{name}}",
}) })
if user != "Write copy.\nAttrs: {{attrs}}\nName: {{name}}" { if !strings.Contains(user, "Write copy.\nAttrs: {{attrs}}\nName: {{name}}") {
t.Fatalf("user=%q want unchanged when attrs present", user) 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)
} }
} }
+8 -1
View File
@@ -10,6 +10,11 @@ import (
const maxPromptFieldRunes = 4000 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)`) 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. // 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. // 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 { func SanitizeOutput(s string) string {
s = strings.TrimSpace(s) s = strings.TrimSpace(s)
if s == "" { if s == "" {
@@ -43,7 +50,7 @@ func SanitizeOutput(s string) string {
b.WriteRune(r) b.WriteRune(r)
} }
} }
return truncateRunes(b.String(), maxPromptFieldRunes) return truncateRunes(b.String(), maxOutputFieldRunes)
} }
func truncateRunes(s string, max int) string { func truncateRunes(s string, max int) string {
+222 -4
View File
@@ -303,6 +303,8 @@ func (e *Engine) RunSteps(ctx context.Context, companyID string, in ProductInput
CategoryEnhancePrompt: catPrompt, CategoryEnhancePrompt: catPrompt,
TitleTemplate: titleTpl, TitleTemplate: titleTpl,
DescriptionTemplate: descTpl, DescriptionTemplate: descTpl,
AllowedAttrKeys: in.AllowedAttrKeys,
CategoryAttrKeys: in.CategoryAttrKeys,
PriorEnhanceHash: priorHash, PriorEnhanceHash: priorHash,
PriorProcessedName: priorName, PriorProcessedName: priorName,
PriorProcessedDescription: priorDesc, PriorProcessedDescription: priorDesc,
@@ -355,6 +357,7 @@ func (e *Engine) RunSteps(ctx context.Context, companyID string, in ProductInput
} }
} }
weakDesc := descriptionNeedsEnhanceRepair(desc, descTpl, name) weakDesc := descriptionNeedsEnhanceRepair(desc, descTpl, name)
enhanceMetaTitle, enhanceMetaDesc := enhanceMetaFromRaw(raw)
// Only persist enhance_input_hash for quality ok / hash-skip unchanged. // Only persist enhance_input_hash for quality ok / hash-skip unchanged.
// Never copy input_hash from error/passthrough/thin/synthesized meta. // Never copy input_hash from error/passthrough/thin/synthesized meta.
persistHash := "" persistHash := ""
@@ -380,8 +383,8 @@ func (e *Engine) RunSteps(ctx context.Context, companyID string, in ProductInput
ProcessedName: name, ProcessedName: name,
ProcessedDescription: desc, ProcessedDescription: desc,
EnhanceInputHash: persistHash, EnhanceInputHash: persistHash,
MetaTitle: company.FieldsForLanguage(localized, lang).MetaTitle, MetaTitle: enhanceMetaTitle,
MetaDescription: company.FieldsForLanguage(localized, lang).MetaDescription, MetaDescription: enhanceMetaDesc,
} }
// Preserve existing meta when re-enhancing titles only. // Preserve existing meta when re-enhancing titles only.
// Never keep a bare "| <unique_id>" poisoned meta_title. // Never keep a bare "| <unique_id>" poisoned meta_title.
@@ -410,6 +413,23 @@ func (e *Engine) RunSteps(ctx context.Context, companyID string, in ProductInput
if desc != "" { if desc != "" {
out.Description = desc out.Description = desc
} }
if lf := localized[lang]; lf.MetaTitle != "" {
out.MetaTitle = lf.MetaTitle
}
if lf := localized[lang]; lf.MetaDescription != "" {
out.MetaDescription = lf.MetaDescription
}
// Merge LLM attrs onto pipeline attrs (validated against category keys).
// Attrs are independent of title/description soft-fail (synthesized/refused).
if err == nil && status != "failed" && status != "parse_failed" {
if merged := mergeEnhanceAttrsInto(attrs, enhanceAttrsFromRaw(raw), enhanceAllowedAttrKeys(in, out.Category)); len(merged) > 0 {
attrs = merged
enhanceAttrs = AttrsForEnhance(attrs, enhanceAllowedAttrKeys(in, out.Category))
out.Attributes = attrs
out.ProcessedAttributes = attrs
out.FieldSources["attributes"] = "ai_enhance"
}
}
if persistHash != "" { if persistHash != "" {
out.FieldSources[FieldEnhanceInputHash] = persistHash out.FieldSources[FieldEnhanceInputHash] = persistHash
} else { } else {
@@ -818,6 +838,8 @@ func (e *Engine) enhance(ctx context.Context, in ProductInput, category string,
} }
llmName := SanitizeOutput(fmt.Sprint(obj["name"])) llmName := SanitizeOutput(fmt.Sprint(obj["name"]))
llmDesc := SanitizeOutput(fmt.Sprint(obj["description"])) llmDesc := SanitizeOutput(fmt.Sprint(obj["description"]))
llmMetaTitle, llmMetaDesc := metaFieldsFromEnhanceObj(obj)
llmAttrs := attrsFromEnhanceObj(obj)
// Never prefer-fallback to originals for empty/leakage LLM fields — that stamped // Never prefer-fallback to originals for empty/leakage LLM fields — that stamped
// status=ok + input_hash on garbage enhance output (prod: 30272ms "ok", wrong copy). // status=ok + input_hash on garbage enhance output (prod: 30272ms "ok", wrong copy).
if reason := llmEnhanceHardRefuseReason(llmName, llmDesc); reason != "" { if reason := llmEnhanceHardRefuseReason(llmName, llmDesc); reason != "" {
@@ -846,6 +868,8 @@ func (e *Engine) enhance(ctx context.Context, in ProductInput, category string,
"reason": reason, "reason": reason,
"raw": comp.Raw, "raw": comp.Raw,
} }
attachEnhanceMeta(meta, llmMetaTitle, llmMetaDesc)
attachEnhanceAttrs(meta, llmAttrs)
if forceReason != "" { if forceReason != "" {
meta["forced_reenhance"] = true meta["forced_reenhance"] = true
meta["force_reason"] = forceReason meta["force_reason"] = forceReason
@@ -878,6 +902,16 @@ func (e *Engine) enhance(ctx context.Context, in ProductInput, category string,
if llmEnhanceHardRefuseReason(n2, d2) == "" { if llmEnhanceHardRefuseReason(n2, d2) == "" {
name = preferredProductTitle(in.GTIN, n2, name, in.Name, in.PriorProcessedName) name = preferredProductTitle(in.GTIN, n2, name, in.Name, in.PriorProcessedName)
desc = preferredProductDescription(name, d2) desc = preferredProductDescription(name, d2)
mt2, md2 := metaFieldsFromEnhanceObj(obj2)
if mt2 != "" {
llmMetaTitle = mt2
}
if md2 != "" {
llmMetaDesc = md2
}
if a2 := attrsFromEnhanceObj(obj2); len(a2) > 0 {
llmAttrs = a2
}
if comp2.Raw != nil { if comp2.Raw != nil {
comp.Raw = comp2.Raw comp.Raw = comp2.Raw
} }
@@ -903,6 +937,8 @@ func (e *Engine) enhance(ctx context.Context, in ProductInput, category string,
"status": "ok", "status": "ok",
"raw": comp.Raw, "raw": comp.Raw,
} }
attachEnhanceMeta(meta, llmMetaTitle, llmMetaDesc)
attachEnhanceAttrs(meta, llmAttrs)
if forceReason != "" { if forceReason != "" {
meta["reason"] = forceReason meta["reason"] = forceReason
meta["forced_reenhance"] = true meta["forced_reenhance"] = true
@@ -942,6 +978,115 @@ func (e *Engine) enhance(ctx context.Context, in ProductInput, category string,
return name, desc, comp.TotalTokens, meta, nil return name, desc, comp.TotalTokens, meta, nil
} }
// attachEnhanceMeta stores parsed SEO fields on enhance raw meta for RunSteps.
func attachEnhanceMeta(meta map[string]any, title, description string) {
if meta == nil {
return
}
if title != "" {
meta["meta_title"] = title
}
if description != "" {
meta["meta_description"] = description
}
}
// attachEnhanceAttrs stores parsed attrs on enhance raw meta for RunSteps merge.
func attachEnhanceAttrs(meta map[string]any, attrs map[string]any) {
if meta == nil || len(attrs) == 0 {
return
}
meta["attrs"] = attrs
}
// attrsFromEnhanceObj extracts an attrs/attributes object from enhance JSON.
func attrsFromEnhanceObj(obj map[string]any) map[string]any {
if obj == nil {
return nil
}
raw := obj["attrs"]
if raw == nil {
raw = obj["attributes"]
}
return coerceAttrMap(raw)
}
// enhanceAttrsFromRaw reads attrs stashed on enhance raw meta by attachEnhanceAttrs.
func enhanceAttrsFromRaw(raw any) map[string]any {
m, ok := raw.(map[string]any)
if !ok || m == nil {
return nil
}
if v, ok := m["attrs"]; ok {
return coerceAttrMap(v)
}
return nil
}
// coerceAttrMap normalizes JSON object / map[string]string into map[string]any.
func coerceAttrMap(raw any) map[string]any {
switch v := raw.(type) {
case map[string]any:
if len(v) == 0 {
return nil
}
out := make(map[string]any, len(v))
for k, val := range v {
k = strings.TrimSpace(k)
if k == "" || val == nil {
continue
}
out[k] = val
}
if len(out) == 0 {
return nil
}
return out
case map[string]string:
if len(v) == 0 {
return nil
}
out := make(map[string]any, len(v))
for k, val := range v {
k = strings.TrimSpace(k)
val = strings.TrimSpace(val)
if k == "" || val == "" {
continue
}
out[k] = val
}
if len(out) == 0 {
return nil
}
return out
default:
return nil
}
}
// mergeEnhanceAttrsInto merges LLM attrs onto base after AttrsForEnhance validation
// (MapAttrsOntoAllowedKeys + category allowlist). Empty LLM attrs → nil (no change).
func mergeEnhanceAttrsInto(base, llmAttrs map[string]any, allowed map[string]struct{}) map[string]any {
if len(llmAttrs) == 0 {
return nil
}
validated := AttrsForEnhance(llmAttrs, allowed)
if len(validated) == 0 {
return nil
}
out := make(map[string]any, len(base)+len(validated))
for k, v := range base {
out[k] = v
}
for k, v := range validated {
if !attrValuePresent(v) {
continue
}
out[k] = v
}
return out
}
// llmEnhanceHardRefuseReason reports empty/leakage LLM fields that must never be // llmEnhanceHardRefuseReason reports empty/leakage LLM fields that must never be
// accepted as quality enhance (even when originals could fill the gap). // accepted as quality enhance (even when originals could fill the gap).
func llmEnhanceHardRefuseReason(name, desc string) string { func llmEnhanceHardRefuseReason(name, desc string) string {
@@ -1042,7 +1187,8 @@ func isPromptLabelTitle(s string) bool {
lower := strings.ToLower(s) lower := strings.ToLower(s)
for _, label := range []string{ for _, label := range []string{
"category", "name", "desc", "description", "category", "name", "desc", "description",
"attrs", "attributes", "current name", "current description", "meta", "meta_title", "meta_description", "metadescription",
"attrs", "attributes", "current name", "current description", "current meta",
} { } {
if lower == label || lower == label+":" { if lower == label || lower == label+":" {
return true return true
@@ -1092,6 +1238,7 @@ var promptLeakagePhrases = []string{
"reply with only json", "reply with only json",
"your reply is parsed as json", "your reply is parsed as json",
"description formula", "description formula",
"seo meta formula",
"build name from attrs", "build name from attrs",
"prefer attrs values", "prefer attrs values",
"order matters; join with", "order matters; join with",
@@ -1114,6 +1261,23 @@ var promptLeakagePhrases = []string{
"1-2 sentences", "1-2 sentences",
"keep literal text as written", "keep literal text as written",
"build name from attrs using this structure", "build name from attrs using this structure",
"category guidance",
"applies to name and description",
"apply it to both",
"never ignore the title formula",
// Role-sectioned CategoryEnhanceUserTemplate / KeySEOMeta markers.
"--- title ---",
"--- end title ---",
"--- description ---",
"--- end description ---",
"--- meta ---",
"--- end meta ---",
"--- attributes ---",
"--- end attributes ---",
"role: title",
"role: description",
"role: meta",
"role: attributes",
} }
var promptLeakageLongKeywords = []string{ var promptLeakageLongKeywords = []string{
@@ -1217,21 +1381,75 @@ func scrubCategoryPollution(out *StepResult, namesByUID map[string]string, valid
} }
// preferredProductTitle picks the first usable title, skipping empty values, // preferredProductTitle picks the first usable title, skipping empty values,
// prompt-label echoes like "Category:", and instruction-text leakage. // prompt-label echoes like "Category:", instruction-text leakage, and brand-only
// stubs when a longer product name exists among the candidates (e.g. "ANKER"
// vs "Anker Soundcore Space One Pro").
func preferredProductTitle(gtin string, candidates ...string) string { func preferredProductTitle(gtin string, candidates ...string) string {
usable := make([]string, 0, len(candidates))
for _, c := range candidates { for _, c := range candidates {
c = strings.TrimSpace(c) c = strings.TrimSpace(c)
if c == "" || c == "<nil>" || isPromptLabelTitle(c) { if c == "" || c == "<nil>" || isPromptLabelTitle(c) {
continue continue
} }
usable = append(usable, c)
}
for _, c := range usable {
if isBrandOnlyTitleAmong(c, usable) {
continue
}
return SanitizeOutput(c) return SanitizeOutput(c)
} }
// All remaining candidates are brand-only relative to each other — pick longest.
best := ""
for _, c := range usable {
if len([]rune(c)) > len([]rune(best)) {
best = c
}
}
if best != "" {
return SanitizeOutput(best)
}
if strings.TrimSpace(gtin) != "" { if strings.TrimSpace(gtin) != "" {
return SanitizeText("Product " + strings.TrimSpace(gtin)) return SanitizeText("Product " + strings.TrimSpace(gtin))
} }
return "Product" return "Product"
} }
// isBrandOnlyTitleAmong is true when candidate looks like a brand stub that a
// longer candidate expands (prefix / first-token match).
func isBrandOnlyTitleAmong(candidate string, all []string) bool {
c := strings.TrimSpace(candidate)
if c == "" {
return false
}
words := strings.Fields(c)
if len(words) > 2 {
return false
}
if len(words) == 1 && len([]rune(c)) > 40 {
return false
}
clower := strings.ToLower(c)
for _, other := range all {
o := strings.TrimSpace(other)
if o == "" || strings.EqualFold(o, c) {
continue
}
if len([]rune(o)) <= len([]rune(c)) {
continue
}
olower := strings.ToLower(o)
if strings.HasPrefix(olower, clower+" ") || strings.HasPrefix(olower, clower+"-") {
return true
}
oWords := strings.Fields(o)
if len(words) == 1 && len(oWords) >= 2 && strings.EqualFold(oWords[0], words[0]) {
return true
}
}
return false
}
// Thin wrappers keep processing call sites stable; logic lives in company so // Thin wrappers keep processing call sites stable; logic lives in company so
// catalog.RepairWeakEnhanceHashes can reuse it without an import cycle. // catalog.RepairWeakEnhanceHashes can reuse it without an import cycle.
func isWeakPriorEnhanceDescription(priorDesc string, titles ...string) bool { func isWeakPriorEnhanceDescription(priorDesc string, titles ...string) bool {
+18 -2
View File
@@ -202,6 +202,22 @@ func TestPreferredProductTitle_skipsFormulaLeakage(t *testing.T) {
} }
} }
func TestPreferredProductTitle_skipsBrandOnlyWhenFullNameExists(t *testing.T) {
got := preferredProductTitle("1", "ANKER", "Anker Soundcore Space One Pro")
if got != "Anker Soundcore Space One Pro" {
t.Fatalf("got %q want full product name", got)
}
got = preferredProductTitle("1", "Anker", "Anker Soundcore Space One Pro Wireless")
if got != "Anker Soundcore Space One Pro Wireless" {
t.Fatalf("got %q", got)
}
// Brand alone when no longer candidate.
got = preferredProductTitle("1", "ANKER")
if got != "ANKER" {
t.Fatalf("brand-only alone got %q", got)
}
}
func TestIsPromptLabelTitle(t *testing.T) { func TestIsPromptLabelTitle(t *testing.T) {
cases := map[string]bool{ cases := map[string]bool{
"Category:": true, "Category:": true,
@@ -390,13 +406,13 @@ func TestHeuristicCompleter_keepsNameWhenFormulaConstraintsPresent(t *testing.T)
h := HeuristicCompleter{} h := HeuristicCompleter{}
// Shape matches aiprompts.CategoryEnhanceUserTemplate: instruction "- name:" // Shape matches aiprompts.CategoryEnhanceUserTemplate: instruction "- name:"
// appears BEFORE the real "Name:" field — labeledPromptValue must skip leakage. // appears BEFORE the real "Name:" field — labeledPromptValue must skip leakage.
user := `Your reply is parsed as JSON {"name":"string","description":"string"} only (system schema). Write name and description in English (do not hardcode a language). user := `Your reply is parsed as JSON {"name":"string","description":"string","meta_title":"string","meta_description":"string"} only (system schema). Write all fields in English (do not hardcode a language).
- name: short retail title; follow any Title formula constraints that follow; use Attrs - name: short retail title; follow any Title formula constraints that follow; use Attrs
- description: prefer 1-3 factual paragraphs as ONE string - description: prefer 1-3 factual paragraphs as ONE string
Category: demo-electronics Category: demo-electronics
Name: Vox SWA-8000W Name: Vox SWA-8000W
Desc: Washer Description: Washer
Attrs: {"brand":"Vox"} Attrs: {"brand":"Vox"}
Title formula (order matters; join with " "). Build name from Attrs using this structure: Title formula (order matters; join with " "). Build name from Attrs using this structure:
+45 -13
View File
@@ -296,15 +296,24 @@ func (p *Pipeline) LoadV1ProcessJobItems(ctx context.Context, companyID, jobID u
_ = json.Unmarshal(rawJSON, &rawData) _ = json.Unmarshal(rawJSON, &rawData)
main, more := catalog.ExtractProductImages(mapped, rawData) main, more := catalog.ExtractProductImages(mapped, rawData)
plainDesc := "" // Preserve formula HTML from processed_description; meta synthesis strips tags.
descOut := ""
if descTxt != nil { if descTxt != nil {
plainDesc = v1PlainDescription(*descTxt) descOut = v1PreserveDescription(*descTxt)
} }
eprelVal := extractEPRELFromAttrs(attrs) eprelVal := extractEPRELFromAttrs(attrs)
attrs = SanitizeV1ProcessAttributesAllowed(attrs, allowedAttrs) attrs = SanitizeV1ProcessAttributesAllowed(attrs, allowedAttrs)
titleStr := derefStringPtr(title) titleStr := derefStringPtr(title)
// Prefer full product name from mapped/raw when processed title is brand-only
// (e.g. LLM returned "ANKER" while feed has "Anker Soundcore Space One Pro").
titleStr = preferredProductTitle(ean, titleStr,
stringFromAny(mapped["name"]),
stringFromAny(mapped["title"]),
stringFromAny(rawData["name"]),
stringFromAny(rawData["title"]),
)
catStr := derefStringPtr(category) catStr := derefStringPtr(category)
catNameStr := derefStringPtr(categoryName) catNameStr := derefStringPtr(categoryName)
// Projection gap: when processed.category is empty but mapped/raw carries a // Projection gap: when processed.category is empty but mapped/raw carries a
@@ -363,14 +372,14 @@ func (p *Pipeline) LoadV1ProcessJobItems(ctx context.Context, companyID, jobID u
if catLabel == "" { if catLabel == "" {
catLabel = catStr catLabel = catStr
} }
if (metaTitleOut == nil || metaDescOut == nil) && (titleStr != "" || plainDesc != "" || catLabel != "") { if (metaTitleOut == nil || metaDescOut == nil) && (titleStr != "" || descOut != "" || catLabel != "") {
synthTitle, synthDesc := fillMetaFromResult(StepResult{ synthTitle, synthDesc := fillMetaFromResult(StepResult{
Name: titleStr, Name: titleStr,
ProcessedName: titleStr, ProcessedName: titleStr,
Category: catStr, Category: catStr,
CategoryName: catNameStr, CategoryName: catNameStr,
Description: plainDesc, Description: descOut,
ProcessedDescription: plainDesc, ProcessedDescription: descOut,
Attributes: attrs, Attributes: attrs,
ProcessedAttributes: attrs, ProcessedAttributes: attrs,
}) })
@@ -384,8 +393,8 @@ func (p *Pipeline) LoadV1ProcessJobItems(ctx context.Context, companyID, jobID u
if metaDescOut == nil { if metaDescOut == nil {
if synthDesc != "" { if synthDesc != "" {
metaDescOut = synthDesc metaDescOut = synthDesc
} else if plainDesc != "" { } else if descOut != "" {
metaDescOut = truncateMetaDescription(plainDesc, v1MetaDescriptionMaxChars) metaDescOut = truncateMetaDescription(v1PlainDescription(descOut), v1MetaDescriptionMaxChars)
} }
} }
} else if metaTitleOut == nil { } else if metaTitleOut == nil {
@@ -393,8 +402,8 @@ func (p *Pipeline) LoadV1ProcessJobItems(ctx context.Context, companyID, jobID u
} }
var description any var description any
if plainDesc != "" { if descOut != "" {
description = plainDesc description = descOut
} else { } else {
description = nil description = nil
} }
@@ -406,7 +415,10 @@ func (p *Pipeline) LoadV1ProcessJobItems(ctx context.Context, companyID, jobID u
catNameOut = catNameStr catNameOut = catNameStr
} }
titleOut := nullIfEmptyPtr(title) var titleOut any
if titleStr != "" {
titleOut = titleStr
}
item := V1ProcessJobItem{ item := V1ProcessJobItem{
"ean": ean, "ean": ean,
"status": MapV1JobItemStatus(itemStatus, true), "status": MapV1JobItemStatus(itemStatus, true),
@@ -518,10 +530,30 @@ func loadCompanyCategoryNameMap(ctx context.Context, p *Pipeline, companyID uuid
return out return out
} }
// v1PreserveDescription unwraps legacy JSON-encoded description payloads and
// normalizes entities, but keeps formula HTML tags (h1/h2/p/ul/…) for the V1
// process poll and DB-facing normalize. Prefer this over v1PlainDescription when
// enhance / category description_template markup must reach API clients.
func v1PreserveDescription(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
s = unwrapLegacyDescriptionStored(s)
s = strings.TrimSpace(s)
if s == "" {
return ""
}
s = html.UnescapeString(s)
s = strings.ReplaceAll(s, "\u00a0", " ")
s = strings.ReplaceAll(s, `\u00a0`, " ")
return strings.TrimSpace(s)
}
// v1PlainDescription normalizes legacy stored descriptions into a single plain-text // v1PlainDescription normalizes legacy stored descriptions into a single plain-text
// string for the process poll (description is a string, not a one-element array). // string (meta/SEO, feed normalize). Handles JSON array/string encodings and
// Handles JSON array/string encodings (e.g. mapped_data->>'description' when the // strips HTML/<br>/entity markup. Do not use for V1 item.description when formula
// feed value was an array) and HTML/<br>/entity markup. // HTML must be preserved — use v1PreserveDescription / DescriptionFromAny.
func v1PlainDescription(s string) string { func v1PlainDescription(s string) string {
s = strings.TrimSpace(s) s = strings.TrimSpace(s)
if s == "" { if s == "" {
@@ -183,9 +183,9 @@ func ScoreV1ProcessCompletedItem(item V1ProcessJobItem, opts ScoreV1ProcessItemO
} }
// EnforceV1ProcessCompletedItem fills LegacyProcessItem projection gaps for a // EnforceV1ProcessCompletedItem fills LegacyProcessItem projection gaps for a
// successful item: plain nonempty description when title exists, meta_*, clean // successful item: nonempty description when title exists (formula HTML
// attributes, eprel object|null, and image key shapes. Category must already be // preserved), meta_*, clean attributes, eprel object|null, and image key shapes.
// set by the caller when mapped provides a unique_id. // 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 // allowed is the company attribute_key set (canonicalized). When nil, only
// coreCharacteristicAttrKeys are kept (never leak feed junk like zavora). // coreCharacteristicAttrKeys are kept (never leak feed junk like zavora).
@@ -224,8 +224,9 @@ func EnforceV1ProcessCompletedItem(item V1ProcessJobItem, language string, allow
catLabel = cat catLabel = cat
} }
desc, _ := plainDescriptionFromItem(item) desc, _ := descriptionFromItem(item)
// Empty, weak, or title-echo copy must be replaced — never leave description==title. // 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 title != "" && (desc == "" || isWeakPriorEnhanceDescription(desc, title) || descriptionEchoesTitle(desc, title)) {
if synth := synthesizeDescriptionFromTitle(title, catLabel, language, attrs); synth != "" { if synth := synthesizeDescriptionFromTitle(title, catLabel, language, attrs); synth != "" {
desc = synth desc = synth
@@ -324,6 +325,38 @@ func stringFromItem(item V1ProcessJobItem, key string) string {
return strings.TrimSpace(stringFromAny(item[key])) 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 == "<nil>" {
return "", true
}
return "", false
}
}
// plainDescriptionFromItem is kept for callers that need SEO/plain text (meta).
func plainDescriptionFromItem(item V1ProcessJobItem) (string, bool) { func plainDescriptionFromItem(item V1ProcessJobItem) (string, bool) {
if item == nil { if item == nil {
return "", true return "", true
@@ -336,7 +369,6 @@ func plainDescriptionFromItem(item V1ProcessJobItem) (string, bool) {
case string: case string:
return PlainDescriptionFromAny(raw), true return PlainDescriptionFromAny(raw), true
case []any, []string: case []any, []string:
// Legacy mistake: description as array — convert to plain string.
if s := PlainDescriptionFromAny(raw); s != "" { if s := PlainDescriptionFromAny(raw); s != "" {
return s, true return s, true
} }
@@ -317,3 +317,33 @@ func TestEnforceV1ProcessCompletedItem_refreshesPromptLeakageMeta(t *testing.T)
t.Fatalf("prompt-leakage meta_description preserved: %q", md) t.Fatalf("prompt-leakage meta_description preserved: %q", md)
} }
} }
func TestEnforceV1ProcessCompletedItem_preservesFormulaHTML(t *testing.T) {
t.Parallel()
htmlDesc := `<h1>Anker Soundcore Space One Pro</h1><p>Zložljive ANC slušalke z bogatim zvokom.</p><ul><li>Bluetooth 5.3</li></ul>`
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{"<h1>", "<p>", "<ul>"} {
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, "<h1>") || strings.Contains(md, "<p>") {
t.Fatalf("meta_description should be plain, got %q", md)
}
}
+1 -1
View File
@@ -81,7 +81,7 @@ func FillMetaAI(ctx context.Context, completer processing.Completer, p ProductIn
system := strings.TrimSpace(aiprompts.Render(sysTpl, vars)) system := strings.TrimSpace(aiprompts.Render(sysTpl, vars))
user := strings.TrimSpace(aiprompts.Render(userTpl, vars)) user := strings.TrimSpace(aiprompts.Render(userTpl, vars))
if user == "" { 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)) name, p.Category, truncateRunes(desc, processing.MaxProductDescRunes))
} }