327 lines
13 KiB
Go
327 lines
13 KiB
Go
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"}.
|
|
|
|
// {{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
|
|
- "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}}`
|
|
|
|
// 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}}`
|
|
|
|
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, 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 = "<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')
|
|
}
|
|
|
|
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 <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:",
|
|
}
|
|
|
|
// 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 (<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
|
|
}
|
|
}
|