Files
descrybe/apps/api/internal/processing/a1_enhance_prompt.go
T

247 lines
9.3 KiB
Go
Raw 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"}.
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
}
}