1. What it is
When an admin turns a document or a prompt into a training module, something has to write the actual teaching material — the screens, the explanatory text, the quiz questions. This worker does that: it takes one lesson (or one concept) at a time, asks a large language model to write it against Oper's authoring framework, checks the result, and saves it straight into the draft the admin is looking at. It also rewrites a single block when an admin clicks "regenerate", and translates a published module into another language.
| Who uses it | No human calls it directly. Its only client is authoring-service-v2, which sends it work over NATS on behalf of an admin. |
| Runtime | FastAPI shell (health probes only) + 5 JetStream consumers · Deployment content-worker, replicas: 1 (authoring-content-worker/k8s/deployment.yaml:17) |
| Database | No database of its own. It connects to authoring-service-v2's database authoring_service_v2_db (authoring-content-worker/k8s/secret.yaml:15) — see §6. |
| Redis | None. |
| NATS streams | Consumes on AUTHORING_CONTENT (authoring.content.>, k8s/configmap.yaml:41); publishes cost rows onto AUTHORING_USAGE |
| External APIs | OpenRouter (LLM calls, app/services/openrouter.py). In-cluster HTTP: rag-context-worker for retrieved context (edge H6) and micro-learning for translations (edge H7). |
| Entry points | 2 HTTP routes (health only) · 5 NATS consumers · 6 published subjects · 1 in-process dispatch task |
2. Feature map
flowchart LR ADMIN["Admin (authoring UI)"] --> AV2[authoring-service-v2] AV2 --> F1["Write a lesson (v3)"] AV2 --> F2["Write a concept (v1 / v2)"] AV2 --> F3["Rewrite one block"] AV2 --> F4["Translate a module"] F1 --> C3["authoring.content.generation.requested.v3"] F2 --> C1["authoring.content.generation.requested(.v2)"] F3 --> C4["authoring.content.regenerate.requested"] F4 --> C5["authoring.content.translate.requested"] C3 --> DRAFT["writes drafts.state + .ready event"] C1 --> DRAFT C4 --> DRAFT C5 --> ML["micro-learning translation store"]
3. Features
Admin
Rewrite a single block live
An admin reading a generated lesson can reject one piece of it — a paragraph, an image caption, a quiz option — and ask for a fresh attempt without regenerating the whole lesson. The worker rewrites only that block and writes it back into the draft, so everything else the admin already approved stays untouched.
- Entry points
authoring.content.regenerate.requested→authoring.content.regenerate.ready/.failed- Touches
drafts.state(authoring-v2 DB),content_generation_runs, OpenRouter- Related
- Triggered by the
regenerate-blocktool in the shared tool catalog - Evidence
- consumer
app/workers/content_worker.py:131· publisherlibs/oper-tools/oper_tools/catalog.json:1381dispatched atauthoring-service-v2/app/services/tool_dispatcher.py:287· result consumed atauthoring-service-v2/app/consumers/register.py:47-48
Translate a published module live
A module written in one language can be offered to employees in another. When an admin publishes with extra languages, this worker translates every lesson and writes the translated copy into the learning service, lesson by lesson, reporting status as it goes. Employees then see the module in their own language in the mobile app.
- Entry points
authoring.content.translate.requested- Touches
- micro-learning
/v1/internal/translations/modules/…(source read, per-lesson write, status), OpenRouter - Related
- HTTP edge H7 in
ARCHITECTURE.md§3.2; published by authoring-v2's publish flow - Evidence
- consumer
app/workers/content_worker.py:143· publisherauthoring-service-v2/app/services/publish_service.py:51· clientapp/services/ml_client.py:99-155
Employee (mobile)
None. Employees never reach this worker; they read the finished module from micro-learning.
Internal (other services)
Write a lesson — current contract (v3) live
This is the main event. For every lesson in the outline the admin approved, the worker writes the whole lesson in one go: screens chosen from the approved archetypes, body text, checks for understanding, and a list of images that still need rendering. It saves the lesson into the draft and announces it, which is what makes the admin's progress bar move and, on the last lesson, what finishes the run.
- Entry points
authoring.content.generation.requested.v3(durablecontent-worker-generate-v3) →authoring.content.generation.ready/.failed/.progress- Touches
drafts.state,course_generation_runs(read),content_generation_runs, OpenRouter, rag-context-worker context blob- Related
- Doc→module and prompt→module flows,
ARCHITECTURE.md§3.3; screen-archetypes.md - Evidence
- consumer
app/workers/content_worker.py:121· publishersauthoring-service-v2/app/services/extraction_orchestration.py:1183(default contractv3,authoring-service-v2/app/core/config.py:227) andauthoring-service-v2/app/services/research_orchestration.py:565
Write one concept — original contract (v1) live
The older, finer-grained request: one message per concept instead of per lesson.
It is still wired up two ways — the legacy document workflow fans one message out per concept, and
the generate-content-for-concept tool lets the authoring chat ask for a single
concept. New documents created with the current engine do not use it.
- Entry points
authoring.content.generation.requested(durablecontent-worker-generate)- Touches
drafts.state,content_generation_runs, OpenRouter- Related
- Legacy engine selected when a run's
outline_engineislegacy(the deployment default,authoring-service-v2/app/core/config.py:216) - Evidence
- consumer
app/workers/content_worker.py:93· publishersauthoring-service-v2/app/workflows/doc_to_module.py:415and catalog toollibs/oper-tools/oper_tools/catalog.json:1344viatool_dispatcher.py:287
Write one screen — extraction contract (v2) suspect
A middle generation of the same feature: one message per concept, with the screen
skeleton pre-created by authoring-v2. Nothing sends it today. authoring-v2 only publishes this
subject when its content contract is explicitly set to v2, and the shipped default is
v3 with no override in the cluster config — so this consumer sits idle while its two
siblings do the work. Retiring it is a decision for authoring-v2's owners, since the flag is the
documented rollback path off v3.
- Entry points
authoring.content.generation.requested.v2(durablecontent-worker-generate-v2)- Touches
- Same as v3 when exercised
- Related
- Searched: whole repo for the subject string and for
EXTRACTION_CONTENT_CONTRACTin every k8s manifest — no override exists - Evidence
- consumer
app/workers/content_worker.py:106· conditional publisherauthoring-service-v2/app/services/extraction_orchestration.py:1183· defaultauthoring-service-v2/app/core/config.py:227
Report what each generation cost live
Every LLM call this worker makes is reported as a usage row. authoring-v2 collects those rows and turns them into the tenant's credit ledger, which is what lets Oper bill for generation and refuse a run when a tenant is out of credit.
- Entry points
authoring.usage.event(publish only)- Touches
AUTHORING_USAGEstream- Related
- Five of the six pipeline workers publish this subject; authoring-v2's usage consumer is the only reader.
- Evidence
app/workers/_base.py:192· consumed atauthoring-service-v2/app/consumers/register.py:68· stream definitionrag-context-worker/nats/streams/authoring-usage.json
Background
Concurrent message dispatch live
Generation is slow because the model is slow. Rather than handling one message at a time, each delivered message is run as its own task under a semaphore, so several lessons can wait on the model at once while the draft writes still take turns on the row lock.
- Entry points
- in-process task
_runper delivered message (not scheduled) - Touches
- All five consumers
- Related
- Concurrency ceiling
CONTENT_MAX_ACK_PENDING=40(k8s/configmap.yaml:37) - Evidence
app/services/nats_client.py:229
4. API reference
There is no business API. Both routes exist for Kubernetes.
| Method | Path | Auth | Feature | Callers | Verdict |
|---|---|---|---|---|---|
| GET | /health | none | Liveness | kubelet livenessProbe, k8s/deployment.yaml:107 | live |
| GET | /ready | none | Readiness (DB + NATS) | kubelet readinessProbe, k8s/deployment.yaml:101 | live |
5. Async contracts
Consumes
| Subject | Stream | Durable | Published by | Feature | Verdict |
|---|---|---|---|---|---|
authoring.content.generation.requested.v3 | AUTHORING_CONTENT | content-worker-generate-v3 | authoring-service-v2 — extraction_orchestration.py:1183, research_orchestration.py:565 | Write a lesson (v3) | live |
authoring.content.generation.requested | AUTHORING_CONTENT | content-worker-generate | authoring-service-v2 — workflows/doc_to_module.py:415; tool generate-content-for-concept | Write one concept (v1) | live |
authoring.content.generation.requested.v2 | AUTHORING_CONTENT | content-worker-generate-v2 | authoring-service-v2 only when EXTRACTION_CONTENT_CONTRACT=v2; unset everywhere | Write one screen (v2) | suspect |
authoring.content.regenerate.requested | AUTHORING_CONTENT | content-worker-regenerate | authoring-service-v2 tool regenerate-block (catalog.json:1381 → tool_dispatcher.py:287) | Rewrite one block | live |
authoring.content.translate.requested | AUTHORING_CONTENT | content-worker-translate | authoring-service-v2 — services/publish_service.py:51 | Translate a module | live |
Publishes
| Subject | Consumed by | Feature | Verdict |
|---|---|---|---|
authoring.content.generation.ready content_worker.py:252,306,417,468,588,636 | authoring-service-v2 content_consumer.on_content_generation_ready (register.py:45) | Lesson/concept written | live |
authoring.content.generation.failed _base.py:83,109 | authoring-service-v2 (register.py:46) | Generation gave up | live |
authoring.content.generation.progress orchestrator.py:155, orchestrator_v2.py:129, orchestrator_v3.py:119 | authoring-service-v2 (register.py:50) — progress bar only | Stage ticks | live |
authoring.content.regenerate.ready content_worker.py:747 | authoring-service-v2 (register.py:47) | Rewrite one block | live |
authoring.content.regenerate.failed _base.py:130 | authoring-service-v2 (register.py:48) | Rewrite failed | live |
authoring.usage.event _base.py:192 | authoring-service-v2 usage_consumer (register.py:68) on AUTHORING_USAGE | Credit metering | live |
Background jobs
| Job | Schedule | What it does | Verdict |
|---|---|---|---|
_run per-message task (app/services/nats_client.py:229) | Not scheduled — one task per delivered message | Runs a handler under a concurrency semaphore so several generations can wait on the model at once | live |
No cron, no CronJob, no polling loop: every unit of work arrives as a NATS message.
6. Data it owns
This worker owns no database. It points at authoring-service-v2's database
(authoring_service_v2_db, k8s/secret.yaml:15) and writes the admin's draft
in place with raw SQL: it loads drafts.state under SELECT … FOR UPDATE,
merges the generated screens into the JSON tree, and writes it back. That row lock is the only thing
serialising two lessons of the same run. This is one of the platform's two-writer couplings recorded
in ARCHITECTURE.md §3.4 — a schema change to drafts in authoring-v2 breaks
this worker silently, and the shared-table write is exactly why merging these two services is on the
consolidation list.
| Table | What it holds | Written by |
|---|---|---|
content_generation_runs | One row per generation attempt: event id (unique, for de-duplication), status, timestamps. This is the worker's own table, created by its own alembic revisions, but it lives in authoring-v2's database. | This worker only (migrations/versions/001_create_content_generation_runs.py, 002_per_concept_runs.py) |
drafts (state jsonb) | The admin's in-progress module. The worker merges generated screens/blocks into it. | Cross-service write — authoring-service-v2 owns the table; this worker also writes it (app/pipeline/block_writer.py:78-108, lock at :88) |
course_generation_runs | Read-only lookup: run id → draft id, module id. | authoring-service-v2 (this worker only reads, app/pipeline/block_writer.py:60-78) |
7. Dependencies
flowchart LR
AV2[authoring-service-v2] -. NATS requests .-> CW[content-worker]
CW -. ready / failed / progress / usage .-> AV2
CW -- "SQL: drafts.state FOR UPDATE" --> DB[("authoring_service_v2_db")]
CW -- "HTTP H6: context blob" --> RAG[rag-context-worker]
CW -- "HTTP H7: translations" --> ML[micro-learning-service-v2]
CW -- LLM calls --> OR[OpenRouter]
8. Dead-code verdicts
Every entry point with no in-repo caller. Deleting is a separate decision — see the hub roll-up.
| Entry point | Kind | Verdict | Evidence |
|---|---|---|---|
authoring.content.generation.requested.v2 (durable content-worker-generate-v2) | NATS consumer | suspect | Publisher exists but is flag-gated off: authoring-service-v2/app/services/extraction_orchestration.py:1183 publishes it only when extraction_content_contract == "v2"; the default is v3 (authoring-service-v2/app/core/config.py:227) and no ConfigMap in the repo sets EXTRACTION_CONTENT_CONTRACT. Searched every .py/.yaml/.md/.json for the subject string. Not dead: flipping one env var makes it the live path again. |
Everything else on this page has a named in-repo publisher, consumer or kubelet probe.
9. Sources
- authoring-content-worker/README.md — NATS contract table, stream rules, scaling note
- authoring-content-worker/TODO.md — known contract drift and the replicas/max_ack_pending constraint
- docs/screen-archetypes.md, docs/agent-orchestrator-architecture.md, docs/agent-orchestrator-langgraph.md
- bug-hunt-reports/authoring-content-worker.md — 7 findings; #1 (lost concept on hard crash) and #4 (unwrapped regenerate publish) fixed
- ARCHITECTURE.md §3.1 (spoke topology), §3.2 (H6, H7), §3.3 (streams and the doc→module flow), §3.4 (shared-DB writers)
authoring-service-v2/docs/worker-integration.md§4.3 — the contract this worker implements- Scan output
tools/feature-docs/out/authoring-content-worker.json