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"}. // {{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":{}} - 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}}` // 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 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 or tags - "meta_title" and "meta_description" are plain SEO text from the meta / 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*>`) 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, bool) { 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') } body := strings.TrimSpace(predloga.String()) slovenian := reSlovenianFormulaCue.MatchString(body) l := a1StyleLabelsEN if slovenian { l = a1StyleLabelsSL } 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 template. extra := strings.TrimSpace(catPrompt) if extra != "" && !aiprompts.CategoryEnhanceHasRoleSections(extra) && !aiprompts.IsLegacyCombinedEnhancePrompt(extra) { fmt.Fprintf(&b, "\n\n%s\n", l.extraHeader) b.WriteString(extra) } 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 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 / tagov`, metaRule: `- "meta_title" / "meta_description" = plain SEO po meta / 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 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 / tags`, metaRule: `- "meta_title" / "meta_description" = plain SEO text per the meta / 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") 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 } }