This commit is contained in:
2026-08-17 00:39:25 +02:00
parent 92f046b542
commit 93dc70123c
54 changed files with 25528 additions and 20666 deletions
@@ -0,0 +1,152 @@
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*>`)
)
// 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) == ""
}
// DeriveDescriptionFormulaFromLegacyParts builds description_template from split
// legacy DescriptionRules HTML + MetaRules.
func DeriveDescriptionFormulaFromLegacyParts(parts LegacyEnhanceParts) DescriptionFormula {
out := DescriptionFormula{
MetaDescription: strings.TrimSpace(parts.MetaRules),
}
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
}
}
@@ -0,0 +1,56 @@
package aiprompts
import (
"strings"
"testing"
)
func TestDeriveDescriptionFormulaFromLegacyParts(t *testing.T) {
t.Parallel()
legacy := `Ustvari nov opis
GPT predloga:
<name>{Napiši novo ime izdelka po formuli: "znamka", "tip izdelka lowercase". Ne uporabljaj vejic.}</name>
<metaDescription>{Najprej napiši Novo_ime_izdelka in potem nadaljuj dokler nisi zapisal 140 znakov}</metaDescription>
<H2>{Napiši Novo ime izdelka in izpostavi en benefit}</H2>
<p>{Napiši odstavek, ki je dolg 100 besed.}</p>
<b>{Tehnične specifikacije}</b><ul><li>{Napiši 3-10 tehničnih specifikacij v alinejah}</li></ul>`
parts := ParseLegacyCombinedEnhancePrompt(legacy)
if !parts.WasLegacy {
t.Fatal("expected legacy parse")
}
f := DeriveDescriptionFormulaFromLegacyParts(parts)
if strings.TrimSpace(f.MetaDescription) == "" || !strings.Contains(f.MetaDescription, "140") {
t.Fatalf("metaDescription=%q", f.MetaDescription)
}
if len(f.Sections) < 3 {
t.Fatalf("want >=3 sections, got %#v", f.Sections)
}
if f.Sections[0].Type != "h2" {
t.Fatalf("first section should be h2 (document order), got %#v", f.Sections[0])
}
last := f.Sections[len(f.Sections)-1]
if last.Type != "ul" {
t.Fatalf("last section should be ul specs, got %#v", last)
}
var sawH2, sawP, sawUL bool
for _, s := range f.Sections {
switch s.Type {
case "h2":
sawH2 = true
case "p":
sawP = true
case "ul":
sawUL = true
}
}
if !sawH2 || !sawP || !sawUL {
t.Fatalf("missing section types: %#v", f.Sections)
}
raw := DeriveDescriptionTemplateJSON(parts)
if raw == "" || DescriptionTemplateNeedsRepair([]byte(raw)) {
t.Fatalf("derived template should be OK: %s", raw)
}
}
+13 -4
View File
@@ -64,7 +64,7 @@ type DefaultTemplate struct {
const CategoryEnhanceUserTemplate = `Your reply is parsed as JSON {"name":"string","description":"string","meta_title":"string","meta_description":"string","attrs":{}} only (system schema). Write all string fields in {{language}} (do not hardcode a language).
--- Title ---
Role: title. Build JSON "name": short retail title; follow any Title formula constraints that follow; use Attrs.
` + TitleRoleInstruction + `
Name: {{name}}
--- End Title ---
@@ -84,13 +84,22 @@ Attrs: {{attrs}}
--- End Attributes ---`
// CategoryEnhancePromptNeedsRepair reports whether a stored categories.prompt value
// should be replaced by CategoryEnhanceUserTemplate (idempotent equality check).
// should be rewritten into role-sectioned enhance overlay form. Empty prompts are
// left alone. Canonical CategoryEnhanceUserTemplate and per-category overlays that
// already carry Title/Description/Meta/Attributes markers (+ {{attrs}}) are OK —
// equality with the shared template is not required (legacy splits keep Slovenian rules).
func CategoryEnhancePromptNeedsRepair(prompt string) bool {
p := strings.TrimSpace(prompt)
if p == "" {
return false
}
return p != strings.TrimSpace(CategoryEnhanceUserTemplate)
if IsLegacyCombinedEnhancePrompt(p) {
return true
}
if CategoryEnhanceHasRoleSections(p) && strings.Contains(p, "{{attrs}}") {
return false
}
return true
}
// BuiltInDefaults match the previous hardcoded system prompts, with structured user templates.
@@ -103,7 +112,7 @@ var BuiltInDefaults = []DefaultTemplate{
Rules:
- Reply with ONLY JSON (no markdown)
- Schema: {"name":"string","description":"string","meta_title":"string","meta_description":"string","attrs":{}}
- name: short retail title
- name: short retail title — never brand-only; follow any Title formula (type + brand + model); use Attrs
- description: product body HTML only — follow any Description formula in the user message (structured HTML sections); otherwise 1-3 factual paragraphs; never copy name as description; never put SEO meta here
- meta_title: plain SEO title 50-60 chars (follow any SEO meta formula)
- meta_description: plain SEO snippet 120-155 chars (follow any SEO meta formula); never HTML
@@ -51,6 +51,14 @@ func TestCategoryEnhancePromptNeedsRepair(t *testing.T) {
if !CategoryEnhancePromptNeedsRepair(legacy) {
t.Fatal("legacy HTML prompt should need repair")
}
// Per-category sectioned overlay (Slovenian rules) must not force re-repair.
split := SplitLegacyCombinedEnhancePrompt(`GPT predloga:
<name>{Napiši tip izdelka}</name>
<metaDescription>{140 znakov}</metaDescription>
<H2>{benefit}</H2>`)
if CategoryEnhancePromptNeedsRepair(split) {
t.Fatal("sectioned split overlay should be idempotent")
}
}
func TestBuiltInProductEnhanceUsesSharedUserTemplate(t *testing.T) {
+177
View File
@@ -0,0 +1,177 @@
package aiprompts
import (
"regexp"
"strings"
)
var (
reLegacyNameTag = regexp.MustCompile(`(?is)<\s*name\s*>\s*\{?\s*(.*?)\s*\}?\s*<\s*/\s*name\s*>`)
reLegacyMetaTag = regexp.MustCompile(`(?is)<\s*metaDescription\s*>\s*\{?\s*(.*?)\s*\}?\s*<\s*/\s*metaDescription\s*>`)
reLegacyDescPH = regexp.MustCompile(`(?i)\{\s*""?\s*OPIS\s+IZDELKA\s*""?\s*\}`)
reLegacyNamePH = regexp.MustCompile(`(?i)\{\s*""?\s*STARO\s+IME\s+IZDELKA\s*""?\s*\}`)
reDoubledQuotes = regexp.MustCompile(`"{2,}`)
)
// LegacyEnhanceParts holds extracted role content from a combined PHP/A1 category Prompt.
type LegacyEnhanceParts struct {
TitleRules string
DescriptionRules string
MetaRules string
WasLegacy bool
}
// IsLegacyCombinedEnhancePrompt reports whether prompt looks like the old A1/PHP
// combined name+description(+meta) marketing blob (<name>/<metaDescription>/GPT predloga).
func IsLegacyCombinedEnhancePrompt(prompt string) bool {
p := strings.TrimSpace(prompt)
if p == "" {
return false
}
// Require real tag wrappers — not instructional mentions like
// `do NOT … <name>/<metaDescription> tags` in CategoryEnhanceUserTemplate.
hasNameTag := reLegacyNameTag.MatchString(p)
hasMetaTag := reLegacyMetaTag.MatchString(p)
if hasNameTag && (hasMetaTag || strings.Contains(strings.ToLower(p), "gpt predloga")) {
return true
}
if hasNameTag && (strings.Contains(p, "STARO IME IZDELKA") || strings.Contains(p, "OPIS IZDELKA")) {
return true
}
if strings.Contains(p, "STARO IME IZDELKA") && strings.Contains(p, "OPIS IZDELKA") && strings.Contains(strings.ToLower(p), "gpt predloga") {
return true
}
return false
}
// ParseLegacyCombinedEnhancePrompt extracts title / description / meta instruction
// blobs from a legacy combined prompt. Placeholders are modernized to {{name}} /
// {{description}}. WasLegacy is false when no combined markers are found.
func ParseLegacyCombinedEnhancePrompt(prompt string) LegacyEnhanceParts {
raw := strings.TrimSpace(prompt)
out := LegacyEnhanceParts{}
if raw == "" || !IsLegacyCombinedEnhancePrompt(raw) {
return out
}
out.WasLegacy = true
if m := reLegacyNameTag.FindStringSubmatch(raw); len(m) == 2 {
out.TitleRules = cleanLegacyInstruction(m[1])
}
if m := reLegacyMetaTag.FindStringSubmatch(raw); len(m) == 2 {
out.MetaRules = cleanLegacyInstruction(m[1])
}
rest := reLegacyNameTag.ReplaceAllString(raw, "")
rest = reLegacyMetaTag.ReplaceAllString(rest, "")
if idx := strings.Index(strings.ToLower(rest), "gpt predloga:"); idx >= 0 {
rest = rest[idx+len("gpt predloga:"):]
}
// Drop Slovenian boilerplate intro lines that are not body structure.
rest = stripLegacyIntroNoise(rest)
out.DescriptionRules = cleanLegacyInstruction(rest)
return out
}
// SplitLegacyCombinedEnhancePrompt rewrites a legacy combined name+description(+meta)
// prompt into CategoryEnhanceUserTemplate role sections, preserving Slovenian
// naming / HTML / meta intent as category-specific rules under Title / Description / Meta.
// Non-legacy input returns CategoryEnhanceUserTemplate (canonical shared overlay).
func SplitLegacyCombinedEnhancePrompt(prompt string) string {
parts := ParseLegacyCombinedEnhancePrompt(prompt)
if !parts.WasLegacy {
return strings.TrimSpace(CategoryEnhanceUserTemplate)
}
return BuildCategoryEnhanceOverlay(parts)
}
// BuildCategoryEnhanceOverlay composes a role-sectioned enhance USER overlay from
// extracted legacy parts (or empty parts → canonical template text with no extra rules).
func BuildCategoryEnhanceOverlay(parts LegacyEnhanceParts) string {
var b strings.Builder
b.WriteString(`Your reply is parsed as JSON {"name":"string","description":"string","meta_title":"string","meta_description":"string","attrs":{}} only (system schema). Write all string fields in {{language}} (do not hardcode a language).`)
b.WriteString("\n\n")
b.WriteString(SectionTitleStart)
b.WriteByte('\n')
b.WriteString(TitleRoleInstruction)
b.WriteByte('\n')
if rules := strings.TrimSpace(parts.TitleRules); rules != "" {
b.WriteString("Category naming rules (preserve intent): ")
b.WriteString(rules)
b.WriteByte('\n')
}
b.WriteString("Name: {{name}}\n")
b.WriteString(SectionTitleEnd)
b.WriteString("\n\n")
b.WriteString(SectionDescriptionStart)
b.WriteString("\nRole: description. Build JSON \"description\": product body HTML only (not SEO meta). When a Description formula follows, emit ONE HTML string covering each section in order (tags matching type: h1/h2/h3/h4, p, ul); otherwise prefer 1-3 factual paragraphs as ONE string with limited HTML (<h2><p><ul><li>) — do NOT emit a competing full HTML document or wrap the whole reply in <name>/<metaDescription> tags.\n")
if rules := strings.TrimSpace(parts.DescriptionRules); rules != "" {
b.WriteString("Category HTML structure (preserve intent; emit as ONE description HTML string, not tagged name/meta blocks):\n")
b.WriteString(rules)
b.WriteByte('\n')
}
b.WriteString("Description: {{description}}\n")
b.WriteString(SectionDescriptionEnd)
b.WriteString("\n\n")
b.WriteString(SectionMetaStart)
b.WriteString("\nRole: meta. Build JSON \"meta_title\" and \"meta_description\" as plain SEO text (never HTML). meta_title: 50-60 chars; meta_description: 120-155 chars; follow any SEO meta formula that follows; never copy the full description HTML into meta_description.\n")
if rules := strings.TrimSpace(parts.MetaRules); rules != "" {
b.WriteString("Category SEO rules (preserve intent; plain text only): ")
b.WriteString(rules)
b.WriteByte('\n')
}
b.WriteString(SectionMetaEnd)
b.WriteString("\n\n")
b.WriteString(SectionAttributesStart)
b.WriteString("\nRole: attributes. Build JSON \"attrs\" as an object of attribute_key → value strings. Prefer Allowed attribute keys / Title formula attr slots that follow; remap near-miss labels onto those keys; fill missing keys only from Name/Description/Category/Attrs evidence; never invent specs; omit unknown keys; never invent dimensions.\n")
b.WriteString("Category: {{category}}\n")
b.WriteString("Attrs: {{attrs}}\n")
b.WriteString(SectionAttributesEnd)
return strings.TrimSpace(b.String())
}
func cleanLegacyInstruction(s string) string {
s = modernizeLegacyPlaceholders(s)
s = reDoubledQuotes.ReplaceAllString(s, `"`)
s = strings.ReplaceAll(s, "\r\n", "\n")
s = strings.TrimSpace(s)
// Collapse excessive blank lines.
for strings.Contains(s, "\n\n\n") {
s = strings.ReplaceAll(s, "\n\n\n", "\n\n")
}
return s
}
func modernizeLegacyPlaceholders(prompt string) string {
prompt = reLegacyDescPH.ReplaceAllString(prompt, "{{description}}")
prompt = reLegacyNamePH.ReplaceAllString(prompt, "{{name}}")
return prompt
}
func stripLegacyIntroNoise(s string) string {
lines := strings.Split(s, "\n")
out := make([]string, 0, len(lines))
for _, line := range lines {
trim := strings.TrimSpace(line)
lower := strings.ToLower(trim)
switch {
case trim == "":
if len(out) > 0 {
out = append(out, "")
}
case strings.HasPrefix(lower, "ustvari nov opis"),
strings.HasPrefix(lower, "star_opis_izdelka"),
strings.HasPrefix(lower, "staro_ime_izdelka"),
strings.HasPrefix(lower, "uporabi spodnjo gpt"),
strings.HasPrefix(lower, "sledi tej gpt"):
continue
default:
out = append(out, trim)
}
}
return strings.TrimSpace(strings.Join(out, "\n"))
}
@@ -0,0 +1,71 @@
package aiprompts
import (
"strings"
"testing"
)
func TestSplitLegacyCombinedEnhancePrompt(t *testing.T) {
t.Parallel()
legacy := `Ustvari nov opis izdelka v Slovenščini z naslednjimi spremenljivkami:
Star_opis_izdelka: {""OPIS IZDELKA""};
Staro_ime_izdelka: {""STARO IME IZDELKA""};
Uporabi spodnjo GPT predlogo.
Sledi tej GPT predlogi stavek po stavek in sestavi nov opis izdelka:
GPT predloga:
<name>{Napiši novo ime izdelka po formuli: ""tip izdelka sentence case"", ""znamka s pravilno kapitalizacijo"". Ne uporabljaj vejic.}</name>
<metaDescription>{Najprej napiši Novo_ime_izdelka in potem nadaljuj dokler nisi zapisal 140 znakov vključno s presledki}</metaDescription>
<H2>{Napiši Novo ime izdelka in izpostavi en benefit}</H2>
<p>{Napiši odstavek, ki je dolg 100 besed, ki NE vsebuje Novo ime izdelka.}</p><b>{Tehnične specifikacije}</b><ul><li>{Napiši 3-10 tehničnih specifikacij v alinejah}</li></ul>`
got := SplitLegacyCombinedEnhancePrompt(legacy)
if !CategoryEnhanceHasRoleSections(got) {
t.Fatal("split output must be role-sectioned")
}
if CategoryEnhancePromptNeedsRepair(got) {
t.Fatal("split output should not need further repair")
}
if reLegacyNameTag.MatchString(got) || reLegacyMetaTag.MatchString(got) {
t.Fatal("split must not keep legacy <name>/<metaDescription> wrapper tags")
}
if !strings.Contains(got, "tip izdelka sentence case") {
t.Fatal("title section must preserve Slovenian naming formula intent")
}
if !strings.Contains(got, "140 znakov") {
t.Fatal("meta section must preserve Slovenian SEO intent")
}
if !strings.Contains(got, "Tehnične specifikacije") && !strings.Contains(got, "tehničnih specifikacij") {
t.Fatal("description section must preserve HTML body intent")
}
if !strings.Contains(got, "{{attrs}}") || !strings.Contains(got, "{{language}}") {
t.Fatal("overlay must keep {{attrs}} and {{language}}")
}
if strings.Contains(got, "OPIS IZDELKA") || strings.Contains(got, "STARO IME IZDELKA") {
t.Fatal("legacy placeholders must be modernized")
}
}
func TestSplitLegacyNonLegacyFallsBackToTemplate(t *testing.T) {
t.Parallel()
got := SplitLegacyCombinedEnhancePrompt("short retail overlay")
if strings.TrimSpace(got) != strings.TrimSpace(CategoryEnhanceUserTemplate) {
t.Fatalf("non-legacy should fall back to canonical template")
}
}
func TestIsLegacyCombinedEnhancePrompt(t *testing.T) {
t.Parallel()
if IsLegacyCombinedEnhancePrompt(CategoryEnhanceUserTemplate) {
t.Fatal("canonical template is not legacy combined")
}
if !IsLegacyCombinedEnhancePrompt("GPT predloga:\n<name>{x}</name><metaDescription>{y}</metaDescription>") {
t.Fatal("expected legacy detection")
}
}
@@ -0,0 +1,298 @@
package aiprompts
import (
"encoding/json"
"fmt"
"regexp"
"strings"
)
var (
reQuotedNameSlot = regexp.MustCompile(`"([^"]+)"`)
reNameFormulaTail = regexp.MustCompile(`(?is)formuli\s*:\s*(.*?)(?:\.?\s*Ne uporabljaj|\.?$)`)
)
// TitleFormulaElement is one ordered slot in categories.title_template.
type TitleFormulaElement struct {
ID string `json:"id"`
Type string `json:"type"` // "text" | "variable"
Label string `json:"label,omitempty"`
Value string `json:"value"`
Description string `json:"description,omitempty"`
Example string `json:"example,omitempty"`
}
// TitleFormula is the JSON shape stored in categories.title_template.
type TitleFormula struct {
Separator string `json:"separator"`
Elements []TitleFormulaElement `json:"elements"`
}
// DefaultRetailTitleFormula matches the dominant old A1/PHP <name> pattern:
// product type + brand + full model (never brand alone).
func DefaultRetailTitleFormula() TitleFormula {
return TitleFormula{
Separator: " ",
Elements: []TitleFormulaElement{
{ID: "0-variable-product_type", Type: "variable", Label: "Product type", Value: "product_type", Description: "Specific product type (sentence case)", Example: "Gaming monitor"},
{ID: "1-variable-brand", Type: "variable", Label: "Brand", Value: "brand", Description: "Brand with correct capitalization", Example: "Samsung"},
{ID: "2-variable-product_model", Type: "variable", Label: "Model", Value: "product_model", Description: "Full product model / ID", Example: "Odyssey G5"},
},
}
}
// TitleRoleInstruction is the shared Title-section body (CategoryEnhanceUserTemplate
// and BuildCategoryEnhanceOverlay). Drives enhance JSON "name"; never brand-only.
const TitleRoleInstruction = `Role: title. Build JSON "name": short retail title from Title formula + Attrs — never brand-only. Include product type and full model when evidence exists; follow any Title formula constraints that follow; use Attrs.`
// TitleTemplateIsBrandOnly reports stubs that only encode brand (the migrated A1
// default) so enhance collapses to brand-only names.
func TitleTemplateIsBrandOnly(template any) bool {
els, ok := titleFormulaElements(template)
if !ok || len(els) == 0 {
return false
}
vars := 0
brandOnly := true
for _, el := range els {
switch strings.ToLower(strings.TrimSpace(el.Type)) {
case "variable":
vars++
v := strings.ToLower(strings.TrimSpace(el.Value))
v = strings.ReplaceAll(v, "-", "_")
if v != "brand" && v != "znamka" {
brandOnly = false
}
case "text":
// Free-form naming-rule blobs are not brand-only stubs.
if strings.TrimSpace(el.Value) != "" {
brandOnly = false
}
}
}
return vars > 0 && brandOnly
}
// TitleTemplateNeedsRepair is true when template is missing, unparseable, or
// brand-only (insufficient vs old name/title rules).
func TitleTemplateNeedsRepair(template any) bool {
els, ok := titleFormulaElements(template)
if !ok || len(els) == 0 {
return true
}
return TitleTemplateIsBrandOnly(template)
}
// DeriveTitleFormulaFromLegacyNameRules turns PHP/A1 <name> formula text
// (e.g. "tip izdelka …", "znamka …", "poln model …") into a structured
// title_template. Falls back to DefaultRetailTitleFormula when parsing yields
// fewer than two variable slots.
func DeriveTitleFormulaFromLegacyNameRules(titleRules string) TitleFormula {
slots := extractLegacyNameSlots(titleRules)
out := TitleFormula{Separator: " ", Elements: make([]TitleFormulaElement, 0, len(slots))}
seen := map[string]struct{}{}
for _, slot := range slots {
el, ok := mapLegacyNameSlot(slot)
if !ok {
continue
}
key := el.Type + ":" + strings.ToLower(el.Value)
if _, dup := seen[key]; dup {
continue
}
seen[key] = struct{}{}
el.ID = fmt.Sprintf("%d-%s-%s", len(out.Elements), el.Type, sanitizeFormulaID(el.Value))
out.Elements = append(out.Elements, el)
}
if countFormulaVariables(out) < 2 {
return ensureRetailTitleCoverage(out)
}
return ensureRetailTitleCoverage(out)
}
// DeriveTitleTemplateJSON is the JSON encoding used when repairing categories.title_template.
func DeriveTitleTemplateJSON(titleRules string) string {
f := DeriveTitleFormulaFromLegacyNameRules(titleRules)
b, err := json.Marshal(f)
if err != nil {
def := DefaultRetailTitleFormula()
b, _ = json.Marshal(def)
}
return string(b)
}
func titleFormulaElements(template any) ([]TitleFormulaElement, bool) {
if template == nil {
return nil, false
}
switch t := template.(type) {
case TitleFormula:
return t.Elements, len(t.Elements) > 0
case *TitleFormula:
if t == nil {
return nil, false
}
return t.Elements, len(t.Elements) > 0
case []byte:
if len(t) == 0 {
return nil, false
}
var f TitleFormula
if err := json.Unmarshal(t, &f); err != nil {
return nil, false
}
return f.Elements, len(f.Elements) > 0
case string:
s := strings.TrimSpace(t)
if s == "" || s == "null" || s == "{}" {
return nil, false
}
var f TitleFormula
if err := json.Unmarshal([]byte(s), &f); err != nil {
return nil, false
}
return f.Elements, len(f.Elements) > 0
case map[string]any:
b, err := json.Marshal(t)
if err != nil {
return nil, false
}
var f TitleFormula
if err := json.Unmarshal(b, &f); err != nil {
return nil, false
}
return f.Elements, len(f.Elements) > 0
default:
b, err := json.Marshal(t)
if err != nil {
return nil, false
}
var f TitleFormula
if err := json.Unmarshal(b, &f); err != nil {
return nil, false
}
return f.Elements, len(f.Elements) > 0
}
}
func extractLegacyNameSlots(rules string) []string {
rules = strings.TrimSpace(rules)
if rules == "" {
return nil
}
body := rules
if m := reNameFormulaTail.FindStringSubmatch(rules); len(m) == 2 {
body = m[1]
}
raw := reQuotedNameSlot.FindAllStringSubmatch(body, -1)
out := make([]string, 0, len(raw))
for _, m := range raw {
s := strings.TrimSpace(m[1])
s = strings.Trim(s, `",. `)
if s == "" || s == "," {
continue
}
out = append(out, s)
}
return out
}
func mapLegacyNameSlot(slot string) (TitleFormulaElement, bool) {
s := strings.TrimSpace(slot)
if s == "" {
return TitleFormulaElement{}, false
}
low := strings.ToLower(s)
switch {
case strings.Contains(low, "znamka"):
return TitleFormulaElement{Type: "variable", Label: "Brand", Value: "brand", Description: s, Example: "Samsung"}, true
case strings.Contains(low, "poln model"), strings.Contains(low, "model izdelka"),
strings.HasPrefix(low, "model "), low == "model", strings.Contains(low, "model uppercase"):
return TitleFormulaElement{Type: "variable", Label: "Model", Value: "product_model", Description: s, Example: "EHT6020"}, true
case strings.Contains(low, "tip izdelka"):
return TitleFormulaElement{Type: "variable", Label: "Product type", Value: "product_type", Description: s, Example: "Bluetooth speaker"}, true
case strings.HasPrefix(low, "barva"):
return TitleFormulaElement{Type: "variable", Label: "Color", Value: "barva", Description: s}, true
case strings.Contains(low, "dimenzij"):
return TitleFormulaElement{Type: "variable", Label: "Dimensions", Value: "dimensions", Description: s}, true
case strings.Contains(low, "diagonala"):
return TitleFormulaElement{Type: "variable", Label: "Diagonal", Value: "diagonala_zaslona", Description: s}, true
case strings.Contains(low, "pomnilnik"):
return TitleFormulaElement{Type: "variable", Label: "Memory", Value: "kapaciteta_ram_pomnilnika", Description: s}, true
case strings.Contains(low, "procesor"):
return TitleFormulaElement{Type: "variable", Label: "CPU", Value: "procesor", Description: s}, true
case strings.Contains(low, "grafi"):
return TitleFormulaElement{Type: "variable", Label: "GPU", Value: "graficna_kartica", Description: s}, true
case strings.Contains(low, "kapaciteta"):
return TitleFormulaElement{Type: "variable", Label: "Capacity", Value: "kapaciteta", Description: s}, true
case strings.Contains(low, "skupina po te") || strings.Contains(low, "teži") || strings.Contains(low, "tezi"):
return TitleFormulaElement{Type: "variable", Label: "Weight group", Value: "weight_group", Description: s}, true
case strings.Contains(low, "priklju"):
return TitleFormulaElement{Type: "variable", Label: "Connector", Value: "connector", Description: s}, true
case strings.Contains(low, "osvež") || strings.Contains(low, "osvez") || strings.Contains(low, "hz"):
return TitleFormulaElement{Type: "variable", Label: "Refresh rate", Value: "refresh_rate", Description: s}, true
case strings.Contains(low, "lowercase") || strings.Contains(low, "sentence case") || strings.Contains(low, "uppercase"):
// Category-specific type word ("cvrtnik lowercase", "monitor lowercase").
return TitleFormulaElement{Type: "variable", Label: "Product type", Value: "product_type", Description: s}, true
default:
// Keep unrecognized instructional slots as text so order/intent survive.
if len([]rune(s)) > 80 {
s = string([]rune(s)[:80])
}
return TitleFormulaElement{Type: "text", Label: "Naming detail", Value: s, Description: "Legacy name-formula slot"}, true
}
}
func countFormulaVariables(f TitleFormula) int {
n := 0
for _, el := range f.Elements {
if el.Type == "variable" {
n++
}
}
return n
}
func ensureRetailTitleCoverage(f TitleFormula) TitleFormula {
has := map[string]bool{}
for _, el := range f.Elements {
if el.Type != "variable" {
continue
}
k := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(el.Value), "-", "_"))
has[k] = true
}
def := DefaultRetailTitleFormula()
for _, el := range def.Elements {
k := strings.ToLower(el.Value)
if has[k] {
continue
}
// Only inject core retail slots when missing.
if k == "brand" || k == "product_model" || (k == "product_type" && countFormulaVariables(f) < 2) {
el.ID = fmt.Sprintf("%d-%s-%s", len(f.Elements), el.Type, el.Value)
f.Elements = append(f.Elements, el)
has[k] = true
}
}
if countFormulaVariables(f) < 2 {
return def
}
if f.Separator == "" {
f.Separator = " "
}
return f
}
func sanitizeFormulaID(v string) string {
v = strings.ToLower(strings.TrimSpace(v))
v = strings.ReplaceAll(v, " ", "_")
v = strings.ReplaceAll(v, "-", "_")
if v == "" {
return "slot"
}
if len(v) > 40 {
v = v[:40]
}
return v
}
@@ -0,0 +1,107 @@
package aiprompts
import (
"strings"
"testing"
)
func TestDeriveTitleFormulaFromLegacyNameRules_Agregati(t *testing.T) {
t.Parallel()
rules := `Napiši novo ime izdelka po formuli: "tip izdelka sentence case", "znamka s pravilno kapitalizacijo", "poln model izdelka, če lahko z besedo in ID uppercase". Ne uporabljaj vejic.`
f := DeriveTitleFormulaFromLegacyNameRules(rules)
if len(f.Elements) < 3 {
t.Fatalf("want >=3 elements, got %#v", f.Elements)
}
vals := make([]string, 0, len(f.Elements))
for _, el := range f.Elements {
if el.Type == "variable" {
vals = append(vals, el.Value)
}
}
wantOrder := []string{"product_type", "brand", "product_model"}
for i, w := range wantOrder {
if i >= len(vals) || vals[i] != w {
t.Fatalf("vars=%v want prefix %v", vals, wantOrder)
}
}
if TitleTemplateIsBrandOnly(f) {
t.Fatal("derived formula must not be brand-only")
}
}
func TestDeriveTitleFormulaFromLegacyNameRules_CarSeatOrder(t *testing.T) {
t.Parallel()
rules := `Napiši novo ime izdelka po formuli: "znamka s pravilno kapitalizacijo", "tip izdelka lowercase", "poln model izdelka, če lahko z besedo in ID uppercase", "skupina po teži [kg]". Ne uporabljaj vejic.`
f := DeriveTitleFormulaFromLegacyNameRules(rules)
vars := []string{}
for _, el := range f.Elements {
if el.Type == "variable" {
vars = append(vars, el.Value)
}
}
if len(vars) < 3 || vars[0] != "brand" || vars[1] != "product_type" || vars[2] != "product_model" {
t.Fatalf("unexpected order: %v", vars)
}
foundWeight := false
for _, v := range vars {
if v == "weight_group" {
foundWeight = true
}
}
if !foundWeight {
t.Fatalf("missing weight_group in %v", vars)
}
}
func TestTitleTemplateIsBrandOnly(t *testing.T) {
t.Parallel()
brandOnly := map[string]any{
"separator": " ",
"elements": []any{
map[string]any{"type": "variable", "value": "brand", "label": "Znamka"},
},
}
if !TitleTemplateIsBrandOnly(brandOnly) {
t.Fatal("single brand variable should be brand-only")
}
if !TitleTemplateNeedsRepair(brandOnly) {
t.Fatal("brand-only needs repair")
}
if TitleTemplateIsBrandOnly(DefaultRetailTitleFormula()) {
t.Fatal("default retail must not be brand-only")
}
if TitleTemplateNeedsRepair(DefaultRetailTitleFormula()) {
t.Fatal("default retail must not need repair")
}
if !TitleTemplateNeedsRepair(nil) {
t.Fatal("nil needs repair")
}
}
func TestTitleRoleInstructionNeverBrandOnly(t *testing.T) {
t.Parallel()
if !strings.Contains(strings.ToLower(TitleRoleInstruction), "never brand-only") {
t.Fatal("TitleRoleInstruction must forbid brand-only names")
}
if !strings.Contains(CategoryEnhanceUserTemplate, TitleRoleInstruction) {
t.Fatal("CategoryEnhanceUserTemplate must embed TitleRoleInstruction")
}
split := SplitLegacyCombinedEnhancePrompt(`GPT predloga:
<name>{Napiši tip izdelka, znamka, poln model}</name>
<metaDescription>{140 znakov}</metaDescription>
<H2>{benefit}</H2>`)
if !strings.Contains(split, "never brand-only") {
t.Fatalf("split Title section missing never brand-only: %s", split)
}
}
func TestDeriveTitleTemplateJSON_roundTrip(t *testing.T) {
t.Parallel()
raw := DeriveTitleTemplateJSON(`formuli: "tip izdelka sentence case", "znamka s pravilno kapitalizacijo", "poln model izdelka"`)
if TitleTemplateNeedsRepair(raw) {
t.Fatalf("derived JSON should be OK: %s", raw)
}
if !strings.Contains(raw, `"product_type"`) || !strings.Contains(raw, `"brand"`) || !strings.Contains(raw, `"product_model"`) {
t.Fatalf("missing core vars: %s", raw)
}
}