330 lines
11 KiB
Go
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
|
|
}
|