diff --git a/apps/api/internal/aiprompts/kinds.go b/apps/api/internal/aiprompts/kinds.go
index 1dc5f12..8a7ee13 100644
--- a/apps/api/internal/aiprompts/kinds.go
+++ b/apps/api/internal/aiprompts/kinds.go
@@ -56,7 +56,7 @@ type DefaultTemplate struct {
// Used by local A1/Demo prompt repair and seed-a1 overlays.
const CategoryEnhanceUserTemplate = `Your reply is parsed as JSON {"name":"string","description":"string"} only (system schema). Write name and description in {{language}} (do not hardcode a language).
- name: short retail title; follow any Title formula constraints that follow; use Attrs
-- description: prefer 1-3 factual paragraphs as ONE string; limited HTML (
- ) is allowed if needed — do NOT emit a competing full HTML document, / blocks, or separate schema
+- 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 (
- ) — do NOT emit a competing full HTML document, / blocks, or separate schema
Category: {{category}}
Name: {{name}}
@@ -84,8 +84,8 @@ Rules:
- Reply with ONLY JSON (no markdown)
- Schema: {"name":"string","description":"string"}
- name: short retail title
-- description: 1-2 factual sentences; never copy name as description
-- When Desc is empty or the same as Name, write 1-2 factual sentences from Category and Attrs only (do not invent specs)
+- description: follow any Description formula in the user message (structured HTML sections); otherwise 1-3 factual paragraphs; never copy name as description
+- 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
- Write name and description in {{language}}
Example:
{"name":"Acme Widget Pro","description":"Durable widget for everyday use. Clear specs, ready to ship."}
diff --git a/apps/api/internal/aiprompts/kinds_test.go b/apps/api/internal/aiprompts/kinds_test.go
index c99af4c..1392fc0 100644
--- a/apps/api/internal/aiprompts/kinds_test.go
+++ b/apps/api/internal/aiprompts/kinds_test.go
@@ -56,4 +56,10 @@ func TestBuiltInProductEnhanceUsesSharedUserTemplate(t *testing.T) {
if def.UserTemplate != CategoryEnhanceUserTemplate {
t.Fatalf("UserTemplate must be CategoryEnhanceUserTemplate")
}
+ if strings.Contains(def.SystemTemplate, "1-2 factual sentences") {
+ t.Fatal("system template must not force 1-2 sentences over category Description formulas")
+ }
+ if !strings.Contains(strings.ToLower(def.SystemTemplate), "description formula") {
+ t.Fatal("system template should defer to Description formula when present")
+ }
}
diff --git a/apps/api/internal/processing/enhance_hash_test.go b/apps/api/internal/processing/enhance_hash_test.go
index 6037efc..c80b28b 100644
--- a/apps/api/internal/processing/enhance_hash_test.go
+++ b/apps/api/internal/processing/enhance_hash_test.go
@@ -107,7 +107,7 @@ func TestRunSteps_callsEnhanceWhenInputHashDiffers(t *testing.T) {
e := &Engine{
Completer: stubCompleter{fn: func(system, user string) (Completion, error) {
calls++
- return Completion{Text: `{"name":"Fresh","description":"New copy."}`, TotalTokens: 3}, nil
+ return Completion{Text: `{"name":"Fresh","description":"Fresh retail copy with enough detail for a quality enhance hash."}`, TotalTokens: 3}, nil
}},
Vector: NoopVectorCategorizer{},
}
@@ -243,6 +243,42 @@ func TestRunSteps_thinOKDoesNotPersistEnhanceHash(t *testing.T) {
}
}
+func TestRunSteps_synthesizedDescDoesNotPersistEnhanceHash(t *testing.T) {
+ e := &Engine{
+ Completer: stubCompleter{fn: func(system, user string) (Completion, error) {
+ // Empty description forces synthesizeProductDescription.
+ return Completion{Text: `{"name":"GIGABYTE GS27QC","description":""}`, TotalTokens: 3}, nil
+ }},
+ Vector: NoopVectorCategorizer{},
+ }
+ descTpl := map[string]any{
+ "sections": []any{
+ map[string]any{"type": "h1", "instructions": "Heading"},
+ map[string]any{"type": "p", "instructions": "Body"},
+ },
+ }
+ out, err := e.RunSteps(context.Background(), "co", ProductInput{
+ Name: "GIGABYTE GS27QC",
+ Mapped: map[string]any{"name": "GIGABYTE GS27QC", "category": "7", "brand": "GIGABYTE"},
+ DescriptionTemplate: descTpl,
+ Language: "sl",
+ }, "enhance_only", nil, StepPolicy{AllowAI: true, AllowEPREL: true})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(out.ProcessedDescription, "
") {
+ t.Fatalf("expected formula synthesize HTML, got %q", out.ProcessedDescription)
+ }
+ if h, _ := out.FieldSources[FieldEnhanceInputHash].(string); h != "" {
+ t.Fatalf("synthesized description must not persist enhance hash (got %q)", h)
+ }
+ for _, lf := range out.LocalizedContent {
+ if lf.EnhanceInputHash != "" {
+ t.Fatalf("localized synthesized hash must be empty: %+v", lf)
+ }
+ }
+}
+
func TestRunSteps_weakPriorHashDoesNotSkipReprocess(t *testing.T) {
calls := 0
e := &Engine{
diff --git a/apps/api/internal/processing/formula_prompt.go b/apps/api/internal/processing/formula_prompt.go
index 72bd125..c91f701 100644
--- a/apps/api/internal/processing/formula_prompt.go
+++ b/apps/api/internal/processing/formula_prompt.go
@@ -76,7 +76,9 @@ func FormatDescriptionFormulaConstraint(template any) string {
return ""
}
var b strings.Builder
- b.WriteString("Description formula (cover each section in order; write in {{language}}):\n")
+ b.WriteString("Description formula (REQUIRED — overrides any shorter \"1-2 sentences\" rule):\n")
+ b.WriteString("Emit description as ONE HTML string covering each section in order in {{language}}.\n")
+ b.WriteString("Use tags matching section type: h1/h2/h3/h4 → …, p →
…
, ul → .\n")
for _, s := range sections {
typ := strings.TrimSpace(s.Type)
instr := strings.TrimSpace(s.Instructions)
@@ -95,6 +97,27 @@ func FormatDescriptionFormulaConstraint(template any) string {
return strings.TrimSpace(b.String())
}
+// descriptionFormulaSystemOverride is appended to the enhance system template when a
+// category description_template is present so company/built-in "1-2 sentences" rules
+// cannot override the per-category formula (A1 category definition pages).
+const descriptionFormulaSystemOverride = `When the user message includes a "Description formula", obey those sections over any shorter "1-2 sentences" / "1-3 paragraphs" guidance: emit one HTML string using tags matching each section type (h1/h2/h3/h4, p, ul), in order, in {{language}}. Never ignore the Description formula.`
+
+// AppendDescriptionFormulaSystemOverride strengthens the system prompt when a
+// description formula is active. No-op when template is empty/unparseable.
+func AppendDescriptionFormulaSystemOverride(systemTpl string, descriptionTemplate any) string {
+ if FormatDescriptionFormulaConstraint(descriptionTemplate) == "" {
+ return strings.TrimSpace(systemTpl)
+ }
+ systemTpl = strings.TrimSpace(systemTpl)
+ if systemTpl == "" {
+ return descriptionFormulaSystemOverride
+ }
+ if strings.Contains(systemTpl, "Description formula") {
+ return systemTpl
+ }
+ return systemTpl + "\n" + descriptionFormulaSystemOverride
+}
+
type titleFormulaElement struct {
Type string `json:"type"`
Value string `json:"value"`
diff --git a/apps/api/internal/processing/formula_prompt_test.go b/apps/api/internal/processing/formula_prompt_test.go
index a511c3b..e311230 100644
--- a/apps/api/internal/processing/formula_prompt_test.go
+++ b/apps/api/internal/processing/formula_prompt_test.go
@@ -38,6 +38,9 @@ func TestFormatDescriptionFormulaConstraint(t *testing.T) {
map[string]any{"type": "ul", "instructions": "3 bullets of specs"},
},
})
+ if !strings.Contains(got, "REQUIRED") || !strings.Contains(got, "") {
+ t.Fatalf("missing required HTML guidance: %s", got)
+ }
if !strings.Contains(got, "- h1: Main product heading") {
t.Fatalf("missing h1: %s", got)
}
@@ -49,6 +52,30 @@ func TestFormatDescriptionFormulaConstraint(t *testing.T) {
}
}
+func TestAppendDescriptionFormulaSystemOverride(t *testing.T) {
+ t.Parallel()
+ desc := map[string]any{
+ "sections": []any{
+ map[string]any{"type": "h1", "instructions": "Heading"},
+ map[string]any{"type": "p", "instructions": "Body"},
+ },
+ }
+ sys, user := resolveProductPromptTemplates(ProductInput{
+ EnhanceSystemTemplate: "Retail product copywriter.\n- description: 1-2 factual sentences",
+ EnhanceUserTemplate: "Name: {{name}}",
+ DescriptionTemplate: desc,
+ })
+ if !strings.Contains(user, "Description formula") {
+ t.Fatalf("missing user formula: %s", user)
+ }
+ if !strings.Contains(sys, "Description formula") || !strings.Contains(sys, "1-2 sentences") {
+ t.Fatalf("system should keep company text and append formula override: %s", sys)
+ }
+ if AppendDescriptionFormulaSystemOverride("plain", nil) != "plain" {
+ t.Fatal("empty formula must be no-op")
+ }
+}
+
func TestAppendFormulaConstraints_injectedIntoResolve(t *testing.T) {
t.Parallel()
title := map[string]any{
diff --git a/apps/api/internal/processing/prompt_render.go b/apps/api/internal/processing/prompt_render.go
index c80251e..b002dbe 100644
--- a/apps/api/internal/processing/prompt_render.go
+++ b/apps/api/internal/processing/prompt_render.go
@@ -25,6 +25,9 @@ func resolveProductPromptTemplates(in ProductInput) (systemTpl, userTpl string)
}
// Category formulas are language-agnostic; inject once into the shared user skeleton.
userTpl = AppendFormulaConstraints(userTpl, in.TitleTemplate, in.DescriptionTemplate)
+ // Company/built-in system prompts often say "1-2 sentences"; when a category
+ // description formula exists, override that so process matches A1 category defs.
+ systemTpl = AppendDescriptionFormulaSystemOverride(systemTpl, in.DescriptionTemplate)
return systemTpl, userTpl
}
diff --git a/apps/api/internal/processing/steps.go b/apps/api/internal/processing/steps.go
index 9c8f902..9e1f4a9 100644
--- a/apps/api/internal/processing/steps.go
+++ b/apps/api/internal/processing/steps.go
@@ -326,19 +326,24 @@ func (e *Engine) RunSteps(ctx context.Context, companyID string, in ProductInput
// Prefer title-aware selection + synthesize before deciding hash persistence.
name = preferredProductTitle(in.GTIN, name, out.Name, priorName, in.Name)
desc = preferredProductDescription(name, desc, out.Description, priorDesc)
+ outerSynth := false
if isWeakPriorEnhanceDescription(desc, name) {
- if synth := synthesizeDescriptionFromTitle(name, displayCat, lang, enhanceAttrs); synth != "" {
+ if synth := synthesizeProductDescription(name, displayCat, lang, enhanceAttrs, descTpl); synth != "" {
desc = synth
+ outerSynth = true
}
}
weakDesc := isWeakPriorEnhanceDescription(desc, name)
// Only persist enhance_input_hash for quality ok / hash-skip unchanged.
- // Never copy input_hash from error/passthrough/thin meta into localized.
+ // Never copy input_hash from error/passthrough/thin/synthesized meta.
persistHash := ""
rawHash := enhanceHashFromMeta(raw)
switch {
case err != nil:
persistHash = ""
+ case status == "synthesized" || outerSynth:
+ persistHash = ""
+ allUnchanged = false
case status == "unchanged" && !weakDesc:
persistHash = rawHash
case status == "ok" && !weakDesc:
@@ -402,7 +407,8 @@ func (e *Engine) RunSteps(ctx context.Context, companyID string, in ProductInput
// Timeout/error/empty: always synthesize a factual fallback when a title exists.
if out.ProcessedName != "" && (out.ProcessedDescription == "" ||
isWeakPriorEnhanceDescription(out.ProcessedDescription, out.ProcessedName, out.Name)) {
- if synth := synthesizeDescriptionFromTitle(out.ProcessedName, displayCat, primary, enhanceAttrs); synth != "" {
+ _, failDescTpl := categoryFormulasFor(in, out.Category)
+ if synth := synthesizeProductDescription(out.ProcessedName, displayCat, primary, enhanceAttrs, failDescTpl); synth != "" {
out.ProcessedDescription = synth
if out.Description == "" || isWeakPriorEnhanceDescription(out.Description, out.Name, out.ProcessedName) {
out.Description = synth
@@ -477,15 +483,16 @@ func (e *Engine) RunSteps(ctx context.Context, companyID string, in ProductInput
if out.ProcessedName == "" {
out.ProcessedName = out.Name
}
+ _, finalDescTpl := categoryFormulasFor(in, out.Category)
if out.ProcessedDescription == "" || isWeakPriorEnhanceDescription(out.ProcessedDescription, out.ProcessedName, out.Name) {
- if synth := synthesizeDescriptionFromTitle(out.ProcessedName, displayCat, in.Language, out.Attributes); synth != "" {
+ if synth := synthesizeProductDescription(out.ProcessedName, displayCat, in.Language, out.Attributes, finalDescTpl); synth != "" {
out.ProcessedDescription = synth
}
}
if out.Description == "" || isWeakPriorEnhanceDescription(out.Description, out.Name, out.ProcessedName) {
if out.ProcessedDescription != "" && !isWeakPriorEnhanceDescription(out.ProcessedDescription, out.Name, out.ProcessedName) {
out.Description = out.ProcessedDescription
- } else if synth := synthesizeDescriptionFromTitle(out.Name, displayCat, in.Language, out.Attributes); synth != "" {
+ } else if synth := synthesizeProductDescription(out.Name, displayCat, in.Language, out.Attributes, finalDescTpl); synth != "" {
out.Description = synth
if out.ProcessedDescription == "" || isWeakPriorEnhanceDescription(out.ProcessedDescription, out.ProcessedName, out.Name) {
out.ProcessedDescription = synth
@@ -657,7 +664,7 @@ func (e *Engine) enhance(ctx context.Context, in ProductInput, category string,
name := preferredProductTitle(in.GTIN, in.Name, in.PriorProcessedName)
desc := preferredProductDescription(name, in.Description, in.PriorProcessedDescription)
if isWeakPriorEnhanceDescription(desc, name) {
- if synth := synthesizeDescriptionFromTitle(name, category, in.Language, attrs); synth != "" {
+ if synth := synthesizeProductDescription(name, category, in.Language, attrs, in.DescriptionTemplate); synth != "" {
desc = synth
}
}
@@ -676,18 +683,22 @@ func (e *Engine) enhance(ctx context.Context, in ProductInput, category string,
}
name := preferredProductTitle(in.GTIN, SanitizeOutput(fmt.Sprint(obj["name"])), in.Name, in.PriorProcessedName)
desc := preferredProductDescription(name, SanitizeOutput(fmt.Sprint(obj["description"])), in.Description, in.PriorProcessedDescription)
+ synthesized := false
if isWeakPriorEnhanceDescription(desc, name) {
- if synth := synthesizeDescriptionFromTitle(name, category, in.Language, attrs); synth != "" {
+ if synth := synthesizeProductDescription(name, category, in.Language, attrs, in.DescriptionTemplate); synth != "" {
desc = synth
+ synthesized = true
}
}
meta := map[string]any{
"status": "ok",
"raw": comp.Raw,
}
- // Only attach input_hash when output description is quality-worthy; thin
- // title-echo / heuristic filler must not poison field_sources / localized skip hashes.
- if !isWeakPriorEnhanceDescription(desc, name) {
+ // Heuristic synthesize must not poison enhance_input_hash (would hash-skip
+ // formula-aware LLM copy on reprocess). Only persist hash for real LLM quality.
+ if synthesized {
+ meta["status"] = "synthesized"
+ } else if !isWeakPriorEnhanceDescription(desc, name) {
meta["input_hash"] = hash
}
return name, desc, comp.TotalTokens, meta, nil
@@ -948,6 +959,73 @@ func formatAttrDimParts(attrs map[string]any, maxParts int) []string {
return parts
}
+// synthesizeProductDescription prefers a category description_template skeleton
+// (HTML section types) when present; otherwise falls back to plain title synthesize.
+func synthesizeProductDescription(title, category, language string, attrs map[string]any, descriptionTemplate any) string {
+ if sections, ok := parseDescriptionFormulaSections(descriptionTemplate); ok && len(sections) > 0 {
+ if out := synthesizeDescriptionFromFormula(title, category, language, attrs, sections); out != "" {
+ return out
+ }
+ }
+ return synthesizeDescriptionFromTitle(title, category, language, attrs)
+}
+
+// synthesizeDescriptionFromFormula builds a minimal HTML description matching
+// category description_template section types so timeout/fallback still respects
+// A1 category structure (unlike plain title synthesize).
+func synthesizeDescriptionFromFormula(title, category, language string, attrs map[string]any, sections []descriptionFormulaSection) string {
+ title = strings.TrimSpace(title)
+ if title == "" || title == "" || isPromptLabelTitle(title) {
+ return ""
+ }
+ base := synthesizeDescriptionFromTitle(title, category, language, attrs)
+ if base == "" {
+ return ""
+ }
+ dims := formatAttrDimParts(attrs, 6)
+ var b strings.Builder
+ paraUsed := false
+ listUsed := false
+ for _, s := range sections {
+ typ := strings.ToLower(strings.TrimSpace(s.Type))
+ switch typ {
+ case "h1", "h2", "h3", "h4":
+ heading := title
+ if typ != "h1" {
+ if isSlovenianContentLanguage(language) {
+ heading = "Ključne lastnosti"
+ } else {
+ heading = "Key features"
+ }
+ }
+ fmt.Fprintf(&b, "<%s>%s%s>", typ, SanitizeOutput(heading), typ)
+ case "ul":
+ b.WriteString("")
+ items := dims
+ if len(items) == 0 {
+ items = []string{base}
+ }
+ for _, it := range items {
+ fmt.Fprintf(&b, "- %s
", SanitizeOutput(it))
+ }
+ b.WriteString("
")
+ listUsed = true
+ default: // p and unknown → paragraph
+ body := base
+ if paraUsed && len(dims) > 0 && !listUsed {
+ if isSlovenianContentLanguage(language) {
+ body = "Ključne specifikacije: " + strings.Join(dims, ", ") + "."
+ } else {
+ body = "Key specs: " + strings.Join(dims, ", ") + "."
+ }
+ }
+ fmt.Fprintf(&b, "%s
", SanitizeOutput(body))
+ paraUsed = true
+ }
+ }
+ return SanitizeOutput(b.String())
+}
+
// synthesizeDescriptionFromTitle builds a short factual fallback when enhance
// returns empty/title-echo/filler copy. Uses title, category, brand, model, and
// key dims. language is a content-language code (en/sl/…) or English label.
diff --git a/apps/api/internal/processing/weak_desc_test.go b/apps/api/internal/processing/weak_desc_test.go
index 08498ad..18513b2 100644
--- a/apps/api/internal/processing/weak_desc_test.go
+++ b/apps/api/internal/processing/weak_desc_test.go
@@ -46,6 +46,30 @@ func TestIsWeakPriorEnhanceDescription_fillerPhrases(t *testing.T) {
}
}
+func TestSynthesizeProductDescription_formulaHTML(t *testing.T) {
+ t.Parallel()
+ title := "GIGABYTE GS27QC"
+ attrs := map[string]any{"brand": "GIGABYTE", "width": "61 cm"}
+ tpl := map[string]any{
+ "sections": []any{
+ map[string]any{"type": "h1", "instructions": "Main heading"},
+ map[string]any{"type": "p", "instructions": "Intro"},
+ map[string]any{"type": "ul", "instructions": "Specs"},
+ },
+ }
+ got := synthesizeProductDescription(title, "Gaming monitorji", "sl", attrs, tpl)
+ if !strings.Contains(got, "") || !strings.Contains(got, "
") || !strings.Contains(got, "
") {
+ t.Fatalf("expected HTML skeleton from formula, got %q", got)
+ }
+ if !strings.Contains(got, title) {
+ t.Fatalf("expected title in formula synth: %q", got)
+ }
+ plain := synthesizeProductDescription(title, "Gaming monitorji", "sl", attrs, nil)
+ if strings.Contains(plain, "") {
+ t.Fatalf("nil formula should stay plain: %q", plain)
+ }
+}
+
func TestSynthesizeDescriptionFromTitle_brandModelCategory(t *testing.T) {
title := "Vogel's WALL 3245 TV Wall Mount"
attrs := map[string]any{