github-actions[bot] commited on
Commit
eb83689
Β·
0 Parent(s):

Deploy 9aa839066bbf99a8ada733b41479a39770b3bb83 from main

Browse files
.editorconfig ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ root = true
2
+
3
+ [*]
4
+ end_of_line = lf
5
+ insert_final_newline = true
6
+ trim_trailing_whitespace = true
7
+ charset = utf-8
8
+
9
+ [*.py]
10
+ indent_style = space
11
+ indent_size = 4
12
+
13
+ [*.{yml,yaml,toml,json}]
14
+ indent_style = space
15
+ indent_size = 2
16
+
17
+ [Makefile]
18
+ indent_style = tab
.env.example ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Google Gemini (DeepMind) - reasoning model
2
+ GOOGLE_API_KEY=
3
+
4
+ # Tavily - public-guidance grounding only (never patient text)
5
+ TAVILY_API_KEY=
6
+
7
+ # LangSmith - tracing + evals
8
+ LANGSMITH_API_KEY=
9
+ LANGSMITH_TRACING=true
10
+ LANGSMITH_PROJECT=noteguard-hackathon
11
+
12
+ # optional: swap the model (any init_chat_model id, e.g. anthropic:claude-... )
13
+ NOTEGUARD_MODEL=google_genai:gemini-2.5-flash
.github/PULL_REQUEST_TEMPLATE.md ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ## Summary
2
+
3
+ <!-- What does this PR do and why? -->
4
+
5
+ ## Checklist
6
+
7
+ - [ ] The `assert_clean()` guarantee is not weakened or bypassed
8
+ - [ ] New functions have type hints and a one-line docstring
9
+ - [ ] Tests added or updated for any changed logic in `noteguard/`
10
+ - [ ] `make lint` and `make test` pass locally
11
+ - [ ] No credentials or API keys committed (check `.env` is git-ignored)
12
+ - [ ] `CHANGELOG.md` updated if this is a user-visible change
.github/workflows/ci.yml ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: ["main"]
6
+ pull_request:
7
+ workflow_dispatch:
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ jobs:
13
+ quality:
14
+ name: Lint and test (Python ${{ matrix.python-version }})
15
+ runs-on: ubuntu-latest
16
+ strategy:
17
+ fail-fast: false
18
+ matrix:
19
+ python-version: ["3.10", "3.12"]
20
+
21
+ steps:
22
+ - name: Check out repository
23
+ uses: actions/checkout@v4
24
+
25
+ - name: Set up Python
26
+ uses: actions/setup-python@v5
27
+ with:
28
+ python-version: ${{ matrix.python-version }}
29
+ cache: pip
30
+
31
+ - name: Install development dependencies
32
+ run: pip install -e ".[dev]"
33
+
34
+ - name: Lint with ruff
35
+ run: ruff check src agent app eval tests
36
+
37
+ - name: Check formatting with ruff
38
+ run: ruff format --check src agent app eval tests
39
+
40
+ - name: Run tests with coverage
41
+ run: pytest --cov=src --cov-report=term-missing
.github/workflows/deploy-hf.yml ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Deploy to Hugging Face Space
2
+
3
+ # Deploys main to the Hugging Face Space (chaeyoona/noteguard-agent), which then
4
+ # rebuilds the Docker image from the Dockerfile and serves it on port 7860.
5
+ # Requires an `HF_TOKEN` repository secret (a write-scoped Hugging Face access token).
6
+
7
+ on:
8
+ push:
9
+ branches: ["main"]
10
+ workflow_dispatch:
11
+
12
+ permissions:
13
+ contents: read
14
+
15
+ concurrency:
16
+ group: hf-deploy
17
+ cancel-in-progress: true
18
+
19
+ jobs:
20
+ deploy:
21
+ name: Push main to the Space
22
+ runs-on: ubuntu-latest
23
+ steps:
24
+ - name: Check out main
25
+ uses: actions/checkout@v4
26
+
27
+ - name: Push snapshot to Hugging Face Space
28
+ env:
29
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
30
+ run: |
31
+ if [ -z "$HF_TOKEN" ]; then
32
+ echo "::error::HF_TOKEN secret is not set. Add a write-scoped Hugging Face token under Settings -> Secrets and variables -> Actions."
33
+ exit 1
34
+ fi
35
+ # The Space is a deploy target, not a source repo. Push a single fresh
36
+ # snapshot of the current tree (orphan commit, no history) and force it
37
+ # onto the Space's main branch. This keeps the Space in lockstep with
38
+ # main and avoids sending historical binary blobs that HF's git backend
39
+ # rejects (it requires such files to be stored via Git LFS / Xet).
40
+ git config user.name "github-actions[bot]"
41
+ git config user.email "github-actions[bot]@users.noreply.github.com"
42
+ git checkout --orphan deploy
43
+ git add -A
44
+ git commit -q -m "Deploy ${GITHUB_SHA} from ${GITHUB_REF_NAME}"
45
+ git push --force \
46
+ "https://chaeyoona:${HF_TOKEN}@huggingface.co/spaces/chaeyoona/noteguard-agent.git" \
47
+ deploy:main
.gitignore ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # secrets / env
2
+ .env
3
+ .env.*
4
+ !.env.example
5
+
6
+ # python
7
+ __pycache__/
8
+ *.py[cod]
9
+ *.egg-info/
10
+ .venv/
11
+ venv/
12
+ .ipynb_checkpoints/
13
+
14
+ # data β€” never commit patient-like data (even synthetic dumps)
15
+ data/
16
+ *.csv
17
+
18
+ # langgraph / langsmith / logs
19
+ .langgraph_api/
20
+ *.log
21
+
22
+ # os / editor
23
+ .DS_Store
24
+ .vscode/
25
+ .idea/
.pre-commit-config.yaml ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Pre-commit hooks -- run `pre-commit install` once, then they run on every commit.
2
+ # See https://pre-commit.com for details.
3
+ repos:
4
+ - repo: https://github.com/pre-commit/pre-commit-hooks
5
+ rev: v4.6.0
6
+ hooks:
7
+ - id: trailing-whitespace
8
+ - id: end-of-file-fixer
9
+ - id: check-yaml
10
+ - id: check-toml
11
+ - id: check-added-large-files
12
+ args: ["--maxkb=2048"]
13
+ - id: detect-private-key
14
+
15
+ - repo: https://github.com/astral-sh/ruff-pre-commit
16
+ rev: v0.6.9
17
+ hooks:
18
+ - id: ruff
19
+ args: ["--fix"]
20
+
21
+ - repo: https://github.com/psf/black
22
+ rev: 24.10.0
23
+ hooks:
24
+ - id: black
CHANGELOG.md ADDED
@@ -0,0 +1,268 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format is based
4
+ on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
+ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [1.2.4] - 2026-06-30
8
+
9
+ ### Fixed
10
+
11
+ - **"Admitted [redacted]" in the discharge summary.** The de-id **shifts** every date
12
+ (DOB and visit dates) rather than tokenising it, so no `[DATE_n]` token exists β€” but
13
+ the 1.2.2 prompt told the model to reproduce a date *token*. Seeing dates but no token,
14
+ the model hallucinated a `[DATE_1]`/`[DATE_X]`, which the `redact_unresolved()` safety
15
+ net then turned into `[redacted]`.
16
+ The date shift is already **reversible** (`forward: 13/02/26 β†’ 22/03/26`, reverse
17
+ restores it), so the fix is prompt-only: the model is now told to copy the
18
+ admission/visit date **exactly as written** in the note (a plain date, not a token);
19
+ it sees only the shifted date, and `reidentify_out` restores the **true** admission
20
+ date for the clinician (e.g. "Admitted on 13/02/26 …"). Not a single line of
21
+ `src/deid.py` changed; a new test guards the shift round-trip.
22
+
23
+ ### Changed
24
+
25
+ - **The patient is no longer named in the discharge summary.** The card previously
26
+ rendered a `<name> β€” discharge summary` title (resolved from `person_id`), which both
27
+ duplicated the static "Discharge Summary" header and put the patient's real name on the
28
+ output. The patient name / `{{PATIENT}}` placeholder is removed entirely:
29
+ - **Prompt** β€” the output format drops the title line (now three elements: narrative,
30
+ follow-up, grounded); the model is told never to name the patient and to refer to
31
+ them as "the patient" throughout. Other people's surrogate tokens still restore.
32
+ - **`reidentify_out`** β€” no longer substitutes a patient name; `State.person_name` and
33
+ the `app/api.py` `person_id β†’ name` lookup (`_PATIENT_NAMES`) are removed (the
34
+ `/process` `person_id` field is kept, accepted-but-unused, for UI compatibility).
35
+ - **UI** β€” `renderSummary()` drops any stray title line; the card's "Discharge Summary"
36
+ header is the only title.
37
+
38
+ ## [1.2.2] - 2026-06-30
39
+
40
+ ### Fixed
41
+
42
+ - **Literal `[DATE_X]` reaching the discharge summary.** Two compounding causes: the
43
+ SYSTEM prompt's format example literally contained `Admitted [DATE_X] after …`, which
44
+ the model copied verbatim when the de-identified note had no admission-date token (the
45
+ date is only in the dataset's structured tables, never in the text the model sees); and
46
+ `reidentify_out` only recognised `[LABEL_<digits>]`, so `[DATE_X]` was never flagged or
47
+ redacted. Fixes:
48
+ - **Prompt** β€” the date example is now the angle-bracket fill-in `<admission date>`,
49
+ with an explicit rule to reproduce a `[DATE_n]` token only if present and otherwise
50
+ omit the date β€” never emit a literal placeholder.
51
+ - **Defense-in-depth** β€” new `NoteGuard.redact_unresolved()` (pattern `_ORPHAN_PAT`,
52
+ `[LABEL_<anything>]`) redacts and flags any surrogate-shaped token still present after
53
+ re-identification, including stray template placeholders like `[DATE_X]`.
54
+ `reidentify_out` now routes through it, so none can reach the clinician verbatim.
55
+
56
+ ## [1.2.1] - 2026-06-30
57
+
58
+ ### Fixed
59
+
60
+ - **NER over-redaction of clinical terms** β€” the small spaCy model (`en_core_web_md`)
61
+ shipped in 1.2.0 mislabels abbreviations like "Subcut" as a `PERSON`, and Presidio
62
+ scores every spaCy entity a flat `0.85`, so a confidence threshold cannot separate
63
+ them from real names. Added a clinical-term allow-list (`_NER_STOPWORDS`) applied at
64
+ the detector boundary (`NoteGuard._detect_names`), so those terms are never redacted
65
+ as names while real names alongside them still are. Used in both `deidentify()` and
66
+ `residual_identifiers()` so the eval leak-check is consistent too.
67
+
68
+ ## [1.2.0] - 2026-06-29
69
+
70
+ Follow-up to 1.1.0: actually **raise de-identification recall** so the residual-PII
71
+ audit has less to catch. The 1.1.0 panel honestly reported leaked free-text names
72
+ (e.g. "Dr Ethel Joanne Duffy") but nothing redacted them in the deployed image,
73
+ because Presidio/spaCy NER was wired behind the `_Detector` interface yet never
74
+ shipped. This turns it on.
75
+
76
+ ### Added
77
+
78
+ - **Presidio + spaCy NER in the deployed image** β€” `Dockerfile` installs the `[nlp]`
79
+ extra (`presidio-analyzer`, `spacy`) and `en_core_web_md`, so free-text PERSON/
80
+ LOCATION names with no vault entry are now tokenised before the model sees them.
81
+ - **`[nlp]` optional dependency** in `pyproject.toml` (`pip install -e ".[nlp]"`).
82
+ - **`NOTEGUARD_SPACY_MODEL`** env var (default `en_core_web_md`) β€” switch to
83
+ `en_core_web_lg` for higher recall at ~14Γ— the model size, or `en_core_web_sm`
84
+ for a smaller image.
85
+
86
+ ### Changed
87
+
88
+ - `src/deid.py` `_build_detector()` now configures the spaCy model explicitly via
89
+ `NlpEngineProvider` (so it no longer hard-defaults to the unshipped `en_core_web_lg`)
90
+ and still degrades gracefully to a no-op stub when Presidio is absent (CI, local).
91
+ - Tests pin the no-op detector by default (autouse fixture) so the suite is
92
+ deterministic whether or not the `[nlp]` extra is installed; a new test covers the
93
+ NER redaction path.
94
+
95
+ ### Notes
96
+
97
+ - NER is a recall **boost**, not a guarantee: `en_core_web_md` misses some names
98
+ (recall is also lower for non-English names β€” see `docs/tool_card.md`). `scan_pii`
99
+ remains the high-precision backstop that surfaces anything still leaking, and
100
+ `assert_clean()` is unchanged (vault + structured patterns).
101
+
102
+ ## [1.1.0] - 2026-06-29
103
+
104
+ The trust panel now reports **only** whether reversible pseudonymisation was done
105
+ correctly. The previous panel could read `RE-ID RISK 0.0%` while un-redacted free-text
106
+ names (e.g. "Dr Ethel Joanne Duffy") reached the model, because the risk number was
107
+ computed from the *vault* β€” and arbitrary pasted names are never in the vault.
108
+
109
+ ### Added
110
+
111
+ - **`NoteGuard.scan_pii(text)`** (`src/deid.py`) β€” a vault-independent residual-PII
112
+ audit of de-identified text. Flags structured identifiers that survived (NHS, GMC,
113
+ NMC, email, phone, postcode) and free-text person names via a high-precision
114
+ person-title heuristic (`Dr/Nurse/Consultant/…` + β‰₯2 Title-Case tokens). Surrogate
115
+ tokens and bare role words are never flagged. Works on notes with no ground-truth
116
+ vault, which is exactly where the old metric was blind.
117
+ - **Trust-panel metrics focused on de-id correctness** β€” `/process` now returns
118
+ `deid_ok` (PASS/FAIL verdict), `residual_pii` (`[{type, text}]` the model still saw),
119
+ `residual_pii_count`, and `reversible`. The UI cards become **De-identification**,
120
+ **Identifiers replaced**, **Residual PII Β· model input** (with the offending snippets
121
+ listed), and **Reversible**.
122
+
123
+ ### Removed
124
+
125
+ - **Faithfulness and Grounded-sources metrics** from the trust panel β€” answer-quality
126
+ signals, not de-identification correctness. Dropped the in-graph faithfulness judge
127
+ (`model.invoke`) and Tavily-source extraction from `compute_trust`, and the
128
+ `faithfulness_score` / `sources` state fields. (Tavily still grounds the answer; the
129
+ `eval/run_eval.py` faithfulness evaluator is unchanged β€” it is a separate experiment.)
130
+ - **`metrics.residual_risk`** from `/process` β€” replaced by the honest `deid_ok` +
131
+ `residual_pii_count`. `/summarise` keeps `residual_risk`/`ok`, now derived from the
132
+ audit (`residual_pii` + `leaked_tokens`).
133
+
134
+ ## [1.0.1] - 2026-06-28
135
+
136
+ ### Changed
137
+
138
+ - **Default model downgraded to `gemini-2.0-flash`** β€” `NOTEGUARD_MODEL` default in
139
+ `agent/graph.py` and `eval/run_eval.py` changed from `gemini-2.5-flash` to
140
+ `gemini-2.0-flash`; `.env.example` updated to match.
141
+
142
+ ### Added
143
+
144
+ - **HF Space auto-deploy** (`.github/workflows/deploy-hf.yml`) β€” every push to `main`
145
+ mirrors the repo onto the HF Space `chaeyoona/noteguard-agent` as an orphan commit,
146
+ triggering a Docker rebuild on port 7860. Needs the `HF_TOKEN` repo secret. Orphan
147
+ strategy avoids the historical `docs/init.png` blob that HF's binary-in-history
148
+ check rejects.
149
+
150
+ ### Removed
151
+
152
+ - `docs/CHANGELOG.md` β€” duplicate of the root `CHANGELOG.md`.
153
+ - `docs/plan.md` β€” historical planning doc; no longer relevant post-1.0.
154
+ - `outputs/.gitkeep` β€” unused placeholder directory.
155
+
156
+ ### Changed (CI)
157
+
158
+ - GitHub Actions Python matrix trimmed to **3.10 + 3.12** (intermediate versions
159
+ removed).
160
+
161
+ ---
162
+
163
+ ## [1.0.0] - 2026-06-27
164
+
165
+ First post-hackathon release. The codebase is pruned to exactly the components
166
+ that ship in the deployed Hugging Face Space; sponsor-only integrations that never
167
+ ran in the deployed image are removed.
168
+
169
+ ### Removed
170
+
171
+ - **Superlinked retrieval** (`src/retrieve.py`, `[retrieval]` optional dependency,
172
+ and the `retrieve_context` graph node) β€” the in-memory vector index was excluded
173
+ from the Docker image and lazy-imported behind a graceful fallback, so it never
174
+ executed in the deployed app. Removing it deletes the `retrieved_context` state
175
+ field and the per-request demo index seeding.
176
+ - **n8n workflow** (`workflows/noteguard.n8n.json`) β€” a three-node proxy
177
+ (Webhook β†’ HTTP Request β†’ Respond) to NoteGuard's own REST API. It added no logic
178
+ and sat off the runtime path; any automation platform can still call the REST API.
179
+
180
+ ### Changed
181
+
182
+ - **Faithfulness judge now scores against the de-identified source note** (`deid_text`)
183
+ instead of Superlinked-retrieved context. The score was previously gated on retrieval,
184
+ so it never populated in the deployed app (which had no retrieval); it now produces a
185
+ live number for every request and matches the definition used by `eval/run_eval.py`.
186
+ - `agent/graph.py`: pipeline simplified to
187
+ `deidentify_in β†’ agent β†’ reidentify_out β†’ compute_trust`; `build_graph()` no longer
188
+ takes a `note_index` argument.
189
+ - `app/api.py`: `/process` reports `faithfulness` whenever a de-identified note exists;
190
+ FastAPI app version bumped to `1.0.0`.
191
+ - `Dockerfile`: dependency comment updated β€” the image no longer "omits" Superlinked,
192
+ it is simply not a dependency.
193
+ - `Makefile`: modernised to the real toolchain β€” installs via `pip install -e .`
194
+ (no `requirements.txt`), lints/formats with `ruff` (not `black`), uses the `src`
195
+ package and `src/fetch_dataset.py`.
196
+ - `pyproject.toml`: version bumped to `1.0.0`; `superlinked` removed from keywords;
197
+ `[retrieval]` optional-dependency group dropped.
198
+
199
+ ---
200
+
201
+ ## [0.2.0] - 2026-06-27
202
+
203
+ ### Added
204
+
205
+ - **Clinician web UI** (`app/static/index.html`) β€” single-file, vanilla JS, no build
206
+ step. NHS dark-blue header, segmented toggle (Clinician view / What the AI sees),
207
+ PHI highlighted in red in the clinician view, `[TYPE_N]` monospace chips in the AI
208
+ view, discharge summary pane, trust-panel metric cards.
209
+ - **`POST /process` endpoint** (`app/api.py`) β€” returns `clinician_note`, `ai_note`,
210
+ `identifiers` (original strings for highlighting), `discharge_summary`, and
211
+ `metrics` (`identifiers_removed`, `residual_risk`, `grounded_sources`,
212
+ `faithfulness`).
213
+ - **`GET /`** β€” FastAPI serves `app/static/index.html` directly; no separate static
214
+ server needed.
215
+ - **`StaticFiles` mount** at `/static` β€” allows future CSS/JS assets alongside the
216
+ single-page UI.
217
+ - **n8n workflow** (`workflows/noteguard.n8n.json`) β€” importable three-node workflow
218
+ (Webhook β†’ HTTP Request β†’ Respond to Webhook) that routes ward notes through the
219
+ NoteGuard API without the model ever seeing PHI.
220
+
221
+ - **Vercel deployment** (`api/index.py`, `api/requirements.txt`, `vercel.json`) β€”
222
+ the FastAPI app is deployable as a serverless Vercel function. Light dep set
223
+ omits superlinked/torch to stay under the 250 MB bundle limit; retrieval falls
224
+ back gracefully to Gemini-only mode.
225
+
226
+ ### Changed
227
+
228
+ - `app/api.py` now also serves the clinician web UI in addition to the REST API.
229
+ - `agent/graph.py`: `NoteIndex` import made lazy (inside try block) so the module
230
+ loads cleanly in environments where superlinked is unavailable (CI, Vercel).
231
+ - `noteguard/__init__.py`: removed `NoteIndex` re-export; only `NoteGuard`,
232
+ `DeidResult`, and `load_known_from_csv` are exported from the package.
233
+ - `Makefile` `run` target now starts uvicorn (`uvicorn app.api:app --reload --port 8000`)
234
+ instead of Streamlit.
235
+ - `pyproject.toml` version bumped to 0.2.0; `per-file-ignores` added for E402
236
+ (intentional `load_dotenv()` before API-key-consuming imports).
237
+
238
+ ### Removed
239
+
240
+ - **`app/trust_panel.py`** β€” Streamlit demo UI retired; superseded by the
241
+ single-file clinician web UI (`app/static/index.html`) served by FastAPI.
242
+ - **`streamlit`** removed from `requirements.txt`.
243
+
244
+ ---
245
+
246
+ ## [0.1.0] - 2026-06-27
247
+
248
+ ### Added
249
+
250
+ - **De-identification core** (`noteguard/deid.py`) β€” dependency-free NHS-aware
251
+ pipeline: NHS number, GMC/NMC, postcode, DOB, email and phone recognisers;
252
+ vault-from-CSV for ground-truth measurement; consistent surrogates;
253
+ `assert_clean()` hard guarantee; `reidentify()` for clinician-only restoration.
254
+ - **Superlinked retrieval node** (`noteguard/retrieve.py`) β€” in-memory vector
255
+ index (`sentence-transformers/all-MiniLM-L6-v2`) with `assert_clean()` called
256
+ on every document in and every retrieved chunk out.
257
+ - **LangGraph agent** (`agent/graph.py`) β€” full pipeline:
258
+ `deidentify_in β†’ retrieve_context β†’ agent (Gemini + Tavily) β†’ reidentify_out β†’ compute_trust`.
259
+ Trust metrics surfaced in graph state: identifiers removed, residual leakage,
260
+ faithfulness score, source URLs.
261
+ - **Streamlit trust panel** (`app/trust_panel.py`) β€” three-way toggle
262
+ (raw / de-identified / clinician answer) and live trust panel; styled to the
263
+ NHS England identity.
264
+ - **LangSmith evaluations** (`eval/run_eval.py`) β€” `zero_phi_to_model` (must
265
+ score 1.0) and `faithfulness` (LLM-as-judge over de-identified text only).
266
+ - Gold RAP packaging: `pyproject.toml`, `Makefile`, `CHANGELOG.md`,
267
+ `CONTRIBUTING.md`, `.pre-commit-config.yaml`, `.editorconfig`, CI workflow,
268
+ and `docs/` (architecture, user guide, RAP compliance).
CLAUDE.md ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # NoteGuard β€” the trust layer for clinical AI
2
+
3
+ ## What this is
4
+ NoteGuard de-identifies NHS clinical free-text so LLM agents can use it safely, and
5
+ proves the privacy with a measured number. This repo is the **agent slice**: a
6
+ LangGraph ReAct agent (Gemini + Tavily) wrapped so the model and tools only ever see
7
+ de-identified text; real identifiers are restored only in the final clinician-facing
8
+ answer.
9
+
10
+ It started at the {Tech: Europe} London AI Hackathon. The `1.0` line is pruned to the
11
+ components that actually ship in the deployed Space (see `CHANGELOG.md`).
12
+
13
+ ## Architecture
14
+ `deidentify_in β†’ agent (Gemini + Tavily) β†’ reidentify_out β†’ compute_trust`
15
+ - The guarantee (non-negotiable): nothing downstream of `deidentify_in` may
16
+ receive PHI. `assert_clean()` raises if any identifier remains. Never weaken or
17
+ bypass this β€” it is the whole point of the project.
18
+ - Tavily is public-guidance grounding only (NICE/NHS). Never send patient text to it.
19
+ - `compute_trust` audits de-identification: `NoteGuard.scan_pii(deid_text)` flags PII
20
+ the vault/NER passes missed (vault-independent, so it works on pasted notes), plus
21
+ orphaned surrogate tokens for reversibility. The trust panel reports only this β€” no
22
+ answer-quality metrics (faithfulness/sources were removed).
23
+
24
+ ## Key files
25
+ - `src/deid.py` β€” de-id core: NHS-aware rules + vault-from-CSV, consistent
26
+ surrogates, DOB date-shift, `reidentify`, `assert_clean`, and `scan_pii` (the
27
+ panel audit). Keep the rule/vault layer std-lib only. Presidio + spaCy NER sit
28
+ behind the `_Detector` interface (the `[nlp]` extra, `NOTEGUARD_SPACY_MODEL`,
29
+ default `en_core_web_md`) and now ship in the Docker image to catch free-text
30
+ names β€” but they degrade gracefully to a no-op stub when absent (CI, local).
31
+ - `agent/graph.py` β€” the graph; exposed as `noteguard` for `langgraph dev`.
32
+ - `app/api.py` β€” FastAPI backend: `GET /` (serves index.html), `GET /health`,
33
+ `POST /process` (full UI payload), `POST /summarise` (compact legacy).
34
+ - `app/static/index.html` β€” single-file clinician web UI (vanilla JS, no build step).
35
+ - `streamlit_app.py` β€” Streamlit demo (no API keys required; rule layer only).
36
+ - `Dockerfile` β€” HF Spaces Docker config; uvicorn on port 7860.
37
+ - `eval/run_eval.py` β€” LangSmith evals: `zero_phi_to_model` (must be 1.0) + `faithfulness`.
38
+ - `langgraph.json`, `.env.example`.
39
+ - `docs/tool_card.md` β€” Five Safes, bias & fairness, use cases out of scope.
40
+ - `docs/report.md` β€” ATRS Tier 1 + Tier 2 record.
41
+
42
+ ## Commands
43
+ - De-id demo (no keys): `python src/deid.py`
44
+ - Install: `pip install -e ".[dev]"`
45
+ - Clinician web UI: `uvicorn app.api:app --reload --port 8000` (or `make run`)
46
+ then open http://localhost:8000
47
+ - Serve agent (Agent Chat UI): `langgraph dev`
48
+ then open: https://smith.langchain.com/studio/?baseUrl=http://127.0.0.1:2024
49
+ - Evals: `python -m eval.run_eval`
50
+ - Deploy: push to `main` β†’ `.github/workflows/deploy-hf.yml` mirrors it onto the HF
51
+ Space `chaeyoona/noteguard-agent` (needs `HF_TOKEN` repo secret); HF rebuilds from
52
+ `Dockerfile` (Docker SDK, `app_port: 7860`).
53
+ - Env: copy `.env.example` β†’ `.env`; fill `GOOGLE_API_KEY`, `TAVILY_API_KEY`,
54
+ `LANGSMITH_API_KEY`; set `LANGSMITH_TRACING=true`.
55
+
56
+ ## Components
57
+ Gemini (reasoning) + Tavily (public-guidance grounding) are the external services.
58
+ LangGraph orchestrates the pipeline; LangSmith provides traces + evals. Superlinked
59
+ (retrieval) and n8n (a proxy workflow) were hackathon-era integrations that never ran
60
+ in the deployed Space and were removed in `1.0`.
61
+
62
+ ## Dataset
63
+ `NHSEDataScience/synthetic_clinical_notes` (Hugging Face, MIT, fully synthetic).
64
+ Build the vault from `patients.csv` via `load_known_from_csv()` so leakage is
65
+ measured against ground truth. Dataset has mojibake β€” `_fix_mojibake` handles it.
66
+
67
+ ## Conventions
68
+ - Python 3.10+. Keep `src/deid.py` std-lib only.
69
+ - Verify/round any number shown in the demo.
70
+ - Model id lives in `NOTEGUARD_MODEL` (default `google_genai:gemini-2.5-flash`).
71
+ - Versions drift: if a `langgraph`/`langsmith`/`create_react_agent` import fails,
72
+ adjust to the installed version rather than pinning blindly.
73
+ - `src/__init__.py` exports only `NoteGuard`, `DeidResult`, `load_known_from_csv`.
74
+ - `pyproject.toml` is the single dependency source; `requirements.txt` is removed.
75
+ - Linting: `ruff check` + `ruff format` (not black).
76
+
77
+ ## HF Spaces notes
78
+ - The Docker image installs only the runtime deps in `pyproject.toml`; `streamlit`
79
+ and `dev` extras are not part of the deployed image.
80
+ - Set `GOOGLE_API_KEY`, `TAVILY_API_KEY`, `LANGSMITH_API_KEY` in Space Secrets.
81
+
82
+ ## Ethics
83
+ Pseudonymised β‰  anonymous (still personal data under UK GDPR). Synthetic β‰  real β€”
84
+ frame as methodology. Clinician stays in the loop and signs off.
CODE_OF_CONDUCT.md ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Contributor Covenant Code of Conduct
2
+
3
+ ## Our Pledge
4
+
5
+ We as members, contributors, and leaders pledge to make participation in our community a
6
+ harassment-free experience for everyone, regardless of age, body size, visible or invisible
7
+ disability, ethnicity, sex characteristics, gender identity and expression, level of experience,
8
+ education, socio-economic status, nationality, personal appearance, race, religion, or sexual
9
+ identity and orientation.
10
+
11
+ We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive,
12
+ and healthy community.
13
+
14
+ ## Our Standards
15
+
16
+ Examples of behaviour that contributes to a positive environment:
17
+
18
+ - Demonstrating empathy and kindness toward other people
19
+ - Being respectful of differing opinions, viewpoints, and experiences
20
+ - Giving and gracefully accepting constructive feedback
21
+ - Accepting responsibility and apologising to those affected by our mistakes, and learning from them
22
+ - Focusing on what is best for the overall community
23
+
24
+ Examples of unacceptable behaviour:
25
+
26
+ - The use of sexualised language or imagery, and sexual attention or advances of any kind
27
+ - Trolling, insulting or derogatory comments, and personal or political attacks
28
+ - Public or private harassment
29
+ - Publishing others' private information, such as a physical or email address, without their
30
+ explicit permission
31
+ - Other conduct which could reasonably be considered inappropriate in a professional setting
32
+
33
+ ## Data-handling note
34
+
35
+ This project processes clinical-note text. Even though the bundled dataset is fully synthetic,
36
+ treat all note content as if it were real NHS PHI:
37
+
38
+ - Never paste note text into issues, pull requests, or chat β€” reference file paths instead.
39
+ - Never commit anything under `data/` (it is gitignored).
40
+ - Never send patient text or surrogate tokens to external APIs β€” the `assert_clean()` guarantee
41
+ and the Tavily usage policy both enforce this technically, but contributors must not work around them.
42
+ - If you discover a real-data leak in any test fixture, treat it as a security incident: notify the
43
+ maintainer privately rather than opening a public issue.
44
+
45
+ ## Enforcement Responsibilities
46
+
47
+ Community leaders are responsible for clarifying and enforcing our standards and will take
48
+ appropriate and fair corrective action in response to any behaviour they deem inappropriate,
49
+ threatening, offensive, or harmful.
50
+
51
+ ## Scope
52
+
53
+ This Code of Conduct applies within all community spaces, and also applies when an individual is
54
+ officially representing the community in public spaces.
55
+
56
+ ## Enforcement
57
+
58
+ Instances of abusive, harassing, or otherwise unacceptable behaviour may be reported to the project
59
+ maintainer by opening a confidential issue or by contacting the repository owner on GitHub
60
+ (**@chaeyoonyunakim**). All complaints will be reviewed and investigated promptly and fairly.
61
+
62
+ ## Attribution
63
+
64
+ This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org),
65
+ version 2.1, available at https://www.contributor-covenant.org/version/2/1/code_of_conduct.html.
CONTRIBUTING.md ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Contributing
2
+
3
+ Thank you for considering a contribution to NoteGuard. This project follows the
4
+ [NHS RAP Community of Practice](https://nhsdigital.github.io/rap-community-of-practice/)
5
+ guidance and the [Government Digital Service (GDS) coding standards](https://gds-way.digital.cabinet-office.gov.uk/standards/programming-languages.html).
6
+
7
+ ## Getting started
8
+
9
+ ```bash
10
+ git clone https://github.com/chaeyoonyunakim/noteguard-agent.git
11
+ cd noteguard-agent
12
+ python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
13
+ make install-dev # installs dev deps and pre-commit hooks
14
+ cp .env.example .env # fill in GOOGLE_API_KEY, TAVILY_API_KEY, LANGSMITH_API_KEY
15
+ ```
16
+
17
+ ## Development workflow
18
+
19
+ 1. Create a feature branch from `main`.
20
+ 2. Make your change, keeping functions small and well-documented.
21
+ 3. Run the checks locally:
22
+ ```bash
23
+ make format # auto-fix style
24
+ make lint # ruff + black
25
+ make test # pytest
26
+ ```
27
+ 4. Commit. Pre-commit hooks run ruff, black and basic hygiene checks.
28
+ 5. Open a **pull request**. CI (GitHub Actions) runs lint and tests on every PR.
29
+
30
+ ## Coding standards
31
+
32
+ - **Python**: PEP 8, 4-space indentation, type hints, formatted with **black**
33
+ and linted with **ruff** (line length 110).
34
+ - Write **tests for all new functions** (`tests/`, run with pytest).
35
+ - Keep `noteguard/deid.py` **dependency-free** (standard library only).
36
+ - Never weaken or bypass `assert_clean()` β€” it is the project's core guarantee.
37
+ - Use **British English** in comments and documentation.
38
+ - Never commit secrets, credentials or API keys β€” use environment variables
39
+ (see `.env.example`).
40
+
41
+ ## The privacy guarantee
42
+
43
+ The non-negotiable invariant: nothing downstream of `deidentify_in` may receive
44
+ PHI. `assert_clean()` must be called before any identifier-bearing text reaches
45
+ a language model or external tool. Do not relax this check under any
46
+ circumstances β€” it is the entire point of the project.
47
+
48
+ ## Review
49
+
50
+ All changes must be **reviewed by a human** before merge. The pull request
51
+ template includes a checklist to confirm standards are met.
Dockerfile ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ RUN apt-get update && apt-get install -y --no-install-recommends gcc && rm -rf /var/lib/apt/lists/*
6
+
7
+ # Runtime dep set β€” mirrors the [project] dependencies in pyproject.toml,
8
+ # plus the [nlp] extra (Presidio + spaCy) so free-text names are de-identified.
9
+ RUN pip install --no-cache-dir \
10
+ "fastapi>=0.109.0" \
11
+ "uvicorn[standard]>=0.27.0" \
12
+ "langgraph>=0.2.0" \
13
+ "langchain>=0.3.0" \
14
+ "langchain-google-genai>=2.0.0" \
15
+ "langchain-tavily>=0.1.0" \
16
+ "langsmith>=0.1.0" \
17
+ "python-dotenv>=1.0.0" \
18
+ "huggingface_hub>=0.20.0" \
19
+ "presidio-analyzer>=2.2.0" \
20
+ "spacy>=3.7.0,<4"
21
+
22
+ # spaCy model for Presidio NER. _md balances recall vs image size; override with
23
+ # NOTEGUARD_SPACY_MODEL (e.g. en_core_web_lg) if you also bake that model in.
24
+ RUN python -m spacy download en_core_web_md
25
+ ENV NOTEGUARD_SPACY_MODEL=en_core_web_md
26
+
27
+ COPY . .
28
+
29
+ # Bake the synthetic dataset into the image so /samples works without runtime downloads
30
+ RUN python src/fetch_dataset.py
31
+
32
+ ENV PYTHONUNBUFFERED=1
33
+ EXPOSE 7860
34
+
35
+ CMD ["uvicorn", "app.api:app", "--host", "0.0.0.0", "--port", "7860"]
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Chaeyoon
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
Makefile ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # NoteGuard Agent -- common developer tasks.
2
+ # Usage: `make <target>`. Run `make help` to list targets.
3
+
4
+ .DEFAULT_GOAL := help
5
+ PYTHON ?= python
6
+ SRC := src agent app eval
7
+
8
+ .PHONY: help install install-dev lint format test coverage data run eval clean
9
+
10
+ help: ## Show this help message
11
+ @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
12
+ | sort \
13
+ | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-14s\033[0m %s\n", $$1, $$2}'
14
+
15
+ install: ## Install runtime dependencies
16
+ $(PYTHON) -m pip install -e .
17
+
18
+ install-dev: ## Install development dependencies and pre-commit hooks
19
+ $(PYTHON) -m pip install -e ".[dev]"
20
+ pre-commit install
21
+
22
+ lint: ## Run ruff lint + format checks
23
+ ruff check $(SRC) tests
24
+ ruff format --check $(SRC) tests
25
+
26
+ format: ## Auto-format and fix lint with ruff
27
+ ruff check --fix $(SRC) tests
28
+ ruff format $(SRC) tests
29
+
30
+ test: ## Run the unit test suite
31
+ $(PYTHON) -m pytest
32
+
33
+ coverage: ## Run tests with a coverage report
34
+ $(PYTHON) -m pytest --cov=src --cov-report=term-missing
35
+
36
+ data: ## Download synthetic dataset CSVs into data/ (run once)
37
+ $(PYTHON) src/fetch_dataset.py
38
+
39
+ run: ## Run the clinician web UI locally (http://localhost:8000)
40
+ uvicorn app.api:app --reload --port 8000
41
+
42
+ eval: ## Run LangSmith evaluations
43
+ $(PYTHON) -m eval.run_eval
44
+
45
+ clean: ## Remove caches and build artefacts
46
+ rm -rf .pytest_cache .ruff_cache build dist *.egg-info
47
+ find . -type d -name __pycache__ -prune -exec rm -rf {} +
README.md ADDED
@@ -0,0 +1,241 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: NoteGuard
3
+ emoji: πŸ₯
4
+ colorFrom: blue
5
+ colorTo: indigo
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ ---
10
+
11
+ [![status: experimental](https://github.com/GIScience/badges/raw/master/status/experimental.svg)](https://github.com/GIScience/badges#experimental)
12
+ [![CI](https://github.com/chaeyoonyunakim/noteguard-agent/actions/workflows/ci.yml/badge.svg)](https://github.com/chaeyoonyunakim/noteguard-agent/actions/workflows/ci.yml)
13
+ [![RAP level: Gold](https://img.shields.io/badge/RAP-Gold-ffd700)](https://nhsdigital.github.io/rap-community-of-practice/introduction_to_RAP/levels_of_RAP/)
14
+ [![code style: ruff](https://img.shields.io/badge/code%20style-ruff-000000)](https://github.com/astral-sh/ruff)
15
+
16
+ # NoteGuard β€” Trust Layer for Clinical AI
17
+
18
+ > NHS clinical notes go in β†’ a safe AI-drafted summary comes out β†’ the model **provably never sees a single identifier**, with a live re-identification-risk number to prove it.
19
+
20
+ NoteGuard is a LangGraph agent (Gemini + Tavily) wrapped so that the language
21
+ model and every tool only ever receive **de-identified** text. Real identifiers
22
+ are restored only in the final, clinician-facing answer. The guarantee is
23
+ enforced by `assert_clean()`, which raises before any PHI can reach the model.
24
+
25
+ > **Project history:** NoteGuard began at the {Tech: Europe} London AI Hackathon.
26
+ > This is the post-hackathon `1.0` line β€” the codebase has been pruned to exactly
27
+ > the components that ship in the deployed app. See [`CHANGELOG.md`](CHANGELOG.md).
28
+
29
+ ---
30
+
31
+ ## What it does
32
+
33
+ ```
34
+ messy NHS note ──► NoteGuard de-id ──► de-identified text
35
+ (synthetic) (NHS-aware rules + identifiers removed count
36
+ + vault from CSV) + residual leakage %
37
+ β”‚
38
+ Tavily (NICE/NHS guidance) ──►│
39
+ β–Ό
40
+ Gemini drafts
41
+ compact eDischarge card
42
+ (sees ONLY de-identified text)
43
+ β”‚
44
+ NoteGuard re-id β—„β”€β”€β”€β”€β”€β”˜
45
+ (surrogates β†’ real names, clinician only)
46
+ β”‚
47
+ Trust panel (de-id correctness only):
48
+ de-id PASS/FAIL Β· identifiers replaced
49
+ residual PII (model input) Β· reversible
50
+ ```
51
+
52
+ The **key technical guarantee**: `assert_clean()` raises before Gemini or Tavily
53
+ receive anything β€” the model structurally cannot leak what it never saw.
54
+
55
+ ---
56
+
57
+ ## Quickstart
58
+
59
+ ```bash
60
+ # 1. Clone and create environment
61
+ git clone https://github.com/chaeyoonyunakim/noteguard-agent.git
62
+ cd noteguard-agent
63
+ python -m venv .venv
64
+ .venv\Scripts\activate # Windows
65
+ # source .venv/Scripts/activate # bash
66
+ # source .venv/bin/activate # macOS/Linux
67
+
68
+ # 2. Install
69
+ pip install -e ".[dev]"
70
+
71
+ # 3. Configure
72
+ cp .env.example .env
73
+ # fill: GOOGLE_API_KEY TAVILY_API_KEY LANGSMITH_API_KEY
74
+
75
+ # 4. Smoke test β€” no API keys needed
76
+ python src/deid.py
77
+
78
+ # 5. Interactive de-id demo β€” no API keys needed
79
+ pip install -e ".[demo]"
80
+ streamlit run streamlit_app.py
81
+
82
+ # 6. Clinician web UI (full agent)
83
+ uvicorn app.api:app --reload --port 8000
84
+ # then open: http://localhost:8000
85
+
86
+ # 7. LangGraph dev server + Agent Chat UI
87
+ langgraph dev # requires pip install -e ".[dev]"
88
+ # then open: https://smith.langchain.com/studio/?baseUrl=http://127.0.0.1:2024
89
+
90
+ # 8. LangSmith evals
91
+ python -m eval.run_eval
92
+ ```
93
+
94
+ ---
95
+
96
+ ## Key files
97
+
98
+ | File | Purpose |
99
+ |---|---|
100
+ | `src/deid.py` | Dependency-free de-id core. `python src/deid.py` runs standalone. |
101
+ | `src/fetch_dataset.py` | Downloads the synthetic dataset into `data/` (run once). |
102
+ | `agent/graph.py` | LangGraph graph exposed as `noteguard` for `langgraph dev`. |
103
+ | `app/api.py` | FastAPI backend β€” `/`, `/health`, `/process`, `/summarise`, `/samples`, `/sample/{id}`. |
104
+ | `app/static/index.html` | Single-file clinician web UI (vanilla JS, no build step). |
105
+ | `streamlit_app.py` | Interactive de-id demo β€” no API keys needed. |
106
+ | `eval/run_eval.py` | LangSmith evals: `zero_phi_to_model` (must be 1.0) + faithfulness. |
107
+ | `langgraph.json` | Graph manifest for `langgraph dev`. |
108
+ | `.env.example` | Required environment variables. |
109
+ | `docs/tool_card.md` | Five Safes, bias & fairness, use cases out of scope, DPIA prerequisites. |
110
+ | `docs/report.md` | gov.uk ATRS record (Tier 1 + Tier 2). |
111
+
112
+ ---
113
+
114
+ ## The guarantee (non-negotiable)
115
+
116
+ ```
117
+ deidentify_in β†’ assert_clean() β†’ [ONLY DE-IDENTIFIED TEXT] β†’ Gemini / Tavily
118
+ ↓
119
+ reidentify_out β†’ clinician
120
+ ```
121
+
122
+ `assert_clean()` raises `ValueError` if any known identifier or regex pattern (NHS
123
+ number, email, phone, GMC, NMC, postcode) survives de-identification. The LangSmith
124
+ `zero_phi_to_model` evaluator verifies this on every run and must score **1.0**.
125
+
126
+ ---
127
+
128
+ ## Graph pipeline
129
+
130
+ ```
131
+ deidentify_in β†’ agent (Gemini + Tavily) β†’ reidentify_out β†’ compute_trust
132
+ ```
133
+
134
+ | Node | Function |
135
+ |---|---|
136
+ | `deidentify_in` | `NoteGuard.deidentify()` + `assert_clean()` β€” strips PHI; raises if any identifier survives. |
137
+ | `agent` | `create_react_agent` (Gemini + Tavily) β€” drafts the eDischarge card; sees only de-identified text. |
138
+ | `reidentify_out` | `NoteGuard.reidentify()` β€” restores surrogates β†’ real names for the clinician only. |
139
+ | `compute_trust` | Audits de-id correctness β€” `scan_pii(deid_text)` for residual PII the model saw, plus orphaned surrogate tokens for reversibility. |
140
+
141
+ ---
142
+
143
+ ## REST API
144
+
145
+ ### POST /process
146
+
147
+ ```json
148
+ {
149
+ "note": "Pt Margaret Okafor (NHS 485 777 3456) admitted post-fall.",
150
+ "question": "Draft a discharge summary."
151
+ }
152
+ ```
153
+
154
+ Response fields:
155
+
156
+ | Field | Description |
157
+ |---|---|
158
+ | `clinician_note` | Verbatim input note |
159
+ | `ai_note` | De-identified note the model saw (surrogate tokens) |
160
+ | `identifiers` | Original identifier strings that were redacted |
161
+ | `discharge_summary` | Gemini-drafted compact eDischarge card, re-identified for the clinician |
162
+ | `metrics.deid_ok` | Overall verdict β€” `true` only when nothing leaked **and** every surrogate is reversible |
163
+ | `metrics.identifiers_removed` | Count of PII spans pseudonymised this turn |
164
+ | `metrics.residual_pii` | List of `{type, text}` β€” suspected un-redacted PII the model still saw |
165
+ | `metrics.residual_pii_count` | Number of residual-PII findings (`0` = de-identified) |
166
+ | `metrics.reversible` | `true` when every surrogate restores to a real value |
167
+ | `metrics.leaked_tokens` | Orphaned/unresolved surrogate tokens (the reversibility detail) |
168
+
169
+ A `422` response means `assert_clean()` detected surviving PHI β€” the request is
170
+ rejected before the model sees anything.
171
+
172
+ ### GET /samples
173
+
174
+ Paginated list of synthetic notes with optional search and `note_type` filter.
175
+
176
+ ```
177
+ GET /samples?q=COPD&note_type=Discharge&limit=50
178
+ ```
179
+
180
+ ### GET /sample/{clinical_note_id}
181
+
182
+ Returns the full note text for a given note ID (used by the note-picker UI).
183
+
184
+ ---
185
+
186
+ ## Components
187
+
188
+ | Stage | Service | Role |
189
+ |---|---|---|
190
+ | De-identification | `src/deid.py` | Dependency-free NHS-aware recognisers; the trust boundary. |
191
+ | Reasoning | **Google Gemini** | Drafts the discharge summary; sees only de-identified text. |
192
+ | Grounding | **Tavily** | Pulls NICE / NHS public guidance; never receives patient text. |
193
+ | Orchestration | **LangGraph** | Wires the de-id β†’ agent β†’ re-id β†’ trust pipeline. |
194
+ | Observability | **LangSmith** | Traces + privacy & faithfulness evals. |
195
+
196
+ ---
197
+
198
+ ## Hugging Face Spaces deployment
199
+
200
+ The app ships as a Docker Space β€” FastAPI + vanilla JS UI, served by uvicorn on port 7860.
201
+ The image bundles **Presidio + spaCy NER** (`en_core_web_md`, via the `[nlp]` extra) so
202
+ free-text patient/clinician names with no vault entry are de-identified; set
203
+ `NOTEGUARD_SPACY_MODEL=en_core_web_lg` for higher recall at a larger image size.
204
+
205
+ **Required secrets** (Space β†’ Settings β†’ Variables and secrets):
206
+
207
+ - `GOOGLE_API_KEY`
208
+ - `TAVILY_API_KEY`
209
+ - `LANGSMITH_API_KEY` (optional β€” enables tracing)
210
+
211
+ **Auto-deploy:** [`.github/workflows/deploy-hf.yml`](.github/workflows/deploy-hf.yml)
212
+ pushes a fresh snapshot of `main` onto the Space (`chaeyoona/noteguard-agent`) on
213
+ every push, which triggers a Docker rebuild. (A snapshot β€” a single orphan commit β€”
214
+ is used so historical binary blobs that HF's git backend rejects are never sent.)
215
+ It needs an `HF_TOKEN` repository secret β€” a write-scoped
216
+ [Hugging Face access token](https://huggingface.co/settings/tokens) added under
217
+ **Settings β†’ Secrets and variables β†’ Actions**. Trigger the first deploy manually via
218
+ the workflow's **Run workflow** button once the secret is set.
219
+
220
+ ---
221
+
222
+ ## Data
223
+
224
+ `NHSEDataScience/synthetic_clinical_notes` (Hugging Face, MIT licence, fully synthetic).
225
+
226
+ ```bash
227
+ python src/fetch_dataset.py # downloads patients.csv, admissions.csv, synthetic_clinical_notes.csv into data/
228
+ ```
229
+
230
+ `load_known_from_csv("data/patients.csv", "data/admissions.csv")` builds the
231
+ identifier vault from both structured tables β€” patient names and clinician names β€”
232
+ so residual leakage is measured against ground truth.
233
+
234
+ ---
235
+
236
+ ## Ethics
237
+
238
+ - Pseudonymised β‰  anonymous β€” still personal data under UK GDPR; don't over-claim.
239
+ - Synthetic β‰  real β€” frame as methodology, not a finished product.
240
+ - Clinician stays in the loop and signs off every summary.
241
+ - See [`docs/tool_card.md`](docs/tool_card.md) for the full Five Safes mapping and bias & fairness statement.
VERSION ADDED
@@ -0,0 +1 @@
 
 
1
+ 1.2.4
agent/__init__.py ADDED
File without changes
agent/graph.py ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LangGraph agent (Gemini + Tavily) wrapped by NoteGuard de-identification.
2
+
3
+ Guarantee enforced in-graph: the LLM and the Tavily tool only ever receive
4
+ DE-IDENTIFIED text. Real identifiers are restored only in the final,
5
+ clinician-facing answer (reidentify_out).
6
+
7
+ Run locally: langgraph dev (serves the `noteguard` graph for Agent Chat UI)
8
+ Trace: set LANGSMITH_TRACING=true + LANGSMITH_API_KEY (runs auto-trace)
9
+
10
+ Graph flow:
11
+ deidentify_in -> agent -> reidentify_out -> compute_trust
12
+
13
+ Version note: import names track LangGraph v1 / LangChain v0.3+. If your installed
14
+ versions differ, adjust the two prebuilt imports and the create_react_agent call.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+
21
+ from dotenv import load_dotenv
22
+
23
+ load_dotenv(override=True) # pick up .env before any os.getenv / API-key validation
24
+
25
+ from langchain.chat_models import init_chat_model
26
+ from langchain_core.messages import AIMessage, HumanMessage
27
+ from langgraph.graph import END, START, MessagesState, StateGraph
28
+ from langgraph.prebuilt import create_react_agent
29
+
30
+ try:
31
+ from langchain_tavily import TavilySearch
32
+ except ImportError: # older package name
33
+ from langchain_community.tools.tavily_search import TavilySearchResults as TavilySearch
34
+
35
+ from src.deid import NoteGuard
36
+
37
+ SYSTEM = """\
38
+ You are a clinical documentation assistant for NHS clinicians.
39
+ Your ONLY output is a compact discharge-summary card. Reproduce EXACTLY the format below β€” \
40
+ no headings, no bullets, no preamble, no sign-off.
41
+
42
+ ## De-identification rules β€” NEVER violate
43
+ You only ever see DE-IDENTIFIED text. Patient identifiers β€” names, NHS numbers, dates of birth,
44
+ addresses, GP names, consultant names β€” have been replaced with surrogate tokens such as
45
+ [PERSON_1], [NHS_1], [DOB_1], [ADDRESS_1], [DATE_1].
46
+ Preserve every surrogate token exactly as given. A re-identification step restores real values
47
+ for the clinician after you respond. Never invent, guess, or expand a surrogate into a real value.
48
+ Do NOT name the patient anywhere in your output β€” no title, no real name, no patient surrogate
49
+ token. Refer to the patient only as "the patient". (Surrogate tokens for OTHER people, e.g. a GP
50
+ or consultant, may appear and will be restored.)
51
+
52
+ ## Output format β€” three elements, blank line between each
53
+
54
+ Admitted <admission date> after <reason>. Background: <key conditions/meds>. <what was done>. <key finding>.
55
+
56
+ Follow-up: <GP action> Β· <action 2> Β· <action 3>
57
+
58
+ Grounded: <source name 1>, <source name 2> Β· via Tavily
59
+
60
+ ## Rules for each element
61
+
62
+ **Narrative paragraph:** plain clinical prose, max 4 sentences. Include only facts stated in \
63
+ the source note β€” never invent investigations, doses, dates, or diagnoses. \
64
+ Refer to the patient as "the patient" β€” never write the patient's name or their surrogate token. \
65
+ Surrogate tokens for other people ([PERSON_1], etc.) may appear here and will be restored. \
66
+ For the admission date, copy the admission/visit date EXACTLY as it appears in the source note \
67
+ (a date such as 13/02/26) β€” character-for-character, same format; the system restores it to the \
68
+ correct value. Do NOT use the date of birth, and do NOT write a date token like [DATE_1] or any \
69
+ placeholder. If the note states no admission/visit date, omit it and write "Admitted after <reason>". \
70
+ Drop a sentence entirely when there is nothing to say (e.g. no imaging β†’ omit that sentence).
71
+
72
+ **Follow-up line:** items separated by " Β· " (middle dot U+00B7). \
73
+ Always include the GP action as the first item.
74
+
75
+ **Grounded line:** list only the public guidance sources (short readable names, not URLs) \
76
+ actually returned by the Tavily search tool this run. \
77
+ If Tavily returned no results, omit the Grounded line entirely β€” never fabricate citations.
78
+
79
+ **Search tool:** use only for public NICE/NHS clinical guidance. \
80
+ Never send patient text or surrogate tokens to the search tool.
81
+ """
82
+
83
+
84
+ class State(MessagesState):
85
+ forward: dict
86
+ reverse: dict
87
+ clinician_answer: str
88
+ # --- trust panel fields (all about de-identification correctness) ---
89
+ deid_text: str # de-identified note text (what the AI saw)
90
+ identifiers_removed: int # identifiers replaced in this turn
91
+ residual_count: int # known identifiers that survived de-id (pre-model)
92
+ leaked_tokens: list # orphaned surrogate tokens in the output (reversibility)
93
+ residual_pii: list # suspected un-redacted PII the model saw (de-id audit)
94
+
95
+
96
+ def build_graph(known: dict | None = None):
97
+ model = init_chat_model(os.getenv("NOTEGUARD_MODEL", "google_genai:gemini-2.5-flash"))
98
+ tools = [TavilySearch(max_results=3)]
99
+ react = create_react_agent(model, tools, prompt=SYSTEM)
100
+
101
+ def deidentify_in(state: State):
102
+ prior_n = len(state.get("forward") or {})
103
+ ng = NoteGuard(known=known, forward=state.get("forward"), reverse=state.get("reverse"))
104
+ last = state["messages"][-1]
105
+ if not isinstance(last, HumanMessage):
106
+ return {"forward": ng.forward, "reverse": ng.reverse}
107
+ res = ng.deidentify(last.content)
108
+ ng.assert_clean(res.clean_text) # hard guarantee before model/tool see anything
109
+ cleaned = HumanMessage(content=res.clean_text, id=last.id)
110
+ return {
111
+ "messages": [cleaned],
112
+ "forward": ng.forward,
113
+ "reverse": ng.reverse,
114
+ "deid_text": res.clean_text,
115
+ "identifiers_removed": len(ng.forward) - prior_n,
116
+ "residual_count": len(res.residual),
117
+ }
118
+
119
+ def run_agent(state: State):
120
+ out = react.invoke({"messages": state["messages"]})
121
+ return {"messages": out["messages"][len(state["messages"]) :]}
122
+
123
+ def reidentify_out(state: State):
124
+ ng = NoteGuard(reverse=state.get("reverse"))
125
+ last = state["messages"][-1]
126
+ if not isinstance(last, AIMessage):
127
+ return {"clinician_answer": "", "leaked_tokens": []}
128
+ content = last.content
129
+ # Gemini can return content as a list of blocks [{type, text}, ...]
130
+ if isinstance(content, list):
131
+ raw_text = " ".join(
132
+ block.get("text", "") if isinstance(block, dict) else str(block) for block in content
133
+ ).strip()
134
+ else:
135
+ raw_text = content or ""
136
+
137
+ # Restore known surrogates for the clinician (the patient is never named).
138
+ restored = ng.reidentify(raw_text)
139
+
140
+ # Anything surrogate-shaped still present is either an unrestored surrogate or
141
+ # a stray template placeholder the model echoed (e.g. [DATE_X]); redact + flag
142
+ # so it never reaches the clinician verbatim.
143
+ restored, leaked_tokens = ng.redact_unresolved(restored)
144
+ leaked = [f"unresolved_token:{tok}" for tok in leaked_tokens]
145
+
146
+ return {"clinician_answer": restored, "leaked_tokens": leaked}
147
+
148
+ def compute_trust(state: State):
149
+ """Audit de-identification quality for the trust panel.
150
+
151
+ Two independent de-id failures, neither needing PHI to compute:
152
+ - residual_pii: PII the vault/NER passes missed but that still reached the
153
+ model β€” scanned out of deid_text (what the model actually saw). This is
154
+ the failure the old residual_count was blind to (vault-only).
155
+ - leaked_tokens: orphaned surrogate tokens in the output that cannot be
156
+ reversed β€” the reversibility side of the pseudonymisation guarantee.
157
+ """
158
+ ng = NoteGuard(known=known, reverse=state.get("reverse"))
159
+ deid_text = state.get("deid_text") or ""
160
+ residual_pii = ng.scan_pii(deid_text) if deid_text else []
161
+ leaked = list(state.get("leaked_tokens") or [])
162
+
163
+ return {
164
+ "residual_pii": residual_pii,
165
+ "leaked_tokens": leaked,
166
+ "residual_count": state.get("residual_count", 0) + len(leaked),
167
+ }
168
+
169
+ g = StateGraph(State)
170
+ g.add_node("deidentify_in", deidentify_in)
171
+ g.add_node("agent", run_agent)
172
+ g.add_node("reidentify_out", reidentify_out)
173
+ g.add_node("compute_trust", compute_trust)
174
+ g.add_edge(START, "deidentify_in")
175
+ g.add_edge("deidentify_in", "agent")
176
+ g.add_edge("agent", "reidentify_out")
177
+ g.add_edge("reidentify_out", "compute_trust")
178
+ g.add_edge("compute_trust", END)
179
+ return g.compile()
180
+
181
+
182
+ # Demo vault β€” seeds the known-identifier set so `langgraph dev` / the web UI
183
+ # resolve surrogates consistently on startup.
184
+ _DEMO_KNOWN = {"PERSON": ["Margaret Okafor"], "NHS": ["485 777 3456"]}
185
+ graph = build_graph(known=_DEMO_KNOWN)
app/__init__.py ADDED
File without changes
app/api.py ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """NoteGuard FastAPI backend β€” PHI-safe REST endpoint for the LangGraph agent.
2
+
3
+ Exposes:
4
+ GET / -> index.html (clinician web UI)
5
+ GET /health -> {"status": "ok"}
6
+ GET /samples -> paginated list of synthetic notes (requires data/ dir)
7
+ GET /sample/random -> one random synthetic note
8
+ GET /sample/{id} -> full note by clinical_note_id
9
+ POST /summarise -> {clinician_answer, identifiers_removed, residual_risk,
10
+ deidentified_excerpt, ok}
11
+ POST /process -> {clinician_note, ai_note, identifiers, discharge_summary, metrics}
12
+
13
+ The assert_clean() guarantee is preserved: the graph raises ValueError if any
14
+ identifier survives de-identification, which surfaces here as HTTP 422.
15
+
16
+ Run: uvicorn app.api:app --reload --port 8000
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import csv
22
+ import random
23
+ from pathlib import Path
24
+
25
+ from dotenv import load_dotenv
26
+
27
+ load_dotenv(override=True)
28
+
29
+ from fastapi import FastAPI, HTTPException, Query
30
+ from fastapi.responses import FileResponse
31
+ from fastapi.staticfiles import StaticFiles
32
+ from langchain_core.messages import HumanMessage
33
+ from pydantic import BaseModel
34
+ from src.deid import NoteGuard, load_known_from_csv
35
+
36
+ STATIC_DIR = Path(__file__).parent / "static"
37
+ _DATA_DIR = Path(__file__).parent.parent / "data"
38
+
39
+ app = FastAPI(title="NoteGuard API", version="1.2.4")
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Dataset β€” loaded once at startup; degrades gracefully when data/ is absent
43
+ # ---------------------------------------------------------------------------
44
+
45
+ _NOTES: list[dict] = []
46
+ _DEFAULT_KNOWN: dict | None = None
47
+
48
+ try:
49
+ _patients_csv = str(_DATA_DIR / "patients.csv")
50
+ _admissions_csv = str(_DATA_DIR / "admissions.csv")
51
+ _DEFAULT_KNOWN = load_known_from_csv(_patients_csv, _admissions_csv)
52
+
53
+ with open(_DATA_DIR / "synthetic_clinical_notes.csv", newline="", encoding="utf-8-sig") as _f:
54
+ for _row in csv.DictReader(_f):
55
+ _text = NoteGuard._fix_mojibake(_row["clean_note_text"])
56
+ _NOTES.append(
57
+ {
58
+ "clinical_note_id": _row["clinical_note_id"],
59
+ "person_id": _row["person_id"],
60
+ "note_type": _row.get("note_type", ""),
61
+ "note_subject": _row.get("note_subject", ""),
62
+ "excerpt": _text[:120].strip(),
63
+ "note_text": _text,
64
+ }
65
+ )
66
+ except Exception:
67
+ pass # data/ not present β€” /samples returns empty, /process still works
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # Per-vault graph cache β€” key is a hashable snapshot of the known-identifier dict.
71
+ # ---------------------------------------------------------------------------
72
+
73
+ _graph_cache: dict = {}
74
+
75
+
76
+ def _vault_key(known: dict | None) -> tuple | None:
77
+ if not known:
78
+ return None
79
+ return tuple(sorted((k, tuple(sorted(v))) for k, v in known.items()))
80
+
81
+
82
+ def _get_graph(known: dict | None):
83
+ """Return a compiled NoteGuard graph, building it once per distinct vault."""
84
+ key = _vault_key(known)
85
+ if key not in _graph_cache:
86
+ from agent.graph import build_graph
87
+
88
+ _graph_cache[key] = build_graph(known=known)
89
+ return _graph_cache[key]
90
+
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # Models
94
+ # ---------------------------------------------------------------------------
95
+
96
+
97
+ class SummariseRequest(BaseModel):
98
+ note: str
99
+ question: str = "Draft an NHS eDischarge summary."
100
+ known: dict | None = None
101
+
102
+
103
+ class SummariseResponse(BaseModel):
104
+ clinician_answer: str
105
+ identifiers_removed: int
106
+ residual_risk: float
107
+ deidentified_excerpt: str
108
+ ok: bool
109
+
110
+
111
+ class ProcessRequest(BaseModel):
112
+ note: str
113
+ question: str = "Draft an NHS eDischarge summary."
114
+ known: dict | None = None
115
+ person_id: str | None = None # accepted for UI compatibility; unused (patient is never named)
116
+
117
+
118
+ class ProcessResponse(BaseModel):
119
+ clinician_note: str
120
+ ai_note: str
121
+ identifiers: list[str]
122
+ discharge_summary: str
123
+ metrics: dict
124
+
125
+
126
+ class SampleItem(BaseModel):
127
+ clinical_note_id: str
128
+ person_id: str
129
+ note_type: str
130
+ excerpt: str
131
+
132
+
133
+ class SamplesResponse(BaseModel):
134
+ total: int
135
+ items: list[SampleItem]
136
+
137
+
138
+ class SampleDetail(BaseModel):
139
+ clinical_note_id: str
140
+ person_id: str
141
+ note_type: str
142
+ note_subject: str
143
+ note_text: str
144
+
145
+
146
+ # ---------------------------------------------------------------------------
147
+ # Endpoints
148
+ # ---------------------------------------------------------------------------
149
+
150
+
151
+ @app.get("/")
152
+ def index():
153
+ return FileResponse(STATIC_DIR / "index.html")
154
+
155
+
156
+ @app.get("/health")
157
+ def health():
158
+ """Liveness probe β€” no API keys required."""
159
+ return {"status": "ok", "notes_loaded": len(_NOTES)}
160
+
161
+
162
+ @app.get("/samples", response_model=SamplesResponse)
163
+ def samples(
164
+ limit: int = Query(50, ge=1, le=200),
165
+ offset: int = Query(0, ge=0),
166
+ q: str = Query(""),
167
+ note_type: str = Query(""),
168
+ ):
169
+ """Paginated list of synthetic notes with optional text/type filter."""
170
+ hits = _NOTES
171
+ if note_type:
172
+ hits = [n for n in hits if n["note_type"] == note_type]
173
+ if q:
174
+ ql = q.lower()
175
+ hits = [n for n in hits if ql in n["note_text"].lower() or ql in n["note_subject"].lower()]
176
+ page = hits[offset : offset + limit]
177
+ return SamplesResponse(
178
+ total=len(hits),
179
+ items=[
180
+ SampleItem(
181
+ clinical_note_id=n["clinical_note_id"],
182
+ person_id=n["person_id"],
183
+ note_type=n["note_type"],
184
+ excerpt=n["excerpt"],
185
+ )
186
+ for n in page
187
+ ],
188
+ )
189
+
190
+
191
+ @app.get("/sample/random", response_model=SampleDetail)
192
+ def sample_random():
193
+ """Return one random synthetic note."""
194
+ if not _NOTES:
195
+ raise HTTPException(status_code=404, detail="No notes loaded β€” run src/fetch_dataset.py first.")
196
+ note = random.choice(_NOTES)
197
+ return SampleDetail(**{k: note[k] for k in SampleDetail.model_fields})
198
+
199
+
200
+ @app.get("/sample/{clinical_note_id}", response_model=SampleDetail)
201
+ def sample_by_id(clinical_note_id: str):
202
+ """Return a single synthetic note by its clinical_note_id."""
203
+ for note in _NOTES:
204
+ if note["clinical_note_id"] == clinical_note_id:
205
+ return SampleDetail(**{k: note[k] for k in SampleDetail.model_fields})
206
+ raise HTTPException(status_code=404, detail=f"Note {clinical_note_id!r} not found.")
207
+
208
+
209
+ @app.post("/summarise", response_model=SummariseResponse)
210
+ def summarise(req: SummariseRequest):
211
+ """Run the NoteGuard agent and return a PHI-safe discharge summary.
212
+
213
+ Raises:
214
+ HTTPException 422: assert_clean() detected surviving PHI.
215
+ HTTPException 500: unexpected agent error.
216
+ """
217
+ known = req.known if req.known is not None else _DEFAULT_KNOWN
218
+ try:
219
+ g = _get_graph(known)
220
+ state = g.invoke({"messages": [HumanMessage(content=req.note + "\n\n" + req.question)]})
221
+ except ValueError as exc:
222
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
223
+ except Exception as exc:
224
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
225
+
226
+ # De-id is correct iff nothing leaked to the model AND every surrogate reverses.
227
+ residual_pii = state.get("residual_pii") or []
228
+ leaked = state.get("leaked_tokens") or []
229
+ ok = not residual_pii and not leaked
230
+ return SummariseResponse(
231
+ clinician_answer=state.get("clinician_answer", ""),
232
+ identifiers_removed=len(state.get("forward", {})),
233
+ residual_risk=0.0 if ok else 1.0,
234
+ deidentified_excerpt=(state.get("deid_text") or "")[:400],
235
+ ok=ok,
236
+ )
237
+
238
+
239
+ @app.post("/process", response_model=ProcessResponse)
240
+ def process(req: ProcessRequest):
241
+ """Run NoteGuard and return rich output for the clinician UI.
242
+
243
+ When req.known is omitted, uses the pre-built vault from data/patients.csv
244
+ so residual-leakage is measured against ground truth identifiers.
245
+ """
246
+ known = req.known if req.known is not None else _DEFAULT_KNOWN
247
+ try:
248
+ g = _get_graph(known)
249
+ state = g.invoke({"messages": [HumanMessage(content=req.note + "\n\n" + req.question)]})
250
+ except ValueError as exc:
251
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
252
+ except Exception as exc:
253
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
254
+
255
+ forward = state.get("forward") or {}
256
+ leaked = state.get("leaked_tokens") or []
257
+ residual_pii = state.get("residual_pii") or []
258
+ reversible = not leaked
259
+ deid_ok = not residual_pii and reversible
260
+
261
+ return ProcessResponse(
262
+ clinician_note=req.note,
263
+ ai_note=state.get("deid_text", ""),
264
+ identifiers=list(forward.keys()),
265
+ discharge_summary=state.get("clinician_answer", ""),
266
+ metrics={
267
+ # Every metric reports whether reversible pseudonymisation was done correctly.
268
+ "deid_ok": deid_ok, # overall verdict: nothing leaked AND fully reversible
269
+ "identifiers_removed": len(forward), # PII spans pseudonymised this turn
270
+ "residual_pii": residual_pii, # [{type, text}] PII the model still saw
271
+ "residual_pii_count": len(residual_pii),
272
+ "reversible": reversible, # every surrogate restores to a real value
273
+ "leaked_tokens": leaked, # orphaned/unresolved surrogate tokens
274
+ },
275
+ )
276
+
277
+
278
+ if STATIC_DIR.exists():
279
+ app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
app/static/index.html ADDED
@@ -0,0 +1,604 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>NoteGuard β€” the trust layer for clinical AI</title>
7
+ <style>
8
+ :root {
9
+ --nhs-dark-blue: #003087;
10
+ --nhs-blue: #005EB8;
11
+ --nhs-mid-blue: #0072CE;
12
+ --nhs-green: #007f3b;
13
+ --nhs-red: #d5281b;
14
+ --nhs-light-grey: #f0f4f5;
15
+ --nhs-mid-grey: #768692;
16
+ --radius: 8px;
17
+ }
18
+ * { box-sizing: border-box; margin: 0; padding: 0; }
19
+ body { font-family: Arial, sans-serif; background: var(--nhs-light-grey); color: #212b32; min-height: 100vh; }
20
+
21
+ header {
22
+ background: var(--nhs-dark-blue); color: #fff;
23
+ padding: 0 32px; height: 56px;
24
+ display: flex; align-items: center; gap: 12px;
25
+ }
26
+ header .logo { font-size: 20px; font-weight: 700; letter-spacing: -.4px; }
27
+ header .tag { font-size: 13px; opacity: .65; }
28
+
29
+ .main {
30
+ padding: 24px 32px;
31
+ display: grid; grid-template-columns: 1fr 1fr; gap: 20px;
32
+ }
33
+
34
+ .card {
35
+ background: #fff; border-radius: var(--radius);
36
+ padding: 20px; box-shadow: 0 1px 4px rgba(0,0,0,.08);
37
+ }
38
+ .card-title {
39
+ font-size: 11px; font-weight: 700; color: var(--nhs-dark-blue);
40
+ text-transform: uppercase; letter-spacing: .7px; margin-bottom: 14px;
41
+ }
42
+
43
+ /* Toggle */
44
+ .toggle-row { display: flex; gap: 6px; margin-bottom: 14px; }
45
+ .toggle-btn {
46
+ padding: 5px 16px; border-radius: 20px;
47
+ border: 2px solid var(--nhs-blue); background: #fff;
48
+ color: var(--nhs-blue); font-size: 13px; font-weight: 600; cursor: pointer;
49
+ transition: background .12s, color .12s;
50
+ }
51
+ .toggle-btn.active { background: var(--nhs-blue); color: #fff; }
52
+
53
+ /* Input */
54
+ textarea {
55
+ width: 100%; min-height: 160px; resize: vertical;
56
+ border: 2px solid #d8dde0; border-radius: var(--radius);
57
+ padding: 10px 12px; font-size: 14px; font-family: inherit;
58
+ line-height: 1.5; outline: none; transition: border-color .12s;
59
+ }
60
+ textarea:focus { border-color: var(--nhs-blue); }
61
+
62
+ .btn-row { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
63
+ .btn {
64
+ padding: 8px 20px; border-radius: 4px; border: none;
65
+ font-size: 14px; font-weight: 600; cursor: pointer; transition: background .12s;
66
+ }
67
+ .btn-primary { background: var(--nhs-blue); color: #fff; }
68
+ .btn-primary:hover:not(:disabled) { background: var(--nhs-dark-blue); }
69
+ .btn-primary:disabled { background: var(--nhs-mid-grey); cursor: not-allowed; }
70
+ .btn-ghost { background: #e8edee; color: #212b32; font-size: 13px; }
71
+ .btn-ghost:hover { background: #d3d8da; }
72
+
73
+ .dataset-hint {
74
+ display: none; font-size: 11px; color: var(--nhs-mid-grey);
75
+ margin-top: 6px;
76
+ }
77
+
78
+ /* Note display */
79
+ .note-display {
80
+ display: none; min-height: 160px;
81
+ border: 2px solid #d8dde0; border-radius: var(--radius);
82
+ padding: 10px 12px; font-size: 14px; line-height: 1.65;
83
+ background: #fafbfc; white-space: pre-wrap; word-break: break-word;
84
+ }
85
+ .note-display.visible { display: block; }
86
+ mark.phi {
87
+ background: #ffe8e6; color: #900; border-radius: 3px;
88
+ padding: 1px 3px; font-weight: 600;
89
+ }
90
+ .chip {
91
+ display: inline-block; font-family: Consolas, Menlo, monospace;
92
+ background: #e3f2fd; color: var(--nhs-mid-blue);
93
+ border: 1px solid #90caf9; border-radius: 4px;
94
+ padding: 0 5px; font-size: 12px; line-height: 1.8;
95
+ }
96
+
97
+ /* Right pane */
98
+ .placeholder {
99
+ min-height: 160px; display: flex; align-items: center; justify-content: center;
100
+ border: 2px dashed #d8dde0; border-radius: var(--radius);
101
+ color: var(--nhs-mid-grey); font-size: 14px; font-style: italic;
102
+ }
103
+ .summary-text {
104
+ display: none; font-size: 14px; line-height: 1.7;
105
+ word-break: break-word;
106
+ }
107
+ .summary-text.visible { display: block; }
108
+ .summary-text p { margin: 0 0 10px; }
109
+ .summary-text p:last-child { margin-bottom: 0; }
110
+ .card-title {
111
+ font-weight: 700; font-size: 15px; color: var(--nhs-dark-blue);
112
+ }
113
+ .card-followup .fu-label {
114
+ font-weight: 700; color: var(--nhs-dark-blue);
115
+ }
116
+ .card-grounded {
117
+ font-size: 12px; color: var(--nhs-mid-grey);
118
+ padding-top: 8px; margin-top: 4px !important;
119
+ border-top: 1px solid #e5e9ec; display: flex; align-items: flex-start; gap: 5px;
120
+ }
121
+ .card-grounded svg { flex-shrink: 0; margin-top: 2px; }
122
+ .powered-by {
123
+ display: none; margin-top: 8px;
124
+ font-size: 11px; color: var(--nhs-mid-grey); text-align: right;
125
+ }
126
+ .powered-by.visible { display: block; }
127
+
128
+ /* Trust panel */
129
+ .trust-row {
130
+ display: grid; grid-template-columns: repeat(4, 1fr);
131
+ gap: 12px; padding: 0 32px 32px;
132
+ }
133
+ .metric {
134
+ background: #fff; border-radius: var(--radius); padding: 14px 16px;
135
+ box-shadow: 0 1px 4px rgba(0,0,0,.08); text-align: center;
136
+ }
137
+ .metric[hidden] { display: none; }
138
+ .metric .mlabel {
139
+ font-size: 11px; color: var(--nhs-mid-grey);
140
+ font-weight: 700; text-transform: uppercase; letter-spacing: .5px;
141
+ }
142
+ .metric .mvalue {
143
+ font-size: 26px; font-weight: 700; color: var(--nhs-dark-blue); margin-top: 4px;
144
+ }
145
+ .mvalue.ok { color: var(--nhs-green); }
146
+ .mvalue.risk { color: var(--nhs-red); }
147
+ .leak-detail {
148
+ font-size: 10px; color: var(--nhs-red); margin-top: 4px;
149
+ word-break: break-all; line-height: 1.4;
150
+ }
151
+
152
+ /* Spinner */
153
+ .spinner {
154
+ display: none; width: 14px; height: 14px;
155
+ border: 2px solid rgba(255,255,255,.4); border-top-color: #fff;
156
+ border-radius: 50%; animation: spin .6s linear infinite;
157
+ vertical-align: middle; margin-right: 5px;
158
+ }
159
+ .spinner.on { display: inline-block; }
160
+ @keyframes spin { to { transform: rotate(360deg); } }
161
+
162
+ .edit-link {
163
+ font-size: 12px; color: var(--nhs-mid-grey); cursor: pointer;
164
+ margin-top: 8px; display: none; text-decoration: underline; text-underline-offset: 2px;
165
+ }
166
+ .edit-link.visible { display: block; }
167
+
168
+ /* ── Note picker modal ── */
169
+ .picker-overlay {
170
+ position: fixed; inset: 0; background: rgba(0,0,0,.5);
171
+ display: none; align-items: center; justify-content: center; z-index: 100;
172
+ }
173
+ .picker-overlay.open { display: flex; }
174
+ .picker-panel {
175
+ background: #fff; border-radius: var(--radius);
176
+ width: min(620px, 95vw); max-height: 82vh;
177
+ display: flex; flex-direction: column; overflow: hidden;
178
+ box-shadow: 0 8px 32px rgba(0,0,0,.22);
179
+ }
180
+ .picker-header {
181
+ display: flex; justify-content: space-between; align-items: center;
182
+ padding: 15px 20px; border-bottom: 1px solid #e5e9ec;
183
+ font-weight: 700; font-size: 14px; color: var(--nhs-dark-blue);
184
+ flex-shrink: 0;
185
+ }
186
+ .picker-close {
187
+ background: none; border: none; font-size: 22px; line-height: 1;
188
+ cursor: pointer; color: var(--nhs-mid-grey); padding: 0 2px;
189
+ }
190
+ .picker-controls {
191
+ padding: 10px 14px; display: flex; gap: 8px;
192
+ border-bottom: 1px solid #e5e9ec; flex-shrink: 0;
193
+ }
194
+ .picker-input {
195
+ flex: 1; padding: 7px 10px; border: 2px solid #d8dde0; border-radius: 4px;
196
+ font-size: 13px; font-family: inherit; outline: none; min-width: 0;
197
+ }
198
+ .picker-input:focus { border-color: var(--nhs-blue); }
199
+ .picker-select {
200
+ padding: 7px 8px; border: 2px solid #d8dde0; border-radius: 4px;
201
+ font-size: 12px; font-family: inherit; background: #fff;
202
+ outline: none; cursor: pointer; max-width: 160px;
203
+ }
204
+ .picker-list { overflow-y: auto; flex: 1; }
205
+ .picker-row {
206
+ padding: 10px 16px; cursor: pointer;
207
+ border-bottom: 1px solid #f0f4f5; transition: background .1s;
208
+ }
209
+ .picker-row:hover { background: var(--nhs-light-grey); }
210
+ .picker-row .ptype {
211
+ font-size: 11px; font-weight: 700; color: var(--nhs-mid-blue);
212
+ text-transform: uppercase; letter-spacing: .5px;
213
+ }
214
+ .picker-row .pexcerpt { font-size: 13px; color: #212b32; margin-top: 2px; line-height: 1.45; }
215
+ .picker-empty {
216
+ padding: 36px 16px; text-align: center;
217
+ font-size: 13px; color: var(--nhs-mid-grey); font-style: italic;
218
+ }
219
+
220
+ @media (max-width: 860px) {
221
+ .main { grid-template-columns: 1fr; }
222
+ .trust-row { grid-template-columns: repeat(2, 1fr); }
223
+ }
224
+ </style>
225
+ </head>
226
+ <body>
227
+
228
+ <header>
229
+ <div class="logo">NoteGuard</div>
230
+ <div class="tag">β€” the trust layer for clinical AI</div>
231
+ </header>
232
+
233
+ <div class="main">
234
+ <!-- LEFT: input / rendered note -->
235
+ <div class="card">
236
+ <div class="card-title">Clinical Note</div>
237
+
238
+ <div class="toggle-row">
239
+ <button class="toggle-btn active" id="btnClinician" onclick="setView('clinician')">Clinician view</button>
240
+ <button class="toggle-btn" id="btnAI" onclick="setView('ai')">What the AI sees</button>
241
+ </div>
242
+
243
+ <div id="inputArea">
244
+ <textarea id="noteInput" placeholder="Paste a ward note, discharge summary or referral letter…"></textarea>
245
+ <div class="btn-row">
246
+ <button class="btn btn-ghost" id="browseBtn" onclick="openPicker()" style="display:none">Browse notes</button>
247
+ <button class="btn btn-ghost" id="sampleBtn" onclick="loadSample()">Load sample note</button>
248
+ <button class="btn btn-primary" id="genBtn" onclick="generate()">
249
+ <span class="spinner" id="spinner"></span>Generate
250
+ </button>
251
+ </div>
252
+ <div class="dataset-hint" id="datasetHint">
253
+ Dataset not loaded β€” run <code>python scripts/fetch_dataset.py</code> to enable note browsing.
254
+ </div>
255
+ </div>
256
+
257
+ <div id="noteDisplay" class="note-display"></div>
258
+ <span class="edit-link" id="editLink" onclick="resetUI()">← Edit note</span>
259
+ </div>
260
+
261
+ <!-- RIGHT: discharge summary -->
262
+ <div class="card">
263
+ <div class="card-title">Discharge Summary</div>
264
+ <div class="placeholder" id="placeholder">Output will appear here after generation</div>
265
+ <div class="summary-text" id="summaryText"></div>
266
+ <div class="powered-by" id="poweredBy">powered by Gemini</div>
267
+ </div>
268
+ </div>
269
+
270
+ <!-- TRUST PANEL β€” every metric reports whether de-identification was done correctly -->
271
+ <div class="trust-row">
272
+ <div class="metric" id="mDeid">
273
+ <div class="mlabel">De-identification</div>
274
+ <div class="mvalue" id="mDeidVal">β€”</div>
275
+ </div>
276
+ <div class="metric" id="mIds">
277
+ <div class="mlabel">Identifiers replaced</div>
278
+ <div class="mvalue" id="mIdsVal">β€”</div>
279
+ </div>
280
+ <div class="metric" id="mResidual">
281
+ <div class="mlabel">Residual PII Β· model input</div>
282
+ <div class="mvalue" id="mResidualVal">β€”</div>
283
+ <div id="residualDetail" class="leak-detail" style="display:none"></div>
284
+ </div>
285
+ <div class="metric" id="mRev">
286
+ <div class="mlabel">Reversible</div>
287
+ <div class="mvalue" id="mRevVal">β€”</div>
288
+ <div id="revDetail" class="leak-detail" style="display:none"></div>
289
+ </div>
290
+ </div>
291
+
292
+ <!-- NOTE PICKER MODAL -->
293
+ <div id="pickerOverlay" class="picker-overlay" onclick="if(event.target===this)closePicker()">
294
+ <div class="picker-panel">
295
+ <div class="picker-header">
296
+ <span id="pickerTitle">Synthetic Note Library</span>
297
+ <button class="picker-close" onclick="closePicker()">&#x2715;</button>
298
+ </div>
299
+ <div class="picker-controls">
300
+ <input id="pickerSearch" class="picker-input" type="text"
301
+ placeholder="Search notes…" oninput="debouncedSearch()">
302
+ <select id="pickerType" class="picker-select" onchange="fetchSamples()">
303
+ <option value="">All types</option>
304
+ </select>
305
+ <button class="btn btn-ghost" onclick="pickerShuffle()" style="white-space:nowrap;font-size:13px">&#x1F500; Shuffle</button>
306
+ </div>
307
+ <div id="pickerList" class="picker-list"></div>
308
+ </div>
309
+ </div>
310
+
311
+ <script>
312
+ const SAMPLE = `Ward 4B. Pt Margaret Okafor (NHS 485 777 3456, DOB 22/09/1958, F, 45 Elm Road SW1A 1AA). GP: Dr James Obi, Riverside Surgery, Lambeth SE1 7PB.
313
+
314
+ Admitted 12 Jan 2025 via ED with acute exacerbation of COPD. PMH: COPD (GOLD stage III), T2DM on metformin, hypertension on amlodipine. NKDA. O2 sats 88% on air on admission.
315
+
316
+ Managed with nebulised salbutamol 2.5 mg QDS and ipratropium 500 mcg QDS, IV hydrocortisone 100 mg TDS (switched to prednisolone 30 mg OD after 48 h), doxycycline 200 mg stat then 100 mg OD (sputum purulent). O2 titrated to 88-92%.
317
+
318
+ CXR: bilateral hyperinflation, no consolidation. Bloods: WBC 11.2, CRP 78. HbA1c 58 mmol/mol. Sputum C&S pending at discharge.
319
+
320
+ Discharged home 14 Jan 2025. TTO: carbocisteine 375 mg TDS, prednisolone 30 mg OD for 5 days, doxycycline 100 mg OD for 4 more days. Metformin and amlodipine continued unchanged.
321
+
322
+ Responsible consultant: Dr Sarah Chen, Respiratory Medicine.`;
323
+
324
+ let view = 'clinician';
325
+ let result = null;
326
+ let pickerTimer = null;
327
+ let currentPersonId = null;
328
+ let noteTypesPopulated = false;
329
+
330
+ // ── Startup: check if dataset is present ──────────────────────────────────
331
+ window.addEventListener('DOMContentLoaded', async () => {
332
+ try {
333
+ const h = await fetch('/health').then(r => r.json());
334
+ if (h.notes_loaded > 0) {
335
+ const browseBtn = document.getElementById('browseBtn');
336
+ browseBtn.textContent = `Browse notes (${h.notes_loaded.toLocaleString()})`;
337
+ browseBtn.style.display = '';
338
+ document.getElementById('sampleBtn').style.display = 'none';
339
+ } else {
340
+ document.getElementById('datasetHint').style.display = 'block';
341
+ }
342
+ } catch (_) {
343
+ // server not running yet β€” keep fallback button
344
+ }
345
+ });
346
+
347
+ // ── Picker logic ──────────────────────────────────────────────────────────
348
+ function openPicker() {
349
+ document.getElementById('pickerOverlay').classList.add('open');
350
+ document.getElementById('pickerSearch').value = '';
351
+ document.getElementById('pickerType').value = '';
352
+ document.getElementById('pickerSearch').focus();
353
+ fetchSamples();
354
+ }
355
+
356
+ function closePicker() {
357
+ document.getElementById('pickerOverlay').classList.remove('open');
358
+ }
359
+
360
+ function debouncedSearch() {
361
+ clearTimeout(pickerTimer);
362
+ pickerTimer = setTimeout(fetchSamples, 300);
363
+ }
364
+
365
+ async function fetchSamples() {
366
+ const q = document.getElementById('pickerSearch').value.trim();
367
+ const nt = document.getElementById('pickerType').value;
368
+ const params = new URLSearchParams({ limit: 50 });
369
+ if (q) params.set('q', q);
370
+ if (nt) params.set('note_type', nt);
371
+
372
+ const list = document.getElementById('pickerList');
373
+ list.innerHTML = '<div class="picker-empty">Loading…</div>';
374
+
375
+ try {
376
+ const data = await fetch('/samples?' + params).then(r => r.json());
377
+
378
+ // Update header count
379
+ const filterLabel = (q || nt) ? ' β€” filtered' : '';
380
+ document.getElementById('pickerTitle').textContent =
381
+ data.total.toLocaleString() + ' synthetic notes' + filterLabel;
382
+
383
+ // Populate type dropdown once
384
+ if (!noteTypesPopulated && data.items.length > 0) {
385
+ populateTypes(data.items);
386
+ }
387
+
388
+ if (data.items.length === 0) {
389
+ list.innerHTML = '<div class="picker-empty">No notes match your search.</div>';
390
+ return;
391
+ }
392
+
393
+ list.innerHTML = data.items.map(n =>
394
+ `<div class="picker-row" onclick="selectNote('${n.clinical_note_id}')">` +
395
+ `<div class="ptype">${esc(n.note_type || 'Note')}</div>` +
396
+ `<div class="pexcerpt">${esc(n.excerpt)}</div>` +
397
+ `</div>`
398
+ ).join('');
399
+ } catch (_) {
400
+ list.innerHTML = '<div class="picker-empty">Failed to load notes.</div>';
401
+ }
402
+ }
403
+
404
+ async function populateTypes(items) {
405
+ // Seed from first page; also fetch a wider set to catch rarer types
406
+ try {
407
+ const all = await fetch('/samples?limit=200').then(r => r.json());
408
+ const types = [...new Set(all.items.map(n => n.note_type).filter(Boolean))].sort();
409
+ const sel = document.getElementById('pickerType');
410
+ types.forEach(t => {
411
+ const o = document.createElement('option');
412
+ o.value = t; o.textContent = t;
413
+ sel.appendChild(o);
414
+ });
415
+ noteTypesPopulated = true;
416
+ } catch (_) {}
417
+ }
418
+
419
+ async function pickerShuffle() {
420
+ try {
421
+ const note = await fetch('/sample/random').then(r => r.json());
422
+ applyPickedNote(note);
423
+ } catch (_) {}
424
+ }
425
+
426
+ async function selectNote(id) {
427
+ try {
428
+ const note = await fetch('/sample/' + encodeURIComponent(id)).then(r => r.json());
429
+ applyPickedNote(note);
430
+ } catch (_) {}
431
+ }
432
+
433
+ function applyPickedNote(note) {
434
+ closePicker();
435
+ resetUI();
436
+ currentPersonId = note.person_id || null;
437
+ document.getElementById('noteInput').value = note.note_text;
438
+ }
439
+
440
+ // ── Existing UI logic ─────────────────────────────────────────────────────
441
+ function setView(v) {
442
+ view = v;
443
+ document.getElementById('btnClinician').classList.toggle('active', v === 'clinician');
444
+ document.getElementById('btnAI').classList.toggle('active', v === 'ai');
445
+ if (result) renderNote();
446
+ }
447
+
448
+ function loadSample() {
449
+ resetUI();
450
+ document.getElementById('noteInput').value = SAMPLE;
451
+ }
452
+
453
+ function resetUI() {
454
+ result = null;
455
+ currentPersonId = null;
456
+ document.getElementById('inputArea').style.display = '';
457
+ document.getElementById('noteDisplay').classList.remove('visible');
458
+ document.getElementById('editLink').classList.remove('visible');
459
+ document.getElementById('placeholder').style.display = '';
460
+ document.getElementById('summaryText').classList.remove('visible');
461
+ document.getElementById('poweredBy').classList.remove('visible');
462
+ for (const id of ['mDeidVal', 'mIdsVal', 'mResidualVal', 'mRevVal']) {
463
+ const el = document.getElementById(id);
464
+ el.textContent = 'β€”';
465
+ el.className = 'mvalue';
466
+ }
467
+ for (const id of ['residualDetail', 'revDetail']) {
468
+ const el = document.getElementById(id);
469
+ if (el) { el.textContent = ''; el.style.display = 'none'; }
470
+ }
471
+ }
472
+
473
+ function esc(s) {
474
+ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
475
+ }
476
+
477
+ const _LINK_ICON = `<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>`;
478
+
479
+ function renderSummary(text) {
480
+ const lines = (text || '').trim().split('\n').map(l => l.trim()).filter(l => l);
481
+ return lines.map(line => {
482
+ if (line.startsWith('Follow-up:')) {
483
+ const rest = esc(line.slice('Follow-up:'.length));
484
+ return `<p class="card-followup"><span class="fu-label">Follow-up:</span>${rest}</p>`;
485
+ }
486
+ if (line.startsWith('Grounded:')) {
487
+ const rest = esc(line.slice('Grounded:'.length));
488
+ return `<p class="card-grounded">${_LINK_ICON}<span>${rest}</span></p>`;
489
+ }
490
+ // Defensive: should the model ever emit a "<name> β€” discharge summary" title
491
+ // line, drop it β€” the patient is never named and the card already has a header.
492
+ if (line.includes('β€” discharge summary') || line.includes('- discharge summary')) {
493
+ return '';
494
+ }
495
+ return `<p>${esc(line)}</p>`;
496
+ }).join('');
497
+ }
498
+
499
+ function highlightPHI(text, ids) {
500
+ if (!ids || !ids.length) return esc(text);
501
+ const sorted = [...ids].sort((a, b) => b.length - a.length);
502
+ let out = '', i = 0;
503
+ while (i < text.length) {
504
+ let hit = false;
505
+ for (const id of sorted) {
506
+ if (text.startsWith(id, i)) {
507
+ out += `<mark class="phi">${esc(id)}</mark>`;
508
+ i += id.length;
509
+ hit = true;
510
+ break;
511
+ }
512
+ }
513
+ if (!hit) { out += esc(text[i]); i++; }
514
+ }
515
+ return out;
516
+ }
517
+
518
+ function renderAI(text) {
519
+ return esc(text).replace(/\[([A-Z]+)_(\d+)\]/g, m => `<span class="chip">${m}</span>`);
520
+ }
521
+
522
+ function renderNote() {
523
+ const el = document.getElementById('noteDisplay');
524
+ el.innerHTML = view === 'clinician'
525
+ ? highlightPHI(result.clinician_note, result.identifiers)
526
+ : renderAI(result.ai_note);
527
+ }
528
+
529
+ async function generate() {
530
+ const note = document.getElementById('noteInput').value.trim();
531
+ if (!note) return;
532
+
533
+ const btn = document.getElementById('genBtn');
534
+ const sp = document.getElementById('spinner');
535
+ btn.disabled = true;
536
+ sp.classList.add('on');
537
+
538
+ try {
539
+ const resp = await fetch('/process', {
540
+ method: 'POST',
541
+ headers: { 'Content-Type': 'application/json' },
542
+ body: JSON.stringify({ note, question: 'Draft an NHS eDischarge summary.', person_id: currentPersonId || undefined })
543
+ });
544
+ if (!resp.ok) {
545
+ const err = await resp.json().catch(() => ({}));
546
+ alert('Error ' + resp.status + ': ' + (err.detail || resp.statusText));
547
+ return;
548
+ }
549
+ result = await resp.json();
550
+
551
+ // Switch left pane to rendered note
552
+ document.getElementById('inputArea').style.display = 'none';
553
+ document.getElementById('noteDisplay').classList.add('visible');
554
+ document.getElementById('editLink').classList.add('visible');
555
+ renderNote();
556
+
557
+ // Right pane
558
+ document.getElementById('placeholder').style.display = 'none';
559
+ const sumEl = document.getElementById('summaryText');
560
+ sumEl.innerHTML = renderSummary(result.discharge_summary);
561
+ sumEl.classList.add('visible');
562
+ document.getElementById('poweredBy').classList.add('visible');
563
+
564
+ // Metrics β€” all four report whether de-identification succeeded
565
+ const m = result.metrics;
566
+
567
+ // 1. Overall verdict
568
+ const dv = document.getElementById('mDeidVal');
569
+ dv.textContent = m.deid_ok ? 'PASS' : 'FAIL';
570
+ dv.className = 'mvalue ' + (m.deid_ok ? 'ok' : 'risk');
571
+
572
+ // 2. Identifiers replaced (pseudonymised this turn)
573
+ document.getElementById('mIdsVal').textContent = m.identifiers_removed;
574
+
575
+ // 3. Residual PII the model still saw β€” the headline failure signal
576
+ const rv = document.getElementById('mResidualVal');
577
+ rv.textContent = m.residual_pii_count;
578
+ rv.className = 'mvalue ' + (m.residual_pii_count === 0 ? 'ok' : 'risk');
579
+ const rd = document.getElementById('residualDetail');
580
+ if (m.residual_pii_count > 0) {
581
+ rd.textContent = m.residual_pii.slice(0, 4)
582
+ .map(p => p.type + ': ' + p.text).join(' Β· ');
583
+ rd.style.display = 'block';
584
+ }
585
+
586
+ // 4. Reversibility β€” every surrogate restores to a real value
587
+ const ev = document.getElementById('mRevVal');
588
+ ev.textContent = m.reversible ? 'βœ“' : 'βœ—';
589
+ ev.className = 'mvalue ' + (m.reversible ? 'ok' : 'risk');
590
+ const ed = document.getElementById('revDetail');
591
+ if (!m.reversible && m.leaked_tokens && m.leaked_tokens.length) {
592
+ ed.textContent = 'Unresolved: ' + m.leaked_tokens.slice(0, 3).join(', ');
593
+ ed.style.display = 'block';
594
+ }
595
+ } catch (e) {
596
+ alert('Request failed: ' + e.message);
597
+ } finally {
598
+ btn.disabled = false;
599
+ sp.classList.remove('on');
600
+ }
601
+ }
602
+ </script>
603
+ </body>
604
+ </html>
docs/architecture.md ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Architecture
2
+
3
+ ## Overview
4
+
5
+ NoteGuard is the trust layer for clinical AI β€” a LangGraph ReAct agent (Gemini +
6
+ Tavily) where the language model structurally **cannot** see patient identifiers
7
+ because `assert_clean()` raises before any PHI reaches it.
8
+
9
+ The system has four layers:
10
+
11
+ 1. **De-identification core** (`src/deid.py`) β€” dependency-free, runnable
12
+ standalone. NHS-aware recognisers, vault-from-CSV, consistent surrogates,
13
+ DOB date-shift, `assert_clean()` hard guarantee.
14
+ 2. **Agent** (`agent/graph.py`) β€” LangGraph `StateGraph`. Gemini drafts the
15
+ answer; Tavily grounds it in NICE/NHS public guidance. Neither sees PHI.
16
+ 3. **REST API** (`app/api.py`) β€” FastAPI backend exposing `GET /` (web UI),
17
+ `GET /health`, `POST /process`, `POST /summarise`, `GET /samples`, and
18
+ `GET /sample/{id}`. Also serves the static clinician UI.
19
+ 4. **UI** β€” `app/static/index.html` (clinician web UI, vanilla JS, no build step, served by the FastAPI `GET /` handler).
20
+
21
+ ## Package layout
22
+
23
+ ```
24
+ src/
25
+ β”œβ”€β”€ __init__.py # exports NoteGuard, DeidResult, load_known_from_csv
26
+ β”œβ”€β”€ deid.py # de-id core (standard library only)
27
+ └── fetch_dataset.py # downloads synthetic_clinical_notes to data/
28
+
29
+ agent/
30
+ └── graph.py # LangGraph StateGraph exposed as `noteguard` for langgraph dev
31
+
32
+ app/
33
+ β”œβ”€β”€ api.py # FastAPI β€” GET /, GET /health, GET /samples, GET /sample/{id},
34
+ β”‚ # POST /process, POST /summarise
35
+ └── static/
36
+ └── index.html # Clinician web UI (single-file, vanilla JS, no build step)
37
+
38
+ streamlit_app.py # Interactive de-id demo β€” no API keys needed
39
+
40
+ eval/
41
+ └── run_eval.py # LangSmith evals: zero_phi_to_model + faithfulness
42
+
43
+ tests/ # pytest suite (24 tests, no external deps)
44
+ data/ # synthetic CSV files (git-ignored; produced by src/fetch_dataset.py)
45
+ docs/ # architecture, user guide, RAP compliance, tool card, ATRS report
46
+ ```
47
+
48
+ ## Graph pipeline
49
+
50
+ For every query the graph runs:
51
+
52
+ ```
53
+ deidentify_in β†’ agent β†’ reidentify_out β†’ compute_trust
54
+ ```
55
+
56
+ | Node | Function | Description |
57
+ |---|---|---|
58
+ | `deidentify_in` | `NoteGuard.deidentify()` + `assert_clean()` | Strips PHI; raises if any identifier survives. |
59
+ | `agent` | `create_react_agent` (Gemini + Tavily) | Drafts answer; sees only de-identified text. |
60
+ | `reidentify_out` | `NoteGuard.reidentify()` | Restores surrogates β†’ real names for the clinician only. |
61
+ | `compute_trust` | `NoteGuard.scan_pii()` | De-id audit β€” residual PII the model saw + orphaned surrogate tokens (reversibility) for the trust panel. |
62
+
63
+ ## State fields
64
+
65
+ In addition to `messages`, the graph state carries:
66
+
67
+ | Field | Type | Description |
68
+ |---|---|---|
69
+ | `deid_text` | `str` | De-identified version of the input note. |
70
+ | `forward` | `dict` | Original-identifier β†’ surrogate mapping. |
71
+ | `identifiers_removed` | `int` | Count of identifiers replaced in this turn. |
72
+ | `residual_count` | `int` | Known identifiers that survived (target: 0). |
73
+ | `leaked_tokens` | `list[str]` | Orphaned/unresolved surrogate tokens in the output (reversibility). |
74
+ | `clinician_answer` | `str` | Re-identified, clinician-facing answer. |
75
+ | `residual_pii` | `list[dict]` | `{type, text}` findings of suspected un-redacted PII in `deid_text`. |
76
+
77
+ ## REST API
78
+
79
+ `app/api.py` exposes six endpoints:
80
+
81
+ | Endpoint | Method | Description |
82
+ |---|---|---|
83
+ | `/` | GET | Serves `app/static/index.html` β€” the clinician web UI. |
84
+ | `/health` | GET | Liveness probe; no API keys required. Returns `notes_loaded` count. |
85
+ | `/samples` | GET | Paginated list of synthetic notes; supports `q`, `note_type`, `limit`, `offset`. |
86
+ | `/sample/random` | GET | One random synthetic note. |
87
+ | `/sample/{clinical_note_id}` | GET | Full note by ID (used by the note-picker modal). |
88
+ | `/process` | POST | Full UI payload: `clinician_note`, `ai_note`, `identifiers`, `discharge_summary`, `metrics`. |
89
+ | `/summarise` | POST | Compact payload: `clinician_answer`, `identifiers_removed`, `residual_risk`, `deidentified_excerpt`, `ok`. |
90
+
91
+ `POST /process` request shape:
92
+
93
+ ```json
94
+ {
95
+ "note": "Pt Margaret Okafor (NHS 485 777 3456) admitted post-fall.",
96
+ "question": "Draft a discharge summary.",
97
+ "person_id": "pt-001"
98
+ }
99
+ ```
100
+
101
+ `POST /process` response shape:
102
+
103
+ ```json
104
+ {
105
+ "clinician_note": "verbatim input",
106
+ "ai_note": "de-identified text the model saw ([PERSON_1], [NHS_1], …)",
107
+ "identifiers": ["Margaret Okafor", "485 777 3456", "…"],
108
+ "discharge_summary": "re-identified Gemini compact eDischarge card for the clinician",
109
+ "metrics": {
110
+ "deid_ok": true,
111
+ "identifiers_removed": 5,
112
+ "residual_pii": [],
113
+ "residual_pii_count": 0,
114
+ "reversible": true,
115
+ "leaked_tokens": []
116
+ }
117
+ }
118
+ ```
119
+
120
+ `422` is returned when `assert_clean()` detects surviving PHI β€” the model sees nothing.
121
+
122
+ Every metric reports whether reversible pseudonymisation was done correctly.
123
+ `deid_ok` is `true` only when `residual_pii` is empty (nothing un-redacted reached the
124
+ model) **and** `reversible` is `true` (every surrogate restores to a real value). The
125
+ `residual_pii` audit (`NoteGuard.scan_pii`) is vault-independent β€” it catches free-text
126
+ names the vault/NER passes missed, so a pasted note with no ground truth is still graded.
127
+
128
+ ## Clinician web UI
129
+
130
+ `app/static/index.html` is a self-contained single-page application (vanilla JS, no build step):
131
+
132
+ - **Note picker modal** β€” browse and filter synthetic notes by keyword and note type;
133
+ clicking a row loads the note into the textarea (the patient is never named in the output).
134
+ - **Segmented toggle** β€” switches between two views without re-calling the API:
135
+ - *Clinician view*: original note with each redacted identifier wrapped in a red `<mark>`.
136
+ - *What the AI sees*: de-identified note with `[TYPE_N]` surrogate tokens displayed as blue monospace chips.
137
+ - **Generate** β€” POSTs to `/process`, populates the discharge summary pane and trust panel.
138
+ - **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 βœ—).
139
+
140
+ ## External services
141
+
142
+ | Concern | Service | Notes |
143
+ |---|---|---|
144
+ | Reasoning | Google Gemini | `google_genai:gemini-2.5-flash` (configurable via `NOTEGUARD_MODEL`). |
145
+ | Grounding | Tavily | Public NICE/NHS guidance only β€” patient text never sent. |
146
+ | Observability | LangSmith | Auto-traces when `LANGSMITH_TRACING=true`. |
147
+
148
+ All credentials are read from environment variables; nothing is hard-coded.
149
+
150
+ ## Deployment
151
+
152
+ ### Local (development)
153
+
154
+ ```bash
155
+ pip install -e ".[dev]"
156
+ uvicorn app.api:app --reload --port 8000
157
+ ```
158
+
159
+ ### Hugging Face Spaces (production)
160
+
161
+ `Dockerfile` builds a lean Docker image installing only the runtime dependencies
162
+ declared in `pyproject.toml`, served by uvicorn on port 7860.
163
+
164
+ Required secrets: `GOOGLE_API_KEY`, `TAVILY_API_KEY`, `LANGSMITH_API_KEY`.
docs/rap_compliance.md ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # RAP compliance
2
+
3
+ This repository is organised to meet **Gold RAP** under the
4
+ [NHS RAP Community of Practice maturity framework](https://nhsdigital.github.io/rap-community-of-practice/introduction_to_RAP/levels_of_RAP/).
5
+ The levels are cumulative β€” Gold includes everything in Baseline and Silver.
6
+ The structure also draws on the
7
+ [NHS England repository template](https://github.com/nhs-england-tools/repository-template)
8
+ and the [NHS England "package your code" workshop](https://github.com/nhsengland/package-your-code-workshop).
9
+
10
+ ## Baseline RAP
11
+
12
+ | Criterion | Status | Evidence |
13
+ |---|---|---|
14
+ | Data produced by code in an open-source language | βœ… | Python pipeline (`src/`, `agent/`, `eval/`). |
15
+ | Code is version controlled | βœ… | Git, hosted on GitHub. |
16
+ | README details steps to reproduce | βœ… | [`README.md`](../README.md), [`docs/user_guide.md`](user_guide.md). |
17
+ | Code has been peer reviewed | βœ… | Pull request workflow with template + required human review. |
18
+ | Code is published in the open | βœ… | Public GitHub repository, MIT licensed. |
19
+
20
+ ## Silver RAP
21
+
22
+ | Criterion | Status | Evidence |
23
+ |---|---|---|
24
+ | Outputs produced with minimal manual intervention | βœ… | `uvicorn app.api:app` / `python -m eval.run_eval`; one-command startup. |
25
+ | Code is well-documented (guidance, structure, docstrings) | βœ… | Module + function docstrings throughout; `docs/` directory. |
26
+ | Well-organised, standard directory format | βœ… | `src/` core, `agent/`, `app/`, `eval/`, `tests/`, `docs/`, `data/`. |
27
+ | Reusable functions and/or classes | βœ… | `NoteGuard`, `load_known_from_csv()`, `build_graph()` β€” composable and parameterisable. |
28
+ | Adheres to agreed coding standards | βœ… | PEP 8, type hints, **ruff** lint + format (see `pyproject.toml`). |
29
+ | Pipeline includes a testing framework | βœ… | `pytest` suite in `tests/` (24 tests; de-id core covered; no external deps needed). |
30
+ | Dependency information included | βœ… | `pyproject.toml` β€” single source of truth; optional extras for the demo and dev tooling. |
31
+ | Logs automatically recorded by the pipeline | βœ… | LangSmith auto-traces every graph run (`LANGSMITH_TRACING=true`). |
32
+ | Configuration aids reusability | βœ… | All settings from environment variables; `.env.example` provided. |
33
+
34
+ ## Gold RAP
35
+
36
+ | Criterion | Status | Evidence |
37
+ |---|---|---|
38
+ | Code is fully packaged | βœ… | `pyproject.toml` (setuptools, `pip install -e ".[dev]"`, optional extras). |
39
+ | Tests run automatically via CI/CD | βœ… | [`.github/workflows/ci.yml`](../.github/workflows/ci.yml) β€” ruff + pytest on 3.10–3.12. |
40
+ | Process runs on event-based triggers or a schedule | βœ… | CI on push / pull request; agent runs on live query events. |
41
+ | Changes clearly signposted (changelog, releases) | βœ… | [`CHANGELOG.md`](../CHANGELOG.md), `VERSION`, semantic versioning. |
42
+
43
+ ## Additional good practice
44
+
45
+ - **Pre-commit hooks** (`.pre-commit-config.yaml`) for local quality gates.
46
+ - **Editor configuration** (`.editorconfig`) for consistent formatting.
47
+ - **Pull request template** and `CONTRIBUTING.md` documenting the review process.
48
+ - **Secret hygiene**: no credentials in code; `.env` git-ignored; a
49
+ `detect-private-key` pre-commit hook.
50
+ - **Hard privacy guarantee**: `assert_clean()` enforced at every PHI boundary β€”
51
+ the de-id node before the model, and the `zero_phi_to_model` LangSmith eval.
52
+ - **Reproducible outputs via the web UI**: `GET /` + `POST /process` provide a
53
+ documented, versioned HTTP interface that produces the same clinician output for
54
+ the same input β€” satisfying the reproducibility intent of RAP Gold.
55
+ - **Documented API contract**: `docs/architecture.md` specifies every endpoint,
56
+ request/response shape, and the `assert_clean()` guarantee; consumers can
57
+ reproduce the analysis without reading source code.
58
+ - **Governance documentation**: `docs/tool_card.md` (Five Safes mapping, bias &
59
+ fairness statement, DPIA prerequisites) and `docs/report.md` (gov.uk ATRS record,
60
+ Tier 1 + Tier 2).
docs/report.md ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Algorithmic Transparency Record β€” NoteGuard
2
+
3
+ > **Illustrative** record following the UK government
4
+ > [Algorithmic Transparency Recording Standard (ATRS)](https://www.gov.uk/government/collections/algorithmic-transparency-recording-standard-hub),
5
+ > modelled on the [NHS.UK Reviews Automoderation Tool record](https://www.gov.uk/algorithmic-transparency-records/nhs-england-nhs-dot-uk-reviews-automoderation-tool).
6
+ > NoteGuard is a hackathon **prototype** evaluated on synthetic data β€” this is not an official
7
+ > published ATRS entry, but is structured so it could become one.
8
+
9
+ ---
10
+
11
+ ## Tier 1 β€” Summary
12
+
13
+ - **Name:** NoteGuard β€” NHS clinical-note de-identification and discharge-summary agent
14
+ - **Description:** Detects and removes patient/clinician PII from free-text NHS clinical notes *inside* a Trust before any LLM sees them. Passes de-identified text to a LangGraph ReAct agent (Gemini 2.5 Flash) that drafts a compact eDischarge summary grounded in public NICE/NHS guidance (Tavily). Combines pure-Python rule recognisers with a Presidio/spaCy NER layer (`en_core_web_md`, shipped in the deployed image; graceful no-op fallback). No model is trained.
15
+ - **Website / repository:** https://github.com/chaeyoonyunakim/noteguard-agent
16
+ - **Contact:** via GitHub issues (maintainer **@chaeyoonyunakim**)
17
+
18
+ ---
19
+
20
+ ## Tier 2
21
+
22
+ ### 1. Owner and responsibility
23
+
24
+ - **1.1 Organisation:** {Tech: Europe} London AI Hackathon β€” individual project.
25
+ - **1.2 Team:** Chaeyoon Kim (sole contributor for this prototype).
26
+ - **1.3 Senior responsible owner:** None β€” prototype, not in service. An SRO would be required before deployment.
27
+ - **1.4 External supplier involvement:** No commercial supplier. Built on open-source components (LangGraph, Google Gemini API, Tavily Search API, Presidio + spaCy `en_core_web_md`).
28
+
29
+ ### 2. Description and rationale
30
+
31
+ - **2.1 Detailed description:** A clinical note is passed through `deidentify_in` (rule recognisers + vault match + Presidio/spaCy NER), which raises if any known identifier survives (`assert_clean()`). The clean text reaches the LangGraph ReAct agent; Tavily retrieves public NICE/NHS guidance. The agent drafts a three-element compact discharge card (narrative, follow-up, grounded) and never names the patient β€” there is no title line and the patient is referred to only as "the patient". `reidentify_out` restores other surrogates (e.g. clinician names) for the clinician; the model never sees the real name. `compute_trust` audits the de-identified text for residual PII (`scan_pii`, vault-independent) and checks surrogate reversibility.
32
+ - **2.2 Scope:** Free-text English NHS clinical notes. Evaluated on the `NHSEDataScience/synthetic_clinical_notes` dataset only. Not evaluated on real Trust data, other languages, or scanned documents.
33
+ - **2.3 Benefit:** Enables clinicians to draft eDischarge summaries from de-identified notes with a *measured* re-identification risk rather than an unverified assurance; grounds the summary in public clinical guidance.
34
+ - **2.4 Previous process:** Manual de-identification by an analyst, or free-text notes not shared at all because re-identification risk could not be quantified.
35
+ - **2.5 Alternatives considered:** Presidio alone (misses NHS-specific formats); a clinical transformer (`obi/deid_roberta_i2b2` β€” US-trained, weaker on UK names); sending raw notes to the LLM (unacceptable PHI risk). Rejected in favour of the rules-first + LLM-second pipeline.
36
+
37
+ ### 3. Decision-making process
38
+
39
+ - **3.1 Process integration:** Sits at the Trust egress boundary and inside the clinician workflow. The clinician **reviews and signs off** the draft before it becomes a medical record.
40
+ - **3.2 Information provided to reviewers:** The clinician sees the re-identified summary, the trust panel (de-identification PASS/FAIL, identifiers replaced, residual PII the model saw, reversibility), and the de-identified excerpt showing what the AI saw.
41
+ - **3.3 Frequency and scale:** Prototype, per-note on demand. No batch deployment.
42
+ - **3.4 Human decisions and review:** The clinician makes the **final** call on every summary. The system explicitly labels the output as AI-drafted.
43
+ - **3.5 Required training:** Clinicians need training on the tool's limitations (esp. name-recall bias for non-English names, synthetic-only evaluation), the residual-risk metric, and the escalation route for missed identifiers.
44
+ - **3.6 Appeals / redress:** Not a citizen-facing decision system. Missed identifiers found post-review are corrected and fed back into the recogniser rules/tests.
45
+
46
+ ### 4. Tool specification
47
+
48
+ - **4.1.1 System architecture:** Python package (`src/`) deployed as a FastAPI service and Streamlit demo. The LangGraph agent runs server-side; the clinician UI is a single-page web app. Raw notes and the re-identification vault stay Trust-local and are gitignored.
49
+ - **4.1.2 Phase:** Prototype (hackathon) β€” not deployed to production.
50
+ - **4.1.3 Maintenance:** CI (`ruff` + `pytest`) on every change; residual leakage acts as a regression gate; recognisers re-evaluated when data or rules change.
51
+ - **4.1.4 Components:** (a) pure-Python rule recognisers (`src/deid.py`); (b) Presidio + spaCy `en_core_web_md` NER (deployed; graceful no-op fallback); (c) `scan_pii` residual-PII audit; (d) LangGraph ReAct agent with Gemini 2.5 Flash; (e) Tavily public-guidance search; (f) FastAPI clinician UI.
52
+
53
+ **4.2 Component specifications**
54
+
55
+ | Component | Task | Method | Notes |
56
+ |---|---|---|---|
57
+ | Rule recognisers | NHS number, DOB, postcode, GMC/NMC, email, phone | Regex + context anchors (name-agnostic) | `src/deid.py`; std-lib only |
58
+ | Vault match | Patient/clinician names | Exact-match from patients.csv + admissions.csv | Deterministic; demographic-agnostic for structured entities |
59
+ | Presidio NER | `PERSON`, `LOCATION` | spaCy `en_core_web_md` (`NOTEGUARD_SPACY_MODEL`), score-thresholded | Shipped in the deployed image; no-op fallback when absent |
60
+ | Gemini 2.5 Flash | Discharge summary drafting | LangGraph ReAct; SYSTEM prompt enforces no-PHI rules and never names the patient | `NOTEGUARD_MODEL` env var |
61
+ | Tavily | Public-guidance grounding | Public web search for NICE/NHS sources only | Patient text never sent |
62
+ | assert_clean() | Hard de-id guarantee | Raises `ValueError` if any identifier survives | Cannot be weakened or bypassed |
63
+ | compute_trust | De-id audit | `scan_pii` residual-PII scan (vault-independent) + orphaned-token / reversibility check | Surfaced in clinician UI |
64
+
65
+ **4.3 Data specification**
66
+
67
+ - **4.3.1 Source:** `NHSEDataScience/synthetic_clinical_notes` (Hugging Face, MIT licence).
68
+ - **4.3.2 Modality:** Text (3 linked CSVs: patients, admissions, notes).
69
+ - **4.3.3 Description:** Synthetic clinical notes joined to synthetic patient/admission records on `person_id`/`admission_id` β€” the join provides free ground truth for the leakage metric.
70
+ - **4.3.4 Quantities:** ~70 patients, ~1,602 notes.
71
+ - **4.3.5 Sensitive attributes:** Synthetic names, NHS numbers, DOBs, sites β€” treated as if real PHI throughout.
72
+ - **4.3.6 Representativeness:** Fully synthetic; not representative of real Trust notes. Real validation required before deployment.
73
+ - **4.3.7 Source URL:** https://huggingface.co/datasets/NHSEDataScience/synthetic_clinical_notes
74
+ - **4.3.8 Cleaning:** Mojibake repair (`_fix_mojibake`); consistent date-shift for DOBs.
75
+ - **4.3.9 Sharing:** Only de-identified text + content-free trust metrics are shareable. Raw data and the vault are gitignored and never committed.
76
+
77
+ ### 5. Risks, mitigations and impact assessments
78
+
79
+ - **5.1 Impact assessment:** A **DPIA is required before any real deployment** and has **not** been done (prototype on synthetic data). IG/Caldicott sign-off and DARS approval also required.
80
+ - **5.2 Risks and mitigations:**
81
+
82
+ | Risk | Impact | Mitigation |
83
+ |---|---|---|
84
+ | False negative (missed PII) | Re-identification of a patient | Name-agnostic rule recognisers; vault from structured tables; assert_clean() hard gate; vault-independent residual-PII audit (`scan_pii`) surfaced to clinician |
85
+ | **Name-recall bias** (non-English names) | Unequal re-identification risk across demographics | Rule recognisers are demographic-agnostic; vault provides deterministic coverage; **stratified recall evaluation required** before deployment |
86
+ | LLM hallucination / fabrication | Clinically incorrect summary | Grounded-only SYSTEM prompt; clinician reviews and signs off every summary |
87
+ | Tavily leaking patient text | PHI to public internet | SYSTEM prompt prohibition; assert_clean() runs before Tavily is ever called; trust metric monitors for policy violations |
88
+ | Orphaned surrogate tokens | Model-invented `[LABEL_n]` not re-identifiable | `reidentify_out` detects and flags unmapped tokens; replaced with `[redacted]`; surfaced in leaked_tokens |
89
+ | Vault compromise | Re-identification via the linkage key | Vault stays Trust-local; gitignored; treated as the re-identification key |
90
+ | Pseudonymised β‰  anonymised (UK GDPR) | Mistaken belief data is non-personal | Stated honestly throughout; DPIA + IG sign-off required |
91
+
92
+ ---
93
+
94
+ *NoteGuard Β· {Tech: Europe} London AI Hackathon Β· prototype Β· v0.2.0*
docs/tool_card.md ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # NoteGuard β€” Tool Card
2
+
3
+ **Version:** 0.2.0
4
+ **Track:** Public Sector & Citizen Services β€” NHS Secure Data Environment on-ramp
5
+ **Status:** Hackathon prototype; not validated for production use without further evaluation.
6
+
7
+ ---
8
+
9
+ ## Specification
10
+
11
+ | Field | Value |
12
+ |---|---|
13
+ | Description | De-identification gate + LangGraph agent that detects/removes PII from NHS clinical notes before any LLM sees them, then grounds a discharge summary in public NICE/NHS guidance |
14
+ | Type | Hybrid pipeline β€” pure-Python rule recognisers + Microsoft Presidio (spaCy NER, `en_core_web_md`, shipped in the deployed image); Gemini 2.5 Flash as the agent model; Tavily for public-guidance retrieval |
15
+ | Developer | Chaeyoon Kim β€” {Tech: Europe} London AI Hackathon |
16
+ | Status / version | Prototype Β· v1.0.0 |
17
+ | Repository | github.com/chaeyoonyunakim/noteguard-agent |
18
+
19
+ > Documented as a **tool card**, not a model card. NoteGuard does not train a model.
20
+ > A gov.uk Algorithmic Transparency Recording Standard (ATRS) record is in [`report.md`](report.md).
21
+
22
+ ---
23
+
24
+ ## What it does
25
+
26
+ NoteGuard is a **trust layer** for clinical AI. It:
27
+
28
+ 1. **De-identifies** free-text NHS clinical notes inside the Trust using rule-based recognisers (NHS number, GMC/NMC, DOB, postcode, email, phone) and a Presidio + spaCy NER detector (`en_core_web_md`, shipped in the deployed image; degrades to a no-op stub if absent) that catches free-text patient/clinician names with no vault entry.
29
+ 2. **Asserts clean** β€” raises `ValueError` if any known identifier survives, hard-blocking the model boundary.
30
+ 3. Passes de-identified text to a **LangGraph ReAct agent** (Gemini 2.5 Flash) that drafts a compact eDischarge summary grounded in NICE/NHS guidance retrieved via Tavily.
31
+ 4. **Re-identifies** the summary for the clinician β€” the model never sees or writes a real name.
32
+ 5. **Audits the de-identification** β€” a vault-independent `scan_pii` pass flags any residual PII the model still saw (incl. free-text names the vault missed), plus orphaned surrogate tokens for reversibility. The trust panel reports only this; it carries no answer-quality metrics.
33
+
34
+ > "De-identify in β†’ agent (Gemini + Tavily) β†’ re-identify out β†’ compute trust."
35
+
36
+ ---
37
+
38
+ ## Who uses it
39
+
40
+ | Role | When | Why |
41
+ |---|---|---|
42
+ | NHS Clinician | At discharge | Needs a draft summary without manually de-identifying notes |
43
+ | Data Wrangler / IG Analyst | Before releasing notes to AI teams | Cannot share raw free-text; needs measured leakage |
44
+ | SDE Operator | At Trust egress boundary | Gate between raw Trust data and a Secure Data Environment |
45
+
46
+ ---
47
+
48
+ ## Use cases out of scope
49
+
50
+ - **Not** a substitute for Information Governance sign-off, a DPIA, or DARS approval β€” it is a technical control, not a legal basis for processing.
51
+ - **Not** validated on real Trust data, non-English notes, or scanned/handwritten documents.
52
+ - **Not** a guarantee of zero re-identification: pseudonymised output is still personal data under UK GDPR, and residual leakage is *measured*, not assumed zero on unseen data.
53
+ - **Not** for clinical decision-making, autonomous prescribing, or any use of note content beyond de-identification and grounded summarisation.
54
+ - **Not** a replacement for a clinician review β€” the output is a draft that the clinician signs off.
55
+
56
+ ---
57
+
58
+ ## Detection coverage
59
+
60
+ | Entity type | Method | Notes |
61
+ |---|---|---|
62
+ | Patient / clinician name (`PERSON`) | Vault match (patients.csv + admissions.csv) + Presidio spaCy NER (`en_core_web_md`) | Vault is ground-truth; Presidio catches unlisted free-text names; `scan_pii` audits whatever still slips through |
63
+ | NHS number (`NHS`) | Regex + 9-digit context anchor | Catches standard and synthetic-dataset forms |
64
+ | Date of birth (`DOB`) | DOB regex + consistent date-shift | Shift preserves clinical plausibility |
65
+ | UK postcode (`POSTCODE`) | Regex | Redacted outward-code only |
66
+ | GMC number (`GMC`) | Context-anchored regex with connector words | "GMC No. 7654321", "GMC number 7654321" |
67
+ | NMC / PIN (`NMC`) | Context-anchored regex with connector words | "NMC number: 18D6896L", "PIN 18D6896L" |
68
+ | Email / phone | Regex | Standard patterns |
69
+
70
+ ---
71
+
72
+ ## Anonymisation policy
73
+
74
+ | Mode | Behaviour |
75
+ |---|---|
76
+ | **Pseudonymise** (default) | Consistent surrogate tokens `[LABEL_n]`; DOB date-shifted; reidentify() restores originals for the clinician |
77
+ | Patient naming | The discharge summary never names the patient β€” no title line, no real name, no patient surrogate; the model refers to "the patient" throughout |
78
+
79
+ ---
80
+
81
+ ## Bias and fairness
82
+
83
+ The Presidio NER (spaCy `en_core_web_md`) is trained largely on Western/English text, so **name recall can be lower for names of non-English origin** β€” and a smaller model (`_md` vs `_lg`) trades some recall for image size. This is an equity risk: under-detection means those patients carry a *higher residual re-identification risk*. The `scan_pii` audit surfaces residual names to the clinician rather than hiding the gap. Honest position and mitigations:
84
+
85
+ - The rule-based recognisers (NHS number, DOB, postcode, GMC/NMC) are **name-agnostic** and detect structured identifiers uniformly regardless of patient demographics.
86
+ - The **vault** (from `patients.csv` + `admissions.csv`) provides deterministic, demographic-agnostic coverage for all names in the structured dataset.
87
+ - **Required before deployment:** evaluate name recall *stratified by name origin* on representative Trust data and report the disparity. Not yet done β€” evaluation is on the `NHSEDataScience/synthetic_clinical_notes` synthetic dataset only.
88
+
89
+ ---
90
+
91
+ ## NHS Five Safes mapping
92
+
93
+ | Safe | Status | How |
94
+ |---|---|---|
95
+ | **Safe data** | βœ… | De-identified before model; assert_clean() hard gate; leakage measured |
96
+ | **Safe settings** | βœ… | Processing inside Trust; vault gitignored; Tavily receives only public queries |
97
+ | **Safe outputs** | βœ… | Only de-identified text + content-free trust metrics leave model boundary |
98
+ | **Safe people** | ⚠️ | Clinician reviews and signs off the draft; vault stays Trust-local |
99
+ | **Safe projects** | ⚠️ | Technical layer only; DPIA + project approval (DARS) remain Trust processes |
100
+
101
+ ---
102
+
103
+ ## Limitations and caveats
104
+
105
+ - **Pseudonymised data is still personal data** under UK GDPR β€” the surrogate vault is the re-identification key and must stay Trust-local.
106
+ - **Not clinically validated:** evaluated on the `NHSEDataScience/synthetic_clinical_notes` dataset. Real deployment requires validation on representative Trust data.
107
+ - **Tavily search** is for public NICE/NHS guidance only β€” patient text or surrogate tokens are never sent to it (enforced in the SYSTEM prompt and monitored via the trust metric).
108
+ - **Governance prerequisites for deployment:** a Data Protection Impact Assessment (DPIA), IG/Caldicott sign-off, and DARS project approval are required before any real use. NoteGuard is the technical control, not the approval.
109
+
110
+ ---
111
+
112
+ ## Components
113
+
114
+ | Component | Role in NoteGuard |
115
+ |---|---|
116
+ | **Google Gemini 2.5 Flash** | Agent model β€” drafts the eDischarge summary from de-identified text |
117
+ | **Tavily** | Public-guidance search β€” NICE/NHS sources only; never receives patient text |
118
+ | LangGraph | Agent orchestration |
119
+ | LangSmith | Tracing and evaluation |
120
+
121
+ ---
122
+
123
+ *NoteGuard Β· {Tech: Europe} London AI Hackathon Β· prototype Β· not for clinical use without further validation*
docs/user_guide.md ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # User guide
2
+
3
+ ## Prerequisites
4
+
5
+ - Python 3.10 or later
6
+ - A free [Google AI Studio API key](https://aistudio.google.com/apikey)
7
+ - A [Tavily API key](https://tavily.com) (free tier available)
8
+ - A [LangSmith API key](https://smith.langchain.com) (free tier available)
9
+
10
+ ## Installation
11
+
12
+ ```bash
13
+ git clone https://github.com/chaeyoonyunakim/noteguard-agent.git
14
+ cd noteguard-agent
15
+
16
+ python -m venv .venv
17
+ .venv\Scripts\activate # Windows
18
+ # source .venv/bin/activate # macOS / Linux
19
+
20
+ pip install -e ".[dev]"
21
+ ```
22
+
23
+ To also install the Streamlit de-id demo:
24
+
25
+ ```bash
26
+ pip install -e ".[demo]" # Streamlit interactive demo
27
+ ```
28
+
29
+ ## Configuration
30
+
31
+ ```bash
32
+ cp .env.example .env
33
+ ```
34
+
35
+ Open `.env` and fill in your credentials:
36
+
37
+ ```env
38
+ GOOGLE_API_KEY=AIza...
39
+ TAVILY_API_KEY=tvly-...
40
+ LANGSMITH_API_KEY=ls__...
41
+ LANGSMITH_TRACING=true
42
+ # NOTEGUARD_MODEL=google_genai:gemini-2.5-flash # optional override
43
+ ```
44
+
45
+ ## Running the de-identification demo (no API keys needed)
46
+
47
+ ```bash
48
+ python src/deid.py
49
+ ```
50
+
51
+ Demonstrates the de-id core on a synthetic note β€” no network calls, no keys.
52
+
53
+ ## Running the interactive Streamlit demo (no API keys needed)
54
+
55
+ ```bash
56
+ streamlit run streamlit_app.py
57
+ ```
58
+
59
+ Lets you paste any text, click **De-identify**, and see the surrogate-token
60
+ output alongside the vault contents and `assert_clean` result.
61
+
62
+ ## Downloading the dataset
63
+
64
+ ```bash
65
+ python src/fetch_dataset.py
66
+ ```
67
+
68
+ Downloads `patients.csv`, `admissions.csv`, and `synthetic_clinical_notes.csv`
69
+ from the `NHSEDataScience/synthetic_clinical_notes` Hugging Face dataset into `data/`.
70
+ Run once before starting the server to enable the note-picker and vault-based leakage metrics.
71
+
72
+ ## Running the clinician web UI (recommended for demos)
73
+
74
+ ```bash
75
+ uvicorn app.api:app --reload --port 8000
76
+ ```
77
+
78
+ Open [http://localhost:8000](http://localhost:8000).
79
+
80
+ 1. Click **Load note** (top-right) to open the note picker, or paste your own note.
81
+ 2. Click **Generate** (~20–30 s on first run; the model loads and de-identifies).
82
+ 3. Use the segmented toggle to switch views without re-calling the API:
83
+ - **Clinician view** β€” the original note with every redacted identifier highlighted in red.
84
+ - **What the AI sees** β€” the de-identified note; real identifiers are replaced by
85
+ `[TYPE_N]` surrogate chips (e.g. `[PERSON_1]`, `[NHS_1]`).
86
+ 4. The compact eDischarge card appears on the right, re-identified for the clinician.
87
+ 5. The trust panel below shows whether de-identification was done correctly β€” and
88
+ nothing about answer quality:
89
+ - **De-identification** β€” `PASS` only when nothing un-redacted reached the model
90
+ *and* every surrogate is reversible; `FAIL` otherwise.
91
+ - **Identifiers replaced** β€” count of PII spans pseudonymised in this call.
92
+ - **Residual PII Β· model input** β€” suspected un-redacted identifiers the model still
93
+ saw (`0` = clean). When > 0, the offending snippets are listed (e.g.
94
+ `name: Ethel Joanne Duffy`). This catches free-text names the vault/NER passes
95
+ missed β€” the case the old re-id-risk number was blind to.
96
+ - **Reversible** β€” `βœ“` when every surrogate restores to a real value; `βœ—` lists the
97
+ orphaned/unresolved tokens.
98
+ 6. Click **← Edit note** to reset and process a different note.
99
+
100
+ ## Running the LangGraph dev server
101
+
102
+ ```bash
103
+ langgraph dev
104
+ ```
105
+
106
+ Connect the [Agent Chat UI](https://smith.langchain.com/studio/?baseUrl=http://127.0.0.1:2024)
107
+ and interact with the `noteguard` graph directly.
108
+
109
+ ## Running the LangSmith evaluations
110
+
111
+ ```bash
112
+ python -m eval.run_eval
113
+ ```
114
+
115
+ Needs `LANGSMITH_API_KEY` and `LANGSMITH_TRACING=true`. Runs two evaluators:
116
+
117
+ | Evaluator | Target | What it measures |
118
+ |---|---|---|
119
+ | `zero_phi_to_model` | 1.0 | No known identifier appeared in any message seen by the model. |
120
+ | `faithfulness` | 0.8+ | Every clinical claim in the answer is supported by the source note. |
121
+
122
+ ## Development
123
+
124
+ ```bash
125
+ ruff check src agent app eval tests # lint
126
+ ruff format src agent app eval tests # format
127
+ pytest # run the test suite
128
+ pytest --cov=src --cov-report=term # with coverage
129
+ ```
130
+
131
+ ## Loading a real patient vault
132
+
133
+ The de-id core can be seeded from the
134
+ [`NHSEDataScience/synthetic_clinical_notes`](https://huggingface.co/datasets/NHSEDataScience/synthetic_clinical_notes)
135
+ dataset (MIT licence, fully synthetic):
136
+
137
+ ```python
138
+ from src.deid import NoteGuard, load_known_from_csv
139
+
140
+ known = load_known_from_csv("data/patients.csv", "data/admissions.csv")
141
+ ng = NoteGuard(known=known)
142
+ result = ng.deidentify(raw_note)
143
+ ng.assert_clean(result.clean_text)
144
+ ```
145
+
146
+ This builds the identifier vault from both structured tables β€” patient names and
147
+ clinician names β€” so residual leakage is measured against ground truth.
eval/__init__.py ADDED
File without changes
eval/run_eval.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LangSmith evaluation for the NoteGuard agent slice.
2
+
3
+ Two evaluators that map straight onto the judging story:
4
+ 1. zero_phi_to_model - the hard privacy guarantee (must be 1.0)
5
+ 2. faithfulness - LLM-as-judge: is every claim supported by the note?
6
+
7
+ Run: python -m eval.run_eval
8
+ Needs: LANGSMITH_API_KEY, GOOGLE_API_KEY, TAVILY_API_KEY (+ LANGSMITH_TRACING=true)
9
+
10
+ API note: the LangSmith evaluate surface has shifted across versions. This targets
11
+ langsmith>=0.1 with dict-style evaluators (inputs/outputs). Adjust signatures if
12
+ your installed version differs.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dotenv import load_dotenv
18
+
19
+ load_dotenv()
20
+
21
+ from langchain.chat_models import init_chat_model
22
+ from langchain_core.messages import HumanMessage
23
+ from langsmith import Client
24
+
25
+ from agent.graph import build_graph
26
+ from src.deid import NoteGuard
27
+
28
+ KNOWN = {"PERSON": ["Margaret Okafor"], "NHS": ["485 777 3456"]}
29
+ EXAMPLES = [
30
+ {
31
+ "note": (
32
+ "Ward 4B. Pt Margaret Okafor (NHS 485 777 3456, DOB 22/09/1958, F, 45 Elm Road SW1A 1AA). "
33
+ "GP: Dr James Obi, Riverside Surgery, Lambeth SE1 7PB. "
34
+ "Admitted 12 Jan 2025 via ED with acute exacerbation of COPD. "
35
+ "PMH: COPD (GOLD III), T2DM on metformin, hypertension on amlodipine. NKDA. "
36
+ "O2 sats 88% on air. Managed with nebulised salbutamol, ipratropium, IV hydrocortisone, "
37
+ "doxycycline. CXR: bilateral hyperinflation, no consolidation. WBC 11.2, CRP 78. "
38
+ "Discharged 14 Jan 2025. TTO: carbocisteine 375 mg TDS, prednisolone 30 mg OD 5/7, "
39
+ "doxycycline 100 mg OD 4/7. Metformin and amlodipine continued. "
40
+ "Consultant: Dr Sarah Chen, Respiratory Medicine."
41
+ ),
42
+ "question": "Draft an NHS eDischarge summary.",
43
+ },
44
+ ]
45
+
46
+ client = Client()
47
+ _judge = None
48
+
49
+
50
+ def _content_str(content) -> str:
51
+ """Flatten AIMessage content β€” Gemini returns a list of blocks, not a plain string."""
52
+ if isinstance(content, list):
53
+ return " ".join(b.get("text", "") if isinstance(b, dict) else str(b) for b in content)
54
+ return content or ""
55
+
56
+
57
+ def target(inputs: dict) -> dict:
58
+ graph = build_graph(known=KNOWN)
59
+ state = graph.invoke(
60
+ {"messages": [HumanMessage(content=inputs["note"] + "\n\n" + inputs["question"])]},
61
+ )
62
+ model_facing = " ".join(_content_str(getattr(m, "content", "")) for m in state["messages"])
63
+ return {"clinician_answer": state.get("clinician_answer", ""), "model_facing": model_facing}
64
+
65
+
66
+ def zero_phi_to_model(inputs: dict, outputs: dict) -> dict:
67
+ hits = NoteGuard(known=KNOWN).residual_identifiers(outputs["model_facing"])
68
+ return {"key": "zero_phi_to_model", "score": 1.0 if not hits else 0.0}
69
+
70
+
71
+ def faithfulness(inputs: dict, outputs: dict) -> dict:
72
+ global _judge
73
+ _judge = _judge or init_chat_model("google_genai:gemini-2.5-flash")
74
+ prompt = (
75
+ f"NOTE:\n{inputs['note']}\n\nSUMMARY:\n{outputs['clinician_answer']}\n\n"
76
+ "Is every clinical claim in SUMMARY supported by NOTE? "
77
+ "Reply with a single number between 0 and 1."
78
+ )
79
+ raw = _content_str(_judge.invoke(prompt).content)
80
+ try:
81
+ score = max(0.0, min(1.0, float(raw.strip().split()[0])))
82
+ except (ValueError, IndexError):
83
+ score = 0.0
84
+ return {"key": "faithfulness", "score": score}
85
+
86
+
87
+ if __name__ == "__main__":
88
+ dataset_name = "noteguard-discharge-eval"
89
+ try:
90
+ dataset = client.create_dataset(dataset_name)
91
+ client.create_examples(
92
+ dataset_id=dataset.id,
93
+ inputs=[{"note": e["note"], "question": e["question"]} for e in EXAMPLES],
94
+ )
95
+ except Exception:
96
+ pass # dataset already exists from a previous run
97
+
98
+ client.evaluate(
99
+ target,
100
+ data=dataset_name,
101
+ evaluators=[zero_phi_to_model, faithfulness],
102
+ experiment_prefix="noteguard-slice",
103
+ )
langgraph.json ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ {
2
+ "dependencies": ["."],
3
+ "graphs": {
4
+ "noteguard": "./agent/graph.py:graph"
5
+ },
6
+ "env": ".env"
7
+ }
pyproject.toml ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "noteguard-agent"
7
+ version = "1.2.4"
8
+ description = "Trust layer for clinical AI β€” de-identifies NHS notes before LLM sees them."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Chaeyoon Kim" }]
13
+ keywords = ["nhs", "de-identification", "phi", "langgraph", "gemini", "rap"]
14
+ dependencies = [
15
+ "fastapi>=0.109.0",
16
+ "uvicorn[standard]>=0.27.0",
17
+ "langgraph>=0.2.0",
18
+ "langchain>=0.3.0",
19
+ "langchain-google-genai>=2.0.0",
20
+ "langchain-tavily>=0.1.0",
21
+ "langsmith>=0.1.0",
22
+ "python-dotenv>=1.0.0",
23
+ "huggingface_hub>=0.20.0",
24
+ ]
25
+
26
+ [project.optional-dependencies]
27
+ # pip install -e ".[nlp]" β€” Presidio + spaCy NER for free-text PERSON/LOCATION.
28
+ # Also needs a spaCy model: python -m spacy download en_core_web_md
29
+ nlp = ["presidio-analyzer>=2.2.0", "spacy>=3.7.0,<4"]
30
+ # pip install -e ".[demo]" β€” Streamlit interactive demo
31
+ demo = ["streamlit>=1.35.0"]
32
+ # pip install -e ".[dev]" β€” full dev tooling incl. langgraph dev server
33
+ dev = [
34
+ "pytest>=8.0.0",
35
+ "pytest-cov>=4.1.0",
36
+ "ruff>=0.3.0",
37
+ "pre-commit>=3.6.0",
38
+ "langgraph-cli[inmem]>=0.4.0",
39
+ ]
40
+
41
+ [project.urls]
42
+ Homepage = "https://github.com/chaeyoonyunakim/noteguard-agent"
43
+
44
+ [tool.setuptools.packages.find]
45
+ where = ["."]
46
+ include = ["src*", "agent*", "app*", "eval*"]
47
+
48
+ [tool.ruff]
49
+ line-length = 110
50
+ target-version = "py310"
51
+ src = ["src", "agent", "app", "eval", "tests"]
52
+
53
+ [tool.ruff.lint]
54
+ select = ["E", "F", "I", "B", "UP", "W"]
55
+ ignore = ["B008"]
56
+
57
+ [tool.ruff.lint.per-file-ignores]
58
+ "agent/graph.py" = ["E402"]
59
+ "app/api.py" = ["E402"]
60
+ "eval/run_eval.py" = ["E402", "I001"]
61
+
62
+ [tool.pytest.ini_options]
63
+ testpaths = ["tests"]
64
+ addopts = "-q"
src/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .deid import DeidResult, NoteGuard, load_known_from_csv
2
+
3
+ __all__ = ["NoteGuard", "DeidResult", "load_known_from_csv"]
src/deid.py ADDED
@@ -0,0 +1,433 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """NoteGuard de-identification core.
2
+
3
+ Dependency-free and runnable on its own (`python noteguard/deid.py`).
4
+ If `presidio-analyzer` + a spaCy model are installed the module upgrades
5
+ free-text PERSON/LOCATION detection automatically; the rule + vault layer
6
+ below always runs without them.
7
+
8
+ Design:
9
+ - NHS-aware recognisers (NHS number, GMC/NMC, postcode, DOB, email, phone)
10
+ - vault of known identifiers from patients.csv + admissions.csv so redaction
11
+ is exact AND measurable
12
+ - patient-consistent surrogates (same original β†’ same token across notes)
13
+ - DOB date-shift (kept clinically plausible); other identifiers tokenised
14
+ - reidentify() restores originals for the CLINICIAN's eyes only
15
+ - assert_clean() is the hard guarantee that no identifier reaches the model
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import csv
21
+ import os
22
+ import re
23
+ from dataclasses import dataclass
24
+ from datetime import datetime, timedelta
25
+
26
+ NHS_NUMBER = re.compile(r"\b\d{3}[ -]?\d{3}[ -]?\d{4}\b")
27
+ NHS_CONTEXT = re.compile(r"(?i)\bNHS(?:\s*(?:no\.?|number|#))?[:\s]*([0-9][0-9 \-]{6,12}\d)")
28
+ UK_POSTCODE = re.compile(r"\b([A-Z]{1,2}\d[A-Z\d]? ?\d[A-Z]{2})\b")
29
+ DOB = re.compile(r"\b(\d{1,2}[/\-.]\d{1,2}[/\-.]\d{2,4})\b")
30
+ EMAIL = re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.-]+\b")
31
+ PHONE = re.compile(r"\b0\d{2,3}[ -]?\d{3,4}[ -]?\d{3,4}\b")
32
+
33
+ # GMC/NMC: optional connector word then optional colon/spaces before the ID
34
+ # Handles: "GMC No. 7654321", "NMC number: 18D6896L", "PIN 18D6896L", etc.
35
+ _CONN = r"(?:\s*(?:no\.?|number|reg(?:istration)?|pin|#))?[:\s]*"
36
+ GMC = re.compile(r"(?i)\bGMC" + _CONN + r"(\d{7})\b")
37
+ NMC = re.compile(r"(?i)\b(?:NMC|PIN)" + _CONN + r"(\d{2}[A-Z]\d{4}[A-Z])\b")
38
+
39
+ # Surrogate token pattern β€” catches any [LABEL_n] the model might invent
40
+ _SURROGATE_PAT = re.compile(r"\[[A-Z]+_\d+\]")
41
+
42
+ # Broader surrogate-shaped pattern: [LABEL_<anything>]. Catches both [LABEL_n] and
43
+ # stray template placeholders the model may echo from the system prompt (e.g.
44
+ # [DATE_X]), so none reach the clinician verbatim. Used by redact_unresolved().
45
+ _ORPHAN_PAT = re.compile(r"\[[A-Z]+_[A-Za-z0-9]+\]")
46
+
47
+ # Person-title prefixes that almost always precede a real name. Used by the
48
+ # trust-panel audit (scan_pii) to flag free-text names that slipped past the
49
+ # vault / NER passes β€” e.g. "Dr Ethel Joanne Duffy", "Nurse Jasmine Freda Murray".
50
+ # Requires >= 2 Title-Case tokens after the title so role words ("Nurse
51
+ # Practitioner", "Consultant Cardiologist") do not false-positive, and a correctly
52
+ # tokenised name ("Dr [PERSON_1]") never matches because "[" is not a letter.
53
+ _PERSON_TITLE = (
54
+ r"(?:Dr|Doctor|Mr|Mrs|Ms|Miss|Prof|Professor|Nurse|Sister|Matron|Midwife|Sir|Dame|Consultant|GP)"
55
+ )
56
+ _NAME_AFTER_TITLE = re.compile(r"\b" + _PERSON_TITLE + r"\b[.,:]?\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+){1,3})")
57
+
58
+ # Column names we look for in any CSV to extract person names
59
+ _NAME_COLS = frozenset(
60
+ {
61
+ "full_name",
62
+ "patient_name",
63
+ "first_name",
64
+ "surname",
65
+ "last_name",
66
+ "clinician_name",
67
+ "author_name",
68
+ "author",
69
+ "attending",
70
+ "attending_physician",
71
+ "nurse",
72
+ "consultant",
73
+ "doctor",
74
+ "provider",
75
+ }
76
+ )
77
+
78
+
79
+ # ── Optional NLP detector (Presidio + spaCy) ─────────────────────────────────
80
+
81
+ # Clinical terms/abbreviations that the small spaCy model (en_core_web_md) often
82
+ # mislabels as PERSON/LOCATION. They are never redacted as names even when the NER
83
+ # layer flags them β€” over-redaction like "Subcut" -> [PERSON_3] is wrong and noisy.
84
+ # Compared case-insensitively; keep to terms that are not plausible real names.
85
+ _NER_STOPWORDS = frozenset(
86
+ {
87
+ "subcut",
88
+ "subcutaneous",
89
+ "obs",
90
+ "sats",
91
+ "spo2",
92
+ "nad",
93
+ "nbm",
94
+ "nkda",
95
+ "prn",
96
+ "stat",
97
+ "mane",
98
+ "nocte",
99
+ "neuro",
100
+ "resp",
101
+ "cvs",
102
+ "abdo",
103
+ "perrla",
104
+ "gcs",
105
+ "afebrile",
106
+ "apyrexial",
107
+ "euvolaemic",
108
+ "normotensive",
109
+ "tachycardic",
110
+ "bradycardic",
111
+ "pyrexial",
112
+ "bibasal",
113
+ "pneumomediastinum",
114
+ }
115
+ )
116
+
117
+
118
+ class _Detector:
119
+ """Stub detector β€” no-op when Presidio/spaCy is not installed."""
120
+
121
+ def detect_persons(self, text: str) -> list[str]:
122
+ return []
123
+
124
+
125
+ def _build_detector() -> _Detector:
126
+ """Upgrade free-text PERSON/LOCATION detection to Presidio + spaCy when
127
+ available; otherwise return a no-op stub so the rule + vault layer still runs.
128
+
129
+ The spaCy model is configurable via ``NOTEGUARD_SPACY_MODEL`` (default
130
+ ``en_core_web_md`` β€” small enough to ship in the Docker image, materially
131
+ better recall than ``_sm``; set ``_lg`` for best recall at ~14x the size).
132
+ Any import or engine-setup failure degrades gracefully to the stub: NER is a
133
+ recall boost over the rules + vault, never a hard dependency.
134
+ """
135
+ try:
136
+ from presidio_analyzer import AnalyzerEngine # type: ignore
137
+ from presidio_analyzer.nlp_engine import NlpEngineProvider # type: ignore
138
+
139
+ model = os.getenv("NOTEGUARD_SPACY_MODEL", "en_core_web_md")
140
+ provider = NlpEngineProvider(
141
+ nlp_configuration={
142
+ "nlp_engine_name": "spacy",
143
+ "models": [{"lang_code": "en", "model_name": model}],
144
+ }
145
+ )
146
+ engine = AnalyzerEngine(nlp_engine=provider.create_engine(), supported_languages=["en"])
147
+
148
+ class _PresidioDetector(_Detector):
149
+ def detect_persons(self, text: str) -> list[str]:
150
+ results = engine.analyze(text, language="en", entities=["PERSON", "LOCATION"])
151
+ return [text[r.start : r.end] for r in results if r.end > r.start]
152
+
153
+ return _PresidioDetector()
154
+ except Exception:
155
+ return _Detector()
156
+
157
+
158
+ _DETECTOR: _Detector = _build_detector()
159
+
160
+
161
+ # ── Vault loader ──────────────────────────────────────────────────────────────
162
+
163
+
164
+ def load_known_from_csv(patients_csv: str, admissions_csv: str | None = None) -> dict:
165
+ """Build the identifier vault from the synthetic dataset's structured tables.
166
+
167
+ Reads ``full_name`` and common name columns from *patients_csv*, plus NHS
168
+ numbers. If *admissions_csv* is supplied, any clinician/author name columns
169
+ found there are added to the PERSON set so clinician names are caught
170
+ deterministically even without Presidio.
171
+ """
172
+ known: dict[str, set] = {"PERSON": set(), "NHS": set()}
173
+
174
+ def _pull_row(row: dict) -> None:
175
+ for col in _NAME_COLS:
176
+ v = (row.get(col) or "").strip()
177
+ if len(v) > 2:
178
+ known["PERSON"].add(v)
179
+
180
+ with open(patients_csv, newline="", encoding="utf-8-sig") as f:
181
+ for row in csv.DictReader(f):
182
+ _pull_row(row)
183
+ nhs = (row.get("nhs_number") or "").strip()
184
+ if nhs:
185
+ known["NHS"].add(nhs)
186
+
187
+ if admissions_csv:
188
+ try:
189
+ with open(admissions_csv, newline="", encoding="utf-8-sig") as f:
190
+ for row in csv.DictReader(f):
191
+ _pull_row(row)
192
+ except FileNotFoundError:
193
+ pass
194
+
195
+ return {k: sorted(v) for k, v in known.items()}
196
+
197
+
198
+ # ── Core de-identification ────────────────────────────────────────────────────
199
+
200
+
201
+ @dataclass
202
+ class DeidResult:
203
+ clean_text: str
204
+ forward: dict
205
+ reverse: dict
206
+ residual: list
207
+
208
+
209
+ class NoteGuard:
210
+ def __init__(self, known=None, dob_shift_days=37, forward=None, reverse=None):
211
+ self.known = {k: list(v) for k, v in (known or {}).items()}
212
+ self.dob_shift = dob_shift_days
213
+ self.forward = dict(forward or {})
214
+ self.reverse = dict(reverse or {})
215
+ self._counter: dict[str, int] = {}
216
+ for tok in self.reverse:
217
+ m = re.match(r"\[([A-Z]+)_(\d+)\]", tok)
218
+ if m:
219
+ self._counter[m.group(1)] = max(self._counter.get(m.group(1), 0), int(m.group(2)))
220
+
221
+ @staticmethod
222
+ def _fix_mojibake(s: str) -> str:
223
+ # Each pair: (UTF-8 bytes of the real char decoded as Windows-1252, real char)
224
+ # · = · β†’ Β· (middle dot U+00B7)
225
+ # Ò€ℒ = Ò€ℒ β†’ ' (right single quote U+2019)
226
+ # Γ’β‚¬β€œ = Ò€" β†’ – (en-dash U+2013; 0x93 in Win-1252 = U+201C)
227
+ # é = é β†’ Γ© (e-acute U+00E9)
228
+ return s.replace("·", "Β·").replace("Ò€ℒ", "’").replace("Γ’β‚¬β€œ", "–").replace("é", "Γ©")
229
+
230
+ def _surrogate(self, label: str, original: str) -> str:
231
+ if original in self.forward:
232
+ return self.forward[original]
233
+ self._counter[label] = self._counter.get(label, 0) + 1
234
+ tok = f"[{label}_{self._counter[label]}]"
235
+ self.forward[original] = tok
236
+ self.reverse[tok] = original
237
+ return tok
238
+
239
+ def _shift_date(self, s: str) -> str:
240
+ if s in self.forward:
241
+ return self.forward[s]
242
+ for fmt in ("%d/%m/%Y", "%d/%m/%y", "%d-%m-%Y", "%Y-%m-%d", "%d.%m.%Y"):
243
+ try:
244
+ shifted = (datetime.strptime(s, fmt) + timedelta(days=self.dob_shift)).strftime(fmt)
245
+ self.forward[s] = shifted
246
+ self.reverse[shifted] = s
247
+ return shifted
248
+ except ValueError:
249
+ continue
250
+ return self._surrogate("DATE", s)
251
+
252
+ def _redact(self, pattern, label, text, group=0, transform=None):
253
+ def repl(m):
254
+ original = m.group(group)
255
+ surr = transform(original) if transform else self._surrogate(label, original)
256
+ return m.group(0).replace(original, surr)
257
+
258
+ return pattern.sub(repl, text)
259
+
260
+ def deidentify(self, text: str) -> DeidResult:
261
+ t = self._fix_mojibake(text)
262
+
263
+ # Vault pass β€” patient names + NHS numbers from structured tables
264
+ for label in ("PERSON", "NHS"):
265
+ terms = [x for x in self.known.get(label, []) if x]
266
+ if terms:
267
+ alternatives = "|".join(re.escape(x) for x in sorted(terms, key=len, reverse=True))
268
+ pat = re.compile(r"\b(" + alternatives + r")\b")
269
+ t = self._redact(pat, label, t, group=1)
270
+
271
+ # Rule-based recognisers
272
+ t = self._redact(NHS_CONTEXT, "NHS", t, group=1)
273
+ t = self._redact(NHS_NUMBER, "NHS", t, group=0)
274
+ t = self._redact(GMC, "GMC", t, group=1)
275
+ t = self._redact(NMC, "NMC", t, group=1)
276
+ t = self._redact(EMAIL, "EMAIL", t, group=0)
277
+ t = self._redact(PHONE, "PHONE", t, group=0)
278
+ t = self._redact(UK_POSTCODE, "POSTCODE", t, group=1)
279
+ t = self._redact(DOB, "DOB", t, group=1, transform=self._shift_date)
280
+
281
+ # Optional NLP pass β€” catches clinician names and locations not in vault
282
+ for name in self._detect_names(t):
283
+ if name not in self.forward:
284
+ t = t.replace(name, self._surrogate("PERSON", name))
285
+
286
+ return DeidResult(t, dict(self.forward), dict(self.reverse), self._residual_known(t))
287
+
288
+ @staticmethod
289
+ def _detect_names(text: str) -> list[str]:
290
+ """NER-detected person/location names, minus clinical-term false positives.
291
+
292
+ Filters the raw detector output so abbreviations the small spaCy model
293
+ mislabels (e.g. "Subcut") are not treated as names.
294
+ """
295
+ return [
296
+ n for n in _DETECTOR.detect_persons(text) if n and len(n) > 2 and n.lower() not in _NER_STOPWORDS
297
+ ]
298
+
299
+ def _residual_known(self, text: str) -> list:
300
+ # Use word-boundary match, same as the deidentify vault pass, so that
301
+ # short names like "Dia" don't false-positive on "Diastolic"/"Diabetes".
302
+ return [
303
+ v
304
+ for vals in self.known.values()
305
+ for v in vals
306
+ if v and re.search(r"\b" + re.escape(v) + r"\b", text)
307
+ ]
308
+
309
+ def residual_identifiers(self, text: str) -> list[str]:
310
+ """Comprehensive leak check β€” used for the trust metric.
311
+
312
+ Covers:
313
+ - vault names that survived de-id
314
+ - regex patterns (NHS, email, GMC, NMC)
315
+ - orphaned surrogate tokens (invented by the model, no reverse mapping)
316
+ - NLP-detected persons/locations (when Presidio is installed)
317
+ """
318
+ hits: list[str] = list(self._residual_known(text))
319
+
320
+ for pat in (NHS_CONTEXT, NHS_NUMBER, EMAIL, GMC, NMC):
321
+ if pat.search(text):
322
+ hits.append(f"pattern:{pat.pattern[:40]}")
323
+
324
+ # Orphaned surrogates: a [LABEL_n] in the text with no reverse mapping
325
+ # means the model invented a token we cannot restore β€” it's a leak.
326
+ for m in _SURROGATE_PAT.finditer(text):
327
+ tok = m.group(0)
328
+ if tok not in self.reverse:
329
+ hits.append(f"unmapped_token:{tok}")
330
+
331
+ # NLP pass (no-op when Presidio not installed)
332
+ for name in self._detect_names(text):
333
+ hits.append(f"PERSON:{name[:30]}")
334
+
335
+ return list(dict.fromkeys(hits)) # deduplicate, preserve order
336
+
337
+ def scan_pii(self, text: str) -> list[dict]:
338
+ """Vault-independent audit of a *de-identified* text for residual PII.
339
+
340
+ ``assert_clean``/``residual_identifiers`` only know the vault and
341
+ structured patterns, so a free-text name that was never in the vault
342
+ (with no Presidio installed) passes them silently β€” exactly the failure
343
+ where "Dr Ethel Joanne Duffy" reaches the model un-redacted. This adds a
344
+ high-precision person-title heuristic so the trust panel can report
345
+ whether de-identification actually succeeded, even on a pasted note with
346
+ no ground-truth vault.
347
+
348
+ Returns a de-duplicated list of ``{"type": str, "text": str}`` findings,
349
+ each a span of suspected un-redacted PII still visible to the model.
350
+ Surrogate tokens (``[PERSON_1]``) are never flagged. Shifted dates are
351
+ intentional, so dates are out of scope here.
352
+ """
353
+ findings: list[dict] = []
354
+ spans: list[tuple[int, int]] = []
355
+
356
+ def _add(typ: str, start: int, end: int) -> None:
357
+ if start < 0 or any(start < pe and ps < end for ps, pe in spans):
358
+ return # invalid, or overlaps an earlier (higher-priority) finding
359
+ spans.append((start, end))
360
+ findings.append({"type": typ, "text": text[start:end]})
361
+
362
+ # 1. structured identifiers that should have been tokenised but survived
363
+ for typ, pat, grp in (
364
+ ("NHS number", NHS_CONTEXT, 1),
365
+ ("NHS number", NHS_NUMBER, 0),
366
+ ("GMC", GMC, 1),
367
+ ("NMC", NMC, 1),
368
+ ("email", EMAIL, 0),
369
+ ("phone", PHONE, 0),
370
+ ("postcode", UK_POSTCODE, 1),
371
+ ):
372
+ for m in pat.finditer(text):
373
+ _add(typ, m.start(grp), m.end(grp))
374
+
375
+ # 2. free-text person names the vault/NER passes missed (title heuristic)
376
+ for m in _NAME_AFTER_TITLE.finditer(text):
377
+ _add("name", m.start(1), m.end(1))
378
+
379
+ # 3. vault names that survived de-id (when a ground-truth vault is loaded)
380
+ for v in self._residual_known(text):
381
+ idx = text.find(v)
382
+ _add("name", idx, idx + len(v))
383
+
384
+ return findings
385
+
386
+ def assert_clean(self, text: str) -> None:
387
+ """Hard guarantee: raises if any known identifier or regex pattern survives."""
388
+ hits = list(self._residual_known(text))
389
+ for pat in (NHS_CONTEXT, NHS_NUMBER, EMAIL, GMC, NMC):
390
+ if pat.search(text):
391
+ hits.append(pat.pattern[:40])
392
+ if hits:
393
+ raise ValueError(f"NoteGuard guarantee failed: identifiers reached the model boundary: {hits}")
394
+
395
+ def reidentify(self, text: str) -> str:
396
+ for tok, original in sorted(self.reverse.items(), key=lambda kv: len(kv[0]), reverse=True):
397
+ text = text.replace(tok, original)
398
+ return text
399
+
400
+ def redact_unresolved(self, text: str) -> tuple[str, list[str]]:
401
+ """Redact any surrogate-shaped token still present after ``reidentify()``.
402
+
403
+ Catches both unrestored surrogates (a ``[PERSON_9]`` with no reverse entry)
404
+ and stray template placeholders the model echoes from the prompt (e.g.
405
+ ``[DATE_X]``) β€” neither must ever reach the clinician verbatim. Returns the
406
+ cleaned text and the tokens that were redacted (a reversibility-leak signal).
407
+ """
408
+ leaked: list[str] = []
409
+
410
+ def _r(m: re.Match) -> str:
411
+ leaked.append(m.group(0))
412
+ return "[redacted]"
413
+
414
+ return _ORPHAN_PAT.sub(_r, text), leaked
415
+
416
+
417
+ if __name__ == "__main__":
418
+ known = {"PERSON": ["Margaret Okafor"], "NHS": ["485 777 3456"]}
419
+ note = (
420
+ "02 Jan, Ward RJ1. Pt Margaret Okafor (NHS 485 777 3456, DOB 14/03/1934) "
421
+ "admitted post-fall. Nurse Chukwuebuka reviewed. "
422
+ "Contact a.okafor@example.com, 020 7946 0991. "
423
+ "GMC No. 7654321. NMC number: 18D6896L."
424
+ )
425
+ print("INPUT:\n", note, "\n")
426
+ ng = NoteGuard(known=known)
427
+ res = ng.deidentify(note)
428
+ print("DE-IDENTIFIED (what the model sees):\n", res.clean_text, "\n")
429
+ print("Residual identifiers:", res.residual)
430
+ ng.assert_clean(res.clean_text)
431
+ print("assert_clean: OK\n")
432
+ restored = NoteGuard(reverse=res.reverse).reidentify(res.clean_text)
433
+ print("RE-IDENTIFIED (clinician view only):\n", restored)
src/fetch_dataset.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Download NHSEDataScience/synthetic_clinical_notes (Silver tier) to data/.
2
+
3
+ Usage:
4
+ python src/fetch_dataset.py
5
+
6
+ Downloads three CSVs into ./data/:
7
+ synthetic_clinical_notes.csv patients.csv admissions.csv
8
+
9
+ Run once before starting the server to enable /samples and /sample/* endpoints.
10
+ Requires: huggingface_hub (pip install huggingface_hub)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import shutil
16
+ from pathlib import Path
17
+
18
+ DATA_DIR = Path(__file__).parent.parent / "data"
19
+ REPO_ID = "NHSEDataScience/synthetic_clinical_notes"
20
+ SILVER_FILES = [
21
+ "silver/synthetic_clinical_notes.csv",
22
+ "silver/patients.csv",
23
+ "silver/admissions.csv",
24
+ ]
25
+
26
+
27
+ def fetch() -> None:
28
+ from huggingface_hub import hf_hub_download
29
+
30
+ DATA_DIR.mkdir(exist_ok=True)
31
+ for remote_path in SILVER_FILES:
32
+ dest = DATA_DIR / Path(remote_path).name
33
+ print(f" {remote_path} ...", end=" ", flush=True)
34
+ cached = hf_hub_download(repo_id=REPO_ID, filename=remote_path, repo_type="dataset")
35
+ shutil.copy(cached, dest)
36
+ print(f"-> {dest.name}")
37
+ print(f"\nDone. Files in {DATA_DIR}/")
38
+
39
+
40
+ if __name__ == "__main__":
41
+ fetch()
streamlit_app.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """NoteGuard β€” interactive de-identification demo (Streamlit).
2
+
3
+ Run: streamlit run streamlit_app.py
4
+ No API keys required β€” uses the pure-Python rule layer only.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import streamlit as st
10
+ from src.deid import NoteGuard
11
+
12
+ st.set_page_config(page_title="NoteGuard demo", page_icon=":lock:", layout="wide")
13
+
14
+ st.title("NoteGuard β€” NHS clinical-note de-identification")
15
+ st.caption(
16
+ "Trust layer for clinical AI Β· "
17
+ "Pseudonymises free-text before any LLM sees it Β· "
18
+ "Pure-Python rule layer (no API key needed for this demo)"
19
+ )
20
+
21
+ BUILT_IN = (
22
+ "02 Jan 2025, Ward RJ1.\n"
23
+ "Pt Margaret Okafor (NHS 485 777 3456, DOB 14/03/1934, F).\n"
24
+ "GP: Dr James Obi, Riverside Surgery SE1 7PB.\n"
25
+ "Admitted post-fall. Hx: AF on warfarin, T2DM on metformin.\n"
26
+ "Nurse Chukwuebuka Okafor reviewed at triage, NMC number: 18D6896L.\n"
27
+ "Consultant: Dr Sarah Chen, GMC No. 7654321.\n"
28
+ "Contact: a.okafor@nhs.net Β· 020 7946 0991."
29
+ )
30
+
31
+ col_in, col_out = st.columns(2)
32
+
33
+ with col_in:
34
+ st.subheader("Clinical note (input)")
35
+ note = st.text_area(
36
+ "Paste or edit a clinical note:", value=BUILT_IN, height=300, label_visibility="collapsed"
37
+ )
38
+ known_names = st.text_input(
39
+ "Known patient/clinician names (comma-separated, optional):",
40
+ placeholder="e.g. Margaret Okafor, Chukwuebuka Okafor",
41
+ )
42
+
43
+ run = st.button("De-identify", type="primary")
44
+
45
+ if run and note.strip():
46
+ known: dict = {"PERSON": [], "NHS": []}
47
+ if known_names.strip():
48
+ known["PERSON"] = [n.strip() for n in known_names.split(",") if n.strip()]
49
+
50
+ ng = NoteGuard(known=known)
51
+ result = ng.deidentify(note)
52
+
53
+ with col_out:
54
+ st.subheader("De-identified text (what the AI sees)")
55
+ st.code(result.clean_text, language=None)
56
+
57
+ st.divider()
58
+ col_m1, col_m2, col_m3 = st.columns(3)
59
+ col_m1.metric("Identifiers removed", len(result.forward))
60
+ col_m2.metric("Residual (known vault)", len(result.residual))
61
+ col_m3.metric("Re-ID risk", "0.0%" if not result.residual else f"{len(result.residual)} found")
62
+
63
+ if result.forward:
64
+ with st.expander("Surrogate mapping (clinician eyes only β€” never sent to model)"):
65
+ st.table(
66
+ [
67
+ {"original": k, "surrogate": v}
68
+ for k, v in sorted(result.forward.items(), key=lambda kv: kv[1])
69
+ ]
70
+ )
71
+
72
+ if result.residual:
73
+ st.error(f"Residual identifiers detected: {result.residual}")
74
+ else:
75
+ st.success("assert_clean passed β€” no known identifier reached the model boundary.")
76
+ elif run:
77
+ st.warning("Please enter a note to de-identify.")
tests/__init__.py ADDED
File without changes
tests/test_deid.py ADDED
@@ -0,0 +1,340 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for noteguard.deid.
2
+
3
+ All tests use the standard-library-only de-id core β€” no external services,
4
+ no API keys, no network calls required. Safe to run in CI.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import pytest
10
+ from src.deid import NoteGuard, load_known_from_csv
11
+
12
+ KNOWN = {"PERSON": ["Margaret Okafor"], "NHS": ["485 777 3456"]}
13
+
14
+
15
+ def _ng() -> NoteGuard:
16
+ return NoteGuard(known=KNOWN)
17
+
18
+
19
+ @pytest.fixture(autouse=True)
20
+ def _no_ner(monkeypatch):
21
+ """Pin the rule/vault layer for unit tests.
22
+
23
+ Presidio NER is an optional recall boost (the ``[nlp]`` extra). Tests must be
24
+ deterministic whether or not it is installed, so default every test to the
25
+ no-op detector; the NER-path test injects its own fake on top.
26
+ """
27
+ import src.deid as deid
28
+
29
+ monkeypatch.setattr(deid, "_DETECTOR", deid._Detector())
30
+
31
+
32
+ # ── deidentify ────────────────────────────────────────────────────────────────
33
+
34
+
35
+ def test_nhs_number_replaced():
36
+ ng = _ng()
37
+ result = ng.deidentify("Patient NHS 485 777 3456 admitted.")
38
+ assert "485 777 3456" not in result.clean_text
39
+ assert "[NHS_" in result.clean_text
40
+
41
+
42
+ def test_person_name_replaced():
43
+ ng = _ng()
44
+ result = ng.deidentify("Pt Margaret Okafor discharged home.")
45
+ assert "Margaret Okafor" not in result.clean_text
46
+ assert "[PERSON_" in result.clean_text
47
+
48
+
49
+ def test_email_replaced():
50
+ ng = _ng()
51
+ result = ng.deidentify("Contact a.okafor@nhs.net for follow-up.")
52
+ assert "@" not in result.clean_text
53
+
54
+
55
+ def test_dob_replaced():
56
+ ng = _ng()
57
+ result = ng.deidentify("DOB 14/03/1934, admitted post-fall.")
58
+ assert "14/03/1934" not in result.clean_text
59
+
60
+
61
+ def test_gmc_replaced():
62
+ ng = _ng()
63
+ result = ng.deidentify("Referring clinician GMC 1234567.")
64
+ assert "1234567" not in result.clean_text
65
+
66
+
67
+ def test_clean_text_passes_through_unchanged():
68
+ ng = _ng()
69
+ note = "Patient admitted post-fall. Hx AF, on warfarin. BP 128/74."
70
+ result = ng.deidentify(note)
71
+ assert result.clean_text == note
72
+
73
+
74
+ # ── GMC / NMC connector-word variants ────────────────────────────────────────
75
+
76
+
77
+ def test_gmc_with_connector_no():
78
+ ng = _ng()
79
+ res = ng.deidentify("Referring clinician GMC No. 7654321.")
80
+ assert "7654321" not in res.clean_text
81
+ assert "[GMC_" in res.clean_text
82
+
83
+
84
+ def test_gmc_with_connector_number():
85
+ ng = _ng()
86
+ res = ng.deidentify("GMC number 7654321 on record.")
87
+ assert "7654321" not in res.clean_text
88
+
89
+
90
+ def test_nmc_with_connector_number_colon():
91
+ ng = _ng()
92
+ res = ng.deidentify("Nurse Chukwuebuka Okafor, NMC number: 18D6896L")
93
+ assert "18D6896L" not in res.clean_text
94
+ assert "[NMC_" in res.clean_text
95
+
96
+
97
+ def test_nmc_with_pin():
98
+ ng = _ng()
99
+ res = ng.deidentify("Registered nurse PIN 18D6896L.")
100
+ assert "18D6896L" not in res.clean_text
101
+
102
+
103
+ def test_nmc_bare():
104
+ ng = _ng()
105
+ res = ng.deidentify("NMC 18D6896L confirmed.")
106
+ assert "18D6896L" not in res.clean_text
107
+
108
+
109
+ # ── Clinician name detection (via expanded vault) ─────────────────────────────
110
+
111
+
112
+ def test_clinician_name_via_vault():
113
+ """Clinician names added to the vault are redacted deterministically."""
114
+ known = {"PERSON": ["Chukwuebuka Okafor", "Margaret Okafor"], "NHS": []}
115
+ ng = NoteGuard(known=known)
116
+ res = ng.deidentify("Nurse Chukwuebuka Okafor assessed the patient.")
117
+ assert "Chukwuebuka Okafor" not in res.clean_text
118
+ assert "[PERSON_" in res.clean_text
119
+
120
+
121
+ def test_full_clinician_nmc_note():
122
+ """Combined: nurse name in vault + NMC number with connector word."""
123
+ known = {"PERSON": ["Chukwuebuka Okafor"], "NHS": []}
124
+ ng = NoteGuard(known=known)
125
+ note = "Patient assessed at triage by Nurse Chukwuebuka Okafor, NMC number: 18D6896L"
126
+ res = ng.deidentify(note)
127
+ assert "Chukwuebuka Okafor" not in res.clean_text
128
+ assert "18D6896L" not in res.clean_text
129
+ ng.assert_clean(res.clean_text)
130
+
131
+
132
+ # ── assert_clean ──────────────────────────────────────────────────────────────
133
+
134
+
135
+ def test_assert_clean_passes_on_safe_text():
136
+ ng = _ng()
137
+ ng.assert_clean("Admitted post-fall. Hx AF. INR 2.4.") # must not raise
138
+
139
+
140
+ def test_assert_clean_raises_on_nhs_number():
141
+ ng = _ng()
142
+ with pytest.raises(ValueError, match="485 777 3456"):
143
+ ng.assert_clean("NHS 485 777 3456 still present.")
144
+
145
+
146
+ def test_assert_clean_raises_on_known_name():
147
+ ng = _ng()
148
+ with pytest.raises(ValueError, match="Margaret Okafor"):
149
+ ng.assert_clean("Patient Margaret Okafor discharged.")
150
+
151
+
152
+ def test_assert_clean_raises_on_nmc():
153
+ ng = _ng()
154
+ with pytest.raises(ValueError):
155
+ ng.assert_clean("NMC number: 18D6896L not redacted.")
156
+
157
+
158
+ # ── residual_identifiers (trust metric) ───────────────────────────────────────
159
+
160
+
161
+ def test_residual_identifiers_catches_orphaned_token():
162
+ """A [LABEL_n] token with no reverse mapping is an unmapped-token leak."""
163
+ ng = NoteGuard(known={}, reverse={"[PERSON_1]": "Real Name"})
164
+ text = "Summary for [PERSON_1] and [PERSON_2]." # PERSON_2 has no mapping
165
+ hits = ng.residual_identifiers(text)
166
+ assert any("unmapped_token" in h for h in hits)
167
+ # PERSON_1 is mapped β€” should NOT appear as orphaned
168
+ assert not any("PERSON_1" in h for h in hits)
169
+
170
+
171
+ def test_residual_identifiers_catches_nmc():
172
+ ng = _ng()
173
+ hits = ng.residual_identifiers("Nurse PIN 18D6896L still present.")
174
+ assert any(h for h in hits) # something was found
175
+
176
+
177
+ # ── reidentify ────────────────────────────────────────────────────────────────
178
+
179
+
180
+ def test_reidentify_restores_surrogate():
181
+ ng = _ng()
182
+ result = ng.deidentify("Pt Margaret Okafor (NHS 485 777 3456) admitted.")
183
+ restored = ng.reidentify(result.clean_text)
184
+ assert "Margaret Okafor" in restored
185
+ assert "485 777 3456" in restored
186
+
187
+
188
+ def test_reidentify_consistent_surrogates():
189
+ """Same original -> same surrogate across multiple notes."""
190
+ ng = _ng()
191
+ r1 = ng.deidentify("Note 1: Margaret Okafor, INR normal.")
192
+ r2 = ng.deidentify("Note 2: Margaret Okafor, discharged.")
193
+ tokens_1 = {tok for tok in r1.clean_text.split() if tok.startswith("[PERSON-")}
194
+ tokens_2 = {tok for tok in r2.clean_text.split() if tok.startswith("[PERSON-")}
195
+ assert tokens_1 == tokens_2
196
+
197
+
198
+ # ── load_known_from_csv ───────────────────────────────────────────────────────
199
+
200
+
201
+ def test_load_known_from_csv(tmp_path):
202
+ csv_file = tmp_path / "patients.csv"
203
+ csv_file.write_text("full_name,nhs_number\nJane Smith,123 456 7890\n")
204
+ known = load_known_from_csv(str(csv_file))
205
+ assert "Jane Smith" in known["PERSON"]
206
+ assert "123 456 7890" in known["NHS"]
207
+
208
+
209
+ def test_load_known_from_csv_admissions(tmp_path):
210
+ """Names in admissions.csv clinician columns are added to the vault."""
211
+ patients = tmp_path / "patients.csv"
212
+ patients.write_text("full_name,nhs_number\nJane Smith,123 456 7890\n")
213
+ admissions = tmp_path / "admissions.csv"
214
+ admissions.write_text("clinician_name,attending\nDr Wei Wang,Nurse Chukwuebuka Okafor\n")
215
+ known = load_known_from_csv(str(patients), str(admissions))
216
+ assert "Dr Wei Wang" in known["PERSON"]
217
+ assert "Nurse Chukwuebuka Okafor" in known["PERSON"]
218
+
219
+
220
+ def test_load_known_from_csv_missing_admissions(tmp_path):
221
+ """Missing admissions.csv is silently ignored."""
222
+ patients = tmp_path / "patients.csv"
223
+ patients.write_text("full_name,nhs_number\nJane Smith,123 456 7890\n")
224
+ known = load_known_from_csv(str(patients), str(tmp_path / "missing.csv"))
225
+ assert "Jane Smith" in known["PERSON"]
226
+
227
+
228
+ # ── scan_pii: vault-independent residual-PII audit for the trust panel ─────────
229
+
230
+
231
+ def test_scan_pii_flags_titled_names_missed_by_vault():
232
+ """The reported failure: free-text clinician names with no vault entry slip
233
+ past de-id, and scan_pii must catch them while ignoring tokenised IDs."""
234
+ ng = NoteGuard(known={"PERSON": [], "NHS": []}) # arbitrary pasted note, no vault
235
+ note = (
236
+ "Contacted patient's GP, Dr. Ethel Joanne Duffy, to provide an update.\n"
237
+ "Nurse Jasmine Freda Murray\nNMC number: 20F4626L"
238
+ )
239
+ res = ng.deidentify(note)
240
+ findings = ng.scan_pii(res.clean_text)
241
+ texts = " | ".join(f["text"] for f in findings)
242
+ assert all(f["type"] == "name" for f in findings)
243
+ assert "Ethel Joanne Duffy" in texts
244
+ assert "Jasmine Freda Murray" in texts
245
+ assert "NMC" not in texts and "[NMC_1]" not in texts # tokenised id is not PII
246
+
247
+
248
+ def test_scan_pii_clean_when_names_tokenised():
249
+ """When names are in the vault they become surrogates β†’ no residual PII."""
250
+ ng = NoteGuard(known={"PERSON": ["Ethel Joanne Duffy", "Jasmine Freda Murray"], "NHS": []})
251
+ res = ng.deidentify("GP Dr. Ethel Joanne Duffy. Nurse Jasmine Freda Murray.")
252
+ assert ng.scan_pii(res.clean_text) == []
253
+
254
+
255
+ def test_scan_pii_ignores_surrogate_tokens_and_role_words():
256
+ """Surrogate tokens and bare role words must not be flagged as names."""
257
+ ng = NoteGuard()
258
+ text = "Consultant: [PERSON_1], seen by Dr [PERSON_2]. Nurse Practitioner reviewed."
259
+ assert ng.scan_pii(text) == []
260
+
261
+
262
+ def test_scan_pii_flags_residual_structured_identifier():
263
+ """A structured identifier that slipped through is reported with its type."""
264
+ ng = NoteGuard()
265
+ findings = ng.scan_pii("Contact the team at a.b.smith@nhs.net for queries.")
266
+ assert any(f["type"] == "email" and "a.b.smith@nhs.net" in f["text"] for f in findings)
267
+
268
+
269
+ # ── NER path: Presidio/spaCy redacts free-text names with no vault entry ───────
270
+
271
+
272
+ def test_deidentify_redacts_ner_detected_names(monkeypatch):
273
+ """When an NLP detector is present, a free-text name with no vault entry is
274
+ still tokenised β€” the recall boost the [nlp] extra adds in the deployed image."""
275
+ import src.deid as deid
276
+
277
+ class _Fake(deid._Detector):
278
+ def detect_persons(self, text: str) -> list[str]:
279
+ return ["Ethel Joanne Duffy"] if "Ethel Joanne Duffy" in text else []
280
+
281
+ monkeypatch.setattr(deid, "_DETECTOR", _Fake())
282
+ ng = deid.NoteGuard(known={"PERSON": [], "NHS": []})
283
+ res = ng.deidentify("Reviewed by Ethel Joanne Duffy on the ward round.")
284
+ assert "Ethel Joanne Duffy" not in res.clean_text
285
+ assert "[PERSON_" in res.clean_text
286
+ assert ng.scan_pii(res.clean_text) == [] # nothing left for the audit to flag
287
+
288
+
289
+ def test_shifted_date_round_trips_through_reidentify():
290
+ """A visit date is shifted (so the model never sees the real one), but the
291
+ shift is reversible: reproducing the shifted date restores the true date for
292
+ the clinician. This is the mechanism the discharge-summary date relies on."""
293
+ ng = NoteGuard()
294
+ res = ng.deidentify("Admission date 13/02/26.")
295
+ shifted = res.forward["13/02/26"]
296
+ assert shifted != "13/02/26" # the model sees a different (shifted) date
297
+ assert "13/02/26" not in res.clean_text
298
+ # Model reproduces the shifted date verbatim β†’ reidentify restores the real one.
299
+ restored = NoteGuard(reverse=res.reverse).reidentify(f"Admitted on {shifted}.")
300
+ assert "13/02/26" in restored
301
+
302
+
303
+ def test_redact_unresolved_strips_stray_date_placeholder():
304
+ """A stray template placeholder like [DATE_X] (label + non-digit) is redacted
305
+ and flagged, so it never reaches the clinician verbatim."""
306
+ ng = NoteGuard()
307
+ out, leaked = ng.redact_unresolved("Admitted [DATE_X] after chest pain.")
308
+ assert "[DATE_X]" not in out
309
+ assert out == "Admitted [redacted] after chest pain."
310
+ assert leaked == ["[DATE_X]"]
311
+
312
+
313
+ def test_redact_unresolved_strips_unrestored_surrogate():
314
+ """An unrestored [LABEL_n] surrogate is also caught."""
315
+ ng = NoteGuard()
316
+ out, leaked = ng.redact_unresolved("Seen by [PERSON_9].")
317
+ assert out == "Seen by [redacted]." and leaked == ["[PERSON_9]"]
318
+
319
+
320
+ def test_redact_unresolved_leaves_clean_text_untouched():
321
+ """Text with no surrogate-shaped tokens is returned unchanged."""
322
+ ng = NoteGuard()
323
+ out, leaked = ng.redact_unresolved("Admitted after chest pain. Stable.")
324
+ assert out == "Admitted after chest pain. Stable." and leaked == []
325
+
326
+
327
+ def test_ner_clinical_stopwords_not_redacted(monkeypatch):
328
+ """Clinical abbreviations the NER layer mislabels (e.g. 'Subcut') are kept,
329
+ while a real name flagged in the same pass is still redacted."""
330
+ import src.deid as deid
331
+
332
+ class _Fake(deid._Detector):
333
+ def detect_persons(self, text: str) -> list[str]:
334
+ return ["Subcut", "Afua Asare"]
335
+
336
+ monkeypatch.setattr(deid, "_DETECTOR", _Fake())
337
+ ng = deid.NoteGuard(known={"PERSON": [], "NHS": []})
338
+ res = ng.deidentify("Subcut emph noted on palpation. Reviewed by Afua Asare.")
339
+ assert "Subcut" in res.clean_text # clinical term β€” not a name
340
+ assert "Afua Asare" not in res.clean_text and "[PERSON_" in res.clean_text