2026-08-09 22:47:43 +02:00
|
|
|
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"
|
2026-08-09 22:47:43 +02:00
|
|
|
"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"`
|
2026-08-09 22:47:43 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
2026-08-09 22:47:43 +02:00
|
|
|
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)
|
|
|
|
|
}
|
2026-08-09 22:47:43 +02:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 22:47:43 +02:00
|
|
|
// 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 / Attributes). Unique title /
|
|
|
|
|
// 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}}.
|
2026-08-09 22:47:43 +02:00
|
|
|
for _, e := range file.Entries {
|
2026-08-16 16:57:36 +02:00
|
|
|
uid := strings.TrimSpace(e.UniqueID)
|
2026-08-09 22:47:43 +02:00
|
|
|
name := strings.TrimSpace(e.Name)
|
2026-08-16 16:57:36 +02:00
|
|
|
if uid == "" && name == "" {
|
2026-08-09 22:47:43 +02:00
|
|
|
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) {
|
|
|
|
|
canonical = prepareCategoryPrompt(raw)
|
|
|
|
|
} else {
|
|
|
|
|
canonical = prepareCategoryPrompt(aiprompts.CategoryEnhanceUserTemplate)
|
|
|
|
|
}
|
|
|
|
|
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-09 22:47:43 +02:00
|
|
|
}
|
|
|
|
|
}
|
2026-08-16 16:57:36 +02:00
|
|
|
if len(byNorm) == 0 && len(byUnique) == 0 {
|
2026-08-09 22:47:43 +02:00
|
|
|
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
|
2026-08-09 22:47:43 +02:00
|
|
|
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))
|
2026-08-09 22:47:43 +02:00
|
|
|
|
|
|
|
|
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 {
|
2026-08-09 22:47:43 +02:00
|
|
|
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 == "" {
|
2026-08-09 22:47:43 +02:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-09 22:47:43 +02:00
|
|
|
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)
|
2026-08-09 22:47:43 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
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))
|
2026-08-09 22:47:43 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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).
|
2026-08-09 22:47:43 +02:00
|
|
|
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()
|
2026-08-09 22:47:43 +02:00
|
|
|
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, ", "))
|
|
|
|
|
}
|