A small hospital system that ingests test results from lab devices, lets a doctor review them, flags abnormal values, and provides an AI-assisted preliminary interpretation with password login, encryption of patient data, and an audit trail.
| Component | Stack | Responsibility |
|---|---|---|
mock-lab-device/ |
Node/Express + TypeScript | Simulates a lab analyzer; emits JSON results across many scenarios |
backend/ |
Spring Boot 3.5 (Java 17, Maven) | Polls, validates, flags, encrypts, stores, serves the REST API, calls the LLM, audits |
frontend/ |
React 18 + Vite + TypeScript + MUI | Turkish doctor dashboard: login, result list with abnormal highlighting, detail view, AI interpretation |
db |
PostgreSQL 16 + Flyway | Persistence + reproducible schema/seed |
llm |
Ollama (qwen2.5:3b) | Local, CPU-friendly preliminary interpretation |
For a quick evaluation walkthrough, please start with the step-by-step
docs/usage-guide.md. It shows the intended reviewer flow with screenshots:
- sign in as doctor/admin,
- review lab results and abnormal/critical highlighting,
- open a report detail,
- request an AI preliminary interpretation,
- inspect the admin audit log.
All endpoints except login and Swagger require Authorization: Bearer <jwt>.
| Method | Path | Description |
|---|---|---|
| POST | /api/auth/login |
Authenticate, receive a JWT |
| GET | /api/auth/me |
Current user |
| GET | /api/lab-reports |
Paged list; filters: abnormalOnly, criticalOnly, status, q (report id), from/to (date) |
| GET | /api/lab-reports/summary |
Dashboard counts (scoped to the caller's visibility) |
| GET | /api/lab-reports/{id} |
Report detail with analytes + flags (PII decrypted) |
| POST | /api/lab-reports/{id}/interpretation |
Generate (or return cached) AI interpretation; ?refresh=true to regenerate |
| GET | /api/lab-reports/{id}/interpretation |
Latest stored interpretation (204 if none) |
| GET / POST | /api/users |
List / create accounts — ADMIN only |
| GET | /api/audit |
Audit trail — ADMIN only |
Stack
- Spring Boot 3.5 (not 4.0). I pinned to the mature 3.5 LTS-aligned release for stability, broad library compatibility (springdoc, jjwt) and APIs I can confidently defend. Same reasoning drove React 18 + MUI 6 + X-Data-Grid 7 instead of the just-released MUI 9. All more stable and reliable.
- Java 17 + Maven wrapper, Vite + TypeScript, PostgreSQL + Flyway — standard,
reproducible, enterprise-familiar. Flyway owns the schema; JPA runs in
validatemode. - Ollama +
qwen2.5:3b. Local, free, fully reproducible, and small enough to run on CPU-only (~2 GB). Chosen over llama3.2:3b specifically for its stronger Turkish (the AI commentary is shown to Turkish clinicians). Trade-off: a 3B model favors latency/footprint over depth. Swappable viaOLLAMA_MODEL. - Turkish doctor UI. LabAssist is a tool for a Turkish hospital, so the doctor-facing UI and the AI interpretation are in Turkish (dates in
tr-TR, Turkish patient names). - Node/Express mock as a separate service: it represents an external analyzer the backend polls over HTTP, so a distinct process was the cleaner boundary here and can later be replaced with a real device integration without affecting the backend's internal logic.
Business logic & AI
- The backend owns abnormality flagging. It recomputes every flag from its own seeded reference-range catalog (including sex-specific ranges, e.g. hemoglobin) rather than trusting the ranges a device sends.
- Hybrid analysis. A deterministic rule engine (
AbnormalityEvaluator) decides what isNORMAL/LOW/HIGH/CRITICAL_*; the LLM only narrates a preliminary interpretation grounded in those pre-computed flags. The system never directly depends on the model, and the flags are reproducible and testable. - Privacy by design, no PII to the LLM. The prompt contains only age, sex, analyte values, reference ranges and flags. Patient name and MRN are never sent to the model (unit-tested).
- Safety framing. The system prompt forbids definitive diagnosis, leads with critical values, and always ends recommending doctor review. UI shows disclaimer.
Resilience & correctness (ingestion)
- Scheduled poll with an in-memory cursor; idempotent on
external_id(duplicates skipped). - Each message is validated and persisted in its own transaction, so one bad message never
aborts the batch. Malformed payloads →
REJECTED(raw payload + reason kept); missing values →PARTIAL; otherwiseVALIDATED. - Device errors / timeouts / empty batches are logged and retried next cycle with the cursor untouched — no data loss. The mock deliberately injects all of these.
Security, encryption & audit
- JWT (HS256) + BCrypt, stateless sessions, CORS limited to the SPA origin, role-based access
(
DOCTOR/ADMIN), consistent JSON401/403/404responses. - Field-level encryption of PII at rest. Patient name and MRN are encrypted with
AES-256-GCM (authenticated) via a JPA
AttributeConverter; ciphertext in the DB, transparent decryption only for authorized API responses. Verified at-rest in an integration test. - Audit trail in a dedicated
audit_logtable (the brief's "logging system"): login success/failure, ingestion polls, report views, LLM requests and account creation — with an admin viewer. Audit writes useREQUIRES_NEWso they persist independently of the business transaction. - Role-scoped visibility & provisioning. Doctors see only clinically-valid reports; malformed
REJECTEDones are admin-only (a data-quality concern). There is no public signup — accounts are provisioned by an admin (/api/users), which fits a clinical system.
LLM endpoint
- Persists every attempt (
SUCCESS/FAILURE/TIMEOUT) for auditability, caches the latest success (regenerate with?refresh=true), is not wrapped in a DB transaction during the slow call, and maps runtime failures to a clear 503.
- Streaming LLM responses — would improve perceived latency; non-streaming kept the contract simple.
- Refresh tokens / revocation — a single short-lived access token is enough for this scope.
- Searchable encrypted fields — because name/MRN are encrypted at rest, free-text search is by report id only. A blind index would enable encrypted-field search at the cost of complexity.
- Frontend test suite — testing effort was concentrated on the backend (where the business logic lives); only high-value paths are covered.
- Queue-based ingestion (e.g. Kafka) — a single scheduled poller is sufficient at this scale.
- Unit (JUnit 5 + Mockito): abnormality flagging, AES-GCM encryption, JWT, the de-identified prompt (asserts no PII leaks), reference-range selection.
- Integration (Testcontainers + real PostgreSQL): the full ingestion path (validate → flag → encrypt → persist, incl. critical / partial / malformed / duplicate and PII-ciphertext-at-rest), and the API/security/LLM endpoints over MockMvc (Ollama mocked).
cd backend && ./mvnw test → 34 tests, all green.
labassist/
├── mock-lab-device/ # Node/Express/TS — lab device simulator (scenarios + chaos)
├── backend/ # Spring Boot — ingestion, REST API, auth, crypto, LLM, audit
├── frontend/ # React + Vite + MUI — doctor dashboard
├── docs/ # architecture, usage guide, screenshots
├── docker-compose.yml # full stack, one command
└── .env.example # config template

