Categories classifier (#201) β
The /browse/ Categories facet is backed by a hybrid embedding-similarity β LLM-fallback classifier with admin override surfaces.
- Spec: docs/superpowers/specs/2026-06-07-categories-facet-design.md
- Plan: docs/superpowers/plans/2026-06-08-201-categories-facet.md
- Tracking: #201
Flow β
Mission/Group/Tutorial INSERT/UPDATE
β (categories-after-hooks.js β debounced 5s on UPDATE of title/description/primaryTag)
classifyAndPersist(kind, id)
β
1. Try embedding path:
getSeedEmbeddings() β cosine vs each category seed β top match
If top.score >= HIGH_THRESHOLD (0.32) AND top1βtop2 >= AMBIGUITY_GAP (0.05) β use it
2. Else LLM fallback:
classifyViaLlm() β forced tool call (submit_categories) via SAP AI SDK
Default model: anthropic--claude-4.6-sonnet (overridable via ChatSettings.modelName)
3. Else skip (item stays uncategorized)
β
Persist (delete-then-insert in cds.tx):
DELETE FROM <junction> WHERE <fk> = id
INSERT new rows (top-N by score, capped at 3)Decision-tree tunables β
| Constant | Default | What it controls |
|---|---|---|
HIGH_THRESHOLD | 0.32 | Min cosine to use embedding path. Calibrated for text-embedding-3-small (1536-dim). Below this β LLM fallback. |
AMBIGUITY_GAP | 0.05 | If top-1 and top-2 cosines are within this gap, the embedding result is "ambiguous" and we fall through to the LLM. |
MAX_CATEGORIES | 3 | Per-item cap. The LLM tool-call schema enforces this server-side too. |
LLM_TIMEOUT_MS | 8000 | Same as srv/lib/code-check-llm.js for parity. |
BACKFILL_CONCURRENCY | 4 | Max parallel classify calls during bulk admin action and one-shot backfill. |
All constants live at the top of srv/lib/category-classifier.js. Tune via PR.
Tuning seed descriptions β
Each category row carries a seedDescription (LargeString). The classifier embeds this at first use and keeps the vector in an in-memory cache (no persistent column β recomputable, ~1.5KB Γ 8 categories).
To improve classifier accuracy for a specific category:
- Open
/admin-ui/#categories-display. - Edit the row's
seedDescriptionto better reflect the kind of content that should land in this category. - Save. (The save invalidates only that one cache entry β next classify call recomputes it.)
- Click Embed seeds in the bulk-ops bar to eagerly re-embed all 8 categories so subsequent classifies see the new vectors immediately.
- Click Re-classify everything (force) to flush the new categorization through the catalog.
Deploy choreography (one-shot backfill) β
bash
# 1. Schema deploys (new tables empty; CSV seed populates Categories table)
cf push tutorials-db-deployer ...
# 2. Srv deploys (classifier service available)
cf push tutorials-srv ...
# 3. One-shot backfill (~5β10 min for ~1,500 items at concurrency 4)
cds bind --exec -- node scripts/backfill-categories.cjs --kind=all
# 4. Refresh /browse/ rail activeCounts
gh workflow run rebuild-content.ymlThe rebuild trigger fires automatically on each backfill write (debounced via srv/lib/rebuild-trigger.js), but doing it manually after the bulk run avoids ~1,500 individual triggers.
Error-handling matrix β
| Failure | Behavior |
|---|---|
| Embedding endpoint times out | Fall through to LLM path |
| LLM endpoint times out | Log item-id, skip β item stays uncategorized |
| LLM returns slugs not in master taxonomy | Filter to known slugs; if none survive β skip |
| LLM tool-call args fail JSON parse | Same as above (skip) |
| Per-item exception in backfill | Log item-id, increment failed counter, continue |
| Two admins run bulk reclassify simultaneously | Second one sees {processed:0, skipped:1} β categories-classify job-lock held |
Followups β
See the spec's "Followups" section.