This commit is contained in:
2026-08-23 18:58:03 +02:00
parent a30b78c3b6
commit f61fe05e7a
9 changed files with 551 additions and 61 deletions
@@ -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*>`) 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
// (<H2>{…}</H2>, <p>{…}</p>, …) used in category Description GPT predloga.
func LegacyHTMLFormulaPresent(s string) bool {
return reLegacyHTMLBlock.MatchString(s)
}
// DescriptionFormulaSection is one ordered block in categories.description_template. // DescriptionFormulaSection is one ordered block in categories.description_template.
type DescriptionFormulaSection struct { type DescriptionFormulaSection struct {
Type string `json:"type"` Type string `json:"type"`
@@ -182,6 +188,9 @@ func EffectiveDescriptionFormula(stored any, categoryPrompt string) DescriptionF
// Unsectioned prompt may still be a legacy HTML blob. // Unsectioned prompt may still be a legacy HTML blob.
descBody = strings.TrimSpace(categoryPrompt) descBody = strings.TrimSpace(categoryPrompt)
} }
if isBuiltInDescriptionRoleBoilerplate(descBody) {
descBody = ""
}
var derived DescriptionFormula var derived DescriptionFormula
if descBody != "" && reLegacyHTMLBlock.MatchString(descBody) { if descBody != "" && reLegacyHTMLBlock.MatchString(descBody) {
derived = DeriveDescriptionFormulaFromLegacyParts(LegacyEnhanceParts{ derived = DeriveDescriptionFormulaFromLegacyParts(LegacyEnhanceParts{
@@ -194,6 +203,9 @@ func EffectiveDescriptionFormula(stored any, categoryPrompt string) DescriptionF
} }
metaBody := ExtractEnhanceSectionBody(categoryPrompt, SectionMetaStart, SectionMetaEnd) metaBody := ExtractEnhanceSectionBody(categoryPrompt, SectionMetaStart, SectionMetaEnd)
if isBuiltInMetaRoleBoilerplate(metaBody) {
metaBody = ""
}
if strings.TrimSpace(out.MetaTitle) == "" && strings.TrimSpace(out.MetaDescription) == "" && strings.TrimSpace(metaBody) != "" { if strings.TrimSpace(out.MetaTitle) == "" && strings.TrimSpace(out.MetaDescription) == "" && strings.TrimSpace(metaBody) != "" {
out.MetaDescription = strings.TrimSpace(metaBody) out.MetaDescription = strings.TrimSpace(metaBody)
out.MetaTitle = DefaultLegacyMetaTitleRule out.MetaTitle = DefaultLegacyMetaTitleRule
@@ -208,6 +220,16 @@ func EffectiveDescriptionFormula(stored any, categoryPrompt string) DescriptionF
return out 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 { func promptHasRoleMarkers(prompt string) bool {
lower := strings.ToLower(prompt) lower := strings.ToLower(prompt)
return strings.Contains(lower, strings.ToLower(SectionTitleStart)) || return strings.Contains(lower, strings.ToLower(SectionTitleStart)) ||
@@ -220,6 +242,26 @@ func TitleSectionInstructions(categoryPrompt string) string {
return ExtractEnhanceSectionBody(categoryPrompt, SectionTitleStart, SectionTitleEnd) 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 // EffectiveDescriptionTemplateAny is EffectiveDescriptionFormula as a JSON object
// for processing.ProductInput.DescriptionTemplate (nil when empty). // for processing.ProductInput.DescriptionTemplate (nil when empty).
func EffectiveDescriptionTemplateAny(stored any, categoryPrompt string) any { func EffectiveDescriptionTemplateAny(stored any, categoryPrompt string) any {
@@ -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 <name> and
// <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}}.
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 <name> 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 <name> or <metaDescription> tags
- "meta_title" and "meta_description" are plain SEO text from the meta / <metaDescription> 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 → <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 {
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 = "<p>{" + descBody + "}</p>"
}
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, "<name>{%s}</name>\n", collapseLegacyInstr(titleInstr))
}
if !omitSEOMeta && metaInstr != "" {
fmt.Fprintf(&predloga, "<metaDescription>{%s}</metaDescription>\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 <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")
}
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 (<H2>{…}</H2><p>{…}</p><ul>…).
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}</%s>", strings.ToUpper(typ), instr, strings.ToUpper(typ))
case "ul", "ol", "li":
fmt.Fprintf(&b, "<b>{Tehnične specifikacije}</b><ul><li>{%s}</li></ul>", instr)
case "p", "":
fmt.Fprintf(&b, "<p>{%s}</p>", instr)
default:
fmt.Fprintf(&b, "<p>{%s}</p>", instr)
}
}
return b.String()
}
// ParseLegacyEnhanceHTML extracts name / metaDescription / body HTML from a legacy
// A1 model reply (generator.php preg_match on <name> / <metaDescription>).
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 <name>/<metaDescription> 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 == "<nil>" {
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 != "<nil>" {
cleaned := reLegacyNameStrip.ReplaceAllString(desc, "")
cleaned = reLegacyMetaStrip.ReplaceAllString(cleaned, "")
cleaned = strings.TrimSpace(cleaned)
if cleaned != "" && cleaned != desc {
obj["description"] = cleaned
}
}
return
}
if (name == "" || name == "<nil>") && ln != "" {
obj["name"] = ln
}
if (metaDesc == "" || metaDesc == "<nil>") && lm != "" {
obj["meta_description"] = lm
}
if (metaTitle == "" || metaTitle == "<nil>") && ln != "" {
// Legacy only had metaDescription; seed meta_title from new name when empty.
obj["meta_title"] = ln
}
if lb != "" {
obj["description"] = lb
}
}
@@ -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" +
`<H2>{Napiši Novo ime izdelka in izpostavi en benefit}</H2>` +
`<p>{Napiši odstavek, ki je dolg 100 besed.}</p>` +
`<b>{Tehnične specifikacije}</b><ul><li>{Napiši 3-10 tehničnih specifikacij}</li></ul>` + "\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>{",
"<metaDescription>{",
"<H2>{",
`"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 := `<name>ovitek PanzerGlass Honor 200 Smart črna</name>` +
`<metaDescription>Ovitek PanzerGlass za Honor 200 Smart z elegantnim videzom.</metaDescription>` +
`<h2>Zaščita z elegantnim videzom</h2><p>Dolg odstavek o ovitku.</p>` +
`<ul><li>Barva: črna</li></ul>`
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, "<name>") || strings.Contains(body, "metaDescription") {
t.Fatalf("body still has tags: %q", body)
}
if !strings.Contains(body, "<h2>") || !strings.Contains(body, "<ul>") {
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"]), "<name>") {
t.Fatalf("fallback desc still has name tag: %v", obj["description"])
}
}
@@ -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" +
`<H2>{Napiši Novo ime izdelka in izpostavi en benefit}</H2>` +
`<p>{Napiši odstavek, ki je dolg 100 besed, ki NE vsebuje Novo ime izdelka.}</p>` +
`<H2>{Izpostavi en benefit, in NE napiši Novo ime izdelka}</H2>` +
`<p>{Napiši odstavek, ki je dolg 100 besed in VKLJUČI tudi tip izdelka lowercase}</p>` +
`<b>{Tehnične specifikacije}</b><ul><li>{Napiši 3-10 tehničnih specifikacij v alinejah}</li></ul>` + "\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": "<h2>Zaščita z elegantnim videzom</h2><p>" + strings.Repeat("Besedilo o ovitku. ", 20) +
"</p><h2>Trden oprijem</h2><p>" + strings.Repeat("Še več besedila. ", 20) +
"</p><ul><li>Barva: črna</li><li>Model: Honor 200 Smart</li></ul>",
"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, "<name>{") {
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, "<H2>{") && !strings.Contains(cap.user, "<h2>{") {
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)
}
}
@@ -77,24 +77,21 @@ func TestA1StyleDescriptionFormulaInResolvedPrompts(t *testing.T) {
TitleTemplate: titleFormula, TitleTemplate: titleFormula,
DescriptionTemplate: descFormula, DescriptionTemplate: descFormula,
}) })
if !strings.Contains(sysTpl, "Description formula") { if !strings.Contains(sysTpl, "Si tekstopisec") {
t.Fatalf("system missing formula override: %s", sysTpl) t.Fatalf("system missing A1 copywriter: %s", sysTpl)
} }
if !strings.Contains(sysTpl, `When the user message includes a "Title formula"`) { if !strings.Contains(userTpl, "GPT predloga:") || !strings.Contains(userTpl, "<H1>{Naziv izdelka}</H1>") {
t.Fatalf("system missing title formula override: %s", sysTpl) 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") { if !strings.Contains(userTpl, "<name>{") || !strings.Contains(userTpl, "brand") {
t.Fatalf("user missing description formula: %s", userTpl) t.Fatalf("user missing A1 title predloga: %s", userTpl)
}
if !strings.Contains(userTpl, "Title formula") || !strings.Contains(userTpl, "attr [brand]") {
t.Fatalf("user missing title formula: %s", userTpl)
} }
system, user := RenderProductEnhancePrompts( system, user := RenderProductEnhancePrompts(
sysTpl, userTpl, sysTpl, userTpl,
"Štedilniki", "VOX EHT6020", "", "", "", "sl", "Štedilniki", "VOX EHT6020", "", "", "", "sl",
map[string]any{"brand": "VOX", "product_model": "EHT6020"}, 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) t.Fatalf("rendered user missing formula: %s", user)
} }
if !strings.Contains(user, "Slovenian") && !strings.Contains(system, "Slovenian") { if !strings.Contains(user, "Slovenian") && !strings.Contains(system, "Slovenian") {
@@ -65,11 +65,12 @@ func TestAppendDescriptionFormulaSystemOverride(t *testing.T) {
EnhanceUserTemplate: "Name: {{name}}", EnhanceUserTemplate: "Name: {{name}}",
DescriptionTemplate: desc, DescriptionTemplate: desc,
}) })
if !strings.Contains(user, "Description formula") { // Structured description_template uses A1 GPT-predloga path (legacy generator.php).
t.Fatalf("missing user formula: %s", user) if !strings.Contains(user, "GPT predloga:") || !strings.Contains(user, "<H1>{Heading}</H1>") {
t.Fatalf("missing A1 description predloga: %s", user)
} }
if !strings.Contains(sys, "Description formula") || !strings.Contains(sys, "1-2 sentences") { if !strings.Contains(sys, "Si tekstopisec") {
t.Fatalf("system should keep company text and append formula override: %s", sys) t.Fatalf("system should be A1 copywriter when formula set: %s", sys)
} }
if AppendDescriptionFormulaSystemOverride("plain", nil) != "plain" { if AppendDescriptionFormulaSystemOverride("plain", nil) != "plain" {
t.Fatal("empty formula must be no-op") t.Fatal("empty formula must be no-op")
@@ -90,16 +91,22 @@ func TestAppendTitleFormulaSystemOverride(t *testing.T) {
EnhanceUserTemplate: "Name: {{name}}", EnhanceUserTemplate: "Name: {{name}}",
TitleTemplate: title, TitleTemplate: title,
}) })
if !strings.Contains(user, "Title formula") || !strings.Contains(user, "attr [brand]") { if !strings.Contains(user, "GPT predloga:") || !strings.Contains(user, "<name>{") {
t.Fatalf("missing user title formula: %s", 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") { if !strings.Contains(user, "brand") {
t.Fatalf("system should keep company text and append title override: %s", sys) 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" { if AppendTitleFormulaSystemOverride("plain", nil) != "plain" {
t.Fatal("empty title formula must be no-op") 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 { if strings.Count(dup, `When the user message includes a "Title formula"`) != 1 {
t.Fatalf("title override must be idempotent: %s", dup) t.Fatalf("title override must be idempotent: %s", dup)
} }
@@ -239,24 +246,24 @@ func TestAppendFormulaConstraints_injectedIntoResolve(t *testing.T) {
TitleTemplate: title, TitleTemplate: title,
DescriptionTemplate: desc, DescriptionTemplate: desc,
}) })
if !strings.Contains(user, "Name: {{name}}") { if !strings.Contains(user, "GPT predloga:") {
t.Fatalf("lost base template: %s", user) t.Fatalf("expected A1 GPT predloga: %s", user)
} }
if !strings.Contains(user, "Title formula") || !strings.Contains(user, "attr [brand]") { if !strings.Contains(user, "<name>{") || !strings.Contains(user, "brand") {
t.Fatalf("missing title formula: %s", user) t.Fatalf("missing title formula in predloga: %s", user)
} }
if !strings.Contains(user, "Description formula") || !strings.Contains(user, "- p: Factual summary") { if !strings.Contains(user, "<p>{Factual summary}</p>") {
t.Fatalf("missing description formula: %s", user) t.Fatalf("missing description formula HTML: %s", user)
} }
if !strings.Contains(user, "SEO meta formula") || !strings.Contains(user, "meta_title (50-60 chars)") { if !strings.Contains(user, "<metaDescription>{") || !strings.Contains(user, "120-155") {
t.Fatalf("missing SEO meta formula: %s", user) t.Fatalf("missing SEO meta in predloga: %s", user)
} }
if !strings.Contains(sys, "SEO meta formula") { if !strings.Contains(sys, "Si tekstopisec") {
t.Fatalf("system missing meta override: %s", sys) t.Fatalf("system missing A1 copywriter: %s", sys)
} }
// Render substitutes {{language}} in formula blocks. // Render substitutes {{language}} in formula blocks.
_, rendered := RenderProductEnhancePrompts( _, rendered := RenderProductEnhancePrompts(
"Write in {{language}}.", user, sys, user,
"50", "Old Name", "", "", "", "sl", "50", "Old Name", "", "", "", "sl",
map[string]any{"brand": "Vox", "product_model": "EHT6020"}, map[string]any{"brand": "Vox", "product_model": "EHT6020"},
) )
+13 -3
View File
@@ -11,6 +11,19 @@ func resolveProductPromptTemplates(in ProductInput) (systemTpl, userTpl string)
systemTpl = strings.TrimSpace(in.EnhanceSystemTemplate) systemTpl = strings.TrimSpace(in.EnhanceSystemTemplate)
userTpl = strings.TrimSpace(in.EnhanceUserTemplate) userTpl = strings.TrimSpace(in.EnhanceUserTemplate)
catPrompt := strings.TrimSpace(in.CategoryEnhancePrompt) catPrompt := strings.TrimSpace(in.CategoryEnhancePrompt)
descTpl := aiprompts.EffectiveDescriptionTemplateAny(in.DescriptionTemplate, catPrompt)
// Legacy A1 path: category Prompt WAS the user message (GPT predloga with
// <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
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). // Per-category prompt wins for the user message (company system keeps JSON schema / brand).
if catPrompt != "" { if catPrompt != "" {
userTpl = ensureCategoryEnhanceUserContext(catPrompt) userTpl = ensureCategoryEnhanceUserContext(catPrompt)
@@ -24,9 +37,6 @@ func resolveProductPromptTemplates(in ProductInput) (systemTpl, userTpl string)
userTpl = def.UserTemplate userTpl = def.UserTemplate
} }
} }
// Prefer stored description_template; else derive HTML sections from the
// category prompt Description role (A1-style <H2>{…}</H2> overlays).
descTpl := aiprompts.EffectiveDescriptionTemplateAny(in.DescriptionTemplate, catPrompt)
// Category formulas are language-agnostic; inject once into the shared user skeleton. // Category formulas are language-agnostic; inject once into the shared user skeleton.
userTpl = AppendFormulaConstraints(userTpl, in.TitleTemplate, descTpl, in.OmitSEOMeta) userTpl = AppendFormulaConstraints(userTpl, in.TitleTemplate, descTpl, in.OmitSEOMeta)
// Free-form --- Title --- instructions when structured title_template is empty. // Free-form --- Title --- instructions when structured title_template is empty.
@@ -82,26 +82,18 @@ func TestResolveProductPromptTemplates_categoryWithTitleFormula(t *testing.T) {
TitleTemplate: title, TitleTemplate: title,
DescriptionTemplate: desc, DescriptionTemplate: desc,
}) })
if !strings.Contains(user, "Emphasize energy class") { // Structured formulas trigger A1-style GPT predloga (legacy generator.php behavior).
t.Fatalf("missing category prompt: %s", user) 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") { if !strings.Contains(user, "GPT predloga:") {
t.Fatalf("missing category title framing: %s", user) t.Fatalf("expected GPT predloga user: %s", user)
} }
if !strings.Contains(user, "Title formula") || !strings.Contains(user, "attr [brand]") { if !strings.Contains(user, "Emphasize energy class") && !strings.Contains(user, "<name>") {
t.Fatalf("missing title formula: %s", user) t.Fatalf("expected title/desc formula in predloga: %s", user)
} }
if !strings.Contains(user, "Description formula") || !strings.Contains(user, "- p: Factual summary") { if !strings.Contains(user, "<p>{Factual summary}</p>") && !strings.Contains(user, "Factual summary") {
t.Fatalf("missing description formula: %s", user) t.Fatalf("missing description formula HTML: %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)
} }
} }
@@ -123,20 +115,17 @@ func TestResolveProductPromptTemplates_derivesFormulaFromPromptHTML(t *testing.T
// Empty structured template — production A1 path often only has prompt HTML. // Empty structured template — production A1 path often only has prompt HTML.
DescriptionTemplate: nil, DescriptionTemplate: nil,
}) })
if !strings.Contains(user, "Description formula") { if !strings.Contains(sys, "Si tekstopisec") {
t.Fatalf("expected Description formula derived from prompt HTML:\n%s", user) t.Fatalf("expected A1 copywriter system:\n%s", sys)
} }
if !strings.Contains(user, "- h2:") || !strings.Contains(user, "- p:") || !strings.Contains(user, "- ul:") { if !strings.Contains(user, "GPT predloga:") || !strings.Contains(user, "<name>{") {
t.Fatalf("expected h2/p/ul sections in formula:\n%s", user) t.Fatalf("expected legacy GPT predloga with <name>:\n%s", user)
} }
if !strings.Contains(sys, "Description formula") { if !strings.Contains(user, "<H2>{") && !strings.Contains(user, "<h2>{") {
t.Fatalf("missing description system override:\n%s", sys) t.Fatalf("expected HTML description formula in predloga:\n%s", user)
} }
if !strings.Contains(sys, `"name" MUST be rewritten`) { if strings.Contains(user, "Category guidance (applies to name") {
t.Fatalf("missing title rewrite system override:\n%s", sys) t.Fatal("A1 path must not use soft category guidance overlay")
}
if !strings.Contains(user, "Title instructions (REQUIRED") {
t.Fatalf("missing Title section constraint:\n%s", user)
} }
effective := aiprompts.EffectiveDescriptionTemplateAny(nil, prompt) effective := aiprompts.EffectiveDescriptionTemplateAny(nil, prompt)
if descriptionSatisfiesFormula("plain Slovenian prose without tags", effective) { if descriptionSatisfiesFormula("plain Slovenian prose without tags", effective) {
+16
View File
@@ -863,6 +863,22 @@ func (e *Engine) enhance(ctx context.Context, in ProductInput, category string,
ReasoningEffort: "low", ReasoningEffort: "low",
}) })
elapsed := time.Since(started) 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 <name>/<metaDescription> 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 { if err != nil {
// Network/provider failure vs parse failure after retry // Network/provider failure vs parse failure after retry
name := preferredProductTitle(in.GTIN, in.Name, in.PriorProcessedName) name := preferredProductTitle(in.GTIN, in.Name, in.PriorProcessedName)