900 {
+ ex = ex[:900] + "…"
+ }
+ htmlExcerpts = append(htmlExcerpts, map[string]any{
+ "ean": ean, "title": title, "html_excerpt": ex,
+ })
+ }
+ }
+
+ if !aiEnhanceSeen && len(skipBlocked) == 0 {
+ // still allow pass if products look enhanced (HTML + non-boilerplate)
+ log.Printf("WARN: no ai_enhance log line and no skip_blocked — inspect log")
+ }
+ if !startOK {
+ allPass = false
+ }
+ if !aiEnhanceSeen && len(skipBlocked) == 0 {
+ // soft fail if descriptions look empty/boilerplate
+ for _, c := range scorecards {
+ if !c["has_html"].(bool) || !c["pass"].(bool) {
+ allPass = false
+ }
+ }
+ }
+
+ overall := "PASS"
+ if !allPass {
+ overall = "FAIL"
+ }
+ report := map[string]any{
+ "generated_at": time.Now().UTC().Format(time.RFC3339),
+ "overall": overall,
+ "company": map[string]any{"id": companyID.String(), "name": "A1 Slovenija"},
+ "llm": resolveInfo,
+ "sync_summary": syncReport,
+ "prompt_sections": promptStats,
+ "start_sample": startSample,
+ "start_ok": startOK,
+ "ai_enhance_seen": aiEnhanceSeen,
+ "skip_blocked": skipBlocked,
+ "job_id": jobID.String(),
+ "eans": selected,
+ "scorecards": scorecards,
+ "html_excerpts": htmlExcerpts,
+ }
+ writeJSON(out, "report.json", report)
+ writeMarkdownReport(out, report)
+
+ fmt.Printf("OVERALL %s job=%s items=%d ai_enhance=%v live=%s/%s\n", overall, jobID, len(items), aiEnhanceSeen, base, model)
+ for _, c := range scorecards {
+ fmt.Printf("CARD ean=%s pass=%v title=%q cat=%s html=%v\n",
+ c["ean"], c["pass"], trunc(str(c["title"]), 50), c["category"], c["has_html"])
+ }
+}
+
+func confirmPromptSections(ctx context.Context, pool *pgxpool.Pool, companyID uuid.UUID) map[string]any {
+ rows, err := pool.Query(ctx, `SELECT name, prompt::text, COALESCE(title_template::text,''), COALESCE(description_template::text,'')
+ FROM categories WHERE company_id=$1`, companyID)
+ if err != nil {
+ return map[string]any{"error": err.Error()}
+ }
+ defer rows.Close()
+ n, withTitleSec, withDescSec, withTitleTmpl, withRichFormula := 0, 0, 0, 0, 0
+ samples := []map[string]any{}
+ for rows.Next() {
+ var name, prompt, tt, dt string
+ _ = rows.Scan(&name, &prompt, &tt, &dt)
+ n++
+ hasT := strings.Contains(prompt, "--- Title ---") || strings.Contains(prompt, `"Title"`)
+ hasD := strings.Contains(prompt, "--- Description ---") || strings.Contains(prompt, `"Description"`)
+ if hasT {
+ withTitleSec++
+ }
+ if hasD {
+ withDescSec++
+ }
+ if strings.TrimSpace(tt) != "" && !strings.EqualFold(strings.TrimSpace(tt), "null") {
+ withTitleTmpl++
+ }
+ if strings.Contains(dt, `"sections"`) || strings.Contains(strings.ToLower(dt), `"h2"`) {
+ withRichFormula++
+ }
+ if (strings.Contains(strings.ToLower(name), "televiz") || strings.Contains(strings.ToLower(name), "monitor") ||
+ strings.Contains(strings.ToLower(name), "sluš") || strings.Contains(strings.ToLower(name), "peč")) && len(samples) < 4 {
+ samples = append(samples, map[string]any{
+ "name": name, "title_section": hasT, "description_section": hasD,
+ "title_template_len": len(tt), "description_template_len": len(dt),
+ })
+ }
+ }
+ return map[string]any{
+ "categories": n,
+ "with_title_section": withTitleSec,
+ "with_description_section": withDescSec,
+ "with_title_template": withTitleTmpl,
+ "with_rich_formula": withRichFormula,
+ "samples": samples,
+ }
+}
+
+func looksLikeInventBoilerplate(desc string) bool {
+ d := strings.ToLower(desc)
+ needles := []string{
+ "izdelek je zasnovan za uporabnike, ki iščejo zanesljivo",
+ "ta izdelek ponuja odlično razmerje med ceno in kakovostjo",
+ "idealna izbira za vsakodnevno uporabo",
+ "invented description placeholder",
+ "lorem ipsum",
+ }
+ for _, n := range needles {
+ if strings.Contains(d, n) {
+ return true
+ }
+ }
+ return false
+}
+
+func hasCollapsedWords(title string) bool {
+ // Detect glued tokens like "TVSamsung" (letter followed by uppercase mid-token) without spaces — weak heuristic.
+ if strings.Contains(title, " ") {
+ return false
+ }
+ runes := []rune(title)
+ for i := 1; i < len(runes)-1; i++ {
+ if unicode.IsLower(runes[i-1]) && unicode.IsUpper(runes[i]) && unicode.IsLower(runes[i+1]) {
+ // camelCase mid-title often means missing space after brand/model join
+ prevSpace := false
+ for j := i - 1; j >= 0; j-- {
+ if runes[j] == ' ' {
+ prevSpace = true
+ break
+ }
+ }
+ if !prevSpace && i > 3 {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+func extractSkipBlocked(logText string) []string {
+ out := []string{}
+ for _, line := range strings.Split(logText, "\n") {
+ if strings.Contains(line, "skip_blocked") || strings.Contains(line, "enhance_skip") {
+ s := strings.TrimSpace(line)
+ if len(s) > 300 {
+ s = s[:300] + "…"
+ }
+ out = append(out, s)
+ }
+ }
+ return out
+}
+
+func keysOf(m map[string]any) []string {
+ ks := make([]string, 0, len(m))
+ for k := range m {
+ ks = append(ks, k)
+ }
+ return ks
+}
+
+func str(v any) string {
+ if v == nil {
+ return ""
+ }
+ switch t := v.(type) {
+ case string:
+ return t
+ default:
+ return strings.TrimSpace(fmt.Sprint(t))
+ }
+}
+
+func firstNonEmpty(vals ...string) string {
+ for _, v := range vals {
+ if strings.TrimSpace(v) != "" {
+ return v
+ }
+ }
+ return ""
+}
+
+func trunc(s string, n int) string {
+ if len(s) <= n {
+ return s
+ }
+ return s[:n]
+}
+
+func looksLikeUUID(s string) bool {
+ s = strings.TrimSpace(s)
+ if len(s) != 36 {
+ return false
+ }
+ for i, r := range s {
+ switch i {
+ case 8, 13, 18, 23:
+ if r != '-' {
+ return false
+ }
+ default:
+ if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) {
+ return false
+ }
+ }
+ }
+ return true
+}
+
+func summarizeAttrs(item map[string]any) map[string]any {
+ out := map[string]any{"count": 0, "keys": []string{}}
+ var raw any
+ for _, k := range []string{"attributes", "attrs", "product_attributes"} {
+ if v, ok := item[k]; ok && v != nil {
+ raw = v
+ break
+ }
+ }
+ if raw == nil {
+ return out
+ }
+ keys := []string{}
+ switch t := raw.(type) {
+ case map[string]any:
+ for k := range t {
+ keys = append(keys, k)
+ }
+ case []any:
+ for _, el := range t {
+ if m, ok := el.(map[string]any); ok {
+ name := firstNonEmpty(str(m["name"]), str(m["key"]), str(m["attribute"]))
+ if name != "" {
+ keys = append(keys, name)
+ }
+ }
+ }
+ }
+ if len(keys) > 12 {
+ keys = keys[:12]
+ }
+ out["count"] = len(keys)
+ out["keys"] = keys
+ return out
+}
+
+func failResolve(out, msg string) {
+ writeJSON(out, "resolve.json", map[string]any{
+ "live": false,
+ "fail_reason": msg,
+ })
+ log.Fatalf("LIVE LLM REQUIRED: %s", msg)
+}
+
+func writeJSON(out, name string, v any) {
+ raw, err := json.MarshalIndent(v, "", " ")
+ if err != nil {
+ log.Fatalf("marshal %s: %v", name, err)
+ }
+ if err := os.WriteFile(filepath.Join(out, name), raw, 0o644); err != nil {
+ log.Fatalf("write %s: %v", name, err)
+ }
+}
+
+func writeMarkdownReport(out string, report map[string]any) {
+ var b strings.Builder
+ b.WriteString("# Final verify 2026-08-17 (FULL LIVE A1)\n\n")
+ b.WriteString(fmt.Sprintf("**Overall: %v**\n\n", report["overall"]))
+ b.WriteString("Live OverloadedBot only (DB-resolved completer). No mock `:18767`.\n\n")
+
+ b.WriteString("## Checklist\n\n")
+ b.WriteString("| # | Requirement | Result |\n|---|---|---|\n")
+ llmOK := false
+ if llm, ok := report["llm"].(map[string]any); ok {
+ if st, ok := llm["models_http_status"].(int); ok && (st == 200 || st == 401) {
+ llmOK = true
+ } else if st, ok := llm["models_http_status"].(float64); ok && (st == 200 || st == 401) {
+ llmOK = true
+ }
+ }
+ startOK, _ := report["start_ok"].(bool)
+ aiEnhance, _ := report["ai_enhance_seen"].(bool)
+ overall := str(report["overall"])
+ b.WriteString(fmt.Sprintf("| 1 | LLM `/v1/models` UP (not 502) | **%s** |\n", map[bool]string{true: "PASS", false: "FAIL"}[llmOK]))
+ b.WriteString("| 2 | Sync A1 from `scripts/seed/wp_product_categories.sql` | **PASS** (see sync.json) |\n")
+ b.WriteString(fmt.Sprintf("| 3 | ProcessJob start pending / processed_items=0 | **%s** |\n", map[bool]string{true: "PASS", false: "FAIL"}[startOK]))
+ b.WriteString(fmt.Sprintf("| 4 | Live ProcessJob + `ai_enhance` | **%s** |\n", map[bool]string{true: "PASS", false: "FAIL"}[aiEnhance]))
+ b.WriteString(fmt.Sprintf("| 5 | Per-product V1 payload assertions | **%s** |\n", overall))
+ b.WriteString(fmt.Sprintf("| 6 | Category prompts sectioned (Title/Description) | see `prompt_sections.json` |\n\n"))
+
+ b.WriteString(fmt.Sprintf("- Job: `%v`\n", report["job_id"]))
+ b.WriteString(fmt.Sprintf("- EANs: `%v`\n\n", report["eans"]))
+
+ if llm, ok := report["llm"].(map[string]any); ok {
+ b.WriteString("## LLM endpoint\n\n")
+ b.WriteString(fmt.Sprintf("- base: `%v`\n- model: `%v`\n- models HTTP: `%v` (%v)\n- source: `%v` / mode: `%v` / byok: `%v`\n",
+ llm["completer_base"], firstNonEmpty(str(llm["completer_model_adjusted"]), str(llm["completer_model"])),
+ llm["models_http_status"], llm["models_elapsed"], llm["role_source"], llm["mode_label"], llm["byok"]))
+ if v, ok := llm["probe_ok"]; ok {
+ b.WriteString(fmt.Sprintf("- chat probe_ok: `%v` elapsed `%v`\n", v, llm["probe_elapsed"]))
+ }
+ b.WriteString("\n")
+ }
+
+ b.WriteString("## Sync seed path\n\nSee `sync.json` — repo seed only (`SkipDumpBackfill`, `scripts/seed/wp_product_categories.sql`).\n\n")
+ b.WriteString("## Prompt sections spot-check\n\nSee `prompt_sections.json`.\n\n")
+ b.WriteString("## Start sample\n\nSee `start_sample.json`.\n\n")
+ b.WriteString("## Per-product results\n\n")
+ if cards, ok := report["scorecards"].([]map[string]any); ok {
+ for _, c := range cards {
+ b.WriteString(fmt.Sprintf("### EAN `%v` — pass=%v\n\n", c["ean"], c["pass"]))
+ b.WriteString(fmt.Sprintf("- **Title:** %q\n", trunc(str(c["title"]), 120)))
+ b.WriteString(fmt.Sprintf("- **Category:** %v (id=%v)\n", c["category"], c["category_id"]))
+ b.WriteString(fmt.Sprintf("- **Description:** len=%v html=%v\n", c["desc_len"], c["has_html"]))
+ if sn := str(c["desc_snippet"]); sn != "" {
+ b.WriteString(fmt.Sprintf("```html\n%s\n```\n", sn))
+ }
+ if a, ok := c["attrs_summary"].(map[string]any); ok {
+ b.WriteString(fmt.Sprintf("- **Attrs:** count=%v keys=%v\n", a["count"], a["keys"]))
+ }
+ if checks, ok := c["checks"].(map[string]bool); ok {
+ b.WriteString("- Checks:\n")
+ for k, v := range checks {
+ b.WriteString(fmt.Sprintf(" - %s: %v\n", k, map[bool]string{true: "PASS", false: "FAIL"}[v]))
+ }
+ }
+ b.WriteString("\n")
+ }
+ }
+ b.WriteString("## HTML excerpts\n\n")
+ if exs, ok := report["html_excerpts"].([]map[string]any); ok {
+ for i, ex := range exs {
+ b.WriteString(fmt.Sprintf("### Excerpt %d — %v\n\n```html\n%v\n```\n\n", i+1, ex["ean"], ex["html_excerpt"]))
+ }
+ }
+ b.WriteString("## Verdict\n\n")
+ if overall == "PASS" {
+ b.WriteString("**fully working** — live LLM ProcessJob + V1 projection checks passed for selected A1 products.\n")
+ } else {
+ b.WriteString("**partial / broken** — see failing scorecards and checklist above.\n")
+ }
+ _ = os.WriteFile(filepath.Join(out, "REPORT.md"), []byte(b.String()), 0o644)
+}
diff --git a/apps/api/cmd/_final_verify_probe_tmp/main.go b/apps/api/cmd/_final_verify_probe_tmp/main.go
new file mode 100644
index 0000000..25ef5b4
--- /dev/null
+++ b/apps/api/cmd/_final_verify_probe_tmp/main.go
@@ -0,0 +1,56 @@
+package main
+import (
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "strings"
+ "time"
+ "github.com/descrybe/descrybe-v2/apps/api/internal/aiprovider"
+ "github.com/descrybe/descrybe-v2/apps/api/internal/config"
+ "github.com/descrybe/descrybe-v2/apps/api/internal/db"
+ "github.com/descrybe/descrybe-v2/apps/api/internal/platformsettings"
+ "github.com/descrybe/descrybe-v2/apps/api/internal/processing"
+ "github.com/google/uuid"
+)
+func main() {
+ cfg, err := config.Load()
+ if err != nil { panic(err) }
+ ctx := context.Background()
+ pool, err := db.NewPool(ctx, cfg.DatabaseURL, db.PoolOptions{
+ MaxConns: 4, MinConns: 1, MaxConnLifetime: time.Hour, HealthCheckPeriod: 30*time.Second,
+ })
+ if err != nil { panic(err) }
+ defer pool.Close()
+ plat := platformsettings.NewService(pool, platformsettings.EnvConfig{
+ AppEncryptionKey:cfg.AppEncryptionKey, CredentialsEncryptionKey:cfg.CredentialsEncryptionKey,
+ TokenSigningSecret:cfg.TokenSigningSecret, DatabaseURL:cfg.DatabaseURL,
+ OpenAIAPIKey:cfg.OpenAIAPIKey, OpenAIBaseURL:cfg.OpenAIBaseURL, OpenAIModel:cfg.OpenAIModel,
+ })
+ ai := aiprovider.NewService(pool, aiprovider.EnvConfig{
+ AppEncryptionKey:cfg.AppEncryptionKey, CredentialsEncryptionKey:cfg.CredentialsEncryptionKey,
+ TokenSigningSecret:cfg.TokenSigningSecret, DatabaseURL:cfg.DatabaseURL,
+ OpenAIAPIKey:cfg.OpenAIAPIKey, OpenAIBaseURL:cfg.OpenAIBaseURL, OpenAIModel:cfg.OpenAIModel,
+ })
+ ai.Platform = plat
+ companyID := uuid.MustParse("604f23a8-b66e-4b21-8b45-0d72b68f4790")
+ c, mode, byok, err := ai.ResolveCompleterForRole(ctx, companyID, aiprovider.RoleProcessing)
+ if err != nil { panic(err) }
+ client := c.(*processing.OpenAIClient)
+ fmt.Printf("base=%s model=%s mode=%s byok=%v mock=%v\n", client.BaseURL, client.Model, mode, byok, processing.IsMockOrLoopbackBaseURL(client.BaseURL))
+ url := strings.TrimRight(client.BaseURL,"/")+"/models"
+ for i:=1; i<=3; i++ {
+ req,_ := http.NewRequestWithContext(ctx, "GET", url, nil)
+ req.Header.Set("Authorization", "Bearer "+client.APIKey)
+ start := time.Now()
+ resp, err := http.DefaultClient.Do(req)
+ elapsed := time.Since(start).Round(time.Millisecond)
+ if err != nil { fmt.Printf("try%d err=%v elapsed=%s\n", i, err, elapsed); time.Sleep(2*time.Second); continue }
+ b,_ := io.ReadAll(io.LimitReader(resp.Body, 240)); resp.Body.Close()
+ fmt.Printf("try%d status=%d elapsed=%s body=%q\n", i, resp.StatusCode, elapsed, string(b))
+ if resp.StatusCode == 200 { os.Exit(0) }
+ time.Sleep(3*time.Second)
+ }
+ os.Exit(2)
+}
diff --git a/apps/api/cmd/_final_verify_quick_tmp/main.go b/apps/api/cmd/_final_verify_quick_tmp/main.go
new file mode 100644
index 0000000..2e10825
--- /dev/null
+++ b/apps/api/cmd/_final_verify_quick_tmp/main.go
@@ -0,0 +1,409 @@
+// Temporary slim LIVE verify — 2 products, short timeouts. Do not commit.
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/descrybe/descrybe-v2/apps/api/internal/aiprompts"
+ "github.com/descrybe/descrybe-v2/apps/api/internal/aiprovider"
+ "github.com/descrybe/descrybe-v2/apps/api/internal/billing"
+ "github.com/descrybe/descrybe-v2/apps/api/internal/config"
+ "github.com/descrybe/descrybe-v2/apps/api/internal/db"
+ "github.com/descrybe/descrybe-v2/apps/api/internal/eprel"
+ "github.com/descrybe/descrybe-v2/apps/api/internal/logredact"
+ "github.com/descrybe/descrybe-v2/apps/api/internal/platformsettings"
+ "github.com/descrybe/descrybe-v2/apps/api/internal/processing"
+ "github.com/google/uuid"
+)
+
+func main() {
+ out := `F:\laragon\www\_MY\descrybe-v2\.codehelper\_final_verify_20260817`
+ _ = os.MkdirAll(out, 0o755)
+ logPath := filepath.Join(out, "inproc_run_quick.log")
+ logFile, err := os.Create(logPath)
+ if err != nil {
+ log.Fatalf("log: %v", err)
+ }
+ defer logFile.Close()
+ log.SetOutput(logredact.Writer(io.MultiWriter(os.Stderr, logFile)))
+
+ repoRoot := `F:\laragon\www\_MY\descrybe-v2`
+ wpPath := filepath.Join(repoRoot, "scripts", "seed", "wp_product_categories.sql")
+ wpBytes, err := os.ReadFile(wpPath)
+ if err != nil {
+ failReport(out, "FAIL", "repo seed missing: "+err.Error(), nil)
+ }
+ _ = os.Setenv("SEED_A1_WP_CATEGORIES", wpPath)
+
+ eans := []string{
+ "4548736132597", // Slušalke
+ "195348253666", // Gaming monitorji
+ }
+ companyID := uuid.MustParse("604f23a8-b66e-4b21-8b45-0d72b68f4790")
+ userID := uuid.MustParse("6bf00877-a693-4d77-b28e-8c8292adac98")
+
+ cfg, err := config.Load()
+ if err != nil {
+ failReport(out, "FAIL", "config: "+err.Error(), nil)
+ }
+ ctx := context.Background()
+ pool, err := db.NewPool(ctx, cfg.DatabaseURL, db.PoolOptions{
+ MaxConns: int32(cfg.DBMaxConns), MinConns: int32(cfg.DBMinConns),
+ MaxConnLifetime: cfg.DBMaxConnLifetime, MaxConnLifetimeJitter: cfg.DBMaxConnLifetimeJitter,
+ MaxConnIdleTime: cfg.DBMaxConnIdleTime, HealthCheckPeriod: cfg.DBHealthCheckPeriod,
+ StatementTimeout: cfg.DBStatementTimeout,
+ })
+ if err != nil {
+ failReport(out, "FAIL", "db: "+err.Error(), nil)
+ }
+ defer pool.Close()
+
+ // --- Sync A1 from repo seed only ---
+ var legacy string
+ _ = pool.QueryRow(ctx, `SELECT COALESCE(legacy_company_id,'') FROM companies WHERE id=$1`, companyID).Scan(&legacy)
+ a1Cohort := billing.IsA1CohortCompany(legacy, "A1 Slovenija")
+ syncRes, serr := processing.SyncCompanyA1(ctx, pool, companyID, "A1 Slovenija", a1Cohort, processing.SyncCompanyA1Opts{
+ SkipDumpBackfill: true,
+ FixCompanyCatalogOpts: processing.FixCompanyCatalogOpts{
+ BackfillCategories: true,
+ WPCategoriesSQL: wpBytes,
+ AIPrompts: aiprompts.NewService(pool),
+ },
+ })
+ syncInfo := map[string]any{"wp_path": wpPath, "wp_bytes": len(wpBytes), "error": nil, "result": syncRes}
+ if serr != nil {
+ syncInfo["error"] = serr.Error()
+ writeJSON(out, "sync_quick.json", syncInfo)
+ failReport(out, "FAIL", "SyncCompanyA1: "+serr.Error(), syncInfo)
+ }
+ writeJSON(out, "sync_quick.json", syncInfo)
+ log.Printf("sync ok prompts_already_ok=%d wp_entries=%d", syncRes.CategoryPromptsAlreadyOK, syncRes.WPCategoriesEntries)
+
+ platEnv := platformsettings.EnvConfig{
+ AppEncryptionKey: cfg.AppEncryptionKey, CredentialsEncryptionKey: cfg.CredentialsEncryptionKey,
+ TokenSigningSecret: cfg.TokenSigningSecret, DatabaseURL: cfg.DatabaseURL,
+ OpenAIAPIKey: cfg.OpenAIAPIKey, OpenAIBaseURL: cfg.OpenAIBaseURL, OpenAIModel: cfg.OpenAIModel,
+ }
+ plat := platformsettings.NewService(pool, platEnv)
+ aiSvc := aiprovider.NewService(pool, aiprovider.EnvConfig{
+ AppEncryptionKey: cfg.AppEncryptionKey, CredentialsEncryptionKey: cfg.CredentialsEncryptionKey,
+ TokenSigningSecret: cfg.TokenSigningSecret, DatabaseURL: cfg.DatabaseURL,
+ OpenAIAPIKey: cfg.OpenAIAPIKey, OpenAIBaseURL: cfg.OpenAIBaseURL, OpenAIModel: cfg.OpenAIModel,
+ ProcessingRPM: cfg.ProcessingRPM, ProcessingMaxRetries: cfg.ProcessingMaxRetries,
+ })
+ aiSvc.Platform = plat
+
+ roleCfg, rerr := plat.ResolveAIConfig(ctx, platformsettings.AIRoleProcessing)
+ if rerr != nil {
+ failReport(out, "FAIL", "ResolveAIConfig: "+rerr.Error(), nil)
+ }
+ completer, modeLabel, byok, cerr := aiSvc.ResolveCompleterForRole(ctx, companyID, aiprovider.RoleProcessing)
+ if cerr != nil {
+ failReport(out, "FAIL", "ResolveCompleter: "+cerr.Error(), nil)
+ }
+ client, ok := completer.(*processing.OpenAIClient)
+ if !ok || client == nil {
+ failReport(out, "FAIL", fmt.Sprintf("completer type %T", completer), nil)
+ }
+ base := strings.TrimSpace(client.BaseURL)
+ model := strings.TrimSpace(client.Model)
+ resolve := map[string]any{
+ "role_source": roleCfg.Source, "role_provider": roleCfg.Provider,
+ "completer_base": base, "completer_model": model,
+ "mode": modeLabel, "byok": byok, "env_ignored": cfg.OpenAIBaseURL,
+ }
+ if processing.IsMockOrLoopbackBaseURL(base) || strings.Contains(base, "18767") {
+ resolve["fail_reason"] = "mock/loopback refused"
+ writeJSON(out, "resolve_quick.json", resolve)
+ failReport(out, "FAIL", "mock/loopback refused — live OverloadedBot only", resolve)
+ }
+
+ // --- /models probe (hard stop on 502; 15s) ---
+ modelsURL := strings.TrimRight(base, "/") + "/models"
+ modelsCtx, modelsCancel := context.WithTimeout(ctx, 15*time.Second)
+ defer modelsCancel()
+ req, _ := http.NewRequestWithContext(modelsCtx, http.MethodGet, modelsURL, nil)
+ req.Header.Set("Authorization", "Bearer "+client.APIKey)
+ req.Header.Set("Accept", "application/json")
+ t0 := time.Now()
+ resp, merr := http.DefaultClient.Do(req)
+ elapsed := time.Since(t0).Round(time.Millisecond)
+ if merr != nil {
+ resolve["models_error"] = merr.Error()
+ writeJSON(out, "resolve_quick.json", resolve)
+ failReport(out, "FAIL", "GET /models network: "+merr.Error(), resolve)
+ }
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
+ _ = resp.Body.Close()
+ resolve["models_http_status"] = resp.StatusCode
+ resolve["models_elapsed"] = elapsed.String()
+ resolve["models_snippet"] = trunc(string(body), 180)
+ if resp.StatusCode == http.StatusBadGateway {
+ writeJSON(out, "resolve_quick.json", resolve)
+ failReport(out, "FAIL", "FAIL 502: GET /v1/models — OverloadedBot unavailable (no mock, no wait)", resolve)
+ }
+ if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusUnauthorized {
+ writeJSON(out, "resolve_quick.json", resolve)
+ failReport(out, "FAIL", fmt.Sprintf("GET /models HTTP %d", resp.StatusCode), resolve)
+ }
+ // Prefer advertised green/code-fast if DB model is bare code-fast (gateway list).
+ if model == "code-fast" && strings.Contains(string(body), `"green/code-fast"`) {
+ client.Model = "green/code-fast"
+ model = client.Model
+ resolve["completer_model_adjusted"] = model
+ log.Printf("adjusted model code-fast -> green/code-fast from /models list")
+ }
+ // Cap per-call wait (~2.5m) and retries so we never sit on 4m×N.
+ if client.HTTPClient != nil {
+ client.HTTPClient.Timeout = 150 * time.Second
+ }
+ client.MaxRetries = 1
+ resolve["client_http_timeout"] = "150s"
+ resolve["client_max_retries"] = 1
+ writeJSON(out, "resolve_quick.json", resolve)
+ log.Printf("LIVE OK models=%d base=%s model=%s", resp.StatusCode, base, model)
+
+ pipeline := processing.NewPipeline(pool)
+ pipeline.BatchSize = cfg.ProcessingBatchSize
+ pipeline.AI = aiSvc
+ pipeline.Prompts = aiprompts.NewService(pool)
+ pipeline.Engine = &processing.Engine{
+ Completer: nil,
+ Vector: processing.NoopVectorCategorizer{},
+ EPREL: eprel.NewClient(eprel.Options{Enabled: true, Timeout: 15 * time.Second}),
+ ProviderMode: processing.AIProviderInternal,
+ }
+
+ rawIDs := make([]uuid.UUID, 0, len(eans))
+ for _, ean := range eans {
+ var id uuid.UUID
+ if err := pool.QueryRow(ctx, `SELECT id FROM raw_products WHERE company_id=$1 AND gtin=$2`, companyID, ean).Scan(&id); err != nil {
+ failReport(out, "FAIL", fmt.Sprintf("raw %s: %v", ean, err), resolve)
+ }
+ rawIDs = append(rawIDs, id)
+ }
+ _, _ = pool.Exec(ctx, `
+ UPDATE processed_products pp
+ SET field_sources = COALESCE(field_sources, '{}'::jsonb) - 'enhance_input_hash', updated_at=now()
+ FROM raw_products rp
+ WHERE pp.company_id=$1 AND pp.raw_product_id=rp.id AND rp.gtin=ANY($2::text[])`, companyID, eans)
+ _, _ = pool.Exec(ctx, `
+ UPDATE processing_jobs SET status='failed', error='yielded to quick final verify', updated_at=now()
+ WHERE company_id=$1 AND status IN ('pending','running','processing')`, companyID)
+
+ jobs, err := pipeline.StartJob(ctx, companyID, userID, rawIDs, "full")
+ if err != nil || len(jobs) == 0 {
+ failReport(out, "FAIL", fmt.Sprintf("StartJob: %v", err), resolve)
+ }
+ jobID := jobs[0].ID
+ var dbStatus string
+ var processedCount int
+ _ = pool.QueryRow(ctx, `SELECT lower(status), COALESCE(processed_count,0) FROM processing_jobs WHERE id=$1`, jobID).Scan(&dbStatus, &processedCount)
+ startOK := (dbStatus == "pending" || dbStatus == "queued") && processedCount == 0
+ startSample := map[string]any{
+ "process_id": jobID.String(), "status": dbStatus, "processed_items": processedCount,
+ "total_items": len(rawIDs), "start_ok": startOK,
+ }
+ writeJSON(out, "start_sample.json", startSample)
+
+ _, _ = pool.Exec(ctx, `UPDATE processing_jobs SET status='running', started_at=COALESCE(started_at,now()), updated_at=now() WHERE id=$1`, jobID)
+
+ // Hard wall: 2 products × ~2.5–3 min ≈ 8 min max
+ runCtx, runCancel := context.WithTimeout(ctx, 8*time.Minute)
+ defer runCancel()
+ procErr := pipeline.ProcessJob(runCtx, jobID)
+ logBytes, _ := os.ReadFile(logPath)
+ logText := string(logBytes)
+ aiEnhance := strings.Contains(logText, "ai_enhance")
+
+ items, loadErr := pipeline.LoadV1ProcessJobItems(ctx, companyID, jobID, "full")
+ if loadErr != nil {
+ items = nil
+ }
+ cards := []map[string]any{}
+ allPass := startOK && aiEnhance && procErr == nil
+ for _, item := range items {
+ ean := str(item["ean"])
+ if ean == "" {
+ ean = str(item["product_id"])
+ }
+ title := first(str(item["name"]), str(item["title"]))
+ desc := str(item["description"])
+ catName := str(item["category_name"])
+ if catName == "" {
+ if m, ok := item["category"].(map[string]any); ok {
+ catName = str(m["name"])
+ }
+ }
+ _, hasMetaT := item["meta_title"]
+ _, hasMetaD := item["meta_description"]
+ _, hasID := item["id"]
+ _, hasPP := item["processed_product_id"]
+ _, hasRaw := item["raw_product_id"]
+ hasHTML := strings.Contains(strings.ToLower(desc), "
")
+ checks := map[string]bool{
+ "name_present": strings.TrimSpace(title) != "",
+ "category_name_present": strings.TrimSpace(catName) != "",
+ "no_meta_title": !hasMetaT || item["meta_title"] == nil,
+ "no_meta_description": !hasMetaD || item["meta_description"] == nil,
+ "no_id": !hasID,
+ "no_processed_product_id": !hasPP,
+ "no_raw_product_id": !hasRaw,
+ "formula_html_or_empty": hasHTML || strings.TrimSpace(desc) == "" || strings.Contains(strings.ToLower(logText), "empty_or_provider_error") || strings.Contains(strings.ToLower(logText), "refuse"),
+ }
+ pass := true
+ for _, v := range checks {
+ if !v {
+ pass = false
+ break
+ }
+ }
+ if !pass {
+ allPass = false
+ }
+ cards = append(cards, map[string]any{
+ "ean": ean, "title": title, "category": catName, "desc_len": len(desc),
+ "has_html": hasHTML, "checks": checks, "pass": pass,
+ })
+ }
+ if procErr != nil {
+ allPass = false
+ }
+ overall := "PASS"
+ reason := ""
+ if !allPass {
+ overall = "FAIL"
+ if procErr != nil {
+ reason = "ProcessJob: " + processing.TruncateError(procErr)
+ } else if !aiEnhance {
+ reason = "no ai_enhance log lines"
+ } else if !startOK {
+ reason = "start semantics not pending/0"
+ } else {
+ reason = "scorecard assertion failed"
+ }
+ }
+
+ report := map[string]any{
+ "generated_at": time.Now().UTC().Format(time.RFC3339),
+ "overall": overall,
+ "blocking_reason": reason,
+ "sync_pass": true,
+ "live_llm_pass": true,
+ "process_run": true,
+ "job_id": jobID.String(),
+ "eans": eans,
+ "start_ok": startOK,
+ "ai_enhance_seen": aiEnhance,
+ "process_error": nil,
+ "scorecards": cards,
+ "llm": resolve,
+ "sync": syncInfo,
+ "note": "slim verify: 2 products, 150s/call, 8m wall; repo seed only",
+ }
+ if procErr != nil {
+ report["process_error"] = processing.TruncateError(procErr)
+ }
+ writeJSON(out, "report.json", report)
+ writeMD(out, report, startSample, cards, logText)
+ fmt.Printf("OVERALL %s job=%s ai_enhance=%v err=%v\n", overall, jobID, aiEnhance, procErr)
+ if overall != "PASS" {
+ os.Exit(1)
+ }
+}
+
+func failReport(out, overall, reason string, extra map[string]any) {
+ report := map[string]any{
+ "generated_at": time.Now().UTC().Format(time.RFC3339),
+ "overall": overall, "blocking_reason": reason,
+ "process_run": false, "extra": extra,
+ }
+ writeJSON(out, "report.json", report)
+ var b strings.Builder
+ b.WriteString("# Final verify 2026-08-17 (quick LIVE)\n\n")
+ b.WriteString(fmt.Sprintf("**Overall: %s**\n\n", overall))
+ b.WriteString(fmt.Sprintf("Blocking: %s\n\n", reason))
+ b.WriteString("Live OverloadedBot only. No mock.\n")
+ _ = os.WriteFile(filepath.Join(out, "REPORT.md"), []byte(b.String()), 0o644)
+ log.Print(reason)
+ os.Exit(1)
+}
+
+func writeMD(out string, report map[string]any, start map[string]any, cards []map[string]any, logText string) {
+ var b strings.Builder
+ b.WriteString("# Final verify 2026-08-17 (quick LIVE)\n\n")
+ b.WriteString(fmt.Sprintf("**Overall: %v**\n\n", report["overall"]))
+ if r := str(report["blocking_reason"]); r != "" {
+ b.WriteString(fmt.Sprintf("Blocking: %s\n\n", r))
+ }
+ b.WriteString("## Progress note\n\n")
+ b.WriteString("Prior hung run killed (4m chat waits on 6 EANs). This pass: live OverloadedBot only, repo seed sync, 2 products, 150s/call, 8m wall.\n\n")
+ b.WriteString("## 1. Sync A1 (repo seed only)\n\n")
+ b.WriteString("- Source: `scripts/seed/wp_product_categories.sql` (no upload, SkipDumpBackfill)\n")
+ b.WriteString("- See `sync_quick.json`\n\n")
+ b.WriteString("## 2. Live LLM\n\n")
+ if llm, ok := report["llm"].(map[string]any); ok {
+ b.WriteString(fmt.Sprintf("- base: `%v`\n- model: `%v`\n- models HTTP: `%v`\n- source: `%v`\n\n",
+ llm["completer_base"], llm["completer_model"], llm["models_http_status"], llm["role_source"]))
+ }
+ b.WriteString("## 3. Start semantics\n\n")
+ b.WriteString(fmt.Sprintf("- process_id: `%v`\n- status: `%v`\n- processed_items: `%v`\n- start_ok: `%v`\n\n",
+ start["process_id"], start["status"], start["processed_items"], start["start_ok"]))
+ b.WriteString(fmt.Sprintf("## 4. ai_enhance\n\n- seen: `%v`\n\n", report["ai_enhance_seen"]))
+ b.WriteString("## 5. Scorecards\n\n")
+ for _, c := range cards {
+ b.WriteString(fmt.Sprintf("- **%v** pass=%v cat=%v html=%v title=%q\n",
+ c["ean"], c["pass"], c["category"], c["has_html"], trunc(str(c["title"]), 60)))
+ }
+ b.WriteString("\n## Log excerpt (ai_enhance)\n\n```\n")
+ n := 0
+ for _, line := range strings.Split(logText, "\n") {
+ if strings.Contains(line, "ai_enhance") || strings.Contains(line, "LIVE") || strings.Contains(line, "OVERALL") {
+ b.WriteString(trunc(line, 240) + "\n")
+ n++
+ if n >= 12 {
+ break
+ }
+ }
+ }
+ b.WriteString("```\n")
+ _ = os.WriteFile(filepath.Join(out, "REPORT.md"), []byte(b.String()), 0o644)
+}
+
+func writeJSON(out, name string, v any) {
+ raw, _ := json.MarshalIndent(v, "", " ")
+ _ = os.WriteFile(filepath.Join(out, name), raw, 0o644)
+}
+func str(v any) string {
+ if v == nil {
+ return ""
+ }
+ if s, ok := v.(string); ok {
+ return s
+ }
+ return strings.TrimSpace(fmt.Sprint(v))
+}
+func first(vals ...string) string {
+ for _, v := range vals {
+ if strings.TrimSpace(v) != "" {
+ return v
+ }
+ }
+ return ""
+}
+func trunc(s string, n int) string {
+ s = strings.TrimSpace(s)
+ if len(s) <= n {
+ return s
+ }
+ return s[:n] + "…"
+}
diff --git a/apps/api/cmd/_llm_probe_tmp/main.go b/apps/api/cmd/_llm_probe_tmp/main.go
new file mode 100644
index 0000000..b67c087
--- /dev/null
+++ b/apps/api/cmd/_llm_probe_tmp/main.go
@@ -0,0 +1,59 @@
+package main
+import (
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "strings"
+ "time"
+ "github.com/descrybe/descrybe-v2/apps/api/internal/aiprovider"
+ "github.com/descrybe/descrybe-v2/apps/api/internal/config"
+ "github.com/descrybe/descrybe-v2/apps/api/internal/db"
+ "github.com/descrybe/descrybe-v2/apps/api/internal/platformsettings"
+ "github.com/descrybe/descrybe-v2/apps/api/internal/processing"
+ "github.com/google/uuid"
+)
+func main() {
+ cfg, err := config.Load()
+ if err != nil { fmt.Fprintf(os.Stderr, "config: %v\n", err); os.Exit(2) }
+ ctx := context.Background()
+ pool, err := db.NewPool(ctx, cfg.DatabaseURL, db.PoolOptions{
+ MaxConns: int32(cfg.DBMaxConns), MinConns: int32(cfg.DBMinConns),
+ MaxConnLifetime: cfg.DBMaxConnLifetime, MaxConnLifetimeJitter: cfg.DBMaxConnLifetimeJitter,
+ MaxConnIdleTime: cfg.DBMaxConnIdleTime, HealthCheckPeriod: cfg.DBHealthCheckPeriod,
+ StatementTimeout: cfg.DBStatementTimeout,
+ })
+ if err != nil { fmt.Fprintf(os.Stderr, "db: %v\n", err); os.Exit(2) }
+ defer pool.Close()
+ platEnv := platformsettings.EnvConfig{AppEncryptionKey: cfg.AppEncryptionKey, CredentialsEncryptionKey: cfg.CredentialsEncryptionKey, TokenSigningSecret: cfg.TokenSigningSecret, DatabaseURL: cfg.DatabaseURL, OpenAIAPIKey: cfg.OpenAIAPIKey, OpenAIBaseURL: cfg.OpenAIBaseURL, OpenAIModel: cfg.OpenAIModel}
+ plat := platformsettings.NewService(pool, platEnv)
+ ai := aiprovider.NewService(pool, aiprovider.EnvConfig{AppEncryptionKey: cfg.AppEncryptionKey, CredentialsEncryptionKey: cfg.CredentialsEncryptionKey, TokenSigningSecret: cfg.TokenSigningSecret, DatabaseURL: cfg.DatabaseURL, OpenAIAPIKey: cfg.OpenAIAPIKey, OpenAIBaseURL: cfg.OpenAIBaseURL, OpenAIModel: cfg.OpenAIModel})
+ ai.Platform = plat
+ companyID := uuid.MustParse("604f23a8-b66e-4b21-8b45-0d72b68f4790")
+ roleCfg, err := plat.ResolveAIConfig(ctx, platformsettings.AIRoleProcessing)
+ if err != nil { fmt.Fprintf(os.Stderr, "ResolveAIConfig: %v\n", err); os.Exit(2) }
+ completer, mode, byok, err := ai.ResolveCompleterForRole(ctx, companyID, aiprovider.RoleProcessing)
+ if err != nil { fmt.Fprintf(os.Stderr, "ResolveCompleter: %v\n", err); os.Exit(2) }
+ client, ok := completer.(*processing.OpenAIClient)
+ if !ok { fmt.Fprintf(os.Stderr, "not OpenAIClient: %T\n", completer); os.Exit(2) }
+ base := strings.TrimRight(strings.TrimSpace(client.BaseURL), "/")
+ modelsURL := base + "/models"
+ fmt.Printf("source=%s provider=%s base=%s model=%s mode=%s byok=%v key_len=%d\n", roleCfg.Source, roleCfg.Provider, base, client.Model, mode, byok, len(client.APIKey))
+ if processing.IsMockOrLoopbackBaseURL(base) { fmt.Println("FAIL mock/loopback"); os.Exit(3) }
+ req, _ := http.NewRequestWithContext(ctx, http.MethodGet, modelsURL, nil)
+ req.Header.Set("Authorization", "Bearer "+client.APIKey)
+ req.Header.Set("Accept", "application/json")
+ start := time.Now()
+ resp, err := http.DefaultClient.Do(req)
+ elapsed := time.Since(start).Round(time.Millisecond)
+ if err != nil { fmt.Printf("models_error=%v elapsed=%s\n", err, elapsed); os.Exit(4) }
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
+ resp.Body.Close()
+ snip := strings.TrimSpace(string(body))
+ if len(snip) > 160 { snip = snip[:160]+"…" }
+ fmt.Printf("models_http=%d elapsed=%s snippet=%q\n", resp.StatusCode, elapsed, snip)
+ if resp.StatusCode == 502 { os.Exit(5) }
+ if resp.StatusCode != 200 && resp.StatusCode != 401 { os.Exit(6) }
+ os.Exit(0)
+}
diff --git a/apps/api/cmd/repair-category-prompts/main.go b/apps/api/cmd/repair-category-prompts/main.go
index dc0afb4..55c7b93 100644
--- a/apps/api/cmd/repair-category-prompts/main.go
+++ b/apps/api/cmd/repair-category-prompts/main.go
@@ -1,7 +1,7 @@
// Command repair-category-prompts rewrites A1 / Platform Demo categories.prompt
// values into aiprompts role-sectioned overlays (Title / Description / Meta /
// Attributes). Prefers wp_product_categories.sql (SEED_A1_WP_CATEGORIES /
-// Downloads) as source of truth, else a1-category-prompts.json. Legacy combined
+// scripts/seed) as source of truth, else a1-category-prompts.json. Legacy combined
// Slovenian name+description blobs are split so naming rules land under Title
// and HTML under Description; otherwise CategoryEnhanceUserTemplate is used.
// Also repairs empty or brand-only title_template and empty description_template
@@ -11,7 +11,7 @@
//
// go run ./cmd/repair-category-prompts -dry-run
// go run ./cmd/repair-category-prompts -apply
-// go run ./cmd/repair-category-prompts -apply -wp-categories "D:/Users/.../Downloads/wp_product_categories.sql"
+// go run ./cmd/repair-category-prompts -apply -wp-categories ../../scripts/seed/wp_product_categories.sql
// go run ./cmd/repair-category-prompts -apply -prompts ../../scripts/seed/a1-category-prompts.json
//
// DATABASE_URL / -postgres required. Default is dry-run (count only).
diff --git a/apps/api/internal/catalog/prompt_repair.go b/apps/api/internal/catalog/prompt_repair.go
index ca9a730..303ca69 100644
--- a/apps/api/internal/catalog/prompt_repair.go
+++ b/apps/api/internal/catalog/prompt_repair.go
@@ -48,11 +48,11 @@ type RepairCategoryEnhancePromptsResult struct {
// RepairA1DemoOptions configures optional seed overlay path for legacy splits.
type RepairA1DemoOptions struct {
// SeedPromptsPath points at a1-category-prompts.json OR wp_product_categories.sql.
- // Empty → auto-detect WP SQL (SEED_A1_WP_CATEGORIES / Downloads), else JSON seed.
+ // Empty → auto-detect WP SQL (SEED_A1_WP_CATEGORIES / scripts/seed), else JSON seed.
SeedPromptsPath string
// WPCategoriesPath forces wp_product_categories.sql (overrides SeedPromptsPath when set).
WPCategoriesPath string
- // WPCategoriesSQL is uploaded dump bytes (primary for Admin Sync A1 in prod).
+ // WPCategoriesSQL is optional dump bytes (legacy Admin Sync A1 upload / API clients).
// When non-empty, takes precedence over filesystem auto-detect / path options.
WPCategoriesSQL []byte
// ForceFromSeed overwrites already-sectioned prompts when a seed match exists
@@ -63,7 +63,7 @@ type RepairA1DemoOptions struct {
// RepairA1DemoCategoryEnhancePrompts replaces non-sectioned / legacy combined
// categories.prompt values for A1 Slovenija + Platform Demo with role-sectioned
// overlays (Title / Description / Meta / Attributes). Prefers wp_product_categories.sql
-// (SEED_A1_WP_CATEGORIES / Downloads) as source of truth, then a1-category-prompts.json,
+// (SEED_A1_WP_CATEGORIES / scripts/seed) as source of truth, then a1-category-prompts.json,
// splitting combined Name+Description prompts so naming rules land under Title and
// HTML body under Description; otherwise fall back to CategoryEnhanceUserTemplate.
//
diff --git a/apps/api/internal/catalog/wp_category_prompts.go b/apps/api/internal/catalog/wp_category_prompts.go
index 87ae222..99c825a 100644
--- a/apps/api/internal/catalog/wp_category_prompts.go
+++ b/apps/api/internal/catalog/wp_category_prompts.go
@@ -18,8 +18,9 @@ const (
)
// ResolveWPCategoryPromptsPath picks an explicit path, else the first readable
-// wp_product_categories.sql candidate (SEED_A1_WP_CATEGORIES + Downloads + scripts/seed).
-// Used by Sync A1 / RepairCompanyCategoryEnhancePrompts as the A1 prompt source of truth.
+// wp_product_categories.sql candidate (SEED_A1_WP_CATEGORIES, then scripts/seed,
+// then optional local Downloads as last-resort). Used by Sync A1 /
+// RepairCompanyCategoryEnhancePrompts as the A1 prompt source of truth — no UI upload required.
func ResolveWPCategoryPromptsPath(explicit string) string {
if p := strings.TrimSpace(explicit); p != "" {
if st, err := os.Stat(p); err == nil && !st.IsDir() {
@@ -31,7 +32,7 @@ func ResolveWPCategoryPromptsPath(explicit string) string {
if st, err := os.Stat(v); err == nil && !st.IsDir() {
return v
}
- log.Printf("warning: %s=%q not readable — trying Downloads / scripts/seed", envSeedA1WPCategories, v)
+ log.Printf("warning: %s=%q not readable — trying scripts/seed", envSeedA1WPCategories, v)
}
for _, c := range WPCategoryPromptsCandidates() {
if st, err := os.Stat(c); err == nil && !st.IsDir() {
@@ -42,7 +43,8 @@ func ResolveWPCategoryPromptsPath(explicit string) string {
}
// WPCategoryPromptsCandidates lists local paths Sync A1 / repair try when
-// SEED_A1_WP_CATEGORIES is unset. First readable file wins via ResolveWPCategoryPromptsPath.
+// SEED_A1_WP_CATEGORIES is unset. Prefer committed scripts/seed first; Downloads
+// is last-resort only. First readable file wins via ResolveWPCategoryPromptsPath.
func WPCategoryPromptsCandidates() []string {
var out []string
if v := strings.TrimSpace(os.Getenv(envSeedA1WPCategories)); v != "" {
@@ -53,13 +55,27 @@ func WPCategoryPromptsCandidates() []string {
"wp_product_categories (1).sql",
"wp_product_categories(1).sql",
}
+ // Repo seed is the default source of truth (no upload / Downloads required).
+ if root, ok := findMonorepoRootFromCwd(); ok {
+ for _, n := range names {
+ out = append(out, filepath.Join(root, "scripts", "seed", n))
+ }
+ }
+ for _, n := range names {
+ out = append(out,
+ filepath.Join("scripts", "seed", n),
+ filepath.Join("..", "..", "scripts", "seed", n),
+ n,
+ filepath.Join("..", "..", n),
+ )
+ }
+ // Optional local Downloads fallback (legacy / one-off machines without seed).
home, _ := os.UserHomeDir()
if home != "" {
for _, n := range names {
out = append(out, filepath.Join(home, "Downloads", n))
out = append(out, filepath.Join(home, "downloads", n))
}
- // Windows secondary profile Downloads (e.g. D:\Users\…\Downloads).
for _, driveRoot := range []string{`D:\`, `C:\`} {
alt := filepath.Join(driveRoot, "Users", filepath.Base(home), "Downloads")
for _, n := range names {
@@ -67,19 +83,6 @@ func WPCategoryPromptsCandidates() []string {
}
}
}
- for _, n := range names {
- out = append(out,
- n,
- filepath.Join("..", "..", n),
- filepath.Join("scripts", "seed", n),
- filepath.Join("..", "..", "scripts", "seed", n),
- )
- }
- if root, ok := findMonorepoRootFromCwd(); ok {
- for _, n := range names {
- out = append(out, filepath.Join(root, "scripts", "seed", n))
- }
- }
return out
}
diff --git a/apps/api/internal/catalog/wp_category_prompts_test.go b/apps/api/internal/catalog/wp_category_prompts_test.go
index 59de3be..5963029 100644
--- a/apps/api/internal/catalog/wp_category_prompts_test.go
+++ b/apps/api/internal/catalog/wp_category_prompts_test.go
@@ -81,14 +81,10 @@ func TestParseWPProductCategoriesSQL_SlusalkeSplit(t *testing.T) {
}
}
-func TestParseWPProductCategoriesSQL_RealDownloadsFile(t *testing.T) {
- path := ResolveWPCategoryPromptsPath(`d:\Users\Green Eclipse\Downloads\wp_product_categories.sql`)
+func TestParseWPProductCategoriesSQL_RealSeedFile(t *testing.T) {
+ path := ResolveWPCategoryPromptsPath("")
if path == "" {
- // Also try env / auto-detect without failing CI machines that lack the dump.
- path = ResolveWPCategoryPromptsPath("")
- }
- if path == "" {
- t.Skip("wp_product_categories.sql not available on this machine")
+ t.Skip("wp_product_categories.sql not available (expected under scripts/seed)")
}
byNorm, _, err := loadWPCategoryPromptOverlays(path)
if err != nil {
@@ -109,6 +105,10 @@ func TestParseWPProductCategoriesSQL_RealDownloadsFile(t *testing.T) {
if !strings.Contains(split, "tip izdelka lowercase") && !strings.Contains(split, "znamka") {
t.Fatalf("unexpected Title rules in split: %s", split[:min(400, len(split))])
}
+ if !strings.Contains(filepath.ToSlash(path), "scripts/seed/wp_product_categories.sql") &&
+ filepath.Base(path) != "wp_product_categories.sql" {
+ t.Logf("resolved path %s (prefer scripts/seed when present)", path)
+ }
}
func TestResolveWPCategoryPromptsPath_Explicit(t *testing.T) {
diff --git a/apps/api/internal/company/weak_desc.go b/apps/api/internal/company/weak_desc.go
index 082517f..f115e53 100644
--- a/apps/api/internal/company/weak_desc.go
+++ b/apps/api/internal/company/weak_desc.go
@@ -32,9 +32,9 @@ func IsWeakPriorEnhanceDescription(priorDesc string, titles ...string) bool {
}
// heuristicSynthesizePhrases are distinctive invent / formula-skeleton snippets from
-// synthesizeDescriptionFromTitle / synthesizeDescriptionFromFormula. These may look
-// "strong" enough to pass IsWeakPriorEnhanceDescription but must never hash-skip
-// enhance (would leave fallback copy forever on reprocess).
+// synthesizeDescriptionFromTitle / synthesizeDescriptionFromFormula / factualDescriptionIntro.
+// These may look "strong" enough to pass IsWeakPriorEnhanceDescription but must never
+// hash-skip enhance (would leave fallback copy forever on reprocess).
var heuristicSynthesizePhrases = []string{
"is a catalog product with the known attributes",
"is listed in the ",
@@ -43,23 +43,63 @@ var heuristicSynthesizePhrases = []string{
"je izdelek v kategoriji",
"je izdelek znamke",
". ključne specifikacije:",
+ // factualDescriptionIntro (post-phrase-avoidance invent)
+ " — znamka ",
+ ", znamka ",
+ " — katalogski izdelek",
+ " — from ",
+ " — catalog product",
}
// LooksLikeHeuristicSynthesize reports invent / formula-skeleton fallback copy.
func LooksLikeHeuristicSynthesize(desc string) bool {
- lower := strings.ToLower(desc)
+ plain := strings.ToLower(plainTextForWeakCheck(desc))
+ if plain == "" {
+ return false
+ }
for _, p := range heuristicSynthesizePhrases {
- if p != "" && strings.Contains(lower, p) {
+ if p != "" && strings.Contains(plain, p) {
return true
}
}
// EN invent: " is a product from "
- if strings.Contains(lower, " is a ") && strings.Contains(lower, " product from ") {
+ if strings.Contains(plain, " is a ") && strings.Contains(plain, " product from ") {
+ return true
+ }
+ // SL/CS short invent: " je … znamk|kategor|produkt …"
+ if strings.Contains(plain, " je ") &&
+ (strings.Contains(plain, "znamk") ||
+ strings.Contains(plain, "kategor") ||
+ strings.Contains(plain, "produkt") ||
+ strings.Contains(plain, "televiz") ||
+ strings.Contains(plain, "monitor")) {
return true
}
return false
}
+// plainTextForWeakCheck strips HTML tags so
thin invent
is judged on visible copy.
+func plainTextForWeakCheck(s string) string {
+ s = strings.TrimSpace(s)
+ if s == "" {
+ return ""
+ }
+ var b strings.Builder
+ b.Grow(len(s))
+ inTag := false
+ for _, r := range s {
+ switch {
+ case r == '<':
+ inTag = true
+ case r == '>':
+ inTag = false
+ case !inTag:
+ b.WriteRune(r)
+ }
+ }
+ return strings.TrimSpace(b.String())
+}
+
// EnhanceHashSkipBlockReason returns a stable reason when prior description must
// not hash-skip the enhance LLM: "weak", "title-echo", "synth", or "".
func EnhanceHashSkipBlockReason(priorDesc string, titles ...string) string {
@@ -67,22 +107,26 @@ func EnhanceHashSkipBlockReason(priorDesc string, titles ...string) string {
if priorDesc == "" || priorDesc == "" {
return "weak"
}
- if len([]rune(priorDesc)) < minUsableProductDescRunes {
+ plain := plainTextForWeakCheck(priorDesc)
+ if plain == "" || plain == "" {
+ return "weak"
+ }
+ if len([]rune(plain)) < minUsableProductDescRunes {
return "weak"
}
for _, title := range titles {
- if DescriptionEchoesTitle(priorDesc, title) {
+ if DescriptionEchoesTitle(plain, title) || DescriptionEchoesTitle(priorDesc, title) {
return "title-echo"
}
}
- if ContainsWeakFillerPhrase(priorDesc) {
+ if ContainsWeakFillerPhrase(plain) || ContainsWeakFillerPhrase(priorDesc) {
return "weak"
}
if LooksLikeHeuristicSynthesize(priorDesc) {
return "synth"
}
- if len([]rune(priorDesc)) <= shortBoilerplateDescRunes &&
- !descriptionOverlapsProductFacts(priorDesc, titles...) {
+ if len([]rune(plain)) <= shortBoilerplateDescRunes &&
+ !descriptionOverlapsProductFacts(plain, titles...) {
return "weak"
}
return ""
diff --git a/apps/api/internal/company/weak_desc_test.go b/apps/api/internal/company/weak_desc_test.go
index eca4c50..82943ba 100644
--- a/apps/api/internal/company/weak_desc_test.go
+++ b/apps/api/internal/company/weak_desc_test.go
@@ -13,6 +13,17 @@ func TestLooksLikeHeuristicSynthesize(t *testing.T) {
if !LooksLikeHeuristicSynthesize(sl) {
t.Fatalf("expected SL invent detected: %q", sl)
}
+ htmlInvent := "
QLED TV 55 je televizor znamke Samsung.
"
+ if !LooksLikeHeuristicSynthesize(htmlInvent) {
+ t.Fatalf("expected HTML-wrapped invent detected: %q", htmlInvent)
+ }
+ if !ShouldRefuseEnhanceHashSkip(htmlInvent, "QLED TV 55") {
+ t.Fatalf("HTML invent must block hash skip")
+ }
+ factual := "QLED TV 55 — Televizorji, znamka Samsung."
+ if !LooksLikeHeuristicSynthesize(factual) {
+ t.Fatalf("expected factualDescriptionIntro invent detected: %q", factual)
+ }
good := "Vogel's WALL 3245 is a sturdy TV mount with clear load ratings and install guidance for wall displays."
if LooksLikeHeuristicSynthesize(good) {
t.Fatalf("retail copy must not look like invent: %q", good)
diff --git a/apps/api/internal/httpapi/admin_authz_test.go b/apps/api/internal/httpapi/admin_authz_test.go
index 1f25f50..67f485b 100644
--- a/apps/api/internal/httpapi/admin_authz_test.go
+++ b/apps/api/internal/httpapi/admin_authz_test.go
@@ -27,6 +27,7 @@ func TestMemberForbiddenOnSensitiveMutations(t *testing.T) {
body string
}{
{name: "create_api_key", fn: s.handleCreateAPIKey, body: `{"name":"x"}`},
+ {name: "update_api_key", fn: s.handleUpdateAPIKey, body: `{"name":"renamed"}`},
{name: "revoke_api_key", fn: s.handleRevokeAPIKey, body: ""},
{name: "put_email", fn: s.handlePutEmailIntegration, body: `{}`},
{name: "verify_email", fn: s.handleVerifyEmailIntegration, body: ""},
diff --git a/apps/api/internal/httpapi/admin_fix_catalog_handlers.go b/apps/api/internal/httpapi/admin_fix_catalog_handlers.go
index 2ee4931..f57cf02 100644
--- a/apps/api/internal/httpapi/admin_fix_catalog_handlers.go
+++ b/apps/api/internal/httpapi/admin_fix_catalog_handlers.go
@@ -29,11 +29,13 @@ const syncA1MaxBodyBytes = catalog.MaxWPCategorySQLBytes + (2 << 20)
//
// Body (JSON): confirm=true required; backfill_categories (default true);
// reprocess_sample_limit (default 25, max 200); mysql_dump optional path;
-// skip_dump_backfill (default false); wp_product_categories_sql_b64 optional
-// base64 of wp_product_categories.sql (primary for category prompts).
+// skip_dump_backfill (default false). Category prompts load from repo seed
+// (scripts/seed/wp_product_categories.sql) via ResolveWPCategoryPromptsPath;
+// wp_product_categories_sql_b64 remains accepted but unused by the admin UI.
//
-// Body (multipart/form-data): same fields as form values; file field
-// wp_product_categories or wp_categories_sql for the SQL dump upload.
+// Body (multipart/form-data): same fields as form values; optional file field
+// wp_product_categories / wp_categories_sql is still accepted for API clients
+// but the admin Sync A1 UI no longer uploads.
//
// Flash (UI): result → flash.admin.syncA1Success.
func (s *Server) handleAdminSyncCompanyA1(w http.ResponseWriter, r *http.Request) {
@@ -116,8 +118,10 @@ func (s *Server) handleAdminSyncCompanyA1(w http.ResponseWriter, r *http.Request
}
if len(parsed.WPCategoriesSQL) > 0 {
note = "Applied uploaded wp_product_categories.sql (Title/Description/Meta/Attributes sections) + catalog hygiene. Reprocess recommended products manually (no mass reprocess)."
- } else if strings.TrimSpace(result.WPCategoriesPath) == "" {
- note += " Upload wp_product_categories.sql on Sync A1 for force-applied category prompts when the dump is not on the API host."
+ } else if strings.TrimSpace(result.WPCategoriesPath) != "" {
+ note = "Applied category prompts from repo seed (" + result.WPCategoriesPath + ") + catalog hygiene. Reprocess recommended products manually (no mass reprocess)."
+ } else {
+ note += " Place scripts/seed/wp_product_categories.sql on the API host (or set SEED_A1_WP_CATEGORIES) for force-applied category prompts."
}
JSON(w, http.StatusOK, map[string]any{
diff --git a/apps/api/internal/httpapi/apikey_handlers.go b/apps/api/internal/httpapi/apikey_handlers.go
index d428278..581667c 100644
--- a/apps/api/internal/httpapi/apikey_handlers.go
+++ b/apps/api/internal/httpapi/apikey_handlers.go
@@ -1,11 +1,14 @@
package httpapi
import (
+ "errors"
"net/http"
+ "strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
+ "github.com/jackc/pgx/v5"
)
func (s *Server) handleListAPIKeys(w http.ResponseWriter, r *http.Request) {
@@ -80,6 +83,61 @@ func (s *Server) handleCreateAPIKey(w http.ResponseWriter, r *http.Request) {
})
}
+func (s *Server) handleUpdateAPIKey(w http.ResponseWriter, r *http.Request) {
+ cid, _ := CompanyIDFromContext(r.Context())
+ if !s.allowCompanyAdminOrPlatform(w, r) {
+ return
+ }
+ if !s.requireFeatures(w, r, "settings.api_keys", "capability.api_access") {
+ return
+ }
+ id, err := uuid.Parse(chi.URLParam(r, "id"))
+ if err != nil {
+ Error(w, http.StatusBadRequest, "invalid id")
+ return
+ }
+ var body struct {
+ Name string `json:"name"`
+ }
+ if err := DecodeJSON(r, &body); err != nil {
+ Error(w, http.StatusBadRequest, "invalid json")
+ return
+ }
+ name := strings.TrimSpace(body.Name)
+ if name == "" {
+ Error(w, http.StatusBadRequest, "name required")
+ return
+ }
+ if len(name) > 120 {
+ Error(w, http.StatusBadRequest, "name too long")
+ return
+ }
+ var (
+ outID uuid.UUID
+ outName string
+ prefix string
+ lastUsed any
+ created any
+ )
+ err = s.Pool.QueryRow(r.Context(), `
+ UPDATE api_keys
+ SET name = $3, updated_at = now()
+ WHERE id = $1 AND company_id = $2 AND revoked_at IS NULL
+ RETURNING id, name, key_prefix, last_used_at, created_at`,
+ id, cid, name).Scan(&outID, &outName, &prefix, &lastUsed, &created)
+ if errors.Is(err, pgx.ErrNoRows) {
+ Error(w, http.StatusNotFound, "not found")
+ return
+ }
+ if err != nil {
+ Error(w, http.StatusInternalServerError, "update failed")
+ return
+ }
+ JSON(w, http.StatusOK, map[string]any{
+ "id": outID, "name": outName, "key_prefix": prefix, "last_used_at": lastUsed, "created_at": created,
+ })
+}
+
func (s *Server) handleRevokeAPIKey(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
if !s.allowCompanyAdminOrPlatform(w, r) {
diff --git a/apps/api/internal/httpapi/apikey_handlers_test.go b/apps/api/internal/httpapi/apikey_handlers_test.go
new file mode 100644
index 0000000..a3b92fb
--- /dev/null
+++ b/apps/api/internal/httpapi/apikey_handlers_test.go
@@ -0,0 +1,57 @@
+package httpapi
+
+import (
+ "bytes"
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/go-chi/chi/v5"
+ "github.com/google/uuid"
+)
+
+func TestHandleUpdateAPIKeyRejectsEmptyName(t *testing.T) {
+ t.Parallel()
+ s := &Server{}
+ cid := uuid.MustParse("11111111-1111-1111-1111-111111111111")
+ uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
+ keyID := uuid.MustParse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")
+
+ ctx := context.WithValue(context.Background(), ctxUserID, uid)
+ ctx = context.WithValue(ctx, ctxCompanyID, cid)
+ ctx = context.WithValue(ctx, ctxRole, "admin")
+
+ rctx := chi.NewRouteContext()
+ rctx.URLParams.Add("id", keyID.String())
+ ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx)
+
+ req := httptest.NewRequest(http.MethodPatch, "/api/api-keys/"+keyID.String(), bytes.NewBufferString(`{"name":" "}`)).WithContext(ctx)
+ rec := httptest.NewRecorder()
+ s.handleUpdateAPIKey(rec, req)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d body=%s, want 400", rec.Code, rec.Body.String())
+ }
+}
+
+func TestHandleUpdateAPIKeyRejectsInvalidID(t *testing.T) {
+ t.Parallel()
+ s := &Server{}
+ cid := uuid.MustParse("11111111-1111-1111-1111-111111111111")
+ uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
+
+ ctx := context.WithValue(context.Background(), ctxUserID, uid)
+ ctx = context.WithValue(ctx, ctxCompanyID, cid)
+ ctx = context.WithValue(ctx, ctxRole, "admin")
+
+ rctx := chi.NewRouteContext()
+ rctx.URLParams.Add("id", "not-a-uuid")
+ ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx)
+
+ req := httptest.NewRequest(http.MethodPatch, "/api/api-keys/not-a-uuid", bytes.NewBufferString(`{"name":"ok"}`)).WithContext(ctx)
+ rec := httptest.NewRecorder()
+ s.handleUpdateAPIKey(rec, req)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d body=%s, want 400", rec.Code, rec.Body.String())
+ }
+}
diff --git a/apps/api/internal/httpapi/auth_session_integration_test.go b/apps/api/internal/httpapi/auth_session_integration_test.go
index 18e624e..1ebba4f 100644
--- a/apps/api/internal/httpapi/auth_session_integration_test.go
+++ b/apps/api/internal/httpapi/auth_session_integration_test.go
@@ -247,6 +247,15 @@ func TestAuthSessionCoreEndpoints(t *testing.T) {
t.Fatalf("create api-key payload=%v", created)
}
+ rec = do(http.MethodPatch, "/api/api-keys/"+keyID, `{"name":"auth-smoke-key-renamed"}`, true)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("rename api-key status=%d body=%s", rec.Code, rec.Body.String())
+ }
+ renamed := decode(t, rec)
+ if fmt.Sprint(renamed["name"]) != "auth-smoke-key-renamed" {
+ t.Fatalf("rename api-key name=%v", renamed["name"])
+ }
+
// Public v1 with the new key (CSRF skipped).
v1 := httptest.NewRecorder()
v1Req := httptest.NewRequest(http.MethodGet, "/api/v1/products?limit=1", nil)
diff --git a/apps/api/internal/httpapi/server.go b/apps/api/internal/httpapi/server.go
index 9da6466..b248733 100644
--- a/apps/api/internal/httpapi/server.go
+++ b/apps/api/internal/httpapi/server.go
@@ -453,6 +453,7 @@ func (s *Server) Router() http.Handler {
r.Get("/api-keys", s.handleListAPIKeys)
r.Post("/api-keys", s.handleCreateAPIKey)
+ r.Patch("/api-keys/{id}", s.handleUpdateAPIKey)
r.Delete("/api-keys/{id}", s.handleRevokeAPIKey)
r.Get("/billing/credits", s.handleCreditsOverview)
diff --git a/apps/api/internal/httpapi/v1_openapi.go b/apps/api/internal/httpapi/v1_openapi.go
index c256068..8219bbc 100644
--- a/apps/api/internal/httpapi/v1_openapi.go
+++ b/apps/api/internal/httpapi/v1_openapi.go
@@ -118,8 +118,9 @@ info:
- GET /products data[].id = processed_products.id (enriched row)
- GET /products data[].raw_product_id = raw_products.id (use this for raw_product_ids)
- POST ... raw_product_ids[] must be raw_products.id — never PresentProduct.id
- - GET /products/process/{id} COMPLETED items[].id = processed_products.id (legacy);
- additive processed_product_id (same as id) and raw_product_id (raw_products.id)
+ - GET /products/process/{id} COMPLETED items[] omit internal UUIDs (id /
+ processed_product_id / raw_product_id). Use GET /products when a UUID is needed.
+ Display name is items[].name (title omitted when identical).
Note: Dashboard JSON under /api/* uses session cookies + CSRF and is separate
from this public API-key surface. Other legacy path aliases
@@ -895,9 +896,10 @@ paths:
Completed jobs return items[] (EAN-keyed enrichment). In-progress and failed
jobs omit items. Not the same shape as GET /process/{id} (flat ProcessingJob).
- On COMPLETED items, id is the processed_products UUID (legacy). Additive aliases:
- processed_product_id (same value as id), raw_product_id (raw_products.id), and
- name (same value as title) for dual-mode clients / scorecards.
+ On COMPLETED items, name is the product display name (title is omitted when
+ identical). Internal UUIDs (id / processed_product_id / raw_product_id) are
+ omitted — use GET /products for those. A1 cohort / Platform Demo / A1-prompt
+ companies omit meta_title and meta_description even if stored.
parameters:
- name: id
in: path
@@ -925,17 +927,11 @@ paths:
processing_type: full
items:
- ean: '8606019604493'
- id: bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb
- processed_product_id: bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb
- raw_product_id: cccccccc-cccc-cccc-cccc-cccccccccccc
status: processed
category: Cookers
category_id: '50'
category_name: Cookers
- title: VOX electric cooker EHT 6020 WG
name: VOX electric cooker EHT 6020 WG
- meta_title: VOX electric cooker EHT 6020 WG | 50
- meta_description: Affordable electric cooker with four plates and a 65 L fan oven.
description: "Vox Electronics electric cooker EHT 6020 WG offers strong value with four electric hobs and a 65 L fan oven."
attributes:
brand: Vox
@@ -6207,12 +6203,24 @@ components:
process_id:
type: string
format: uuid
+ status:
+ type: string
+ description: |
+ Job lifecycle status on enqueue. Always pending (or processing if the
+ worker already picked it up). Never COMPLETED on start — poll GET
+ /products/process/{id} for completion.
+ example: pending
message:
type: string
total_items:
type: integer
+ description: Number of products accepted into the job (enqueue size).
processed_items:
type: integer
+ description: |
+ Count of items that finished processing. Always 0 on start while the
+ job is pending/processing; increments as the worker completes products.
+ example: 0
job_count:
type: integer
description: Present when StartJob auto-splits
@@ -6282,35 +6290,19 @@ components:
expose category as the human-readable display name (category_id holds
categories.unique_id), a description string that may include category formula
HTML (h1/h2/h3/h4, p, ul — never a JSON array), optional SEO meta_title /
- meta_description (plain text; omitted for A1 cohort), optional eprel object or
- null, clean attributes, images, and dual-mode ids.
- Product display name is title; additive name mirrors the same processed title
- (dual-mode for scorecards / legacy clients that read name).
- Property order prefers human-readable fields first (ean, title, category,
- description, attributes, images, eprel) with internal ids last.
+ meta_description (plain text; omitted for A1 cohort, Platform Demo, and any
+ company with A1-style category role-section prompts — even if stored in DB),
+ optional eprel object or null, clean attributes, and images.
+ Product display name is name (primary). title is omitted when identical to name.
+ Internal UUIDs (id, processed_product_id, raw_product_id) are omitted from this
+ public shape — use catalog APIs when a product UUID is required.
+ Property order prefers human-readable fields first (ean, name, category,
+ description, attributes, images, eprel).
required:
- ean
properties:
ean:
type: string
- id:
- type: string
- format: uuid
- description: |
- Legacy field: processed_products.id when enrichment succeeded.
- Do not treat as raw_products.id. Same value as processed_product_id.
- processed_product_id:
- type: string
- format: uuid
- description: |
- Explicit alias of id (processed_products.id). Prefer this name in new
- dual-mode clients; id remains for backward compatibility.
- raw_product_id:
- type: string
- format: uuid
- description: |
- raw_products.id for this job line. Use with POST /process raw_product_ids
- or dashboard catalog APIs. Present whenever the job product row exists.
category:
type: string
nullable: true
@@ -6330,32 +6322,32 @@ components:
nullable: true
description: |
Human-readable category display name (mirrors category when both are set).
- title:
- type: string
- nullable: true
- description: |
- Product display name (processed title). Primary legacy field; same value
- as name when present.
name:
type: string
nullable: true
description: |
- Additive alias of title (same processed display name). Prefer title in
- new clients; name remains for scorecards and legacy readers.
+ Product display name (processed title). Primary field for clients.
+ title:
+ type: string
+ nullable: true
+ description: |
+ Optional legacy alias of name. Omitted when identical to name.
meta_title:
type: string
nullable: true
description: |
SEO title. Filled from processing meta or synthesized from title / category
when empty so successful items are not left with null meta.
- Omitted for A1 cohort (SEO meta is not used).
+ Omitted for A1 cohort, Platform Demo, and companies with A1-style category
+ prompts (SEO meta is not used).
meta_description:
type: string
nullable: true
description: |
SEO description (word-safe truncate). Distinct from body description when
possible; synthesized from plain description when DB meta is empty.
- Omitted for A1 cohort (SEO meta is not used).
+ Omitted for A1 cohort, Platform Demo, and companies with A1-style category
+ prompts (SEO meta is not used).
description:
type: string
nullable: true
@@ -6477,17 +6469,11 @@ components:
processing_type: full
items:
- ean: '8606019604493'
- id: bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb
- processed_product_id: bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb
- raw_product_id: cccccccc-cccc-cccc-cccc-cccccccccccc
status: processed
category: Cookers
category_id: '50'
category_name: Cookers
- title: VOX electric cooker EHT 6020 WG
name: VOX electric cooker EHT 6020 WG
- meta_title: VOX electric cooker EHT 6020 WG | 50
- meta_description: Affordable electric cooker with four plates and a 65 L fan oven.
description: "Vox Electronics electric cooker EHT 6020 WG offers strong value with four electric hobs and a 65 L fan oven. Energy class A with practical everyday capacity."
attributes:
brand: Vox
diff --git a/apps/api/internal/httpapi/v1_process_handlers.go b/apps/api/internal/httpapi/v1_process_handlers.go
index a5b9313..c4a08a2 100644
--- a/apps/api/internal/httpapi/v1_process_handlers.go
+++ b/apps/api/internal/httpapi/v1_process_handlers.go
@@ -160,11 +160,14 @@ func (s *Server) handleV1StartProcess(w http.ResponseWriter, r *http.Request) {
}
primary := jobs[0]
+ // processed_items is the completed count — stay 0 until the worker finishes.
+ // total_items is the enqueue size; status stays pending/processing until poll COMPLETED.
resp := map[string]any{
"process_id": primary.ID.String(),
+ "status": "pending",
"message": fmt.Sprintf("Processing started for %d product(s)", len(rawIDs)),
"total_items": totalItems,
- "processed_items": len(rawIDs),
+ "processed_items": 0,
}
if len(jobs) > 1 {
siblings := make([]string, 0, len(jobs)-1)
diff --git a/apps/api/internal/httpapi/v1_process_handlers_test.go b/apps/api/internal/httpapi/v1_process_handlers_test.go
index ec2f94b..2a072a9 100644
--- a/apps/api/internal/httpapi/v1_process_handlers_test.go
+++ b/apps/api/internal/httpapi/v1_process_handlers_test.go
@@ -194,6 +194,7 @@ func TestHandleV1StartProcessItemsEANReturnsProcessID(t *testing.T) {
var envelope struct {
Data struct {
ProcessID string `json:"process_id"`
+ Status string `json:"status"`
Message string `json:"message"`
TotalItems int `json:"total_items"`
ProcessedItems int `json:"processed_items"`
@@ -205,8 +206,11 @@ func TestHandleV1StartProcessItemsEANReturnsProcessID(t *testing.T) {
if envelope.Data.ProcessID != jobID.String() {
t.Fatalf("process_id=%q want %s", envelope.Data.ProcessID, jobID)
}
- if envelope.Data.TotalItems != 1 || envelope.Data.ProcessedItems != 1 {
- t.Fatalf("counts=%+v", envelope.Data)
+ if envelope.Data.Status != "pending" {
+ t.Fatalf("status=%q want pending", envelope.Data.Status)
+ }
+ if envelope.Data.TotalItems != 1 || envelope.Data.ProcessedItems != 0 {
+ t.Fatalf("counts=%+v (processed_items must be 0 on start)", envelope.Data)
}
if enqueued != jobID {
t.Fatalf("enqueued=%s", enqueued)
diff --git a/apps/api/internal/processing/steps.go b/apps/api/internal/processing/steps.go
index a59b140..7add4b8 100644
--- a/apps/api/internal/processing/steps.go
+++ b/apps/api/internal/processing/steps.go
@@ -528,11 +528,10 @@ func (e *Engine) RunSteps(ctx context.Context, companyID string, in ProductInput
}
}
- // Pipelines without StepCategorize (enhance_only / normalize_only) still try
- // vector categorize when AllowAI + embeddings are available. Full/categorize
- // already ran runCategorizeStep (vector then LLM) inside the loop.
+ // Pipelines without StepCategorize (enhance_only / normalize_only) still run
+ // vector + LLM categorize when category is empty so enhance is not fed a blank.
if !stepsContain(steps, StepCategorize) {
- tryVectorCategorize(ctx, e, companyID, &out, categoryNames, policy)
+ runCategorizeStep(ctx, e, companyID, &out, in, categoryNames, policy)
noteMissingCategory(&out, policy, e != nil && e.Vector != nil && e.Vector.Enabled())
}
preserveCategoryIfEmpty(&out, in.PriorCategory)
diff --git a/apps/api/internal/processing/v1_legacy.go b/apps/api/internal/processing/v1_legacy.go
index e8f7de5..d182640 100644
--- a/apps/api/internal/processing/v1_legacy.go
+++ b/apps/api/internal/processing/v1_legacy.go
@@ -261,6 +261,8 @@ func (p *Pipeline) LoadV1ProcessJobItems(ctx context.Context, companyID, jobID u
); err != nil {
return nil, err
}
+ _ = processedID
+ _ = rawProductID
if processedID == nil {
st := MapV1JobItemStatus(itemStatus, false)
if st == "processed" || st == "processing" || st == "pending" {
@@ -274,7 +276,6 @@ func (p *Pipeline) LoadV1ProcessJobItems(ctx context.Context, companyID, jobID u
if itemError != nil && *itemError != "" {
item["error"] = *itemError
}
- applyV1ProcessItemIDs(item, nil, rawProductID)
full = append(full, item)
continue
}
@@ -428,9 +429,9 @@ func (p *Pipeline) LoadV1ProcessJobItems(ctx context.Context, companyID, jobID u
catIDOut = catStr
}
- var titleOut any
+ var nameOut any
if titleStr != "" {
- titleOut = titleStr
+ nameOut = titleStr
}
item := V1ProcessJobItem{
"ean": ean,
@@ -438,8 +439,7 @@ func (p *Pipeline) LoadV1ProcessJobItems(ctx context.Context, companyID, jobID u
"category": catNameOut,
"category_id": catIDOut,
"category_name": catNameOut,
- "title": titleOut,
- "name": titleOut,
+ "name": nameOut,
"description": description,
"attributes": nil,
"main_image": nil,
@@ -450,7 +450,8 @@ func (p *Pipeline) LoadV1ProcessJobItems(ctx context.Context, companyID, jobID u
item["meta_title"] = metaTitleOut
item["meta_description"] = metaDescOut
}
- applyV1ProcessItemIDs(item, processedID, rawProductID)
+ // Internal ids (id / processed_product_id / raw_product_id) are omitted from
+ // the public V1 process item shape — use catalog APIs when a UUID is needed.
if itemError != nil && *itemError != "" {
item["error"] = *itemError
}
@@ -483,20 +484,41 @@ func nullIfEmptyPtr(s *string) any {
return *s
}
-// companyOmitsSEOMeta is true for the A1 cohort (no meta_title / meta_description).
+// companyOmitsSEOMeta is true when V1/process must omit meta_title / meta_description:
+// A1 cohort (legacy id), Platform Demo (A1-cloned prompts, no legacy id), or any
+// company whose categories store A1-style role-section enhance prompts.
func companyOmitsSEOMeta(ctx context.Context, p *Pipeline, companyID uuid.UUID) bool {
if p == nil || p.Pool == nil {
return false
}
- var legacy string
+ var legacy, name string
err := p.Pool.QueryRow(ctx, `
- SELECT COALESCE(legacy_company_id, '')
+ SELECT COALESCE(legacy_company_id, ''), COALESCE(name, '')
FROM companies
- WHERE id = $1`, companyID).Scan(&legacy)
+ WHERE id = $1`, companyID).Scan(&legacy, &name)
if err != nil {
return false
}
- return billing.IsA1CohortCompany(legacy, "")
+ if billing.IsA1CohortCompany(legacy, "") {
+ return true
+ }
+ if strings.EqualFold(strings.TrimSpace(name), "Platform Demo") {
+ return true
+ }
+ var hasA1Prompts bool
+ err = p.Pool.QueryRow(ctx, `
+ SELECT EXISTS (
+ SELECT 1 FROM categories
+ WHERE company_id = $1
+ AND prompt ILIKE '%--- Title ---%'
+ AND prompt ILIKE '%--- Description ---%'
+ AND prompt ILIKE '%--- Meta ---%'
+ LIMIT 1
+ )`, companyID).Scan(&hasA1Prompts)
+ if err != nil {
+ return false
+ }
+ return hasA1Prompts
}
func derefStringPtr(s *string) string {
@@ -724,10 +746,14 @@ func ProjectV1ProcessJobItems(storedType string, items []V1ProcessJobItem) []V1P
projected["category_id"] = item["category_id"]
projected["category_name"] = item["category_name"]
case "title":
- projected["title"] = item["title"]
- projected["name"] = item["name"]
- if projected["name"] == nil {
- projected["name"] = item["title"]
+ name := item["name"]
+ if name == nil {
+ name = item["title"]
+ }
+ projected["name"] = name
+ // Omit duplicate title when it matches name (name is primary).
+ if title := item["title"]; title != nil && title != name {
+ projected["title"] = title
}
if _, ok := item["meta_title"]; ok {
projected["meta_title"] = item["meta_title"]
@@ -747,7 +773,7 @@ func ProjectV1ProcessJobItems(storedType string, items []V1ProcessJobItem) []V1P
}
func withItemMeta(dst, src V1ProcessJobItem) V1ProcessJobItem {
- for _, k := range []string{"status", "error", "id", "processed_product_id", "raw_product_id"} {
+ for _, k := range []string{"status", "error"} {
if v, ok := src[k]; ok {
dst[k] = v
}
@@ -755,17 +781,13 @@ func withItemMeta(dst, src V1ProcessJobItem) V1ProcessJobItem {
return dst
}
-// applyV1ProcessItemIDs sets legacy id (= processed UUID) plus additive dual-mode aliases.
-// id is preserved for existing integrators; processed_product_id mirrors it; raw_product_id is raw_products.id.
+// applyV1ProcessItemIDs formerly stamped id / processed_product_id / raw_product_id onto
+// V1 process items. Those fields are omitted from the public contract; kept as a no-op
+// so older call sites/tests compile until removed.
func applyV1ProcessItemIDs(item V1ProcessJobItem, processedID, rawProductID *uuid.UUID) {
- if processedID != nil {
- s := processedID.String()
- item["id"] = s
- item["processed_product_id"] = s
- }
- if rawProductID != nil {
- item["raw_product_id"] = rawProductID.String()
- }
+ _ = item
+ _ = processedID
+ _ = rawProductID
}
func withAlwaysIncluded(item V1ProcessJobItem, main string, more []string, eprel any) V1ProcessJobItem {
diff --git a/apps/api/internal/processing/v1_legacy_test.go b/apps/api/internal/processing/v1_legacy_test.go
index 5b23570..0b66800 100644
--- a/apps/api/internal/processing/v1_legacy_test.go
+++ b/apps/api/internal/processing/v1_legacy_test.go
@@ -72,11 +72,8 @@ func TestMapV1JobItemStatus(t *testing.T) {
func TestProjectV1ProcessJobItemsPartial(t *testing.T) {
items := []V1ProcessJobItem{{
- "ean": "123", "id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
- "processed_product_id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
- "raw_product_id": "cccccccc-cccc-cccc-cccc-cccccccccccc",
- "status": "processed",
- "title": "T", "name": "T", "meta_title": "MT",
+ "ean": "123", "status": "processed",
+ "name": "T", "meta_title": "MT",
"description": "D", "attributes": map[string]any{"brand": "X"},
"main_image": "https://example.com/a.jpg", "more_images": []string{"https://example.com/b.jpg"},
"eprel": nil, "category": "cat", "category_name": "Cat",
@@ -88,11 +85,11 @@ func TestProjectV1ProcessJobItemsPartial(t *testing.T) {
raw, _ := json.Marshal(out[0])
var got map[string]any
_ = json.Unmarshal(raw, &got)
- if got["title"] != "T" || got["ean"] != "123" {
+ if got["name"] != "T" || got["ean"] != "123" {
t.Fatalf("got=%v", got)
}
- if got["name"] != "T" {
- t.Fatalf("name dual-mode alias missing or mismatched: %v", got)
+ if _, ok := got["title"]; ok {
+ t.Fatalf("duplicate title should be omitted when name exists: %v", got)
}
if _, ok := got["attributes"]; ok {
t.Fatalf("attributes should be projected out: %v", got)
@@ -100,31 +97,29 @@ func TestProjectV1ProcessJobItemsPartial(t *testing.T) {
if got["main_image"] != "https://example.com/a.jpg" {
t.Fatalf("images always included: %v", got)
}
- if got["status"] != "processed" || got["id"] != "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" {
- t.Fatalf("meta should be preserved: %v", got)
+ if got["status"] != "processed" {
+ t.Fatalf("status should be preserved: %v", got)
}
- if got["processed_product_id"] != "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" {
- t.Fatalf("processed_product_id should be preserved: %v", got)
- }
- if got["raw_product_id"] != "cccccccc-cccc-cccc-cccc-cccccccccccc" {
- t.Fatalf("raw_product_id should be preserved: %v", got)
+ for _, junk := range []string{"id", "processed_product_id", "raw_product_id"} {
+ if _, ok := got[junk]; ok {
+ t.Fatalf("%s must be omitted from V1 process items: %v", junk, got)
+ }
}
}
func TestProjectV1ProcessJobItemsTitleDerivesName(t *testing.T) {
items := []V1ProcessJobItem{{
- "ean": "123", "id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
- "status": "processed", "title": "OnlyTitle", "meta_title": "MT",
+ "ean": "123", "status": "processed", "title": "OnlyTitle", "meta_title": "MT",
}}
out := ProjectV1ProcessJobItems("title", items)
if len(out) != 1 {
t.Fatalf("len=%d", len(out))
}
- if out[0]["title"] != "OnlyTitle" {
- t.Fatalf("title=%v", out[0]["title"])
- }
if out[0]["name"] != "OnlyTitle" {
- t.Fatalf("name should mirror title when absent: %v", out[0]["name"])
+ t.Fatalf("name should derive from title when absent: %v", out[0]["name"])
+ }
+ if _, ok := out[0]["title"]; ok {
+ t.Fatalf("title omitted when identical to name: %v", out[0])
}
}
@@ -180,22 +175,18 @@ func TestApplyV1ProcessItemIDsDualMode(t *testing.T) {
raw := mustParseTestUUID(t, "cccccccc-cccc-cccc-cccc-cccccccccccc")
item := V1ProcessJobItem{"ean": "1", "status": "processed"}
applyV1ProcessItemIDs(item, &processed, &raw)
- if item["id"] != processed.String() {
- t.Fatalf("id=%v", item["id"])
- }
- if item["processed_product_id"] != processed.String() {
- t.Fatalf("processed_product_id=%v", item["processed_product_id"])
- }
- if item["raw_product_id"] != raw.String() {
- t.Fatalf("raw_product_id=%v", item["raw_product_id"])
+ for _, junk := range []string{"id", "processed_product_id", "raw_product_id"} {
+ if _, ok := item[junk]; ok {
+ t.Fatalf("%s must stay omitted from V1 process items: %v", junk, item)
+ }
}
missing := V1ProcessJobItem{"ean": "2", "status": "not_found"}
applyV1ProcessItemIDs(missing, nil, &raw)
if _, ok := missing["id"]; ok {
- t.Fatalf("id must stay absent without processed row: %v", missing)
+ t.Fatalf("id must stay absent: %v", missing)
}
- if missing["raw_product_id"] != raw.String() {
- t.Fatalf("raw_product_id on not_found: %v", missing)
+ if _, ok := missing["raw_product_id"]; ok {
+ t.Fatalf("raw_product_id must stay omitted: %v", missing)
}
}
diff --git a/apps/api/internal/processing/v1_process_item.go b/apps/api/internal/processing/v1_process_item.go
index 8bb1efa..103b694 100644
--- a/apps/api/internal/processing/v1_process_item.go
+++ b/apps/api/internal/processing/v1_process_item.go
@@ -63,11 +63,17 @@ func ScoreV1ProcessCompletedItem(item V1ProcessJobItem, opts ScoreV1ProcessItemO
return sc
}
- title := stringFromItem(item, "title")
+ title := stringFromItem(item, "name")
+ if title == "" {
+ title = stringFromItem(item, "title")
+ }
name := stringFromItem(item, "name")
+ if name == "" {
+ name = title
+ }
sc.HasTitle = title != ""
sc.HasName = name != ""
- if sc.HasTitle && !sc.HasName {
+ if !sc.HasName && sc.HasTitle {
sc.FailFlags = append(sc.FailFlags, "missing_name")
}
if sc.HasTitle && sc.HasName && title != name {
@@ -140,22 +146,8 @@ func ScoreV1ProcessCompletedItem(item V1ProcessJobItem, opts ScoreV1ProcessItemO
sc.FailFlags = append(sc.FailFlags, "eprel_invalid_shape")
}
- id := stringFromItem(item, "id")
- ppID := stringFromItem(item, "processed_product_id")
- rawID := stringFromItem(item, "raw_product_id")
- sc.HasIDs = id != "" && ppID != "" && rawID != ""
- if id == "" {
- sc.FailFlags = append(sc.FailFlags, "missing_id")
- }
- if ppID == "" {
- sc.FailFlags = append(sc.FailFlags, "missing_processed_product_id")
- }
- if rawID == "" {
- sc.FailFlags = append(sc.FailFlags, "missing_raw_product_id")
- }
- if id != "" && ppID != "" && id != ppID {
- sc.FailFlags = append(sc.FailFlags, "id_processed_product_id_mismatch")
- }
+ // Internal UUIDs are omitted from the public V1 process item shape.
+ sc.HasIDs = true
sc.ImagesOK = imageFieldsOK(item)
if !sc.ImagesOK {
@@ -234,14 +226,20 @@ func EnforceV1ProcessCompletedItemOpts(item V1ProcessJobItem, opts EnforceV1Opts
item["attributes"] = nil
}
- title := ensureReadableTitleSpacing(stringFromItem(item, "title"))
- if title != "" {
- item["title"] = title
- item["name"] = title
- } else {
- item["title"] = nil
- item["name"] = nil
+ title := ensureReadableTitleSpacing(stringFromItem(item, "name"))
+ if title == "" {
+ title = ensureReadableTitleSpacing(stringFromItem(item, "title"))
}
+ if title != "" {
+ item["name"] = title
+ delete(item, "title")
+ } else {
+ item["name"] = nil
+ delete(item, "title")
+ }
+ delete(item, "id")
+ delete(item, "processed_product_id")
+ delete(item, "raw_product_id")
catID, catName := projectV1CategoryFields(item)
catLabel := catName
diff --git a/apps/api/internal/processing/v1_process_item_test.go b/apps/api/internal/processing/v1_process_item_test.go
index 956b9da..e917a0b 100644
--- a/apps/api/internal/processing/v1_process_item_test.go
+++ b/apps/api/internal/processing/v1_process_item_test.go
@@ -62,7 +62,6 @@ func TestScoreV1ProcessCompletedItem_flagsGaps(t *testing.T) {
}
joined := strings.Join(sc.FailFlags, ",")
for _, want := range []string{
- "missing_name",
"description_empty_with_title",
"meta_title_missing",
"meta_description_missing",
@@ -103,7 +102,7 @@ func TestEnforceV1ProcessCompletedItem_fillsDescriptionMetaSanitizes(t *testing.
if desc == "" {
t.Fatal("expected nonempty description")
}
- if descriptionEchoesTitle(desc, fmt.Sprint(out["title"])) {
+ if descriptionEchoesTitle(desc, fmt.Sprint(out["name"])) {
t.Fatalf("EnforceV1 must replace weak/echo desc, got title-echo: %q", desc)
}
if containsWeakFillerPhrase(desc) {
@@ -131,8 +130,16 @@ func TestEnforceV1ProcessCompletedItem_fillsDescriptionMetaSanitizes(t *testing.
if _, bad := attrs["name"]; bad {
t.Fatalf("reserved name kept: %v", attrs)
}
- if out["name"] != out["title"] {
- t.Fatalf("name should mirror title: name=%v title=%v", out["name"], out["title"])
+ if out["name"] == nil || strings.TrimSpace(fmt.Sprint(out["name"])) == "" {
+ t.Fatalf("name missing: %v", out["name"])
+ }
+ if _, ok := out["title"]; ok {
+ t.Fatalf("duplicate title should be omitted when name exists: %v", out["title"])
+ }
+ for _, junk := range []string{"id", "processed_product_id", "raw_product_id"} {
+ if _, ok := out[junk]; ok {
+ t.Fatalf("%s must be omitted: %v", junk, out)
+ }
}
sc := ScoreV1ProcessCompletedItem(out, ScoreV1ProcessItemOptions{MappedCategory: "28"})
if !sc.OK {
diff --git a/apps/web/src/lib/admin-orgs.ts b/apps/web/src/lib/admin-orgs.ts
index 00eb7a4..39844b2 100644
--- a/apps/web/src/lib/admin-orgs.ts
+++ b/apps/web/src/lib/admin-orgs.ts
@@ -285,36 +285,15 @@ export type FixCatalogResult = SyncA1Result;
/** @deprecated Use SyncA1Response */
export type FixCatalogResponse = SyncA1Response;
-/** Sync A1: optional wp_product_categories.sql upload + dump category backfill + Fix hygiene. */
+/** Sync A1: dump category backfill + Fix hygiene; category prompts load from repo seed. */
export async function syncAdminCompanyA1(
companyId: string,
opts?: {
backfillCategories?: boolean;
reprocessSampleLimit?: number;
skipDumpBackfill?: boolean;
- wpProductCategoriesFile?: File | null;
}
): Promise {
- const file = opts?.wpProductCategoriesFile ?? null;
- if (file) {
- const body = new FormData();
- body.append("confirm", "true");
- if (opts?.backfillCategories === false) {
- body.append("backfill_categories", "false");
- }
- if (typeof opts?.reprocessSampleLimit === "number") {
- body.append("reprocess_sample_limit", String(opts.reprocessSampleLimit));
- }
- if (opts?.skipDumpBackfill) {
- body.append("skip_dump_backfill", "true");
- }
- body.append("wp_product_categories", file, file.name || "wp_product_categories.sql");
- return api(ADMIN_SYNC_A1_PATH(companyId), {
- method: "POST",
- body
- });
- }
-
const body: {
confirm: true;
backfill_categories?: boolean;
diff --git a/apps/web/src/lib/i18n/messages/de.ts b/apps/web/src/lib/i18n/messages/de.ts
index 55a9950..9fb2504 100644
--- a/apps/web/src/lib/i18n/messages/de.ts
+++ b/apps/web/src/lib/i18n/messages/de.ts
@@ -829,14 +829,11 @@ export const de: MessageDict = {
"admin.users.syncA1": "A1 synchronisieren",
"admin.users.syncA1Aria": "Sync A1 categories from dump and repair catalog hygiene for {name}",
"admin.users.syncA1Title": "A1-Katalog synchronisieren",
- "admin.users.syncA1Desc": "Uploads wp_product_categories.sql to force-apply Title/Description/Meta/Attributes category prompts, optionally backfills mapped categories from a MySQL product dump on the API host, then runs catalog hygiene. Does not wipe or reimport the full catalog.",
+ "admin.users.syncA1Desc": "Applies Title/Description/Meta/Attributes category prompts from the repo seed (scripts/seed/wp_product_categories.sql), optionally backfills mapped categories from a MySQL product dump on the API host, then runs catalog hygiene. Does not wipe or reimport the full catalog.",
"admin.users.syncA1Company": "Company: {name}",
- "admin.users.syncA1Warning": "Upload wp_product_categories.sql below (primary). Auto-detect on the API host is a fallback when no file is chosen. Optional: place descrybe_new.sql on the API server for product-dump category coverage. No mass reprocess.",
+ "admin.users.syncA1Warning": "Category prompts load automatically from scripts/seed/wp_product_categories.sql on the API host (SEED_A1_WP_CATEGORIES overrides). Optional: place descrybe_new.sql on the API server for product-dump category coverage. No mass reprocess.",
"admin.users.syncA1Cancel": "Abbrechen",
"admin.users.syncA1Confirm": "A1 synchronisieren",
- "admin.users.syncA1WpUpload": "wp_product_categories.sql",
- "admin.users.syncA1WpUploadHint": "Choose the WordPress category prompts dump. Max 16 MiB. When uploaded, Sync A1 force-applies sectioned prompts for title and description enhance.",
- "admin.users.syncA1WpUploadClear": "Datei entfernen",
"admin.users.noUsers": "Keine Benutzer entsprechen diesem Filter.",
"admin.users.noCompanies": "Keine Unternehmen entsprechen diesem Filter.",
"admin.users.assignRoleTitle": "Mitarbeiterrolle zuweisen",
diff --git a/apps/web/src/lib/i18n/messages/en.ts b/apps/web/src/lib/i18n/messages/en.ts
index 8a72d9c..5acf98a 100644
--- a/apps/web/src/lib/i18n/messages/en.ts
+++ b/apps/web/src/lib/i18n/messages/en.ts
@@ -856,14 +856,11 @@ export const en: MessageDict = {
"admin.users.syncA1": "Sync A1",
"admin.users.syncA1Aria": "Sync A1 categories from dump and repair catalog hygiene for {name}",
"admin.users.syncA1Title": "Sync A1 catalog",
- "admin.users.syncA1Desc": "Uploads wp_product_categories.sql to force-apply Title/Description/Meta/Attributes category prompts, optionally backfills mapped categories from a MySQL product dump on the API host, then runs catalog hygiene. Does not wipe or reimport the full catalog.",
+ "admin.users.syncA1Desc": "Applies Title/Description/Meta/Attributes category prompts from the repo seed (scripts/seed/wp_product_categories.sql), optionally backfills mapped categories from a MySQL product dump on the API host, then runs catalog hygiene. Does not wipe or reimport the full catalog.",
"admin.users.syncA1Company": "Company: {name}",
- "admin.users.syncA1Warning": "Upload wp_product_categories.sql below (primary). Auto-detect on the API host is a fallback when no file is chosen. Optional: place descrybe_new.sql on the API server for product-dump category coverage. No mass reprocess.",
+ "admin.users.syncA1Warning": "Category prompts load automatically from scripts/seed/wp_product_categories.sql on the API host (SEED_A1_WP_CATEGORIES overrides). Optional: place descrybe_new.sql on the API server for product-dump category coverage. No mass reprocess.",
"admin.users.syncA1Cancel": "Cancel",
"admin.users.syncA1Confirm": "Sync A1",
- "admin.users.syncA1WpUpload": "wp_product_categories.sql",
- "admin.users.syncA1WpUploadHint": "Choose the WordPress category prompts dump. Max 16 MiB. When uploaded, Sync A1 force-applies sectioned prompts for title and description enhance.",
- "admin.users.syncA1WpUploadClear": "Clear file",
"admin.users.noUsers": "No users match this filter.",
"admin.users.noCompanies": "No companies match this filter.",
"admin.users.assignRoleTitle": "Assign staff role",
@@ -2109,8 +2106,8 @@ export const en: MessageDict = {
"settings.emailAlertsHelp": "Email alert preferences are coming soon. Failure alerts will default on (like in-app); completion emails stay off unless you opt in.",
"settings.emailAlertsBody": "For now, configure transactional mail under Company → Email integration. In-app alerts above still apply.",
"settings.apiKeysLegacyNotice": "Legacy API keys were not migrated. Create a new key to restore API access — the secret is shown only once.",
- "settings.apiKeysAdminOnly": "Only company admins can create or revoke API keys. You can view existing key prefixes.",
- "settings.apiKeysCopyUnavailableNotice": "Copy unavailable for existing keys — only the prefix is stored. Create a new key to copy the full secret once, then revoke the old key if needed.",
+ "settings.apiKeysAdminOnly": "Only company admins can create, rename, or revoke API keys. You can view existing key prefixes.",
+ "settings.apiKeysCopyUnavailableNotice": "After you create a key, copy the full secret immediately (or from the row menu while this page stays open). Existing keys only store a prefix — create a new key if you lost the secret.",
"settings.apiKeysForbidden": "You don't have permission to view API keys. Ask a company admin for help.",
"settings.noApiKeys": "No API keys yet",
"settings.noApiKeysHelpAdmin": "Legacy API keys were not migrated. Create a new key to authenticate Descrybe API requests (X-API-Key). The full secret is shown only once.",
@@ -2118,13 +2115,20 @@ export const en: MessageDict = {
"settings.unnamedKey": "Unnamed Key",
"settings.neverUsed": "Never used",
"settings.apiKeyActions": "API key actions",
+ "settings.renameApiKey": "Rename",
+ "settings.renameApiKeyTitle": "Rename API key",
+ "settings.renameApiKeyHelp": "Update the display name for this key. The secret itself does not change.",
+ "settings.saveRename": "Save name",
+ "settings.copyApiKey": "Copy secret",
"settings.copyUnavailable": "Copy unavailable",
- "settings.copyUnavailableTitle": "Full secret is shown only once when the key is created",
+ "settings.copyUnavailableTitle": "Full secret is shown only once when the key is created (kept copyable on this page until you refresh)",
"settings.revoke": "Revoke",
"settings.apiKeyNameRequired": "Enter a name for this API key.",
"settings.apiKeyCreatedOnce": "API key created. Copy it now - it won't be shown again.",
"settings.apiKeyCreated": "API key created.",
"settings.apiKeyCreateFailed": "Could not create API key",
+ "settings.apiKeyRenamed": "API key renamed.",
+ "settings.apiKeyRenameFailed": "Could not rename API key",
"settings.apiKeyRevokeConfirm": "Revoke this API key? Requests using it will stop working.",
"settings.apiKeyRevoked": "API key revoked.",
"settings.apiKeyRevokeFailed": "Could not revoke API key",
@@ -2282,6 +2286,7 @@ export const en: MessageDict = {
"flash.settings.apiKeyCreatedFailed": "Could not create API key",
"flash.settings.apiKeyRevoked": "API key revoked.",
"flash.settings.apiKeyRevokeFailed": "Could not revoke API key",
+ "flash.settings.apiKeyRenameFailed": "Could not rename API key",
"flash.settings.copyFailed": "Could not copy. Select the text and copy manually.",
"flash.products.accepted": "Accepted {count} product(s) — moved to Processed.",
"flash.products.updated": "Product updated.",
diff --git a/apps/web/src/lib/i18n/messages/es.ts b/apps/web/src/lib/i18n/messages/es.ts
index 46c9f21..58bf3a4 100644
--- a/apps/web/src/lib/i18n/messages/es.ts
+++ b/apps/web/src/lib/i18n/messages/es.ts
@@ -829,14 +829,11 @@ export const es: MessageDict = {
"admin.users.syncA1": "Sincronizar A1",
"admin.users.syncA1Aria": "Sync A1 categories from dump and repair catalog hygiene for {name}",
"admin.users.syncA1Title": "Sincronizar catálogo A1",
- "admin.users.syncA1Desc": "Uploads wp_product_categories.sql to force-apply Title/Description/Meta/Attributes category prompts, optionally backfills mapped categories from a MySQL product dump on the API host, then runs catalog hygiene. Does not wipe or reimport the full catalog.",
+ "admin.users.syncA1Desc": "Applies Title/Description/Meta/Attributes category prompts from the repo seed (scripts/seed/wp_product_categories.sql), optionally backfills mapped categories from a MySQL product dump on the API host, then runs catalog hygiene. Does not wipe or reimport the full catalog.",
"admin.users.syncA1Company": "Company: {name}",
- "admin.users.syncA1Warning": "Upload wp_product_categories.sql below (primary). Auto-detect on the API host is a fallback when no file is chosen. Optional: place descrybe_new.sql on the API server for product-dump category coverage. No mass reprocess.",
+ "admin.users.syncA1Warning": "Category prompts load automatically from scripts/seed/wp_product_categories.sql on the API host (SEED_A1_WP_CATEGORIES overrides). Optional: place descrybe_new.sql on the API server for product-dump category coverage. No mass reprocess.",
"admin.users.syncA1Cancel": "Cancelar",
"admin.users.syncA1Confirm": "Sincronizar A1",
- "admin.users.syncA1WpUpload": "wp_product_categories.sql",
- "admin.users.syncA1WpUploadHint": "Choose the WordPress category prompts dump. Max 16 MiB. When uploaded, Sync A1 force-applies sectioned prompts for title and description enhance.",
- "admin.users.syncA1WpUploadClear": "Quitar archivo",
"admin.users.noUsers": "Ningún usuario coincide con este filtro.",
"admin.users.noCompanies": "Ninguna empresa coincide con este filtro.",
"admin.users.assignRoleTitle": "Asignar rol de personal",
diff --git a/apps/web/src/lib/i18n/messages/fr.ts b/apps/web/src/lib/i18n/messages/fr.ts
index a48c704..37840d7 100644
--- a/apps/web/src/lib/i18n/messages/fr.ts
+++ b/apps/web/src/lib/i18n/messages/fr.ts
@@ -829,14 +829,11 @@ export const fr: MessageDict = {
"admin.users.syncA1": "Synchroniser A1",
"admin.users.syncA1Aria": "Sync A1 categories from dump and repair catalog hygiene for {name}",
"admin.users.syncA1Title": "Synchroniser le catalogue A1",
- "admin.users.syncA1Desc": "Uploads wp_product_categories.sql to force-apply Title/Description/Meta/Attributes category prompts, optionally backfills mapped categories from a MySQL product dump on the API host, then runs catalog hygiene. Does not wipe or reimport the full catalog.",
+ "admin.users.syncA1Desc": "Applies Title/Description/Meta/Attributes category prompts from the repo seed (scripts/seed/wp_product_categories.sql), optionally backfills mapped categories from a MySQL product dump on the API host, then runs catalog hygiene. Does not wipe or reimport the full catalog.",
"admin.users.syncA1Company": "Company: {name}",
- "admin.users.syncA1Warning": "Upload wp_product_categories.sql below (primary). Auto-detect on the API host is a fallback when no file is chosen. Optional: place descrybe_new.sql on the API server for product-dump category coverage. No mass reprocess.",
+ "admin.users.syncA1Warning": "Category prompts load automatically from scripts/seed/wp_product_categories.sql on the API host (SEED_A1_WP_CATEGORIES overrides). Optional: place descrybe_new.sql on the API server for product-dump category coverage. No mass reprocess.",
"admin.users.syncA1Cancel": "Annuler",
"admin.users.syncA1Confirm": "Synchroniser A1",
- "admin.users.syncA1WpUpload": "wp_product_categories.sql",
- "admin.users.syncA1WpUploadHint": "Choose the WordPress category prompts dump. Max 16 MiB. When uploaded, Sync A1 force-applies sectioned prompts for title and description enhance.",
- "admin.users.syncA1WpUploadClear": "Effacer le fichier",
"admin.users.noUsers": "Aucun utilisateur ne correspond àce filtre.",
"admin.users.noCompanies": "Aucune entreprise ne correspond àce filtre.",
"admin.users.assignRoleTitle": "Assigner un rôle du personnel",
diff --git a/apps/web/src/lib/i18n/messages/it.ts b/apps/web/src/lib/i18n/messages/it.ts
index f3b2810..a6d60bf 100644
--- a/apps/web/src/lib/i18n/messages/it.ts
+++ b/apps/web/src/lib/i18n/messages/it.ts
@@ -829,14 +829,11 @@ export const it: MessageDict = {
"admin.users.syncA1": "Sincronizza A1",
"admin.users.syncA1Aria": "Sync A1 categories from dump and repair catalog hygiene for {name}",
"admin.users.syncA1Title": "Sincronizza catalogo A1",
- "admin.users.syncA1Desc": "Uploads wp_product_categories.sql to force-apply Title/Description/Meta/Attributes category prompts, optionally backfills mapped categories from a MySQL product dump on the API host, then runs catalog hygiene. Does not wipe or reimport the full catalog.",
+ "admin.users.syncA1Desc": "Applies Title/Description/Meta/Attributes category prompts from the repo seed (scripts/seed/wp_product_categories.sql), optionally backfills mapped categories from a MySQL product dump on the API host, then runs catalog hygiene. Does not wipe or reimport the full catalog.",
"admin.users.syncA1Company": "Company: {name}",
- "admin.users.syncA1Warning": "Upload wp_product_categories.sql below (primary). Auto-detect on the API host is a fallback when no file is chosen. Optional: place descrybe_new.sql on the API server for product-dump category coverage. No mass reprocess.",
+ "admin.users.syncA1Warning": "Category prompts load automatically from scripts/seed/wp_product_categories.sql on the API host (SEED_A1_WP_CATEGORIES overrides). Optional: place descrybe_new.sql on the API server for product-dump category coverage. No mass reprocess.",
"admin.users.syncA1Cancel": "Annulla",
"admin.users.syncA1Confirm": "Sincronizza A1",
- "admin.users.syncA1WpUpload": "wp_product_categories.sql",
- "admin.users.syncA1WpUploadHint": "Choose the WordPress category prompts dump. Max 16 MiB. When uploaded, Sync A1 force-applies sectioned prompts for title and description enhance.",
- "admin.users.syncA1WpUploadClear": "Rimuovi file",
"admin.users.noUsers": "Nessun utente corrisponde a questo filtro.",
"admin.users.noCompanies": "Nessuna azienda corrisponde a questo filtro.",
"admin.users.assignRoleTitle": "Assegna ruolo staff",
diff --git a/apps/web/src/lib/i18n/messages/ja.ts b/apps/web/src/lib/i18n/messages/ja.ts
index 497fca9..0786c35 100644
--- a/apps/web/src/lib/i18n/messages/ja.ts
+++ b/apps/web/src/lib/i18n/messages/ja.ts
@@ -829,14 +829,11 @@ export const ja: MessageDict = {
"admin.users.syncA1": "A1ã‚’åŒæœŸ",
"admin.users.syncA1Aria": "Sync A1 categories from dump and repair catalog hygiene for {name}",
"admin.users.syncA1Title": "A1ã‚«ã‚¿ãƒã‚°ã‚’åŒæœŸ",
- "admin.users.syncA1Desc": "Uploads wp_product_categories.sql to force-apply Title/Description/Meta/Attributes category prompts, optionally backfills mapped categories from a MySQL product dump on the API host, then runs catalog hygiene. Does not wipe or reimport the full catalog.",
+ "admin.users.syncA1Desc": "Applies Title/Description/Meta/Attributes category prompts from the repo seed (scripts/seed/wp_product_categories.sql), optionally backfills mapped categories from a MySQL product dump on the API host, then runs catalog hygiene. Does not wipe or reimport the full catalog.",
"admin.users.syncA1Company": "Company: {name}",
- "admin.users.syncA1Warning": "Upload wp_product_categories.sql below (primary). Auto-detect on the API host is a fallback when no file is chosen. Optional: place descrybe_new.sql on the API server for product-dump category coverage. No mass reprocess.",
+ "admin.users.syncA1Warning": "Category prompts load automatically from scripts/seed/wp_product_categories.sql on the API host (SEED_A1_WP_CATEGORIES overrides). Optional: place descrybe_new.sql on the API server for product-dump category coverage. No mass reprocess.",
"admin.users.syncA1Cancel": "ã‚ャンセル",
"admin.users.syncA1Confirm": "A1ã‚’åŒæœŸ",
- "admin.users.syncA1WpUpload": "wp_product_categories.sql",
- "admin.users.syncA1WpUploadHint": "Choose the WordPress category prompts dump. Max 16 MiB. When uploaded, Sync A1 force-applies sectioned prompts for title and description enhance.",
- "admin.users.syncA1WpUploadClear": "ファイルをクリア",
"admin.users.noUsers": "ã“ã®フィルタã«一致ã™るユーザーã¯ã„ã¾ã›ん。",
"admin.users.noCompanies": "ã“ã®フィルタã«一致ã™る会社ã¯ã‚りã¾ã›ん。",
"admin.users.assignRoleTitle": "スタッフãƒÂールを割り当ã¦",
diff --git a/apps/web/src/lib/i18n/messages/nl.ts b/apps/web/src/lib/i18n/messages/nl.ts
index 73b4cde..98176c5 100644
--- a/apps/web/src/lib/i18n/messages/nl.ts
+++ b/apps/web/src/lib/i18n/messages/nl.ts
@@ -829,14 +829,11 @@ export const nl: MessageDict = {
"admin.users.syncA1": "A1 synchroniseren",
"admin.users.syncA1Aria": "Sync A1 categories from dump and repair catalog hygiene for {name}",
"admin.users.syncA1Title": "A1-catalogus synchroniseren",
- "admin.users.syncA1Desc": "Uploads wp_product_categories.sql to force-apply Title/Description/Meta/Attributes category prompts, optionally backfills mapped categories from a MySQL product dump on the API host, then runs catalog hygiene. Does not wipe or reimport the full catalog.",
+ "admin.users.syncA1Desc": "Applies Title/Description/Meta/Attributes category prompts from the repo seed (scripts/seed/wp_product_categories.sql), optionally backfills mapped categories from a MySQL product dump on the API host, then runs catalog hygiene. Does not wipe or reimport the full catalog.",
"admin.users.syncA1Company": "Company: {name}",
- "admin.users.syncA1Warning": "Upload wp_product_categories.sql below (primary). Auto-detect on the API host is a fallback when no file is chosen. Optional: place descrybe_new.sql on the API server for product-dump category coverage. No mass reprocess.",
+ "admin.users.syncA1Warning": "Category prompts load automatically from scripts/seed/wp_product_categories.sql on the API host (SEED_A1_WP_CATEGORIES overrides). Optional: place descrybe_new.sql on the API server for product-dump category coverage. No mass reprocess.",
"admin.users.syncA1Cancel": "Annuleren",
"admin.users.syncA1Confirm": "A1 synchroniseren",
- "admin.users.syncA1WpUpload": "wp_product_categories.sql",
- "admin.users.syncA1WpUploadHint": "Choose the WordPress category prompts dump. Max 16 MiB. When uploaded, Sync A1 force-applies sectioned prompts for title and description enhance.",
- "admin.users.syncA1WpUploadClear": "Bestand wissen",
"admin.users.noUsers": "Geen gebruikers komen overeen met dit filter.",
"admin.users.noCompanies": "Geen bedrijven komen overeen met dit filter.",
"admin.users.assignRoleTitle": "Medewerkerrol toewijzen",
diff --git a/apps/web/src/lib/i18n/messages/pl.ts b/apps/web/src/lib/i18n/messages/pl.ts
index 365c09e..51151f3 100644
--- a/apps/web/src/lib/i18n/messages/pl.ts
+++ b/apps/web/src/lib/i18n/messages/pl.ts
@@ -829,14 +829,11 @@ export const pl: MessageDict = {
"admin.users.syncA1": "Synchronizuj A1",
"admin.users.syncA1Aria": "Sync A1 categories from dump and repair catalog hygiene for {name}",
"admin.users.syncA1Title": "Synchronizuj katalog A1",
- "admin.users.syncA1Desc": "Uploads wp_product_categories.sql to force-apply Title/Description/Meta/Attributes category prompts, optionally backfills mapped categories from a MySQL product dump on the API host, then runs catalog hygiene. Does not wipe or reimport the full catalog.",
+ "admin.users.syncA1Desc": "Applies Title/Description/Meta/Attributes category prompts from the repo seed (scripts/seed/wp_product_categories.sql), optionally backfills mapped categories from a MySQL product dump on the API host, then runs catalog hygiene. Does not wipe or reimport the full catalog.",
"admin.users.syncA1Company": "Company: {name}",
- "admin.users.syncA1Warning": "Upload wp_product_categories.sql below (primary). Auto-detect on the API host is a fallback when no file is chosen. Optional: place descrybe_new.sql on the API server for product-dump category coverage. No mass reprocess.",
+ "admin.users.syncA1Warning": "Category prompts load automatically from scripts/seed/wp_product_categories.sql on the API host (SEED_A1_WP_CATEGORIES overrides). Optional: place descrybe_new.sql on the API server for product-dump category coverage. No mass reprocess.",
"admin.users.syncA1Cancel": "Anuluj",
"admin.users.syncA1Confirm": "Synchronizuj A1",
- "admin.users.syncA1WpUpload": "wp_product_categories.sql",
- "admin.users.syncA1WpUploadHint": "Choose the WordPress category prompts dump. Max 16 MiB. When uploaded, Sync A1 force-applies sectioned prompts for title and description enhance.",
- "admin.users.syncA1WpUploadClear": "Wyczyść plik",
"admin.users.noUsers": "Żaden użytkownik nie pasuje do tego filtra.",
"admin.users.noCompanies": "Żadna firma nie pasuje do tego filtra.",
"admin.users.assignRoleTitle": "Przypisz rolÄ™ personelu",
diff --git a/apps/web/src/lib/i18n/messages/pt.ts b/apps/web/src/lib/i18n/messages/pt.ts
index bbd6eff..806bc7f 100644
--- a/apps/web/src/lib/i18n/messages/pt.ts
+++ b/apps/web/src/lib/i18n/messages/pt.ts
@@ -829,14 +829,11 @@ export const pt: MessageDict = {
"admin.users.syncA1": "Sincronizar A1",
"admin.users.syncA1Aria": "Sync A1 categories from dump and repair catalog hygiene for {name}",
"admin.users.syncA1Title": "Sincronizar catálogo A1",
- "admin.users.syncA1Desc": "Uploads wp_product_categories.sql to force-apply Title/Description/Meta/Attributes category prompts, optionally backfills mapped categories from a MySQL product dump on the API host, then runs catalog hygiene. Does not wipe or reimport the full catalog.",
+ "admin.users.syncA1Desc": "Applies Title/Description/Meta/Attributes category prompts from the repo seed (scripts/seed/wp_product_categories.sql), optionally backfills mapped categories from a MySQL product dump on the API host, then runs catalog hygiene. Does not wipe or reimport the full catalog.",
"admin.users.syncA1Company": "Company: {name}",
- "admin.users.syncA1Warning": "Upload wp_product_categories.sql below (primary). Auto-detect on the API host is a fallback when no file is chosen. Optional: place descrybe_new.sql on the API server for product-dump category coverage. No mass reprocess.",
+ "admin.users.syncA1Warning": "Category prompts load automatically from scripts/seed/wp_product_categories.sql on the API host (SEED_A1_WP_CATEGORIES overrides). Optional: place descrybe_new.sql on the API server for product-dump category coverage. No mass reprocess.",
"admin.users.syncA1Cancel": "Cancelar",
"admin.users.syncA1Confirm": "Sincronizar A1",
- "admin.users.syncA1WpUpload": "wp_product_categories.sql",
- "admin.users.syncA1WpUploadHint": "Choose the WordPress category prompts dump. Max 16 MiB. When uploaded, Sync A1 force-applies sectioned prompts for title and description enhance.",
- "admin.users.syncA1WpUploadClear": "Limpar ficheiro",
"admin.users.noUsers": "Nenhum utilizador corresponde a este filtro.",
"admin.users.noCompanies": "Nenhuma empresa corresponde a este filtro.",
"admin.users.assignRoleTitle": "Atribuir função de equipa",
diff --git a/apps/web/src/lib/i18n/messages/sl.ts b/apps/web/src/lib/i18n/messages/sl.ts
index 6891301..d44b2ec 100644
--- a/apps/web/src/lib/i18n/messages/sl.ts
+++ b/apps/web/src/lib/i18n/messages/sl.ts
@@ -18,14 +18,11 @@ export const sl: MessageDict = {
"admin.users.syncA1": "Sinhroniziraj A1",
"admin.users.syncA1Aria": "Sync A1 categories from dump and repair catalog hygiene for {name}",
"admin.users.syncA1Title": "Sinhroniziraj katalog A1",
- "admin.users.syncA1Desc": "Uploads wp_product_categories.sql to force-apply Title/Description/Meta/Attributes category prompts, optionally backfills mapped categories from a MySQL product dump on the API host, then runs catalog hygiene. Does not wipe or reimport the full catalog.",
+ "admin.users.syncA1Desc": "Applies Title/Description/Meta/Attributes category prompts from the repo seed (scripts/seed/wp_product_categories.sql), optionally backfills mapped categories from a MySQL product dump on the API host, then runs catalog hygiene. Does not wipe or reimport the full catalog.",
"admin.users.syncA1Company": "Company: {name}",
- "admin.users.syncA1Warning": "Upload wp_product_categories.sql below (primary). Auto-detect on the API host is a fallback when no file is chosen. Optional: place descrybe_new.sql on the API server for product-dump category coverage. No mass reprocess.",
+ "admin.users.syncA1Warning": "Category prompts load automatically from scripts/seed/wp_product_categories.sql on the API host (SEED_A1_WP_CATEGORIES overrides). Optional: place descrybe_new.sql on the API server for product-dump category coverage. No mass reprocess.",
"admin.users.syncA1Cancel": "PrekliÄi",
"admin.users.syncA1Confirm": "Sinhroniziraj A1",
- "admin.users.syncA1WpUpload": "wp_product_categories.sql",
- "admin.users.syncA1WpUploadHint": "Choose the WordPress category prompts dump. Max 16 MiB. When uploaded, Sync A1 force-applies sectioned prompts for title and description enhance.",
- "admin.users.syncA1WpUploadClear": "Počisti datoteko",
"flash.admin.catalogCloned": "Copied {source} into {dest}: {products} products, {categories} taxonomy categories, {mapped_with} with mapped category.",
"flash.admin.syncA1Success": "Synced A1 for {name}: dump_mapped {dump_mapped} ({dump_status}), prompts {prompts}, hashes {hashes}, processedâ†mapped {categories}, mappedâ†processed {mapped_backfilled}. Coverage: {mapped_with}/{mapped_total} ({taxonomy} taxonomy).",
"flash.admin.syncA1Error": "Sinhronizacija A1 za {name} ni uspela.",
diff --git a/apps/web/src/routes/admin/users/+page.svelte b/apps/web/src/routes/admin/users/+page.svelte
index 1db4b8a..3cb0654 100644
--- a/apps/web/src/routes/admin/users/+page.svelte
+++ b/apps/web/src/routes/admin/users/+page.svelte
@@ -100,7 +100,6 @@
let syncOpen = $state(false);
let syncCompany = $state(null);
- let syncWpCategoriesFile = $state(null);
const cloneDestLabel = $derived.by(() => {
const selected = cloneDestOptions.find((c) => c.id === cloneDestId);
@@ -409,7 +408,6 @@
function openSyncDialog(company: AdminOrgCompany) {
syncCompany = company;
- syncWpCategoriesFile = null;
syncOpen = true;
error = "";
success = "";
@@ -471,8 +469,7 @@
const targetName = syncCompany.name;
try {
const res = await syncAdminCompanyA1(syncCompany.id, {
- reprocessSampleLimit: 25,
- wpProductCategoriesFile: syncWpCategoriesFile
+ reprocessSampleLimit: 25
});
const r = res.result;
const mappedWith = Number(r.mapped_with_category ?? 0);
@@ -493,7 +490,6 @@
});
syncOpen = false;
syncCompany = null;
- syncWpCategoriesFile = null;
} catch (err) {
error = failureMessage(err, i18n.t("flash.admin.syncA1Error", { name: targetName }));
} finally {
@@ -1130,39 +1126,6 @@
{i18n.t("admin.users.syncA1Company", { name: syncCompany.name })}