diff --git a/apps/api/cmd/_formula_probe_20260816_tmp/main.go b/apps/api/cmd/_formula_probe_20260816_tmp/main.go
new file mode 100644
index 0000000..3a982d9
--- /dev/null
+++ b/apps/api/cmd/_formula_probe_20260816_tmp/main.go
@@ -0,0 +1,200 @@
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log"
+ "os"
+ "os/signal"
+ "strings"
+ "syscall"
+ "time"
+
+ "github.com/descrybe/descrybe-v2/apps/api/internal/aiprompts"
+ "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/processing"
+ "github.com/google/uuid"
+)
+
+func main() {
+ log.SetOutput(logredact.Writer(os.Stderr))
+ eans := []string{
+ "195348253666", // 7 Gaming monitorji (rich)
+ "4548736132597", // 48 Slušalke (rich)
+ "8712285326882", // 28 Nosilci za TV (thin p-only)
+ "3045380024786", // 36 Pečice (rich)
+ "3838782459856", // 38 Pomivalni stroji (rich)
+ "8806095210711", // 40 Pralni stroji (rich)
+ "4242005342488", // 41 Pralno-sušilni (rich)
+ "3838782103889", // 17 Kuhalne plošče (rich)
+ "1200130000638", // 3 Bluetooth zvočniki (rich)
+ "194252029558", // 24 Mobilne naprave (rich)
+ }
+ companyID := uuid.MustParse("2b3159b0-fc08-415b-b248-35ed02a6baab")
+ userID := uuid.MustParse("0ce3305c-d810-4b56-b1d4-3c1ed510db76")
+
+ cfg, err := config.Load()
+ if err != nil {
+ log.Fatalf("config: %v", err)
+ }
+ ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+ defer cancel()
+
+ 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 {
+ log.Fatalf("db: %v", err)
+ }
+ defer pool.Close()
+
+ out := os.Getenv("PROBE_OUT_DIR")
+ if out == "" {
+ out = `F:\laragon\www\_MY\descrybe-v2\.codehelper\_formula_probe_20260816`
+ }
+ _ = os.MkdirAll(out, 0o755)
+
+ pipeline := processing.NewPipeline(pool)
+ pipeline.BatchSize = cfg.ProcessingBatchSize
+ pipeline.AI = nil
+ pipeline.Prompts = aiprompts.NewService(pool)
+
+ mockKey := strings.TrimSpace(cfg.OpenAIAPIKey)
+ if mockKey == "" {
+ mockKey = "local-test"
+ }
+ mockBase := "http://127.0.0.1:18767/v1"
+ mockModel := "mock-llm"
+ log.Printf("forcing completer base=%s model=%s (ModeLabel=internal; OverloadedBot not used)", mockBase, mockModel)
+ client := processing.NewOpenAIClient(mockKey, mockBase, mockModel, cfg.ProcessingRPM, cfg.ProcessingMaxRetries)
+ client.ModeLabel = processing.AIProviderInternal
+
+ eprelClient := eprel.NewClient(eprel.Options{Enabled: true, Timeout: 20 * time.Second})
+ pipeline.Engine = &processing.Engine{
+ Completer: client,
+ Vector: processing.NoopVectorCategorizer{},
+ EPREL: eprelClient,
+ ProviderMode: processing.AIProviderInternal,
+ }
+
+ rawIDs := make([]uuid.UUID, 0, len(eans))
+ for _, ean := range eans {
+ var id uuid.UUID
+ err := pool.QueryRow(ctx, `
+ SELECT id FROM raw_products
+ WHERE company_id = $1 AND gtin = $2`, companyID, ean).Scan(&id)
+ if err != nil {
+ log.Fatalf("raw product %s: %v", ean, err)
+ }
+ rawIDs = append(rawIDs, id)
+ log.Printf("ean=%s raw_id=%s", ean, id)
+ }
+
+ if n, err := processing.BackfillMissingMeta(ctx, pool, companyID); err != nil {
+ log.Printf("BackfillMissingMeta: %v", err)
+ } else {
+ log.Printf("BackfillMissingMeta updated=%d", n)
+ }
+
+ jobs, err := pipeline.StartJob(ctx, companyID, userID, rawIDs, "full")
+ if err != nil {
+ log.Fatalf("StartJob: %v", err)
+ }
+ if len(jobs) == 0 {
+ log.Fatal("StartJob returned no jobs")
+ }
+ jobID := jobs[0].ID
+ log.Printf("job_id=%s", jobID)
+ _ = os.WriteFile(out+string(os.PathSeparator)+"process_id.txt", []byte(jobID.String()+"\n"), 0o644)
+
+ defend := make(chan struct{})
+ go func() {
+ t := time.NewTicker(500 * time.Millisecond)
+ defer t.Stop()
+ for {
+ select {
+ case <-defend:
+ return
+ case <-ctx.Done():
+ return
+ case <-t.C:
+ _, _ = pool.Exec(context.Background(), `
+ UPDATE processing_jobs
+ SET status = 'running', error = NULL, updated_at = now()
+ WHERE id = $1 AND status IN ('failed','cancelled')
+ AND (error ILIKE '%P0%' OR error ILIKE '%parked%' OR error ILIKE '%yield%'
+ OR error ILIKE '%reclaim%' OR error ILIKE '%cleared%' OR error ILIKE '%superseded%'
+ OR error ILIKE '%exclusive%' OR error ILIKE '%batch_%' OR error ILIKE '%inproc%'
+ OR error ILIKE '%formula%')`, jobID)
+ _, _ = pool.Exec(context.Background(), `
+ UPDATE processing_job_products
+ SET status = 'pending', error = NULL, updated_at = now()
+ WHERE job_id = $1 AND status IN ('failed','cancelled')
+ AND processed_product_id IS NULL
+ AND (error ILIKE '%P0%' OR error ILIKE '%parked%' OR error ILIKE '%yield%'
+ OR error ILIKE '%reclaim%' OR error ILIKE '%cleared%' OR error ILIKE '%superseded%'
+ OR error ILIKE '%exclusive%' OR error ILIKE '%batch_%' OR error ILIKE '%inproc%'
+ OR error ILIKE '%formula%')`, jobID)
+ }
+ }
+ }()
+
+ _, _ = pool.Exec(ctx, `
+ UPDATE processing_jobs
+ SET status = 'running', started_at = COALESCE(started_at, now()), updated_at = now()
+ WHERE id = $1`, jobID)
+
+ runCtx, runCancel := context.WithTimeout(ctx, 15*time.Minute)
+ defer runCancel()
+ if err := pipeline.ProcessJob(runCtx, jobID); err != nil {
+ close(defend)
+ log.Fatalf("ProcessJob: %v", err)
+ }
+ close(defend)
+
+ items, err := pipeline.LoadV1ProcessJobItems(ctx, companyID, jobID, "full")
+ if err != nil {
+ log.Fatalf("LoadV1ProcessJobItems: %v", err)
+ }
+ payload := map[string]any{
+ "data": map[string]any{
+ "process_id": jobID.String(),
+ "status": "COMPLETED",
+ "processing_type": "full",
+ "total_items": len(items),
+ "items": items,
+ },
+ }
+ raw, err := json.MarshalIndent(payload, "", " ")
+ if err != nil {
+ log.Fatalf("marshal: %v", err)
+ }
+ if err := os.WriteFile(out+string(os.PathSeparator)+"final.json", raw, 0o644); err != nil {
+ log.Fatalf("write final: %v", err)
+ }
+ fmt.Printf("JOB %s items=%d\n", jobID, len(items))
+ for _, it := range items {
+ ean, _ := it["ean"].(string)
+ st, _ := it["status"].(string)
+ title, _ := it["title"].(string)
+ cat, _ := it["category"].(string)
+ catn, _ := it["category_name"].(string)
+ desc, _ := it["description"].(string)
+ if len(title) > 50 {
+ title = title[:50]
+ }
+ ai, _ := it["ai_provider_mode"].(string)
+ fmt.Printf("ITEM %s status=%s cat=%s/%s ai=%s desc_len=%d title=%s\n",
+ ean, st, cat, catn, ai, len(desc), title)
+ }
+}
diff --git a/apps/api/cmd/_live_llm_diag_tmp/main.go b/apps/api/cmd/_live_llm_diag_tmp/main.go
new file mode 100644
index 0000000..867d796
--- /dev/null
+++ b/apps/api/cmd/_live_llm_diag_tmp/main.go
@@ -0,0 +1,83 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "strings"
+ "time"
+
+ "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"
+)
+
+func main() {
+ cfg, err := config.Load()
+ if err != nil {
+ fmt.Println("config:", err)
+ os.Exit(1)
+ }
+ ctx := context.Background()
+ pool, err := db.NewPool(ctx, cfg.DatabaseURL, db.PoolOptions{
+ MaxConns: 2,
+ MinConns: 1,
+ HealthCheckPeriod: 30 * time.Second,
+ MaxConnLifetime: time.Hour,
+ MaxConnIdleTime: 30 * time.Minute,
+ })
+ if err != nil {
+ fmt.Println("db:", err)
+ os.Exit(1)
+ }
+ 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,
+ })
+ rc, err := plat.ResolveAIConfig(ctx, platformsettings.AIRoleProcessing)
+ if err != nil {
+ fmt.Println("resolve:", err)
+ os.Exit(1)
+ }
+ base := strings.TrimRight(strings.TrimSpace(rc.BaseURL), "/")
+ url := base + "/chat/completions"
+ body := map[string]any{
+ "model": rc.Model,
+ "messages": []map[string]string{
+ {"role": "user", "content": "Say PONG"},
+ },
+ "max_tokens": 16,
+ }
+ rawBody, _ := json.Marshal(body)
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(rawBody))
+ if err != nil {
+ fmt.Println("req:", err)
+ os.Exit(1)
+ }
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Authorization", "Bearer "+rc.APIKey)
+ cli := &http.Client{Timeout: 30 * time.Second}
+ resp, err := cli.Do(req)
+ if err != nil {
+ fmt.Println("do:", err)
+ os.Exit(1)
+ }
+ defer resp.Body.Close()
+ raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1200))
+ s := string(raw)
+ s = strings.ReplaceAll(s, rc.APIKey, "[redacted]")
+ fmt.Printf("source=%s provider=%s model=%s\n", rc.Source, rc.Provider, rc.Model)
+ fmt.Printf("url=%s status=%d bytes=%d\n", url, resp.StatusCode, len(raw))
+ fmt.Printf("content_type=%s\n", resp.Header.Get("Content-Type"))
+ fmt.Printf("body_head=%q\n", s)
+}
diff --git a/apps/api/cmd/_live_llm_dump_db_tmp/main.go b/apps/api/cmd/_live_llm_dump_db_tmp/main.go
new file mode 100644
index 0000000..91d1bf9
--- /dev/null
+++ b/apps/api/cmd/_live_llm_dump_db_tmp/main.go
@@ -0,0 +1,99 @@
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "strings"
+ "time"
+
+ "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/processing"
+ "github.com/google/uuid"
+)
+
+func main() {
+ out := `F:\laragon\www\_MY\descrybe-v2\.codehelper\_live_llm_formula_20260816`
+ jobID := uuid.MustParse("3d926dfa-de8a-4afc-b1dd-8707633c673e")
+ cfg, err := config.Load()
+ if err != nil {
+ panic(err)
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
+ defer cancel()
+ pool, err := db.NewPool(ctx, cfg.DatabaseURL, db.PoolOptions{
+ MaxConns: 4, MinConns: 0,
+ MaxConnLifetime: time.Hour, HealthCheckPeriod: 30 * time.Second,
+ })
+ if err != nil {
+ panic(err)
+ }
+ defer pool.Close()
+
+ rows, err := pool.Query(ctx, `
+ SELECT rp.gtin,
+ COALESCE(pp.processed_name, pp.name, '') AS title,
+ COALESCE(NULLIF(pp.processed_description, ''), pp.description, '') AS description,
+ COALESCE(pp.category, '') AS category,
+ COALESCE(c.name, '') AS category_name,
+ c.description_template,
+ pp.id
+ FROM processing_job_products pjp
+ JOIN raw_products rp ON rp.id = pjp.raw_product_id
+ LEFT JOIN processed_products pp ON pp.id = pjp.processed_product_id
+ LEFT JOIN categories c ON c.company_id = pp.company_id AND c.unique_id = pp.category
+ WHERE pjp.job_id = $1
+ ORDER BY rp.gtin`, jobID)
+ if err != nil {
+ panic(err)
+ }
+ defer rows.Close()
+ products := []map[string]any{}
+ for rows.Next() {
+ var gtin, title, desc, cat, catName string
+ var tmpl any
+ var ppID *uuid.UUID
+ if err := rows.Scan(>in, &title, &desc, &cat, &catName, &tmpl, &ppID); err != nil {
+ panic(err)
+ }
+ idStr := ""
+ if ppID != nil {
+ idStr = ppID.String()
+ }
+ lower := strings.ToLower(desc)
+ products = append(products, map[string]any{
+ "ean": gtin,
+ "product_id": idStr,
+ "title": title,
+ "description": desc,
+ "category": cat,
+ "category_name": catName,
+ "description_template": tmpl,
+ "formula_constraint": processing.FormatDescriptionFormulaConstraint(tmpl),
+ "has_h1": strings.Contains(lower, "
40 {
+ t = t[:40]
+ }
+ fmt.Printf("%s cat=%s/%s h1=%v h2=%v p=%v ul=%v desc_ne_title=%v len=%v title=%q\n",
+ p["ean"], p["category"], p["category_name"], p["has_h1"], p["has_h2"], p["has_p"], p["has_ul"], p["desc_ne_title"], p["desc_len"], t)
+ d := p["description"].(string)
+ if len(d) > 220 {
+ d = d[:220]
+ }
+ fmt.Printf(" desc_preview=%q\n", d)
+ }
+}
diff --git a/apps/api/cmd/_live_llm_enhance_budget_20260816_tmp/main.go b/apps/api/cmd/_live_llm_enhance_budget_20260816_tmp/main.go
new file mode 100644
index 0000000..db0ed13
--- /dev/null
+++ b/apps/api/cmd/_live_llm_enhance_budget_20260816_tmp/main.go
@@ -0,0 +1,171 @@
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log"
+ "os"
+ "path/filepath"
+ "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() {
+ out := os.Getenv("PROBE_OUT_DIR")
+ if out == "" {
+ out = `F:\laragon\www\_MY\descrybe-v2\.codehelper\_live_llm_enhance_budget_20260816`
+ }
+ _ = os.MkdirAll(out, 0o755)
+
+ cfg, err := config.Load()
+ if err != nil {
+ log.Fatalf("config: %v", err)
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 12*time.Minute)
+ defer cancel()
+
+ pool, err := db.NewPool(ctx, cfg.DatabaseURL, db.PoolOptions{
+ MaxConns: 2,
+ MinConns: 1,
+ HealthCheckPeriod: 30 * time.Second,
+ MaxConnLifetime: time.Hour,
+ MaxConnIdleTime: 30 * time.Minute,
+ })
+ if err != nil {
+ log.Fatalf("db: %v", 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,
+ })
+ 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
+
+ companyID := uuid.MustParse("2b3159b0-fc08-415b-b248-35ed02a6baab")
+ completer, mode, byok, err := aiSvc.ResolveCompleterForRole(ctx, companyID, aiprovider.RoleProcessing)
+ if err != nil {
+ log.Fatalf("resolve: %v", err)
+ }
+ client, ok := completer.(*processing.OpenAIClient)
+ if !ok || client == nil {
+ log.Fatalf("completer type %T", completer)
+ }
+ if processing.IsMockOrLoopbackBaseURL(client.BaseURL) {
+ log.Fatalf("resolved mock/loopback base=%s — refuse", client.BaseURL)
+ }
+
+ system := `Reply with ONLY one JSON object: {"name":string,"description":string}.
+description MUST be HTML using exactly these tags in order: ,
,
, ,
,
,
.
+No markdown. No prose outside JSON.`
+ cases := []struct {
+ ean string
+ user string
+ }{
+ {
+ ean: "1200130000638",
+ user: "Category: Bluetooth zvočniki\nName: JBL Flip 6\nDesc: Portable Bluetooth speaker.\nAttrs: {\"brand\":\"JBL\"}",
+ },
+ {
+ ean: "195348253666",
+ user: "Category: Gaming monitorji\nName: Lenovo G27-20\nDesc: 27-inch gaming monitor.\nAttrs: {\"brand\":\"Lenovo\"}",
+ },
+ }
+
+ results := make([]map[string]any, 0, len(cases))
+ okLLM := 0
+ for _, c := range cases {
+ started := time.Now()
+ comp, obj, cerr := processing.CompleteJSON(ctx, client, system, c.user, processing.CompleteOptions{
+ MaxTokens: processing.MaxTokensEnhance,
+ Temperature: processing.DefaultStructuredTemp,
+ })
+ elapsed := time.Since(started).Round(time.Millisecond)
+ fr := ""
+ if m, ok := comp.Raw.(map[string]any); ok {
+ fr, _ = m["finish_reason"].(string)
+ }
+ row := map[string]any{
+ "ean": c.ean,
+ "elapsed": elapsed.String(),
+ "err": "",
+ "finish_reason": fr,
+ "prompt_tokens": comp.PromptTokens,
+ "completion_tokens": comp.OutputTokens,
+ "total_tokens": comp.TotalTokens,
+ "content_runes": len([]rune(comp.Text)),
+ "parsed": obj != nil,
+ "llm_formula_ok": false,
+ }
+ if cerr != nil {
+ row["err"] = processing.TruncateError(cerr)
+ }
+ if obj != nil {
+ desc := fmt.Sprint(obj["description"])
+ row["name"] = fmt.Sprint(obj["name"])
+ row["description_head"] = truncate(desc, 180)
+ hasTags := strings.Contains(desc, "
8 {
+ keyHint = k[:4] + "…" + k[len(k)-4:]
+ } else if k := strings.TrimSpace(client.APIKey); k != "" {
+ keyHint = "(short)"
+ }
+ resolveInfo["api_key_hint"] = keyHint
+ resolveInfo["retried_at_utc"] = time.Now().UTC().Format(time.RFC3339)
+
+ modelsCtx, modelsCancel := context.WithTimeout(ctx, 30*time.Second)
+ defer modelsCancel()
+ modelsReq, merr := http.NewRequestWithContext(modelsCtx, http.MethodGet, modelsURL, nil)
+ if merr != nil {
+ resolveInfo["probe_ok"] = false
+ resolveInfo["fail_reason"] = "models request build: " + merr.Error()
+ writeJSON(out, "resolve.json", resolveInfo)
+ log.Fatalf("LIVE LLM CONNECTION ERROR: models req: %v", merr)
+ }
+ modelsReq.Header.Set("Authorization", "Bearer "+client.APIKey)
+ modelsReq.Header.Set("Accept", "application/json")
+ modelsStart := time.Now()
+ modelsResp, merr := http.DefaultClient.Do(modelsReq)
+ modelsElapsed := time.Since(modelsStart).Round(time.Millisecond)
+ if merr != nil {
+ resolveInfo["probe_ok"] = false
+ resolveInfo["models_url"] = modelsURL
+ resolveInfo["models_error"] = processing.TruncateError(merr)
+ resolveInfo["models_elapsed"] = modelsElapsed.String()
+ resolveInfo["fail_reason"] = "GET /v1/models network error"
+ writeJSON(out, "resolve.json", resolveInfo)
+ log.Fatalf("LIVE LLM CONNECTION ERROR: GET %s elapsed=%s err=%s",
+ modelsURL, modelsElapsed, processing.TruncateError(merr))
+ }
+ modelsBody, _ := io.ReadAll(io.LimitReader(modelsResp.Body, 2048))
+ _ = modelsResp.Body.Close()
+ modelsSnippet := strings.TrimSpace(string(modelsBody))
+ if len(modelsSnippet) > 240 {
+ modelsSnippet = modelsSnippet[:240] + "…"
+ }
+ resolveInfo["models_url"] = modelsURL
+ resolveInfo["models_http_status"] = modelsResp.StatusCode
+ resolveInfo["models_content_type"] = modelsResp.Header.Get("Content-Type")
+ resolveInfo["models_elapsed"] = modelsElapsed.String()
+ resolveInfo["models_body_snippet"] = modelsSnippet
+ if modelsResp.StatusCode != http.StatusOK {
+ resolveInfo["probe_ok"] = false
+ resolveInfo["http_status"] = modelsResp.StatusCode
+ resolveInfo["fail_reason"] = fmt.Sprintf("GET /v1/models returned HTTP %d (expect 200; 401=wrong/missing key, 502=upstream)", modelsResp.StatusCode)
+ writeJSON(out, "resolve.json", resolveInfo)
+ log.Fatalf("LIVE LLM CONNECTION ERROR: GET %s status=%d ct=%s snippet=%q",
+ modelsURL, modelsResp.StatusCode, modelsResp.Header.Get("Content-Type"), modelsSnippet)
+ }
+ log.Printf("live models ok status=200 elapsed=%s key_hint=%s", modelsElapsed, keyHint)
+
+ probeCtx, probeCancel := context.WithTimeout(ctx, 45*time.Second)
+ defer probeCancel()
+ probeStart := time.Now()
+ comp, perr := client.Complete(probeCtx, "Reply with exactly: PONG", "Say PONG")
+ probeElapsed := time.Since(probeStart).Round(time.Millisecond)
+ if perr != nil {
+ resolveInfo["probe_ok"] = false
+ resolveInfo["probe_error"] = processing.TruncateError(perr)
+ resolveInfo["probe_elapsed"] = probeElapsed.String()
+ resolveInfo["fail_reason"] = "chat/completions probe failed after models 200"
+ writeJSON(out, "resolve.json", resolveInfo)
+ log.Fatalf("LIVE LLM CONNECTION ERROR: base=%s model=%s elapsed=%s err=%s",
+ base, model, probeElapsed, processing.TruncateError(perr))
+ }
+ resolveInfo["probe_ok"] = true
+ resolveInfo["probe_elapsed"] = probeElapsed.String()
+ resolveInfo["probe_response_len"] = len(comp.Text)
+ writeJSON(out, "resolve.json", resolveInfo)
+ log.Printf("live probe ok elapsed=%s response_len=%d", probeElapsed, len(comp.Text))
+
+ pipeline := processing.NewPipeline(pool)
+ pipeline.BatchSize = cfg.ProcessingBatchSize
+ pipeline.AI = aiSvc
+ pipeline.Prompts = aiprompts.NewService(pool)
+ eprelClient := eprel.NewClient(eprel.Options{Enabled: true, Timeout: 20 * time.Second})
+ pipeline.Engine = &processing.Engine{
+ Completer: nil, // per-job via pipeline.AI
+ Vector: processing.NoopVectorCategorizer{},
+ EPREL: eprelClient,
+ ProviderMode: processing.AIProviderInternal,
+ }
+
+ rawIDs := make([]uuid.UUID, 0, len(eans))
+ for _, ean := range eans {
+ var id uuid.UUID
+ err := pool.QueryRow(ctx, `
+ SELECT id FROM raw_products
+ WHERE company_id = $1 AND gtin = $2`, companyID, ean).Scan(&id)
+ if err != nil {
+ log.Fatalf("raw product %s: %v", ean, err)
+ }
+ rawIDs = append(rawIDs, id)
+ log.Printf("ean=%s raw_id=%s", ean, id)
+ }
+
+ // Force re-enhance: clear prior enhance_input_hash for these raw products.
+ tag, cerr2 := pool.Exec(ctx, `
+ UPDATE processed_products pp
+ SET field_sources = COALESCE(field_sources, '{}'::jsonb) - 'enhance_input_hash',
+ localized = CASE
+ WHEN localized IS NULL OR localized = '{}'::jsonb THEN localized
+ ELSE (
+ SELECT COALESCE(jsonb_object_agg(lang, val - 'enhance_input_hash'), '{}'::jsonb)
+ FROM jsonb_each(localized) AS t(lang, val)
+ )
+ END,
+ 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)
+ if cerr2 != nil {
+ log.Printf("clear enhance hash: %v", cerr2)
+ } else {
+ log.Printf("cleared enhance_input_hash rows=%d", tag.RowsAffected())
+ }
+
+ if n, err := processing.BackfillMissingMeta(ctx, pool, companyID); err != nil {
+ log.Printf("BackfillMissingMeta: %v", err)
+ } else {
+ log.Printf("BackfillMissingMeta updated=%d", n)
+ }
+
+ // Soft-yield competing queue noise (do not mass-fail the whole company queue).
+ _, _ = pool.Exec(ctx, `
+ UPDATE processing_jobs
+ SET status = 'failed', error = 'yielded to live LLM formula probe', 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 {
+ log.Fatalf("StartJob: %v", err)
+ }
+ if len(jobs) == 0 {
+ log.Fatal("StartJob returned no jobs")
+ }
+ jobID := jobs[0].ID
+ log.Printf("job_id=%s chunks=%d", jobID, len(jobs))
+ _ = os.WriteFile(filepath.Join(out, "process_id.txt"), []byte(jobID.String()+"\n"), 0o644)
+
+ defend := make(chan struct{})
+ go func() {
+ t := time.NewTicker(500 * time.Millisecond)
+ defer t.Stop()
+ for {
+ select {
+ case <-defend:
+ return
+ case <-ctx.Done():
+ return
+ case <-t.C:
+ _, _ = pool.Exec(context.Background(), `
+ UPDATE processing_jobs
+ SET status = 'failed', error = 'yielded to live LLM formula probe', updated_at = now()
+ WHERE company_id = $1 AND status IN ('pending','running','processing') AND id <> $2`, companyID, jobID)
+ _, _ = pool.Exec(context.Background(), `
+ UPDATE processing_jobs
+ SET status = 'running', error = NULL, updated_at = now()
+ WHERE id = $1 AND status IN ('failed','cancelled')
+ AND (error ILIKE '%P0%' OR error ILIKE '%parked%' OR error ILIKE '%yield%'
+ OR error ILIKE '%reclaim%' OR error ILIKE '%cleared%' OR error ILIKE '%superseded%'
+ OR error ILIKE '%exclusive%' OR error ILIKE '%batch_%' OR error ILIKE '%inproc%'
+ OR error ILIKE '%formula%' OR error ILIKE '%live LLM%')`, jobID)
+ _, _ = pool.Exec(context.Background(), `
+ UPDATE processing_job_products
+ SET status = 'pending', error = NULL, updated_at = now()
+ WHERE job_id = $1 AND status IN ('failed','cancelled')
+ AND processed_product_id IS NULL
+ AND (error ILIKE '%P0%' OR error ILIKE '%parked%' OR error ILIKE '%yield%'
+ OR error ILIKE '%reclaim%' OR error ILIKE '%cleared%' OR error ILIKE '%superseded%'
+ OR error ILIKE '%exclusive%' OR error ILIKE '%batch_%' OR error ILIKE '%inproc%'
+ OR error ILIKE '%formula%' OR error ILIKE '%live LLM%')`, jobID)
+ }
+ }
+ }()
+
+ _, _ = pool.Exec(ctx, `
+ UPDATE processing_jobs
+ SET status = 'running', started_at = COALESCE(started_at, now()), updated_at = now()
+ WHERE id = $1`, jobID)
+
+ runCtx, runCancel := context.WithTimeout(ctx, 20*time.Minute)
+ defer runCancel()
+ if err := pipeline.ProcessJob(runCtx, jobID); err != nil {
+ close(defend)
+ log.Fatalf("ProcessJob: %v", err)
+ }
+ close(defend)
+
+ items, err := pipeline.LoadV1ProcessJobItems(ctx, companyID, jobID, "full")
+ if err != nil {
+ log.Fatalf("LoadV1ProcessJobItems: %v", err)
+ }
+ payload := map[string]any{
+ "data": map[string]any{
+ "process_id": jobID.String(),
+ "status": "COMPLETED",
+ "processing_type": "full",
+ "total_items": len(items),
+ "items": items,
+ "llm": map[string]any{
+ "base": base,
+ "model": model,
+ "source": roleCfg.Source,
+ "mode": modeLabel,
+ "byok": byok,
+ },
+ },
+ }
+ writeJSON(out, "final.json", payload)
+
+ // DB HTML descriptions (V1 may strip tags).
+ rows, err := pool.Query(ctx, `
+ SELECT rp.gtin,
+ COALESCE(pp.processed_name, pp.name, ''),
+ COALESCE(NULLIF(pp.processed_description, ''), pp.description, ''),
+ COALESCE(pp.category, ''),
+ COALESCE(c.name, ''),
+ COALESCE(c.unique_id, ''),
+ c.description_template,
+ pp.id
+ FROM processing_job_products pjp
+ JOIN raw_products rp ON rp.id = pjp.raw_product_id
+ LEFT JOIN processed_products pp ON pp.id = pjp.processed_product_id
+ LEFT JOIN categories c ON c.company_id = pp.company_id AND c.unique_id = pp.category
+ WHERE pjp.job_id = $1
+ ORDER BY rp.gtin`, jobID)
+ if err != nil {
+ log.Fatalf("db products: %v", err)
+ }
+ defer rows.Close()
+ dbProducts := make([]map[string]any, 0)
+ for rows.Next() {
+ var gtin, name, desc, cat, catName, catUID string
+ var tmpl any
+ var ppID *uuid.UUID
+ if err := rows.Scan(>in, &name, &desc, &cat, &catName, &catUID, &tmpl, &ppID); err != nil {
+ log.Fatal(err)
+ }
+ idStr := ""
+ if ppID != nil {
+ idStr = ppID.String()
+ }
+ dbProducts = append(dbProducts, map[string]any{
+ "ean": gtin,
+ "product_id": idStr,
+ "title": name,
+ "description": desc,
+ "category": cat,
+ "category_name": catName,
+ "category_unique_id": catUID,
+ "description_template": tmpl,
+ "formula_constraint": processing.FormatDescriptionFormulaConstraint(tmpl),
+ })
+ }
+ writeJSON(out, "db_products.json", dbProducts)
+
+ fmt.Printf("JOB %s items=%d live_base=%s model=%s\n", jobID, len(items), base, model)
+ for _, p := range dbProducts {
+ ean, _ := p["ean"].(string)
+ title, _ := p["title"].(string)
+ cat, _ := p["category"].(string)
+ catn, _ := p["category_name"].(string)
+ desc, _ := p["description"].(string)
+ if len(title) > 50 {
+ title = title[:50]
+ }
+ fmt.Printf("ITEM %s cat=%s/%s desc_len=%d title=%s\n", ean, cat, catn, len(desc), title)
+ }
+}
+
+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)
+ }
+}
diff --git a/apps/api/internal/catalog/repair_enhance_hashes.go b/apps/api/internal/catalog/repair_enhance_hashes.go
index 2b65423..87aac4f 100644
--- a/apps/api/internal/catalog/repair_enhance_hashes.go
+++ b/apps/api/internal/catalog/repair_enhance_hashes.go
@@ -23,7 +23,8 @@ type RepairWeakEnhanceHashesResult struct {
// RepairWeakEnhanceHashes clears enhance_input_hash from field_sources and
// localized_content for processed products whose descriptions are weak /
-// title-echo / filler (so the next enhance cannot hash-skip thin priors).
+// title-echo / filler / invent-synthesize (so the next enhance cannot hash-skip
+// thin or heuristic priors).
// Returns the number of products updated.
func RepairWeakEnhanceHashes(ctx context.Context, pool *pgxpool.Pool, companyID uuid.UUID) (int64, error) {
res, err := RepairWeakEnhanceHashesDetailed(ctx, pool, companyID)
@@ -124,7 +125,7 @@ func repairWeakEnhanceHashesTx(ctx context.Context, tx pgx.Tx, companyID uuid.UU
}
if _, has := fs[enhanceInputHashKey]; has {
- if company.IsWeakPriorEnhanceDescription(primaryDesc, primaryName, name) {
+ if company.ShouldRefuseEnhanceHashSkip(primaryDesc, primaryName, name) {
delete(fs, enhanceInputHashKey)
changed = true
}
@@ -139,7 +140,7 @@ func repairWeakEnhanceHashesTx(ctx context.Context, tx pgx.Tx, companyID uuid.UU
if strings.TrimSpace(fields.EnhanceInputHash) == "" {
continue
}
- if company.IsWeakPriorEnhanceDescription(d, n, primaryName, name) {
+ if company.ShouldRefuseEnhanceHashSkip(d, n, primaryName, name) {
fields.EnhanceInputHash = ""
loc[lang] = fields
changed = true
diff --git a/apps/api/internal/catalog/service.go b/apps/api/internal/catalog/service.go
index 25b97c6..59d0106 100644
--- a/apps/api/internal/catalog/service.go
+++ b/apps/api/internal/catalog/service.go
@@ -950,7 +950,7 @@ func (s *Service) ListRawProducts(ctx context.Context, companyID uuid.UUID, f Li
SELECT rp.id, rp.gtin, rp.feed_id, rp.is_processed, rp.processing_status,
COALESCE(NULLIF(rp.mapped_data->>'name', ''), NULLIF(rp.mapped_data->>'title', ''), '') AS name,
NULLIF(BTRIM(rp.mapped_data->>'category'), '') AS category,
- COALESCE(cat.name, NULLIF(BTRIM(rp.mapped_data->>'category'), '')) AS category_name,
+ cat.name AS category_name,
COALESCE(cat.unique_id, NULLIF(BTRIM(rp.mapped_data->>'category'), '')) AS category_unique_id,
f.name AS feed_name,
rp.mapped_data->'_sync_changes' AS sync_changes,
@@ -1099,7 +1099,7 @@ func (s *Service) ListProcessedProducts(ctx context.Context, companyID uuid.UUID
`+processedPreferredNameSQL+` AS name,
COALESCE(NULLIF(p.processed_name, ''), '') AS processed_name,
p.category,
- COALESCE(cat.name, NULLIF(BTRIM(p.category), '')) AS category_name,
+ cat.name AS category_name,
COALESCE(cat.unique_id, NULLIF(BTRIM(p.category), '')) AS category_unique_id,
p.status, p.raw_product_id, COALESCE(p.feed_id, r.feed_id) AS feed_id, r.gtin,
f.name AS feed_name,
@@ -1186,7 +1186,7 @@ func (s *Service) ListProcessedProductsDetailed(ctx context.Context, companyID u
SELECT p.id, p.product_id,
`+processedPreferredNameSQL+` AS name,
p.category,
- COALESCE(cat.name, NULLIF(BTRIM(p.category), '')) AS category_name,
+ cat.name AS category_name,
COALESCE(cat.unique_id, NULLIF(BTRIM(p.category), '')) AS category_unique_id,
p.status, p.raw_product_id, p.feed_id, r.gtin,
`+processedPreferredDescriptionSQL+` AS description,
@@ -1226,7 +1226,7 @@ func (s *Service) GetProcessedProduct(ctx context.Context, companyID, id uuid.UU
SELECT p.id, p.product_id,
`+processedPreferredNameSQL+` AS name,
p.category,
- COALESCE(cat.name, NULLIF(BTRIM(p.category), '')) AS category_name,
+ cat.name AS category_name,
COALESCE(cat.unique_id, NULLIF(BTRIM(p.category), '')) AS category_unique_id,
`+processedPreferredDescriptionSQL+` AS description,
p.processed_name, p.processed_description,
@@ -1279,7 +1279,7 @@ func (s *Service) GetRawProduct(ctx context.Context, companyID, id uuid.UUID) (m
COALESCE(NULLIF(rp.gtin, ''), '') AS product_id,
COALESCE(NULLIF(rp.mapped_data->>'name', ''), NULLIF(rp.mapped_data->>'title', ''), '') AS name,
NULLIF(BTRIM(rp.mapped_data->>'category'), '') AS category,
- COALESCE(cat.name, NULLIF(BTRIM(rp.mapped_data->>'category'), '')) AS category_name,
+ cat.name AS category_name,
COALESCE(cat.unique_id, NULLIF(BTRIM(rp.mapped_data->>'category'), '')) AS category_unique_id,
COALESCE(NULLIF(rp.mapped_data->>'description', ''), '') AS description,
''::text AS processed_name,
diff --git a/apps/api/internal/company/weak_desc.go b/apps/api/internal/company/weak_desc.go
index 670176d..082517f 100644
--- a/apps/api/internal/company/weak_desc.go
+++ b/apps/api/internal/company/weak_desc.go
@@ -25,25 +25,98 @@ var weakFillerPhrases = []string{
// IsWeakPriorEnhanceDescription reports empty, too-short, title-echo, known filler,
// or short boilerplate without overlapping tokens from title/fact sources.
+// Heuristic synthesize (invent) is intentionally excluded — use ShouldRefuseEnhanceHashSkip.
func IsWeakPriorEnhanceDescription(priorDesc string, titles ...string) bool {
- priorDesc = strings.TrimSpace(priorDesc)
- if priorDesc == "" || priorDesc == "" {
- return true
- }
- if len([]rune(priorDesc)) < minUsableProductDescRunes {
- return true
- }
- for _, title := range titles {
- if DescriptionEchoesTitle(priorDesc, title) {
+ reason := EnhanceHashSkipBlockReason(priorDesc, titles...)
+ return reason == "weak" || reason == "title-echo"
+}
+
+// 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).
+var heuristicSynthesizePhrases = []string{
+ "is a catalog product with the known attributes",
+ "is listed in the ",
+ ". key specs:",
+ "je katalogski izdelek z znanimi atributi",
+ "je izdelek v kategoriji",
+ "je izdelek znamke",
+ ". ključne specifikacije:",
+}
+
+// LooksLikeHeuristicSynthesize reports invent / formula-skeleton fallback copy.
+func LooksLikeHeuristicSynthesize(desc string) bool {
+ lower := strings.ToLower(desc)
+ for _, p := range heuristicSynthesizePhrases {
+ if p != "" && strings.Contains(lower, p) {
return true
}
}
- if ContainsWeakFillerPhrase(priorDesc) {
+ // EN invent: " is a product from "
+ if strings.Contains(lower, " is a ") && strings.Contains(lower, " product from ") {
return true
}
+ return false
+}
+
+// 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 {
+ priorDesc = strings.TrimSpace(priorDesc)
+ if priorDesc == "" || priorDesc == "" {
+ return "weak"
+ }
+ if len([]rune(priorDesc)) < minUsableProductDescRunes {
+ return "weak"
+ }
+ for _, title := range titles {
+ if DescriptionEchoesTitle(priorDesc, title) {
+ return "title-echo"
+ }
+ }
+ if ContainsWeakFillerPhrase(priorDesc) {
+ return "weak"
+ }
+ if LooksLikeHeuristicSynthesize(priorDesc) {
+ return "synth"
+ }
if len([]rune(priorDesc)) <= shortBoilerplateDescRunes &&
!descriptionOverlapsProductFacts(priorDesc, titles...) {
- return true
+ return "weak"
+ }
+ return ""
+}
+
+// ShouldRefuseEnhanceHashSkip is true when a stored enhance_input_hash must be
+// ignored / cleared (weak, title-echo, or heuristic synthesize).
+func ShouldRefuseEnhanceHashSkip(priorDesc string, titles ...string) bool {
+ return EnhanceHashSkipBlockReason(priorDesc, titles...) != ""
+}
+
+// DescriptionMissingFormulaHTMLTags is true when sectionTypes require HTML tags
+// (h1/h2/h3/h4, p, ul) that are absent from desc — formula-mismatch for skip/clear.
+func DescriptionMissingFormulaHTMLTags(desc string, sectionTypes []string) bool {
+ if len(sectionTypes) == 0 {
+ return false
+ }
+ lower := strings.ToLower(desc)
+ for _, raw := range sectionTypes {
+ typ := strings.ToLower(strings.TrimSpace(raw))
+ switch typ {
+ case "h1", "h2", "h3", "h4":
+ if !strings.Contains(lower, "<"+typ) {
+ return true
+ }
+ case "ul":
+ if !strings.Contains(lower, "
Body
", types) {
+ t.Fatal("full HTML must match")
+ }
+}
diff --git a/apps/api/internal/processing/ai.go b/apps/api/internal/processing/ai.go
index f7ea017..fa72b05 100644
--- a/apps/api/internal/processing/ai.go
+++ b/apps/api/internal/processing/ai.go
@@ -112,6 +112,14 @@ type ProductInput struct {
PriorProcessedDescription string
PriorCategory string
PriorLocalized company.LocalizedContent
+ // JobID / CompanyID / RawProductID are optional worker correlation ids for
+ // structured ai_enhance logs (never secrets). Empty in unit tests.
+ JobID string
+ CompanyID string
+ RawProductID string
+ // CategoryUniqueID is the taxonomy unique_id for overlay/formula keys when
+ // the enhance display category argument is a localized name.
+ CategoryUniqueID string
}
// CategoryFormulas holds optional title/description templates for one category key.
diff --git a/apps/api/internal/processing/catalog_fix.go b/apps/api/internal/processing/catalog_fix.go
index fbe225b..82ec2b4 100644
--- a/apps/api/internal/processing/catalog_fix.go
+++ b/apps/api/internal/processing/catalog_fix.go
@@ -356,12 +356,12 @@ func RecommendReprocessRawProductIDs(ctx context.Context, pool *pgxpool.Pool, co
if isPromptLabelTitle(primaryName) || isPromptLabelTitle(processedName) || isPromptLabelTitle(name) {
needs = true
}
- if isWeakPriorEnhanceDescription(primaryDesc, primaryName, name) {
+ if company.ShouldRefuseEnhanceHashSkip(primaryDesc, primaryName, name) {
needs = true
}
if _, has := fs[FieldEnhanceInputHash]; !has {
// Missing hash after weak clear / never enhanced — recommend when desc weak or empty category.
- if isWeakPriorEnhanceDescription(primaryDesc, primaryName, name) || strings.TrimSpace(category) == "" {
+ if company.ShouldRefuseEnhanceHashSkip(primaryDesc, primaryName, name) || strings.TrimSpace(category) == "" {
needs = true
}
}
@@ -371,7 +371,7 @@ func RecommendReprocessRawProductIDs(ctx context.Context, pool *pgxpool.Pool, co
if n == "" {
n = primaryName
}
- if fields.EnhanceInputHash == "" && isWeakPriorEnhanceDescription(d, n, primaryName, name) {
+ if fields.EnhanceInputHash == "" && company.ShouldRefuseEnhanceHashSkip(d, n, primaryName, name) {
needs = true
break
}
diff --git a/apps/api/internal/processing/category.go b/apps/api/internal/processing/category.go
index 9e53522..6e214ea 100644
--- a/apps/api/internal/processing/category.go
+++ b/apps/api/internal/processing/category.go
@@ -7,13 +7,21 @@ import (
)
// categoryDisplayLabel returns the human category name for meta / synthesize /
-// {{category}}. Prefers CategoryName; never falls back to a digits-only unique_id.
+// {{category}}. Prefers taxonomy CategoryName; never falls back to a digits-only
+// unique_id, prompt/formula leakage, or the product title itself.
func categoryDisplayLabel(result StepResult) string {
if n := strings.TrimSpace(result.CategoryName); n != "" {
- return n
+ // CategoryName comes from categories.name — trust unless prompt leakage.
+ if !isPromptLabelTitle(n) {
+ return n
+ }
}
cat := strings.TrimSpace(result.Category)
- if cat == "" || isDigitsOnlyCategoryID(cat) {
+ if cat == "" || isDigitsOnlyCategoryID(cat) || isPromptLabelTitle(cat) {
+ return ""
+ }
+ // Unresolved non-uid token: never surface the product name as {{category}}.
+ if categoryTokenEqualsProductTitle(cat, result.ProcessedName, result.Name) {
return ""
}
return cat
@@ -109,6 +117,10 @@ func normalizeCategoryUniqueID(s string) string {
if s == "" || s == "" || strings.EqualFold(s, "none") {
return ""
}
+ // Never treat enhance-prompt / formula scaffolding as a category unique_id.
+ if isPromptLabelTitle(s) {
+ return ""
+ }
return s
}
@@ -155,7 +167,10 @@ func applyCategoryFromMapped(out *StepResult, maps ...map[string]any) {
return
}
cat := categoryUniqueIDFromMaps(maps...)
- if cat == "" {
+ // Prompt/formula leakage never becomes Category. Product-title equals are
+ // scrubbed later (scrubCategoryPollution) so a title that matches a real
+ // taxonomy display name can still coerce to unique_id.
+ if cat == "" || isPromptLabelTitle(cat) {
return
}
out.Category = SanitizeText(cat)
@@ -213,6 +228,15 @@ func coerceCategoryToCompanyUniqueID(out *StepResult, namesByUID map[string]stri
if cat == "" {
return
}
+ if isPromptLabelTitle(cat) {
+ out.Category = ""
+ out.CategoryName = ""
+ if out.FieldSources == nil {
+ out.FieldSources = map[string]any{}
+ }
+ out.FieldSources["category"] = "cleared_invalid"
+ return
+ }
resolved := resolveCompanyCategoryUniqueID(cat, namesByUID, valid)
if resolved == "" || resolved == cat {
return
@@ -241,8 +265,10 @@ func filterCategoryIfInvalid(out *StepResult, valid map[string]struct{}) {
return
}
out.Category = ""
- if out.FieldSources != nil {
- delete(out.FieldSources, "category")
+ out.CategoryName = ""
+ if out.FieldSources == nil {
+ out.FieldSources = map[string]any{}
}
+ out.FieldSources["category"] = "cleared_invalid"
out.Notes = append(out.Notes, "category: ignored (unknown unique_id for company)")
}
diff --git a/apps/api/internal/processing/category_test.go b/apps/api/internal/processing/category_test.go
index df87832..6535367 100644
--- a/apps/api/internal/processing/category_test.go
+++ b/apps/api/internal/processing/category_test.go
@@ -133,6 +133,9 @@ func TestFilterCategoryIfInvalid(t *testing.T) {
if out.Category != "" {
t.Fatalf("invalid unique_id kept: %q", out.Category)
}
+ if src, _ := out.FieldSources["category"].(string); src != "cleared_invalid" {
+ t.Fatalf("field_sources.category=%v want cleared_invalid", out.FieldSources["category"])
+ }
out.Category = "50"
filterCategoryIfInvalid(&out, nil)
if out.Category != "50" {
@@ -179,6 +182,76 @@ func TestCoerceCategoryToCompanyUniqueID_nameToUID(t *testing.T) {
if out.Category != "" {
t.Fatalf("unknown should clear: %q", out.Category)
}
+ if src, _ := out.FieldSources["category"].(string); src != "cleared_invalid" {
+ t.Fatalf("field_sources.category=%v want cleared_invalid", out.FieldSources["category"])
+ }
+}
+
+// Product titles (and nested category.name copies of the title) must never land
+// in Category — only taxonomy unique_ids (+ display names from categories).
+func TestRunSteps_productTitleNeverBecomesCategory(t *testing.T) {
+ t.Parallel()
+ const productTitle = "Bosch Serie 6 WAU28PH0BY 9kg White Washing Machine"
+ names := map[string]string{"50": "Pralni stroji", "28": "TV mounts"}
+ e := &Engine{Vector: NoopVectorCategorizer{}}
+
+ out, err := e.RunSteps(context.Background(), "co", ProductInput{
+ GTIN: "4242005191234",
+ Name: productTitle,
+ Mapped: map[string]any{
+ "name": productTitle,
+ "category": map[string]any{
+ "name": productTitle, // feed pollution: product title as category.name
+ },
+ },
+ CategoryNamesByUID: names,
+ }, "normalize_only", nil, StepPolicy{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if out.Category == productTitle || strings.EqualFold(out.Category, productTitle) {
+ t.Fatalf("Category must not be product title: %q", out.Category)
+ }
+ if out.CategoryName == productTitle || strings.EqualFold(out.CategoryName, productTitle) {
+ t.Fatalf("CategoryName must not be product title: %q", out.CategoryName)
+ }
+ if cat := categoryDisplayLabel(out); cat == productTitle || strings.EqualFold(cat, productTitle) {
+ t.Fatalf("categoryDisplayLabel leaked product title: %q", cat)
+ }
+ if out.Category != "" {
+ t.Fatalf("Category=%q want empty (unresolved product-title token scrubbed)", out.Category)
+ }
+
+ // Plain string category == product title must also be scrubbed.
+ out2, err := e.RunSteps(context.Background(), "co", ProductInput{
+ GTIN: "4242005191235",
+ Name: productTitle,
+ Mapped: map[string]any{
+ "name": productTitle,
+ "category": productTitle,
+ },
+ CategoryNamesByUID: names,
+ }, "normalize_only", nil, StepPolicy{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if out2.Category != "" {
+ t.Fatalf("plain title-as-category kept: %q", out2.Category)
+ }
+
+ // Real taxonomy display name still coerces via processOne helpers.
+ out3 := StepResult{Category: "Pralni stroji", Name: productTitle, ProcessedName: productTitle}
+ valid := map[string]struct{}{"50": {}, "28": {}}
+ coerceCategoryToCompanyUniqueID(&out3, names, valid)
+ filterCategoryIfInvalid(&out3, valid)
+ scrubCategoryPollution(&out3, names, valid)
+ syncCategoryName(&out3, names)
+ if out3.Category != "50" {
+ t.Fatalf("taxonomy name coerce: Category=%q want 50", out3.Category)
+ }
+ if out3.CategoryName != "Pralni stroji" {
+ t.Fatalf("CategoryName=%q want Pralni stroji", out3.CategoryName)
+ }
}
func TestResolveCompanyCategoryUniqueID_usedByV1Projection(t *testing.T) {
diff --git a/apps/api/internal/processing/enhance_hash.go b/apps/api/internal/processing/enhance_hash.go
index d87c4b6..ba5d985 100644
--- a/apps/api/internal/processing/enhance_hash.go
+++ b/apps/api/internal/processing/enhance_hash.go
@@ -63,3 +63,12 @@ func enhanceStatusFromMeta(raw any) string {
s, _ := m["status"].(string)
return s
}
+
+func enhanceReasonFromMeta(raw any) string {
+ m, ok := raw.(map[string]any)
+ if !ok {
+ return ""
+ }
+ s, _ := m["reason"].(string)
+ return s
+}
diff --git a/apps/api/internal/processing/enhance_hash_test.go b/apps/api/internal/processing/enhance_hash_test.go
index c80b28b..2d40e4c 100644
--- a/apps/api/internal/processing/enhance_hash_test.go
+++ b/apps/api/internal/processing/enhance_hash_test.go
@@ -97,7 +97,7 @@ func TestRunSteps_skipsEnhanceWhenInputHashUnchanged(t *testing.T) {
t.Fatalf("preferKnownProviderMode(unknown, internal)=%q", got)
}
joined := strings.Join(out.Notes, ";")
- if !strings.Contains(joined, "unchanged") {
+ if !strings.Contains(joined, "ai_enhance_unchanged") {
t.Fatalf("notes=%v", out.Notes)
}
}
@@ -279,6 +279,195 @@ func TestRunSteps_synthesizedDescDoesNotPersistEnhanceHash(t *testing.T) {
}
}
+func TestRunSteps_formulaIgnoredByLLM_retriesThenSynthesizes(t *testing.T) {
+ calls := 0
+ var sawFormulaRetry bool
+ e := &Engine{
+ Completer: stubCompleter{fn: func(system, user string) (Completion, error) {
+ calls++
+ if strings.Contains(user, "INVALID DESCRIPTION") {
+ sawFormulaRetry = true
+ }
+ // Non-weak short prose that ignores multi-section HTML formula.
+ return Completion{
+ Text: `{"name":"GIGABYTE GS27QC","description":"GIGABYTE GS27QC is a gaming monitor with a 61 cm curved panel for immersive play."}`,
+ TotalTokens: 4,
+ }, nil
+ }},
+ Vector: NoopVectorCategorizer{},
+ }
+ descTpl := map[string]any{
+ "sections": []any{
+ map[string]any{"type": "h1", "instructions": "Heading"},
+ map[string]any{"type": "p", "instructions": "Body"},
+ map[string]any{"type": "ul", "instructions": "Specs"},
+ },
+ }
+ out, err := e.RunSteps(context.Background(), "co", ProductInput{
+ Name: "GIGABYTE GS27QC",
+ Mapped: map[string]any{"name": "GIGABYTE GS27QC", "category": "7", "brand": "GIGABYTE", "width": "61 cm"},
+ CategoryFormulasByKey: map[string]CategoryFormulas{
+ "7": {DescriptionTemplate: descTpl},
+ },
+ Language: "en",
+ }, "enhance_only", nil, StepPolicy{AllowAI: true, AllowEPREL: true})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if calls < 2 {
+ t.Fatalf("expected formula retry after ignore, calls=%d", calls)
+ }
+ if !sawFormulaRetry {
+ t.Fatal("expected INVALID DESCRIPTION retry user suffix")
+ }
+ for _, tag := range []string{"", "
", "