noteguard-agent / docs /architecture.md
github-actions[bot]
Deploy 9aa839066bbf99a8ada733b41479a39770b3bb83 from main
eb83689
|
Raw
History Blame Contribute Delete
7.07 kB
# Architecture
## Overview
NoteGuard is the trust layer for clinical AI β€” a LangGraph ReAct agent (Gemini +
Tavily) where the language model structurally **cannot** see patient identifiers
because `assert_clean()` raises before any PHI reaches it.
The system has four layers:
1. **De-identification core** (`src/deid.py`) β€” dependency-free, runnable
standalone. NHS-aware recognisers, vault-from-CSV, consistent surrogates,
DOB date-shift, `assert_clean()` hard guarantee.
2. **Agent** (`agent/graph.py`) β€” LangGraph `StateGraph`. Gemini drafts the
answer; Tavily grounds it in NICE/NHS public guidance. Neither sees PHI.
3. **REST API** (`app/api.py`) β€” FastAPI backend exposing `GET /` (web UI),
`GET /health`, `POST /process`, `POST /summarise`, `GET /samples`, and
`GET /sample/{id}`. Also serves the static clinician UI.
4. **UI** β€” `app/static/index.html` (clinician web UI, vanilla JS, no build step, served by the FastAPI `GET /` handler).
## Package layout
```
src/
β”œβ”€β”€ __init__.py # exports NoteGuard, DeidResult, load_known_from_csv
β”œβ”€β”€ deid.py # de-id core (standard library only)
└── fetch_dataset.py # downloads synthetic_clinical_notes to data/
agent/
└── graph.py # LangGraph StateGraph exposed as `noteguard` for langgraph dev
app/
β”œβ”€β”€ api.py # FastAPI β€” GET /, GET /health, GET /samples, GET /sample/{id},
β”‚ # POST /process, POST /summarise
└── static/
└── index.html # Clinician web UI (single-file, vanilla JS, no build step)
streamlit_app.py # Interactive de-id demo β€” no API keys needed
eval/
└── run_eval.py # LangSmith evals: zero_phi_to_model + faithfulness
tests/ # pytest suite (24 tests, no external deps)
data/ # synthetic CSV files (git-ignored; produced by src/fetch_dataset.py)
docs/ # architecture, user guide, RAP compliance, tool card, ATRS report
```
## Graph pipeline
For every query the graph runs:
```
deidentify_in β†’ agent β†’ reidentify_out β†’ compute_trust
```
| Node | Function | Description |
|---|---|---|
| `deidentify_in` | `NoteGuard.deidentify()` + `assert_clean()` | Strips PHI; raises if any identifier survives. |
| `agent` | `create_react_agent` (Gemini + Tavily) | Drafts answer; sees only de-identified text. |
| `reidentify_out` | `NoteGuard.reidentify()` | Restores surrogates β†’ real names for the clinician only. |
| `compute_trust` | `NoteGuard.scan_pii()` | De-id audit β€” residual PII the model saw + orphaned surrogate tokens (reversibility) for the trust panel. |
## State fields
In addition to `messages`, the graph state carries:
| Field | Type | Description |
|---|---|---|
| `deid_text` | `str` | De-identified version of the input note. |
| `forward` | `dict` | Original-identifier β†’ surrogate mapping. |
| `identifiers_removed` | `int` | Count of identifiers replaced in this turn. |
| `residual_count` | `int` | Known identifiers that survived (target: 0). |
| `leaked_tokens` | `list[str]` | Orphaned/unresolved surrogate tokens in the output (reversibility). |
| `clinician_answer` | `str` | Re-identified, clinician-facing answer. |
| `residual_pii` | `list[dict]` | `{type, text}` findings of suspected un-redacted PII in `deid_text`. |
## REST API
`app/api.py` exposes six endpoints:
| Endpoint | Method | Description |
|---|---|---|
| `/` | GET | Serves `app/static/index.html` β€” the clinician web UI. |
| `/health` | GET | Liveness probe; no API keys required. Returns `notes_loaded` count. |
| `/samples` | GET | Paginated list of synthetic notes; supports `q`, `note_type`, `limit`, `offset`. |
| `/sample/random` | GET | One random synthetic note. |
| `/sample/{clinical_note_id}` | GET | Full note by ID (used by the note-picker modal). |
| `/process` | POST | Full UI payload: `clinician_note`, `ai_note`, `identifiers`, `discharge_summary`, `metrics`. |
| `/summarise` | POST | Compact payload: `clinician_answer`, `identifiers_removed`, `residual_risk`, `deidentified_excerpt`, `ok`. |
`POST /process` request shape:
```json
{
"note": "Pt Margaret Okafor (NHS 485 777 3456) admitted post-fall.",
"question": "Draft a discharge summary.",
"person_id": "pt-001"
}
```
`POST /process` response shape:
```json
{
"clinician_note": "verbatim input",
"ai_note": "de-identified text the model saw ([PERSON_1], [NHS_1], …)",
"identifiers": ["Margaret Okafor", "485 777 3456", "…"],
"discharge_summary": "re-identified Gemini compact eDischarge card for the clinician",
"metrics": {
"deid_ok": true,
"identifiers_removed": 5,
"residual_pii": [],
"residual_pii_count": 0,
"reversible": true,
"leaked_tokens": []
}
}
```
`422` is returned when `assert_clean()` detects surviving PHI β€” the model sees nothing.
Every metric reports whether reversible pseudonymisation was done correctly.
`deid_ok` is `true` only when `residual_pii` is empty (nothing un-redacted reached the
model) **and** `reversible` is `true` (every surrogate restores to a real value). The
`residual_pii` audit (`NoteGuard.scan_pii`) is vault-independent β€” it catches free-text
names the vault/NER passes missed, so a pasted note with no ground truth is still graded.
## Clinician web UI
`app/static/index.html` is a self-contained single-page application (vanilla JS, no build step):
- **Note picker modal** β€” browse and filter synthetic notes by keyword and note type;
clicking a row loads the note into the textarea (the patient is never named in the output).
- **Segmented toggle** β€” switches between two views without re-calling the API:
- *Clinician view*: original note with each redacted identifier wrapped in a red `<mark>`.
- *What the AI sees*: de-identified note with `[TYPE_N]` surrogate tokens displayed as blue monospace chips.
- **Generate** β€” POSTs to `/process`, populates the discharge summary pane and trust panel.
- **Trust panel** β€” metric cards, all reporting de-id correctness: De-identification (PASS/FAIL), Identifiers replaced, Residual PII Β· model input (count + the offending snippets when > 0), Reversible (βœ“/βœ—, with unresolved tokens when βœ—).
## External services
| Concern | Service | Notes |
|---|---|---|
| Reasoning | Google Gemini | `google_genai:gemini-2.5-flash` (configurable via `NOTEGUARD_MODEL`). |
| Grounding | Tavily | Public NICE/NHS guidance only β€” patient text never sent. |
| Observability | LangSmith | Auto-traces when `LANGSMITH_TRACING=true`. |
All credentials are read from environment variables; nothing is hard-coded.
## Deployment
### Local (development)
```bash
pip install -e ".[dev]"
uvicorn app.api:app --reload --port 8000
```
### Hugging Face Spaces (production)
`Dockerfile` builds a lean Docker image installing only the runtime dependencies
declared in `pyproject.toml`, served by uvicorn on port 7860.
Required secrets: `GOOGLE_API_KEY`, `TAVILY_API_KEY`, `LANGSMITH_API_KEY`.