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

mobile → edge: HTTPS, Bearer tokenweb → edge: HTTPS, session cookieedge → api: hono/vercel handle()api → auth: getSessionapi → onnx: 1x3x224x224 tensorapi → cox: feature mapapi → pg: Drizzle over Neon HTTPauth → pg: Drizzle adapterapi → blob: REST upload/downloadpy → cox: export_genomics_json.pypy → onnx: distil + ONNX exportExpo mobile appExpo mobile app (client) capture, register patientsNext.js clinicianconsoleNext.js clinician console (client) review queue, campaigns, genomicsPython researchpipelinePython research pipeline (third party) trains + exports both modelsVercel function(vercel-entry)Vercel function (vercel-entry) (edge) raw /health, lazy app importHono APIHono API (service) 39 handlers, 8 routersBetter AuthBetter Auth (service) email/password + bearercervicallens_edge.onnxcervicallens_edge.onnx (model) MobileNetV3-Small, NILM vs AbnormalCox risk scorer (TS)Cox risk scorer (TS) (model) 2 axes, JSON coefficientsNeon PostgresNeon Postgres (store) 11 tables via DrizzleSupabase StorageSupabase Storage (store) slide images, local-fs fallbackHTTPS, Bearer tokenHTTPS, session cookiehono/vercel handle()getSession1x3x224x224 tensorfeature mapDrizzle over Neon HTTPDrizzle adapterREST upload/downloadexport_genomics_json.…distil + ONNX export mobile → edge: HTTPS, Bearer tokenweb → edge: HTTPS, session cookieedge → api: hono/vercel handle()api → auth: getSessionapi → onnx: 1x3x224x224 tensorapi → cox: feature mapapi → pg: Drizzle over Neon HTTPauth → pg: Drizzle adapterapi → blob: REST upload/downloadpy → cox: export_genomics_json.pypy → onnx: distil + ONNX exportExpo mobile appExpo mobile app (client) capture, register patientsNext.js clinicianconsoleNext.js clinician console (client) review queue, campaigns, genomicsPython researchpipelinePython research pipeline (third party) trains + exports both modelsVercel function(vercel-entry)Vercel function (vercel-entry) (edge) raw /health, lazy app importHono APIHono API (service) 39 handlers, 8 routersBetter AuthBetter Auth (service) email/password + bearercervicallens_edge.onnxcervicallens_edge.onnx (model) MobileNetV3-Small, NILM vs AbnormalCox risk scorer (TS)Cox risk scorer (TS) (model) 2 axes, JSON coefficientsNeon PostgresNeon Postgres (store) 11 tables via DrizzleSupabase StorageSupabase Storage (store) slide images, local-fs fallbackHTTPS, Bearer tokenHTTPS, session cookiehono/vercel handle()getSession1x3x224x224 tensorfeature mapDrizzle over Neon HTTPDrizzle adapterREST upload/downloadexport_genomics_json.…distil + ONNX export
clientedgeservicemodelstorethird partyasync
  • [*]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

  1. 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:54 platform/apps/api/src/ml/genomicsRisk.ts:70 platform/apps/api/src/ml/genomics_risk_model.json:4 models/export_genomics_json.py
  2. 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:38 platform/apps/api/src/ml/cytology.ts:49 platform/apps/api/src/ml/cytology.ts:84
  3. 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:23 platform/apps/api/src/vercel-entry.ts:31 platform/build-vercel.js:33
  4. 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:208 platform/apps/api/src/routes/slides.ts:243 platform/apps/api/src/routes/reviews.ts:13 platform/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)