backend / cervical-lens-api
CervicalLens API
A Hono API on Vercel that serves a distilled ONNX cytology classifier and a Cox survival risk model ported to TypeScript, behind Better Auth, over Neon Postgres.
2026 · live · private repository
- [*]LanguagesTypeScript · Python
- [*]FrameworksHono · Better Auth · Drizzle ORM · onnxruntime-node · FastAPI
- [*]StoresPostgres (Neon) · Supabase Storage · Local filesystem
- [*]InfraVercel Serverless Functions (Node 22) · Vercel Build Output API · esbuild · Turborepo · Bun
- [*]Third partiesNeon serverless HTTP · Supabase Storage REST API
The API is a Hono app with one runtime-agnostic core (apps/api/src/app.ts) and two entrypoints: Bun for local dev and a hand-written Vercel Node handler bundled by esbuild through the Build Output API. It mounts 39 route handlers across 8 routers under /api/v1, plus Better Auth at /api/auth/* and a /health check. 28 of those handlers do real work against Postgres or a model; the 11 notification and device-token handlers are placeholders that return empty lists or 501.
Two models are served in-process, with no Python at runtime. The cytology screen is a MobileNetV3-Small student distilled from a larger teacher, exported to ONNX (about 0.3 MB of graph plus 4.3 MB of weights) and run with onnxruntime-node; the image is decoded and resized with Jimp and normalised into a 1x3x224x224 tensor by hand. The genomic risk model is a pair of regularised Cox models trained in Python on TCGA-CESC (n=294, cross-validated C-index 0.655); because a Cox score is a linear predictor and an exp(), the coefficients, means, scales and medians are exported to JSON and the scorer is re-implemented in about 20 lines of TypeScript.
The data layer is Drizzle over the Neon HTTP driver with 11 tables (4 Better Auth tables plus patients, campaigns, slides, reviews, campaign_members, device_tokens, notifications). A slide moves through a six-state status column: upload, inference, flagged for review when the model calls it abnormal, claimed by a pathologist, review complete. Schema is applied with drizzle-kit push; there is no migrations directory in the repo.
The API is deployed and responding at api-cervicallens.hallelx2.com. There are no automated tests for it; the only CI workflow builds the Android APK.
How it holds up
- Auth
- Better Auth email/password with the bearer() plugin so the Expo app can send the session token as Authorization: Bearer; a Hono middleware resolves the session and rejects deactivated users, and requireRole() gates pathologist-only review routes (admin passes every gate).
- Data
- 11 Drizzle tables in packages/db/src/schema.ts: Better Auth's user/session/account/verification (user extended with role, clinic and licence fields) plus patients, campaigns, slides (six-state status enum, four-level risk enum, model outputs stored as JSON text), reviews (agree/upgrade/downgrade/inconclusive decision), campaign_members, device_tokens and notifications. No migration files are committed; schema is pushed with drizzle-kit push.
- Consistency
- No transactions: a pathologist review inserts the review row and then updates the slide status as two separate statements (the neon-http driver used here does not offer interactive transactions), and slide-code uniqueness is checked with a select before insert, backed by a UNIQUE constraint. There is no idempotency key, retry or locking on claim, so two pathologists can claim the same slide.
- Failure
- onnxruntime-node is loaded lazily with createRequire inside a try/catch on first inference, so a native-binary failure on Vercel turns into a 400/500 on the model route instead of crashing cold start, and the Vercel entry answers /health before importing the app at all. Storage falls back from Supabase to the local filesystem when Supabase env vars are absent; there are no timeouts, retries or queues, and inference runs synchronously inside the request.
- Testing
- None for the API: git ls-files matches no test or spec files anywhere in the repo (0 files). Model metrics come from held-out and out-of-fold evaluation in research/, reported through /cytology/info and /risk/info.
Techniques
- Cox model ported to dependency-free TypeScriptThe Python Cox models are exported as per-feature coefficient, mean, scale, median and cohort-median threshold; the API median-imputes missing features, standardises, sums the log partial hazard per axis and maps the two high/low axes to LOW/MODERATE/HIGH/CRITICAL, so risk scoring needs no Python service.
platform/apps/api/src/ml/genomicsRisk.ts:54platform/apps/api/src/ml/genomicsRisk.ts:70platform/apps/api/src/ml/genomics_risk_model.json:4models/export_genomics_json.py - In-process ONNX inference with hand-built preprocessingJimp decodes and resizes the image, a loop writes RGBA pixels into a channel-first Float32Array with the training mean/std, and a softmax over the logits yields NILM/Abnormal probabilities; the session is created once and cached across warm invocations.
platform/apps/api/src/ml/cytology.ts:38platform/apps/api/src/ml/cytology.ts:49platform/apps/api/src/ml/cytology.ts:84 - Lazy native-module loading for serverlessonnxruntime-node is kept external to the esbuild bundle, loaded via createRequire inside a try/catch on first use, and the Vercel entry serves /health before importing the Hono app, so a missing native binary degrades only the inference routes.
platform/apps/api/src/ml/cytology.ts:23platform/apps/api/src/vercel-entry.ts:31platform/build-vercel.js:33 - Model-driven triage state machineRunning inference on a slide converts the abnormal probability into a four-level risk (critical above 0.8 when abnormal, low below 0.25 when normal) and moves abnormal slides to flagged_for_review; pathologists claim (under_review) and submit an agree/upgrade/downgrade decision that closes the slide.
platform/apps/api/src/routes/slides.ts:208platform/apps/api/src/routes/slides.ts:243platform/apps/api/src/routes/reviews.ts:13platform/packages/db/src/schema.ts:116
Counted
- 116Commits
git rev-list --count HEAD - 2026-07-16 to 2026-07-20Active span
git log --format=%ad --date=short (first, last) - 39 (+ /api/auth/* and /health)Route handlers
grep -cE '^[a-zA-Z]+Routes\.(get|post|put|patch|delete)\(' platform/apps/api/src/routes/*.ts - 11 (notifications 8, device-tokens 3)Placeholder handlers
platform/apps/api/src/routes/notifications.ts, device-tokens.ts - 11Postgres tables
grep -c "= pgTable(" platform/packages/db/src/schema.ts - 0Committed migrations
git ls-files | grep -iE 'migrat|drizzle/' - 0Test files
git ls-files | grep -iE 'test|spec' - 1,934API + db TypeScript LOC
git ls-files platform/apps/api platform/packages/db | grep '\.ts$' | xargs wc -l - 12,773Python LOC (models + research)
git ls-files '*.py' | xargs wc -l - 0.31 MB graph + 4.27 MB weightsONNX model size
ls -la platform/api/ - sensitivity 0.961, specificity 0.40, AUC 0.844Cytology test metrics
GET /api/v1/cytology/info (live) - C-index 0.655, n_train 294, 6+6 featuresGenomic risk model
GET /api/v1/risk/info (live)