fix
This commit is contained in:
@@ -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, "<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) {
|
||||
calls := 0
|
||||
e := &Engine{
|
||||
|
||||
@@ -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 → <hN>…</hN>, p → <p>…</p>, ul → <ul><li>…</li></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"`
|
||||
|
||||
@@ -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, "<hN>") {
|
||||
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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 == "<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
|
||||
// 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.
|
||||
|
||||
@@ -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) {
|
||||
title := "Vogel's WALL 3245 TV Wall Mount"
|
||||
attrs := map[string]any{
|
||||
|
||||
Reference in New Issue
Block a user