From f61fe05e7ad5e7667ae3f5d18af966466d1551b7 Mon Sep 17 00:00:00 2001 From: GreenEclipse Date: Sun, 23 Aug 2026 18:58:03 +0200 Subject: [PATCH] fix --- .../internal/aiprompts/description_formula.go | 42 +++ .../internal/processing/a1_enhance_prompt.go | 246 ++++++++++++++++++ .../processing/a1_enhance_prompt_test.go | 96 +++++++ .../processing/a1_enhance_smoke_test.go | 87 +++++++ .../internal/processing/enhance_log_test.go | 17 +- .../processing/formula_prompt_test.go | 47 ++-- apps/api/internal/processing/prompt_render.go | 16 +- .../internal/processing/prompt_render_test.go | 45 ++-- apps/api/internal/processing/steps.go | 16 ++ 9 files changed, 551 insertions(+), 61 deletions(-) create mode 100644 apps/api/internal/processing/a1_enhance_prompt.go create mode 100644 apps/api/internal/processing/a1_enhance_prompt_test.go create mode 100644 apps/api/internal/processing/a1_enhance_smoke_test.go diff --git a/apps/api/internal/aiprompts/description_formula.go b/apps/api/internal/aiprompts/description_formula.go index ddbcf5b..402118d 100644 --- a/apps/api/internal/aiprompts/description_formula.go +++ b/apps/api/internal/aiprompts/description_formula.go @@ -11,6 +11,12 @@ var ( reLegacyBoldUL = regexp.MustCompile(`(?is)<\s*b\s*>\s*\{?\s*(.*?)\s*\}?\s*<\s*/\s*b\s*>\s*<\s*ul\s*>\s*<\s*li\s*>\s*\{?\s*(.*?)\s*\}?\s*<\s*/\s*li\s*>\s*<\s*/\s*ul\s*>`) ) +// LegacyHTMLFormulaPresent reports whether s contains A1-style HTML formula blocks +// (

{…}

,

{…}

, …) used in category Description GPT predloga. +func LegacyHTMLFormulaPresent(s string) bool { + return reLegacyHTMLBlock.MatchString(s) +} + // DescriptionFormulaSection is one ordered block in categories.description_template. type DescriptionFormulaSection struct { Type string `json:"type"` @@ -182,6 +188,9 @@ func EffectiveDescriptionFormula(stored any, categoryPrompt string) DescriptionF // Unsectioned prompt may still be a legacy HTML blob. descBody = strings.TrimSpace(categoryPrompt) } + if isBuiltInDescriptionRoleBoilerplate(descBody) { + descBody = "" + } var derived DescriptionFormula if descBody != "" && reLegacyHTMLBlock.MatchString(descBody) { derived = DeriveDescriptionFormulaFromLegacyParts(LegacyEnhanceParts{ @@ -194,6 +203,9 @@ func EffectiveDescriptionFormula(stored any, categoryPrompt string) DescriptionF } metaBody := ExtractEnhanceSectionBody(categoryPrompt, SectionMetaStart, SectionMetaEnd) + if isBuiltInMetaRoleBoilerplate(metaBody) { + metaBody = "" + } if strings.TrimSpace(out.MetaTitle) == "" && strings.TrimSpace(out.MetaDescription) == "" && strings.TrimSpace(metaBody) != "" { out.MetaDescription = strings.TrimSpace(metaBody) out.MetaTitle = DefaultLegacyMetaTitleRule @@ -208,6 +220,16 @@ func EffectiveDescriptionFormula(stored any, categoryPrompt string) DescriptionF return out } +func isBuiltInMetaRoleBoilerplate(body string) bool { + lower := strings.ToLower(strings.TrimSpace(body)) + return strings.HasPrefix(lower, "role: meta") +} + +func isBuiltInDescriptionRoleBoilerplate(body string) bool { + lower := strings.ToLower(strings.TrimSpace(body)) + return strings.HasPrefix(lower, "role: description") +} + func promptHasRoleMarkers(prompt string) bool { lower := strings.ToLower(prompt) return strings.Contains(lower, strings.ToLower(SectionTitleStart)) || @@ -220,6 +242,26 @@ func TitleSectionInstructions(categoryPrompt string) string { return ExtractEnhanceSectionBody(categoryPrompt, SectionTitleStart, SectionTitleEnd) } +// DescriptionSectionInstructions returns the --- Description --- body, or empty +// when it is only the built-in Role: description boilerplate. +func DescriptionSectionInstructions(categoryPrompt string) string { + body := ExtractEnhanceSectionBody(categoryPrompt, SectionDescriptionStart, SectionDescriptionEnd) + if isBuiltInDescriptionRoleBoilerplate(body) { + return "" + } + return body +} + +// MetaSectionInstructions returns the --- Meta --- body, or empty when it is only +// the built-in Role: meta boilerplate. +func MetaSectionInstructions(categoryPrompt string) string { + body := ExtractEnhanceSectionBody(categoryPrompt, SectionMetaStart, SectionMetaEnd) + if isBuiltInMetaRoleBoilerplate(body) { + return "" + } + return body +} + // EffectiveDescriptionTemplateAny is EffectiveDescriptionFormula as a JSON object // for processing.ProductInput.DescriptionTemplate (nil when empty). func EffectiveDescriptionTemplateAny(stored any, categoryPrompt string) any { diff --git a/apps/api/internal/processing/a1_enhance_prompt.go b/apps/api/internal/processing/a1_enhance_prompt.go new file mode 100644 index 0000000..76697cb --- /dev/null +++ b/apps/api/internal/processing/a1_enhance_prompt.go @@ -0,0 +1,246 @@ +package processing + +import ( + "fmt" + "regexp" + "strings" + + "github.com/descrybe/descrybe-v2/apps/api/internal/aiprompts" +) + +// Legacy A1 (generator.php process_product_description) used the category Prompt +// as the entire USER message after substituting OPIS IZDELKA / STARO IME IZDELKA, +// with a one-line Slovenian copywriter SYSTEM message, then parsed and +// 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}}. +Rules: +- Reply with ONLY JSON (no markdown) +- Schema: {"name":"string","description":"string","meta_title":"string","meta_description":"string","attrs":{}} +- Obey the GPT predloga in the user message step by step (same behavior as legacy A1 category Prompt) +- "name" follows the Title formula — never copy Staro_ime_izdelka unchanged when the formula asks for a new name +- "description" is ONE HTML string covering each GPT predloga body section in order (tags matching type: h1/h2/h3/h4, p, ul) — do NOT wrap the reply in or tags +- "meta_title" and "meta_description" are plain SEO text from the meta / formula (never HTML body) +- 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*>`) + reLegacyNameStrip = regexp.MustCompile(`(?is)<\s*name\s*>.*?<\s*/\s*name\s*>`) + reLegacyMetaStrip = regexp.MustCompile(`(?is)<\s*metaDescription\s*>.*?<\s*/\s*metaDescription\s*>`) +) + +// categoryUsesA1StyleFormula is true when the category carries Title/Description/Meta +// formula content that must drive enhance like legacy generator.php (not soft overlay). +func categoryUsesA1StyleFormula(catPrompt string, titleTemplate, descriptionTemplate any) bool { + if FormatTitleFormulaConstraint(titleTemplate) != "" { + return true + } + if FormatDescriptionFormulaConstraint(descriptionTemplate) != "" { + return true + } + if FormatMetaFormulaConstraint(descriptionTemplate) != "" { + return true + } + if FormatTitleSectionConstraint(catPrompt) != "" { + return true + } + if aiprompts.CategoryTitlePromptRequiresRewrite(catPrompt) { + return true + } + descBody := aiprompts.DescriptionSectionInstructions(catPrompt) + if descBody != "" && aiprompts.LegacyHTMLFormulaPresent(descBody) { + return true + } + return strings.TrimSpace(aiprompts.MetaSectionInstructions(catPrompt)) != "" +} + +// buildA1StyleEnhanceUserTemplate rebuilds the legacy GPT-predloga user message +// (Title → , Meta → , Description HTML blocks) plus product +// evidence placeholders, asking for JSON field mapping. +func buildA1StyleEnhanceUserTemplate(catPrompt string, titleTemplate, descriptionTemplate any, omitSEOMeta bool) string { + titleInstr := strings.TrimSpace(aiprompts.TitleSectionInstructions(catPrompt)) + if titleInstr == "" || isBuiltInTitleRoleBoilerplate(titleInstr) { + titleInstr = "" + } + if titleInstr == "" { + if block := FormatTitleFormulaConstraint(titleTemplate); block != "" { + titleInstr = block + } + } + + descBody := strings.TrimSpace(aiprompts.DescriptionSectionInstructions(catPrompt)) + if descBody != "" && !aiprompts.LegacyHTMLFormulaPresent(descBody) { + // Free-form description instructions without HTML — keep as a p-block cue. + descBody = "

{" + descBody + "}

" + } + if descBody == "" { + descBody = FormatDescriptionFormulaAsLegacyHTML(descriptionTemplate) + } + + metaInstr := strings.TrimSpace(aiprompts.MetaSectionInstructions(catPrompt)) + if metaInstr == "" { + mt, md := parseMetaFormulaInstructions(descriptionTemplate) + switch { + case mt != "" && md != "": + metaInstr = "meta_title: " + mt + "\nmeta_description: " + md + case md != "": + metaInstr = md + case mt != "": + metaInstr = mt + } + } + + var predloga strings.Builder + if titleInstr != "" { + fmt.Fprintf(&predloga, "{%s}\n", collapseLegacyInstr(titleInstr)) + } + if !omitSEOMeta && metaInstr != "" { + fmt.Fprintf(&predloga, "{%s}\n", collapseLegacyInstr(metaInstr)) + } + if descBody != "" { + predloga.WriteString(strings.TrimSpace(descBody)) + 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 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 / tagov` + "\n") + if !omitSEOMeta { + b.WriteString(`- "meta_title" / "meta_description" = plain SEO po meta / formuli` + "\n") + } + b.WriteString(`- "attrs" = atributi (samo Allowed attribute keys, če so navedeni)`) + + // Free-form category overlay (no role markers) — keep as extra guidance like + // soft "Category guidance" did, without replacing the GPT predloga. + extra := strings.TrimSpace(catPrompt) + if extra != "" && + !aiprompts.CategoryEnhanceHasRoleSections(extra) && + !aiprompts.IsLegacyCombinedEnhancePrompt(extra) { + b.WriteString("\n\nDodatna kategorijska navodila:\n") + b.WriteString(extra) + } + return strings.TrimSpace(b.String()) +} + +func collapseLegacyInstr(s string) string { + s = strings.TrimSpace(s) + s = strings.ReplaceAll(s, "\r\n", "\n") + for strings.Contains(s, "\n\n\n") { + s = strings.ReplaceAll(s, "\n\n\n", "\n\n") + } + return s +} + +// FormatDescriptionFormulaAsLegacyHTML turns description_template sections back into +// the A1 HTML GPT-predloga shape (

{…}

{…}

    …). +func FormatDescriptionFormulaAsLegacyHTML(template any) string { + sections, ok := parseDescriptionFormulaSections(template) + if !ok || len(sections) == 0 { + return "" + } + var b strings.Builder + for _, s := range sections { + typ := strings.ToLower(strings.TrimSpace(s.Type)) + instr := strings.TrimSpace(s.Instructions) + if instr == "" { + instr = typ + } + switch typ { + case "h1", "h2", "h3", "h4": + fmt.Fprintf(&b, "<%s>{%s}", strings.ToUpper(typ), instr, strings.ToUpper(typ)) + case "ul", "ol", "li": + fmt.Fprintf(&b, "{Tehnične specifikacije}
    • {%s}
    ", instr) + case "p", "": + fmt.Fprintf(&b, "

    {%s}

    ", instr) + default: + fmt.Fprintf(&b, "

    {%s}

    ", instr) + } + } + return b.String() +} + +// ParseLegacyEnhanceHTML extracts name / metaDescription / body HTML from a legacy +// A1 model reply (generator.php preg_match on / ). +func ParseLegacyEnhanceHTML(raw string) (name, metaDescription, bodyHTML string, ok bool) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", "", "", false + } + if m := reLegacyNameCapture.FindStringSubmatch(raw); len(m) == 2 { + name = strings.TrimSpace(m[1]) + } + if m := reLegacyMetaCapture.FindStringSubmatch(raw); len(m) == 2 { + metaDescription = strings.TrimSpace(m[1]) + } + body := reLegacyNameStrip.ReplaceAllString(raw, "") + body = reLegacyMetaStrip.ReplaceAllString(body, "") + bodyHTML = strings.TrimSpace(body) + ok = name != "" || metaDescription != "" || (bodyHTML != "" && bodyHTML != raw) + return name, metaDescription, bodyHTML, ok +} + +// applyLegacyEnhanceTagFallback fills empty JSON fields from embedded legacy tags +// and strips / wrappers from description (feed.php behavior). +func applyLegacyEnhanceTagFallback(obj map[string]any, rawText string) { + if obj == nil { + return + } + name := strings.TrimSpace(fmt.Sprint(obj["name"])) + desc := strings.TrimSpace(fmt.Sprint(obj["description"])) + metaTitle := strings.TrimSpace(fmt.Sprint(obj["meta_title"])) + metaDesc := strings.TrimSpace(fmt.Sprint(obj["meta_description"])) + if metaDesc == "" { + metaDesc = strings.TrimSpace(fmt.Sprint(obj["metaDescription"])) + } + + src := desc + if src == "" || src == "" { + src = rawText + } + ln, lm, lb, ok := ParseLegacyEnhanceHTML(src) + if !ok { + ln2, lm2, lb2, ok2 := ParseLegacyEnhanceHTML(rawText) + if ok2 { + ln, lm, lb, ok = ln2, lm2, lb2, true + } + } + if !ok { + // Still strip tags if description accidentally includes them. + if desc != "" && desc != "" { + cleaned := reLegacyNameStrip.ReplaceAllString(desc, "") + cleaned = reLegacyMetaStrip.ReplaceAllString(cleaned, "") + cleaned = strings.TrimSpace(cleaned) + if cleaned != "" && cleaned != desc { + obj["description"] = cleaned + } + } + return + } + if (name == "" || name == "") && ln != "" { + obj["name"] = ln + } + if (metaDesc == "" || metaDesc == "") && lm != "" { + obj["meta_description"] = lm + } + if (metaTitle == "" || metaTitle == "") && ln != "" { + // Legacy only had metaDescription; seed meta_title from new name when empty. + obj["meta_title"] = ln + } + if lb != "" { + obj["description"] = lb + } +} diff --git a/apps/api/internal/processing/a1_enhance_prompt_test.go b/apps/api/internal/processing/a1_enhance_prompt_test.go new file mode 100644 index 0000000..06e2967 --- /dev/null +++ b/apps/api/internal/processing/a1_enhance_prompt_test.go @@ -0,0 +1,96 @@ +package processing + +import ( + "fmt" + "strings" + "testing" + + "github.com/descrybe/descrybe-v2/apps/api/internal/aiprompts" +) + +func TestBuildA1StyleEnhanceUserTemplate_mirrorsLegacyGenerator(t *testing.T) { + t.Parallel() + prompt := aiprompts.SectionTitleStart + "\n" + + `Napiši novo ime izdelka po formuli: "tip izdelka sentence case", "znamka", "model". Ne uporabljaj vejic.` + "\n" + + aiprompts.SectionTitleEnd + "\n\n" + + aiprompts.SectionDescriptionStart + "\n" + + `

    {Napiši Novo ime izdelka in izpostavi en benefit}

    ` + + `

    {Napiši odstavek, ki je dolg 100 besed.}

    ` + + `{Tehnične specifikacije}
    • {Napiši 3-10 tehničnih specifikacij}
    ` + "\n" + + aiprompts.SectionDescriptionEnd + "\n\n" + + aiprompts.SectionMetaStart + "\n" + + `Najprej napiši Novo ime in potem nadaljuj do 140 znakov.` + "\n" + + aiprompts.SectionMetaEnd + + descTpl := aiprompts.EffectiveDescriptionTemplateAny(nil, prompt) + if !categoryUsesA1StyleFormula(prompt, nil, descTpl) { + t.Fatal("expected A1-style formula detection") + } + user := buildA1StyleEnhanceUserTemplate(prompt, nil, descTpl, false) + for _, want := range []string{ + "GPT predloga:", + "Sledi tej GPT predlogi", + "Staro_ime_izdelka: {{name}}", + "Star_opis_izdelka: {{description}}", + "{", + "{", + "

    {", + `"name"`, + `"description"`, + } { + if !strings.Contains(user, want) { + t.Fatalf("missing %q in:\n%s", want, user) + } + } + + sys, renderedUser := resolveProductPromptTemplates(ProductInput{ + CategoryEnhancePrompt: prompt, + Language: "sl", + }) + if !strings.Contains(sys, "Si tekstopisec") { + t.Fatalf("system must be A1 copywriter, got:\n%s", sys) + } + if !strings.Contains(renderedUser, "GPT predloga:") { + t.Fatalf("user must be A1 GPT predloga, got:\n%s", renderedUser) + } + // Soft overlay framing must not win over GPT predloga. + if strings.Contains(renderedUser, "Category guidance (applies to name") { + t.Fatal("A1 path must not use soft category guidance overlay") + } +} + +func TestParseLegacyEnhanceHTML(t *testing.T) { + t.Parallel() + raw := `ovitek PanzerGlass Honor 200 Smart črna` + + `Ovitek PanzerGlass za Honor 200 Smart z elegantnim videzom.` + + `

    Zaščita z elegantnim videzom

    Dolg odstavek o ovitku.

    ` + + `
    • Barva: črna
    ` + name, meta, body, ok := ParseLegacyEnhanceHTML(raw) + if !ok { + t.Fatal("expected parse ok") + } + if name != "ovitek PanzerGlass Honor 200 Smart črna" { + t.Fatalf("name=%q", name) + } + if !strings.Contains(meta, "PanzerGlass") { + t.Fatalf("meta=%q", meta) + } + if strings.Contains(body, "") || strings.Contains(body, "metaDescription") { + t.Fatalf("body still has tags: %q", body) + } + if !strings.Contains(body, "

    ") || !strings.Contains(body, "
      ") { + t.Fatalf("body missing HTML: %q", body) + } + + obj := map[string]any{ + "name": "", + "description": raw, + } + applyLegacyEnhanceTagFallback(obj, "") + if obj["name"] != name { + t.Fatalf("fallback name=%v", obj["name"]) + } + if strings.Contains(fmt.Sprint(obj["description"]), "") { + t.Fatalf("fallback desc still has name tag: %v", obj["description"]) + } +} diff --git a/apps/api/internal/processing/a1_enhance_smoke_test.go b/apps/api/internal/processing/a1_enhance_smoke_test.go new file mode 100644 index 0000000..2cf07b9 --- /dev/null +++ b/apps/api/internal/processing/a1_enhance_smoke_test.go @@ -0,0 +1,87 @@ +package processing + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/descrybe/descrybe-v2/apps/api/internal/aiprompts" +) + +type a1SmokeCompleter struct { + system, user string + reply string +} + +func (c *a1SmokeCompleter) Complete(_ context.Context, system, user string) (Completion, error) { + c.system, c.user = system, user + return Completion{Text: c.reply, TotalTokens: 12}, nil +} + +func (c *a1SmokeCompleter) Enabled() bool { return true } + +func TestEnhance_A1StyleFormulaLocalSmoke(t *testing.T) { + t.Parallel() + prompt := aiprompts.SectionTitleStart + "\n" + + `Napiši novo ime izdelka po formuli: "tip izdelka sentence case", "znamka s pravilno kapitalizacijo", "model", barva v eni besedi. Ne uporabljaj vejic.` + "\n" + + aiprompts.SectionTitleEnd + "\n\n" + + aiprompts.SectionDescriptionStart + "\n" + + `

      {Napiši Novo ime izdelka in izpostavi en benefit}

      ` + + `

      {Napiši odstavek, ki je dolg 100 besed, ki NE vsebuje Novo ime izdelka.}

      ` + + `

      {Izpostavi en benefit, in NE napiši Novo ime izdelka}

      ` + + `

      {Napiši odstavek, ki je dolg 100 besed in VKLJUČI tudi tip izdelka lowercase}

      ` + + `{Tehnične specifikacije}
      • {Napiši 3-10 tehničnih specifikacij v alinejah}
      ` + "\n" + + aiprompts.SectionDescriptionEnd + "\n\n" + + aiprompts.SectionMetaStart + "\n" + + `Najprej napiši Novo ime izdelka in potem nadaljuj dokler nisi zapisal 140 znakov.` + "\n" + + aiprompts.SectionMetaEnd + + replyObj := map[string]any{ + "name": "ovitek PanzerGlass Honor 200 Smart črna", + "description": "

      Zaščita z elegantnim videzom

      " + strings.Repeat("Besedilo o ovitku. ", 20) + + "

      Trden oprijem

      " + strings.Repeat("Še več besedila. ", 20) + + "

      • Barva: črna
      • Model: Honor 200 Smart
      ", + "meta_title": "ovitek PanzerGlass Honor 200 Smart črna | zaščita", + "meta_description": "ovitek PanzerGlass Honor 200 Smart črna z elegantnim videzom in trdnim oprijemom za vsakodnevno uporabo.", + "attrs": map[string]any{"brand": "PanzerGlass"}, + } + raw, _ := json.Marshal(replyObj) + cap := &a1SmokeCompleter{reply: string(raw)} + eng := &Engine{Completer: cap} + + descTpl := aiprompts.EffectiveDescriptionTemplateAny(nil, prompt) + name, desc, _, meta, err := eng.enhance(context.Background(), ProductInput{ + Name: "ovitek PanzerGlass 200 Smart", + Description: "Zaščitni ovitek za telefon.", + Language: "sl", + CategoryEnhancePrompt: prompt, + DescriptionTemplate: descTpl, + CategoryUniqueID: "ovitki", + }, "Ovitki in mape", map[string]any{"brand": "PanzerGlass", "product_model": "Honor 200 Smart"}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(cap.system, "Si tekstopisec") { + t.Fatalf("expected A1 system, got: %s", cap.system) + } + if !strings.Contains(cap.user, "GPT predloga:") || !strings.Contains(cap.user, "{") { + t.Fatalf("expected legacy GPT predloga user, got: %s", cap.user) + } + if !strings.Contains(cap.user, "Staro_ime_izdelka: ovitek PanzerGlass 200 Smart") { + t.Fatalf("expected supplier name substituted like legacy STARO IME: %s", cap.user) + } + if !strings.Contains(cap.user, "

      {") && !strings.Contains(cap.user, "

      {") { + t.Fatalf("expected HTML formula in predloga: %s", cap.user) + } + if name != "ovitek PanzerGlass Honor 200 Smart črna" { + t.Fatalf("name=%q", name) + } + if !descriptionSatisfiesFormula(desc, descTpl) { + t.Fatalf("description must satisfy formula: %s", desc) + } + st, _ := meta.(map[string]any) + if st == nil || st["status"] != "ok" { + t.Fatalf("meta=%#v", meta) + } +} diff --git a/apps/api/internal/processing/enhance_log_test.go b/apps/api/internal/processing/enhance_log_test.go index 9f8c7d9..238d545 100644 --- a/apps/api/internal/processing/enhance_log_test.go +++ b/apps/api/internal/processing/enhance_log_test.go @@ -77,24 +77,21 @@ func TestA1StyleDescriptionFormulaInResolvedPrompts(t *testing.T) { TitleTemplate: titleFormula, DescriptionTemplate: descFormula, }) - if !strings.Contains(sysTpl, "Description formula") { - t.Fatalf("system missing formula override: %s", sysTpl) + if !strings.Contains(sysTpl, "Si tekstopisec") { + t.Fatalf("system missing A1 copywriter: %s", sysTpl) } - if !strings.Contains(sysTpl, `When the user message includes a "Title formula"`) { - t.Fatalf("system missing title formula override: %s", sysTpl) + if !strings.Contains(userTpl, "GPT predloga:") || !strings.Contains(userTpl, "

      {Naziv izdelka}

      ") { + t.Fatalf("user missing A1 description predloga: %s", userTpl) } - if !strings.Contains(userTpl, "Description formula") || !strings.Contains(userTpl, "- h1: Naziv izdelka") || !strings.Contains(userTpl, "- h2: Podnaslov prednosti") { - t.Fatalf("user missing description formula: %s", userTpl) - } - if !strings.Contains(userTpl, "Title formula") || !strings.Contains(userTpl, "attr [brand]") { - t.Fatalf("user missing title formula: %s", userTpl) + if !strings.Contains(userTpl, "{") || !strings.Contains(userTpl, "brand") { + t.Fatalf("user missing A1 title predloga: %s", userTpl) } system, user := RenderProductEnhancePrompts( sysTpl, userTpl, "Štedilniki", "VOX EHT6020", "", "", "", "sl", map[string]any{"brand": "VOX", "product_model": "EHT6020"}, ) - if !strings.Contains(user, "Description formula") || !strings.Contains(user, "- h1: Naziv izdelka") { + if !strings.Contains(user, "GPT predloga:") || !strings.Contains(user, "Naziv izdelka") { t.Fatalf("rendered user missing formula: %s", user) } if !strings.Contains(user, "Slovenian") && !strings.Contains(system, "Slovenian") { diff --git a/apps/api/internal/processing/formula_prompt_test.go b/apps/api/internal/processing/formula_prompt_test.go index ce3ed1c..9bcddd2 100644 --- a/apps/api/internal/processing/formula_prompt_test.go +++ b/apps/api/internal/processing/formula_prompt_test.go @@ -65,11 +65,12 @@ func TestAppendDescriptionFormulaSystemOverride(t *testing.T) { EnhanceUserTemplate: "Name: {{name}}", DescriptionTemplate: desc, }) - if !strings.Contains(user, "Description formula") { - t.Fatalf("missing user formula: %s", user) + // Structured description_template uses A1 GPT-predloga path (legacy generator.php). + if !strings.Contains(user, "GPT predloga:") || !strings.Contains(user, "

      {Heading}

      ") { + t.Fatalf("missing A1 description predloga: %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 !strings.Contains(sys, "Si tekstopisec") { + t.Fatalf("system should be A1 copywriter when formula set: %s", sys) } if AppendDescriptionFormulaSystemOverride("plain", nil) != "plain" { t.Fatal("empty formula must be no-op") @@ -90,16 +91,22 @@ func TestAppendTitleFormulaSystemOverride(t *testing.T) { EnhanceUserTemplate: "Name: {{name}}", TitleTemplate: title, }) - if !strings.Contains(user, "Title formula") || !strings.Contains(user, "attr [brand]") { - t.Fatalf("missing user title formula: %s", user) + if !strings.Contains(user, "GPT predloga:") || !strings.Contains(user, "{") { + t.Fatalf("missing A1 title predloga: %s", user) } - if !strings.Contains(sys, `When the user message includes a "Title formula"`) || !strings.Contains(sys, "short retail title") { - t.Fatalf("system should keep company text and append title override: %s", sys) + if !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 AppendTitleFormulaSystemOverride("plain", nil) != "plain" { t.Fatal("empty title formula must be no-op") } - dup := AppendTitleFormulaSystemOverride(sys, title) + // Direct override helper stays idempotent for soft path. + soft := "Retail product copywriter.\n- name: short retail title" + with := AppendTitleFormulaSystemOverride(soft, title) + dup := AppendTitleFormulaSystemOverride(with, title) if strings.Count(dup, `When the user message includes a "Title formula"`) != 1 { t.Fatalf("title override must be idempotent: %s", dup) } @@ -239,24 +246,24 @@ func TestAppendFormulaConstraints_injectedIntoResolve(t *testing.T) { TitleTemplate: title, DescriptionTemplate: desc, }) - if !strings.Contains(user, "Name: {{name}}") { - t.Fatalf("lost base template: %s", user) + if !strings.Contains(user, "GPT predloga:") { + t.Fatalf("expected A1 GPT predloga: %s", user) } - if !strings.Contains(user, "Title formula") || !strings.Contains(user, "attr [brand]") { - t.Fatalf("missing title formula: %s", user) + if !strings.Contains(user, "{") || !strings.Contains(user, "brand") { + t.Fatalf("missing title formula in predloga: %s", user) } - if !strings.Contains(user, "Description formula") || !strings.Contains(user, "- p: Factual summary") { - t.Fatalf("missing description formula: %s", user) + if !strings.Contains(user, "

      {Factual summary}

      ") { + t.Fatalf("missing description formula HTML: %s", user) } - if !strings.Contains(user, "SEO meta formula") || !strings.Contains(user, "meta_title (50-60 chars)") { - t.Fatalf("missing SEO meta formula: %s", user) + if !strings.Contains(user, "{") || !strings.Contains(user, "120-155") { + t.Fatalf("missing SEO meta in predloga: %s", user) } - if !strings.Contains(sys, "SEO meta formula") { - t.Fatalf("system missing meta override: %s", sys) + if !strings.Contains(sys, "Si tekstopisec") { + t.Fatalf("system missing A1 copywriter: %s", sys) } // Render substitutes {{language}} in formula blocks. _, rendered := RenderProductEnhancePrompts( - "Write in {{language}}.", user, + sys, user, "50", "Old Name", "", "", "", "sl", map[string]any{"brand": "Vox", "product_model": "EHT6020"}, ) diff --git a/apps/api/internal/processing/prompt_render.go b/apps/api/internal/processing/prompt_render.go index 02ffc8c..6314467 100644 --- a/apps/api/internal/processing/prompt_render.go +++ b/apps/api/internal/processing/prompt_render.go @@ -11,6 +11,19 @@ func resolveProductPromptTemplates(in ProductInput) (systemTpl, userTpl string) systemTpl = strings.TrimSpace(in.EnhanceSystemTemplate) userTpl = strings.TrimSpace(in.EnhanceUserTemplate) catPrompt := strings.TrimSpace(in.CategoryEnhancePrompt) + descTpl := aiprompts.EffectiveDescriptionTemplateAny(in.DescriptionTemplate, catPrompt) + + // Legacy A1 path: category Prompt WAS the user message (GPT predloga with + // //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 + allowed := enhanceAllowedAttrKeys(in, in.CategoryUniqueID) + userTpl = AppendAttributeConstraints(userTpl, allowed, in.TitleTemplate) + return systemTpl, userTpl + } + // Per-category prompt wins for the user message (company system keeps JSON schema / brand). if catPrompt != "" { userTpl = ensureCategoryEnhanceUserContext(catPrompt) @@ -24,9 +37,6 @@ func resolveProductPromptTemplates(in ProductInput) (systemTpl, userTpl string) userTpl = def.UserTemplate } } - // Prefer stored description_template; else derive HTML sections from the - // category prompt Description role (A1-style

      {…}

      overlays). - descTpl := aiprompts.EffectiveDescriptionTemplateAny(in.DescriptionTemplate, catPrompt) // Category formulas are language-agnostic; inject once into the shared user skeleton. userTpl = AppendFormulaConstraints(userTpl, in.TitleTemplate, descTpl, in.OmitSEOMeta) // Free-form --- Title --- instructions when structured title_template is empty. diff --git a/apps/api/internal/processing/prompt_render_test.go b/apps/api/internal/processing/prompt_render_test.go index fad5b1f..7d4abf7 100644 --- a/apps/api/internal/processing/prompt_render_test.go +++ b/apps/api/internal/processing/prompt_render_test.go @@ -82,26 +82,18 @@ func TestResolveProductPromptTemplates_categoryWithTitleFormula(t *testing.T) { TitleTemplate: title, DescriptionTemplate: desc, }) - if !strings.Contains(user, "Emphasize energy class") { - t.Fatalf("missing category prompt: %s", user) + // 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) } - if !strings.Contains(user, "applies to name, description, and attrs") { - t.Fatalf("missing category title framing: %s", user) + if !strings.Contains(user, "GPT predloga:") { + t.Fatalf("expected GPT predloga user: %s", user) } - if !strings.Contains(user, "Title formula") || !strings.Contains(user, "attr [brand]") { - t.Fatalf("missing title formula: %s", user) + if !strings.Contains(user, "Emphasize energy class") && !strings.Contains(user, "") { + t.Fatalf("expected title/desc formula in predloga: %s", user) } - if !strings.Contains(user, "Description formula") || !strings.Contains(user, "- p: Factual summary") { - t.Fatalf("missing description formula: %s", user) - } - if !strings.Contains(sys, `When the user message includes a "Title formula"`) { - t.Fatalf("missing title system override: %s", sys) - } - if !strings.Contains(sys, "Description formula") { - t.Fatalf("missing description system override: %s", sys) - } - if !strings.Contains(sys, `apply it to "name", "description", and "attrs"`) { - t.Fatalf("missing category system overlay: %s", sys) + if !strings.Contains(user, "

      {Factual summary}

      ") && !strings.Contains(user, "Factual summary") { + t.Fatalf("missing description formula HTML: %s", user) } } @@ -123,20 +115,17 @@ func TestResolveProductPromptTemplates_derivesFormulaFromPromptHTML(t *testing.T // Empty structured template — production A1 path often only has prompt HTML. DescriptionTemplate: nil, }) - if !strings.Contains(user, "Description formula") { - t.Fatalf("expected Description formula derived from prompt HTML:\n%s", user) + if !strings.Contains(sys, "Si tekstopisec") { + t.Fatalf("expected A1 copywriter system:\n%s", sys) } - if !strings.Contains(user, "- h2:") || !strings.Contains(user, "- p:") || !strings.Contains(user, "- ul:") { - t.Fatalf("expected h2/p/ul sections in formula:\n%s", user) + if !strings.Contains(user, "GPT predloga:") || !strings.Contains(user, "{") { + t.Fatalf("expected legacy GPT predloga with :\n%s", user) } - if !strings.Contains(sys, "Description formula") { - t.Fatalf("missing description system override:\n%s", sys) + if !strings.Contains(user, "

      {") && !strings.Contains(user, "

      {") { + t.Fatalf("expected HTML description formula in predloga:\n%s", user) } - if !strings.Contains(sys, `"name" MUST be rewritten`) { - t.Fatalf("missing title rewrite system override:\n%s", sys) - } - if !strings.Contains(user, "Title instructions (REQUIRED") { - t.Fatalf("missing Title section constraint:\n%s", user) + if strings.Contains(user, "Category guidance (applies to name") { + t.Fatal("A1 path must not use soft category guidance overlay") } effective := aiprompts.EffectiveDescriptionTemplateAny(nil, prompt) if descriptionSatisfiesFormula("plain Slovenian prose without tags", effective) { diff --git a/apps/api/internal/processing/steps.go b/apps/api/internal/processing/steps.go index 0f55a29..72fccc7 100644 --- a/apps/api/internal/processing/steps.go +++ b/apps/api/internal/processing/steps.go @@ -863,6 +863,22 @@ func (e *Engine) enhance(ctx context.Context, in ProductInput, category string, ReasoningEffort: "low", }) elapsed := time.Since(started) + if obj != nil { + applyLegacyEnhanceTagFallback(obj, comp.Text) + } else if err != nil && strings.TrimSpace(comp.Text) != "" { + // Legacy A1 models sometimes reply with HTML / instead of JSON. + if n, m, body, ok := ParseLegacyEnhanceHTML(comp.Text); ok && (n != "" || body != "") { + obj = map[string]any{ + "name": n, + "description": body, + "meta_description": m, + } + if n != "" && m == "" { + obj["meta_title"] = n + } + err = nil + } + } if err != nil { // Network/provider failure vs parse failure after retry name := preferredProductTitle(in.GTIN, in.Name, in.PriorProcessedName)