Files
descrybe/apps/api/cmd/seed-a1/category_prompts.go
T
greeneclipseandClaude Fable 5 9a839d6d13 Store category prompts as EXACT split legacy text, no boilerplate
The per-category prompt editor showed English machine boilerplate
("Role: description. Build JSON …", schema intro, {{var}} context lines)
because seed/sync baked the render framing into categories.prompt. Now the
stored overlay is exactly the legacy wp_product_categories.sql content,
split into the three sections a user would type themselves:

  --- Title ---        Slovenian naming formula
  --- Description ---  legacy <H2>/<p>/<ul> body structure
  --- Meta ---         legacy metaDescription instruction

Schema, role framing, and Name/Description/Category/Attrs product context
stay render-time only (system template + ensureCategoryEnhanceUserContext),
where they already existed.

- SplitLegacyCombinedEnhancePrompt: non-legacy input now returns "" —
  categories without seed/legacy content get their override CLEARED
  (company default) instead of being stuffed with the canonical template
  (e.g. parent categories like "Bela tehnika" absent from the SQL).
- CategoryEnhancePromptNeedsRepair flags stored boilerplate ("parsed as
  JSON", "Build JSON", retired Attributes section) so Sync rewrites old
  data to the clean shape; repair supports clearing (prompt = '{}').
- seed-a1 apply-category-prompts skips instead of writing template text.
- Web editor compose stores only the user's section text: empty sections
  keep bare markers, default bodies and the default preamble are never
  persisted (DEFAULT_SECTION_BODIES removed).
- Verified locally: apply rewrote 238 A1+Demo categories (216 exact
  splits, 22 cleared), zero boilerplate matches in DB, idempotent re-run
  (would_update=0).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 00:53:26 +02:00

331 lines
9.7 KiB
Go

package main
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"unicode"
"github.com/descrybe/descrybe-v2/apps/api/internal/aiprompts"
"github.com/descrybe/descrybe-v2/apps/api/internal/catalog"
"github.com/descrybe/descrybe-v2/apps/api/internal/security"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
// MaxCategoryPromptRunes bounds stored category.prompt (aligned with campaign prompts).
const MaxCategoryPromptRunes = security.MaxCampaignPromptRunes
// categoryPromptsFile is the committed A1 overlay of legacy Name → Prompt pairs.
type categoryPromptsFile struct {
Version int `json:"version"`
Source string `json:"source"`
Entries []categoryPromptEntry `json:"entries"`
}
type categoryPromptEntry struct {
Name string `json:"name"`
UniqueID string `json:"unique_id,omitempty"`
Prompt string `json:"prompt"`
}
var (
// Legacy v1 placeholders → v2 {{variables}} used by aiprompts.Render.
reLegacyDesc = regexp.MustCompile(`(?i)\{\s*""?\s*OPIS\s+IZDELKA\s*""?\s*\}`)
reLegacyName = regexp.MustCompile(`(?i)\{\s*""?\s*STARO\s+IME\s+IZDELKA\s*""?\s*\}`)
)
func defaultCategoryPromptsPath(archivePath string) string {
// Prefer live WP dump when present (Sync A1 / seed share the same source of truth).
if p := catalog.ResolveWPCategoryPromptsPath(""); p != "" {
return p
}
if strings.TrimSpace(archivePath) != "" {
return filepath.Join(filepath.Dir(archivePath), "a1-category-prompts.json")
}
return filepath.Join("..", "..", "scripts", "seed", "a1-category-prompts.json")
}
func loadCategoryPromptsFile(path string) (categoryPromptsFile, error) {
path = filepath.Clean(strings.TrimSpace(path))
if path == "" || path == "." {
return categoryPromptsFile{}, fmt.Errorf("category prompts path is empty")
}
base := strings.ToLower(filepath.Base(path))
if strings.HasPrefix(base, "wp_product_categories") && strings.HasSuffix(base, ".sql") {
return loadCategoryPromptsFromWPSQL(path)
}
// Allowlist the committed seed filename (blocks accidental reads of unrelated dumps).
if filepath.Base(path) != "a1-category-prompts.json" {
return categoryPromptsFile{}, fmt.Errorf("refusing unexpected category prompts file name %q", filepath.Base(path))
}
raw, err := os.ReadFile(path)
if err != nil {
return categoryPromptsFile{}, fmt.Errorf("read category prompts: %w", err)
}
if len(raw) > 8<<20 {
return categoryPromptsFile{}, fmt.Errorf("category prompts file too large (%d bytes)", len(raw))
}
var f categoryPromptsFile
if err := json.Unmarshal(raw, &f); err != nil {
return categoryPromptsFile{}, fmt.Errorf("parse category prompts JSON: %w", err)
}
if len(f.Entries) == 0 {
return categoryPromptsFile{}, fmt.Errorf("category prompts file has no entries")
}
if len(f.Entries) > 5000 {
return categoryPromptsFile{}, fmt.Errorf("category prompts file has too many entries (%d)", len(f.Entries))
}
return f, nil
}
func loadCategoryPromptsFromWPSQL(path string) (categoryPromptsFile, error) {
raw, err := os.ReadFile(path)
if err != nil {
return categoryPromptsFile{}, fmt.Errorf("read wp category prompts: %w", err)
}
if len(raw) > 16<<20 {
return categoryPromptsFile{}, fmt.Errorf("wp category prompts file too large (%d bytes)", len(raw))
}
parsed, err := catalog.ParseWPProductCategoriesSQL(string(raw))
if err != nil {
return categoryPromptsFile{}, err
}
entries := make([]categoryPromptEntry, 0, len(parsed))
for _, e := range parsed {
entries = append(entries, categoryPromptEntry{
Name: e.Name,
UniqueID: e.UniqueID,
Prompt: e.Prompt,
})
}
if len(entries) == 0 {
return categoryPromptsFile{}, fmt.Errorf("wp category prompts file has no entries")
}
return categoryPromptsFile{
Version: 1,
Source: filepath.Base(path),
Entries: entries,
}, nil
}
// modernizeLegacyPromptPlaceholders rewrites v1 {""OPIS IZDELKA""} tokens to {{description}} / {{name}}.
func modernizeLegacyPromptPlaceholders(prompt string) string {
prompt = reLegacyDesc.ReplaceAllString(prompt, "{{description}}")
prompt = reLegacyName.ReplaceAllString(prompt, "{{name}}")
return prompt
}
func prepareCategoryPrompt(prompt string) string {
prompt = modernizeLegacyPromptPlaceholders(prompt)
return security.SanitizePrompt(prompt, MaxCategoryPromptRunes)
}
// normalizeCategoryName keys categories for matching (case/space/diacritic tolerant).
func normalizeCategoryName(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 = foldSloveneRune(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)
continue
}
// Drop punctuation noise from names.
}
return strings.TrimSpace(b.String())
}
func foldSloveneRune(r rune) rune {
switch r {
case 'č', 'ć':
return 'c'
case 'š':
return 's'
case 'ž':
return 'z'
case 'đ':
return 'd'
default:
return r
}
}
type categoryPromptApplyResult struct {
Updated int
Unmatched []string
Skipped int // empty prompt after sanitize
}
func applyCategoryPrompts(ctx context.Context, pg *pgxpool.Pool, companyID uuid.UUID, promptsPath string) (categoryPromptApplyResult, error) {
var out categoryPromptApplyResult
file, err := loadCategoryPromptsFile(promptsPath)
if err != nil {
return out, err
}
byNorm := make(map[string]string, len(file.Entries))
byUnique := make(map[string]string, len(file.Entries))
// Overlay: split each legacy combined Name+Description prompt into role-sectioned
// enhance USER text (Title / Description / Meta) holding exactly the legacy
// per-category text — no schema/role boilerplate. Unique title /
// description / meta formulas stay in title_template / description_template.
// Stored under "sl" + "*" so prompt->>'sl' and LangPromptAny both resolve;
// language stays via {{language}}.
for _, e := range file.Entries {
uid := strings.TrimSpace(e.UniqueID)
name := strings.TrimSpace(e.Name)
if uid == "" && name == "" {
out.Skipped++
continue
}
raw := strings.TrimSpace(e.Prompt)
var canonical string
if raw == "" {
out.Skipped++
continue
}
if aiprompts.IsLegacyCombinedEnhancePrompt(raw) {
canonical = prepareCategoryPrompt(aiprompts.SplitLegacyCombinedEnhancePrompt(raw))
} else if aiprompts.CategoryEnhanceHasRoleSections(raw) && !aiprompts.CategoryEnhancePromptNeedsRepair(raw) {
canonical = prepareCategoryPrompt(raw)
} else {
// No usable per-category content — never store template boilerplate;
// the category keeps the company default.
out.Skipped++
continue
}
if canonical == "" {
out.Skipped++
continue
}
if uid != "" {
byUnique[strings.ToLower(uid)] = canonical
}
if name != "" {
key := normalizeCategoryName(name)
if key == "" {
if uid == "" {
out.Skipped++
}
continue
}
byNorm[key] = canonical
}
}
if len(byNorm) == 0 && len(byUnique) == 0 {
return out, fmt.Errorf("no usable category prompts in %s", promptsPath)
}
rows, err := pg.Query(ctx, `
SELECT id, COALESCE(unique_id, ''), name
FROM categories
WHERE company_id = $1`, companyID)
if err != nil {
return out, fmt.Errorf("list categories: %w", err)
}
defer rows.Close()
ids := make([]uuid.UUID, 0, len(byNorm)+len(byUnique))
prompts := make([]string, 0, len(byNorm)+len(byUnique))
matchedNorm := make(map[string]struct{}, len(byNorm))
matchedUID := make(map[string]struct{}, len(byUnique))
for rows.Next() {
var id uuid.UUID
var uniqueID, name string
if err := rows.Scan(&id, &uniqueID, &name); err != nil {
return out, err
}
prompt := ""
if uidKey := strings.ToLower(strings.TrimSpace(uniqueID)); uidKey != "" {
if p, ok := byUnique[uidKey]; ok {
prompt = p
matchedUID[uidKey] = struct{}{}
}
}
if prompt == "" {
key := normalizeCategoryName(name)
if p, ok := byNorm[key]; ok {
prompt = p
matchedNorm[key] = struct{}{}
}
}
if prompt == "" {
continue
}
ids = append(ids, id)
prompts = append(prompts, prompt)
}
if err := rows.Err(); err != nil {
return out, err
}
for key := range byUnique {
if _, ok := matchedUID[key]; !ok {
out.Unmatched = append(out.Unmatched, "uid:"+key)
}
}
for key := range byNorm {
if _, ok := matchedNorm[key]; !ok {
// Name unmatched is noise when unique_id matched the same row; still report for seed hygiene.
out.Unmatched = append(out.Unmatched, "name:"+key)
}
}
sort.Strings(out.Unmatched)
if len(ids) == 0 {
return out, fmt.Errorf("no A1 categories matched any of %d seed prompts (check unique_id/names)", len(byNorm)+len(byUnique))
}
// Single parameterized batch update — company_id gate prevents cross-tenant writes.
// sl + * = prompt->>'sl' and company.LangPromptAny ({{language}} at render).
tag, err := pg.Exec(ctx, `
UPDATE categories AS c
SET prompt = jsonb_build_object('sl', v.prompt, '*', v.prompt), updated_at = now()
FROM (
SELECT * FROM unnest($1::uuid[], $2::text[]) AS t(id, prompt)
) AS v
WHERE c.id = v.id AND c.company_id = $3`, ids, prompts, companyID)
if err != nil {
return out, fmt.Errorf("update category prompts: %w", err)
}
out.Updated = int(tag.RowsAffected())
return out, nil
}
func logCategoryPromptResult(res categoryPromptApplyResult, path string) {
log.Printf("category prompts from %s: updated=%d skipped=%d unmatched=%d",
path, res.Updated, res.Skipped, len(res.Unmatched))
if len(res.Unmatched) == 0 {
return
}
const maxShow = 20
show := res.Unmatched
if len(show) > maxShow {
show = show[:maxShow]
}
log.Printf(" unmatched seed names (normalized, first %d): %s", len(show), strings.Join(show, ", "))
}