micro-learning-service-v2 — business features

The learning content itself, and everything a learner does with it · ← platform hub · entry points verified against tools/feature-docs/out/micro-learning-service-v2.json

1. What it is

micro-learning-service-v2 holds the training content — modules, sections, lessons, screens and blocks — and everything a learner does with it: opening a lesson, answering quiz questions, keeping a streak, earning points and getting a certificate. Admins build and publish content here (or push it in from authoring-service-v2), and the mobile app reads every lesson it plays from this service.

The same repository also ships tts-worker-v2, a separate Deployment built from app/tts_worker/ and Dockerfile.tts-worker. It turns lesson text into spoken narration. The top-level tts-worker-v2/ directory is an empty shell — it holds no tracked source (git ls-files tts-worker-v2 returns nothing); the code moved here in M4 (ARCHITECTURE.md §2).

Who uses itAdmin web console · mobile app (employees) · authoring-service-v2, content-worker, assignment-service, users-auth-service · its own in-process schedulers
RuntimeFastAPI + 3 in-process schedulers · Deployment micro-learning-service-v2; second Deployment tts-worker-v2 (NATS worker, same repo, own image)
DatabasePostgres microlearning — shared with tts-worker-v2, which writes lesson_screen_audio directly (ARCHITECTURE.md §3.4)
RedisDB index 2 (ARCHITECTURE.md §3.5)
NATS streamsPublishes onto MICRO_LEARNING (training.*, learning.*) and TRAINING_AUDIO (tts.*); tts-worker-v2 consumes TRAINING_AUDIO. MICRO_LEARNING has three rival create-if-missing definitions — known defect, ARCHITECTURE.md §4.1(4)
External APIstts-worker-v2 → Modal chatterbox TTS endpoint; both processes → asset-manager (S3/CDN uploads)
Entry points109 HTTP routes · 1 NATS subject consumed (2 subscribe call sites) · 15 publish call sites · 11 background tasks

2. Feature map

flowchart LR
  ADM["Admin web"] --> CONTENT["Content tree and publishing"]
  ADM --> DASH["Dashboards and reports"]
  MOB["Mobile app"] --> PLAY["Catalogue and lesson player"]
  MOB --> PROG["Progress, points, streaks, certificates"]
  SVC["Other services"] --> INGEST["Module ingest and translations"]
  BG["Schedulers and TTS worker"] --> AUDIO["Narration audio and streak nudges"]
  CONTENT --> A1["/v1/admin/modules ... /screens"]
  DASH --> A2["/v1/admin/overview, /v1/admin/progress"]
  PLAY --> A3["/v1/mobile/modules, /v1/mobile/lessons"]
  PROG --> A4["POST /v1/mobile/progress/events"]
  INGEST --> A5["/v1/internal/modules, /v1/internal/translations"]
  AUDIO --> A6["tts.audio.requested"]

3. Features

Admin

Build the content tree live

admin

An admin creates a module, adds sections to it, lessons inside those sections, and then fills each lesson with screens and blocks — text, images, quizzes, a quiz graph. Sections and lessons can be reordered, and a lesson can be moved to another section. Everything is a soft delete, so nothing a learner already opened disappears mid-course.

Entry points
/v1/admin/modules, /v1/admin/modules/{module_id}/sections, /v1/admin/modules/{module_id}/sections/{section_id}/lessons, /v1/admin/lessons/{lesson_id}/blocks, /v1/admin/lessons/{lesson_id}/screens, POST /v1/admin/lessons/{lesson_id}/move
Touches
modules, sections, lessons, lesson_screens, lesson_blocks
Related
API_ENDPOINTS.md, BLOCK_TYPES_REFERENCE.md, CREATE_MODULE_DIRECT.md
Evidence
app/routes/module_routes.py:34, app/routes/section_routes.py:26, app/routes/lesson_routes.py:36, app/routes/block_routes.py:53, app/routes/screen_routes.py:61; caller: assignment-service/app/services/training_service.py:88

Publish a module, and version it live

admin

Publishing freezes the draft into a numbered version that learners can be assigned to. The admin can ask for spoken narration at the same time; when narration credit enforcement is switched on, the tenant's credit balance is debited before the publish commits, and refunded if the commit fails. Unpublishing and re-publishing produce a new version rather than editing the live one.

Entry points
POST /v1/admin/modules/{module_id}/publish, POST /v1/admin/modules/{module_id}/unpublish, GET /v1/admin/modules/{module_id}/versions, GET /v1/admin/modules/{root_id}/latest, GET /v1/admin/modules/{root_id}/draft
Touches
modules (root_id / version), users-auth credits, NATS training.module.published, TTS prewarm
Related
NARRATION_CREDIT_ESTIMATE_FE.md, CREATE_MODULE_DIRECT.md
Evidence
app/routes/module_routes.py:175 (debit at :223, commit at :259, prewarm at :297); caller: authoring-service-v2/app/core/http.py:108

Delete a module without orphaning assignments live

admin

Deleting a module archives it and cancels every active assignment for it in assignment-service, so nobody is left with a course that no longer exists.

Entry points
DELETE /v1/admin/modules/{module_id}, DELETE /v1/internal/modules/{module_id}, DELETE /v1/internal/modules/by-root/{root_id}
Touches
modules; assignment-service cancel endpoint
Related
MODULE_DELETION_STRATEGY.md; the cancel call was broken until bug-hunt #10 added HttpClient.delete
Evidence
app/routes/module_routes.py:318, app/clients/assignment_service.py:203; bug-hunt-reports/micro-learning-service-v2.md #10

Overview dashboard live

admin

Nine read-only cards on the admin home: completion and score KPIs, recently edited modules, active assignments, daily learning time, total versus active users, score distribution, recently issued certificates, a leaderboard and a team pulse list. Every card accepts a location and group filter.

Entry points
GET /v1/admin/overview/kpis and eight siblings under /v1/admin/overview
Touches
user_lesson_progress, user_module_progress, certificates, user_points; assignment-service and users-auth over HTTP
Related
ADMIN_OVERVIEW_DASHBOARD_FE.md
Evidence
app/routes/admin_overview_routes.py:79; docs/ADMIN_OVERVIEW_DASHBOARD_FE.md:312-320; ingress rule global-configs/k8s/users-auth-ingress.yaml:69

Assignment progress and reports live

admin

For one assignment an admin sees a completion summary, the per-learner list, an analytics breakdown, and can generate and download a report. There is also a bulk progress update for corrections.

Entry points
/v1/admin/progress/assignments/{assignment_id}/summary, /users, /analytics, /reports, /v1/admin/progress/reports/{report_id}/download, POST /v1/admin/progress/users/bulk
Touches
user_module_progress, user_lesson_progress; assignment-service
Related
API_ENDPOINTS.md §Progress Admin
Evidence
app/routes/progress_admin_routes.py:52; docs/API_ENDPOINTS.md:137-142; bug-hunt #17 fixed 500s on four of these

Narration voice and credit estimate live

admin

An admin picks the voice their tenant's lessons are spoken in, or uploads a reference clip of their own. Before publishing with narration they are shown what it will cost in credits, so the price is never a surprise after the fact.

Entry points
/v1/admin/audio/voices, /v1/admin/audio/voice-settings, /v1/admin/audio/voice-upload-url, /v1/admin/audio/voice-uploads/{asset_id}/complete, POST /v1/internal/narration/quote
Touches
voice_catalog, tenant_audio_settings, asset-manager
Related
tenant-voice-fe.md, NARRATION_CREDIT_ESTIMATE_FE.md
Evidence
app/routes/admin_audio_routes.py:107, app/services/narration_credit_service.py:173; caller: authoring-service-v2/app/core/http.py:83

Streak rules per tenant live

admin

An admin decides which weekdays count towards a streak, so a Monday-to-Friday workforce does not lose its streak over the weekend.

Entry points
GET /v1/admin/streak-settings, PUT /v1/admin/streak-settings
Touches
tenant_streak_settings
Related
STREAKS_AND_POINTS.md
Evidence
app/routes/admin_streak_routes.py:54; docs/STREAKS_AND_POINTS.md:175-176; ingress rule global-configs/k8s/users-auth-ingress.yaml:59

Employee (mobile)

See what I have to learn live

mobile

The home screen shows one hero module, the next lesson to pick up, and the rest of the assigned modules in a ranked order, each with its own progress. Tapping a module shows its sections and lessons.

Entry points
GET /v1/mobile/modules, GET /v1/mobile/modules/{module_id}, GET /v1/mobile/modules/{module_id}/sections, GET /v1/mobile/recent
Touches
modules, sections, lessons, user_module_progress; assignment-service for the learner's assignments
Related
FRONTEND_INTEGRATION_GUIDE.md, RANKING_MODULES.md
Evidence
app/routes/progress_mobile_routes.py:1961; caller: users-auth-service/app/routes/mobile_login.py:538 (mobile bootstrap)

Play a lesson live

mobile

Opening a lesson returns its screens, each with its blocks and — when narration exists — an audio clip plus word-level timings so the text can highlight as it is read out. A lesson summary gives the pass mark and the learner's attempts.

Entry points
GET /v1/mobile/lessons/{lesson_id}/screens, GET /v1/mobile/lessons/{lesson_id}/summary
Touches
lesson_screens, lesson_blocks, lesson_screen_audio, lesson_block_translations
Related
SCREENS_RESPONSE_EXAMPLE.md, BLOCK_TYPES_REFERENCE.md, tenant-voice-fe.md
Evidence
app/routes/progress_mobile_routes.py:2977, app/routes/progress_mobile_routes.py:2824; docs/FRONTEND_INTEGRATION_GUIDE.md:207

Progress that survives closing the app live

mobile

The app posts one kind of event — lesson started, progressed, completed, abandoned — and the service keeps the session, the screen the learner was on, time on task and the module rollup. Re-opening the app resumes where the learner stopped, and completing a lesson updates the assignment in assignment-service.

Entry points
POST /v1/mobile/progress/events
Touches
lesson_sessions, user_lesson_progress, user_module_progress, lesson_screen_dwell; NATS learning.lesson.*, training.progress.assignment_progress, training.module.completed
Related
MOBILE_FRONTEND_EVENTS_GUIDE.md, mobile/progress-events-telemetry.md, CHANGE_PROGESS_ROUTES.md
Evidence
app/routes/progress_mobile_routes.py:471 (dispatcher at :507-526); open defects: bug-hunt #6/#12 (points re-awarded on replay), #13 (replay wipes quiz attempts)

Quizzes and scoring live

mobile

Multiple-choice and drag-and-drop questions ship their answer key with the block, so the app scores them instantly and reports the attempt. The quiz-graph question is the exception: its answer stays on the server, so the app submits the attempt and gets back whether it was right, hints, and whether the answer may be revealed.

Entry points
POST /v1/mobile/progress/events (quiz.attempt), POST /v1/mobile/lessons/{lesson_id}/quiz/questions/{question_id}/attempts
Touches
quiz_question_attempts, quiz_graph_answer_keys, user_lesson_progress
Related
QUIZ_GRAPH_FLUTTER_INTEGRATION.md, QUIZ_SCORE_TRACKING.md, QUIZ_GRAPH_BLOCK_EXAMPLES.md
Evidence
app/routes/progress_mobile_routes.py:2685, app/services/graph_quiz_service.py, app/services/lesson_score_service.py; open defects: bug-hunt #5 (points on every attempt), #9 (allowed_incorrect_questions ignored)

Streaks, points and the leaderboard live

mobile

Finishing lessons earns points and keeps a daily streak alive in the learner's own timezone. The learner sees their XP total and where they sit against colleagues this month and this year. Overtaking someone, or being overtaken, triggers a push notification.

Entry points
GET /v1/mobile/xp-summary, GET /v1/mobile/leaderboard; the streak scanner and leaderboard diff run in the background
Touches
user_points, user_points_history, user_streaks, user_streak_stats, user_leaderboard_snapshot, leaderboard_event_cooldown; NATS learning.leaderboard.*, learning.streak.at_risk.*
Related
STREAKS_AND_POINTS.md
Evidence
app/routes/progress_mobile_routes.py:1833, app/routes/mobile_routes.py:198, app/services/points_service.py, app/services/leaderboard_diff_service.py:660; caller: users-auth-service/app/routes/mobile_login.py:539-540; bug-hunt #1/#11/#16 fixed 500s and an N+1 here

Certificates live

mobile

Completing a module issues a certificate in the same request that records the completion — there is no certificate worker any more. Two renders are uploaded, one sized for the phone and one for the console, and a startup backfill fills in certificates whose render never landed.

Entry points
GET /v1/mobile/certificates, GET /v1/mobile/certificate/{module_id}, GET /v1/admin/users/{target_user_id}/certificates, GET /v1/admin/certificates/{certificate_id}, GET /v1/internal/users/{user_id}/certificates
Touches
certificates; asset-manager for the SVG renders
Related
overview card GET /v1/admin/overview/certificates/recent
Evidence
app/services/certificate_service.py:390 (backfill), app/routes/progress_mobile_routes.py:1428 (issued inline on module completion), app/main.py:96 (startup backfill task); no certificate-worker source exists in the repo

Internal (other services)

Take a module in from authoring live

internal

authoring-service-v2 is where AI-assisted courses are written. When an author publishes, authoring pushes the whole tree here in one call and this service becomes the system of record for it. Unpublish and delete come back the same way, so authoring can undo a publish.

Entry points
POST /v1/internal/modules/ingest, POST /v1/internal/modules/by-root/{root_id}/unpublish, DELETE /v1/internal/modules/by-root/{root_id}, DELETE /v1/internal/modules/{module_id}
Touches
modules, sections, lessons, lesson_screens, lesson_blocks
Related
ARCHITECTURE.md §3.2 edge H1
Evidence
app/routes/internal_routes.py:100; caller: authoring-service-v2/app/core/http.py:56, :197, :217, :235

Translations written back by content-worker live

internal

A module can be offered in more than one language. content-worker pulls the source text from here, translates it, and writes each translated lesson and entity back, then marks the language ready. The mobile player then serves the learner's language automatically.

Entry points
GET /v1/internal/translations/modules/{root_id}/source, PUT /v1/internal/translations/modules/{module_id}/{locale}/lessons/{lesson_id}, PUT /v1/internal/translations/modules/{module_id}/{locale}/entities, POST /v1/internal/translations/modules/{module_id}/{locale}/status, GET /v1/internal/translations/modules/{module_id}/{locale}
Touches
lesson_block_translations, content_entity_translations, module_translation_status
Related
ARCHITECTURE.md §3.2 edges H7 and H1
Evidence
app/routes/internal_routes.py:488-624; callers: authoring-content-worker/app/services/ml_client.py:103,121,136,154; authoring-service-v2/app/core/http.py:146,172

Answer other services' questions about a learner live

internal

users-auth builds the back office and the mobile profile from these reads: how much training a user has done, their certificates, their activity, tenant-wide usage series, and the monthly-active-learner count that billing charges on. assignment-service reads module scores to decide whether an assignment passed. GDPR export hands over one user's whole learning record.

Entry points
/v1/internal/users/{user_id}/training-stats, /certificates, /activity, /v1/internal/analytics/*, GET /v1/internal/billing/active-learners, POST /v1/internal/progress/module-scores, GET /v1/internal/export/{user_id}
Touches
user_lesson_progress, user_module_progress, certificates, user_points, user_streak_stats, lesson_sessions
Related
ARCHITECTURE.md §3.2 edges H16–17 and H19
Evidence
app/routes/internal_routes.py:662,799,884,1265; callers: users-auth-service/app/clients/training_clients.py:134,149,167, users-auth-service/app/clients/backoffice_clients.py:103,123,172, users-auth-service/app/services/billing.py:265, users-auth-service/app/services/gdpr_export.py:156, assignment-service/app/services/training_service.py:37

Background

Turn lesson text into narration live

background

When a module is published with narration, or a learner opens a screen whose audio is missing, the service records a pending audio row and asks for it over NATS. tts-worker-v2 — a separate pod built from this repo — picks the request up, synthesises the speech on a Modal GPU, uploads the clip and its word timings through asset-manager, and writes the result straight back into the shared table. There is no completion event: the next read of the screen simply finds the audio there.

Entry points
publish → app/services/screen_audio_service.py:108 publishes tts.audio.requested; tts-worker-v2 consumes it on stream TRAINING_AUDIO, durable audio-gen-worker-v2-q
Touches
lesson_screen_audio (claimed with SELECT … FOR UPDATE SKIP LOCKED), lesson_screens, lesson_blocks; Modal chatterbox endpoint; asset-manager
Related
tts-worker-v2.md; ARCHITECTURE.md §3.3 (Audio flow) and §3.4 (shared database)
Evidence
app/tts_worker/worker.py:115 (subscribe), app/tts_worker/pipeline.py:224 (SKIP LOCKED claim), app/tts_worker/pipeline.py:939 (asset-manager upload); Deployment k8s/tts-worker-v2-deployment.yaml:51

Chase a streak before it breaks live

background

Every hour the service looks for learners whose streak will lapse today in their own timezone and publishes one nudge per learner per local day. notification-worker turns that into a push, respecting quiet hours.

Entry points
StreakReminderWorker loop (hourly); publishes learning.streak.at_risk.ten.<tenant>
Touches
user_streak_stats, tenant_streak_settings
Related
STREAKS_AND_POINTS.md. ARCHITECTURE.md §4.1(3) is out of date: it reports a flat learning.streak.at_risk that could never match the consumer. The code publishes the five-token subject and the filter matches — see the evidence line; do not re-raise it.
Evidence
app/services/streak_reminder_scheduler.py:183 builds f"{self.subject_root}.ten.{user.tenant_id}" with subject_root = settings.streak_reminder_subject = learning.streak.at_risk (app/core/config.py:77) and publishes at :209; consumer notification-worker/app/services/notification_service.py:1807 filters learning.streak.at_risk.ten.* on durable notification_worker_streak_v2

Repair stuck audio and abandoned sessions live

background

Two janitors keep the data honest: one re-requests audio rows that have been stuck generating for too long, the other closes lesson sessions a learner walked away from so their time on task is not counted forever.

Entry points
AudioReconcilerWorker (every 5 min), SessionSweeperWorker (every 10 min)
Touches
lesson_screen_audio, lesson_sessions
Related
manual equivalents exist under /v1/internal/audio/* and /v1/internal/sessions/sweep-stale but nothing calls them (§8)
Evidence
app/main.py:103-111 starts both; app/workers/audio_reconciler_worker.py:33, app/workers/session_sweeper_worker.py:33; intervals app/core/config.py:118,129

4. API reference

All 109 scanned routes. Admin and mobile routes reach the service through the shared auth.useoper.com ingress; /v1/internal/* has no ingress rule and is reachable only from inside the cluster.

MethodPathAuthFeatureCallersVerdict
GET/health/livenonePlatform healthkubelet liveness probe - k8s/deployment.yaml:88live
GET/health/readynonePlatform healthkubelet readiness probe - k8s/deployment.yaml:78live
GET/healthznoneTTS worker healthkubelet liveness probe - k8s/tts-worker-v2-deployment.yaml:181live
GET/readyznoneTTS worker healthkubelet readiness probe - k8s/tts-worker-v2-deployment.yaml:174live
GET/v1/admin/audio/voice-settingsadmin JWTNarration voiceadmin web - docs/tenant-voice-fe.mdlive
PUT/v1/admin/audio/voice-settingsadmin JWT + settings.writeNarration voiceadmin web - docs/tenant-voice-fe.mdlive
POST/v1/admin/audio/voice-upload-urladmin JWT + settings.writeNarration voiceadmin web - docs/tenant-voice-fe.mdlive
POST/v1/admin/audio/voice-uploads/{asset_id}/completeadmin JWT + settings.writeNarration voiceadmin web - docs/tenant-voice-fe.mdlive
GET/v1/admin/audio/voicesadmin JWTNarration voiceadmin web - docs/tenant-voice-fe.mdlive
GET/v1/admin/certificates/{certificate_id}admin JWTCertificatesnone foundsuspect
GET/v1/admin/lessons/{lesson_id}/blocksadmin JWTLesson blocksadmin web - API_ENDPOINTS.md:79live
POST/v1/admin/lessons/{lesson_id}/blocksadmin JWT + content.publishLesson blocksadmin web - API_ENDPOINTS.md:81live
PUT/v1/admin/lessons/{lesson_id}/blocksadmin JWT + content.publishLesson blocksadmin web - API_ENDPOINTS.md:80live
PATCH/v1/admin/lessons/{lesson_id}/blocks/{block_id}admin JWT + content.publishLesson blocksadmin web - API_ENDPOINTS.md:82live
DELETE/v1/admin/lessons/{lesson_id}/blocks/{block_id}admin JWT + content.publishLesson blocksadmin web - API_ENDPOINTS.md:83live
POST/v1/admin/lessons/{lesson_id}/moveadmin JWT + content.publishLessonsadmin web - API_ENDPOINTS.md:71live
GET/v1/admin/lessons/{lesson_id}/quizadmin JWTQuiz bank (legacy)admin web - API_ENDPOINTS.md:104live
POST/v1/admin/lessons/{lesson_id}/quizadmin JWT + content.publishQuiz bank (legacy)admin web - API_ENDPOINTS.md:103live
GET/v1/admin/lessons/{lesson_id}/quiz/{question_id}admin JWTQuiz bank (legacy)admin web - API_ENDPOINTS.md:105live
PATCH/v1/admin/lessons/{lesson_id}/quiz/{question_id}admin JWT + content.publishQuiz bank (legacy)admin web - API_ENDPOINTS.md:106live
DELETE/v1/admin/lessons/{lesson_id}/quiz/{question_id}admin JWT + content.publishQuiz bank (legacy)admin web - API_ENDPOINTS.md:107live
GET/v1/admin/lessons/{lesson_id}/screensadmin JWTLesson screensadmin web - API_ENDPOINTS.md:91live
POST/v1/admin/lessons/{lesson_id}/screensadmin JWT + content.publishLesson screensadmin web - API_ENDPOINTS.md:93live
PUT/v1/admin/lessons/{lesson_id}/screensadmin JWT + content.publishLesson screensadmin web - API_ENDPOINTS.md:92live
PATCH/v1/admin/lessons/{lesson_id}/screens/{screen_id}admin JWT + content.publishLesson screensadmin web - API_ENDPOINTS.md:94live
DELETE/v1/admin/lessons/{lesson_id}/screens/{screen_id}admin JWT + content.publishLesson screensadmin web - API_ENDPOINTS.md:95live
GET/v1/admin/mediaadmin JWTMedia libraryadmin web - API_ENDPOINTS.md:115live
GET/v1/admin/media/{media_id}admin JWTMedia libraryadmin web - API_ENDPOINTS.md:116live
PATCH/v1/admin/media/{media_id}admin JWT + content.publishMedia libraryadmin web - API_ENDPOINTS.md:118live
DELETE/v1/admin/media/{media_id}admin JWT + content.publishMedia libraryadmin web - API_ENDPOINTS.md:119live
POST/v1/admin/media/{media_id}/mark-uploadedadmin JWT + content.publishMedia libraryadmin web - API_ENDPOINTS.md:117live
GET/v1/admin/modulesadmin JWTModule treeadmin web - API_ENDPOINTS.md:39live
POST/v1/admin/modulesadmin JWT + content.publishModule treeadmin web - API_ENDPOINTS.md:38live
GET/v1/admin/modules/{module_id}admin JWTModule treeassignment-service/app/services/training_service.py:88live
PATCH/v1/admin/modules/{module_id}admin JWT + content.publishModule treeadmin web - API_ENDPOINTS.md:41live
DELETE/v1/admin/modules/{module_id}admin JWT + content.publishModule deletionadmin web - docs/MODULE_DELETION_STRATEGY.mdlive
POST/v1/admin/modules/{module_id}/duplicateadmin JWT + content.publishModule treeadmin web - API_ENDPOINTS.md:43live
POST/v1/admin/modules/{module_id}/publishadmin JWT + content.publishPublishauthoring-service-v2/app/core/http.py:108live
GET/v1/admin/modules/{module_id}/sectionsadmin JWTSectionsassignment-service/app/services/training_service.py:206live
POST/v1/admin/modules/{module_id}/sectionsadmin JWT + content.publishSectionsadmin web - API_ENDPOINTS.md:52live
POST/v1/admin/modules/{module_id}/sections/reorderadmin JWT + content.publishSectionsadmin web - API_ENDPOINTS.md:57live
GET/v1/admin/modules/{module_id}/sections/{section_id}admin JWTSectionsadmin web - API_ENDPOINTS.md:54live
PATCH/v1/admin/modules/{module_id}/sections/{section_id}admin JWT + content.publishSectionsadmin web - API_ENDPOINTS.md:55live
DELETE/v1/admin/modules/{module_id}/sections/{section_id}admin JWT + content.publishSectionsadmin web - API_ENDPOINTS.md:56live
GET/v1/admin/modules/{module_id}/sections/{section_id}/lessonsadmin JWTLessonsassignment-service/app/services/training_service.py:226live
POST/v1/admin/modules/{module_id}/sections/{section_id}/lessonsadmin JWT + content.publishLessonsadmin web - API_ENDPOINTS.md:65live
POST/v1/admin/modules/{module_id}/sections/{section_id}/lessons/reorderadmin JWT + content.publishLessonsadmin web - API_ENDPOINTS.md:70live
GET/v1/admin/modules/{module_id}/sections/{section_id}/lessons/{lesson_id}admin JWTLessonsadmin web - API_ENDPOINTS.md:67live
PATCH/v1/admin/modules/{module_id}/sections/{section_id}/lessons/{lesson_id}admin JWT + content.publishLessonsadmin web - API_ENDPOINTS.md:68live
DELETE/v1/admin/modules/{module_id}/sections/{section_id}/lessons/{lesson_id}admin JWT + content.publishLessonsadmin web - API_ENDPOINTS.md:69live
GET/v1/admin/modules/{module_id}/translationsadmin JWTTranslationsusers-auth-service/app/routes/oper_training.py:117live
POST/v1/admin/modules/{module_id}/unpublishadmin JWT + content.publishPublishadmin web - docs/CREATE_MODULE_DIRECT.mdlive
GET/v1/admin/modules/{module_id}/versionsadmin JWTVersioningadmin web - API_ENDPOINTS.md:127live
GET/v1/admin/modules/{root_id}/draftadmin JWTVersioningadmin web - API_ENDPOINTS.md:129live
GET/v1/admin/modules/{root_id}/latestadmin JWTVersioningauthoring-service-v2/app/core/http.py:247live
GET/v1/admin/overview/assignments/activeadmin JWTOverview dashboardadmin web - docs/ADMIN_OVERVIEW_DASHBOARD_FE.md:314live
GET/v1/admin/overview/certificates/recentadmin JWTOverview dashboardadmin web - docs/ADMIN_OVERVIEW_DASHBOARD_FE.md:318live
GET/v1/admin/overview/kpisadmin JWTOverview dashboardadmin web - docs/ADMIN_OVERVIEW_DASHBOARD_FE.md:312live
GET/v1/admin/overview/leaderboardadmin JWTOverview dashboardadmin web - docs/ADMIN_OVERVIEW_DASHBOARD_FE.md:319live
GET/v1/admin/overview/learning-time/dailyadmin JWTOverview dashboardadmin web - docs/ADMIN_OVERVIEW_DASHBOARD_FE.md:315live
GET/v1/admin/overview/modules/recentadmin JWTOverview dashboardadmin web - docs/ADMIN_OVERVIEW_DASHBOARD_FE.md:313live
GET/v1/admin/overview/score-distributionadmin JWTOverview dashboardadmin web - docs/ADMIN_OVERVIEW_DASHBOARD_FE.md:317live
GET/v1/admin/overview/team-pulseadmin JWTOverview dashboardadmin web - docs/ADMIN_OVERVIEW_DASHBOARD_FE.md:320live
GET/v1/admin/overview/users/activityadmin JWTOverview dashboardadmin web - docs/ADMIN_OVERVIEW_DASHBOARD_FE.md:316live
GET/v1/admin/progress/assignments/{assignment_id}/analyticsadmin JWTAssignment progress reportsadmin web - API_ENDPOINTS.md:142live
POST/v1/admin/progress/assignments/{assignment_id}/reportsadmin JWTAssignment progress reportsadmin web - API_ENDPOINTS.md:140live
GET/v1/admin/progress/assignments/{assignment_id}/summaryadmin JWTAssignment progress reportsadmin web - API_ENDPOINTS.md:137live
GET/v1/admin/progress/assignments/{assignment_id}/usersadmin JWTAssignment progress reportsadmin web - API_ENDPOINTS.md:138live
GET/v1/admin/progress/reports/{report_id}/downloadadmin JWTAssignment progress reportsadmin web - API_ENDPOINTS.md:141live
POST/v1/admin/progress/users/bulkadmin JWTAssignment progress reportsadmin web - API_ENDPOINTS.md:139live
GET/v1/admin/streak-settingsadmin JWTStreak settingsadmin web - docs/STREAKS_AND_POINTS.md:175live
PUT/v1/admin/streak-settingsadmin JWT + settings.writeStreak settingsadmin web - docs/STREAKS_AND_POINTS.md:176live
GET/v1/admin/users/{target_user_id}/certificatesadmin JWTCertificatesnone foundsuspect
POST/v1/internal/analytics/last-seenX-Oper-Key + X-Tenant-IdBack-office analyticsusers-auth-service/app/clients/backoffice_clients.py:123live
POST/v1/internal/analytics/tenant-activityX-Oper-KeyBack-office analyticsusers-auth-service/app/clients/backoffice_clients.py:172live
GET/v1/internal/analytics/usage-seriesX-Oper-Key + X-Tenant-IdBack-office analyticsusers-auth-service/app/clients/backoffice_clients.py:103live
POST/v1/internal/audio/prewarm/{module_id}admin JWTAudio opsnone founddead
POST/v1/internal/audio/reconcileadmin JWTAudio opsnone founddead
POST/v1/internal/audio/retry-failedadmin JWTAudio opsnone founddead
GET/v1/internal/billing/active-learnersX-Oper-Key + X-Tenant-IdBilling MAUusers-auth-service/app/services/billing.py:265live
GET/v1/internal/export/{user_id}X-Oper-Key + X-Tenant-IdGDPR exportusers-auth-service/app/services/gdpr_export.py:156live
DELETE/v1/internal/modules/by-root/{root_id}X-Oper-Key + X-Tenant-IdModule deletionauthoring-service-v2/app/core/http.py:217live
POST/v1/internal/modules/by-root/{root_id}/unpublishX-Oper-Key + X-Tenant-IdPublishauthoring-service-v2/app/core/http.py:197live
POST/v1/internal/modules/ingestX-Oper-Key + X-Tenant-IdModule ingestauthoring-service-v2/app/core/http.py:56live
POST/v1/internal/modules/versions/batchadmin JWTVersioningnone founddead
DELETE/v1/internal/modules/{module_id}X-Oper-Key + X-Tenant-IdModule deletionauthoring-service-v2/app/core/http.py:235live
POST/v1/internal/narration/quoteX-Oper-Key + X-Tenant-IdNarration creditsauthoring-service-v2/app/core/http.py:83live
POST/v1/internal/progress/module-scoresX-Oper-Key + X-Tenant-IdScore lookupassignment-service/app/services/training_service.py:37live
POST/v1/internal/sessions/sweep-staleadmin JWTSession opsnone founddead
GET/v1/internal/translations/modules/{module_id}/{locale}X-Oper-Key + X-Tenant-IdTranslationsauthoring-service-v2/app/core/http.py:172live
PUT/v1/internal/translations/modules/{module_id}/{locale}/entitiesX-Oper-Key + X-Tenant-IdTranslationsauthoring-content-worker/app/services/ml_client.py:136live
PUT/v1/internal/translations/modules/{module_id}/{locale}/lessons/{lesson_id}X-Oper-Key + X-Tenant-IdTranslationsauthoring-content-worker/app/services/ml_client.py:121live
POST/v1/internal/translations/modules/{module_id}/{locale}/statusX-Oper-Key + X-Tenant-IdTranslationsauthoring-content-worker/app/services/ml_client.py:154live
GET/v1/internal/translations/modules/{root_id}/sourceX-Oper-Key + X-Tenant-IdTranslationsauthoring-service-v2/app/core/http.py:146; authoring-content-worker/app/services/ml_client.py:103live
GET/v1/internal/users/{user_id}/activityX-Oper-Key + X-Tenant-IdLearner statsusers-auth-service/app/clients/training_clients.py:134live
GET/v1/internal/users/{user_id}/certificatesX-Oper-Key + X-Tenant-IdLearner statsusers-auth-service/app/clients/training_clients.py:167live
GET/v1/internal/users/{user_id}/training-statsX-Oper-Key + X-Tenant-IdLearner statsusers-auth-service/app/clients/training_clients.py:149live
GET/v1/mobile/certificate/{module_id}learner JWTCertificatesnone foundsuspect
GET/v1/mobile/certificateslearner JWTCertificatesingress rule onlysuspect
GET/v1/mobile/leaderboardlearner JWTLeaderboardusers-auth-service/app/routes/mobile_login.py:540live
POST/v1/mobile/lessons/{lesson_id}/quiz/questions/{question_id}/attemptslearner JWTQuiz attemptsmobile app - docs/FRONTEND_INTEGRATION_GUIDE.md:339live
GET/v1/mobile/lessons/{lesson_id}/screenslearner JWTLesson playermobile app - docs/FRONTEND_INTEGRATION_GUIDE.md:207live
GET/v1/mobile/lessons/{lesson_id}/summarylearner JWTLesson playerassignment-service/docs/question-retry-settings.md:57live
GET/v1/mobile/moduleslearner JWTCatalogueusers-auth-service/app/routes/mobile_login.py:538live
GET/v1/mobile/modules/{module_id}learner JWTCataloguemobile app - docs/FRONTEND_INTEGRATION_GUIDE.md:126live
GET/v1/mobile/modules/{module_id}/sectionslearner JWTCataloguemobile app - docs/FRONTEND_INTEGRATION_GUIDE.md:201live
POST/v1/mobile/progress/eventslearner JWTProgress eventsmobile app - docs/MOBILE_FRONTEND_EVENTS_GUIDE.md:6live
GET/v1/mobile/recentlearner JWTCatalogueingress rule onlysuspect
GET/v1/mobile/xp-summarylearner JWTPoints and streaksusers-auth-service/app/routes/mobile_login.py:539live

5. Async contracts

Consumes

SubjectStreamDurablePublished byFeatureVerdict
tts.audio.requestedTRAINING_AUDIOaudio-gen-worker-v2-qthis service — app/services/screen_audio_service.py:108, app/services/audio_reconciler_scheduler.py:127Narration audiolive
app/tts_worker/worker.py:115; NATS_USE_JETSTREAM=true k8s/tts-worker-v2-deployment.yaml:109
tts.audio.requested (core-NATS fallback)nonequeue audio-gen-workers-v2, no durablesameNarration audio (local dev)dead
app/tts_worker/worker.py:128 runs only when nats_use_jetstream is false; the Deployment sets it true (k8s/tts-worker-v2-deployment.yaml:109-110), so this branch never executes in a deployed environment

Publishes

Fifteen publish call sites. learning.streak.at_risk.ten.<tenant> is built from an instance attribute, so the scanner cannot resolve it and it is absent from the scan JSON — it is nonetheless a real published subject and is listed here.

SubjectConsumed byFeatureVerdict
tts.audio.requested
app/services/screen_audio_service.py:108
tts-worker-v2 (app/tts_worker/worker.py:115)Narration audiolive
tts.audio.requested
app/services/audio_reconciler_scheduler.py:127
tts-worker-v2 (app/tts_worker/worker.py:115)Audio reconcilerlive
training.module.completed
app/events/events.py:80
assignment-service durable progress_module_completion_worker (assignment-service/app/workers/progress_event_worker.py:137)Module completionlive
training.progress.assignment_progress
app/events/events.py:125
assignment-service durable progress_event_worker, filter training.progress.> (assignment-service/app/workers/progress_event_worker.py:110)Progress mirrorlive
learning.leaderboard.<event>.ten.<tenant>
app/services/leaderboard_diff_service.py:662-664 (scan JSON records it as learning.leaderboard.*.ten.*)
notification-worker durable notification_worker_leaderboard, filter learning.leaderboard.> (notification-worker/app/core/config.py:100, notification-worker/app/services/notification_service.py:2297)Leaderboard nudgeslive
learning.streak.at_risk.ten.<tenant>
app/services/streak_reminder_scheduler.py:183,209 — absent from the scan JSON (f-string off self.subject_root)
notification-worker durable notification_worker_streak_v2, filter learning.streak.at_risk.ten.* (notification-worker/app/services/notification_service.py:1807)Streak nudgeslive
five tokens on both sides; ARCHITECTURE.md §4.1(3), which calls this unroutable, is stale
training.module.published
app/events/events.py:55
feed-service maps it to a NEW_MODULE post (feed-service/app/services/event_processor.py:309) but nothing ever starts that consumerPublishsuspect
publisher is on the live publish path (app/routes/module_routes.py:276); the only subscriber is feed-service's EventProcessorService, which is never instantiated — ARCHITECTURE.md §4.1(5)
training.certificates.module_completed
app/events/events.py:257
same feed-service CERTIFICATE_EARNED mapping (feed-service/app/services/event_processor.py:309); consumer never startedCertificatessuspect
emitted at app/routes/progress_mobile_routes.py:1428; no running consumer — ARCHITECTURE.md §4.1(5)
training.module.created
app/events/events.py:30
none — no service subscribes to itModule treedead
emitted at app/routes/module_routes.py:44; a repo-wide search finds the string only in feed-service seed/undo migrations (feed-service/migrations/versions/fix_feed_category.py re-points those rows to training.module.published), never in a subscribe call
learning.lesson.started
app/routes/progress_mobile_routes.py:783
noneProgress eventsdead
captured by MICRO_LEARNING via learning.> (nats/streams/training-modules.json) but no consumer filter in any service matches learning.lesson.*
learning.lesson.progressed
app/routes/progress_mobile_routes.py:900
noneProgress eventsdead
as above — published, stored, never consumed
learning.lesson.completed
app/routes/progress_mobile_routes.py:1264
none (completion reaches assignment-service over training.module.completed instead)Progress eventsdead
as above
learning.lesson.abandoned
app/routes/progress_mobile_routes.py:1514
noneProgress eventsdead
as above
training.module.archived
app/events/events.py:142
nonedead
its publisher emit_module_archived has zero call sites in the service, and no consumer subscribes to the subject
training.module.assigned
app/events/events.py:167
nonedead
emit_module_assigned has zero call sites; assignments are owned by assignment-service, which publishes its own events
user.notification.training
app/events/events.py:186
nonedead
emit_notification_for_user has zero call sites; its only would-be caller assign_module_to_employees_sync (app/events/events.py:197) is itself uncalled, and notification-worker's stream copy omits user.notification.> entirely (ARCHITECTURE.md §4.1(4))

Background jobs

JobScheduleWhat it doesVerdict
StreakReminderWorker._loop
app/workers/streak_reminder_worker.py:36
every 3600sFinds learners about to lose a streak and publishes one nudge eachlive
started app/main.py:98-101; interval app/core/config.py:75
AudioReconcilerWorker._loop
app/workers/audio_reconciler_worker.py:33
every 300sRe-requests narration rows stuck in processinglive
started app/main.py:103-106; interval app/core/config.py:118
SessionSweeperWorker._loop
app/workers/session_sweeper_worker.py:33
every 600sCloses lesson sessions the learner abandonedlive
started app/main.py:108-111; interval app/core/config.py:129
_run_certificate_backfill
app/main.py:96
once at startupRenders certificates whose asset never uploadedlive
gated on certificate_backfill_on_startup (default true, app/core/config.py:65) and the internal API key
_disarm_hang_watchdog
app/main.py:114
once at startupCancels the 75s faulthandler stack dump armed at import, so only a wedged event loop dumpslive
app/main.py:22 arms it, :114 schedules the disarm
LeaderboardDiffService.on_points_awarded
app/routes/progress_mobile_routes.py:1256
after every points awardRecomputes the board and emits overtake / rank-one notificationslive
scheduled inside _handle_lesson_completed; bug-hunt #15 notes the task reference is not retained
_emit_audio_requested._runner
app/services/screen_audio_service.py:126
per missing screen audioPublishes the TTS request from whichever thread found the gaplive
loop pinned by bind_event_loop() at app/main.py:87-88
spawn_module_audio_prewarm._runner
app/services/screen_audio_service.py:625
per publish with narrationEnumerates a module's screens and queues every narration job after the publish commitslive
called from app/routes/module_routes.py:297
AudioGenWorker._bounded_handle
app/tts_worker/worker.py:140
per NATS messageRuns one narration generation under a concurrency semaphorelive
dispatched from the consume loop, app/tts_worker/worker.py:183
_shutdown
app/tts_worker/main.py:53
on SIGINT / SIGTERMDrains in-flight generations before the pod exitslive
registered as a signal handler at app/tts_worker/main.py:53
_fire_and_forget(coro)
app/events/events.py:192
neverWould run assign_module_to_employees_sync's notification fan-outdead
its only caller is assign_module_to_employees_sync (app/events/events.py:197), which has zero call sites anywhere in the repo

6. Data it owns

Postgres logical DB microlearning, 35 alembic revisions under migrations/versions/. Every table is written by this service except where noted.

TableWhat it holdsWritten by
modulesA course: title, status, version, root_id identity across versions, audio_enabled, narration localesmicro-learning
sectionsChapters inside a module, orderedmicro-learning
lessonsLessons inside a section, ordered; pass mark and retry rulesmicro-learning
lesson_screensThe screens a learner swipes through in a lessonmicro-learning
lesson_blocksThe content on a screen: text, image, quiz, quiz graphmicro-learning
media_assetsImages and videos referenced by blocks, registered against asset-managermicro-learning
lesson_screen_audioNarration clip and word-timings sidecar per screen and locale, with a pending / processing / ready / failed lifecyclemicro-learning and tts-worker-v2 — the worker claims rows with SELECT … FOR UPDATE SKIP LOCKED and writes the result (app/tts_worker/pipeline.py:224; ARCHITECTURE.md §3.4)
voice_catalog, tenant_audio_settingsAvailable narration voices and the tenant's chosen onemicro-learning
quiz_questions, quiz_answersLegacy server-side quiz bank; block-authored quizzes do not use it (bug-hunt #19)micro-learning
quiz_graph_answer_keysAnswer keys for quiz-graph questions, deliberately kept off the devicemicro-learning
lesson_sessionsOne learner's run at a lesson: start, last screen, duration, statusmicro-learning
lesson_screen_dwellPer-screen time-on-task telemetrymicro-learning
user_lesson_progress, user_module_progressWhat the learner has finished and their scoremicro-learning
quiz_question_attemptsEvery answer submitted, right or wrongmicro-learning
user_points, user_points_historyXP total and the ledger behind itmicro-learning
user_streaks, user_streak_stats, tenant_streak_settingsCurrent and longest streak, the learner's timezone, and which weekdays countmicro-learning
user_leaderboard_snapshot, leaderboard_event_cooldownLast known rank per learner and the anti-spam window for overtake pushesmicro-learning
certificatesOne issued certificate per learner and module, with mobile and admin rendersmicro-learning
lesson_block_translations, content_entity_translations, module_translation_statusTranslated text and per-language readinessmicro-learning — rows written through the internal API by content-worker
suggestion_runs, suggestions, suggestion_dismissalsContent suggestions attached to a modulemicro-learning

7. Dependencies

flowchart LR
  AV2["authoring-service-v2"] --> ML["micro-learning-service-v2"]
  CW["content-worker"] --> ML
  AS["assignment-service"] --> ML
  UA["users-auth-service"] --> ML
  ML --> UAO["users-auth: profiles and credits"]
  ML --> ASO["assignment-service: employee assignments"]
  ML --> AVO["authoring-service-v2: recent modules"]
  ML --> AM["asset-manager: uploads"]
  ML --> NATS["NATS: MICRO_LEARNING and TRAINING_AUDIO"]
  NATS --> TTS["tts-worker-v2"]
  TTS --> MODAL["Modal chatterbox TTS"]
  TTS --> AM
  TTS --> DB[("microlearning DB")]
  ML --> DB

Inbound edges H1 (authoring ingest / publish), H7 (content-worker translations), H16–17 (assignment-service progress and module validation) and H19 (users-auth analytics, billing, training stats). Outbound edges H11–12 (users-auth users and the publish-time credit debit), H13 (assignment-service on the mobile read path), H14 (authoring recent-modules card) and H9 (tts-worker-v2 to asset-manager) — ARCHITECTURE.md §3.2.

8. Dead-code verdicts

Every entry point with no in-repo caller. Deleting is a separate decision — see the hub roll-up.

Entry pointKindVerdictEvidence
POST /v1/internal/modules/versions/batchHTTPdeadInternal-only path, and no in-repo service calls it: a repo-wide search for versions/batch finds only the route (app/routes/internal_routes.py:227), its test, and scripts/smoke_internal_batch.py. /v1/internal/* has no ingress rule, so no browser or app can reach it either. Confirmed dormant by bug-hunt #18.
POST /v1/internal/audio/reconcileHTTPdeadNo caller anywhere in the repo, and the same work runs on a timer (AudioReconcilerWorker, app/main.py:103). Internal path with no ingress rule (global-configs/k8s/users-auth-ingress.yaml has no /v1/internal entry).
POST /v1/internal/audio/retry-failedHTTPdeadAs above — no caller (app/routes/internal_routes.py:401), and the reconciler covers the same case.
POST /v1/internal/audio/prewarm/{module_id}HTTPdeadNo caller: the publish flow calls spawn_module_audio_prewarm in process (app/routes/module_routes.py:297) instead of this route. Internal path, no ingress rule.
POST /v1/internal/sessions/sweep-staleHTTPdeadNo caller; SessionSweeperWorker runs the same sweep every 10 minutes (app/main.py:108). Internal path, no ingress rule.
GET /v1/admin/certificates/{certificate_id}HTTPsuspectSearched the repo for the path and for admin_get_certificate, plus every docs/*.md in this service: no caller, no FE doc. The admin web app is not in this repo, so not dead — but note the ingress has no /v1/admin/certificates rule, so today the call would fall through to the users-auth catch-all.
GET /v1/admin/users/{target_user_id}/certificatesHTTPsuspectSame search, same result: no in-repo caller, no FE doc, and no matching ingress rule. Admin-facing, so suspect rather than dead.
GET /v1/mobile/certificatesHTTPsuspectNo in-repo caller and no FE doc names it; the only hit is the ops script management-scripts/mobile_list_certificates.py. A dedicated ingress rule exists (global-configs/k8s/users-auth-ingress.yaml:111), so the mobile app — which is not in this repo — is expected to call it.
GET /v1/mobile/certificate/{module_id}HTTPsuspectNo caller, no FE doc, and no ingress rule matches the singular path (the /v1/mobile/certificates prefix at users-auth-ingress.yaml:111 does not cover it). Mobile-facing, so suspect; worth confirming with the app team.
GET /v1/mobile/recentHTTPsuspectNo in-repo caller and no FE doc; only tests/test_recent_modules.py. A dedicated ingress rule was added for it (global-configs/k8s/users-auth-ingress.yaml:118-123), which shows intent but is not proof of traffic.
tts.audio.requested core-NATS subscribeNATS consumerdeadapp/tts_worker/worker.py:128 is the else branch of the JetStream subscribe; NATS_USE_JETSTREAM is "true" in the Deployment (k8s/tts-worker-v2-deployment.yaml:109-110), so it is a local-dev-only path.
training.module.createdNATS publishdeadEmitted at app/routes/module_routes.py:44; no subscribe call in any service matches it. feed-service's subject list (feed-service/app/services/event_processor.py:308-316) carries training.module.published instead, and its migration re-pointed the old config rows.
training.module.archivedNATS publishdeadPublisher emit_module_archived (app/events/events.py:132) has zero call sites, and no consumer subscribes to the subject.
training.module.assignedNATS publishdeadPublisher emit_module_assigned (app/events/events.py:145) has zero call sites; no consumer.
user.notification.trainingNATS publishdeadPublisher emit_notification_for_user (app/events/events.py:170) has zero call sites, and no consumer subscribes to user.notification.* — notification-worker's stream copy omits the subject (ARCHITECTURE.md §4.1(4)).
learning.lesson.started / .progressed / .completed / .abandonedNATS publishdeadPublished on every learner event (app/routes/progress_mobile_routes.py:783, 900, 1264, 1514) and captured by MICRO_LEARNING via learning.>, but no service subscribes to any learning.lesson.* filter.
training.module.publishedNATS publishsuspectA matching subscriber exists (feed-service/app/services/event_processor.py:309) but EventProcessorService is never instantiated — ARCHITECTURE.md §4.1(5). The event is correct; the consumer is broken.
training.certificates.module_completedNATS publishsuspectSame feed-service consumer, same reason.
_fire_and_forget and assign_module_to_employees_syncBackground / functionsdeadapp/events/events.py:189 and :197; assign_module_to_employees_sync has zero call sites anywhere in the repo, so both it and the helper are unreachable, together with the three uncalled emitters above.

Doc drift, not a code verdict: API_ENDPOINTS.md still documents PUT /v1/mobile/lessons/{lesson_id}/session, GET /v1/mobile/lessons/{lesson_id}/quiz/summary and POST /v1/mobile/progress/assignments/{assignment_id}/lessons/{lesson_id}/complete. None of those routes exist in the scan; they were replaced by the single POST /v1/mobile/progress/events endpoint (CHANGE_PROGESS_ROUTES.md).

9. Sources