Files

331 lines
9.7 KiB
Go
Raw Permalink Normal View History

package main
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"unicode"
2026-08-16 16:57:36 +02:00
"github.com/descrybe/descrybe-v2/apps/api/internal/aiprompts"
2026-08-17 00:39:25 +02:00
"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 {
2026-08-16 16:57:36 +02:00
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 {
2026-08-17 00:39:25 +02:00
// 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")
}
2026-08-17 00:39:25 +02:00
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
}
2026-08-17 00:39:25 +02:00
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))
2026-08-16 16:57:36 +02:00
byUnique := make(map[string]string, len(file.Entries))
2026-08-17 00:39:25 +02:00
// 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 /
2026-08-17 00:39:25 +02:00
// description / meta formulas stay in title_template / description_template.
2026-08-16 16:57:36 +02:00
// Stored under "sl" + "*" so prompt->>'sl' and LangPromptAny both resolve;
// language stays via {{language}}.
for _, e := range file.Entries {
2026-08-16 16:57:36 +02:00
uid := strings.TrimSpace(e.UniqueID)
name := strings.TrimSpace(e.Name)
2026-08-16 16:57:36 +02:00
if uid == "" && name == "" {
out.Skipped++
continue
}
2026-08-17 00:39:25 +02:00
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) {
2026-08-17 00:39:25 +02:00
canonical = prepareCategoryPrompt(raw)
} else {
// No usable per-category content — never store template boilerplate;
// the category keeps the company default.
out.Skipped++
continue
2026-08-17 00:39:25 +02:00
}
if canonical == "" {
out.Skipped++
continue
}
2026-08-16 16:57:36 +02:00
if uid != "" {
byUnique[strings.ToLower(uid)] = canonical
}
if name != "" {
key := normalizeCategoryName(name)
if key == "" {
if uid == "" {
out.Skipped++
}
continue
}
byNorm[key] = canonical
}
}
2026-08-16 16:57:36 +02:00
if len(byNorm) == 0 && len(byUnique) == 0 {
return out, fmt.Errorf("no usable category prompts in %s", promptsPath)
}
rows, err := pg.Query(ctx, `
2026-08-16 16:57:36 +02:00
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()
2026-08-16 16:57:36 +02:00
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
2026-08-16 16:57:36 +02:00
var uniqueID, name string
if err := rows.Scan(&id, &uniqueID, &name); err != nil {
return out, err
}
2026-08-16 16:57:36 +02:00
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
}
2026-08-16 16:57:36 +02:00
for key := range byUnique {
if _, ok := matchedUID[key]; !ok {
out.Unmatched = append(out.Unmatched, "uid:"+key)
}
}
for key := range byNorm {
2026-08-16 16:57:36 +02:00
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 {
2026-08-16 16:57:36 +02:00
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.
2026-08-16 16:57:36 +02:00
// sl + * = prompt->>'sl' and company.LangPromptAny ({{language}} at render).
tag, err := pg.Exec(ctx, `
UPDATE categories AS c
2026-08-16 16:57:36 +02:00
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, ", "))
}