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
+35 -11
View File
@@ -41,22 +41,46 @@ func EnsureCategoryAttributeLinks(ctx context.Context, pool *pgxpool.Pool, compa
}
// RepairCompanyCategoryEnhancePrompts is the company-scoped variant of
// RepairA1DemoCategoryEnhancePrompts: same repairedCategoryEnhancePromptMap
// (sl + "*" with CategoryEnhanceUserTemplate / {{attrs}}), applied to one company.
// Idempotent — already-OK categories count as already_ok, not updated.
// RepairA1DemoCategoryEnhancePrompts: loads wp_product_categories.sql when
// available (upload bytes, SEED_A1_WP_CATEGORIES / Downloads), else
// a1-category-prompts.json, splits legacy combined overlays into role sections,
// and force-applies WP dump matches. Falls back to CategoryEnhanceUserTemplate.
// Idempotent when unchanged.
func RepairCompanyCategoryEnhancePrompts(ctx context.Context, pool *pgxpool.Pool, companyID uuid.UUID) (updated, alreadyOK, emptySkipped int, err error) {
if pool == nil {
return 0, 0, 0, fmt.Errorf("nil pool")
}
want, err := repairedCategoryEnhancePromptMap()
res, err := RepairCompanyCategoryEnhancePromptsWithOptions(ctx, pool, companyID, RepairA1DemoOptions{})
if err != nil {
return 0, 0, 0, err
}
summary := &RepairCategoryEnhancePromptsResult{ByCompany: map[string]int{}}
if _, err := repairCompanyCategoryEnhancePrompts(ctx, pool, companyID, companyID.String(), want, false, summary); err != nil {
return 0, 0, 0, err
return res.Updated, res.AlreadyOK, res.EmptySkipped, nil
}
// RepairCompanyCategoryEnhancePromptsWithOptions applies category enhance prompt
// repair for one company. Pass WPCategoriesSQL to force-apply an uploaded dump
// (Admin Sync A1); empty opts keep auto-detect fallback.
func RepairCompanyCategoryEnhancePromptsWithOptions(ctx context.Context, pool *pgxpool.Pool, companyID uuid.UUID, opts RepairA1DemoOptions) (RepairCategoryEnhancePromptsResult, error) {
out := RepairCategoryEnhancePromptsResult{
ByCompany: map[string]int{},
}
return summary.Updated, summary.AlreadyOK, summary.EmptySkipped, nil
if pool == nil {
return out, fmt.Errorf("nil pool")
}
if len(opts.WPCategoriesSQL) > 0 {
if _, _, err := LoadWPCategoryPromptOverlaysFromBytes(opts.WPCategoriesSQL); err != nil {
return out, fmt.Errorf("wp_product_categories upload: %w", err)
}
}
seedByNorm, seedByUID, seedMeta := resolveCategoryPromptOverlays(opts)
out.SeedPath = seedMeta.Path
out.WPCategoriesPath = seedMeta.WPPath
out.SeedEntries = seedMeta.Entries
forceFromSeed := seedMeta.ForceFromSeed
if opts.ForceFromSeed != nil {
forceFromSeed = *opts.ForceFromSeed
}
if _, err := repairCompanyCategoryEnhancePrompts(ctx, pool, companyID, companyID.String(), seedByNorm, seedByUID, false, forceFromSeed, &out); err != nil {
return out, err
}
return out, nil
}
// ResolvePlatformDemoCompanyID finds the Platform Demo sandbox (never A1 cohort).
@@ -46,3 +46,54 @@ func TestRepairedCategoryEnhancePromptMapMatchesA1Demo(t *testing.T) {
t.Fatal("template must include {{attrs}}")
}
}
func TestCategoryEnhancePromptMapOKAcceptsSplitOverlay(t *testing.T) {
t.Parallel()
legacy := `GPT predloga:
<name>{Napiši tip izdelka sentence case}</name>
<metaDescription>{140 znakov}</metaDescription>
<H2>{benefit}</H2>`
split := aiprompts.SplitLegacyCombinedEnhancePrompt(legacy)
m := company.LangPromptMap{"sl": split, company.LangPromptAny: split}
if !categoryEnhancePromptMapOK(m) {
t.Fatal("split overlay should count as OK")
}
}
func TestJsonbTemplatePresent(t *testing.T) {
t.Parallel()
if jsonbTemplatePresent(nil) || jsonbTemplatePresent([]byte("null")) || jsonbTemplatePresent([]byte("{}")) {
t.Fatal("empty templates must be absent")
}
if !jsonbTemplatePresent([]byte(`{"elements":[{"type":"variable","value":"brand"}]}`)) {
t.Fatal("title elements should count")
}
if !jsonbTemplatePresent([]byte(`{"sections":[{"type":"p","instructions":"x"}]}`)) {
t.Fatal("description sections should count")
}
}
func TestDeriveTitleTemplateJSON(t *testing.T) {
t.Parallel()
got := aiprompts.DeriveTitleTemplateJSON(`Napiši novo ime po formuli: "tip izdelka sentence case", "znamka s pravilno kapitalizacijo", "poln model izdelka"`)
if aiprompts.TitleTemplateNeedsRepair(got) {
t.Fatalf("derived formula still needs repair: %s", got)
}
if !strings.Contains(got, "product_type") || !strings.Contains(got, "brand") || !strings.Contains(got, "product_model") {
t.Fatalf("expected type+brand+model: %s", got)
}
fallback := aiprompts.DeriveTitleTemplateJSON("")
if !strings.Contains(fallback, "brand") || !strings.Contains(fallback, "product_model") {
t.Fatalf("fallback title template: %s", fallback)
}
if aiprompts.TitleTemplateIsBrandOnly([]byte(`{"elements":[{"type":"variable","value":"brand"}]}`)) != true {
t.Fatal("single brand stub must be brand-only")
}
}
func TestNormalizeCategoryPromptName(t *testing.T) {
t.Parallel()
if got := normalizeCategoryPromptName(" Avtosedeži in lupinice "); got != "avtosedezi in lupinice" {
t.Fatalf("got %q", got)
}
}
+588 -60
View File
@@ -2,12 +2,17 @@ package catalog
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"unicode"
"github.com/descrybe/descrybe-v2/apps/api/internal/aiprompts"
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
"github.com/descrybe/descrybe-v2/apps/api/internal/company"
"github.com/descrybe/descrybe-v2/apps/api/internal/security"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
@@ -21,30 +26,63 @@ const platformDemoCompanyName = "Platform Demo"
// RepairCategoryEnhancePromptsResult is the dry-run / apply summary for
// RepairA1DemoCategoryEnhancePrompts.
type RepairCategoryEnhancePromptsResult struct {
CompaniesScanned int `json:"companies_scanned"`
CategoriesSeen int `json:"categories_seen"`
WouldUpdate int `json:"would_update"`
Updated int `json:"updated"`
AlreadyOK int `json:"already_ok"`
EmptySkipped int `json:"empty_skipped"`
ByCompany map[string]int `json:"by_company"`
DryRun bool `json:"dry_run"`
CompaniesScanned int `json:"companies_scanned"`
CategoriesSeen int `json:"categories_seen"`
WouldUpdate int `json:"would_update"`
Updated int `json:"updated"`
AlreadyOK int `json:"already_ok"`
EmptySkipped int `json:"empty_skipped"`
SeedMatched int `json:"seed_matched"`
SplitFromLegacy int `json:"split_from_legacy"`
FallbackTemplate int `json:"fallback_template"`
TitleTemplateBackfill int `json:"title_template_backfill"`
DescTemplateBackfill int `json:"description_template_backfill"`
ByCompany map[string]int `json:"by_company"`
DryRun bool `json:"dry_run"`
SeedPath string `json:"seed_path,omitempty"`
// WPCategoriesPath is set when overlays came from wp_product_categories.sql.
WPCategoriesPath string `json:"wp_categories_path,omitempty"`
SeedEntries int `json:"seed_entries,omitempty"`
}
// RepairA1DemoCategoryEnhancePrompts replaces legacy HTML marketing categories.prompt
// values for A1 Slovenija + Platform Demo with aiprompts.CategoryEnhanceUserTemplate
// (role-sectioned title/description/meta/attributes USER overlay).
// RepairA1DemoOptions configures optional seed overlay path for legacy splits.
type RepairA1DemoOptions struct {
// SeedPromptsPath points at a1-category-prompts.json OR wp_product_categories.sql.
// Empty → auto-detect WP SQL (SEED_A1_WP_CATEGORIES / Downloads), else JSON seed.
SeedPromptsPath string
// WPCategoriesPath forces wp_product_categories.sql (overrides SeedPromptsPath when set).
WPCategoriesPath string
// WPCategoriesSQL is uploaded dump bytes (primary for Admin Sync A1 in prod).
// When non-empty, takes precedence over filesystem auto-detect / path options.
WPCategoriesSQL []byte
// ForceFromSeed overwrites already-sectioned prompts when a seed match exists
// (WP dump / JSON is source of truth). Default true when WP SQL is resolved.
ForceFromSeed *bool
}
// RepairA1DemoCategoryEnhancePrompts replaces non-sectioned / legacy combined
// categories.prompt values for A1 Slovenija + Platform Demo with role-sectioned
// overlays (Title / Description / Meta / Attributes). Prefers wp_product_categories.sql
// (SEED_A1_WP_CATEGORIES / Downloads) as source of truth, then a1-category-prompts.json,
// splitting combined Name+Description prompts so naming rules land under Title and
// HTML body under Description; otherwise fall back to CategoryEnhanceUserTemplate.
//
// LOCAL repair only (idempotent):
// Also repairs empty or brand-only title_template and empty description_template
// from legacy <name> / HTML / meta blocks so enhance uses real A1 formulas.
//
// LOCAL repair only (idempotent when seed unchanged):
// - Writes JSON-compatible user overlays under both "sl" (prompt->>'sl') and "*"
// (LangPromptAny) so language stays via {{language}}, not hardcoded-only copy.
// - Touches ONLY categories.prompt — never title_template / description_template
// (unique name/description/meta formulas stay intact; AppendFormulaConstraints
// encodes them as plain-text instructions at enhance render time).
// (LangPromptAny) so language stays via {{language}}.
// - dryRun=true: count WouldUpdate only; dryRun=false: apply and set Updated.
//
// Entrypoint: go run ./cmd/repair-category-prompts (-dry-run | -apply).
func RepairA1DemoCategoryEnhancePrompts(ctx context.Context, pool *pgxpool.Pool, dryRun bool) (RepairCategoryEnhancePromptsResult, error) {
return RepairA1DemoCategoryEnhancePromptsWithOptions(ctx, pool, dryRun, RepairA1DemoOptions{})
}
// RepairA1DemoCategoryEnhancePromptsWithOptions is RepairA1DemoCategoryEnhancePrompts
// with an explicit seed / WP dump path.
func RepairA1DemoCategoryEnhancePromptsWithOptions(ctx context.Context, pool *pgxpool.Pool, dryRun bool, opts RepairA1DemoOptions) (RepairCategoryEnhancePromptsResult, error) {
out := RepairCategoryEnhancePromptsResult{
DryRun: dryRun,
ByCompany: map[string]int{},
@@ -53,6 +91,12 @@ func RepairA1DemoCategoryEnhancePrompts(ctx context.Context, pool *pgxpool.Pool,
return out, fmt.Errorf("postgres pool is required")
}
if len(opts.WPCategoriesSQL) > 0 {
if _, _, err := LoadWPCategoryPromptOverlaysFromBytes(opts.WPCategoriesSQL); err != nil {
return out, fmt.Errorf("wp_product_categories upload: %w", err)
}
}
a1ID, err := uuid.Parse(a1CompanyID)
if err != nil {
return out, fmt.Errorf("a1 company id: %w", err)
@@ -90,13 +134,17 @@ func RepairA1DemoCategoryEnhancePrompts(ctx context.Context, pool *pgxpool.Pool,
return out, fmt.Errorf("no A1 / Platform Demo companies found")
}
want, err := repairedCategoryEnhancePromptMap()
if err != nil {
return out, err
seedByNorm, seedByUID, seedMeta := resolveCategoryPromptOverlays(opts)
out.SeedPath = seedMeta.Path
out.WPCategoriesPath = seedMeta.WPPath
out.SeedEntries = seedMeta.Entries
forceFromSeed := seedMeta.ForceFromSeed
if opts.ForceFromSeed != nil {
forceFromSeed = *opts.ForceFromSeed
}
for _, c := range companies {
n, err := repairCompanyCategoryEnhancePrompts(ctx, pool, c.id, c.name, want, dryRun, &out)
n, err := repairCompanyCategoryEnhancePrompts(ctx, pool, c.id, c.name, seedByNorm, seedByUID, dryRun, forceFromSeed, &out)
if err != nil {
return out, err
}
@@ -107,9 +155,190 @@ func RepairA1DemoCategoryEnhancePrompts(ctx context.Context, pool *pgxpool.Pool,
return out, nil
}
// repairedCategoryEnhancePromptMap is the canonical stored shape: "sl" (prompt->>'sl'
// for A1/Demo) plus LangPromptAny ("*") so PromptForLanguage resolves for any content
// language. Copy stays language-agnostic via {{language}} — not hardcoded Slovenian.
type categoryPromptOverlayMeta struct {
Path string
WPPath string
Entries int
ForceFromSeed bool
}
func resolveCategoryPromptOverlays(opts RepairA1DemoOptions) (byNorm, byUID map[string]string, meta categoryPromptOverlayMeta) {
byNorm = map[string]string{}
byUID = map[string]string{}
if len(opts.WPCategoriesSQL) > 0 {
n, u, err := LoadWPCategoryPromptOverlaysFromBytes(opts.WPCategoriesSQL)
if err == nil {
meta.Path = "upload:wp_product_categories.sql"
meta.WPPath = "upload:wp_product_categories.sql"
meta.Entries = len(n)
meta.ForceFromSeed = true
return n, u, meta
}
}
wpPath := strings.TrimSpace(opts.WPCategoriesPath)
if wpPath == "" {
wpPath = ResolveWPCategoryPromptsPath("")
} else {
wpPath = ResolveWPCategoryPromptsPath(wpPath)
}
if wpPath != "" {
n, u, err := loadWPCategoryPromptOverlays(wpPath)
if err == nil {
meta.Path = wpPath
meta.WPPath = wpPath
meta.Entries = len(n)
meta.ForceFromSeed = true
return n, u, meta
}
}
seedPath := strings.TrimSpace(opts.SeedPromptsPath)
if seedPath == "" {
seedPath = resolveA1CategoryPromptsPath()
}
if seedPath != "" && isWPCategoryPromptsSQLPath(seedPath) {
n, u, err := loadWPCategoryPromptOverlays(seedPath)
if err == nil {
meta.Path = seedPath
meta.WPPath = seedPath
meta.Entries = len(n)
meta.ForceFromSeed = true
return n, u, meta
}
}
if seedPath != "" {
n, u, err := loadA1CategoryPromptOverlays(seedPath)
if err == nil {
meta.Path = seedPath
meta.Entries = len(n)
meta.ForceFromSeed = false
return n, u, meta
}
}
return byNorm, byUID, meta
}
func resolveA1CategoryPromptsPath() string {
candidates := []string{
filepath.Join("scripts", "seed", "a1-category-prompts.json"),
filepath.Join("..", "..", "scripts", "seed", "a1-category-prompts.json"),
filepath.Join("..", "..", "..", "scripts", "seed", "a1-category-prompts.json"),
}
// Walk up from cwd looking for scripts/seed/a1-category-prompts.json.
if wd, err := os.Getwd(); err == nil {
dir := wd
for i := 0; i < 8; i++ {
candidates = append(candidates, filepath.Join(dir, "scripts", "seed", "a1-category-prompts.json"))
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
}
}
for _, p := range candidates {
if st, err := os.Stat(p); err == nil && !st.IsDir() {
return p
}
}
return ""
}
type a1CategoryPromptFile struct {
Entries []a1CategoryPromptEntry `json:"entries"`
}
type a1CategoryPromptEntry struct {
Name string `json:"name"`
UniqueID string `json:"unique_id,omitempty"`
Prompt string `json:"prompt"`
}
func loadA1CategoryPromptOverlays(path string) (byNorm map[string]string, byUID map[string]string, err error) {
byNorm = map[string]string{}
byUID = map[string]string{}
path = filepath.Clean(strings.TrimSpace(path))
if path == "" || path == "." {
return byNorm, byUID, fmt.Errorf("seed prompts path empty")
}
if filepath.Base(path) != "a1-category-prompts.json" {
return byNorm, byUID, fmt.Errorf("refusing unexpected category prompts file name %q", filepath.Base(path))
}
raw, err := os.ReadFile(path)
if err != nil {
return byNorm, byUID, err
}
if len(raw) > 8<<20 {
return byNorm, byUID, fmt.Errorf("category prompts file too large (%d bytes)", len(raw))
}
var f a1CategoryPromptFile
if err := json.Unmarshal(raw, &f); err != nil {
return byNorm, byUID, err
}
for _, e := range f.Entries {
p := strings.TrimSpace(e.Prompt)
if p == "" {
continue
}
if uid := strings.ToLower(strings.TrimSpace(e.UniqueID)); uid != "" {
byUID[uid] = p
}
if key := normalizeCategoryPromptName(e.Name); key != "" {
byNorm[key] = p
}
}
if len(byNorm) == 0 && len(byUID) == 0 {
return byNorm, byUID, fmt.Errorf("no usable seed prompt entries")
}
return byNorm, byUID, nil
}
func normalizeCategoryPromptName(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
s = strings.ToLower(s)
var b strings.Builder
b.Grow(len(s))
prevSpace := false
for _, r := range s {
r = foldSlovenePromptRune(r)
if unicode.IsSpace(r) {
if prevSpace || b.Len() == 0 {
continue
}
b.WriteByte(' ')
prevSpace = true
continue
}
prevSpace = false
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' || r == '/' || r == '&' || r == '+' {
b.WriteRune(r)
}
}
return strings.TrimSpace(b.String())
}
func foldSlovenePromptRune(r rune) rune {
switch r {
case 'č', 'ć':
return 'c'
case 'š':
return 's'
case 'ž':
return 'z'
case 'đ':
return 'd'
default:
return r
}
}
// repairedCategoryEnhancePromptMap is the shared fallback overlay (no per-category
// Slovenian rules): "sl" + LangPromptAny ("*").
func repairedCategoryEnhancePromptMap() (company.LangPromptMap, error) {
tpl := strings.TrimSpace(aiprompts.CategoryEnhanceUserTemplate)
if tpl == "" {
@@ -121,33 +350,96 @@ func repairedCategoryEnhancePromptMap() (company.LangPromptMap, error) {
}, nil
}
func categoryEnhancePromptMapOK(m company.LangPromptMap, want company.LangPromptMap) bool {
if !company.HasAnyPrompt(m) || !company.HasAnyPrompt(want) {
func categoryEnhancePromptValueOK(p string) bool {
p = strings.TrimSpace(p)
if p == "" {
return false
}
tpl := strings.TrimSpace(want[company.LangPromptAny])
if tpl == "" {
tpl = strings.TrimSpace(want["sl"])
}
if tpl == "" {
return !aiprompts.CategoryEnhancePromptNeedsRepair(p)
}
func categoryEnhancePromptMapOK(m company.LangPromptMap) bool {
if !company.HasAnyPrompt(m) {
return false
}
// Accept already-repaired maps: every non-empty value equals the shared template
// and LangPromptAny (or legacy sl-only) is present.
hasKey := false
for lang, p := range m {
for _, p := range m {
p = strings.TrimSpace(p)
if p == "" {
continue
}
if p != tpl {
if !categoryEnhancePromptValueOK(p) {
return false
}
if lang == company.LangPromptAny || lang == "sl" {
hasKey = true
}
// Require sl or * present.
if strings.TrimSpace(m[company.LangPromptAny]) == "" && strings.TrimSpace(m["sl"]) == "" {
return false
}
return true
}
func pickSeedPrompt(uniqueID, name string, byNorm, byUID map[string]string) string {
if uid := strings.ToLower(strings.TrimSpace(uniqueID)); uid != "" {
if p, ok := byUID[uid]; ok {
return p
}
}
return hasKey
if key := normalizeCategoryPromptName(name); key != "" {
if p, ok := byNorm[key]; ok {
return p
}
}
return ""
}
func resolveRepairedEnhancePrompt(current company.LangPromptMap, seedLegacy string, preferSeed bool, out *RepairCategoryEnhancePromptsResult) string {
prompt, fromSeed, fromLegacy, fallback := computeRepairedEnhancePrompt(current, seedLegacy, preferSeed)
if fromSeed {
out.SeedMatched++
}
if fromLegacy {
out.SplitFromLegacy++
}
if fallback {
out.FallbackTemplate++
}
return prompt
}
func computeRepairedEnhancePrompt(current company.LangPromptMap, seedLegacy string, preferSeed bool) (prompt string, fromSeed, fromLegacy, fallback bool) {
trySeed := func(raw string) (string, bool, bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", false, false
}
if aiprompts.IsLegacyCombinedEnhancePrompt(raw) {
return aiprompts.SplitLegacyCombinedEnhancePrompt(raw), true, false
}
if aiprompts.CategoryEnhanceHasRoleSections(raw) {
return security.SanitizePrompt(raw, MaxCategoryPromptRunes), false, false
}
return strings.TrimSpace(aiprompts.CategoryEnhanceUserTemplate), false, true
}
if preferSeed && seedLegacy != "" {
p, leg, fb := trySeed(seedLegacy)
return p, true, leg, fb
}
for _, lang := range []string{"sl", company.LangPromptAny} {
if cur := strings.TrimSpace(current[lang]); aiprompts.IsLegacyCombinedEnhancePrompt(cur) {
return aiprompts.SplitLegacyCombinedEnhancePrompt(cur), false, true, false
}
}
for _, cur := range current {
if aiprompts.IsLegacyCombinedEnhancePrompt(cur) {
return aiprompts.SplitLegacyCombinedEnhancePrompt(cur), false, true, false
}
}
if seedLegacy != "" {
p, leg, fb := trySeed(seedLegacy)
return p, true, leg, fb
}
return strings.TrimSpace(aiprompts.CategoryEnhanceUserTemplate), false, false, true
}
func repairCompanyCategoryEnhancePrompts(
@@ -155,12 +447,19 @@ func repairCompanyCategoryEnhancePrompts(
pool *pgxpool.Pool,
companyID uuid.UUID,
companyName string,
want company.LangPromptMap,
seedByNorm map[string]string,
seedByUID map[string]string,
dryRun bool,
forceFromSeed bool,
out *RepairCategoryEnhancePromptsResult,
) (int, error) {
rows, err := pool.Query(ctx, `
SELECT id, COALESCE(prompt, '{}'::jsonb)
SELECT id,
COALESCE(unique_id, ''),
name,
COALESCE(prompt, '{}'::jsonb),
title_template,
description_template
FROM categories
WHERE company_id = $1`, companyID)
if err != nil {
@@ -171,8 +470,10 @@ func repairCompanyCategoryEnhancePrompts(
updatedHere := 0
for rows.Next() {
var id uuid.UUID
var uniqueID, name string
var raw []byte
if err := rows.Scan(&id, &raw); err != nil {
var titleTpl, descTpl []byte
if err := rows.Scan(&id, &uniqueID, &name, &raw, &titleTpl, &descTpl); err != nil {
return updatedHere, err
}
out.CategoriesSeen++
@@ -181,41 +482,268 @@ func repairCompanyCategoryEnhancePrompts(
if err != nil {
return updatedHere, fmt.Errorf("decode prompt category=%s company=%s: %w", id, companyName, err)
}
seedLegacy := pickSeedPrompt(uniqueID, name, seedByNorm, seedByUID)
needPrompt := company.HasAnyPrompt(m) && !categoryEnhancePromptMapOK(m)
// Empty prompt → write sectioned overlay (seed split when available, else shared template).
if !company.HasAnyPrompt(m) {
out.EmptySkipped++
needPrompt = true
}
var wantPrompt string
if seedLegacy != "" && (forceFromSeed || needPrompt) {
wantPrompt, _, _, _ = computeRepairedEnhancePrompt(m, seedLegacy, forceFromSeed)
wantPrompt = security.SanitizePrompt(wantPrompt, MaxCategoryPromptRunes)
if cur := currentEnhancePrompt(m); forceFromSeed && cur != "" && cur == wantPrompt && categoryEnhancePromptMapOK(m) {
needPrompt = false
wantPrompt = ""
} else if wantPrompt != "" {
needPrompt = true
}
}
titleRules := legacyTitleRules(m, seedLegacy)
needTitle := aiprompts.TitleTemplateNeedsRepair(titleTpl)
legacyParts := legacyEnhanceParts(m, seedLegacy)
needDesc := aiprompts.DescriptionTemplateNeedsRepair(descTpl)
var wantTitleTpl, wantDescTpl string
if forceFromSeed && seedLegacy != "" {
if strings.TrimSpace(titleRules) != "" {
wantTitleTpl = aiprompts.DeriveTitleTemplateJSON(titleRules)
if !jsonbEqual(titleTpl, []byte(wantTitleTpl)) {
needTitle = true
} else {
needTitle = false
wantTitleTpl = ""
}
}
if legacyParts.WasLegacy && (strings.TrimSpace(legacyParts.DescriptionRules) != "" || strings.TrimSpace(legacyParts.MetaRules) != "") {
wantDescTpl = aiprompts.DeriveDescriptionTemplateJSON(legacyParts)
if strings.TrimSpace(wantDescTpl) != "" && !jsonbEqual(descTpl, []byte(wantDescTpl)) {
needDesc = true
} else if strings.TrimSpace(wantDescTpl) == "" || jsonbEqual(descTpl, []byte(wantDescTpl)) {
needDesc = false
wantDescTpl = ""
}
}
}
if needDesc && strings.TrimSpace(legacyParts.DescriptionRules) == "" && strings.TrimSpace(legacyParts.MetaRules) == "" {
needDesc = false
}
if !needPrompt && !needTitle && !needDesc {
if company.HasAnyPrompt(m) {
out.AlreadyOK++
} else {
out.EmptySkipped++
}
continue
}
if categoryEnhancePromptMapOK(m, want) {
out.AlreadyOK++
continue
if needPrompt && wantPrompt == "" {
wantPrompt = resolveRepairedEnhancePrompt(m, seedLegacy, forceFromSeed && seedLegacy != "", out)
wantPrompt = security.SanitizePrompt(wantPrompt, MaxCategoryPromptRunes)
if wantPrompt == "" {
return updatedHere, fmt.Errorf("repaired prompt empty category=%s", id)
}
} else if needPrompt {
// Record seed/split stats for the prompt we already computed.
_, fromSeed, fromLegacy, fallback := computeRepairedEnhancePrompt(m, seedLegacy, forceFromSeed && seedLegacy != "")
if fromSeed {
out.SeedMatched++
}
if fromLegacy {
out.SplitFromLegacy++
}
if fallback {
out.FallbackTemplate++
}
}
out.WouldUpdate++
if dryRun {
if needTitle {
out.TitleTemplateBackfill++
}
if needDesc {
out.DescTemplateBackfill++
}
updatedHere++
continue
}
cleaned, err := company.SanitizeLangPromptMap(want, MaxCategoryPromptRunes)
if err != nil {
return updatedHere, fmt.Errorf("sanitize prompt category=%s: %w", id, err)
if needPrompt {
want := company.LangPromptMap{
"sl": wantPrompt,
company.LangPromptAny: wantPrompt,
}
cleaned, err := company.SanitizeLangPromptMap(want, MaxCategoryPromptRunes)
if err != nil {
return updatedHere, fmt.Errorf("sanitize prompt category=%s: %w", id, err)
}
encoded, err := company.EncodeLangPromptMap(cleaned)
if err != nil {
return updatedHere, err
}
ct, err := pool.Exec(ctx, `
UPDATE categories
SET prompt = $3::jsonb, updated_at = now()
WHERE id = $1 AND company_id = $2`, id, companyID, string(encoded))
if err != nil {
return updatedHere, fmt.Errorf("update prompt category=%s: %w", id, err)
}
if ct.RowsAffected() == 0 {
return updatedHere, fmt.Errorf("category %s not updated (company mismatch?)", id)
}
}
encoded, err := company.EncodeLangPromptMap(cleaned)
if err != nil {
return updatedHere, err
if needTitle {
tpl := wantTitleTpl
if tpl == "" {
tpl = aiprompts.DeriveTitleTemplateJSON(titleRules)
}
ct, err := pool.Exec(ctx, `
UPDATE categories
SET title_template = $3::jsonb, updated_at = now()
WHERE id = $1 AND company_id = $2`, id, companyID, tpl)
if err != nil {
return updatedHere, fmt.Errorf("backfill title_template category=%s: %w", id, err)
}
if ct.RowsAffected() > 0 {
out.TitleTemplateBackfill++
}
}
ct, err := pool.Exec(ctx, `
UPDATE categories
SET prompt = $3::jsonb, updated_at = now()
WHERE id = $1 AND company_id = $2`, id, companyID, string(encoded))
if err != nil {
return updatedHere, fmt.Errorf("update prompt category=%s: %w", id, err)
}
if ct.RowsAffected() == 0 {
return updatedHere, fmt.Errorf("category %s not updated (company mismatch?)", id)
if needDesc {
tpl := wantDescTpl
if tpl == "" {
tpl = aiprompts.DeriveDescriptionTemplateJSON(legacyParts)
}
if strings.TrimSpace(tpl) != "" {
ct, err := pool.Exec(ctx, `
UPDATE categories
SET description_template = $3::jsonb, updated_at = now()
WHERE id = $1 AND company_id = $2`, id, companyID, tpl)
if err != nil {
return updatedHere, fmt.Errorf("backfill description_template category=%s: %w", id, err)
}
if ct.RowsAffected() > 0 {
out.DescTemplateBackfill++
}
}
}
out.Updated++
updatedHere++
}
return updatedHere, rows.Err()
}
func currentEnhancePrompt(m company.LangPromptMap) string {
for _, lang := range []string{"sl", company.LangPromptAny} {
if p := strings.TrimSpace(m[lang]); p != "" {
return p
}
}
for _, p := range m {
if t := strings.TrimSpace(p); t != "" {
return t
}
}
return ""
}
func legacyEnhanceParts(current company.LangPromptMap, seedLegacy string) aiprompts.LegacyEnhanceParts {
if seedLegacy != "" {
parts := aiprompts.ParseLegacyCombinedEnhancePrompt(seedLegacy)
if parts.WasLegacy {
return parts
}
}
for _, lang := range []string{"sl", company.LangPromptAny} {
parts := aiprompts.ParseLegacyCombinedEnhancePrompt(current[lang])
if parts.WasLegacy {
return parts
}
}
for _, cur := range current {
parts := aiprompts.ParseLegacyCombinedEnhancePrompt(cur)
if parts.WasLegacy {
return parts
}
}
return aiprompts.LegacyEnhanceParts{}
}
func jsonbTemplatePresent(raw []byte) bool {
s := strings.TrimSpace(string(raw))
if s == "" || s == "null" || s == "{}" || s == "[]" {
return false
}
var obj map[string]any
if err := json.Unmarshal(raw, &obj); err != nil {
return true // non-empty non-object still counts as present
}
if elems, ok := obj["elements"].([]any); ok && len(elems) > 0 {
return true
}
if sections, ok := obj["sections"].([]any); ok && len(sections) > 0 {
return true
}
if mt, ok := obj["metaTitle"].(string); ok && strings.TrimSpace(mt) != "" {
return true
}
if md, ok := obj["metaDescription"].(string); ok && strings.TrimSpace(md) != "" {
return true
}
// Any other non-empty keys.
return len(obj) > 0
}
func jsonbEqual(a, b []byte) bool {
as := strings.TrimSpace(string(a))
bs := strings.TrimSpace(string(b))
if as == "" || as == "null" {
as = ""
}
if bs == "" || bs == "null" {
bs = ""
}
if as == bs {
return true
}
var ao, bo any
if err := json.Unmarshal([]byte(as), &ao); err != nil {
return false
}
if err := json.Unmarshal([]byte(bs), &bo); err != nil {
return false
}
ab, err1 := json.Marshal(ao)
bb, err2 := json.Marshal(bo)
if err1 != nil || err2 != nil {
return false
}
return string(ab) == string(bb)
}
func legacyTitleRules(current company.LangPromptMap, seedLegacy string) string {
if seedLegacy != "" {
parts := aiprompts.ParseLegacyCombinedEnhancePrompt(seedLegacy)
if parts.WasLegacy && strings.TrimSpace(parts.TitleRules) != "" {
return parts.TitleRules
}
}
for _, lang := range []string{"sl", company.LangPromptAny} {
parts := aiprompts.ParseLegacyCombinedEnhancePrompt(current[lang])
if parts.WasLegacy && strings.TrimSpace(parts.TitleRules) != "" {
return parts.TitleRules
}
}
for _, cur := range current {
parts := aiprompts.ParseLegacyCombinedEnhancePrompt(cur)
if parts.WasLegacy && strings.TrimSpace(parts.TitleRules) != "" {
return parts.TitleRules
}
}
return ""
}
@@ -21,19 +21,19 @@ func TestRepairedCategoryEnhancePromptMap(t *testing.T) {
if want[company.LangPromptAny] != tpl {
t.Fatalf("want * = shared template")
}
if !categoryEnhancePromptMapOK(want, want) {
if !categoryEnhancePromptMapOK(want) {
t.Fatal("canonical map should be OK")
}
legacy := company.LangPromptMap{"sl": "<H2>legacy HTML marketing</H2>"}
if categoryEnhancePromptMapOK(legacy, want) {
if categoryEnhancePromptMapOK(legacy) {
t.Fatal("legacy HTML must need repair")
}
slOnly := company.LangPromptMap{"sl": tpl}
if !categoryEnhancePromptMapOK(slOnly, want) {
if !categoryEnhancePromptMapOK(slOnly) {
t.Fatal("sl-only repaired map should be OK (idempotent)")
}
starOnly := company.LangPromptMap{company.LangPromptAny: tpl}
if !categoryEnhancePromptMapOK(starOnly, want) {
if !categoryEnhancePromptMapOK(starOnly) {
t.Fatal("*-only repaired map should be OK (idempotent)")
}
}
@@ -46,7 +46,28 @@ func TestRepairTargetsUseSharedTemplate(t *testing.T) {
t.Fatalf("shared template missing {{%s}}", v)
}
}
if !strings.Contains(strings.ToLower(tpl), "never brand-only") {
t.Fatal("Title section must forbid brand-only names")
}
if a1CompanyID == "" || platformDemoCompanyName == "" {
t.Fatal("missing company target constants")
}
}
func TestLegacyTitleRulesFromSeedShape(t *testing.T) {
t.Parallel()
seed := `Ustvari nov opis
GPT predloga:
<name>{Napiši novo ime izdelka po formuli: "tip izdelka sentence case", "znamka s pravilno kapitalizacijo", "poln model izdelka". Ne uporabljaj vejic.}</name>
<metaDescription>{140 znakov}</metaDescription>
<H2>{benefit}</H2>`
rules := legacyTitleRules(nil, seed)
if !strings.Contains(strings.ToLower(rules), "znamka") {
t.Fatalf("expected title rules from seed, got %q", rules)
}
raw := aiprompts.DeriveTitleTemplateJSON(rules)
if aiprompts.TitleTemplateNeedsRepair(raw) {
t.Fatalf("derived title_template still needs repair: %s", raw)
}
}
@@ -0,0 +1,313 @@
package catalog
import (
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
"unicode/utf8"
)
const (
wpProductCategoriesFile = "wp_product_categories.sql"
envSeedA1WPCategories = "SEED_A1_WP_CATEGORIES"
// MaxWPCategorySQLBytes caps uploaded / on-disk wp_product_categories.sql size (16 MiB).
MaxWPCategorySQLBytes = 16 << 20
)
// ResolveWPCategoryPromptsPath picks an explicit path, else the first readable
// wp_product_categories.sql candidate (SEED_A1_WP_CATEGORIES + Downloads + scripts/seed).
// Used by Sync A1 / RepairCompanyCategoryEnhancePrompts as the A1 prompt source of truth.
func ResolveWPCategoryPromptsPath(explicit string) string {
if p := strings.TrimSpace(explicit); p != "" {
if st, err := os.Stat(p); err == nil && !st.IsDir() {
return p
}
log.Printf("warning: wp category prompts not found at %q — trying auto-detect", p)
}
if v := strings.TrimSpace(os.Getenv(envSeedA1WPCategories)); v != "" {
if st, err := os.Stat(v); err == nil && !st.IsDir() {
return v
}
log.Printf("warning: %s=%q not readable — trying Downloads / scripts/seed", envSeedA1WPCategories, v)
}
for _, c := range WPCategoryPromptsCandidates() {
if st, err := os.Stat(c); err == nil && !st.IsDir() {
return c
}
}
return ""
}
// WPCategoryPromptsCandidates lists local paths Sync A1 / repair try when
// SEED_A1_WP_CATEGORIES is unset. First readable file wins via ResolveWPCategoryPromptsPath.
func WPCategoryPromptsCandidates() []string {
var out []string
if v := strings.TrimSpace(os.Getenv(envSeedA1WPCategories)); v != "" {
out = append(out, v)
}
names := []string{
wpProductCategoriesFile,
"wp_product_categories (1).sql",
"wp_product_categories(1).sql",
}
home, _ := os.UserHomeDir()
if home != "" {
for _, n := range names {
out = append(out, filepath.Join(home, "Downloads", n))
out = append(out, filepath.Join(home, "downloads", n))
}
// Windows secondary profile Downloads (e.g. D:\Users\…\Downloads).
for _, driveRoot := range []string{`D:\`, `C:\`} {
alt := filepath.Join(driveRoot, "Users", filepath.Base(home), "Downloads")
for _, n := range names {
out = append(out, filepath.Join(alt, n))
}
}
}
for _, n := range names {
out = append(out,
n,
filepath.Join("..", "..", n),
filepath.Join("scripts", "seed", n),
filepath.Join("..", "..", "scripts", "seed", n),
)
}
if root, ok := findMonorepoRootFromCwd(); ok {
for _, n := range names {
out = append(out, filepath.Join(root, "scripts", "seed", n))
}
}
return out
}
func findMonorepoRootFromCwd() (string, bool) {
cwd, err := os.Getwd()
if err != nil {
return "", false
}
dir := cwd
for {
api := filepath.Join(dir, "apps", "api")
web := filepath.Join(dir, "apps", "web")
if st, err := os.Stat(api); err == nil && st.IsDir() {
if st, err := os.Stat(web); err == nil && st.IsDir() {
return dir, true
}
}
parent := filepath.Dir(dir)
if parent == dir {
return "", false
}
dir = parent
}
}
func isWPCategoryPromptsSQLPath(path string) bool {
base := strings.ToLower(filepath.Base(strings.TrimSpace(path)))
if base == "" {
return false
}
if base == wpProductCategoriesFile {
return true
}
return strings.HasPrefix(base, "wp_product_categories") && strings.HasSuffix(base, ".sql")
}
// loadWPCategoryPromptOverlays parses Name→Prompt pairs from a wp_product_categories.sql dump.
func loadWPCategoryPromptOverlays(path string) (byNorm map[string]string, byUID map[string]string, err error) {
byNorm = map[string]string{}
byUID = map[string]string{}
path = filepath.Clean(strings.TrimSpace(path))
if path == "" || path == "." {
return byNorm, byUID, fmt.Errorf("wp category prompts path empty")
}
if !isWPCategoryPromptsSQLPath(path) {
return byNorm, byUID, fmt.Errorf("refusing unexpected wp category prompts file name %q", filepath.Base(path))
}
raw, err := os.ReadFile(path)
if err != nil {
return byNorm, byUID, err
}
return LoadWPCategoryPromptOverlaysFromBytes(raw)
}
// LoadWPCategoryPromptOverlaysFromBytes parses an uploaded or in-memory
// wp_product_categories.sql dump. Enforces MaxWPCategorySQLBytes and UTF-8.
func LoadWPCategoryPromptOverlaysFromBytes(raw []byte) (byNorm map[string]string, byUID map[string]string, err error) {
byNorm = map[string]string{}
byUID = map[string]string{}
if len(raw) == 0 {
return byNorm, byUID, fmt.Errorf("empty wp_product_categories sql")
}
if len(raw) > MaxWPCategorySQLBytes {
return byNorm, byUID, fmt.Errorf("wp category prompts file too large (%d bytes)", len(raw))
}
if !utf8.Valid(raw) {
return byNorm, byUID, fmt.Errorf("wp category prompts file is not valid UTF-8")
}
entries, err := ParseWPProductCategoriesSQL(string(raw))
if err != nil {
return byNorm, byUID, err
}
for _, e := range entries {
p := strings.TrimSpace(e.Prompt)
if p == "" {
continue
}
if uid := strings.ToLower(strings.TrimSpace(e.UniqueID)); uid != "" {
byUID[uid] = p
}
if key := normalizeCategoryPromptName(e.Name); key != "" {
byNorm[key] = p
}
}
if len(byNorm) == 0 && len(byUID) == 0 {
return byNorm, byUID, fmt.Errorf("no usable wp_product_categories prompt entries")
}
return byNorm, byUID, nil
}
// ParseWPProductCategoriesSQL extracts (Name, Prompt) rows from a mysqldump-style
// INSERT INTO `wp_product_categories` (`Name`, `Prompt`) VALUES … statement.
func ParseWPProductCategoriesSQL(sql string) ([]a1CategoryPromptEntry, error) {
sql = strings.TrimSpace(sql)
if sql == "" {
return nil, fmt.Errorf("empty wp_product_categories sql")
}
lower := strings.ToLower(sql)
if !strings.Contains(lower, "wp_product_categories") {
return nil, fmt.Errorf("sql does not reference wp_product_categories")
}
out := make([]a1CategoryPromptEntry, 0, 128)
i := 0
for i < len(sql) {
name, prompt, next, ok := scanWPCategoryRow(sql, i)
if !ok {
break
}
i = next
name = strings.TrimSpace(name)
prompt = strings.TrimSpace(prompt)
if name == "" || prompt == "" {
continue
}
out = append(out, a1CategoryPromptEntry{Name: name, Prompt: prompt})
}
if len(out) == 0 {
return nil, fmt.Errorf("no Name/Prompt rows parsed from wp_product_categories sql")
}
if len(out) > 5000 {
return nil, fmt.Errorf("too many wp_product_categories rows (%d)", len(out))
}
return out, nil
}
// scanWPCategoryRow finds the next ('Name', 'Prompt') tuple starting at from.
func scanWPCategoryRow(sql string, from int) (name, prompt string, next int, ok bool) {
// Locate opening (' after from.
i := from
for i < len(sql) {
if sql[i] == '(' && i+1 < len(sql) && sql[i+1] == '\'' {
break
}
i++
}
if i >= len(sql) {
return "", "", from, false
}
i += 2 // past ('
nameRaw, i2, err := scanMySQLQuotedString(sql, i)
if err != nil {
return "", "", from, false
}
i = i2
// Expect , then optional whitespace then '
for i < len(sql) && (sql[i] == ' ' || sql[i] == '\t' || sql[i] == '\n' || sql[i] == '\r') {
i++
}
if i >= len(sql) || sql[i] != ',' {
return "", "", from, false
}
i++
for i < len(sql) && (sql[i] == ' ' || sql[i] == '\t' || sql[i] == '\n' || sql[i] == '\r') {
i++
}
if i >= len(sql) || sql[i] != '\'' {
return "", "", from, false
}
i++
promptRaw, i3, err := scanMySQLQuotedString(sql, i)
if err != nil {
return "", "", from, false
}
return nameRaw, promptRaw, i3, true
}
// scanMySQLQuotedString reads a mysqldump string whose opening quote was already consumed.
// Handles ”, \', \", \\, \n, \r, \t, \0, and leaves the index after the closing quote.
func scanMySQLQuotedString(s string, start int) (string, int, error) {
var b strings.Builder
b.Grow(256)
i := start
for i < len(s) {
c := s[i]
if c == '\\' && i+1 < len(s) {
n := s[i+1]
switch n {
case 'n':
b.WriteByte('\n')
case 'r':
b.WriteByte('\r')
case 't':
b.WriteByte('\t')
case '0':
b.WriteByte(0)
case 'b':
b.WriteByte('\b')
case 'Z':
b.WriteByte(0x1a)
case '\'', '"', '\\':
b.WriteByte(n)
default:
// MySQL keeps the second char for unknown escapes.
b.WriteByte(n)
}
i += 2
continue
}
if c == '\'' {
if i+1 < len(s) && s[i+1] == '\'' {
b.WriteByte('\'')
i += 2
continue
}
return b.String(), i + 1, nil
}
b.WriteByte(c)
i++
}
return "", start, fmt.Errorf("unterminated mysql string")
}
// ReadWPCategoryPromptOverlaysFromReader is a test helper around ParseWPProductCategoriesSQL.
func ReadWPCategoryPromptOverlaysFromReader(r io.Reader) (byNorm map[string]string, err error) {
raw, err := io.ReadAll(r)
if err != nil {
return nil, err
}
entries, err := ParseWPProductCategoriesSQL(string(raw))
if err != nil {
return nil, err
}
byNorm = map[string]string{}
for _, e := range entries {
if key := normalizeCategoryPromptName(e.Name); key != "" && strings.TrimSpace(e.Prompt) != "" {
byNorm[key] = e.Prompt
}
}
return byNorm, nil
}
@@ -0,0 +1,166 @@
package catalog
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/descrybe/descrybe-v2/apps/api/internal/aiprompts"
)
func TestParseWPProductCategoriesSQL_SlusalkeSplit(t *testing.T) {
t.Parallel()
sql := "SET NAMES utf8mb4;\n\n" +
"INSERT INTO `wp_product_categories` (`Name`, `Prompt`) VALUES\n" +
"('Slušalke',\t'Ustvari nov opis izdelka v Slovenščini z naslednjimi spremenljivkami:\\n\\n" +
"Star_opis_izdelka: {\\\"\\\"OPIS IZDELKA\\\"\\\"};\\n" +
"Staro_ime_izdelka: {\\\"\\\"STARO IME IZDELKA\\\"\\\"};\\n\\n" +
"Uporabi spodnjo GPT predlogo. \\n\\n" +
"Sledi tej GPT predlogi stavek po stavek in sestavi nov opis izdelka:\\n\\n" +
"GPT predloga:\\n\\n\\n" +
"<name>{Napiši novo ime izdelka po formuli: \\\"\\\"znamka s pravilno kapitalizacijo\\\"\\\", \\\"\\\"tip izdelka lowercase\\\"\\\", \\\"\\\"\\\"poln model izdelka, če lahko z besedo in ID uppercase\\\"\\\"\\\". Ne uporabljaj vejic.}</name>\\n" +
"<metaDescription>{Najprej napiši Novo_ime_izdelka in potem nadaljuj dokler nisi zapisal 140 znakov vključno s presledki}</metaDescription>\\n\\n" +
"<H2>{Napiši Novo ime izdelka in izpostavi en benefit}</H2>\\n" +
"<p>{Napiši odstavek, ki je dolg 100 besed, ki NE vsebuje Novo ime izdelka.}</p>\\n" +
"<H2>{Izpostavi en benefit, in NE napiši Novo ime izdelka}</H2>\\n" +
"<p>{Napiši odstavek, ki je dolg 100 besed in VKLJUČI tudi Novo ime izdelka.}</p>" +
"<b>{Tehnične specifikacije}</b><ul><li>{Napiši 3-10 tehničnih specifikacij v alinejah}</li></ul>');\n"
entries, err := ParseWPProductCategoriesSQL(sql)
if err != nil {
t.Fatal(err)
}
if len(entries) != 1 {
t.Fatalf("want 1 entry, got %d", len(entries))
}
if entries[0].Name != "Slušalke" {
t.Fatalf("name=%q", entries[0].Name)
}
raw := entries[0].Prompt
if strings.Contains(raw, `\"`) {
t.Fatalf("mysql escapes should be unescaped, still has backslash-quote: %q", raw[:80])
}
if !aiprompts.IsLegacyCombinedEnhancePrompt(raw) {
t.Fatal("parsed prompt should look like legacy combined enhance")
}
split := aiprompts.SplitLegacyCombinedEnhancePrompt(raw)
if !aiprompts.CategoryEnhanceHasRoleSections(split) {
t.Fatal("split must produce role sections")
}
if !strings.Contains(split, aiprompts.SectionTitleStart) || !strings.Contains(split, aiprompts.SectionDescriptionStart) {
t.Fatal("split missing Title/Description section markers")
}
if !strings.Contains(split, "znamka s pravilno kapitalizacijo") {
t.Fatal("Title section must keep naming formula")
}
if !strings.Contains(split, "Tehnične specifikacije") && !strings.Contains(split, "tehničnih specifikacij") {
t.Fatal("Description section must keep HTML body intent")
}
if !strings.Contains(split, "140 znakov") {
t.Fatal("Meta section must keep SEO intent")
}
if aiprompts.IsLegacyCombinedEnhancePrompt(split) {
t.Fatal("split output must not still look like legacy combined prompt")
}
if strings.Contains(split, "<name>{") || strings.Contains(split, "<metaDescription>{") {
t.Fatal("split must not keep legacy wrapper tag bodies")
}
parts := aiprompts.ParseLegacyCombinedEnhancePrompt(raw)
descJSON := aiprompts.DeriveDescriptionTemplateJSON(parts)
if descJSON == "" {
t.Fatal("expected description_template JSON from legacy HTML")
}
if !strings.Contains(descJSON, `"type":"h2"`) && !strings.Contains(descJSON, `"type":"p"`) {
t.Fatalf("description_template should include h2/p sections: %s", descJSON)
}
if !strings.Contains(descJSON, "140 znakov") {
t.Fatalf("description_template should carry metaDescription: %s", descJSON)
}
}
func TestParseWPProductCategoriesSQL_RealDownloadsFile(t *testing.T) {
path := ResolveWPCategoryPromptsPath(`d:\Users\Green Eclipse\Downloads\wp_product_categories.sql`)
if path == "" {
// Also try env / auto-detect without failing CI machines that lack the dump.
path = ResolveWPCategoryPromptsPath("")
}
if path == "" {
t.Skip("wp_product_categories.sql not available on this machine")
}
byNorm, _, err := loadWPCategoryPromptOverlays(path)
if err != nil {
t.Fatal(err)
}
if len(byNorm) < 50 {
t.Fatalf("expected dozens of categories, got %d from %s", len(byNorm), path)
}
key := normalizeCategoryPromptName("Slušalke")
raw, ok := byNorm[key]
if !ok {
t.Fatalf("Slušalke missing from %s (keys=%d)", path, len(byNorm))
}
split := aiprompts.SplitLegacyCombinedEnhancePrompt(raw)
if aiprompts.CategoryEnhancePromptNeedsRepair(split) {
t.Fatal("split of real dump Slušalke should not need repair")
}
if !strings.Contains(split, "tip izdelka lowercase") && !strings.Contains(split, "znamka") {
t.Fatalf("unexpected Title rules in split: %s", split[:min(400, len(split))])
}
}
func TestResolveWPCategoryPromptsPath_Explicit(t *testing.T) {
t.Parallel()
dir := t.TempDir()
p := filepath.Join(dir, "wp_product_categories.sql")
if err := os.WriteFile(p, []byte("INSERT INTO `wp_product_categories` (`Name`, `Prompt`) VALUES\n('X','GPT predloga:\\n<name>{a}</name><metaDescription>{b}</metaDescription>');\n"), 0o644); err != nil {
t.Fatal(err)
}
got := ResolveWPCategoryPromptsPath(p)
if got != p {
t.Fatalf("got %q want %q", got, p)
}
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func TestLoadWPCategoryPromptOverlaysFromBytes(t *testing.T) {
t.Parallel()
sql := "INSERT INTO `wp_product_categories` (`Name`, `Prompt`) VALUES\n('Slusalke','GPT predloga:\\n<name>{tip}</name><metaDescription>{140}</metaDescription><H2>{benefit}</H2>');\n"
byNorm, _, err := LoadWPCategoryPromptOverlaysFromBytes([]byte(sql))
if err != nil {
t.Fatal(err)
}
if len(byNorm) != 1 {
t.Fatalf("entries=%d", len(byNorm))
}
if _, _, err := LoadWPCategoryPromptOverlaysFromBytes(nil); err == nil {
t.Fatal("expected empty error")
}
huge := make([]byte, MaxWPCategorySQLBytes+1)
if _, _, err := LoadWPCategoryPromptOverlaysFromBytes(huge); err == nil {
t.Fatal("expected too-large error")
}
}
func TestResolveCategoryPromptOverlaysPrefersUploadBytes(t *testing.T) {
t.Parallel()
sql := "INSERT INTO `wp_product_categories` (`Name`, `Prompt`) VALUES\n('UploadCat','GPT predloga:\\n<name>{a}</name><metaDescription>{b}</metaDescription>');\n"
byNorm, _, meta := resolveCategoryPromptOverlays(RepairA1DemoOptions{WPCategoriesSQL: []byte(sql)})
if meta.WPPath != "upload:wp_product_categories.sql" {
t.Fatalf("WPPath=%q", meta.WPPath)
}
if !meta.ForceFromSeed {
t.Fatal("upload must force from seed")
}
if byNorm[normalizeCategoryPromptName("UploadCat")] == "" {
t.Fatal("missing upload category")
}
}