This commit is contained in:
2026-08-16 19:21:49 +02:00
parent d6fe7c38d5
commit 106f602232
8 changed files with 212 additions and 15 deletions
+3 -3
View File
@@ -56,7 +56,7 @@ type DefaultTemplate struct {
// Used by local A1/Demo prompt repair and seed-a1 overlays. // 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). 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 - name: short retail title; follow any Title formula constraints that follow; use Attrs
- description: prefer 1-3 factual paragraphs as ONE string; limited HTML (<h2><p><ul><li>) is allowed if needed — do NOT emit a competing full HTML document, <name>/<metaDescription> 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 (<h2><p><ul><li>) — do NOT emit a competing full HTML document, <name>/<metaDescription> blocks, or separate schema
Category: {{category}} Category: {{category}}
Name: {{name}} Name: {{name}}
@@ -84,8 +84,8 @@ Rules:
- Reply with ONLY JSON (no markdown) - Reply with ONLY JSON (no markdown)
- Schema: {"name":"string","description":"string"} - Schema: {"name":"string","description":"string"}
- name: short retail title - name: short retail title
- description: 1-2 factual sentences; never copy name as description - 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 1-2 factual sentences from Category and Attrs only (do not invent specs) - 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}} - Write name and 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":"Durable widget for everyday use. Clear specs, ready to ship."}
@@ -56,4 +56,10 @@ func TestBuiltInProductEnhanceUsesSharedUserTemplate(t *testing.T) {
if def.UserTemplate != CategoryEnhanceUserTemplate { if def.UserTemplate != CategoryEnhanceUserTemplate {
t.Fatalf("UserTemplate must be 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")
}
} }
@@ -107,7 +107,7 @@ func TestRunSteps_callsEnhanceWhenInputHashDiffers(t *testing.T) {
e := &Engine{ e := &Engine{
Completer: stubCompleter{fn: func(system, user string) (Completion, error) { Completer: stubCompleter{fn: func(system, user string) (Completion, error) {
calls++ 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{}, 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, "<h1>") {
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) { func TestRunSteps_weakPriorHashDoesNotSkipReprocess(t *testing.T) {
calls := 0 calls := 0
e := &Engine{ e := &Engine{
+24 -1
View File
@@ -76,7 +76,9 @@ func FormatDescriptionFormulaConstraint(template any) string {
return "" return ""
} }
var b strings.Builder 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 → <hN>…</hN>, p → <p>…</p>, ul → <ul><li>…</li></ul>.\n")
for _, s := range sections { for _, s := range sections {
typ := strings.TrimSpace(s.Type) typ := strings.TrimSpace(s.Type)
instr := strings.TrimSpace(s.Instructions) instr := strings.TrimSpace(s.Instructions)
@@ -95,6 +97,27 @@ func FormatDescriptionFormulaConstraint(template any) string {
return strings.TrimSpace(b.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 titleFormulaElement struct {
Type string `json:"type"` Type string `json:"type"`
Value string `json:"value"` Value string `json:"value"`
@@ -38,6 +38,9 @@ func TestFormatDescriptionFormulaConstraint(t *testing.T) {
map[string]any{"type": "ul", "instructions": "3 bullets of specs"}, map[string]any{"type": "ul", "instructions": "3 bullets of specs"},
}, },
}) })
if !strings.Contains(got, "REQUIRED") || !strings.Contains(got, "<hN>") {
t.Fatalf("missing required HTML guidance: %s", got)
}
if !strings.Contains(got, "- h1: Main product heading") { if !strings.Contains(got, "- h1: Main product heading") {
t.Fatalf("missing h1: %s", got) 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) { func TestAppendFormulaConstraints_injectedIntoResolve(t *testing.T) {
t.Parallel() t.Parallel()
title := map[string]any{ title := map[string]any{
@@ -25,6 +25,9 @@ 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)
// 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 return systemTpl, userTpl
} }
+88 -10
View File
@@ -326,19 +326,24 @@ func (e *Engine) RunSteps(ctx context.Context, companyID string, in ProductInput
// Prefer title-aware selection + synthesize before deciding hash persistence. // Prefer title-aware selection + synthesize before deciding hash persistence.
name = preferredProductTitle(in.GTIN, name, out.Name, priorName, in.Name) name = preferredProductTitle(in.GTIN, name, out.Name, priorName, in.Name)
desc = preferredProductDescription(name, desc, out.Description, priorDesc) desc = preferredProductDescription(name, desc, out.Description, priorDesc)
outerSynth := false
if isWeakPriorEnhanceDescription(desc, name) { if isWeakPriorEnhanceDescription(desc, name) {
if synth := synthesizeDescriptionFromTitle(name, displayCat, lang, enhanceAttrs); synth != "" { if synth := synthesizeProductDescription(name, displayCat, lang, enhanceAttrs, descTpl); synth != "" {
desc = synth desc = synth
outerSynth = true
} }
} }
weakDesc := isWeakPriorEnhanceDescription(desc, name) weakDesc := isWeakPriorEnhanceDescription(desc, name)
// 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 meta into localized. // Never copy input_hash from error/passthrough/thin/synthesized meta.
persistHash := "" persistHash := ""
rawHash := enhanceHashFromMeta(raw) rawHash := enhanceHashFromMeta(raw)
switch { switch {
case err != nil: case err != nil:
persistHash = "" persistHash = ""
case status == "synthesized" || outerSynth:
persistHash = ""
allUnchanged = false
case status == "unchanged" && !weakDesc: case status == "unchanged" && !weakDesc:
persistHash = rawHash persistHash = rawHash
case status == "ok" && !weakDesc: 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. // Timeout/error/empty: always synthesize a factual fallback when a title exists.
if out.ProcessedName != "" && (out.ProcessedDescription == "" || if out.ProcessedName != "" && (out.ProcessedDescription == "" ||
isWeakPriorEnhanceDescription(out.ProcessedDescription, out.ProcessedName, out.Name)) { 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 out.ProcessedDescription = synth
if out.Description == "" || isWeakPriorEnhanceDescription(out.Description, out.Name, out.ProcessedName) { if out.Description == "" || isWeakPriorEnhanceDescription(out.Description, out.Name, out.ProcessedName) {
out.Description = synth out.Description = synth
@@ -477,15 +483,16 @@ func (e *Engine) RunSteps(ctx context.Context, companyID string, in ProductInput
if out.ProcessedName == "" { if out.ProcessedName == "" {
out.ProcessedName = out.Name out.ProcessedName = out.Name
} }
_, finalDescTpl := categoryFormulasFor(in, out.Category)
if out.ProcessedDescription == "" || isWeakPriorEnhanceDescription(out.ProcessedDescription, out.ProcessedName, out.Name) { 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 out.ProcessedDescription = synth
} }
} }
if out.Description == "" || isWeakPriorEnhanceDescription(out.Description, out.Name, out.ProcessedName) { if out.Description == "" || isWeakPriorEnhanceDescription(out.Description, out.Name, out.ProcessedName) {
if out.ProcessedDescription != "" && !isWeakPriorEnhanceDescription(out.ProcessedDescription, out.Name, out.ProcessedName) { if out.ProcessedDescription != "" && !isWeakPriorEnhanceDescription(out.ProcessedDescription, out.Name, out.ProcessedName) {
out.Description = out.ProcessedDescription 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 out.Description = synth
if out.ProcessedDescription == "" || isWeakPriorEnhanceDescription(out.ProcessedDescription, out.ProcessedName, out.Name) { if out.ProcessedDescription == "" || isWeakPriorEnhanceDescription(out.ProcessedDescription, out.ProcessedName, out.Name) {
out.ProcessedDescription = synth 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) name := preferredProductTitle(in.GTIN, in.Name, in.PriorProcessedName)
desc := preferredProductDescription(name, in.Description, in.PriorProcessedDescription) desc := preferredProductDescription(name, in.Description, in.PriorProcessedDescription)
if isWeakPriorEnhanceDescription(desc, name) { 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 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) 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) desc := preferredProductDescription(name, SanitizeOutput(fmt.Sprint(obj["description"])), in.Description, in.PriorProcessedDescription)
synthesized := false
if isWeakPriorEnhanceDescription(desc, name) { 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 desc = synth
synthesized = true
} }
} }
meta := map[string]any{ meta := map[string]any{
"status": "ok", "status": "ok",
"raw": comp.Raw, "raw": comp.Raw,
} }
// Only attach input_hash when output description is quality-worthy; thin // Heuristic synthesize must not poison enhance_input_hash (would hash-skip
// title-echo / heuristic filler must not poison field_sources / localized skip hashes. // formula-aware LLM copy on reprocess). Only persist hash for real LLM quality.
if !isWeakPriorEnhanceDescription(desc, name) { if synthesized {
meta["status"] = "synthesized"
} else if !isWeakPriorEnhanceDescription(desc, name) {
meta["input_hash"] = hash meta["input_hash"] = hash
} }
return name, desc, comp.TotalTokens, meta, nil return name, desc, comp.TotalTokens, meta, nil
@@ -948,6 +959,73 @@ func formatAttrDimParts(attrs map[string]any, maxParts int) []string {
return parts 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 == "<nil>" || 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("<ul>")
items := dims
if len(items) == 0 {
items = []string{base}
}
for _, it := range items {
fmt.Fprintf(&b, "<li>%s</li>", SanitizeOutput(it))
}
b.WriteString("</ul>")
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, "<p>%s</p>", SanitizeOutput(body))
paraUsed = true
}
}
return SanitizeOutput(b.String())
}
// synthesizeDescriptionFromTitle builds a short factual fallback when enhance // synthesizeDescriptionFromTitle builds a short factual fallback when enhance
// returns empty/title-echo/filler copy. Uses title, category, brand, model, and // 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. // key dims. language is a content-language code (en/sl/…) or English label.
@@ -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, "<h1>") || !strings.Contains(got, "<p>") || !strings.Contains(got, "<ul>") {
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, "<h1>") {
t.Fatalf("nil formula should stay plain: %q", plain)
}
}
func TestSynthesizeDescriptionFromTitle_brandModelCategory(t *testing.T) { func TestSynthesizeDescriptionFromTitle_brandModelCategory(t *testing.T) {
title := "Vogel's WALL 3245 TV Wall Mount" title := "Vogel's WALL 3245 TV Wall Mount"
attrs := map[string]any{ attrs := map[string]any{