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 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 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 }