fix
This commit is contained in:
@@ -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 ""
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user