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

338 lines
13 KiB
Go

package aiprompts
import (
"encoding/json"
"fmt"
"regexp"
"strings"
)
var (
reQuotedNameSlot = regexp.MustCompile(`"([^"]+)"`)
// Slovenian legacy ("… po formuli: … Ne uporabljaj vejic.") and the English
// platform-default translation ("… using this formula: … Do not use commas.").
reNameFormulaTail = regexp.MustCompile(`(?is)formuli\s*:\s*(.*?)(?:\.?\s*Ne uporabljaj|\.?$)`)
reNameFormulaTailEN = regexp.MustCompile(`(?is)formula\s*:\s*(.*?)(?:\.?\s*Do not use|\.?$)`)
)
// 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]
} else if m := reNameFormulaTailEN.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)
// English platform-default slots (defaults/default-category-prompts.json) are
// matched before the Slovenian legacy vocabulary. Slovenian rules below are
// deliberately unchanged: A1's derived templates are already stored, and adding
// keywords there would churn them on every repair pass.
switch {
case strings.Contains(low, "product model"), strings.Contains(low, "device model"),
strings.Contains(low, "model of the device"):
return TitleFormulaElement{Type: "variable", Label: "Model", Value: "product_model", Description: s, Example: "EHT6020"}, true
case strings.Contains(low, "brand"):
return TitleFormulaElement{Type: "variable", Label: "Brand", Value: "brand", Description: s, Example: "Samsung"}, true
case strings.Contains(low, "product type"):
return TitleFormulaElement{Type: "variable", Label: "Product type", Value: "product_type", Description: s, Example: "Bluetooth speaker"}, true
case strings.Contains(low, "graphics card"):
return TitleFormulaElement{Type: "variable", Label: "GPU", Value: "graficna_kartica", Description: s}, true
case strings.Contains(low, "processor"):
return TitleFormulaElement{Type: "variable", Label: "CPU", Value: "procesor", Description: s}, true
case strings.Contains(low, "memory"):
return TitleFormulaElement{Type: "variable", Label: "Memory", Value: "kapaciteta_ram_pomnilnika", Description: s}, true
case strings.Contains(low, "screen diagonal"), strings.Contains(low, "screen size"):
return TitleFormulaElement{Type: "variable", Label: "Diagonal", Value: "diagonala_zaslona", Description: s}, true
case strings.Contains(low, "refresh rate"):
return TitleFormulaElement{Type: "variable", Label: "Refresh rate", Value: "refresh_rate", Description: s}, true
case strings.Contains(low, "weight group"):
return TitleFormulaElement{Type: "variable", Label: "Weight group", Value: "weight_group", Description: s}, true
case strings.Contains(low, "wash capacity"), strings.Contains(low, "capacity"):
return TitleFormulaElement{Type: "variable", Label: "Capacity", Value: "kapaciteta", Description: s}, true
case strings.Contains(low, "connector type"), strings.Contains(low, "socket type"),
strings.Contains(low, "cable type"):
return TitleFormulaElement{Type: "variable", Label: "Connector", Value: "connector", Description: s}, true
case strings.Contains(low, "colour"), strings.Contains(low, "color"):
return TitleFormulaElement{Type: "variable", Label: "Color", Value: "barva", Description: s}, true
case strings.Contains(low, "dimensions"):
return TitleFormulaElement{Type: "variable", Label: "Dimensions", Value: "dimensions", Description: s}, true
}
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
}