2026-08-23 18:58:03 +02:00
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"}.
2026-08-23 20:49:40 +02:00
// {{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 }} .
2026-08-23 18:58:03 +02:00
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 }} `
2026-08-23 20:49:40 +02:00
// 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 }} `
2026-08-23 18:58:03 +02:00
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.
2026-08-23 20:49:40 +02:00
func buildA1StyleEnhanceUserTemplate ( catPrompt string , titleTemplate , descriptionTemplate any , omitSEOMeta bool ) ( string , bool ) {
2026-08-23 18:58:03 +02:00
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' )
}
2026-08-23 20:49:40 +02:00
body := strings . TrimSpace ( predloga . String ())
slovenian := reSlovenianFormulaCue . MatchString ( body )
l := a1StyleLabelsEN
if slovenian {
l = a1StyleLabelsSL
}
2026-08-23 18:58:03 +02:00
var b strings . Builder
2026-08-23 20:49:40 +02:00
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 )
2026-08-23 18:58:03 +02:00
b . WriteString ( "Attrs: {{attrs}}\n\n" )
2026-08-23 20:49:40 +02:00
fmt . Fprintf ( & b , "%s\n%s\n\n" , l . useTemplate , l . followTemplate )
fmt . Fprintf ( & b , "%s\n\n" , l . templateHeader )
b . WriteString ( body )
2026-08-23 18:58:03 +02:00
b . WriteString ( "\n\n" )
2026-08-23 20:49:40 +02:00
fmt . Fprintf ( & b , "%s\n" , l . replyHeader )
fmt . Fprintf ( & b , "%s\n" , l . nameRule )
fmt . Fprintf ( & b , "%s\n" , l . descRule )
2026-08-23 18:58:03 +02:00
if ! omitSEOMeta {
2026-08-23 20:49:40 +02:00
fmt . Fprintf ( & b , "%s\n" , l . metaRule )
2026-08-23 18:58:03 +02:00
}
2026-08-23 20:49:40 +02:00
b . WriteString ( l . attrsRule )
2026-08-23 18:58:03 +02:00
// Free-form category overlay (no role markers) — keep as extra guidance like
2026-08-23 20:49:40 +02:00
// soft "Category guidance" did, without replacing the template.
2026-08-23 18:58:03 +02:00
extra := strings . TrimSpace ( catPrompt )
if extra != "" &&
! aiprompts . CategoryEnhanceHasRoleSections ( extra ) &&
! aiprompts . IsLegacyCombinedEnhancePrompt ( extra ) {
2026-08-23 20:49:40 +02:00
fmt . Fprintf ( & b , "\n\n%s\n" , l . extraHeader )
2026-08-23 18:58:03 +02:00
b . WriteString ( extra )
}
2026-08-23 20:49:40 +02:00
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:" ,
2026-08-23 18:58:03 +02:00
}
2026-08-23 20:49:40 +02:00
// 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` )
2026-08-23 18:58:03 +02:00
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
}
}