This commit is contained in:
2026-08-23 20:49:40 +02:00
parent 3b678ca857
commit f3d4fb56ed
31 changed files with 3608 additions and 111 deletions
+102 -22
View File
@@ -14,7 +14,10 @@ import (
// <metaDescription> from the HTML reply. v2 must recreate that GPT-predloga
// dominance while keeping JSON {"name","description","meta_*","attrs"}.
const a1StyleEnhanceSystemTemplate = `Si tekstopisec, ki piše opise izdelkov v {{language}}.
// {{language}} renders an English label ("Slovenian"), so both templates name the
// language after a colon instead of inflecting it — legacy "v Slovenščini" read as
// "v Slovenian" and put a grammatical error in front of every A1 prompt.
const a1StyleEnhanceSystemTemplateSL = `Si tekstopisec, ki piše opise izdelkov v tem jeziku: {{language}}.
Rules:
- Reply with ONLY JSON (no markdown)
- Schema: {"name":"string","description":"string","meta_title":"string","meta_description":"string","attrs":{}}
@@ -25,6 +28,20 @@ Rules:
- attrs: only Allowed attribute keys when listed; fill from evidence only; omit unknowns
{{brand_voice}}`
// a1StyleEnhanceSystemTemplateEN is the same contract for the English
// platform-default formulas, matching the English scaffolding in a1StyleLabelsEN.
const a1StyleEnhanceSystemTemplateEN = `You are a copywriter who writes product descriptions in this language: {{language}}.
Rules:
- Reply with ONLY JSON (no markdown)
- Schema: {"name":"string","description":"string","meta_title":"string","meta_description":"string","attrs":{}}
- Obey the product template in the user message step by step
- "name" follows the <name> formula — never copy Old_product_name unchanged when the formula asks for a new name
- "description" is ONE HTML string covering each product template body section in order (tags matching type: h1/h2/h3/h4, p, ul) — do NOT wrap the reply in <name> or <metaDescription> tags
- "meta_title" and "meta_description" are plain SEO text from the meta / <metaDescription> formula (never HTML body)
- Use only the supplied Old_product_name, Old_product_description, Category and Attrs as facts; never invent specifications
- attrs: only Allowed attribute keys when listed; fill from evidence only; omit unknowns
{{brand_voice}}`
var (
reLegacyNameCapture = regexp.MustCompile(`(?is)<\s*name\s*>\s*(.*?)\s*<\s*/\s*name\s*>`)
reLegacyMetaCapture = regexp.MustCompile(`(?is)<\s*metaDescription\s*>\s*(.*?)\s*<\s*/\s*metaDescription\s*>`)
@@ -60,7 +77,7 @@ func categoryUsesA1StyleFormula(catPrompt string, titleTemplate, descriptionTemp
// buildA1StyleEnhanceUserTemplate rebuilds the legacy GPT-predloga user message
// (Title → <name>, Meta → <metaDescription>, Description HTML blocks) plus product
// evidence placeholders, asking for JSON field mapping.
func buildA1StyleEnhanceUserTemplate(catPrompt string, titleTemplate, descriptionTemplate any, omitSEOMeta bool) string {
func buildA1StyleEnhanceUserTemplate(catPrompt string, titleTemplate, descriptionTemplate any, omitSEOMeta bool) (string, bool) {
titleInstr := strings.TrimSpace(aiprompts.TitleSectionInstructions(catPrompt))
if titleInstr == "" || isBuiltInTitleRoleBoilerplate(titleInstr) {
titleInstr = ""
@@ -105,37 +122,100 @@ func buildA1StyleEnhanceUserTemplate(catPrompt string, titleTemplate, descriptio
predloga.WriteByte('\n')
}
var b strings.Builder
b.WriteString("Ustvari nov opis izdelka v {{language}} z naslednjimi spremenljivkami:\n\n")
b.WriteString("Staro_ime_izdelka: {{name}}\n")
b.WriteString("Star_opis_izdelka: {{description}}\n")
b.WriteString("Kategorija: {{category}}\n")
b.WriteString("Attrs: {{attrs}}\n\n")
b.WriteString("Uporabi spodnjo GPT predlogo.\n")
b.WriteString("Sledi tej GPT predlogi stavek po stavek in sestavi nov opis izdelka.\n\n")
b.WriteString("GPT predloga:\n\n")
b.WriteString(strings.TrimSpace(predloga.String()))
b.WriteString("\n\n")
b.WriteString("Odgovori SAMO z enim JSON objektom:\n")
b.WriteString(`- "name" = rezultat <name> formule (novo ime; ne kopiraj Staro_ime_izdelka, če formula zahteva novo ime)` + "\n")
b.WriteString(`- "description" = HTML telo po GPT predlogi (vsi razdelki po vrsti), BREZ <name>/<metaDescription> tagov` + "\n")
if !omitSEOMeta {
b.WriteString(`- "meta_title" / "meta_description" = plain SEO po meta / <metaDescription> formuli` + "\n")
body := strings.TrimSpace(predloga.String())
slovenian := reSlovenianFormulaCue.MatchString(body)
l := a1StyleLabelsEN
if slovenian {
l = a1StyleLabelsSL
}
b.WriteString(`- "attrs" = atributi (samo Allowed attribute keys, če so navedeni)`)
var b strings.Builder
fmt.Fprintf(&b, "%s\n\n", l.intro)
fmt.Fprintf(&b, "%s: {{name}}\n", l.oldName)
fmt.Fprintf(&b, "%s: {{description}}\n", l.oldDesc)
fmt.Fprintf(&b, "%s: {{category}}\n", l.category)
b.WriteString("Attrs: {{attrs}}\n\n")
fmt.Fprintf(&b, "%s\n%s\n\n", l.useTemplate, l.followTemplate)
fmt.Fprintf(&b, "%s\n\n", l.templateHeader)
b.WriteString(body)
b.WriteString("\n\n")
fmt.Fprintf(&b, "%s\n", l.replyHeader)
fmt.Fprintf(&b, "%s\n", l.nameRule)
fmt.Fprintf(&b, "%s\n", l.descRule)
if !omitSEOMeta {
fmt.Fprintf(&b, "%s\n", l.metaRule)
}
b.WriteString(l.attrsRule)
// Free-form category overlay (no role markers) — keep as extra guidance like
// soft "Category guidance" did, without replacing the GPT predloga.
// soft "Category guidance" did, without replacing the template.
extra := strings.TrimSpace(catPrompt)
if extra != "" &&
!aiprompts.CategoryEnhanceHasRoleSections(extra) &&
!aiprompts.IsLegacyCombinedEnhancePrompt(extra) {
b.WriteString("\n\nDodatna kategorijska navodila:\n")
fmt.Fprintf(&b, "\n\n%s\n", l.extraHeader)
b.WriteString(extra)
}
return strings.TrimSpace(b.String())
return strings.TrimSpace(b.String()), slovenian
}
// a1StyleLabels are the scaffolding strings around a category formula: how the
// supplied variables are labelled and how the reply is framed.
type a1StyleLabels struct {
intro string
oldName string
oldDesc string
category string
useTemplate string
followTemplate string
templateHeader string
replyHeader string
nameRule string
descRule string
metaRule string
attrsRule string
extraHeader string
}
var a1StyleLabelsSL = a1StyleLabels{
intro: "Ustvari nov opis izdelka v tem jeziku: {{language}}, z naslednjimi spremenljivkami:",
oldName: "Staro_ime_izdelka",
oldDesc: "Star_opis_izdelka",
category: "Kategorija",
useTemplate: "Uporabi spodnjo GPT predlogo.",
followTemplate: "Sledi tej GPT predlogi stavek po stavek in sestavi nov opis izdelka.",
templateHeader: "GPT predloga:",
replyHeader: "Odgovori SAMO z enim JSON objektom:",
nameRule: `- "name" = rezultat <name> formule (novo ime; ne kopiraj Staro_ime_izdelka, če formula zahteva novo ime)`,
descRule: `- "description" = HTML telo po GPT predlogi (vsi razdelki po vrsti), BREZ <name>/<metaDescription> tagov`,
metaRule: `- "meta_title" / "meta_description" = plain SEO po meta / <metaDescription> formuli`,
attrsRule: `- "attrs" = atributi (samo Allowed attribute keys, če so navedeni)`,
extraHeader: "Dodatna kategorijska navodila:",
}
var a1StyleLabelsEN = a1StyleLabels{
intro: "Create a new product description in {{language}} using the following variables:",
oldName: "Old_product_name",
oldDesc: "Old_product_description",
category: "Category",
useTemplate: "Use the product template below.",
followTemplate: "Follow this product template sentence by sentence and compose a new product description.",
templateHeader: "Product template:",
replyHeader: "Reply with ONE JSON object only:",
nameRule: `- "name" = the result of the <name> formula (a new name; do not copy Old_product_name when the formula asks for a new one)`,
descRule: `- "description" = the HTML body per the product template (every section in order), WITHOUT <name>/<metaDescription> tags`,
metaRule: `- "meta_title" / "meta_description" = plain SEO text per the meta / <metaDescription> formula`,
attrsRule: `- "attrs" = attributes (only Allowed attribute keys when listed)`,
extraHeader: "Additional category instructions:",
}
// reSlovenianFormulaCue keeps the scaffolding in the same language the formula
// speaks. A1's legacy formulas refer to "Novo ime izdelka" and were written against
// the Slovenian variable labels in generator.php, so those keep the Slovenian
// frame; the English platform defaults get the English one. Output language is set
// by {{language}} in the system prompt either way.
var reSlovenianFormulaCue = regexp.MustCompile(`(?i)novo[ _]ime[ _]izdelka|napiši|napisi|izpostavi|izdelka|znamka`)
func collapseLegacyInstr(s string) string {
s = strings.TrimSpace(s)
s = strings.ReplaceAll(s, "\r\n", "\n")
@@ -26,7 +26,10 @@ func TestBuildA1StyleEnhanceUserTemplate_mirrorsLegacyGenerator(t *testing.T) {
if !categoryUsesA1StyleFormula(prompt, nil, descTpl) {
t.Fatal("expected A1-style formula detection")
}
user := buildA1StyleEnhanceUserTemplate(prompt, nil, descTpl, false)
user, slovenian := buildA1StyleEnhanceUserTemplate(prompt, nil, descTpl, false)
if !slovenian {
t.Fatal("Slovenian A1 formula must keep the Slovenian scaffolding")
}
for _, want := range []string{
"GPT predloga:",
"Sledi tej GPT predlogi",
+4
View File
@@ -126,6 +126,10 @@ type ProductInput struct {
// CategoryUniqueID is the taxonomy unique_id for overlay/formula keys when
// the enhance display category argument is a localized name.
CategoryUniqueID string
// CategoryDisplayName is the resolved category label. Together with
// CategoryUniqueID it selects the platform-default formula for categories that
// carry no prompt/formula of their own (aiprompts.DefaultCategoryFormula).
CategoryDisplayName string
// OmitSEOMeta skips meta_title / meta_description enhance + free-template fill
// (A1 cohort does not use SEO meta fields).
OmitSEOMeta bool
@@ -0,0 +1,152 @@
package processing
import (
"context"
"encoding/json"
"strings"
"testing"
"github.com/descrybe/descrybe-v2/apps/api/internal/aiprompts"
"github.com/descrybe/descrybe-v2/apps/api/internal/company"
)
// A categorised product with no formula of its own enhances through the English
// platform default rather than the generic "1-3 paragraphs" rule.
func TestWithDefaultCategoryFormula_appliesToCategorisedProduct(t *testing.T) {
t.Parallel()
got := withDefaultCategoryFormula(ProductInput{
CategoryUniqueID: "high-chairs",
CategoryDisplayName: "High chairs",
})
if got.CategoryEnhancePrompt == "" {
t.Fatal("expected the platform default overlay")
}
if FormatTitleFormulaConstraint(got.TitleTemplate) == "" {
t.Fatalf("expected a default title formula, got %#v", got.TitleTemplate)
}
if FormatDescriptionFormulaConstraint(got.DescriptionTemplate) == "" {
t.Fatalf("expected a default description formula, got %#v", got.DescriptionTemplate)
}
}
func TestWithDefaultCategoryFormula_doesNotOverride(t *testing.T) {
t.Parallel()
cases := map[string]ProductInput{
"no category at all": {},
"category prompt already set": {
CategoryUniqueID: "high-chairs",
CategoryEnhancePrompt: "--- Title ---\nKeep the supplier title.\n--- End Title ---",
},
"tenant-authored enhance template": {
CategoryUniqueID: "high-chairs",
EnhanceUserTemplate: "Write a haiku about {{name}}",
},
"category has its own title formula": {
CategoryUniqueID: "high-chairs",
TitleTemplate: map[string]any{"separator": " ", "elements": []any{
map[string]any{"type": "variable", "value": "product_type"},
map[string]any{"type": "variable", "value": "brand"},
}},
},
}
for name, in := range cases {
t.Run(name, func(t *testing.T) {
got := withDefaultCategoryFormula(in)
if got.CategoryEnhancePrompt != in.CategoryEnhancePrompt {
t.Fatalf("platform default overrode an explicit choice: %q", got.CategoryEnhancePrompt)
}
})
}
}
// A1's Slovenian formulas keep the Slovenian scaffolding and system prompt they had
// in generator.php; the English defaults get an English frame. Output language is
// set by {{language}} in both cases.
func TestResolveProductPromptTemplates_scaffoldingFollowsFormulaLanguage(t *testing.T) {
t.Parallel()
slovenian := aiprompts.SplitLegacyCombinedEnhancePrompt(a1StolckiLegacyPrompt)
sysSL, userSL := resolveProductPromptTemplates(ProductInput{
CategoryUniqueID: "stolcki",
CategoryDisplayName: "Stolčki",
CategoryEnhancePrompt: slovenian,
Language: "sl",
})
if !strings.Contains(sysSL, "Si tekstopisec") {
t.Fatalf("Slovenian formula must keep the Slovenian system prompt:\n%s", sysSL)
}
for _, want := range []string{"GPT predloga:", "Staro_ime_izdelka: {{name}}", "<name>{"} {
if !strings.Contains(userSL, want) {
t.Fatalf("Slovenian scaffolding missing %q:\n%s", want, userSL)
}
}
sysEN, userEN := resolveProductPromptTemplates(ProductInput{
CategoryUniqueID: "high-chairs",
CategoryDisplayName: "High chairs",
})
if !strings.Contains(sysEN, "You are a copywriter") {
t.Fatalf("English default must use the English system prompt:\n%s", sysEN)
}
for _, want := range []string{"Product template:", "Old_product_name: {{name}}", "<name>{"} {
if !strings.Contains(userEN, want) {
t.Fatalf("English scaffolding missing %q:\n%s", want, userEN)
}
}
if strings.Contains(userEN, "GPT predloga") || strings.Contains(userEN, "Staro_ime_izdelka") {
t.Fatalf("English default leaked Slovenian scaffolding:\n%s", userEN)
}
}
// End to end through RunSteps: a category with no stored formula still produces
// formula-shaped HTML and a rebuilt name, and the feed is only used as evidence.
func TestRunSteps_defaultFormulaDrivesGeneration(t *testing.T) {
t.Parallel()
reply, _ := json.Marshal(map[string]any{
"name": "BRUNNER folding chair ONE SHOT 0404164N.C20",
"description": "<h2>BRUNNER folding chair for life on the road</h2><p>" +
strings.Repeat("A light aluminium frame opens in one motion and stays steady on uneven ground. ", 4) +
"</p><h2>Built to last a season outdoors</h2><p>" +
strings.Repeat("Reinforced joints take the load where it matters and wipe clean in seconds. ", 4) +
"</p><b>Technical specifications</b><ul><li>Brand: BRUNNER</li><li>Material: aluminium</li></ul>",
"attrs": map[string]any{"brand": "BRUNNER", "product_model": "ONE SHOT 0404164N.C20"},
})
c := &formulaRegressionCompleter{reply: string(reply)}
feedName := "BRUNNER folding camping chair ONE SHOT grey black 0404164N.C20"
feedDesc := "An elegant and very light chair designed for art directors. Folding aluminium frame and armrests."
out, err := (&Engine{Completer: c}).RunSteps(context.Background(), "co", ProductInput{
GTIN: "8022068075495",
Name: feedName,
Description: feedDesc,
Mapped: map[string]any{
"name": feedName, "description": feedDesc,
"brand": "BRUNNER", "category": "high-chairs",
"specifications": map[string]any{"Material": "aluminium"},
},
Language: "en",
ContentLanguages: []string{"en"},
CategoryNamesByUID: map[string]string{"high-chairs": "High chairs"},
}, "full", []string{"high-chairs"}, StepPolicy{AllowAI: true})
if err != nil {
t.Fatal(err)
}
if c.calls != 1 {
t.Fatalf("a compliant reply must not need a retry, calls=%d", c.calls)
}
if !strings.Contains(c.user, "Product template:") || !strings.Contains(c.user, "<H2>{") {
t.Fatalf("default formula never reached the prompt:\n%s", c.user)
}
if out.ProcessedName == out.Name || out.ProcessedDescription == out.Description {
t.Fatal("enriched output must differ from the feed")
}
if !strings.Contains(out.ProcessedDescription, "<h2>") || !strings.Contains(out.ProcessedDescription, "<ul>") {
t.Fatalf("processed description is not formula HTML: %q", out.ProcessedDescription)
}
if out.Name != feedName || out.Description != feedDesc {
t.Fatalf("Original must stay the feed: name=%q desc=%q", out.Name, out.Description)
}
if h, _ := out.FieldSources[FieldEnhanceInputHash].(string); h == "" {
t.Fatal("quality enhance under the default formula must persist a hash")
}
_ = company.DefaultLanguage
}
@@ -0,0 +1,269 @@
package processing
import (
"context"
"encoding/json"
"strings"
"testing"
"github.com/descrybe/descrybe-v2/apps/api/internal/aiprompts"
"github.com/descrybe/descrybe-v2/apps/api/internal/company"
)
// Regressions for "enhance just copies the feed" on A1-style category formulas.
// Fixture is the reported product (GTIN 8022068075495, category Stolčki) with the
// committed A1 seed prompt and the formula columns as the live A1 catalog stores
// them: a brand-only title_template stub and a generic English description_template.
type formulaRegressionCompleter struct {
system, user string
calls int
reply string
}
func (c *formulaRegressionCompleter) Complete(_ context.Context, system, user string) (Completion, error) {
c.calls++
c.system, c.user = system, user
return Completion{Text: c.reply, TotalTokens: 10}, nil
}
func (c *formulaRegressionCompleter) Enabled() bool { return true }
// a1StolckiLegacyPrompt is the "Stolčki" entry of scripts/seed/a1-category-prompts.json.
const a1StolckiLegacyPrompt = "Ustvari nov opis izdelka v Slovenščini z naslednjimi spremenljivkami:\n\n" +
"Star_opis_izdelka: {\"\"OPIS IZDELKA\"\"};\nStaro_ime_izdelka: {\"\"STARO IME IZDELKA\"\"};\n\n" +
"Uporabi spodnjo GPT predlogo. \n\nSledi tej GPT predlogi stavek po stavek in sestavi nov opis izdelka:\n\nGPT predloga:\n\n\n" +
"<name>{Napiši novo ime izdelka po formuli: \"\"znamka s pravilno kapitalizacijo\"\", \"\"tip izdelka lowercase\"\", " +
"\"\"\"poln model izdelka, če lahko z besedo in ID uppercase\"\"\". Ne uporabljaj vejic.}</name>\n" +
"<metaDescription>{Najprej napiši Novo_ime_izdelka in potem nadaljuj dokler nisi zapisal 140 znakov vključno s presledki}</metaDescription>\n\n" +
"<H2>{Napiši Novo ime izdelka in izpostavi en benefit}</H2>\n" +
"<p>{Napiši odstavek, ki je dolg 100 besed, ki NE vsebuje Novo ime izdelka.}</p>\n" +
"<H2>{Izpostavi en benefit, in NE napiši Novo ime izdelka}</H2>\n" +
"<p>{Napiši odstavek, ki je dolg 100 besed in VKLJUČI tudi Novo ime izdelka.}</p>" +
"<b>{Tehnične specifikacije}</b><ul><li>{Napiši 3-10 tehničnih specifikacij v alinejah}</li></ul>"
const a1BrandOnlyTitleTemplate = `{"separator":" ","elements":[` +
`{"id":"0-variable-brand","type":"variable","label":"Znamka","value":"brand","example":"Samsung"}]}`
const a1GenericEnglishDescTemplate = `{"metaTitle":"Create a concise and compelling Meta Title.",` +
`"metaDescription":"Write a short Meta Description.",` +
`"sections":[{"id":"a7908e85","type":"p","instructions":"Write a detailed paragraph about specific features or benefits."}]}`
const a1FeedName = "BRUNNER zlažljiv stol za kampiranje ONE SHOT sivo črn 0404164N.C20"
const a1FeedDesc = "Eleganten in lahek stol za umetniške direktorje. Zložljiv aluminijast okvir, udobno sedišče in naslon za roke."
const a1FormulaName = "BRUNNER zložljiv stol za kampiranje ONE SHOT 0404164N.C20"
// a1FormulaReply obeys the Stolčki formula: h2 + p + h2 + p + spec list, in
// Slovenian. Its prose contains " je " and its spec list contains "Znamka:" —
// the combination that used to be misread as invent fallback and discarded.
const a1FormulaReply = `{"name":"` + a1FormulaName + `",
"description":"<h2>BRUNNER zložljiv stol ONE SHOT za udobje na poti</h2><p>Zložljiv stol iz aluminija je zasnovan za dolge ure udobnega sedenja na terenu, na snemanju ali ob kampiranju. Okvir se razpre v enem gibu in ostane stabilen tudi na neravnih tleh, medtem ko tkanina sedišča prijetno diha in se hitro suši. Naslonjala za roke razbremenijo ramena, hrbtni del pa podpira naravno držo. Zaradi majhne teže ga brez napora prenesete od avtomobila do prizorišča, zložen pa zavzame le malo prostora v prtljažniku.</p><h2>Stabilnost in dolga življenjska doba</h2><p>Konstrukcija združuje trpežne materiale in premišljene detajle. Aluminijasti profili so odporni proti koroziji, spoji pa so ojačani na mestih največjih obremenitev. Sivo črna kombinacija ostaja videti urejena tudi po sezoni uporabe na prostem, saj se madeži manj poznajo. Stol je enostavno očistiti z vlažno krpo, po uporabi pa ga preprosto zložite in shranite.</p><b>Tehnične specifikacije</b><ul><li>Znamka: BRUNNER</li><li>Model: ONE SHOT 0404164N.C20</li><li>Barva: sivo črna</li><li>Material okvirja: aluminij</li></ul>",
"meta_title":"BRUNNER zložljiv stol ONE SHOT",
"meta_description":"BRUNNER zložljiv stol za kampiranje ONE SHOT je lahek in stabilen zložljiv stol z udobnim sediščem za teren.",
"attrs":{"brand":"BRUNNER","product_model":"ONE SHOT 0404164N.C20"}}`
func jsonObject(t *testing.T, raw string) map[string]any {
t.Helper()
var m map[string]any
if err := json.Unmarshal([]byte(raw), &m); err != nil {
t.Fatal(err)
}
return m
}
func a1ProductInput(t *testing.T, catPrompt string, titleTpl, descTpl any) ProductInput {
t.Helper()
in := ProductInput{
GTIN: "8022068075495",
Name: a1FeedName,
Description: a1FeedDesc,
Mapped: map[string]any{
"name": a1FeedName,
"description": a1FeedDesc,
"brand": "BRUNNER",
"gtin": "8022068075495",
"category": "stolcki",
"specifications": map[string]any{"Barva": "sivo črna", "Material": "aluminij"},
},
Raw: map[string]any{},
Language: "sl",
ContentLanguages: []string{"sl"},
CategoryFormulasByKey: map[string]CategoryFormulas{
"stolcki": {TitleTemplate: titleTpl, DescriptionTemplate: descTpl},
},
CategoryNamesByUID: map[string]string{"stolcki": "Stolčki"},
OmitSEOMeta: true,
}
if catPrompt != "" {
in.CategoryPromptsByLang = map[string]company.LangPromptMap{
"stolcki": {"sl": catPrompt, "*": catPrompt},
}
}
return in
}
// Formula-compliant Slovenian copy must reach processed_* on every processing type
// the dashboard starts. It used to be dropped by the invent heuristic, leaving the
// supplier feed text on both sides of Review ("Matched").
func TestRunSteps_A1FormulaCopyReachesProcessedFields(t *testing.T) {
t.Parallel()
catPrompt := aiprompts.SplitLegacyCombinedEnhancePrompt(a1StolckiLegacyPrompt)
in := a1ProductInput(t, catPrompt,
jsonObject(t, a1BrandOnlyTitleTemplate), jsonObject(t, a1GenericEnglishDescTemplate))
// "title" / "description" / "enhance" are what products/+page.svelte sends.
for _, ptype := range []string{"full", "enhance", "title", "description"} {
t.Run(ptype, func(t *testing.T) {
c := &formulaRegressionCompleter{reply: a1FormulaReply}
out, err := (&Engine{Completer: c}).RunSteps(
context.Background(), "co", in, ptype, []string{"stolcki"}, StepPolicy{AllowAI: true})
if err != nil {
t.Fatal(err)
}
if c.calls != 1 {
t.Fatalf("compliant reply must not trigger a formula retry, calls=%d", c.calls)
}
if out.ProcessedName != a1FormulaName {
t.Fatalf("ProcessedName=%q want the formula name %q", out.ProcessedName, a1FormulaName)
}
if !strings.Contains(out.ProcessedDescription, "<h2>") ||
!strings.Contains(out.ProcessedDescription, "<ul>") {
t.Fatalf("ProcessedDescription lost the formula HTML: %q", out.ProcessedDescription)
}
if out.ProcessedDescription == out.Description || out.ProcessedName == out.Name {
t.Fatal("Review would show Matched — enriched must differ from Original")
}
if out.Name != a1FeedName || out.Description != a1FeedDesc {
t.Fatalf("Original must stay supplier copy: name=%q desc=%q", out.Name, out.Description)
}
if h, _ := out.FieldSources[FieldEnhanceInputHash].(string); h == "" {
t.Fatal("quality enhance must persist enhance_input_hash so reprocess can skip")
}
})
}
}
// The formula only keys when enhance is fed a category and attributes. Partial
// enhance types used to run normalize+ai_enhance alone, so the prompt carried an
// empty Attrs line and (for feeds without a mapped category) no category at all.
func TestRunSteps_partialEnhanceStillFeedsFormulaInputs(t *testing.T) {
t.Parallel()
catPrompt := aiprompts.SplitLegacyCombinedEnhancePrompt(a1StolckiLegacyPrompt)
in := a1ProductInput(t, catPrompt,
jsonObject(t, a1BrandOnlyTitleTemplate), jsonObject(t, a1GenericEnglishDescTemplate))
for _, ptype := range []string{"enhance", "title", "description"} {
t.Run(ptype, func(t *testing.T) {
c := &formulaRegressionCompleter{reply: a1FormulaReply}
if _, err := (&Engine{Completer: c}).RunSteps(
context.Background(), "co", in, ptype, []string{"stolcki"}, StepPolicy{AllowAI: true}); err != nil {
t.Fatal(err)
}
if !strings.Contains(c.user, "Kategorija: Stolčki") {
t.Fatalf("category missing from enhance prompt:\n%s", c.user)
}
if !strings.Contains(c.user, `"brand":"BRUNNER"`) {
t.Fatalf("attrs missing from enhance prompt:\n%s", c.user)
}
if !strings.Contains(c.user, "GPT predloga:") || !strings.Contains(c.user, "<name>{") {
t.Fatalf("category formula missing from enhance prompt:\n%s", c.user)
}
})
}
}
// A model that echoes the supplier name/description must never be recorded as a
// successful enhance: no enhance_input_hash (so reprocess retries) and Original
// keeps the feed copy.
func TestRunSteps_feedEchoIsNotRecordedAsEnhanced(t *testing.T) {
t.Parallel()
catPrompt := aiprompts.SplitLegacyCombinedEnhancePrompt(a1StolckiLegacyPrompt)
echo, _ := json.Marshal(map[string]any{"name": a1FeedName, "description": a1FeedDesc})
c := &formulaRegressionCompleter{reply: string(echo)}
in := a1ProductInput(t, catPrompt,
jsonObject(t, a1BrandOnlyTitleTemplate), jsonObject(t, a1GenericEnglishDescTemplate))
out, err := (&Engine{Completer: c}).RunSteps(
context.Background(), "co", in, "enhance", []string{"stolcki"}, StepPolicy{AllowAI: true})
if err != nil {
t.Fatal(err)
}
if c.calls < 2 {
t.Fatalf("feed echo must trigger the formula/title retry, calls=%d", c.calls)
}
if h, _ := out.FieldSources[FieldEnhanceInputHash].(string); h != "" {
t.Fatalf("feed echo must not persist an enhance hash, got %q", h)
}
if out.Description != a1FeedDesc {
t.Fatalf("Original description must stay supplier copy, got %q", out.Description)
}
}
// Original mirrors the feed. A supplier description that trips a quality heuristic
// must still show up as Original rather than being blanked or replaced with the
// enriched text (that replacement is what Review reported as "Matched").
func TestRunSteps_originalNeverBorrowsEnrichedCopy(t *testing.T) {
t.Parallel()
weakFeedDesc := "Ta stol je izdelek znamke BRUNNER."
if !company.LooksLikeHeuristicSynthesize(weakFeedDesc) {
t.Fatalf("fixture should trip the invent heuristic: %q", weakFeedDesc)
}
catPrompt := aiprompts.SplitLegacyCombinedEnhancePrompt(a1StolckiLegacyPrompt)
in := a1ProductInput(t, catPrompt,
jsonObject(t, a1BrandOnlyTitleTemplate), jsonObject(t, a1GenericEnglishDescTemplate))
in.Description = weakFeedDesc
in.Mapped["description"] = weakFeedDesc
c := &formulaRegressionCompleter{reply: a1FormulaReply}
out, err := (&Engine{Completer: c}).RunSteps(
context.Background(), "co", in, "enhance", []string{"stolcki"}, StepPolicy{AllowAI: true})
if err != nil {
t.Fatal(err)
}
if out.Description != weakFeedDesc {
t.Fatalf("Original must stay the supplier text, got %q", out.Description)
}
if out.ProcessedDescription == out.Description {
t.Fatal("enriched must differ from Original")
}
}
// A brand-only title_template ({elements:[brand]}) is a migration stub, not a
// formula: instructing "name = brand" produces a title the picker then discards in
// favour of the feed name — an LLM call spent to reproduce the supplier title.
func TestFormatTitleFormulaConstraint_ignoresBrandOnlyStub(t *testing.T) {
t.Parallel()
if got := FormatTitleFormulaConstraint(jsonObject(t, a1BrandOnlyTitleTemplate)); got != "" {
t.Fatalf("brand-only stub must not read as a Title formula, got:\n%s", got)
}
real := map[string]any{"separator": " ", "elements": []any{
map[string]any{"type": "variable", "value": "product_type"},
map[string]any{"type": "variable", "value": "brand"},
map[string]any{"type": "variable", "value": "product_model"},
}}
if FormatTitleFormulaConstraint(real) == "" {
t.Fatal("a real multi-slot title formula must still produce constraints")
}
}
// A formula name that is shorter than — and a prefix of — the supplier name must
// survive: preferredProductTitle's brand-stub rule would otherwise hand the feed
// title back and undo the Title formula.
func TestFormulaAwareTitle_keepsShortFormulaNameOverSupplierPrefix(t *testing.T) {
t.Parallel()
in := ProductInput{
Name: "BRUNNER stol za kampiranje ONE SHOT sivo črn 0404164N.C20",
CategoryEnhancePrompt: aiprompts.SplitLegacyCombinedEnhancePrompt(a1StolckiLegacyPrompt),
}
if !categoryRequiresTitleRewrite(in) {
t.Fatal("A1 Title section must count as a rewrite formula")
}
if got := formulaAwareTitle(in, "BRUNNER stol"); got != "BRUNNER stol" {
t.Fatalf("formula name dropped for the supplier title: %q", got)
}
// Without a category rewrite rule the richer supplier title still wins.
plain := ProductInput{Name: in.Name}
if got := formulaAwareTitle(plain, "BRUNNER stol"); got != in.Name {
t.Fatalf("without a formula the fuller supplier title should win, got %q", got)
}
}
@@ -34,6 +34,36 @@ func TestHashEnhanceInput_stableAndSensitive(t *testing.T) {
}
}
// enhanceHashFor runs the real pipeline once with a compliant stub reply and
// returns the enhance_input_hash it stamped. Deriving it this way keeps hash-skip
// tests correct as the steps feeding enhance (parsed specs, filled fields,
// category) evolve — hard-coding HashEnhanceInput args silently drifts instead.
func enhanceHashFor(t *testing.T, in ProductInput, processingType string) string {
t.Helper()
e := &Engine{
Completer: stubCompleter{fn: func(string, string) (Completion, error) {
return Completion{
Text: `{"name":"Seed Title","description":"Seed retail copy with enough factual detail to pass the enhance quality gate."}`,
TotalTokens: 1,
}, nil
}},
Vector: NoopVectorCategorizer{},
}
seed := in
seed.PriorEnhanceHash = ""
seed.PriorProcessedName = ""
seed.PriorProcessedDescription = ""
out, err := e.RunSteps(context.Background(), "co", seed, processingType, nil, StepPolicy{AllowAI: true, AllowEPREL: true})
if err != nil {
t.Fatal(err)
}
hash, _ := out.FieldSources[FieldEnhanceInputHash].(string)
if hash == "" {
t.Fatalf("seed run did not stamp an enhance hash: sources=%v notes=%v", out.FieldSources, out.Notes)
}
return hash
}
func TestRunSteps_skipsEnhanceWhenInputHashUnchanged(t *testing.T) {
calls := 0
e := &Engine{
@@ -51,13 +81,7 @@ func TestRunSteps_skipsEnhanceWhenInputHashUnchanged(t *testing.T) {
PriorProcessedName: "Cached Widget",
PriorProcessedDescription: priorDesc,
}
// First pass without prior hash to compute the hash shape via enhance path is awkward;
// compute the same hash RunSteps will see after normalize (name/desc from mapped).
// enhance_only: normalize then enhance with out.Name from normalized.
normName := "Widget"
normDesc := "A widget with enough mapped detail for hashing."
sysTpl, userTpl := resolveProductPromptTemplates(ProductInput{})
in.PriorEnhanceHash = HashEnhanceInput("", normName, normDesc, "", "", sysTpl, userTpl, map[string]any{})
in.PriorEnhanceHash = enhanceHashFor(t, in, "enhance_only")
out, err := e.RunSteps(context.Background(), "co", in, "enhance_only", nil, StepPolicy{AllowAI: true, AllowEPREL: true})
if err != nil {
@@ -381,16 +405,15 @@ func TestRunSteps_synthPriorHashDoesNotSkipReprocess(t *testing.T) {
if !company.LooksLikeHeuristicSynthesize(synthPrior) {
t.Fatalf("expected invent synth prior, got %q", synthPrior)
}
sysTpl, userTpl := resolveProductPromptTemplates(ProductInput{})
priorHash := HashEnhanceInput("", normName, normDesc, "", "", sysTpl, userTpl, map[string]any{})
out, err := e.RunSteps(context.Background(), "co", ProductInput{
Name: normName,
Description: normDesc,
Mapped: map[string]any{"name": normName, "description": normDesc},
PriorEnhanceHash: priorHash,
PriorProcessedName: normName,
PriorProcessedDescription: synthPrior,
}, "enhance_only", nil, StepPolicy{AllowAI: true, AllowEPREL: true})
base := ProductInput{
Name: normName,
Description: normDesc,
Mapped: map[string]any{"name": normName, "description": normDesc},
}
base.PriorEnhanceHash = enhanceHashFor(t, base, "enhance_only")
base.PriorProcessedName = normName
base.PriorProcessedDescription = synthPrior
out, err := e.RunSteps(context.Background(), "co", base, "enhance_only", nil, StepPolicy{AllowAI: true, AllowEPREL: true})
if err != nil {
t.Fatal(err)
}
@@ -191,7 +191,16 @@ func titleFormulaAttrKeys(template any) []string {
// FormatTitleFormulaConstraint turns categories.title_template into enhance-prompt
// constraints (element order: literal text + attribute variable keys).
//
// Brand-only stubs ({elements:[brand]}, the shape an older A1 migration wrote onto
// every category) are not a formula: instructing "name = brand" yields a
// brand-only title that the title picker then rejects in favour of the supplier
// name — an LLM call spent to reproduce the feed. Treat them as absent so the
// category prompt / built-in retail rules drive the name instead.
func FormatTitleFormulaConstraint(template any) string {
if aiprompts.TitleTemplateIsBrandOnly(template) {
return ""
}
elements, separator, ok := parseTitleFormula(template)
if !ok || len(elements) == 0 {
return ""
@@ -521,6 +530,31 @@ func categoryFormulasFor(in ProductInput, category string) (title, description a
return f.TitleTemplate, f.DescriptionTemplate
}
// effectiveDescriptionTemplateFor resolves the description formula RunSteps should
// judge a category's copy against: the category's own template, else one derived
// from its prompt, else the platform default. Mirrors what e.enhance renders, so
// the final repair pass cannot demand a different formula than the one the model
// was given.
func effectiveDescriptionTemplateFor(in ProductInput, out StepResult) any {
category := strings.TrimSpace(out.Category)
_, descTpl := categoryFormulasFor(in, category)
catPrompt := strings.TrimSpace(in.CategoryEnhancePrompt)
if catPrompt == "" {
catPrompt = categoryEnhancePromptFor(in.CategoryPromptsByLang, category, in.Language, in.Language)
}
if effective := aiprompts.EffectiveDescriptionTemplateAny(descTpl, catPrompt); effective != nil {
return effective
}
if catPrompt != "" || descTpl != nil {
return descTpl
}
probe := ProductInput{
CategoryUniqueID: category,
CategoryDisplayName: categoryDisplayLabel(out),
}
return withDefaultCategoryFormula(probe).DescriptionTemplate
}
func asObjectMap(v any) (map[string]any, error) {
switch t := v.(type) {
case map[string]any:
@@ -65,12 +65,13 @@ func TestAppendDescriptionFormulaSystemOverride(t *testing.T) {
EnhanceUserTemplate: "Name: {{name}}",
DescriptionTemplate: desc,
})
// Structured description_template uses A1 GPT-predloga path (legacy generator.php).
if !strings.Contains(user, "GPT predloga:") || !strings.Contains(user, "<H1>{Heading}</H1>") {
t.Fatalf("missing A1 description predloga: %s", user)
// Structured description_template uses the A1 template path (legacy generator.php).
// Fixture instructions are English, so the scaffolding renders in English.
if !strings.Contains(user, "Product template:") || !strings.Contains(user, "<H1>{Heading}</H1>") {
t.Fatalf("missing description template: %s", user)
}
if !strings.Contains(sys, "Si tekstopisec") {
t.Fatalf("system should be A1 copywriter when formula set: %s", sys)
if !strings.Contains(sys, "You are a copywriter") {
t.Fatalf("system should be the formula copywriter when formula set: %s", sys)
}
if AppendDescriptionFormulaSystemOverride("plain", nil) != "plain" {
t.Fatal("empty formula must be no-op")
@@ -91,14 +92,14 @@ func TestAppendTitleFormulaSystemOverride(t *testing.T) {
EnhanceUserTemplate: "Name: {{name}}",
TitleTemplate: title,
})
if !strings.Contains(user, "GPT predloga:") || !strings.Contains(user, "<name>{") {
t.Fatalf("missing A1 title predloga: %s", user)
if !strings.Contains(user, "Product template:") || !strings.Contains(user, "<name>{") {
t.Fatalf("missing title template: %s", user)
}
if !strings.Contains(user, "brand") {
t.Fatalf("missing brand in title formula: %s", user)
}
if !strings.Contains(sys, "Si tekstopisec") {
t.Fatalf("system should be A1 copywriter when title formula set: %s", sys)
if !strings.Contains(sys, "You are a copywriter") {
t.Fatalf("system should be the formula copywriter when title formula set: %s", sys)
}
if AppendTitleFormulaSystemOverride("plain", nil) != "plain" {
t.Fatal("empty title formula must be no-op")
@@ -246,8 +247,8 @@ func TestAppendFormulaConstraints_injectedIntoResolve(t *testing.T) {
TitleTemplate: title,
DescriptionTemplate: desc,
})
if !strings.Contains(user, "GPT predloga:") {
t.Fatalf("expected A1 GPT predloga: %s", user)
if !strings.Contains(user, "Product template:") {
t.Fatalf("expected formula template: %s", user)
}
if !strings.Contains(user, "<name>{") || !strings.Contains(user, "brand") {
t.Fatalf("missing title formula in predloga: %s", user)
@@ -258,8 +259,8 @@ func TestAppendFormulaConstraints_injectedIntoResolve(t *testing.T) {
if !strings.Contains(user, "<metaDescription>{") || !strings.Contains(user, "120-155") {
t.Fatalf("missing SEO meta in predloga: %s", user)
}
if !strings.Contains(sys, "Si tekstopisec") {
t.Fatalf("system missing A1 copywriter: %s", sys)
if !strings.Contains(sys, "You are a copywriter") {
t.Fatalf("system missing formula copywriter: %s", sys)
}
// Render substitutes {{language}} in formula blocks.
_, rendered := RenderProductEnhancePrompts(
+48 -2
View File
@@ -8,6 +8,7 @@ import (
)
func resolveProductPromptTemplates(in ProductInput) (systemTpl, userTpl string) {
in = withDefaultCategoryFormula(in)
systemTpl = strings.TrimSpace(in.EnhanceSystemTemplate)
userTpl = strings.TrimSpace(in.EnhanceUserTemplate)
catPrompt := strings.TrimSpace(in.CategoryEnhancePrompt)
@@ -17,8 +18,12 @@ func resolveProductPromptTemplates(in ProductInput) (systemTpl, userTpl string)
// <name>/<metaDescription>/HTML). When formulas are set, rebuild that shape
// instead of burying instructions under the generic retail copywriter overlay.
if categoryUsesA1StyleFormula(catPrompt, in.TitleTemplate, descTpl) {
userTpl = buildA1StyleEnhanceUserTemplate(catPrompt, in.TitleTemplate, descTpl, in.OmitSEOMeta)
systemTpl = a1StyleEnhanceSystemTemplate
var slovenianFormula bool
userTpl, slovenianFormula = buildA1StyleEnhanceUserTemplate(catPrompt, in.TitleTemplate, descTpl, in.OmitSEOMeta)
systemTpl = a1StyleEnhanceSystemTemplateEN
if slovenianFormula {
systemTpl = a1StyleEnhanceSystemTemplateSL
}
allowed := enhanceAllowedAttrKeys(in, in.CategoryUniqueID)
userTpl = AppendAttributeConstraints(userTpl, allowed, in.TitleTemplate)
return systemTpl, userTpl
@@ -59,6 +64,47 @@ func resolveProductPromptTemplates(in ProductInput) (systemTpl, userTpl string)
return systemTpl, userTpl
}
// withDefaultCategoryFormula supplies the platform-default formula when the
// category carries none of its own.
//
// Categories created before defaults existed (and any tenant who never opened the
// formula editor) would otherwise enhance through the generic "1-3 factual
// paragraphs" rule and produce copy shaped by the feed rather than by a formula.
// The default is the English translation of the A1 template style; a category that
// has its own prompt or formula — every A1 category — is returned untouched.
func withDefaultCategoryFormula(in ProductInput) ProductInput {
// Only categorised products: with no category there is nothing to look a
// default up for, and the company/built-in enhance template still applies.
if strings.TrimSpace(in.CategoryUniqueID) == "" && strings.TrimSpace(in.CategoryDisplayName) == "" {
return in
}
// A tenant-authored enhance template is an explicit choice and outranks any
// platform default.
if !aiprompts.IsBuiltInProductEnhanceUserTemplate(in.EnhanceUserTemplate) {
return in
}
if strings.TrimSpace(in.CategoryEnhancePrompt) != "" {
return in
}
if FormatTitleFormulaConstraint(in.TitleTemplate) != "" ||
FormatDescriptionFormulaConstraint(in.DescriptionTemplate) != "" ||
FormatMetaFormulaConstraint(in.DescriptionTemplate) != "" {
return in
}
def := aiprompts.DefaultCategoryFormula(in.CategoryUniqueID, in.CategoryDisplayName)
if def.Overlay == "" {
return in
}
in.CategoryEnhancePrompt = def.Overlay
if in.TitleTemplate == nil && def.TitleTemplateJSON != "" {
in.TitleTemplate = decodeOptionalJSONObject([]byte(def.TitleTemplateJSON))
}
if in.DescriptionTemplate == nil && def.DescriptionTemplateJSON != "" {
in.DescriptionTemplate = decodeOptionalJSONObject([]byte(def.DescriptionTemplateJSON))
}
return in
}
// thinEnhanceUserInstruction is prepended when Desc is empty or ≈ Name so the model
// builds copy from Attrs+Category instead of echoing the title.
const thinEnhanceUserInstruction = "Build the description from Attrs and Category only; never copy Name."
@@ -82,12 +82,13 @@ func TestResolveProductPromptTemplates_categoryWithTitleFormula(t *testing.T) {
TitleTemplate: title,
DescriptionTemplate: desc,
})
// Structured formulas trigger A1-style GPT predloga (legacy generator.php behavior).
if !strings.Contains(sys, "Si tekstopisec") {
t.Fatalf("expected A1 system when formulas set: %s", sys)
// Structured formulas trigger the A1-style template (legacy generator.php behavior);
// this fixture's instructions are English so the scaffolding renders in English.
if !strings.Contains(sys, "You are a copywriter") {
t.Fatalf("expected formula system when formulas set: %s", sys)
}
if !strings.Contains(user, "GPT predloga:") {
t.Fatalf("expected GPT predloga user: %s", user)
if !strings.Contains(user, "Product template:") {
t.Fatalf("expected formula template user: %s", user)
}
if !strings.Contains(user, "Emphasize energy class") && !strings.Contains(user, "<name>") {
t.Fatalf("expected title/desc formula in predloga: %s", user)
+96 -20
View File
@@ -48,10 +48,12 @@ func (e *Engine) RunSteps(ctx context.Context, companyID string, in ProductInput
in.Name,
in.PriorProcessedName,
)
out.Description = preferredProductDescription(out.Name,
// Original is supplier copy only. PriorProcessedDescription is the last
// ENRICHED text — using it here made Review report "Matched" against copy
// the feed never contained.
out.Description = supplierOriginalDescription(out.Name,
stringFromAny(normalized["description"]),
in.Description,
in.PriorProcessedDescription,
)
// mapped_data.category / category_unique_id (unique_id codes) win.
applyCategoryFromMapped(&out, normalized, in.Mapped, in.Raw)
@@ -104,7 +106,7 @@ func (e *Engine) RunSteps(ctx context.Context, companyID string, in ProductInput
in.Name,
in.PriorProcessedName,
)
out.Description = preferredProductDescription(out.Name,
out.Description = supplierOriginalDescription(out.Name,
stringFromAny(normalized["description"]),
out.Description,
in.Description,
@@ -451,7 +453,7 @@ func (e *Engine) RunSteps(ctx context.Context, companyID string, in ProductInput
out.ProcessedDescription = out.Description
}
// Timeout/error/empty/formula-miss: always synthesize a factual fallback when a title exists.
_, failDescTpl := categoryFormulasFor(in, out.Category)
failDescTpl := effectiveDescriptionTemplateFor(in, out)
if out.ProcessedName != "" && descriptionNeedsEnhanceRepair(out.ProcessedDescription, failDescTpl, out.ProcessedName, out.Name) {
if synth := synthesizeProductDescription(out.ProcessedName, displayCat, primary, enhanceAttrs, failDescTpl); synth != "" {
out.ProcessedDescription = synth
@@ -528,17 +530,22 @@ func (e *Engine) RunSteps(ctx context.Context, companyID string, in ProductInput
}
}
// Pipelines without StepCategorize (enhance_only / normalize_only) still run
// vector + LLM categorize when category is empty so enhance is not fed a blank.
// Pipelines without StepCategorize (normalize_only) still run vector + LLM
// categorize when category is empty so downstream steps are not fed a blank.
if !stepsContain(steps, StepCategorize) {
runCategorizeStep(ctx, e, companyID, &out, in, categoryNames, policy)
noteMissingCategory(&out, policy, e != nil && e.Vector != nil && e.Vector.Enabled())
}
// Record why category stayed empty for every pipeline, including the ones that
// now categorize inside the loop (enhance / title / description).
noteMissingCategory(&out, policy, e != nil && e.Vector != nil && e.Vector.Enabled())
preserveCategoryIfEmpty(&out, in.PriorCategory)
syncCategoryName(&out, in.CategoryNamesByUID)
out.Name = preferredProductTitle(in.GTIN, out.Name, out.ProcessedName, in.Name, in.PriorProcessedName)
out.ProcessedName = preferredProductTitle(in.GTIN, out.ProcessedName, out.Name, in.PriorProcessedName, in.Name)
out.Description = preferredProductDescription(out.Name, out.Description, out.ProcessedDescription, in.Description)
// Original stays supplier copy; only the enriched side may borrow from it.
// Filling Original from ProcessedName/Description is what produced Review rows
// that read "Matched" while the Feed tab still showed the real supplier text.
out.Name = preferredProductTitle(in.GTIN, out.Name, in.Name)
out.ProcessedName = finalProcessedTitle(in, &out)
out.Description = supplierOriginalDescription(out.Name, out.Description, in.Description)
out.ProcessedDescription = preferredProductDescription(out.ProcessedName, out.ProcessedDescription, out.Description, in.PriorProcessedDescription)
if out.ProcessedName == "" {
out.ProcessedName = out.Name
@@ -549,7 +556,7 @@ func (e *Engine) RunSteps(ctx context.Context, companyID string, in ProductInput
displayCat := categoryDisplayLabel(out)
// After vector/mapped category resolution, formulas may key for the first time —
// repair short prose / title echo that ignored A1 description_template.
_, finalDescTpl := categoryFormulasFor(in, out.Category)
finalDescTpl := effectiveDescriptionTemplateFor(in, out)
finalRepaired := false
if descriptionNeedsEnhanceRepair(out.ProcessedDescription, finalDescTpl, out.ProcessedName, out.Name) {
if synth := synthesizeProductDescription(out.ProcessedName, displayCat, in.Language, out.Attributes, finalDescTpl); synth != "" {
@@ -684,7 +691,12 @@ func noteMissingCategory(out *StepResult, policy StepPolicy, vectorEnabled bool)
func resolveSteps(processingType string) []string {
switch strings.ToLower(strings.TrimSpace(processingType)) {
case "enhance", "enhance_only", "enhance-only", "title", "description":
return []string{StepNormalize, StepAIEnhance}
// parse_specs/fill_fields/categorize feed ai_enhance — they never rewrite
// title/description themselves. Without them enhance ran with empty Attrs
// and (for feeds without a mapped category) no category, so the category
// title/description formula could not key and the model echoed the feed.
// categorize is a no-op when the category is already set.
return []string{StepNormalize, StepParseSpecs, StepFillFields, StepCategorize, StepAIEnhance}
case "attributes", "attributes_only", "specs", "specifications":
return []string{StepNormalize, StepParseSpecs, StepFillFields}
case "eprel", "eprel_only":
@@ -779,10 +791,10 @@ func normalizeTitleCompare(s string) string {
return strings.TrimSpace(b.String())
}
func titleNeedsRewriteRetry(in ProductInput, name string) bool {
if !titlesAreEquivalent(name, in.Name) {
return false
}
// categoryRequiresTitleRewrite reports that the category asks enhance for a NEW
// product name — a structured title_template, or free-form Title instructions that
// spell out a naming formula (legacy A1 <name>{…}</name>).
func categoryRequiresTitleRewrite(in ProductInput) bool {
if aiprompts.CategoryTitlePromptRequiresRewrite(in.CategoryEnhancePrompt) {
return true
}
@@ -792,6 +804,62 @@ func titleNeedsRewriteRetry(in ProductInput, name string) bool {
return FormatTitleSectionConstraint(in.CategoryEnhancePrompt) != ""
}
func titleNeedsRewriteRetry(in ProductInput, name string) bool {
return titlesAreEquivalent(name, in.Name) && categoryRequiresTitleRewrite(in)
}
// formulaAwareTitle keeps a formula-built name even when it is shorter than — and a
// prefix of — the supplier Name. preferredProductTitle's brand-stub rule would
// otherwise prefer the longer feed title and silently undo the Title formula
// (A1 formulas like "znamka tip model" often are a prefix of the feed name).
func formulaAwareTitle(in ProductInput, llmName string) string {
llmName = strings.TrimSpace(llmName)
if llmName != "" && llmName != "<nil>" && !isPromptLabelTitle(llmName) &&
!titlesAreEquivalent(llmName, in.Name) && categoryRequiresTitleRewrite(in) {
return preferredProductTitle(in.GTIN, llmName)
}
return preferredProductTitle(in.GTIN, llmName, in.Name, in.PriorProcessedName)
}
// finalProcessedTitle settles ProcessedName after category resolution. A name the
// enhance step built from an active category Title formula is kept as-is; only an
// empty/unusable one falls back to the supplier title.
func finalProcessedTitle(in ProductInput, out *StepResult) string {
processed := strings.TrimSpace(out.ProcessedName)
if processed != "" && processed != "<nil>" && !isPromptLabelTitle(processed) &&
!titlesAreEquivalent(processed, out.Name) {
titleTpl, _ := categoryFormulasFor(in, out.Category)
formulaIn := in
formulaIn.TitleTemplate = titleTpl
if formulaIn.CategoryEnhancePrompt == "" {
formulaIn.CategoryEnhancePrompt = categoryEnhancePromptFor(
in.CategoryPromptsByLang, out.Category, in.Language, in.Language)
}
if categoryRequiresTitleRewrite(formulaIn) {
return preferredProductTitle(in.GTIN, processed)
}
}
return preferredProductTitle(in.GTIN, out.ProcessedName, out.Name, in.PriorProcessedName, in.Name)
}
// supplierOriginalDescription picks the Original (pre-enrich) description from
// supplier candidates only. When every candidate trips a quality filter it still
// returns the raw feed text — Original is meant to mirror the feed, and leaving it
// blank (or borrowing enriched copy) is what made Review show a false "Matched".
func supplierOriginalDescription(title string, candidates ...string) string {
if desc := preferredProductDescription(title, candidates...); desc != "" {
return desc
}
for _, c := range candidates {
c = strings.TrimSpace(c)
if c == "" || c == "<nil>" || isPromptLabelTitle(c) {
continue
}
return SanitizeOutput(c)
}
return ""
}
// enhanceHashForceReason returns why a matching prior enhance_input_hash must not
// skip the LLM (empty = safe to reuse as ai_enhance_unchanged).
func enhanceHashForceReason(in ProductInput) string {
@@ -831,13 +899,19 @@ func normalizeDescCompare(s string) string {
}
func (e *Engine) enhance(ctx context.Context, in ProductInput, category string, attrs map[string]any) (string, string, int, any, error) {
catUID := strings.TrimSpace(in.CategoryUniqueID)
catName := strings.TrimSpace(category)
if in.CategoryDisplayName == "" {
in.CategoryDisplayName = catName
}
// Fill the platform default before anything reads the formula, so the prompt the
// model receives and the gate its reply is judged against are the same formula.
in = withDefaultCategoryFormula(in)
// Prompt Description HTML overlays count as the formula when description_template
// is empty — otherwise enhance accepts supplier-like prose and skips formula retry.
if effective := aiprompts.EffectiveDescriptionTemplateAny(in.DescriptionTemplate, in.CategoryEnhancePrompt); effective != nil {
in.DescriptionTemplate = effective
}
catUID := strings.TrimSpace(in.CategoryUniqueID)
catName := strings.TrimSpace(category)
sysTpl, userTpl := resolveProductPromptTemplates(in)
hash := HashEnhanceInput(category, in.Name, in.Description, in.BrandPrompt, in.Language, sysTpl, userTpl, attrs)
if e == nil || e.Completer == nil {
@@ -967,7 +1041,7 @@ func (e *Engine) enhance(ctx context.Context, in ProductInput, category string,
return name, desc, comp.TotalTokens, meta, nil
}
name := preferredProductTitle(in.GTIN, llmName, in.Name, in.PriorProcessedName)
name := formulaAwareTitle(in, llmName)
// Quality-gate on LLM description alone (do not absorb originals yet).
desc := preferredProductDescription(name, llmDesc)
didFormulaRetry := false
@@ -1582,7 +1656,9 @@ func preferredProductDescription(title string, candidates ...string) string {
if containsWeakFillerPhrase(c) {
continue
}
if company.LooksLikeHeuristicSynthesize(c) {
// Strict test only: this drops the candidate outright, so ambiguous
// skeleton wording must not disqualify real formula copy.
if company.IsInventFallbackDescription(c) {
continue
}
return SanitizeOutput(c)
+3 -1
View File
@@ -20,7 +20,9 @@ func (s stubCompleter) Complete(_ context.Context, system, user string) (Complet
func TestResolveSteps(t *testing.T) {
cases := map[string][]string{
"full": {StepNormalize, StepParseSpecs, StepFillFields, StepEPREL, StepCategorize, StepAIEnhance},
"enhance_only": {StepNormalize, StepAIEnhance},
"enhance_only": {StepNormalize, StepParseSpecs, StepFillFields, StepCategorize, StepAIEnhance},
"title": {StepNormalize, StepParseSpecs, StepFillFields, StepCategorize, StepAIEnhance},
"description": {StepNormalize, StepParseSpecs, StepFillFields, StepCategorize, StepAIEnhance},
"attributes_only": {StepNormalize, StepParseSpecs, StepFillFields},
"eprel_only": {StepNormalize, StepParseSpecs, StepEPREL},
"normalize_only": {StepNormalize},