Enrichment system redesign — proposal
For review before implementation. Goal: a small set of clearly-named services, a clean sub-task taxonomy with pools simplified to context-length tiers, a trigger-based scheduler, a one-file way to define a new task, data read/write on postcrimedb (.181/postcrime), and stats-mining + web-chart caching folded in as scheduled enrichment.
1. What exists today (the sprawl we're replacing)
| Piece | Role | Trigger | Problem |
|---|---|---|---|
fraud-publisher |
producer — scans corpus.db selectSql, enqueues to RabbitMQ fraud.rows |
blind 30 s poll | no triggers; scans everything every 30 s |
publisher-regex |
same, split out for regex tasks | 30 s poll | duplicate of publisher |
fraud-enricher-v2 |
consumer — pulls envelope, dispatches LLM via gpumon reply-mode, writes via broker | RabbitMQ | reports only aggregate progress |
enrich-worker |
legacy v1 polling enricher | SQL poll | idle, 0 replicas |
processor-triples |
separate 60 s poll for triples | poll | one-off service for one task |
enrich-*.yml one-shots |
manual backfills | one-shot | 10+ near-duplicate stacks |
stats-miner |
42 stat panels → SeaweedFS + stats.panels |
weekly/manual | completely separate from enrichment |
Naming confusion found: entities-ner is just the entity-extraction stage of the entities
workflow; it dispatches to a pool literally named pool-extract-triples (a misnomer — that pool
is qwen3.6-27b used for all JSON extraction, not triples specifically). Pools today encode
model + context-length + workload all at once (pool-postcrime-qwen-14b, pool-summary-medium,
…), so a task's name, its pool's name, and the model are three different vocabularies.
2. Proposed services (4, clear roles)
crawler/new docs ─┐
task completions ─┤→ enrich-scheduler ──enqueue──▶ enrich-queue ──▶ enrich-runner (N replicas)
failures / skips ─┘ (the brain) (RabbitMQ, one routing (the muscle)
key per task type) │
▼
postcrimedb (.181)
| New service | = today's | Role |
|---|---|---|
| enrich-scheduler | enrich-worker (repurposed) |
THE BRAIN. Decides what needs enriching and enqueues it, by trigger (below). Owns watermarks, the task dependency DAG, retry/backoff, and stats-rebuild scheduling. Emits queue depth per task type. |
| enrich-queue | fraud.rows (renamed) |
RabbitMQ with one routing key per task (enrich.title, enrich.summarize, …) so depth + progress are per-task, plus a per-task DLQ. |
| enrich-runner | fraud-enricher-v2 (generalized) |
THE MUSCLE. N replicas consume any task, run it (regex / LLM-via-gpumon / vision / embed), write to postcrimedb. Each replica emits per-task-type progress (done/total/rate/errors), not one aggregate. Absorbs processor-triples + the one-shot stacks. |
| enrich-tasks (library, not a service) | processor/src/extract/* |
The task definitions (one file each), shared by scheduler + runner. |
fraud-publisher, publisher-regex, enrich-worker, processor-triples, and the enrich-*.yml
one-shots all collapse into the three services above. stats-miner becomes a set of tasks (§6).
3. Sub-task taxonomy (clean names) + pool simplification
Pools → context-length tiers only. A task declares the ctx tier it needs; the pool is just a ctx-routed target that picks/falls-back across models. Model choice leaves the task vocabulary.
| New pool | Replaces | Routes to (ctx budget) |
|---|---|---|
pool-ctx-s |
pool-enrich-titles, scheme/triples short calls | small ≤8 K — fast model (qwen-14b/gemma) |
pool-ctx-m |
pool-postcrime-qwen-large/14b, summary-medium | medium ≤32 K — mid model (qwen3.6-27b) |
pool-ctx-l |
summary-short(100K), full-doc | large ≤128 K — long-ctx model |
pool-vision |
pool-ocr, pool-vision | image modality (not ctx) |
pool-embed |
pool-embed | embeddings modality |
Tasks (clean, purpose-named; the runner picks the pool from kind+ctx):
| Task name | Replaces | kind | pool | What it does |
|---|---|---|---|---|
title |
titles, format-title, title-llm | regex+llm | ctx-s | Format the document title (regex first, LLM gap-fill) |
extract-entities |
entities, entities-ner | regex+llm | ctx-s | Pull people/orgs/agencies/tickers (regex tiers, LLM fallback) |
extract-triples |
triples, processor-triples | llm | ctx-m | Subject-verb-object knowledge triples |
extract-case-facts |
case-facts | regex+llm | ctx-m | Defendants, dates, plea/sentence, $ amounts |
summarize |
summarize | llm | ctx-m/l | Document summary (length-tiered) |
classify-scheme |
scheme-classify | llm | ctx-s | Fraud-scheme classification |
link-references |
link-references | regex | — | Statute/citation cross-links |
recover-ocr |
ocr-recover | vision | vision | Re-OCR [OCR_UNRECOVERABLE] / empty bodies |
refetch-pdf |
refetch-pdf | http | — | Re-fetch + validate the source PDF |
embed |
(embedder) | embed | embed | Chunk + embed for vector search |
stats-<panel> |
stats-miner panels | sql | — | Materialize a stat panel + cache its chart (§6) |
entities-ner → extract-entities (it's entity extraction; "ner" + "extract-triples" pool were
the confusing bits).
4. Defining a new task — one file
Extend WorkflowDef into a declarative EnrichmentTask (one file in enrich-tasks/), so adding
a task is: write the file, drop it in the dir (auto-registered). The scheduler reads triggers +
selects; the runner reads run + writes; both read reportsAs.
export const task: EnrichmentTask = {
name: "summarize",
kind: "llm", // regex | llm | vision | embed | http | sql
ctx: "m", // → pool-ctx-m (only for kind:llm)
reportsAs: "summarize", // /progress + gpumon workflow_stage label
dependsOn: ["recover-ocr"], // re-runs when an upstream task changes this doc (DAG)
triggers: ["new-doc", "backfill", "on-dep-change", "on-failure"],
// WHAT needs it (Postgres now, not corpus.db):
selects: (sql, scope) => sql`SELECT id, body, ocr_text FROM postcrime.documents
WHERE summary_combined IS NULL ${scope}`,
// DO it (dispatch helper injects the pool + gpumon attribution):
run: async (doc, llm) => ({ summary_combined: await llm.chat(doc.body) }),
// WRITE it (postcrimedb single-writer path):
writes: { table: "postcrime.documents", key: "id", cols: ["summary_combined"] },
};
5. Scheduling model (triggers)
enrich-scheduler enqueues per task based on declared triggers:
| Trigger | Fires when | Mechanism |
|---|---|---|
new-doc / new-matter |
crawler inserts a row | watermark on documents.created_at / matters.created_at (or LISTEN/NOTIFY) |
backfill |
task's selects matches + not done |
bounded scan, low priority |
on-skip |
a run returned skip |
re-queue with backoff after N hours (e.g. OCR pool was down) |
on-failure |
DLQ after max attempts | re-queue with exponential backoff; cap attempts |
on-dep-change |
an upstream task (dependsOn) rewrote the doc |
DAG: e.g. recover-ocr fills a body → summarize+extract-entities re-fire on it |
on-data-change |
rows relevant to a stats-* task changed |
debounced stats rebuild (§6) |
A task_state(task, doc_id) → status, attempts, last_ts, watermark table (in postcrimedb) replaces
the scattered workflow_runs/queued_* columns and makes "what's pending/skipped/failed" one query.
6. Stats mining + web-chart caching as enrichment
Each of the 42 stats-miner panels becomes a stats-<panel> task with kind: "sql" and
triggers: ["on-data-change", "schedule"]. When enrichment changes the underlying rows (or on a
cadence), the scheduler debounce-fires the affected panels; the runner materializes the panel into
stats.panels and writes the rendered chart payload to SeaweedFS …/dashboard/stats/cache/.
Result: stats stay fresh automatically as enrichment runs, no separate weekly miner.
7. Data read/write → postcrimedb
- Read:
selects()queriespostcrimedb(.181, schemapostcrime) — the canonical store — not SQLite corpus.db. - Write: a single
postcrime-writerpath (the runner, or a thin writer the runner calls) owns writes to postcrimedb with upsert discipline, replacing the broker/apply/*→ SQLite + dual-write. corpus.db/broker can stay as a transitional fallback during a soak, then retire.
8. Migration path (incremental, low-risk)
- Land
EnrichmentTask+ theenrich-tasks/registry; port existing workflows into it (mechanical). - Build
enrich-runner(generalize enricher-v2) reading PG, emitting per-task progress; run it alongside the old enricher on a few tasks; verify parity on/progress. - Build
enrich-scheduler(repurpose enrich-worker) with triggers; cut producers over to it; retirefraud-publisher/publisher-regex/processor-triples/one-shots. - Fold stats panels in as
stats-*tasks; retirestats-miner. - Flip reads/writes fully to postcrimedb; retire the SQLite path.
Open decisions for you
- Pools: confirm the ctx-tier simplification (
pool-ctx-s/m/l+ vision + embed), vs. keeping workload-named pools. - PG writer: runner writes postcrimedb directly (single-writer per task) vs. keep a thin broker-style writer service in front.
- Triggers: Postgres LISTEN/NOTIFY for new-doc/dep-change (event-driven) vs. watermark polling (simpler, ~few-sec latency).
- Scope: all-at-once remake vs. the incremental §8 path (recommended).