Files
descrybe/apps/api/internal/aiprompts/description_formula.go
T
2026-08-23 20:49:40 +02:00

330 lines
11 KiB
Go

package aiprompts
import (
"encoding/json"
"regexp"
"strings"
)
var (
reLegacyHTMLBlock = regexp.MustCompile(`(?is)<\s*(h[1-4]|p|ul|ol|li|b|strong)\s*>\s*\{?\s*(.*?)\s*\}?\s*<\s*/\s*(?:h[1-4]|p|ul|ol|li|b|strong)\s*>`)
reLegacyBoldUL = regexp.MustCompile(`(?is)<\s*b\s*>\s*\{?\s*(.*?)\s*\}?\s*<\s*/\s*b\s*>\s*<\s*ul\s*>\s*<\s*li\s*>\s*\{?\s*(.*?)\s*\}?\s*<\s*/\s*li\s*>\s*<\s*/\s*ul\s*>`)
)
// LegacyHTMLFormulaPresent reports whether s contains A1-style HTML formula blocks
// (<H2>{…}</H2>, <p>{…}</p>, …) used in category Description GPT predloga.
func LegacyHTMLFormulaPresent(s string) bool {
return reLegacyHTMLBlock.MatchString(s)
}
// DescriptionFormulaSection is one ordered block in categories.description_template.
type DescriptionFormulaSection struct {
Type string `json:"type"`
Instructions string `json:"instructions"`
}
// DescriptionFormula is the JSON shape stored in categories.description_template
// (sections + optional SEO meta instruction strings).
type DescriptionFormula struct {
Sections []DescriptionFormulaSection `json:"sections,omitempty"`
MetaTitle string `json:"metaTitle,omitempty"`
MetaDescription string `json:"metaDescription,omitempty"`
}
// DescriptionTemplateNeedsRepair is true when template is missing or has no sections
// (and no usable meta instructions).
func DescriptionTemplateNeedsRepair(template any) bool {
f, ok := parseDescriptionFormula(template)
if !ok {
return true
}
if len(f.Sections) > 0 {
return false
}
return strings.TrimSpace(f.MetaTitle) == "" && strings.TrimSpace(f.MetaDescription) == ""
}
// DefaultLegacyMetaTitleRule fills description_template.metaTitle for legacy A1
// prompts, which only carried a <metaDescription> instruction. Mirrors the legacy
// name-first Slovenian instruction style with the SEO title length bound.
const DefaultLegacyMetaTitleRule = "Najprej napiši Novo ime izdelka in dodaj glavno prednost. Dolžina 50-60 znakov vključno s presledki."
// DefaultMetaTitleRuleEN is the same instruction for the English platform-default
// formulas. Legacy dumps carry only a <metaDescription> rule, so the meta title
// instruction is supplied here in the language of the formula set.
const DefaultMetaTitleRuleEN = "Start with the New product name and add the main benefit. Length 50-60 characters including spaces."
// DeriveDescriptionFormulaFromLegacyParts builds description_template from split
// legacy DescriptionRules HTML + MetaRules. Legacy dumps never carried a meta
// title rule, so meta intent (MetaRules present) also seeds a default metaTitle
// instruction — the category UI then shows all four prompt areas filled.
func DeriveDescriptionFormulaFromLegacyParts(parts LegacyEnhanceParts) DescriptionFormula {
out := DescriptionFormula{
MetaDescription: strings.TrimSpace(parts.MetaRules),
}
if out.MetaDescription != "" {
out.MetaTitle = DefaultLegacyMetaTitleRule
}
body := strings.TrimSpace(parts.DescriptionRules)
if body == "" {
return out
}
// Walk left-to-right so section order matches the legacy HTML template.
remaining := body
for remaining != "" {
boldUL := reLegacyBoldUL.FindStringSubmatchIndex(remaining)
block := reLegacyHTMLBlock.FindStringSubmatchIndex(remaining)
switch {
case boldUL != nil && (block == nil || boldUL[0] <= block[0]):
m := reLegacyBoldUL.FindStringSubmatch(remaining[boldUL[0]:boldUL[1]])
heading, instr := "", ""
if len(m) >= 3 {
heading = cleanLegacyInstruction(m[1])
instr = cleanLegacyInstruction(m[2])
}
if heading != "" && instr != "" && !strings.Contains(strings.ToLower(instr), strings.ToLower(heading)) {
instr = heading + ": " + instr
} else if instr == "" {
instr = heading
}
if strings.TrimSpace(instr) != "" {
out.Sections = append(out.Sections, DescriptionFormulaSection{Type: "ul", Instructions: instr})
}
remaining = remaining[boldUL[1]:]
case block != nil:
m := reLegacyHTMLBlock.FindStringSubmatch(remaining[block[0]:block[1]])
if len(m) >= 3 {
typ := strings.ToLower(strings.TrimSpace(m[1]))
instr := cleanLegacyInstruction(m[2])
if instr != "" {
switch typ {
case "b", "strong":
typ = "h2"
case "li", "ol":
typ = "ul"
}
out.Sections = append(out.Sections, DescriptionFormulaSection{Type: typ, Instructions: instr})
}
}
remaining = remaining[block[1]:]
default:
return out
}
}
return out
}
// DeriveDescriptionTemplateJSON encodes DeriveDescriptionFormulaFromLegacyParts for DB write.
func DeriveDescriptionTemplateJSON(parts LegacyEnhanceParts) string {
f := DeriveDescriptionFormulaFromLegacyParts(parts)
if len(f.Sections) == 0 && strings.TrimSpace(f.MetaTitle) == "" && strings.TrimSpace(f.MetaDescription) == "" {
return ""
}
b, err := json.Marshal(f)
if err != nil {
return ""
}
return string(b)
}
func parseDescriptionFormula(template any) (DescriptionFormula, bool) {
if template == nil {
return DescriptionFormula{}, false
}
switch t := template.(type) {
case DescriptionFormula:
return t, true
case *DescriptionFormula:
if t == nil {
return DescriptionFormula{}, false
}
return *t, true
case []byte:
if len(t) == 0 {
return DescriptionFormula{}, false
}
var f DescriptionFormula
if err := json.Unmarshal(t, &f); err != nil {
return DescriptionFormula{}, false
}
return f, true
case string:
s := strings.TrimSpace(t)
if s == "" || s == "null" || s == "{}" {
return DescriptionFormula{}, false
}
var f DescriptionFormula
if err := json.Unmarshal([]byte(s), &f); err != nil {
return DescriptionFormula{}, false
}
return f, true
default:
b, err := json.Marshal(t)
if err != nil {
return DescriptionFormula{}, false
}
var f DescriptionFormula
if err := json.Unmarshal(b, &f); err != nil {
return DescriptionFormula{}, false
}
return f, true
}
}
// EffectiveDescriptionFormula merges structured description_template with the
// category enhance prompt Title/Description/Meta role sections.
//
// Precedence for HTML body sections:
// 1. Prompt Description HTML overlay when it yields more sections than stored
// (tenants author structure in /categories/.../prompt Section instructions).
// 2. Otherwise stored description_template.sections.
//
// Meta instructions: stored metaTitle/metaDescription win; else --- Meta --- body.
func EffectiveDescriptionFormula(stored any, categoryPrompt string) DescriptionFormula {
storedF, storedOK := parseDescriptionFormula(stored)
out := DescriptionFormula{}
if storedOK {
out = storedF
}
descBody := ExtractEnhanceSectionBody(categoryPrompt, SectionDescriptionStart, SectionDescriptionEnd)
if descBody == "" && !promptHasRoleMarkers(categoryPrompt) {
// Unsectioned prompt may still be a legacy HTML blob.
descBody = strings.TrimSpace(categoryPrompt)
}
if isBuiltInDescriptionRoleBoilerplate(descBody) {
descBody = ""
}
var derived DescriptionFormula
if descBody != "" && reLegacyHTMLBlock.MatchString(descBody) {
derived = DeriveDescriptionFormulaFromLegacyParts(LegacyEnhanceParts{
DescriptionRules: descBody,
WasLegacy: true,
})
}
// A legacy HTML formula in the category prompt is the tenant's own authored
// structure, so it wins outright — it is also what buildA1StyleEnhanceUserTemplate
// puts in the GPT predloga. Preferring it only when it has MORE sections let a
// stale stored template (e.g. seven English boilerplate sections from an old
// migration) validate against a five-section prompt: the model obeyed the prompt,
// the gate rejected the reply as formula-mismatch, and enhance fell back to synth
// or supplier copy. Instruction and gate must read the same formula.
if len(derived.Sections) > 0 {
out.Sections = derived.Sections
}
metaBody := ExtractEnhanceSectionBody(categoryPrompt, SectionMetaStart, SectionMetaEnd)
if isBuiltInMetaRoleBoilerplate(metaBody) {
metaBody = ""
}
if strings.TrimSpace(out.MetaTitle) == "" && strings.TrimSpace(out.MetaDescription) == "" && strings.TrimSpace(metaBody) != "" {
out.MetaDescription = strings.TrimSpace(metaBody)
out.MetaTitle = DefaultLegacyMetaTitleRule
} else {
if strings.TrimSpace(out.MetaTitle) == "" && strings.TrimSpace(derived.MetaTitle) != "" {
out.MetaTitle = derived.MetaTitle
}
if strings.TrimSpace(out.MetaDescription) == "" && strings.TrimSpace(derived.MetaDescription) != "" {
out.MetaDescription = derived.MetaDescription
}
}
return out
}
func isBuiltInMetaRoleBoilerplate(body string) bool {
lower := strings.ToLower(strings.TrimSpace(body))
return strings.HasPrefix(lower, "role: meta")
}
func isBuiltInDescriptionRoleBoilerplate(body string) bool {
lower := strings.ToLower(strings.TrimSpace(body))
return strings.HasPrefix(lower, "role: description")
}
func promptHasRoleMarkers(prompt string) bool {
lower := strings.ToLower(prompt)
return strings.Contains(lower, strings.ToLower(SectionTitleStart)) ||
strings.Contains(lower, strings.ToLower(SectionDescriptionStart)) ||
strings.Contains(lower, strings.ToLower(SectionMetaStart))
}
// TitleSectionInstructions returns the --- Title --- body from a category prompt.
func TitleSectionInstructions(categoryPrompt string) string {
return ExtractEnhanceSectionBody(categoryPrompt, SectionTitleStart, SectionTitleEnd)
}
// DescriptionSectionInstructions returns the --- Description --- body, or empty
// when it is only the built-in Role: description boilerplate.
func DescriptionSectionInstructions(categoryPrompt string) string {
body := ExtractEnhanceSectionBody(categoryPrompt, SectionDescriptionStart, SectionDescriptionEnd)
if isBuiltInDescriptionRoleBoilerplate(body) {
return ""
}
return body
}
// MetaSectionInstructions returns the --- Meta --- body, or empty when it is only
// the built-in Role: meta boilerplate.
func MetaSectionInstructions(categoryPrompt string) string {
body := ExtractEnhanceSectionBody(categoryPrompt, SectionMetaStart, SectionMetaEnd)
if isBuiltInMetaRoleBoilerplate(body) {
return ""
}
return body
}
// EffectiveDescriptionTemplateAny is EffectiveDescriptionFormula as a JSON object
// for processing.ProductInput.DescriptionTemplate (nil when empty).
func EffectiveDescriptionTemplateAny(stored any, categoryPrompt string) any {
f := EffectiveDescriptionFormula(stored, categoryPrompt)
if len(f.Sections) == 0 && strings.TrimSpace(f.MetaTitle) == "" && strings.TrimSpace(f.MetaDescription) == "" {
return nil
}
b, err := json.Marshal(f)
if err != nil {
return nil
}
var out map[string]any
if err := json.Unmarshal(b, &out); err != nil {
return nil
}
return out
}
// CategoryTitlePromptRequiresRewrite reports whether the Title role instructs a
// new retail name (not keep/echo supplier title). Used to reject "Matched" copy-paste.
func CategoryTitlePromptRequiresRewrite(categoryPrompt string) bool {
body := TitleSectionInstructions(categoryPrompt)
if body == "" {
body = strings.TrimSpace(categoryPrompt)
}
if body == "" {
return false
}
// Built-in CategoryEnhanceUserTemplate Title role mentions "Title formula"
// generically — that is not a tenant rewrite formula.
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(body)), "role: title") {
return false
}
lower := strings.ToLower(body)
cues := []string{
"napiši novo ime",
"napisi novo ime",
"novo ime izdelka",
"po formuli",
"write a new",
"new product name",
"rename",
"sentence case",
"tip izdelka",
"product type",
}
for _, c := range cues {
if strings.Contains(lower, c) {
return true
}
}
return false
}