Files

327 lines
13 KiB
Go
Raw Permalink Normal View History

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":{}}
2026-08-24 04:03:44 +02:00
- Obey the GPT predloga in the user message step by step
2026-08-23 18:58:03 +02:00
- "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
}
}