authoring-content-worker — business features

Writes the actual lesson content (screens, text, quizzes) for a course an admin is authoring · ← platform hub · entry points verified against tools/feature-docs/out/authoring-content-worker.json

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 itNo human calls it directly. Its only client is authoring-service-v2, which sends it work over NATS on behalf of an admin.
RuntimeFastAPI shell (health probes only) + 5 JetStream consumers · Deployment content-worker, replicas: 1 (authoring-content-worker/k8s/deployment.yaml:17)
DatabaseNo 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.
RedisNone.
NATS streamsConsumes on AUTHORING_CONTENT (authoring.content.>, k8s/configmap.yaml:41); publishes cost rows onto AUTHORING_USAGE
External APIsOpenRouter (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 points2 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

admin

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.requestedauthoring.content.regenerate.ready / .failed
Touches
drafts.state (authoring-v2 DB), content_generation_runs, OpenRouter
Related
Triggered by the regenerate-block tool in the shared tool catalog
Evidence
consumer app/workers/content_worker.py:131 · publisher libs/oper-tools/oper_tools/catalog.json:1381 dispatched at authoring-service-v2/app/services/tool_dispatcher.py:287 · result consumed at authoring-service-v2/app/consumers/register.py:47-48

Translate a published module live

admin

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 · publisher authoring-service-v2/app/services/publish_service.py:51 · client app/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

authoring-service-v2

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 (durable content-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 · publishers authoring-service-v2/app/services/extraction_orchestration.py:1183 (default contract v3, authoring-service-v2/app/core/config.py:227) and authoring-service-v2/app/services/research_orchestration.py:565

Write one concept — original contract (v1) live

authoring-service-v2

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 (durable content-worker-generate)
Touches
drafts.state, content_generation_runs, OpenRouter
Related
Legacy engine selected when a run's outline_engine is legacy (the deployment default, authoring-service-v2/app/core/config.py:216)
Evidence
consumer app/workers/content_worker.py:93 · publishers authoring-service-v2/app/workflows/doc_to_module.py:415 and catalog tool libs/oper-tools/oper_tools/catalog.json:1344 via tool_dispatcher.py:287

Write one screen — extraction contract (v2) suspect

authoring-service-v2

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 (durable content-worker-generate-v2)
Touches
Same as v3 when exercised
Related
Searched: whole repo for the subject string and for EXTRACTION_CONTENT_CONTRACT in every k8s manifest — no override exists
Evidence
consumer app/workers/content_worker.py:106 · conditional publisher authoring-service-v2/app/services/extraction_orchestration.py:1183 · default authoring-service-v2/app/core/config.py:227

Report what each generation cost live

authoring-service-v2

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_USAGE stream
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 at authoring-service-v2/app/consumers/register.py:68 · stream definition rag-context-worker/nats/streams/authoring-usage.json

Background

Concurrent message dispatch live

background

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 _run per 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.

MethodPathAuthFeatureCallersVerdict
GET/healthnoneLivenesskubelet livenessProbe, k8s/deployment.yaml:107live
GET/readynoneReadiness (DB + NATS)kubelet readinessProbe, k8s/deployment.yaml:101live

5. Async contracts

Consumes

SubjectStreamDurablePublished byFeatureVerdict
authoring.content.generation.requested.v3AUTHORING_CONTENTcontent-worker-generate-v3authoring-service-v2 — extraction_orchestration.py:1183, research_orchestration.py:565Write a lesson (v3)live
authoring.content.generation.requestedAUTHORING_CONTENTcontent-worker-generateauthoring-service-v2 — workflows/doc_to_module.py:415; tool generate-content-for-conceptWrite one concept (v1)live
authoring.content.generation.requested.v2AUTHORING_CONTENTcontent-worker-generate-v2authoring-service-v2 only when EXTRACTION_CONTENT_CONTRACT=v2; unset everywhereWrite one screen (v2)suspect
authoring.content.regenerate.requestedAUTHORING_CONTENTcontent-worker-regenerateauthoring-service-v2 tool regenerate-block (catalog.json:1381tool_dispatcher.py:287)Rewrite one blocklive
authoring.content.translate.requestedAUTHORING_CONTENTcontent-worker-translateauthoring-service-v2 — services/publish_service.py:51Translate a modulelive

Publishes

SubjectConsumed byFeatureVerdict
authoring.content.generation.ready content_worker.py:252,306,417,468,588,636authoring-service-v2 content_consumer.on_content_generation_ready (register.py:45)Lesson/concept writtenlive
authoring.content.generation.failed _base.py:83,109authoring-service-v2 (register.py:46)Generation gave uplive
authoring.content.generation.progress orchestrator.py:155, orchestrator_v2.py:129, orchestrator_v3.py:119authoring-service-v2 (register.py:50) — progress bar onlyStage tickslive
authoring.content.regenerate.ready content_worker.py:747authoring-service-v2 (register.py:47)Rewrite one blocklive
authoring.content.regenerate.failed _base.py:130authoring-service-v2 (register.py:48)Rewrite failedlive
authoring.usage.event _base.py:192authoring-service-v2 usage_consumer (register.py:68) on AUTHORING_USAGECredit meteringlive

Background jobs

JobScheduleWhat it doesVerdict
_run per-message task (app/services/nats_client.py:229)Not scheduled — one task per delivered messageRuns a handler under a concurrency semaphore so several generations can wait on the model at oncelive

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.

TableWhat it holdsWritten by
content_generation_runsOne 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_runsRead-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 pointKindVerdictEvidence
authoring.content.generation.requested.v2 (durable content-worker-generate-v2)NATS consumersuspectPublisher 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