Skip to main content

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

PathRole
lumie-worker/services/report/main.pyFastAPI app, lifespan, health route, sync fallback route
lumie-worker/services/report/src/schema.pyMQ command and callback payload models
lumie-worker/services/report/src/usecase.pyReport generation orchestration
lumie-worker/services/report/src/mq/consumer.pyRabbitMQ consumer and callback payload builders
lumie-worker/services/report/src/domain/report_generator.pyJinja2 plus Playwright rendering
lumie-worker/services/report/src/domain/report_data.pyTyped report data model
lumie-worker/services/report/src/adapters/exam_api.pyBackend exam, result, rank, and statistics client
lumie-worker/services/report/src/adapters/student_api.pyBackend student profile client
lumie-worker/services/report/templates/exam_report.htmlHTML template captured as a JPEG
lumie-worker/contracts/mq-schemas-v1.yamlHand-maintained report MQ contract reference

Public Surface

Operational routes:

  • GET /health
  • GET /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

  1. FastAPI startup builds the dependency container, warms the Playwright browser, connects the callback publisher, and starts the RabbitMQ consumer.
  2. GenerateReportUseCase fans out five backend reads in parallel: student profile, exam statistics, stability index, exam result for the student, and student rank.
  3. Rank lookup is best-effort. If it fails, the report still renders.
  4. The worker fetches question-level results for the selected exam result.
  5. It assembles a typed ReportData object and renders HTML with the exam_report.html Jinja2 template.
  6. Playwright opens a Chromium page, loads the HTML, and captures a full-page JPEG screenshot.
  7. 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-Slug
  • X-Signature
  • X-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

DependencyWhy it exists
RabbitMQ via aio-pikaReceive report jobs and publish completion callbacks
Backend HTTP API via httpxFetch student, exam, result, rank, and stability data
Jinja2Render the report template from typed report data
Playwright ChromiumConvert the HTML template into the final image
OpenTelemetry and PrometheusMeasure latency and trace backend plus rendering work

Failure Semantics

FailureBehavior
Malformed MQ bodyRejected by shared MQ processing and sent toward the broker DLQ path
Backend rank lookup failsLogged and ignored; report still renders without rank
Backend exam result is missingHTTPException(404) and failure callback on MQ path
Backend required data call failsHandler failure metric and MQ retry path
Callback publish fails after retriesMQ 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 /health returns both status and service name.
  • /metrics publishes report_generation_total, report_generation_duration_seconds, and report_generation_inflight.
  • The service closes the callback publisher, Playwright browser, and shared httpx.AsyncClient during shutdown.
  • LUMIE_BACKEND_URL and LUMIE_INTERNAL_HMAC_SECRET are required settings.
  • The sync fallback route builds a ReportCommand internally and returns the raw JPEG bytes with media_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:

  • pytest exits 0 and the services/report/tests/test_generate_report_usecase.py slice passes
  • the worker grep shows report.generation-request in services/report/src/config.py and report.generation.callback in services/report/src/adapters/callback_mq.py
  • a successful batch run eventually makes backend GET /v1/exams/{examId}/reports/jobs/{jobId} return processedReports == totalReports
  • when at least one report succeeded, zipFileKey becomes non-null in ReportJobStatusResponse