Files
descrybe/apps/api/internal/catalog/prompt_repair.go
T

800 lines
24 KiB
Go
Raw Normal View History

2026-08-16 16:57:36 +02:00
package catalog
import (
"context"
2026-08-17 00:39:25 +02:00
"encoding/json"
2026-08-16 16:57:36 +02:00
"fmt"
2026-08-17 00:39:25 +02:00
"os"
"path/filepath"
2026-08-16 16:57:36 +02:00
"strings"
2026-08-17 00:39:25 +02:00
"unicode"
2026-08-16 16:57:36 +02:00
"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"
2026-08-17 00:39:25 +02:00
"github.com/descrybe/descrybe-v2/apps/api/internal/security"
2026-08-16 16:57:36 +02:00
"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 {
2026-08-17 00:39:25 +02:00
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"`
// *_Cleared count non-seed categories whose leftover formula artifacts
// (English defaults from earlier repairs / cats.json / migration) were removed.
TitleTemplateCleared int `json:"title_template_cleared"`
DescTemplateCleared int `json:"description_template_cleared"`
2026-08-17 00:39:25 +02:00
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.
2026-08-17 09:33:07 +02:00
// Empty → auto-detect WP SQL (SEED_A1_WP_CATEGORIES / scripts/seed), else JSON seed.
2026-08-17 00:39:25 +02:00
SeedPromptsPath string
// WPCategoriesPath forces wp_product_categories.sql (overrides SeedPromptsPath when set).
WPCategoriesPath string
2026-08-17 09:33:07 +02:00
// WPCategoriesSQL is optional dump bytes (legacy Admin Sync A1 upload / API clients).
2026-08-17 00:39:25 +02:00
// 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) carrying exactly the split legacy text.
// 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; categories with no seed/legacy content have their override
// cleared (company default) — template boilerplate is never stored.
2026-08-17 00:39:25 +02:00
//
// Also repairs empty or brand-only title_template and empty description_template
// from legacy <name> / HTML / meta blocks so enhance uses real A1 formulas.
2026-08-16 16:57:36 +02:00
//
2026-08-17 00:39:25 +02:00
// LOCAL repair only (idempotent when seed unchanged):
2026-08-16 16:57:36 +02:00
// - Writes JSON-compatible user overlays under both "sl" (prompt->>'sl') and "*"
2026-08-17 00:39:25 +02:00
// (LangPromptAny) so language stays via {{language}}.
2026-08-16 16:57:36 +02:00
// - 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) {
2026-08-17 00:39:25 +02:00
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) {
2026-08-16 16:57:36 +02:00
out := RepairCategoryEnhancePromptsResult{
DryRun: dryRun,
ByCompany: map[string]int{},
}
if pool == nil {
return out, fmt.Errorf("postgres pool is required")
}
2026-08-17 00:39:25 +02:00
if len(opts.WPCategoriesSQL) > 0 {
if _, _, err := LoadWPCategoryPromptOverlaysFromBytes(opts.WPCategoriesSQL); err != nil {
return out, fmt.Errorf("wp_product_categories upload: %w", err)
}
}
2026-08-16 16:57:36 +02:00
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")
}
2026-08-17 00:39:25 +02:00
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
2026-08-16 16:57:36 +02:00
}
for _, c := range companies {
2026-08-17 00:39:25 +02:00
n, err := repairCompanyCategoryEnhancePrompts(ctx, pool, c.id, c.name, seedByNorm, seedByUID, dryRun, forceFromSeed, &out)
2026-08-16 16:57:36 +02:00
if err != nil {
return out, err
}
if n > 0 {
out.ByCompany[c.name] = n
}
}
return out, nil
}
2026-08-17 00:39:25 +02:00
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
}
}
func categoryEnhancePromptValueOK(p string) bool {
p = strings.TrimSpace(p)
if p == "" {
2026-08-16 16:57:36 +02:00
return false
}
2026-08-17 00:39:25 +02:00
return !aiprompts.CategoryEnhancePromptNeedsRepair(p)
}
func categoryEnhancePromptMapOK(m company.LangPromptMap) bool {
if !company.HasAnyPrompt(m) {
2026-08-16 16:57:36 +02:00
return false
}
2026-08-17 00:39:25 +02:00
for _, p := range m {
2026-08-16 16:57:36 +02:00
p = strings.TrimSpace(p)
if p == "" {
continue
}
2026-08-17 00:39:25 +02:00
if !categoryEnhancePromptValueOK(p) {
2026-08-16 16:57:36 +02:00
return false
}
2026-08-17 00:39:25 +02:00
}
// 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
}
// computeRepairedEnhancePrompt returns the clean role-sectioned overlay for a
// category — exactly the split per-category text. "" means the override should
// be CLEARED (no legacy/seed content to preserve → company default applies);
// fallback reports that clear case.
2026-08-17 00:39:25 +02:00
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) && !aiprompts.CategoryEnhancePromptNeedsRepair(raw) {
2026-08-17 00:39:25 +02:00
return security.SanitizePrompt(raw, MaxCategoryPromptRunes), false, false
}
return "", false, true
2026-08-17 00:39:25 +02:00
}
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
2026-08-16 16:57:36 +02:00
}
}
2026-08-17 00:39:25 +02:00
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 "", false, false, true
2026-08-16 16:57:36 +02:00
}
func repairCompanyCategoryEnhancePrompts(
ctx context.Context,
pool *pgxpool.Pool,
companyID uuid.UUID,
companyName string,
2026-08-17 00:39:25 +02:00
seedByNorm map[string]string,
seedByUID map[string]string,
2026-08-16 16:57:36 +02:00
dryRun bool,
2026-08-17 00:39:25 +02:00
forceFromSeed bool,
2026-08-16 16:57:36 +02:00
out *RepairCategoryEnhancePromptsResult,
) (int, error) {
rows, err := pool.Query(ctx, `
2026-08-17 00:39:25 +02:00
SELECT id,
COALESCE(unique_id, ''),
name,
COALESCE(prompt, '{}'::jsonb),
title_template,
description_template
2026-08-16 16:57:36 +02:00
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
2026-08-17 00:39:25 +02:00
var uniqueID, name string
2026-08-16 16:57:36 +02:00
var raw []byte
2026-08-17 00:39:25 +02:00
var titleTpl, descTpl []byte
if err := rows.Scan(&id, &uniqueID, &name, &raw, &titleTpl, &descTpl); err != nil {
2026-08-16 16:57:36 +02:00
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)
}
2026-08-17 00:39:25 +02:00
seedLegacy := pickSeedPrompt(uniqueID, name, seedByNorm, seedByUID)
needPrompt := company.HasAnyPrompt(m) && !categoryEnhancePromptMapOK(m)
// Empty prompt: only seed-matched categories get an overlay written;
// categories without seed content stay on the company default.
if !company.HasAnyPrompt(m) && seedLegacy != "" {
2026-08-17 00:39:25 +02:00
needPrompt = true
2026-08-16 16:57:36 +02:00
}
2026-08-17 00:39:25 +02:00
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
}
// Never invent a default title formula: formulas are derived ONLY from
// legacy naming rules. (Earlier repairs backfilled DefaultRetailTitleFormula
// with English labels onto every category — that junk gets cleared below.)
if strings.TrimSpace(titleRules) == "" {
needTitle = false
}
// Seed is the source of truth: categories with no seed/legacy content must
// not keep formula artifacts (English default sections/formulas from
// earlier repairs, cats.json seeding, or migration).
noLegacyContent := seedLegacy == "" && !legacyParts.WasLegacy && strings.TrimSpace(titleRules) == ""
clearTitleTpl := forceFromSeed && noLegacyContent && jsonbTemplatePresent(titleTpl)
clearDescTpl := forceFromSeed && noLegacyContent && jsonbTemplatePresent(descTpl)
if !needPrompt && !needTitle && !needDesc && !clearTitleTpl && !clearDescTpl {
2026-08-17 00:39:25 +02:00
if company.HasAnyPrompt(m) {
out.AlreadyOK++
} else {
out.EmptySkipped++
}
2026-08-16 16:57:36 +02:00
continue
}
// clearPrompt: repair resolved to "no content" → drop the override so the
// category falls back to the company default (never store template boilerplate).
clearPrompt := false
2026-08-17 00:39:25 +02:00
if needPrompt && wantPrompt == "" {
wantPrompt = resolveRepairedEnhancePrompt(m, seedLegacy, forceFromSeed && seedLegacy != "", out)
wantPrompt = security.SanitizePrompt(wantPrompt, MaxCategoryPromptRunes)
if wantPrompt == "" {
clearPrompt = true
2026-08-17 00:39:25 +02:00
}
} 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++
}
}
2026-08-16 16:57:36 +02:00
out.WouldUpdate++
if dryRun {
2026-08-17 00:39:25 +02:00
if needTitle {
out.TitleTemplateBackfill++
}
if needDesc {
out.DescTemplateBackfill++
}
if clearTitleTpl {
out.TitleTemplateCleared++
}
if clearDescTpl {
out.DescTemplateCleared++
}
2026-08-16 16:57:36 +02:00
updatedHere++
continue
}
if needPrompt && clearPrompt {
ct, err := pool.Exec(ctx, `
UPDATE categories
SET prompt = '{}'::jsonb, updated_at = now()
WHERE id = $1 AND company_id = $2`, id, companyID)
if err != nil {
return updatedHere, fmt.Errorf("clear prompt category=%s: %w", id, err)
}
if ct.RowsAffected() == 0 {
return updatedHere, fmt.Errorf("category %s not updated (company mismatch?)", id)
}
} else if needPrompt {
2026-08-17 00:39:25 +02:00
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)
}
2026-08-16 16:57:36 +02:00
}
2026-08-17 00:39:25 +02:00
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++
}
2026-08-16 16:57:36 +02:00
}
2026-08-17 00:39:25 +02:00
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++
}
}
2026-08-16 16:57:36 +02:00
}
2026-08-17 00:39:25 +02:00
if clearTitleTpl {
if _, err := pool.Exec(ctx, `
UPDATE categories
SET title_template = NULL, updated_at = now()
WHERE id = $1 AND company_id = $2`, id, companyID); err != nil {
return updatedHere, fmt.Errorf("clear title_template category=%s: %w", id, err)
}
out.TitleTemplateCleared++
}
if clearDescTpl {
if _, err := pool.Exec(ctx, `
UPDATE categories
SET description_template = NULL, updated_at = now()
WHERE id = $1 AND company_id = $2`, id, companyID); err != nil {
return updatedHere, fmt.Errorf("clear description_template category=%s: %w", id, err)
}
out.DescTemplateCleared++
}
2026-08-16 16:57:36 +02:00
out.Updated++
updatedHere++
}
return updatedHere, rows.Err()
}
2026-08-17 00:39:25 +02:00
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 ""
}