Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
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, ", "))
|
||||
}
|
||||
Reference in New Issue
Block a user