fix
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user