suggest-pipeline-worker — business features

Proposes course outlines and editor suggestions — and holds the platform's only OpenRouter account key · ← platform hub · entry points verified against tools/feature-docs/out/suggest-pipeline-worker.json

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 itauthoring-service-v2 only — over NATS for suggestions and over HTTP for model access and credit reads. No admin or mobile client reaches it directly.
RuntimeFastAPI (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
DatabaseReads and writes the micro-learning database microlearning (k8s/es-suggest-pipeline.yaml:35). It owns no schema of its own — see §6.
Redisusers-auth-redis DB index 8 (k8s/configmap.yaml:23, matching ARCHITECTURE.md §3.5)
NATS streamsAUTHORING_SUGGEST (authoring.suggest.>, 24 h); publishes usage and credit alerts onto AUTHORING_USAGE
External APIsOpenRouter — this service owns the account key — plus OpenAI embeddings for de-duplication (ARCHITECTURE.md §3.6)
Entry points5 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

admin

"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.event for 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 · callers authoring-service-v2/app/services/creation_chat_service.py:339 and authoring-service-v2/app/routes/estimate_routes.py:192 via authoring-service-v2/app/core/http.py:401-423

Suggest improvements in the editor sidebar live

admin

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 (durable suggest-pipeline-worker-suggestions) → authoring.suggest.suggestions.ready
Touches
suggestions, suggestion_runs, suggestion_dismissals, plus read-only modules/lessons/lesson_blocks in 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: tool request-suggestions (libs/oper-tools/oper_tools/catalog.json:1494) dispatched at authoring-service-v2/app/services/tool_dispatcher.py:287 · result consumed at authoring-service-v2/app/consumers/register.py:42

Employee (mobile)

None.

Internal (other services)

Draft the outline a course should follow live

authoring-service-v2

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 (durable suggest-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-outline tool
Evidence
consumer app/workers/outline_worker.py:40 · publishers authoring-service-v2/app/workflows/doc_to_module.py:377 and libs/oper-tools/oper_tools/catalog.json:1306 · context fetch app/pipeline/outline.py:92-97 · result consumed at authoring-service-v2/app/consumers/register.py:40

Front the OpenRouter account for authoring live

authoring-service-v2

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 · callers authoring-service-v2/app/services/reconciliation_service.py:91,118 via authoring-service-v2/app/core/http.py:452,468

Report what each suggestion cost live

authoring-service-v2

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_USAGE stream
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 at authoring-service-v2/app/consumers/register.py:68

Background

Credits watchdog suspect

background

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 → publishes authoring.usage.credits_low
Touches
OpenRouter credits endpoint; AUTHORING_USAGE stream
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) · publish app/services/credits_monitor.py:94

4. API reference

MethodPathAuthFeatureCallersVerdict
POST/v1/internal/prompt-context-questionsX-Oper-Key (require_internal_key)Screen a course prompt for missing contextauthoring-service-v2 — creation_chat_service.py:339, routes/estimate_routes.py:192 (60 s timeout, user-blocking)live
GET/v1/internal/openrouter/creditsX-Oper-KeyAccount balance for the cost reconcilerauthoring-service-v2 — reconciliation_service.py:118live
GET/v1/internal/openrouter/generation/{generation_id}X-Oper-KeyProvider-side record of one call (real tokens + cost)authoring-service-v2 — reconciliation_service.py:91live
GET/healthnoneLivenesskubelet livenessProbe (k8s/deployment.yaml:86-89)live
GET/readynoneReadiness (DB + NATS)kubelet readinessProbe (k8s/deployment.yaml:80-84)live

5. Async contracts

Consumes

SubjectStreamDurablePublished byFeatureVerdict
authoring.suggest.outline.requestedAUTHORING_SUGGESTsuggest-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:287Draft a course outlinelive
authoring.suggest.suggestions.requestedAUTHORING_SUGGESTsuggest-pipeline-worker-suggestions (ack_wait 120 s)authoring-service-v2 — tool request-suggestions (catalog.json:1494) via tool_dispatcher.py:287Suggest improvementslive

Publishes

SubjectConsumed byFeatureVerdict
authoring.suggest.outline.ready outline_worker.py:126authoring-service-v2 suggest_consumer.on_outline_ready (register.py:40)Outline → content fan-outlive
authoring.suggest.outline.failed outline_worker.py:66,101,117 via _base.publish_outline_failure:79authoring-service-v2 suggest_consumer.on_outline_failed (register.py:41)Outline failed — run ends visiblylive
authoring.suggest.suggestions.ready suggestions_worker.py:123authoring-service-v2 suggest_consumer.on_suggestions_ready (register.py:42) → pushed to the draft:{draft_id} WS topicEditor sidebar cardslive
authoring.suggest.suggestions.failed suggestions_worker.py:97,113 via _base.publish_suggestions_failure:104Nobody. authoring-v2 registers outline.failed but not this one (register.py:39-42)Suggestions failed — never surfacesdead
authoring.usage.event _base.py:177; outline_worker.py:130; suggestions_worker.py:128; prompt_context_api.py:78authoring-service-v2 usage_consumer (register.py:68)Credit meteringlive
authoring.usage.credits_low credits_monitor.py:94Nobody. Stored by AUTHORING_USAGE (authoring.usage.>) and never readCredits watchdogdead

Background jobs

JobScheduleWhat it doesVerdict
CreditsMonitor._loop (app/services/credits_monitor.py:36)Every OPENROUTER_CREDITS_POLL_SECONDS; disabled when ≤ 0Polls the OpenRouter balance, logs openrouter.credits for alerting, publishes a low-balance event that currently has no consumerlive (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.

TableWhat it holdsWritten by
suggestion_runsOne 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
suggestionsThe cards themselves — type, target lesson, title, rationale, priority, confidence.This worker (app/models/suggestion.py:41)
suggestion_dismissalsWhat 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 pointKindVerdictEvidence
authoring.suggest.suggestions.failedPublished subjectdeadPublished: app/workers/suggestions_worker.py:97,113app/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_lowPublished subjectdeadPublished: 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