1. What it is
This worker has two unrelated jobs. The obvious one: it suggests things to an author — the outline a course should follow, and the improvement cards that appear in the editor sidebar ("this lesson is hard to read", "these three text blocks in a row need a break"). The less obvious one, and the more load-bearing: it is the only service that holds the OpenRouter account key, so authoring-service-v2 calls it over HTTP whenever authoring itself needs a model — including on a screen the admin is sitting in front of.
| Who uses it | authoring-service-v2 only — over NATS for suggestions and over HTTP for model access and credit reads. No admin or mobile client reaches it directly. |
| Runtime | FastAPI (3 business routes + 2 probes) + 2 JetStream consumers · Deployment suggest-pipeline-worker, replicas: 1, strategy: Recreate (suggest-pipeline-worker/k8s/deployment.yaml:9-16) — the durables are single-binding, so a rolling update would have two pods fighting over them |
| Database | Reads and writes the micro-learning database microlearning (k8s/es-suggest-pipeline.yaml:35). It owns no schema of its own — see §6. |
| Redis | users-auth-redis DB index 8 (k8s/configmap.yaml:23, matching ARCHITECTURE.md §3.5) |
| NATS streams | AUTHORING_SUGGEST (authoring.suggest.>, 24 h); publishes usage and credit alerts onto AUTHORING_USAGE |
| External APIs | OpenRouter — this service owns the account key — plus OpenAI embeddings for de-duplication (ARCHITECTURE.md §3.6) |
| Entry points | 5 HTTP routes (3 internal + 2 probes) · 2 NATS consumers · 6 published subjects · 1 background loop |
One pod, one key, one user-blocking call
POST /v1/internal/prompt-context-questions sits on a path the admin is waiting on:
authoring-v2 calls it with a 60-second timeout
(authoring-service-v2/app/core/config.py:68) from the creation chat and the estimate
route. This worker runs a single replica with strategy: Recreate, so every deploy is a
short window in which that screen fails. Its own handler deliberately times out first and answers
504 so the caller sees an explicit error rather than an aborted request
(app/prompt_context_api.py:45-54). Committed-secret exposure for this same key is
recorded in ARCHITECTURE.md §4.4.
2. Feature map
flowchart LR ADMIN["Admin in the course editor"] --> AV2[authoring-service-v2] AV2 -. NATS .-> F1["Draft a course outline"] AV2 -. NATS .-> F2["Suggest improvements"] AV2 -- "HTTP (60s, blocking)" --> F3["Screen a course prompt"] AV2 -- HTTP --> F4["Read OpenRouter cost + balance"] F1 --> S1["authoring.suggest.outline.ready"] F2 --> S2["authoring.suggest.suggestions.ready"] F3 --> Q["missing-context questions"] F4 --> LEDGER["nightly cost reconciliation"] WATCH["Credits watchdog"] --> LOW["authoring.usage.credits_low"]
3. Features
Admin
Ask the admin for the context their prompt is missing live
"Make me a forklift course" is not enough to generate from — who is it for, and what should they be able to do afterwards? This worker screens the admin's prompt (or the uploaded document's filename and first pages) with a model, decides which of those questions are already answered, and offers options in the prompt's own language. authoring-v2 renders them as the follow-up questions in the creation chat, so the admin fills one gap instead of re-writing their brief.
- Entry points
POST /v1/internal/prompt-context-questions- Touches
- OpenRouter; publishes
authoring.usage.eventfor the call - Related
- HTTP edge H3 in
ARCHITECTURE.md§3.2 — user-blocking, 60 s caller timeout - Evidence
- route
app/prompt_context_api.py:31· callersauthoring-service-v2/app/services/creation_chat_service.py:339andauthoring-service-v2/app/routes/estimate_routes.py:192viaauthoring-service-v2/app/core/http.py:401-423
Suggest improvements in the editor sidebar live
While an admin edits a course, this worker looks at what they have and proposes concrete cards: sharpen the goal, fix the description, adjust the skills, add an image, restructure a lesson, break up a wall of text. Each card carries a rationale and a priority so the admin can act on the top one and ignore the rest, and previously dismissed or duplicate suggestions are filtered out so the sidebar does not nag.
- Entry points
authoring.suggest.suggestions.requested(durablesuggest-pipeline-worker-suggestions) →authoring.suggest.suggestions.ready- Touches
suggestions,suggestion_runs,suggestion_dismissals, plus read-onlymodules/lessons/lesson_blocksin the micro-learning DB; OpenRouter; OpenAI embeddings- Related
- Two triggers — the course form changing, and a lesson being opened or a manual refresh (spec §4, §5). Readability and block-distribution checks are pure Python, not model calls (
app/pipeline/checks.py). - Evidence
- consumer
app/workers/suggestions_worker.py:39· publisher: toolrequest-suggestions(libs/oper-tools/oper_tools/catalog.json:1494) dispatched atauthoring-service-v2/app/services/tool_dispatcher.py:287· result consumed atauthoring-service-v2/app/consumers/register.py:42
Employee (mobile)
None.
Internal (other services)
Draft the outline a course should follow live
Step three of the document-to-module workflow: given the topic and the retrieved context from the uploaded document, produce the ordered list of concepts the course will teach. Each concept comes back with an id, a title and a position, and that list is exactly what authoring-v2 fans out to content-worker as one lesson request per concept — so this output determines the shape and the cost of the whole run.
- Entry points
authoring.suggest.outline.requested(durablesuggest-pipeline-worker-outline) →authoring.suggest.outline.ready/.failed- Touches
- rag-context-worker
GET /contexts/{id}/stream(edge H10; degrades to topic-only when unavailable), OpenRouter - Related
- Used by the legacy doc→module workflow and the
generate-outlinetool - Evidence
- consumer
app/workers/outline_worker.py:40· publishersauthoring-service-v2/app/workflows/doc_to_module.py:377andlibs/oper-tools/oper_tools/catalog.json:1306· context fetchapp/pipeline/outline.py:92-97· result consumed atauthoring-service-v2/app/consumers/register.py:40
Front the OpenRouter account for authoring live
Oper's rule is that no service outside the workers holds an LLM key. authoring-v2 still needs two facts from the provider: what a specific call actually cost, and what the account balance is. It gets them through these two read-only proxies, which is how the nightly reconciliation turns estimated costs into real ones and how the platform's billing figures stay honest.
- Entry points
GET /v1/internal/openrouter/credits,GET /v1/internal/openrouter/generation/{generation_id}- Touches
- OpenRouter account API (read-only)
- Related
- HTTP edge H3; both guarded by
X-Oper-Key(app/openrouter_api.py:20-24) - Evidence
- routes
app/openrouter_api.py:27,42· callersauthoring-service-v2/app/services/reconciliation_service.py:91,118viaauthoring-service-v2/app/core/http.py:452,468
Report what each suggestion cost live
Every model call — the two NATS paths and the synchronous prompt screening — reports a usage row, and cache hits deliberately do not, so a tenant is never billed twice for the same answer. authoring-v2 turns those rows into the credit ledger.
- Entry points
authoring.usage.event(publish only, best-effort)- Touches
AUTHORING_USAGEstream- Related
- Five of the six authoring pipeline workers publish this subject; authoring-v2's usage consumer is its only reader.
- Evidence
app/workers/_base.py:177,app/workers/outline_worker.py:130,app/workers/suggestions_worker.py:128,app/prompt_context_api.py:78· consumed atauthoring-service-v2/app/consumers/register.py:68
Background
Credits watchdog suspect
Running out of OpenRouter credit is an outage, not a billing nuance: every generating worker in the platform shares this one account, so a dry balance turns all course generation into hard failures. This worker owns the key, so it polls the balance, logs it for alerting, and publishes a low-balance warning. The logging half is live and useful. The event half currently goes nowhere — nothing subscribes to it (see §8).
- Entry points
- loop
CreditsMonitor._loop→ publishesauthoring.usage.credits_low - Touches
- OpenRouter credits endpoint;
AUTHORING_USAGEstream - Related
- One alert per cooldown per pod, re-armed when the balance recovers; disabled when the poll interval is set to zero. Known gap:
stop()cancels the task without awaiting it (bug-hunt-reports/suggest-pipeline-worker.md#5). - Evidence
- loop start
app/services/credits_monitor.py:36(launched from the lifespan,app/main.py:127-128) · publishapp/services/credits_monitor.py:94
4. API reference
| Method | Path | Auth | Feature | Callers | Verdict |
|---|---|---|---|---|---|
| POST | /v1/internal/prompt-context-questions | X-Oper-Key (require_internal_key) | Screen a course prompt for missing context | authoring-service-v2 — creation_chat_service.py:339, routes/estimate_routes.py:192 (60 s timeout, user-blocking) | live |
| GET | /v1/internal/openrouter/credits | X-Oper-Key | Account balance for the cost reconciler | authoring-service-v2 — reconciliation_service.py:118 | live |
| GET | /v1/internal/openrouter/generation/{generation_id} | X-Oper-Key | Provider-side record of one call (real tokens + cost) | authoring-service-v2 — reconciliation_service.py:91 | live |
| GET | /health | none | Liveness | kubelet livenessProbe (k8s/deployment.yaml:86-89) | live |
| GET | /ready | none | Readiness (DB + NATS) | kubelet readinessProbe (k8s/deployment.yaml:80-84) | live |
5. Async contracts
Consumes
| Subject | Stream | Durable | Published by | Feature | Verdict |
|---|---|---|---|---|---|
authoring.suggest.outline.requested | AUTHORING_SUGGEST | suggest-pipeline-worker-outline (ack_wait 120 s, max_deliver 3) | authoring-service-v2 — app/workflows/doc_to_module.py:377; tool generate-outline via tool_dispatcher.py:287 | Draft a course outline | live |
authoring.suggest.suggestions.requested | AUTHORING_SUGGEST | suggest-pipeline-worker-suggestions (ack_wait 120 s) | authoring-service-v2 — tool request-suggestions (catalog.json:1494) via tool_dispatcher.py:287 | Suggest improvements | live |
Publishes
| Subject | Consumed by | Feature | Verdict |
|---|---|---|---|
authoring.suggest.outline.ready outline_worker.py:126 | authoring-service-v2 suggest_consumer.on_outline_ready (register.py:40) | Outline → content fan-out | live |
authoring.suggest.outline.failed outline_worker.py:66,101,117 via _base.publish_outline_failure:79 | authoring-service-v2 suggest_consumer.on_outline_failed (register.py:41) | Outline failed — run ends visibly | live |
authoring.suggest.suggestions.ready suggestions_worker.py:123 | authoring-service-v2 suggest_consumer.on_suggestions_ready (register.py:42) → pushed to the draft:{draft_id} WS topic | Editor sidebar cards | live |
authoring.suggest.suggestions.failed suggestions_worker.py:97,113 via _base.publish_suggestions_failure:104 | Nobody. authoring-v2 registers outline.failed but not this one (register.py:39-42) | Suggestions failed — never surfaces | dead |
authoring.usage.event _base.py:177; outline_worker.py:130; suggestions_worker.py:128; prompt_context_api.py:78 | authoring-service-v2 usage_consumer (register.py:68) | Credit metering | live |
authoring.usage.credits_low credits_monitor.py:94 | Nobody. Stored by AUTHORING_USAGE (authoring.usage.>) and never read | Credits watchdog | dead |
Background jobs
| Job | Schedule | What it does | Verdict |
|---|---|---|---|
CreditsMonitor._loop (app/services/credits_monitor.py:36) | Every OPENROUTER_CREDITS_POLL_SECONDS; disabled when ≤ 0 | Polls the OpenRouter balance, logs openrouter.credits for alerting, publishes a low-balance event that currently has no consumer | live (loop) · dead (its event) |
6. Data it owns
Three tables are functionally this worker's — and its own migrations/versions/
directory is empty (only a __pycache__), so it cannot create them. They are
created by a micro-learning-service-v2 migration, in micro-learning's database, because they sit next
to modules and lessons which the pipeline reads. In practice that means a
fresh environment must migrate micro-learning before this worker can do anything, and a schema change
here is a pull request against another service.
| Table | What it holds | Written by |
|---|---|---|
suggestion_runs | One row per suggestion pass: trigger, status, error message. The record of why the sidebar shows what it shows. | This worker (app/models/suggestion_run.py:13); schema from micro-learning-service-v2/sql/migrations/2026_05_15_create_suggestions_tables.sql |
suggestions | The cards themselves — type, target lesson, title, rationale, priority, confidence. | This worker (app/models/suggestion.py:41) |
suggestion_dismissals | What an admin has already waved away, so it is not proposed again. | This worker (app/models/suggestion_dismissal.py:12); read back by the de-duplicator (app/pipeline/deduplicator.py:128-137) |
Read-only: modules (including industry_vertical and target_audience_notes, added by micro-learning's 20260609_add_module_authoring_context.py), sections, lessons, lesson_screens, lesson_blocks. | ||
Cross-service write: this worker writes the microlearning database,
which ARCHITECTURE.md §3.4 lists as owned by micro-learning-service-v2 (plus
tts-worker-v2). The suggestion tables are disjoint from micro-learning's own writes, so this is a
schema-ownership coupling rather than a row-level race — but it is the same class of hazard.
7. Dependencies
flowchart LR
AV2[authoring-service-v2] -- "HTTP H3: prompt screening · credits · generation" --> SPW[suggest-pipeline-worker]
AV2 -. "outline.requested · suggestions.requested" .-> SPW
SPW -. "ready · failed · usage · credits_low" .-> AV2
SPW -- "HTTP H10: context blob" --> RAG[rag-context-worker]
SPW -- "LLM + embeddings" --> OR["OpenRouter (account key holder)"]
SPW -- "suggestion tables" --> DB[("microlearning DB")]
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.suggest.suggestions.failed | Published subject | dead | Published: app/workers/suggestions_worker.py:97,113 → app/workers/_base.py:104, subject from app/config.py:26. Unconsumed: authoring-v2's registry adds suggest.outline.ready, suggest.outline.failed and suggest.suggestions.ready and stops there (authoring-service-v2/app/consumers/register.py:39-42); its per-subject consumer manager has no wildcard, so no filter matches. Repo-wide search for the string finds only this worker's config and its own test. Consequence: a failed sidebar request is invisible to the admin — the tool call stays pending instead of erroring. The fix belongs in authoring-v2, not here. |
authoring.usage.credits_low | Published subject | dead | Published: app/services/credits_monitor.py:94, subject from app/config.py:31. Unconsumed: authoring-v2's usage consumer subscribes to authoring.usage.event exactly (register.py:68), not authoring.usage.>; repo-wide search for the string finds only this worker. The AUTHORING_USAGE stream does capture the messages for 30 days, so they are retained and unread. The code comment says it is "for the admin notification pipeline" — that pipeline was never wired. The logged openrouter.credits_low warning is the alerting path that actually works today. |
All five HTTP routes have a named caller or kubelet probe, and both consumers have a named authoring-v2 publisher.
9. Sources
- suggest-pipeline-worker/README.md — the two paths, the subject table, and the "schema lives in micro-learning" note
- suggest-pipeline-worker/TODO.md — contract-alignment log; still open: honouring
authoring.tool-call.cancelled, usage events for verifier calls - spec/SDD-suggest-pipeline-worker.md §4 (two-phase pipeline), §4.4 (deduplication), §5 (trigger rules), §6 (subjects), §7 (usage)
- bug-hunt-reports/suggest-pipeline-worker.md — 5 findings; #1 and #3 fixed, #2 (cross-tenant cache slot), #4 (publish outside try), #5 (watchdog cancel) open
- ARCHITECTURE.md §3.1 (spoke topology), §3.2 (H3, H10), §3.3 (
AUTHORING_SUGGEST), §3.4/§3.5 (micro-learning DB, Redis index 8), §3.6 (OpenRouter key ownership), §4.4 (committed secrets) app/main.py,app/workers/,app/prompt_context_api.py,app/openrouter_api.py,app/services/credits_monitor.py,app/pipeline/,app/models/,migrations/versions/(empty)- Scan output
tools/feature-docs/out/suggest-pipeline-worker.json