# Category formulas A category formula is what makes enhance *generate* copy instead of reshuffling the feed. The feed supplies facts; the formula supplies the shape. Legacy Descrybe (`descrybe-legacy/generator.php`) did this by making the category's `Prompt` column the entire user message — a "GPT predloga" with `{…}`, `{…}` and an HTML body of `

`/`

` pairs closing with a technical-specification list. v2 keeps that behaviour and adds structured `title_template` / `description_template` columns on top of it. ## Two formula sets | | A1 Slovenija (legacy plan) | Everyone else | |---|---|---| | Source | `scripts/seed/a1-category-prompts.json` (+ `wp_product_categories.sql`) | `apps/api/internal/aiprompts/defaults/default-category-prompts.json` | | Applied by | `cmd/seed-a1`, `cmd/repair-category-prompts`, Admin → Sync A1 | category create + enhance-time fallback | | Language of the formula text | Slovenian (the tenant's own copy) | English | | Prompt scaffolding | Slovenian (`GPT predloga:`, `Staro_ime_izdelka:`) | English (`Product template:`, `Old_product_name:`) | Both drive the same code path. The scaffolding language follows the formula's own language (`reSlovenianFormulaCue` in `processing/a1_enhance_prompt.go`) so A1 keeps the exact frame `generator.php` used. Output language is always `{{language}}` from the company's content language — an English formula still produces Slovenian copy for a Slovenian tenant. ## The English defaults `default-category-prompts.json` is the A1 seed translated: 116 categories, same naming formulas and body structure, generated by ``` make seed-defaults # regenerate make check-defaults # fail if stale (CI guard) ``` Translation is phrase-level over a closed vocabulary (55 title slots, 39 body blocks, 1 meta rule) and the generator **fails** if any Slovenian text survives, so a new legacy phrase can never ship untranslated. Where they apply: 1. **Category create** — `catalog.ApplyDefaultCategoryFormulas` fills `prompt`, `title_template` and `description_template` on new categories (API create and CSV taxonomy import). Only empty columns are written, so nothing existing is overwritten. 2. **Enhance-time fallback** — `processing.withDefaultCategoryFormula` supplies the default for a categorised product whose category still has no formula. It is skipped when the product has no category, when the category has its own prompt/formula, or when the tenant authored their own enhance template. A category outside the seeded taxonomy gets `GenericDefaultCategoryPrompt` — same structure, no category-specific naming slots. ## Proving it locally `cmd/formula-e2e` builds the pipeline the way `cmd/worker` does, runs a real job and checks the output is not a feed copy: ``` make up # Postgres :5433 cd apps/api && go run ./cmd/mock-llm & # formula-aware local LLM make formula-e2e ``` `cmd/mock-llm` reads the template out of the prompt and answers in its shape using only the supplied name/description/category/attrs. That proves two things a static stub cannot: the formula reached the model, and a compliant reply survives the pipeline's formula gate. Useful flags: ``` go run ./cmd/formula-e2e -company "A1 Slovenija" -gtin 8022068075495 -llm-base http://127.0.0.1:18767/v1 go run ./cmd/formula-e2e -new-tenant -llm-base http://127.0.0.1:18767/v1 ``` `-llm-base` pins the provider for the run; without it the harness uses whatever `platformsettings.ResolveOpenAI` returns (admin DB setting first, then env). ## Why enhance can still return feed copy In order of how often it bites: 1. **The product has no category.** No category → no formula. Roughly 83% of the A1 catalog has no `mapped_data.category` (see `scripts/seed/README.txt`); those rely on the `categorize` step, which needs a working LLM or Pinecone. 2. **The AI provider is unreachable.** Enhance falls back to supplier copy and marks `field_sources.name = ai_enhance_failed`. Check the admin OpenAI setting — it overrides the `OPENAI_*` env values. 3. **The plan/credits gate.** `ai_enhance skip reason=entitlement_can_use_ai` or `insufficient credits`. 4. **The model ignored the formula.** One retry is issued (`descriptionFormulaRetrySuffix` / `titleRewriteRetrySuffix`); if it still fails, the result is marked `synthesized`/`refused` and no `enhance_input_hash` is stored, so a reprocess tries again rather than caching the bad copy. `processing: ai_enhance outcome=… category_uid=… title_formula=… desc_formula=… formula_override=…` in the worker log tells you which of these happened.