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, ", ")) }