Files
descrybe/apps/api/cmd/seed-a1/category_prompts.go
T

238 lines
6.5 KiB
Go
Raw Normal View History

package main
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"unicode"
"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"`
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 {
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")
}
// 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
}
// 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))
for _, e := range file.Entries {
name := strings.TrimSpace(e.Name)
prompt := prepareCategoryPrompt(e.Prompt)
if name == "" || prompt == "" {
out.Skipped++
continue
}
key := normalizeCategoryName(name)
if key == "" {
out.Skipped++
continue
}
byNorm[key] = prompt
}
if len(byNorm) == 0 {
return out, fmt.Errorf("no usable category prompts in %s", promptsPath)
}
rows, err := pg.Query(ctx, `
SELECT 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))
prompts := make([]string, 0, len(byNorm))
matchedKeys := make(map[string]struct{}, len(byNorm))
for rows.Next() {
var id uuid.UUID
var name string
if err := rows.Scan(&id, &name); err != nil {
return out, err
}
key := normalizeCategoryName(name)
prompt, ok := byNorm[key]
if !ok {
continue
}
matchedKeys[key] = struct{}{}
ids = append(ids, id)
prompts = append(prompts, prompt)
}
if err := rows.Err(); err != nil {
return out, err
}
for key := range byNorm {
if _, ok := matchedKeys[key]; !ok {
out.Unmatched = append(out.Unmatched, key)
}
}
sort.Strings(out.Unmatched)
if len(ids) == 0 {
return out, fmt.Errorf("no A1 categories matched any of %d seed prompts (check names)", len(byNorm))
}
// Single parameterized batch update — company_id gate prevents cross-tenant writes.
// ASSUMPTION: A1 seed company content language is Slovenian ("sl").
tag, err := pg.Exec(ctx, `
UPDATE categories AS c
SET prompt = jsonb_build_object('sl', 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, ", "))
}