Report
Purpose
report-svc builds a per-student exam report from backend data and renders it with Jinja2 plus Playwright. Its main production path is asynchronous through RabbitMQ, but it also keeps a synchronous fallback route for single-report generation.
For shared worker conventions, see Workers Overview.
Source Paths
| Path | Role |
|---|---|
lumie-worker/services/report/main.py | FastAPI app, lifespan, health route, sync fallback route |
lumie-worker/services/report/src/schema.py | MQ command and callback payload models |
lumie-worker/services/report/src/usecase.py | Report generation orchestration |
lumie-worker/services/report/src/mq/consumer.py | RabbitMQ consumer and callback payload builders |
lumie-worker/services/report/src/domain/report_generator.py | Jinja2 plus Playwright rendering |
lumie-worker/services/report/src/domain/report_data.py | Typed report data model |
lumie-worker/services/report/src/adapters/exam_api.py | Backend exam, result, rank, and statistics client |
lumie-worker/services/report/src/adapters/student_api.py | Backend student profile client |
lumie-worker/services/report/templates/exam_report.html | HTML template captured as a JPEG |
lumie-worker/contracts/mq-schemas-v1.yaml | Hand-maintained report MQ contract reference |
Public Surface
Operational routes:
GET /healthGET /metrics
Synchronous fallback route:
POST /v1/reports/students/{student_id}/exams/{exam_id}- required header:
X-Tenant-Slug - response type:
image/jpeg
RabbitMQ surfaces:
- Command queue:
report.generation-request - Callback routing key:
report.generation.callback
The queue payload is validated as ReportCommand in services/report/src/schema.py.
ReportCommand Example
{
"jobId": 1201,
"examId": 42,
"studentId": 1001,
"tenantSlug": "demo",
"reportIndex": 0,
"totalReports": 2,
"schemaVersion": 1
}
Success Callback Example
services/report/src/mq/consumer.py base64-encodes result["jpg_bytes"] into reportBytes, and backend ReportCallbackRequest accepts the same fields.
{
"jobId": 1201,
"examId": 42,
"studentId": 1001,
"tenantSlug": "demo",
"reportIndex": 0,
"totalReports": 2,
"success": true,
"error": null,
"reportBytes": "<base64-jpeg>"
}
Failure Callback Example
{
"jobId": 1201,
"examId": 42,
"studentId": 1001,
"tenantSlug": "demo",
"reportIndex": 0,
"totalReports": 2,
"success": false,
"error": "404: exam result not found",
"reportBytes": null
}
Processing Flow
- FastAPI startup builds the dependency container, warms the Playwright browser, connects the callback publisher, and starts the RabbitMQ consumer.
GenerateReportUseCasefans out five backend reads in parallel: student profile, exam statistics, stability index, exam result for the student, and student rank.- Rank lookup is best-effort. If it fails, the report still renders.
- The worker fetches question-level results for the selected exam result.
- It assembles a typed
ReportDataobject and renders HTML with theexam_report.htmlJinja2 template. - Playwright opens a Chromium page, loads the HTML, and captures a full-page JPEG screenshot.
- The MQ path base64-encodes the generated bytes into the callback field named
reportBytes.
If the worker cannot find an exam result for the requested student and exam pair, it raises HTTPException(404).
Data The Worker Pulls From The Backend
The report worker does not query the database directly. It calls backend /internal/** routes through signed httpx adapters:
/internal/reports/students/<studentId>/internal/reports/exams/<examId>/statistics/internal/reports/students/<studentId>/stability/internal/reports/students/<studentId>/rank/internal/reports/students/<studentId>/results/internal/reports/results/<resultId>/questions
ExamClient caches exam statistics for 60 seconds to avoid repeated reads during a large batch.
All /internal/** requests include:
X-Tenant-SlugX-SignatureX-Timestamp
Those headers are produced through the shared HMAC signing helper. Do not add unsigned backend reads to this service.
Rendering Model
The current implementation renders an image, not a PDF:
ReportGenerator.generate_jpg(...)returns JPEG bytes- the sync HTTP route responds with
image/jpeg - the MQ callback base64-encodes those JPEG bytes into
reportBytes
That field name is generic, but the service behavior is image-based today.
The template is fixed-size and designed for screenshot rendering:
- viewport:
794 x 1123 - default device scale factor:
4 - browser is warmed at startup to reduce first-request latency
Contract Drift Note
lumie-worker/contracts/mq-schemas-v1.yaml still describes reportBytes as a
base64-encoded PDF. The implementation currently base64-encodes JPEG bytes from
ReportGenerator.generate_jpg(...), and the sync route returns image/jpeg.
Treat the implementation as the runtime source of truth until the contract file
is corrected.
External Dependencies
| Dependency | Why it exists |
|---|---|
RabbitMQ via aio-pika | Receive report jobs and publish completion callbacks |
Backend HTTP API via httpx | Fetch student, exam, result, rank, and stability data |
| Jinja2 | Render the report template from typed report data |
| Playwright Chromium | Convert the HTML template into the final image |
| OpenTelemetry and Prometheus | Measure latency and trace backend plus rendering work |
Failure Semantics
| Failure | Behavior |
|---|---|
| Malformed MQ body | Rejected by shared MQ processing and sent toward the broker DLQ path |
| Backend rank lookup fails | Logged and ignored; report still renders without rank |
| Backend exam result is missing | HTTPException(404) and failure callback on MQ path |
| Backend required data call fails | Handler failure metric and MQ retry path |
| Callback publish fails after retries | MQ message is nacked for broker retry |
Queue processing treats callback delivery as part of job completion. A generated image without a delivered callback is not considered a completed report job.
Operational Notes
- Default queue prefetch is
2, lower than grading because Chromium rendering is memory-heavy. GET /healthreturns both status and service name./metricspublishesreport_generation_total,report_generation_duration_seconds, andreport_generation_inflight.- The service closes the callback publisher, Playwright browser, and shared
httpx.AsyncClientduring shutdown. LUMIE_BACKEND_URLandLUMIE_INTERNAL_HMAC_SECRETare required settings.- The sync fallback route builds a
ReportCommandinternally and returns the raw JPEG bytes withmedia_type="image/jpeg".
Backend Trigger Example
The backend batch entrypoint that creates report jobs is ReportController.createBatchReport(...) at POST /v1/exams/{examId}/reports/batch.
curl -i \
-X POST http://localhost:8080/v1/exams/42/reports/batch \
-H 'Content-Type: application/json' \
-H 'X-Tenant-Slug: demo' \
-H 'Cookie: lumie_access_token=<staff-session-cookie>' \
-d '{"studentIds":[1001,1002]}'
Expected backend response:
- HTTP
202 Accepted Location: /v1/exams/42/reports/jobs/{jobId}- JSON body with the accepted job id and status URL
Verification
cd lumie-worker
uv run pytest services/report/tests
cd /path/to/Lumie
rg -n "report.generation-request|report.generation.callback|reportBytes|generate_jpg" \
lumie-worker/services/report lumie-worker/contracts/mq-schemas-v1.yaml
Verification success signals:
pytestexits0and theservices/report/tests/test_generate_report_usecase.pyslice passes- the worker grep shows
report.generation-requestinservices/report/src/config.pyandreport.generation.callbackinservices/report/src/adapters/callback_mq.py - a successful batch run eventually makes backend
GET /v1/exams/{examId}/reports/jobs/{jobId}returnprocessedReports == totalReports - when at least one report succeeded,
zipFileKeybecomes non-null inReportJobStatusResponse