Armaan Gulati
A single-screen review console where a person checks, corrects, and approves the structured oncology facts that an AI pulled out of a clinical note.
Live demo: https://armaangulati1.github.io/ChartExtract-UI/
Parent project (the extraction API this sits on top of): https://github.com/armaangulati1/chartextract
Clinical notes are messy free text. A separate service, ChartExtractor, reads a note and returns structured variables: primary site, histology, stage, biomarkers, ECOG status, line of therapy, date of diagnosis, and treatment regimen. Each value comes with a confidence score and, when available, the exact quote from the note that supports it.
This front-end is the human side of that loop. A reviewer:
- picks a note (five bundled synthetic examples, or pastes their own in Live API mode),
- runs the extraction,
- sees one card per field with its value, a confidence bar, the source, and any validator flags,
- hovers a card to light up the supporting quote inside the note,
- approves each field or corrects it with a typed editor, and
- exports the approved and corrected values as a gold-label JSON file.
Low-confidence fields are pushed to the top and visually marked as needing review, so attention goes where the model is least sure.
This is a time-boxed curiosity project, built in a day, exploring what the human-review surface on top of ChartExtractor should look and feel like. It is deliberately one screen with a fixed feature set.
All notes here are fictional and synthetic. Nothing in this project is a medical device and it must not be used for any clinical decision.
JavaScript and JSX only (no TypeScript), Vite, React 18 with functional
components and hooks. The review workflow runs on a single useReducer. Styling
is plain CSS with a small clinical-blue palette. Tests are Vitest plus React
Testing Library. Lint is ESLint (flat config) plus Prettier.
demo mode (default)
+-----------------------------+
note text ---> | lib/demo.js |
| load bundled fixture JSON |
| applyThreshold(threshold) |---+
+-----------------------------+ |
v
note text ---> +-----------------------------+ ExtractionOutput
live mode | lib/api.js POST /extract |--> { extract, fields,
| real ChartExtractor API | needs_review, usage,
+-----------------------------+ run_metrics }
|
v
+-----------------------------------------------+
| reviewReducer (EXTRACT_SUCCESS) |
| result + fresh per-field review (pending) |
+-----------------------------------------------+
|
selectOrderedFields (needs-review first) + selectCounts
v
+-----------------------------------------------+
| ResultsPanel -> FieldCard per field |
| approve / correct (typed editor) / reset |
+-----------------------------------------------+
|
buildCorrectionExport -> gold-label JSON
src/lib/reviewReducer.js owns the whole session: the active mode
(demo or live), the review threshold, the selected note, request status
(idle / loading / ready / error), the current result
(ExtractionOutput), and a review map of { status, value } per field where
status is pending, approved, or corrected.
Keeping this pure and framework-free is intentional. Every workflow transition
(extract, approve, correct, reset a field, reset the whole review, change note,
re-route on a new threshold) is a plain action, and the selectors
(selectCounts, selectOrderedFields, buildCorrectionExport) are pure
functions of state. That makes the workflow easy to unit test without rendering
React.
Both sources return the same ExtractionOutput shape, so the rest of the app
does not care which one ran:
lib/api.jscalls the real API atPOST /extractusingfetchand anAbortControllertimeout. Errors (non-200, timeout, network, bad JSON) are normalized into a singleApiError.lib/demo.jsreplays a bundled fixture. The fixtures insrc/fixtures/*.jsonare real responses captured from the live API by curling each of the five synthetic notes (values vary slightly per run, since the pipeline uses an LLM; the captures are saved verbatim). Demo mode is the default so the console works instantly and stays usable when the free-tier API is asleep.
Demo mode only replays fixtures for the five bundled notes. Pasting your own
note is a Live API feature: in demo mode the paste box disables Extract and
shows an inline note pointing you to Live API. This is deliberate. Pairing an
arbitrary pasted note with a canned response would be dishonest and would
corrupt any gold-label export built from it, so extractDemo also throws if it
is ever asked to resolve an unknown note id, as a last line of defense behind
the UI guard.
The API decides needs_review server-side: a field is routed to review when its
confidence is below review_threshold (see schema.py in the parent repo). The
UI reads result.needs_review and selectOrderedFields sorts those fields to
the top, where FieldCard renders them in a distinct amber state.
The threshold slider drives this from the front end:
- In live mode, changing the slider changes the value sent as
review_thresholdon the next extract, and the server recomputes routing. - In demo mode, the fixtures have fixed confidences, so
lib/routing.jsapplyThresholdrecomputesneeds_reviewclient-side using the same rule (confidence < threshold). This keeps the slider honest and interactive without a network call. Raising the threshold on a bundled note visibly moves cards into the needs-review state.
The default threshold differs by mode on purpose. Live mode defaults to 0.75 to
match the API server default in schema.py. Demo mode defaults to 0.90 because
the captured fixture confidences cluster around 0.85, so a 0.75 cutoff would
show zero flagged fields and hide the review feature on the very first extract.
Both defaults live in DEFAULT_THRESHOLD in reviewReducer.js, and switching
mode resets the threshold to that mode's default.
npm install
npm run dev # http://localhost:5173/ChartExtract-UI/
npm test # vitest
npm run lint # eslint
npm run build # optimized bundle into dist/
Vitest and React Testing Library cover the API client (mocked fetch, including error and timeout paths), the review reducer (approve, correct, reset, threshold change, re-route), needs-review sorting, the correction-export shape, demo-mode fixture loading, evidence highlighting, value formatting, and field-card render states including the needs-review state.
Two GitHub Actions workflows are included and activate once the repo is pushed:
ci.yml runs install, lint, format check, test, and build on every push and PR;
deploy.yml builds and publishes to GitHub Pages via actions/deploy-pages
(Vite base is set to /ChartExtract-UI/ for project-site hosting). The deploy
workflow calls actions/configure-pages@v5 with enablement: true, so it turns
Pages on for the repo on its first run rather than failing before Pages has been
enabled in settings.
The README screenshot is regenerated with a committed dev utility,
scripts/capture-screenshot.mjs, which drives a local Chrome via puppeteer-core
against a running preview server. puppeteer-core is intentionally not a
dependency; install it transiently with npm i --no-save puppeteer-core before
running the script (see the script header for the exact steps).
- Notes are synthetic and fictional. There is no real PHI anywhere.
- No authentication. Anyone with the URL can use the demo.
- No history browsing. Each session reviews one note at a time; exported corrections are downloaded, not persisted anywhere.
- Single screen by design. No routing, no multi-note batch view.
- Confidence values from the current API cluster around 0.85, so the review threshold becomes interesting mainly above about 0.85. Demo mode defaults to 0.90 for that reason (see the routing section).
- Migrate to TypeScript for stronger schema typing at the boundary.
- A
/historybrowser backed by the API's existing history endpoint. - Gold-set aggregation: collect exported corrections into an evaluation set and measure model accuracy against reviewer decisions over time.
