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" ) // Canonical Postgres id for the migrated A1 Slovenija tenant (seed-a1 default). const a1CompanyID = "604f23a8-b66e-4b21-8b45-0d72b68f4790" // platformDemoCompanyName matches migrator / seed-demo standalone demo tenant. 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"` 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"` } // 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 / scripts/seed), else JSON seed. SeedPromptsPath string // WPCategoriesPath forces wp_product_categories.sql (overrides SeedPromptsPath when set). WPCategoriesPath string // WPCategoriesSQL is optional dump bytes (legacy Admin Sync A1 upload / API clients). // 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 / scripts/seed) 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. // // Also repairs empty or brand-only title_template and empty description_template // from legacy / 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}}. // - 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{}, } if pool == nil { 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) } companyRows, err := pool.Query(ctx, ` SELECT id, name FROM companies WHERE id = $1 OR name = $2 OR COALESCE(legacy_company_id, '') = $3 ORDER BY name`, a1ID, platformDemoCompanyName, billing.A1LegacyCompanyID) if err != nil { return out, fmt.Errorf("list target companies: %w", err) } defer companyRows.Close() type co struct { id uuid.UUID name string } companies := make([]co, 0, 2) for companyRows.Next() { var c co if err := companyRows.Scan(&c.id, &c.name); err != nil { return out, err } companies = append(companies, c) } if err := companyRows.Err(); err != nil { return out, err } out.CompaniesScanned = len(companies) if len(companies) == 0 { return out, fmt.Errorf("no A1 / Platform Demo companies found") } 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, seedByNorm, seedByUID, dryRun, forceFromSeed, &out) if err != nil { return out, err } if n > 0 { out.ByCompany[c.name] = n } } return out, nil } 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 == "" { return nil, fmt.Errorf("CategoryEnhanceUserTemplate is empty") } return company.LangPromptMap{ "sl": tpl, company.LangPromptAny: tpl, }, nil } func categoryEnhancePromptValueOK(p string) bool { p = strings.TrimSpace(p) if p == "" { return false } return !aiprompts.CategoryEnhancePromptNeedsRepair(p) } func categoryEnhancePromptMapOK(m company.LangPromptMap) bool { if !company.HasAnyPrompt(m) { return false } for _, p := range m { p = strings.TrimSpace(p) if p == "" { continue } if !categoryEnhancePromptValueOK(p) { return false } } // 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 } } 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( ctx context.Context, pool *pgxpool.Pool, companyID uuid.UUID, companyName string, seedByNorm map[string]string, seedByUID map[string]string, dryRun bool, forceFromSeed bool, out *RepairCategoryEnhancePromptsResult, ) (int, error) { rows, err := pool.Query(ctx, ` SELECT id, COALESCE(unique_id, ''), name, COALESCE(prompt, '{}'::jsonb), title_template, description_template FROM categories WHERE company_id = $1`, companyID) if err != nil { return 0, fmt.Errorf("list categories for %s: %w", companyName, err) } defer rows.Close() updatedHere := 0 for rows.Next() { var id uuid.UUID var uniqueID, name string var raw []byte var titleTpl, descTpl []byte if err := rows.Scan(&id, &uniqueID, &name, &raw, &titleTpl, &descTpl); err != nil { return updatedHere, err } out.CategoriesSeen++ m, err := company.DecodeLangPromptMap(raw) 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) { 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 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 } 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) } } 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++ } } 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 "" }