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

Deploy 8244dcbebc2e76b6843857728efe8f835b7426b4 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,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.0.1] - 2026-06-28
8
+
9
+ ### Changed
10
+
11
+ - **Default model downgraded to `gemini-2.0-flash`** β€” `NOTEGUARD_MODEL` default in
12
+ `agent/graph.py` and `eval/run_eval.py` changed from `gemini-2.5-flash` to
13
+ `gemini-2.0-flash`; `.env.example` updated to match.
14
+
15
+ ### Added
16
+
17
+ - **HF Space auto-deploy** (`.github/workflows/deploy-hf.yml`) β€” every push to `main`
18
+ mirrors the repo onto the HF Space `chaeyoona/noteguard-agent` as an orphan commit,
19
+ triggering a Docker rebuild on port 7860. Needs the `HF_TOKEN` repo secret. Orphan
20
+ strategy avoids the historical `docs/init.png` blob that HF's binary-in-history
21
+ check rejects.
22
+
23
+ ### Removed
24
+
25
+ - `docs/CHANGELOG.md` β€” duplicate of the root `CHANGELOG.md`.
26
+ - `docs/plan.md` β€” historical planning doc; no longer relevant post-1.0.
27
+ - `outputs/.gitkeep` β€” unused placeholder directory.
28
+
29
+ ### Changed (CI)
30
+
31
+ - GitHub Actions Python matrix trimmed to **3.10 + 3.12** (intermediate versions
32
+ removed).
33
+
34
+ ---
35
+
36
+ ## [1.0.0] - 2026-06-27
37
+
38
+ First post-hackathon release. The codebase is pruned to exactly the components
39
+ that ship in the deployed Hugging Face Space; sponsor-only integrations that never
40
+ ran in the deployed image are removed.
41
+
42
+ ### Removed
43
+
44
+ - **Superlinked retrieval** (`src/retrieve.py`, `[retrieval]` optional dependency,
45
+ and the `retrieve_context` graph node) β€” the in-memory vector index was excluded
46
+ from the Docker image and lazy-imported behind a graceful fallback, so it never
47
+ executed in the deployed app. Removing it deletes the `retrieved_context` state
48
+ field and the per-request demo index seeding.
49
+ - **n8n workflow** (`workflows/noteguard.n8n.json`) β€” a three-node proxy
50
+ (Webhook β†’ HTTP Request β†’ Respond) to NoteGuard's own REST API. It added no logic
51
+ and sat off the runtime path; any automation platform can still call the REST API.
52
+
53
+ ### Changed
54
+
55
+ - **Faithfulness judge now scores against the de-identified source note** (`deid_text`)
56
+ instead of Superlinked-retrieved context. The score was previously gated on retrieval,
57
+ so it never populated in the deployed app (which had no retrieval); it now produces a
58
+ live number for every request and matches the definition used by `eval/run_eval.py`.
59
+ - `agent/graph.py`: pipeline simplified to
60
+ `deidentify_in β†’ agent β†’ reidentify_out β†’ compute_trust`; `build_graph()` no longer
61
+ takes a `note_index` argument.
62
+ - `app/api.py`: `/process` reports `faithfulness` whenever a de-identified note exists;
63
+ FastAPI app version bumped to `1.0.0`.
64
+ - `Dockerfile`: dependency comment updated β€” the image no longer "omits" Superlinked,
65
+ it is simply not a dependency.
66
+ - `Makefile`: modernised to the real toolchain β€” installs via `pip install -e .`
67
+ (no `requirements.txt`), lints/formats with `ruff` (not `black`), uses the `src`
68
+ package and `src/fetch_dataset.py`.
69
+ - `pyproject.toml`: version bumped to `1.0.0`; `superlinked` removed from keywords;
70
+ `[retrieval]` optional-dependency group dropped.
71
+
72
+ ---
73
+
74
+ ## [0.2.0] - 2026-06-27
75
+
76
+ ### Added
77
+
78
+ - **Clinician web UI** (`app/static/index.html`) β€” single-file, vanilla JS, no build
79
+ step. NHS dark-blue header, segmented toggle (Clinician view / What the AI sees),
80
+ PHI highlighted in red in the clinician view, `[TYPE_N]` monospace chips in the AI
81
+ view, discharge summary pane, trust-panel metric cards.
82
+ - **`POST /process` endpoint** (`app/api.py`) β€” returns `clinician_note`, `ai_note`,
83
+ `identifiers` (original strings for highlighting), `discharge_summary`, and
84
+ `metrics` (`identifiers_removed`, `residual_risk`, `grounded_sources`,
85
+ `faithfulness`).
86
+ - **`GET /`** β€” FastAPI serves `app/static/index.html` directly; no separate static
87
+ server needed.
88
+ - **`StaticFiles` mount** at `/static` β€” allows future CSS/JS assets alongside the
89
+ single-page UI.
90
+ - **n8n workflow** (`workflows/noteguard.n8n.json`) β€” importable three-node workflow
91
+ (Webhook β†’ HTTP Request β†’ Respond to Webhook) that routes ward notes through the
92
+ NoteGuard API without the model ever seeing PHI.
93
+
94
+ - **Vercel deployment** (`api/index.py`, `api/requirements.txt`, `vercel.json`) β€”
95
+ the FastAPI app is deployable as a serverless Vercel function. Light dep set
96
+ omits superlinked/torch to stay under the 250 MB bundle limit; retrieval falls
97
+ back gracefully to Gemini-only mode.
98
+
99
+ ### Changed
100
+
101
+ - `app/api.py` now also serves the clinician web UI in addition to the REST API.
102
+ - `agent/graph.py`: `NoteIndex` import made lazy (inside try block) so the module
103
+ loads cleanly in environments where superlinked is unavailable (CI, Vercel).
104
+ - `noteguard/__init__.py`: removed `NoteIndex` re-export; only `NoteGuard`,
105
+ `DeidResult`, and `load_known_from_csv` are exported from the package.
106
+ - `Makefile` `run` target now starts uvicorn (`uvicorn app.api:app --reload --port 8000`)
107
+ instead of Streamlit.
108
+ - `pyproject.toml` version bumped to 0.2.0; `per-file-ignores` added for E402
109
+ (intentional `load_dotenv()` before API-key-consuming imports).
110
+
111
+ ### Removed
112
+
113
+ - **`app/trust_panel.py`** β€” Streamlit demo UI retired; superseded by the
114
+ single-file clinician web UI (`app/static/index.html`) served by FastAPI.
115
+ - **`streamlit`** removed from `requirements.txt`.
116
+
117
+ ---
118
+
119
+ ## [0.1.0] - 2026-06-27
120
+
121
+ ### Added
122
+
123
+ - **De-identification core** (`noteguard/deid.py`) β€” dependency-free NHS-aware
124
+ pipeline: NHS number, GMC/NMC, postcode, DOB, email and phone recognisers;
125
+ vault-from-CSV for ground-truth measurement; consistent surrogates;
126
+ `assert_clean()` hard guarantee; `reidentify()` for clinician-only restoration.
127
+ - **Superlinked retrieval node** (`noteguard/retrieve.py`) β€” in-memory vector
128
+ index (`sentence-transformers/all-MiniLM-L6-v2`) with `assert_clean()` called
129
+ on every document in and every retrieved chunk out.
130
+ - **LangGraph agent** (`agent/graph.py`) β€” full pipeline:
131
+ `deidentify_in β†’ retrieve_context β†’ agent (Gemini + Tavily) β†’ reidentify_out β†’ compute_trust`.
132
+ Trust metrics surfaced in graph state: identifiers removed, residual leakage,
133
+ faithfulness score, source URLs.
134
+ - **Streamlit trust panel** (`app/trust_panel.py`) β€” three-way toggle
135
+ (raw / de-identified / clinician answer) and live trust panel; styled to the
136
+ NHS England identity.
137
+ - **LangSmith evaluations** (`eval/run_eval.py`) β€” `zero_phi_to_model` (must
138
+ score 1.0) and `faithfulness` (LLM-as-judge over de-identified text only).
139
+ - Gold RAP packaging: `pyproject.toml`, `Makefile`, `CHANGELOG.md`,
140
+ `CONTRIBUTING.md`, `.pre-commit-config.yaml`, `.editorconfig`, CI workflow,
141
+ and `docs/` (architecture, user guide, RAP compliance).
CLAUDE.md ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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` scores faithfulness (LLM-as-judge) of the answer against the
20
+ de-identified source note β€” it never sees PHI.
21
+
22
+ ## Key files
23
+ - `src/deid.py` β€” de-id core (std-lib only): NHS-aware rules + vault-from-CSV,
24
+ consistent surrogates, DOB date-shift, `reidentify`, `assert_clean`. Keep it
25
+ dependency-free; Presidio/spaCy are optional behind the same interface.
26
+ - `agent/graph.py` β€” the graph; exposed as `noteguard` for `langgraph dev`.
27
+ - `app/api.py` β€” FastAPI backend: `GET /` (serves index.html), `GET /health`,
28
+ `POST /process` (full UI payload), `POST /summarise` (compact legacy).
29
+ - `app/static/index.html` β€” single-file clinician web UI (vanilla JS, no build step).
30
+ - `streamlit_app.py` β€” Streamlit demo (no API keys required; rule layer only).
31
+ - `Dockerfile` β€” HF Spaces Docker config; uvicorn on port 7860.
32
+ - `eval/run_eval.py` β€” LangSmith evals: `zero_phi_to_model` (must be 1.0) + `faithfulness`.
33
+ - `langgraph.json`, `.env.example`.
34
+ - `docs/tool_card.md` β€” Five Safes, bias & fairness, use cases out of scope.
35
+ - `docs/report.md` β€” ATRS Tier 1 + Tier 2 record.
36
+
37
+ ## Commands
38
+ - De-id demo (no keys): `python src/deid.py`
39
+ - Install: `pip install -e ".[dev]"`
40
+ - Clinician web UI: `uvicorn app.api:app --reload --port 8000` (or `make run`)
41
+ then open http://localhost:8000
42
+ - Serve agent (Agent Chat UI): `langgraph dev`
43
+ then open: https://smith.langchain.com/studio/?baseUrl=http://127.0.0.1:2024
44
+ - Evals: `python -m eval.run_eval`
45
+ - Deploy: push to `main` β†’ `.github/workflows/deploy-hf.yml` mirrors it onto the HF
46
+ Space `chaeyoona/noteguard-agent` (needs `HF_TOKEN` repo secret); HF rebuilds from
47
+ `Dockerfile` (Docker SDK, `app_port: 7860`).
48
+ - Env: copy `.env.example` β†’ `.env`; fill `GOOGLE_API_KEY`, `TAVILY_API_KEY`,
49
+ `LANGSMITH_API_KEY`; set `LANGSMITH_TRACING=true`.
50
+
51
+ ## Components
52
+ Gemini (reasoning) + Tavily (public-guidance grounding) are the external services.
53
+ LangGraph orchestrates the pipeline; LangSmith provides traces + evals. Superlinked
54
+ (retrieval) and n8n (a proxy workflow) were hackathon-era integrations that never ran
55
+ in the deployed Space and were removed in `1.0`.
56
+
57
+ ## Dataset
58
+ `NHSEDataScience/synthetic_clinical_notes` (Hugging Face, MIT, fully synthetic).
59
+ Build the vault from `patients.csv` via `load_known_from_csv()` so leakage is
60
+ measured against ground truth. Dataset has mojibake β€” `_fix_mojibake` handles it.
61
+
62
+ ## Conventions
63
+ - Python 3.10+. Keep `src/deid.py` std-lib only.
64
+ - Verify/round any number shown in the demo.
65
+ - Model id lives in `NOTEGUARD_MODEL` (default `google_genai:gemini-2.5-flash`).
66
+ - Versions drift: if a `langgraph`/`langsmith`/`create_react_agent` import fails,
67
+ adjust to the installed version rather than pinning blindly.
68
+ - `src/__init__.py` exports only `NoteGuard`, `DeidResult`, `load_known_from_csv`.
69
+ - `pyproject.toml` is the single dependency source; `requirements.txt` is removed.
70
+ - Linting: `ruff check` + `ruff format` (not black).
71
+
72
+ ## HF Spaces notes
73
+ - The Docker image installs only the runtime deps in `pyproject.toml`; `streamlit`
74
+ and `dev` extras are not part of the deployed image.
75
+ - Set `GOOGLE_API_KEY`, `TAVILY_API_KEY`, `LANGSMITH_API_KEY` in Space Secrets.
76
+
77
+ ## Ethics
78
+ Pseudonymised β‰  anonymous (still personal data under UK GDPR). Synthetic β‰  real β€”
79
+ 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,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ RUN pip install --no-cache-dir \
9
+ "fastapi>=0.109.0" \
10
+ "uvicorn[standard]>=0.27.0" \
11
+ "langgraph>=0.2.0" \
12
+ "langchain>=0.3.0" \
13
+ "langchain-google-genai>=2.0.0" \
14
+ "langchain-tavily>=0.1.0" \
15
+ "langsmith>=0.1.0" \
16
+ "python-dotenv>=1.0.0" \
17
+ "huggingface_hub>=0.20.0"
18
+
19
+ COPY . .
20
+
21
+ # Bake the synthetic dataset into the image so /samples works without runtime downloads
22
+ RUN python src/fetch_dataset.py
23
+
24
+ ENV PYTHONUNBUFFERED=1
25
+ EXPOSE 7860
26
+
27
+ 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,238 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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:
48
+ leakage % Β· identifiers removed
49
+ leaked tokens Β· faithfulness Β· sources
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` | Extracts Tavily sources and scores faithfulness (LLM-as-judge over the de-identified note). |
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
+ "person_id": "pt-001"
152
+ }
153
+ ```
154
+
155
+ Response fields:
156
+
157
+ | Field | Description |
158
+ |---|---|
159
+ | `clinician_note` | Verbatim input note |
160
+ | `ai_note` | De-identified note the model saw (surrogate tokens) |
161
+ | `identifiers` | Original identifier strings that were redacted |
162
+ | `discharge_summary` | Gemini-drafted compact eDischarge card, re-identified for the clinician |
163
+ | `metrics.identifiers_removed` | Count of identifiers replaced |
164
+ | `metrics.residual_risk` | `0.0` when privacy guarantee held; fractional or `1.0` when leaks detected |
165
+ | `metrics.leaked_tokens` | List of unmapped/unresolved surrogate tokens detected post-model |
166
+ | `metrics.grounded_sources` | Distinct Tavily sources cited |
167
+ | `metrics.faithfulness` | LLM-judge score `0–1`: is every claim in the summary supported by the de-identified note? |
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
+
202
+ **Required secrets** (Space β†’ Settings β†’ Variables and secrets):
203
+
204
+ - `GOOGLE_API_KEY`
205
+ - `TAVILY_API_KEY`
206
+ - `LANGSMITH_API_KEY` (optional β€” enables tracing)
207
+
208
+ **Auto-deploy:** [`.github/workflows/deploy-hf.yml`](.github/workflows/deploy-hf.yml)
209
+ pushes a fresh snapshot of `main` onto the Space (`chaeyoona/noteguard-agent`) on
210
+ every push, which triggers a Docker rebuild. (A snapshot β€” a single orphan commit β€”
211
+ is used so historical binary blobs that HF's git backend rejects are never sent.)
212
+ It needs an `HF_TOKEN` repository secret β€” a write-scoped
213
+ [Hugging Face access token](https://huggingface.co/settings/tokens) added under
214
+ **Settings β†’ Secrets and variables β†’ Actions**. Trigger the first deploy manually via
215
+ the workflow's **Run workflow** button once the secret is set.
216
+
217
+ ---
218
+
219
+ ## Data
220
+
221
+ `NHSEDataScience/synthetic_clinical_notes` (Hugging Face, MIT licence, fully synthetic).
222
+
223
+ ```bash
224
+ python src/fetch_dataset.py # downloads patients.csv, admissions.csv, synthetic_clinical_notes.csv into data/
225
+ ```
226
+
227
+ `load_known_from_csv("data/patients.csv", "data/admissions.csv")` builds the
228
+ identifier vault from both structured tables β€” patient names and clinician names β€”
229
+ so residual leakage is measured against ground truth.
230
+
231
+ ---
232
+
233
+ ## Ethics
234
+
235
+ - Pseudonymised β‰  anonymous β€” still personal data under UK GDPR; don't over-claim.
236
+ - Synthetic β‰  real β€” frame as methodology, not a finished product.
237
+ - Clinician stays in the loop and signs off every summary.
238
+ - 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.0.0
agent/__init__.py ADDED
File without changes
agent/graph.py ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 json
20
+ import os
21
+ import re
22
+
23
+ from dotenv import load_dotenv
24
+
25
+ load_dotenv(override=True) # pick up .env before any os.getenv / API-key validation
26
+
27
+ from langchain.chat_models import init_chat_model
28
+ from langchain_core.messages import AIMessage, HumanMessage
29
+ from langgraph.graph import END, START, MessagesState, StateGraph
30
+ from langgraph.prebuilt import create_react_agent
31
+
32
+ try:
33
+ from langchain_tavily import TavilySearch
34
+ except ImportError: # older package name
35
+ from langchain_community.tools.tavily_search import TavilySearchResults as TavilySearch
36
+
37
+ from src.deid import NoteGuard
38
+
39
+ SYSTEM = """\
40
+ You are a clinical documentation assistant for NHS clinicians.
41
+ Your ONLY output is a compact discharge-summary card. Reproduce EXACTLY the format below β€” \
42
+ no headings, no bullets, no preamble, no sign-off.
43
+
44
+ ## De-identification rules β€” NEVER violate
45
+ You only ever see DE-IDENTIFIED text. Patient identifiers β€” names, NHS numbers, dates of birth,
46
+ addresses, GP names, consultant names β€” have been replaced with surrogate tokens such as
47
+ [PERSON_1], [NHS_1], [DOB_1], [ADDRESS_1], [DATE_1].
48
+ Preserve every surrogate token exactly as given. A re-identification step restores real values
49
+ for the clinician after you respond. Never invent, guess, or expand a surrogate into a real value.
50
+ Never write the literal text of a surrogate token (e.g. [PERSON_1]) in the title line β€” \
51
+ use {{PATIENT}} there instead (see below).
52
+
53
+ ## Output format β€” four elements, blank line between each
54
+
55
+ {{PATIENT}} β€” discharge summary
56
+
57
+ Admitted [DATE_X] after <reason>. Background: <key conditions/meds>. <what was done>. <key finding>.
58
+
59
+ Follow-up: <GP action> Β· <action 2> Β· <action 3>
60
+
61
+ Grounded: <source name 1>, <source name 2> Β· via Tavily
62
+
63
+ ## Rules for each element
64
+
65
+ **Title line:** write exactly `{{PATIENT}} β€” discharge summary`. \
66
+ The placeholder {{PATIENT}} is resolved to the real patient name by the system β€” \
67
+ you must never write a real name, a surrogate token, or any other identifier there.
68
+
69
+ **Narrative paragraph:** plain clinical prose, max 4 sentences. Include only facts stated in \
70
+ the source note β€” never invent investigations, doses, dates, or diagnoses. \
71
+ Surrogate tokens ([DATE_1], [PERSON_1], etc.) may appear here and will be restored. \
72
+ Drop a sentence entirely when there is nothing to say (e.g. no imaging β†’ omit that sentence).
73
+
74
+ **Follow-up line:** items separated by " Β· " (middle dot U+00B7). \
75
+ Always include the GP action as the first item.
76
+
77
+ **Grounded line:** list only the public guidance sources (short readable names, not URLs) \
78
+ actually returned by the Tavily search tool this run. \
79
+ If Tavily returned no results, omit the Grounded line entirely β€” never fabricate citations.
80
+
81
+ **Search tool:** use only for public NICE/NHS clinical guidance. \
82
+ Never send patient text or surrogate tokens to the search tool.
83
+ """
84
+
85
+
86
+ class State(MessagesState):
87
+ forward: dict
88
+ reverse: dict
89
+ clinician_answer: str
90
+ person_name: str # resolved from person_id β€” fills {{PATIENT}} in title
91
+ # --- trust panel fields ---
92
+ deid_text: str # de-identified note text (what the AI saw)
93
+ identifiers_removed: int # identifiers replaced in this turn
94
+ residual_count: int # known identifiers that survived de-id (pre-model)
95
+ leaked_tokens: list # tokens/patterns that slipped through (post-model)
96
+ faithfulness_score: float # LLM-as-judge: 0–1
97
+ sources: list # Tavily URLs cited in the answer
98
+
99
+
100
+ def build_graph(known: dict | None = None):
101
+ model = init_chat_model(os.getenv("NOTEGUARD_MODEL", "google_genai:gemini-2.5-flash"))
102
+ tools = [TavilySearch(max_results=3)]
103
+ react = create_react_agent(model, tools, prompt=SYSTEM)
104
+
105
+ def deidentify_in(state: State):
106
+ prior_n = len(state.get("forward") or {})
107
+ ng = NoteGuard(known=known, forward=state.get("forward"), reverse=state.get("reverse"))
108
+ last = state["messages"][-1]
109
+ if not isinstance(last, HumanMessage):
110
+ return {"forward": ng.forward, "reverse": ng.reverse}
111
+ res = ng.deidentify(last.content)
112
+ ng.assert_clean(res.clean_text) # hard guarantee before model/tool see anything
113
+ cleaned = HumanMessage(content=res.clean_text, id=last.id)
114
+ return {
115
+ "messages": [cleaned],
116
+ "forward": ng.forward,
117
+ "reverse": ng.reverse,
118
+ "deid_text": res.clean_text,
119
+ "identifiers_removed": len(ng.forward) - prior_n,
120
+ "residual_count": len(res.residual),
121
+ }
122
+
123
+ def run_agent(state: State):
124
+ out = react.invoke({"messages": state["messages"]})
125
+ return {"messages": out["messages"][len(state["messages"]) :]}
126
+
127
+ def reidentify_out(state: State):
128
+ ng = NoteGuard(reverse=state.get("reverse"))
129
+ last = state["messages"][-1]
130
+ if not isinstance(last, AIMessage):
131
+ return {"clinician_answer": "", "leaked_tokens": []}
132
+ content = last.content
133
+ # Gemini can return content as a list of blocks [{type, text}, ...]
134
+ if isinstance(content, list):
135
+ raw_text = " ".join(
136
+ block.get("text", "") if isinstance(block, dict) else str(block) for block in content
137
+ ).strip()
138
+ else:
139
+ raw_text = content or ""
140
+
141
+ # Check model output for orphaned tokens BEFORE reidentify restores known ones
142
+ reverse = state.get("reverse") or {}
143
+ leaked: list[str] = []
144
+ for m in re.finditer(r"\[[A-Z]+_\d+\]", raw_text):
145
+ tok = m.group(0)
146
+ if tok not in reverse:
147
+ leaked.append(f"unmapped_token:{tok}")
148
+
149
+ # Restore known surrogates
150
+ restored = ng.reidentify(raw_text)
151
+
152
+ # Replace {{PATIENT}} with the structured patient name (never from model)
153
+ person_name = state.get("person_name") or "Patient"
154
+ restored = restored.replace("{{PATIENT}}", person_name)
155
+
156
+ # Replace any remaining [LABEL_n] that reidentify couldn't resolve β€” flag each
157
+ def _replace_leftover(m: re.Match) -> str:
158
+ tok = m.group(0)
159
+ leaked.append(f"unresolved_token:{tok}")
160
+ return "[redacted]"
161
+
162
+ restored = re.sub(r"\[[A-Z]+_\d+\]", _replace_leftover, restored)
163
+
164
+ return {"clinician_answer": restored, "leaked_tokens": leaked}
165
+
166
+ def compute_trust(state: State):
167
+ """Extract Tavily sources and compute faithfulness (LLM-as-judge).
168
+
169
+ The faithfulness judge compares the de-identified AI answer against
170
+ the de-identified source note (deid_text) β€” it never sees PHI.
171
+ """
172
+ # --- Tavily sources from ToolMessages ---
173
+ sources: list[str] = []
174
+ for msg in state["messages"]:
175
+ content = getattr(msg, "content", None)
176
+ if not content:
177
+ continue
178
+ try:
179
+ items = json.loads(content) if isinstance(content, str) else content
180
+ if isinstance(items, list):
181
+ for item in items:
182
+ if isinstance(item, dict) and item.get("url"):
183
+ sources.append(item["url"])
184
+ except (json.JSONDecodeError, TypeError):
185
+ pass
186
+
187
+ # --- faithfulness: judge de-identified answer vs de-identified source note ---
188
+ score = 0.0
189
+ last_ai = next((m for m in reversed(state["messages"]) if isinstance(m, AIMessage)), None)
190
+ context = state.get("deid_text") or ""
191
+ if last_ai and context:
192
+ ai_content = last_ai.content
193
+ if isinstance(ai_content, list):
194
+ ai_content = " ".join(
195
+ b.get("text", "") if isinstance(b, dict) else str(b) for b in ai_content
196
+ ).strip()
197
+ prompt = (
198
+ f"CONTEXT (de-identified source note):\n{context}\n\n"
199
+ f"ANSWER:\n{ai_content}\n\n"
200
+ "Is every clinical claim in ANSWER supported by CONTEXT? "
201
+ "Reply with a single number between 0 and 1."
202
+ )
203
+ try:
204
+ raw = model.invoke(prompt).content
205
+ if isinstance(raw, list):
206
+ raw = " ".join(b.get("text", "") if isinstance(b, dict) else str(b) for b in raw)
207
+ score = max(0.0, min(1.0, float(raw.strip().split()[0])))
208
+ except Exception:
209
+ score = 0.0
210
+
211
+ # Merge leaked_tokens from reidentify_out with any new findings
212
+ leaked = list(state.get("leaked_tokens") or [])
213
+ residual_extra = state.get("residual_count", 0)
214
+
215
+ return {
216
+ "sources": list(dict.fromkeys(filter(None, sources))),
217
+ "faithfulness_score": score,
218
+ "leaked_tokens": leaked,
219
+ # Bump residual_count so the API's risk calculation sees the truth
220
+ "residual_count": residual_extra + len(leaked),
221
+ }
222
+
223
+ g = StateGraph(State)
224
+ g.add_node("deidentify_in", deidentify_in)
225
+ g.add_node("agent", run_agent)
226
+ g.add_node("reidentify_out", reidentify_out)
227
+ g.add_node("compute_trust", compute_trust)
228
+ g.add_edge(START, "deidentify_in")
229
+ g.add_edge("deidentify_in", "agent")
230
+ g.add_edge("agent", "reidentify_out")
231
+ g.add_edge("reidentify_out", "compute_trust")
232
+ g.add_edge("compute_trust", END)
233
+ return g.compile()
234
+
235
+
236
+ # Demo vault β€” seeds the known-identifier set so `langgraph dev` / the web UI
237
+ # resolve surrogates consistently on startup.
238
+ _DEMO_KNOWN = {"PERSON": ["Margaret Okafor"], "NHS": ["485 777 3456"]}
239
+ graph = build_graph(known=_DEMO_KNOWN)
app/__init__.py ADDED
File without changes
app/api.py ADDED
@@ -0,0 +1,295 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.0.0")
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
+ _PATIENT_NAMES: dict[str, str] = {} # person_id -> full_name for {{PATIENT}} resolution
48
+
49
+ try:
50
+ _patients_csv = str(_DATA_DIR / "patients.csv")
51
+ _admissions_csv = str(_DATA_DIR / "admissions.csv")
52
+ _DEFAULT_KNOWN = load_known_from_csv(_patients_csv, _admissions_csv)
53
+
54
+ # Build person_id β†’ name lookup for {{PATIENT}} resolution
55
+ with open(_patients_csv, newline="", encoding="utf-8-sig") as _pf:
56
+ for _row in csv.DictReader(_pf):
57
+ _pid = (_row.get("person_id") or "").strip()
58
+ _name = (
59
+ _row.get("full_name")
60
+ or _row.get("patient_name")
61
+ or f"{_row.get('first_name', '').strip()} {_row.get('surname', '').strip()}".strip()
62
+ )
63
+ if _pid and _name:
64
+ _PATIENT_NAMES[_pid] = _name
65
+
66
+ with open(_DATA_DIR / "synthetic_clinical_notes.csv", newline="", encoding="utf-8-sig") as _f:
67
+ for _row in csv.DictReader(_f):
68
+ _text = NoteGuard._fix_mojibake(_row["clean_note_text"])
69
+ _NOTES.append(
70
+ {
71
+ "clinical_note_id": _row["clinical_note_id"],
72
+ "person_id": _row["person_id"],
73
+ "note_type": _row.get("note_type", ""),
74
+ "note_subject": _row.get("note_subject", ""),
75
+ "excerpt": _text[:120].strip(),
76
+ "note_text": _text,
77
+ }
78
+ )
79
+ except Exception:
80
+ pass # data/ not present β€” /samples returns empty, /process still works
81
+
82
+ # ---------------------------------------------------------------------------
83
+ # Per-vault graph cache β€” key is a hashable snapshot of the known-identifier dict.
84
+ # ---------------------------------------------------------------------------
85
+
86
+ _graph_cache: dict = {}
87
+
88
+
89
+ def _vault_key(known: dict | None) -> tuple | None:
90
+ if not known:
91
+ return None
92
+ return tuple(sorted((k, tuple(sorted(v))) for k, v in known.items()))
93
+
94
+
95
+ def _get_graph(known: dict | None):
96
+ """Return a compiled NoteGuard graph, building it once per distinct vault."""
97
+ key = _vault_key(known)
98
+ if key not in _graph_cache:
99
+ from agent.graph import build_graph
100
+
101
+ _graph_cache[key] = build_graph(known=known)
102
+ return _graph_cache[key]
103
+
104
+
105
+ # ---------------------------------------------------------------------------
106
+ # Models
107
+ # ---------------------------------------------------------------------------
108
+
109
+
110
+ class SummariseRequest(BaseModel):
111
+ note: str
112
+ question: str = "Draft an NHS eDischarge summary."
113
+ known: dict | None = None
114
+
115
+
116
+ class SummariseResponse(BaseModel):
117
+ clinician_answer: str
118
+ identifiers_removed: int
119
+ residual_risk: float
120
+ deidentified_excerpt: str
121
+ ok: bool
122
+
123
+
124
+ class ProcessRequest(BaseModel):
125
+ note: str
126
+ question: str = "Draft an NHS eDischarge summary."
127
+ known: dict | None = None
128
+ person_id: str | None = None # when set, {{PATIENT}} resolves to the real name
129
+
130
+
131
+ class ProcessResponse(BaseModel):
132
+ clinician_note: str
133
+ ai_note: str
134
+ identifiers: list[str]
135
+ discharge_summary: str
136
+ metrics: dict
137
+
138
+
139
+ class SampleItem(BaseModel):
140
+ clinical_note_id: str
141
+ person_id: str
142
+ note_type: str
143
+ excerpt: str
144
+
145
+
146
+ class SamplesResponse(BaseModel):
147
+ total: int
148
+ items: list[SampleItem]
149
+
150
+
151
+ class SampleDetail(BaseModel):
152
+ clinical_note_id: str
153
+ person_id: str
154
+ note_type: str
155
+ note_subject: str
156
+ note_text: str
157
+
158
+
159
+ # ---------------------------------------------------------------------------
160
+ # Endpoints
161
+ # ---------------------------------------------------------------------------
162
+
163
+
164
+ @app.get("/")
165
+ def index():
166
+ return FileResponse(STATIC_DIR / "index.html")
167
+
168
+
169
+ @app.get("/health")
170
+ def health():
171
+ """Liveness probe β€” no API keys required."""
172
+ return {"status": "ok", "notes_loaded": len(_NOTES)}
173
+
174
+
175
+ @app.get("/samples", response_model=SamplesResponse)
176
+ def samples(
177
+ limit: int = Query(50, ge=1, le=200),
178
+ offset: int = Query(0, ge=0),
179
+ q: str = Query(""),
180
+ note_type: str = Query(""),
181
+ ):
182
+ """Paginated list of synthetic notes with optional text/type filter."""
183
+ hits = _NOTES
184
+ if note_type:
185
+ hits = [n for n in hits if n["note_type"] == note_type]
186
+ if q:
187
+ ql = q.lower()
188
+ hits = [n for n in hits if ql in n["note_text"].lower() or ql in n["note_subject"].lower()]
189
+ page = hits[offset : offset + limit]
190
+ return SamplesResponse(
191
+ total=len(hits),
192
+ items=[
193
+ SampleItem(
194
+ clinical_note_id=n["clinical_note_id"],
195
+ person_id=n["person_id"],
196
+ note_type=n["note_type"],
197
+ excerpt=n["excerpt"],
198
+ )
199
+ for n in page
200
+ ],
201
+ )
202
+
203
+
204
+ @app.get("/sample/random", response_model=SampleDetail)
205
+ def sample_random():
206
+ """Return one random synthetic note."""
207
+ if not _NOTES:
208
+ raise HTTPException(status_code=404, detail="No notes loaded β€” run src/fetch_dataset.py first.")
209
+ note = random.choice(_NOTES)
210
+ return SampleDetail(**{k: note[k] for k in SampleDetail.model_fields})
211
+
212
+
213
+ @app.get("/sample/{clinical_note_id}", response_model=SampleDetail)
214
+ def sample_by_id(clinical_note_id: str):
215
+ """Return a single synthetic note by its clinical_note_id."""
216
+ for note in _NOTES:
217
+ if note["clinical_note_id"] == clinical_note_id:
218
+ return SampleDetail(**{k: note[k] for k in SampleDetail.model_fields})
219
+ raise HTTPException(status_code=404, detail=f"Note {clinical_note_id!r} not found.")
220
+
221
+
222
+ @app.post("/summarise", response_model=SummariseResponse)
223
+ def summarise(req: SummariseRequest):
224
+ """Run the NoteGuard agent and return a PHI-safe discharge summary.
225
+
226
+ Raises:
227
+ HTTPException 422: assert_clean() detected surviving PHI.
228
+ HTTPException 500: unexpected agent error.
229
+ """
230
+ known = req.known if req.known is not None else _DEFAULT_KNOWN
231
+ try:
232
+ g = _get_graph(known)
233
+ state = g.invoke({"messages": [HumanMessage(content=req.note + "\n\n" + req.question)]})
234
+ except ValueError as exc:
235
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
236
+ except Exception as exc:
237
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
238
+
239
+ residual = state.get("residual_count", 0)
240
+ return SummariseResponse(
241
+ clinician_answer=state.get("clinician_answer", ""),
242
+ identifiers_removed=len(state.get("forward", {})),
243
+ residual_risk=0.0 if residual == 0 else 1.0,
244
+ deidentified_excerpt=(state.get("deid_text") or "")[:400],
245
+ ok=residual == 0,
246
+ )
247
+
248
+
249
+ @app.post("/process", response_model=ProcessResponse)
250
+ def process(req: ProcessRequest):
251
+ """Run NoteGuard and return rich output for the clinician UI.
252
+
253
+ When req.known is omitted, uses the pre-built vault from data/patients.csv
254
+ so residual-leakage is measured against ground truth identifiers.
255
+ """
256
+ known = req.known if req.known is not None else _DEFAULT_KNOWN
257
+ person_name = _PATIENT_NAMES.get(req.person_id, "Patient") if req.person_id else "Patient"
258
+ try:
259
+ g = _get_graph(known)
260
+ state = g.invoke(
261
+ {
262
+ "messages": [HumanMessage(content=req.note + "\n\n" + req.question)],
263
+ "person_name": person_name,
264
+ }
265
+ )
266
+ except ValueError as exc:
267
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
268
+ except Exception as exc:
269
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
270
+
271
+ forward = state.get("forward") or {}
272
+ residual = state.get("residual_count", 0)
273
+ leaked = state.get("leaked_tokens") or []
274
+ faith = state.get("faithfulness_score", 0.0)
275
+ has_leak = residual > 0 or bool(leaked)
276
+
277
+ return ProcessResponse(
278
+ clinician_note=req.note,
279
+ ai_note=state.get("deid_text", ""),
280
+ identifiers=list(forward.keys()),
281
+ discharge_summary=state.get("clinician_answer", ""),
282
+ metrics={
283
+ "identifiers_removed": len(forward),
284
+ "residual_risk": 0.0
285
+ if not has_leak
286
+ else min(1.0, (residual + len(leaked)) / max(len(forward), 1)),
287
+ "grounded_sources": len(state.get("sources") or []),
288
+ "faithfulness": faith if state.get("deid_text") else None,
289
+ "leaked_tokens": leaked,
290
+ },
291
+ )
292
+
293
+
294
+ if STATIC_DIR.exists():
295
+ app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
app/static/index.html ADDED
@@ -0,0 +1,585 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 -->
271
+ <div class="trust-row">
272
+ <div class="metric" id="mRisk">
273
+ <div class="mlabel">Re-id risk Β· model input</div>
274
+ <div class="mvalue" id="mRiskVal">β€”</div>
275
+ <div id="leakDetail" class="leak-detail" style="display:none"></div>
276
+ </div>
277
+ <div class="metric" id="mIds">
278
+ <div class="mlabel">Identifiers removed</div>
279
+ <div class="mvalue" id="mIdsVal">β€”</div>
280
+ </div>
281
+ <div class="metric" id="mFaith" hidden>
282
+ <div class="mlabel">Faithfulness</div>
283
+ <div class="mvalue" id="mFaithVal">β€”</div>
284
+ </div>
285
+ <div class="metric" id="mSrc">
286
+ <div class="mlabel">Grounded sources</div>
287
+ <div class="mvalue" id="mSrcVal">β€”</div>
288
+ </div>
289
+ </div>
290
+
291
+ <!-- NOTE PICKER MODAL -->
292
+ <div id="pickerOverlay" class="picker-overlay" onclick="if(event.target===this)closePicker()">
293
+ <div class="picker-panel">
294
+ <div class="picker-header">
295
+ <span id="pickerTitle">Synthetic Note Library</span>
296
+ <button class="picker-close" onclick="closePicker()">&#x2715;</button>
297
+ </div>
298
+ <div class="picker-controls">
299
+ <input id="pickerSearch" class="picker-input" type="text"
300
+ placeholder="Search notes…" oninput="debouncedSearch()">
301
+ <select id="pickerType" class="picker-select" onchange="fetchSamples()">
302
+ <option value="">All types</option>
303
+ </select>
304
+ <button class="btn btn-ghost" onclick="pickerShuffle()" style="white-space:nowrap;font-size:13px">&#x1F500; Shuffle</button>
305
+ </div>
306
+ <div id="pickerList" class="picker-list"></div>
307
+ </div>
308
+ </div>
309
+
310
+ <script>
311
+ 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.
312
+
313
+ 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.
314
+
315
+ 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%.
316
+
317
+ CXR: bilateral hyperinflation, no consolidation. Bloods: WBC 11.2, CRP 78. HbA1c 58 mmol/mol. Sputum C&S pending at discharge.
318
+
319
+ 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.
320
+
321
+ Responsible consultant: Dr Sarah Chen, Respiratory Medicine.`;
322
+
323
+ let view = 'clinician';
324
+ let result = null;
325
+ let pickerTimer = null;
326
+ let currentPersonId = null;
327
+ let noteTypesPopulated = false;
328
+
329
+ // ── Startup: check if dataset is present ──────────────────────────────────
330
+ window.addEventListener('DOMContentLoaded', async () => {
331
+ try {
332
+ const h = await fetch('/health').then(r => r.json());
333
+ if (h.notes_loaded > 0) {
334
+ const browseBtn = document.getElementById('browseBtn');
335
+ browseBtn.textContent = `Browse notes (${h.notes_loaded.toLocaleString()})`;
336
+ browseBtn.style.display = '';
337
+ document.getElementById('sampleBtn').style.display = 'none';
338
+ } else {
339
+ document.getElementById('datasetHint').style.display = 'block';
340
+ }
341
+ } catch (_) {
342
+ // server not running yet β€” keep fallback button
343
+ }
344
+ });
345
+
346
+ // ── Picker logic ──────────────────────────────────────────────────────────
347
+ function openPicker() {
348
+ document.getElementById('pickerOverlay').classList.add('open');
349
+ document.getElementById('pickerSearch').value = '';
350
+ document.getElementById('pickerType').value = '';
351
+ document.getElementById('pickerSearch').focus();
352
+ fetchSamples();
353
+ }
354
+
355
+ function closePicker() {
356
+ document.getElementById('pickerOverlay').classList.remove('open');
357
+ }
358
+
359
+ function debouncedSearch() {
360
+ clearTimeout(pickerTimer);
361
+ pickerTimer = setTimeout(fetchSamples, 300);
362
+ }
363
+
364
+ async function fetchSamples() {
365
+ const q = document.getElementById('pickerSearch').value.trim();
366
+ const nt = document.getElementById('pickerType').value;
367
+ const params = new URLSearchParams({ limit: 50 });
368
+ if (q) params.set('q', q);
369
+ if (nt) params.set('note_type', nt);
370
+
371
+ const list = document.getElementById('pickerList');
372
+ list.innerHTML = '<div class="picker-empty">Loading…</div>';
373
+
374
+ try {
375
+ const data = await fetch('/samples?' + params).then(r => r.json());
376
+
377
+ // Update header count
378
+ const filterLabel = (q || nt) ? ' β€” filtered' : '';
379
+ document.getElementById('pickerTitle').textContent =
380
+ data.total.toLocaleString() + ' synthetic notes' + filterLabel;
381
+
382
+ // Populate type dropdown once
383
+ if (!noteTypesPopulated && data.items.length > 0) {
384
+ populateTypes(data.items);
385
+ }
386
+
387
+ if (data.items.length === 0) {
388
+ list.innerHTML = '<div class="picker-empty">No notes match your search.</div>';
389
+ return;
390
+ }
391
+
392
+ list.innerHTML = data.items.map(n =>
393
+ `<div class="picker-row" onclick="selectNote('${n.clinical_note_id}')">` +
394
+ `<div class="ptype">${esc(n.note_type || 'Note')}</div>` +
395
+ `<div class="pexcerpt">${esc(n.excerpt)}</div>` +
396
+ `</div>`
397
+ ).join('');
398
+ } catch (_) {
399
+ list.innerHTML = '<div class="picker-empty">Failed to load notes.</div>';
400
+ }
401
+ }
402
+
403
+ async function populateTypes(items) {
404
+ // Seed from first page; also fetch a wider set to catch rarer types
405
+ try {
406
+ const all = await fetch('/samples?limit=200').then(r => r.json());
407
+ const types = [...new Set(all.items.map(n => n.note_type).filter(Boolean))].sort();
408
+ const sel = document.getElementById('pickerType');
409
+ types.forEach(t => {
410
+ const o = document.createElement('option');
411
+ o.value = t; o.textContent = t;
412
+ sel.appendChild(o);
413
+ });
414
+ noteTypesPopulated = true;
415
+ } catch (_) {}
416
+ }
417
+
418
+ async function pickerShuffle() {
419
+ try {
420
+ const note = await fetch('/sample/random').then(r => r.json());
421
+ applyPickedNote(note);
422
+ } catch (_) {}
423
+ }
424
+
425
+ async function selectNote(id) {
426
+ try {
427
+ const note = await fetch('/sample/' + encodeURIComponent(id)).then(r => r.json());
428
+ applyPickedNote(note);
429
+ } catch (_) {}
430
+ }
431
+
432
+ function applyPickedNote(note) {
433
+ closePicker();
434
+ resetUI();
435
+ currentPersonId = note.person_id || null;
436
+ document.getElementById('noteInput').value = note.note_text;
437
+ }
438
+
439
+ // ── Existing UI logic ─────────────────────────────────────────────────────
440
+ function setView(v) {
441
+ view = v;
442
+ document.getElementById('btnClinician').classList.toggle('active', v === 'clinician');
443
+ document.getElementById('btnAI').classList.toggle('active', v === 'ai');
444
+ if (result) renderNote();
445
+ }
446
+
447
+ function loadSample() {
448
+ resetUI();
449
+ document.getElementById('noteInput').value = SAMPLE;
450
+ }
451
+
452
+ function resetUI() {
453
+ result = null;
454
+ currentPersonId = null;
455
+ document.getElementById('inputArea').style.display = '';
456
+ document.getElementById('noteDisplay').classList.remove('visible');
457
+ document.getElementById('editLink').classList.remove('visible');
458
+ document.getElementById('placeholder').style.display = '';
459
+ document.getElementById('summaryText').classList.remove('visible');
460
+ document.getElementById('poweredBy').classList.remove('visible');
461
+ document.getElementById('mRiskVal').textContent = 'β€”';
462
+ document.getElementById('mRiskVal').className = 'mvalue';
463
+ document.getElementById('mIdsVal').textContent = 'β€”';
464
+ document.getElementById('mSrcVal').textContent = 'β€”';
465
+ document.getElementById('mFaith').hidden = true;
466
+ const leakEl = document.getElementById('leakDetail');
467
+ if (leakEl) { leakEl.textContent = ''; leakEl.style.display = 'none'; }
468
+ }
469
+
470
+ function esc(s) {
471
+ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
472
+ }
473
+
474
+ 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>`;
475
+
476
+ function renderSummary(text) {
477
+ const lines = (text || '').trim().split('\n').map(l => l.trim()).filter(l => l);
478
+ return lines.map(line => {
479
+ if (line.startsWith('Follow-up:')) {
480
+ const rest = esc(line.slice('Follow-up:'.length));
481
+ return `<p class="card-followup"><span class="fu-label">Follow-up:</span>${rest}</p>`;
482
+ }
483
+ if (line.startsWith('Grounded:')) {
484
+ const rest = esc(line.slice('Grounded:'.length));
485
+ return `<p class="card-grounded">${_LINK_ICON}<span>${rest}</span></p>`;
486
+ }
487
+ if (line.includes('β€” discharge summary') || line.includes('- discharge summary')) {
488
+ return `<p class="card-title">${esc(line)}</p>`;
489
+ }
490
+ return `<p>${esc(line)}</p>`;
491
+ }).join('');
492
+ }
493
+
494
+ function highlightPHI(text, ids) {
495
+ if (!ids || !ids.length) return esc(text);
496
+ const sorted = [...ids].sort((a, b) => b.length - a.length);
497
+ let out = '', i = 0;
498
+ while (i < text.length) {
499
+ let hit = false;
500
+ for (const id of sorted) {
501
+ if (text.startsWith(id, i)) {
502
+ out += `<mark class="phi">${esc(id)}</mark>`;
503
+ i += id.length;
504
+ hit = true;
505
+ break;
506
+ }
507
+ }
508
+ if (!hit) { out += esc(text[i]); i++; }
509
+ }
510
+ return out;
511
+ }
512
+
513
+ function renderAI(text) {
514
+ return esc(text).replace(/\[([A-Z]+)_(\d+)\]/g, m => `<span class="chip">${m}</span>`);
515
+ }
516
+
517
+ function renderNote() {
518
+ const el = document.getElementById('noteDisplay');
519
+ el.innerHTML = view === 'clinician'
520
+ ? highlightPHI(result.clinician_note, result.identifiers)
521
+ : renderAI(result.ai_note);
522
+ }
523
+
524
+ async function generate() {
525
+ const note = document.getElementById('noteInput').value.trim();
526
+ if (!note) return;
527
+
528
+ const btn = document.getElementById('genBtn');
529
+ const sp = document.getElementById('spinner');
530
+ btn.disabled = true;
531
+ sp.classList.add('on');
532
+
533
+ try {
534
+ const resp = await fetch('/process', {
535
+ method: 'POST',
536
+ headers: { 'Content-Type': 'application/json' },
537
+ body: JSON.stringify({ note, question: 'Draft an NHS eDischarge summary.', person_id: currentPersonId || undefined })
538
+ });
539
+ if (!resp.ok) {
540
+ const err = await resp.json().catch(() => ({}));
541
+ alert('Error ' + resp.status + ': ' + (err.detail || resp.statusText));
542
+ return;
543
+ }
544
+ result = await resp.json();
545
+
546
+ // Switch left pane to rendered note
547
+ document.getElementById('inputArea').style.display = 'none';
548
+ document.getElementById('noteDisplay').classList.add('visible');
549
+ document.getElementById('editLink').classList.add('visible');
550
+ renderNote();
551
+
552
+ // Right pane
553
+ document.getElementById('placeholder').style.display = 'none';
554
+ const sumEl = document.getElementById('summaryText');
555
+ sumEl.innerHTML = renderSummary(result.discharge_summary);
556
+ sumEl.classList.add('visible');
557
+ document.getElementById('poweredBy').classList.add('visible');
558
+
559
+ // Metrics
560
+ const m = result.metrics;
561
+ const rv = document.getElementById('mRiskVal');
562
+ rv.textContent = (m.residual_risk * 100).toFixed(1) + '%';
563
+ rv.className = 'mvalue ' + (m.residual_risk === 0 ? 'ok' : 'risk');
564
+ document.getElementById('mIdsVal').textContent = m.identifiers_removed;
565
+ document.getElementById('mSrcVal').textContent = m.grounded_sources;
566
+ if (m.faithfulness !== null && m.faithfulness !== undefined) {
567
+ document.getElementById('mFaith').hidden = false;
568
+ document.getElementById('mFaithVal').textContent = (m.faithfulness * 100).toFixed(0) + '%';
569
+ }
570
+ // Leak detail β€” shown when residual_risk > 0
571
+ const leakEl = document.getElementById('leakDetail');
572
+ if (leakEl && m.residual_risk > 0 && m.leaked_tokens && m.leaked_tokens.length) {
573
+ leakEl.textContent = 'Leak detected: ' + m.leaked_tokens.slice(0, 3).join(', ');
574
+ leakEl.style.display = 'block';
575
+ }
576
+ } catch (e) {
577
+ alert('Request failed: ' + e.message);
578
+ } finally {
579
+ btn.disabled = false;
580
+ sp.classList.remove('on');
581
+ }
582
+ }
583
+ </script>
584
+ </body>
585
+ </html>
docs/architecture.md ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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` | LLM-as-judge + source extraction | Faithfulness (answer vs de-identified source note) + source URLs 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
+ | `person_name` | `str` | Real patient name for `{{PATIENT}}` placeholder resolution. |
71
+ | `forward` | `dict` | Original-identifier β†’ surrogate mapping. |
72
+ | `identifiers_removed` | `int` | Count of identifiers replaced in this turn. |
73
+ | `residual_count` | `int` | Known identifiers that survived (target: 0). |
74
+ | `leaked_tokens` | `list[str]` | Unmapped/unresolved surrogate tokens detected post-model. |
75
+ | `clinician_answer` | `str` | Re-identified, clinician-facing answer. |
76
+ | `faithfulness_score` | `float` | LLM-as-judge 0–1 score. |
77
+ | `sources` | `list[str]` | Tavily / NICE / NHS URLs cited. |
78
+
79
+ ## REST API
80
+
81
+ `app/api.py` exposes six endpoints:
82
+
83
+ | Endpoint | Method | Description |
84
+ |---|---|---|
85
+ | `/` | GET | Serves `app/static/index.html` β€” the clinician web UI. |
86
+ | `/health` | GET | Liveness probe; no API keys required. Returns `notes_loaded` count. |
87
+ | `/samples` | GET | Paginated list of synthetic notes; supports `q`, `note_type`, `limit`, `offset`. |
88
+ | `/sample/random` | GET | One random synthetic note. |
89
+ | `/sample/{clinical_note_id}` | GET | Full note by ID (used by the note-picker modal). |
90
+ | `/process` | POST | Full UI payload: `clinician_note`, `ai_note`, `identifiers`, `discharge_summary`, `metrics`. |
91
+ | `/summarise` | POST | Compact payload: `clinician_answer`, `identifiers_removed`, `residual_risk`, `deidentified_excerpt`, `ok`. |
92
+
93
+ `POST /process` request shape:
94
+
95
+ ```json
96
+ {
97
+ "note": "Pt Margaret Okafor (NHS 485 777 3456) admitted post-fall.",
98
+ "question": "Draft a discharge summary.",
99
+ "person_id": "pt-001"
100
+ }
101
+ ```
102
+
103
+ `POST /process` response shape:
104
+
105
+ ```json
106
+ {
107
+ "clinician_note": "verbatim input",
108
+ "ai_note": "de-identified text the model saw ([PERSON_1], [NHS_1], …)",
109
+ "identifiers": ["Margaret Okafor", "485 777 3456", "…"],
110
+ "discharge_summary": "re-identified Gemini compact eDischarge card for the clinician",
111
+ "metrics": {
112
+ "identifiers_removed": 5,
113
+ "residual_risk": 0.0,
114
+ "grounded_sources": 2,
115
+ "faithfulness": 0.9,
116
+ "leaked_tokens": []
117
+ }
118
+ }
119
+ ```
120
+
121
+ `422` is returned when `assert_clean()` detects surviving PHI β€” the model sees nothing.
122
+
123
+ `metrics.residual_risk` is `0.0` when the guarantee holds; fractional (or `1.0`) when
124
+ residual identifiers or leaked surrogate tokens are detected.
125
+
126
+ ## Clinician web UI
127
+
128
+ `app/static/index.html` is a self-contained single-page application (vanilla JS, no build step):
129
+
130
+ - **Note picker modal** β€” browse and filter synthetic notes by keyword and note type;
131
+ clicking a row loads the note into the textarea and sets `person_id` for `{{PATIENT}}` resolution.
132
+ - **Segmented toggle** β€” switches between two views without re-calling the API:
133
+ - *Clinician view*: original note with each redacted identifier wrapped in a red `<mark>`.
134
+ - *What the AI sees*: de-identified note with `[TYPE_N]` surrogate tokens displayed as blue monospace chips.
135
+ - **Generate** β€” POSTs to `/process`, populates the discharge summary pane and trust panel.
136
+ - **Trust panel** β€” metric cards: Re-id risk, Identifiers removed, Faithfulness (answer vs de-identified note), Grounded sources, leaked tokens (shown when non-empty).
137
+
138
+ ## External services
139
+
140
+ | Concern | Service | Notes |
141
+ |---|---|---|
142
+ | Reasoning | Google Gemini | `google_genai:gemini-2.5-flash` (configurable via `NOTEGUARD_MODEL`). |
143
+ | Grounding | Tavily | Public NICE/NHS guidance only β€” patient text never sent. |
144
+ | Observability | LangSmith | Auto-traces when `LANGSMITH_TRACING=true`. |
145
+
146
+ All credentials are read from environment variables; nothing is hard-coded.
147
+
148
+ ## Deployment
149
+
150
+ ### Local (development)
151
+
152
+ ```bash
153
+ pip install -e ".[dev]"
154
+ uvicorn app.api:app --reload --port 8000
155
+ ```
156
+
157
+ ### Hugging Face Spaces (production)
158
+
159
+ `Dockerfile` builds a lean Docker image installing only the runtime dependencies
160
+ declared in `pyproject.toml`, served by uvicorn on port 7860.
161
+
162
+ 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 an optional Presidio/spaCy NLP layer. 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, optional Presidio/spaCy).
28
+
29
+ ### 2. Description and rationale
30
+
31
+ - **2.1 Detailed description:** A clinical note is passed through `deidentify_in` (rule recognisers + vault match + optional NLP), which raises if any identifier survives (`assert_clean()`). The clean text reaches the LangGraph ReAct agent; Tavily retrieves public NICE/NHS guidance. The agent drafts a four-element compact discharge card using a `{{PATIENT}}` placeholder for the title. `reidentify_out` restores surrogates and resolves `{{PATIENT}}` from `patients.csv` β€” the model never sees the real name. `compute_trust` measures residual leakage, orphaned tokens, and faithfulness.
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 (identifiers removed, residual risk, leaked tokens, faithfulness score), 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) optional Presidio + spaCy `en_core_web_lg`; (c) LangGraph ReAct agent with Gemini 2.5 Flash; (d) Tavily public-guidance search; (e) 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 (optional) | `PERSON`, `LOCATION` | spaCy `en_core_web_lg`, score-thresholded | No-op fallback when not installed |
60
+ | Gemini 2.5 Flash | Discharge summary drafting | LangGraph ReAct; SYSTEM prompt enforces `{{PATIENT}}` and no-PHI rules | `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 | Trust metric | Residual leakage + orphaned-token check + LLM-as-judge faithfulness | 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; residual risk metric 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; faithfulness score; clinician signs off |
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 + optional Microsoft Presidio (spaCy NER); 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 an optional NLP detector (Presidio + spaCy `en_core_web_lg`).
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. **Computes a trust metric** β€” residual leakage rate, leaked-token list, and faithfulness score.
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) + optional Presidio spaCy NER | Vault is ground-truth; Presidio adds unlisted names |
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}}` placeholder | Title line of discharge summary uses this literal; resolved from `patients.csv` β€” the model never writes the real name |
78
+
79
+ ---
80
+
81
+ ## Bias and fairness
82
+
83
+ The optional Presidio NER (spaCy `en_core_web_lg`) is trained largely on Western/English text, so **name recall can be lower for names of non-English origin**. This is an equity risk: under-detection means those patients carry a *higher residual re-identification risk*. 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,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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:
88
+ - **Re-id risk Β· model input** β€” `0.0 %` when the privacy guarantee holds; higher when leaks detected.
89
+ - **Identifiers removed** β€” count of distinct tokens de-identified in this call.
90
+ - **Faithfulness** β€” LLM-as-judge score (`0–100 %`): is every claim in the summary supported by the de-identified note?
91
+ - **Grounded sources** β€” number of distinct Tavily / NICE / NHS URLs cited by Gemini.
92
+ - **Leaked tokens** β€” surrogate tokens that survived the model without being re-identified (should be empty).
93
+ 6. Click **← Edit note** to reset and process a different note.
94
+
95
+ ## Running the LangGraph dev server
96
+
97
+ ```bash
98
+ langgraph dev
99
+ ```
100
+
101
+ Connect the [Agent Chat UI](https://smith.langchain.com/studio/?baseUrl=http://127.0.0.1:2024)
102
+ and interact with the `noteguard` graph directly.
103
+
104
+ ## Running the LangSmith evaluations
105
+
106
+ ```bash
107
+ python -m eval.run_eval
108
+ ```
109
+
110
+ Needs `LANGSMITH_API_KEY` and `LANGSMITH_TRACING=true`. Runs two evaluators:
111
+
112
+ | Evaluator | Target | What it measures |
113
+ |---|---|---|
114
+ | `zero_phi_to_model` | 1.0 | No known identifier appeared in any message seen by the model. |
115
+ | `faithfulness` | 0.8+ | Every clinical claim in the answer is supported by the source note. |
116
+
117
+ ## Development
118
+
119
+ ```bash
120
+ ruff check src agent app eval tests # lint
121
+ ruff format src agent app eval tests # format
122
+ pytest # run the test suite
123
+ pytest --cov=src --cov-report=term # with coverage
124
+ ```
125
+
126
+ ## Loading a real patient vault
127
+
128
+ The de-id core can be seeded from the
129
+ [`NHSEDataScience/synthetic_clinical_notes`](https://huggingface.co/datasets/NHSEDataScience/synthetic_clinical_notes)
130
+ dataset (MIT licence, fully synthetic):
131
+
132
+ ```python
133
+ from src.deid import NoteGuard, load_known_from_csv
134
+
135
+ known = load_known_from_csv("data/patients.csv", "data/admissions.csv")
136
+ ng = NoteGuard(known=known)
137
+ result = ng.deidentify(raw_note)
138
+ ng.assert_clean(result.clean_text)
139
+ ```
140
+
141
+ This builds the identifier vault from both structured tables β€” patient names and
142
+ 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,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "noteguard-agent"
7
+ version = "1.0.0"
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 ".[demo]" β€” Streamlit interactive demo
28
+ demo = ["streamlit>=1.35.0"]
29
+ # pip install -e ".[dev]" β€” full dev tooling incl. langgraph dev server
30
+ dev = [
31
+ "pytest>=8.0.0",
32
+ "pytest-cov>=4.1.0",
33
+ "ruff>=0.3.0",
34
+ "pre-commit>=3.6.0",
35
+ "langgraph-cli[inmem]>=0.4.0",
36
+ ]
37
+
38
+ [project.urls]
39
+ Homepage = "https://github.com/chaeyoonyunakim/noteguard-agent"
40
+
41
+ [tool.setuptools.packages.find]
42
+ where = ["."]
43
+ include = ["src*", "agent*", "app*", "eval*"]
44
+
45
+ [tool.ruff]
46
+ line-length = 110
47
+ target-version = "py310"
48
+ src = ["src", "agent", "app", "eval", "tests"]
49
+
50
+ [tool.ruff.lint]
51
+ select = ["E", "F", "I", "B", "UP", "W"]
52
+ ignore = ["B008"]
53
+
54
+ [tool.ruff.lint.per-file-ignores]
55
+ "agent/graph.py" = ["E402"]
56
+ "app/api.py" = ["E402"]
57
+ "eval/run_eval.py" = ["E402", "I001"]
58
+
59
+ [tool.pytest.ini_options]
60
+ testpaths = ["tests"]
61
+ 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,288 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 re
22
+ from dataclasses import dataclass
23
+ from datetime import datetime, timedelta
24
+
25
+ NHS_NUMBER = re.compile(r"\b\d{3}[ -]?\d{3}[ -]?\d{4}\b")
26
+ NHS_CONTEXT = re.compile(r"(?i)\bNHS(?:\s*(?:no\.?|number|#))?[:\s]*([0-9][0-9 \-]{6,12}\d)")
27
+ UK_POSTCODE = re.compile(r"\b([A-Z]{1,2}\d[A-Z\d]? ?\d[A-Z]{2})\b")
28
+ DOB = re.compile(r"\b(\d{1,2}[/\-.]\d{1,2}[/\-.]\d{2,4})\b")
29
+ EMAIL = re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.-]+\b")
30
+ PHONE = re.compile(r"\b0\d{2,3}[ -]?\d{3,4}[ -]?\d{3,4}\b")
31
+
32
+ # GMC/NMC: optional connector word then optional colon/spaces before the ID
33
+ # Handles: "GMC No. 7654321", "NMC number: 18D6896L", "PIN 18D6896L", etc.
34
+ _CONN = r"(?:\s*(?:no\.?|number|reg(?:istration)?|pin|#))?[:\s]*"
35
+ GMC = re.compile(r"(?i)\bGMC" + _CONN + r"(\d{7})\b")
36
+ NMC = re.compile(r"(?i)\b(?:NMC|PIN)" + _CONN + r"(\d{2}[A-Z]\d{4}[A-Z])\b")
37
+
38
+ # Surrogate token pattern β€” catches any [LABEL_n] the model might invent
39
+ _SURROGATE_PAT = re.compile(r"\[[A-Z]+_\d+\]")
40
+
41
+ # Column names we look for in any CSV to extract person names
42
+ _NAME_COLS = frozenset(
43
+ {
44
+ "full_name",
45
+ "patient_name",
46
+ "first_name",
47
+ "surname",
48
+ "last_name",
49
+ "clinician_name",
50
+ "author_name",
51
+ "author",
52
+ "attending",
53
+ "attending_physician",
54
+ "nurse",
55
+ "consultant",
56
+ "doctor",
57
+ "provider",
58
+ }
59
+ )
60
+
61
+
62
+ # ── Optional NLP detector (Presidio + spaCy) ─────────────────────────────────
63
+
64
+
65
+ class _Detector:
66
+ """Stub detector β€” no-op when Presidio/spaCy is not installed."""
67
+
68
+ def detect_persons(self, text: str) -> list[str]:
69
+ return []
70
+
71
+
72
+ def _build_detector() -> _Detector:
73
+ try:
74
+ from presidio_analyzer import AnalyzerEngine # type: ignore
75
+
76
+ engine = AnalyzerEngine()
77
+
78
+ class _PresidioDetector(_Detector):
79
+ def detect_persons(self, text: str) -> list[str]:
80
+ results = engine.analyze(text, language="en", entities=["PERSON", "LOCATION"])
81
+ return [text[r.start : r.end] for r in results if r.end > r.start]
82
+
83
+ return _PresidioDetector()
84
+ except Exception:
85
+ return _Detector()
86
+
87
+
88
+ _DETECTOR: _Detector = _build_detector()
89
+
90
+
91
+ # ── Vault loader ──────────────────────────────────────────────────────────────
92
+
93
+
94
+ def load_known_from_csv(patients_csv: str, admissions_csv: str | None = None) -> dict:
95
+ """Build the identifier vault from the synthetic dataset's structured tables.
96
+
97
+ Reads ``full_name`` and common name columns from *patients_csv*, plus NHS
98
+ numbers. If *admissions_csv* is supplied, any clinician/author name columns
99
+ found there are added to the PERSON set so clinician names are caught
100
+ deterministically even without Presidio.
101
+ """
102
+ known: dict[str, set] = {"PERSON": set(), "NHS": set()}
103
+
104
+ def _pull_row(row: dict) -> None:
105
+ for col in _NAME_COLS:
106
+ v = (row.get(col) or "").strip()
107
+ if len(v) > 2:
108
+ known["PERSON"].add(v)
109
+
110
+ with open(patients_csv, newline="", encoding="utf-8-sig") as f:
111
+ for row in csv.DictReader(f):
112
+ _pull_row(row)
113
+ nhs = (row.get("nhs_number") or "").strip()
114
+ if nhs:
115
+ known["NHS"].add(nhs)
116
+
117
+ if admissions_csv:
118
+ try:
119
+ with open(admissions_csv, newline="", encoding="utf-8-sig") as f:
120
+ for row in csv.DictReader(f):
121
+ _pull_row(row)
122
+ except FileNotFoundError:
123
+ pass
124
+
125
+ return {k: sorted(v) for k, v in known.items()}
126
+
127
+
128
+ # ── Core de-identification ────────────────────────────────────────────────────
129
+
130
+
131
+ @dataclass
132
+ class DeidResult:
133
+ clean_text: str
134
+ forward: dict
135
+ reverse: dict
136
+ residual: list
137
+
138
+
139
+ class NoteGuard:
140
+ def __init__(self, known=None, dob_shift_days=37, forward=None, reverse=None):
141
+ self.known = {k: list(v) for k, v in (known or {}).items()}
142
+ self.dob_shift = dob_shift_days
143
+ self.forward = dict(forward or {})
144
+ self.reverse = dict(reverse or {})
145
+ self._counter: dict[str, int] = {}
146
+ for tok in self.reverse:
147
+ m = re.match(r"\[([A-Z]+)_(\d+)\]", tok)
148
+ if m:
149
+ self._counter[m.group(1)] = max(self._counter.get(m.group(1), 0), int(m.group(2)))
150
+
151
+ @staticmethod
152
+ def _fix_mojibake(s: str) -> str:
153
+ # Each pair: (UTF-8 bytes of the real char decoded as Windows-1252, real char)
154
+ # · = · β†’ Β· (middle dot U+00B7)
155
+ # Ò€ℒ = Ò€ℒ β†’ ' (right single quote U+2019)
156
+ # Γ’β‚¬β€œ = Ò€" β†’ – (en-dash U+2013; 0x93 in Win-1252 = U+201C)
157
+ # é = é β†’ Γ© (e-acute U+00E9)
158
+ return s.replace("·", "Β·").replace("Ò€ℒ", "’").replace("Γ’β‚¬β€œ", "–").replace("é", "Γ©")
159
+
160
+ def _surrogate(self, label: str, original: str) -> str:
161
+ if original in self.forward:
162
+ return self.forward[original]
163
+ self._counter[label] = self._counter.get(label, 0) + 1
164
+ tok = f"[{label}_{self._counter[label]}]"
165
+ self.forward[original] = tok
166
+ self.reverse[tok] = original
167
+ return tok
168
+
169
+ def _shift_date(self, s: str) -> str:
170
+ if s in self.forward:
171
+ return self.forward[s]
172
+ for fmt in ("%d/%m/%Y", "%d/%m/%y", "%d-%m-%Y", "%Y-%m-%d", "%d.%m.%Y"):
173
+ try:
174
+ shifted = (datetime.strptime(s, fmt) + timedelta(days=self.dob_shift)).strftime(fmt)
175
+ self.forward[s] = shifted
176
+ self.reverse[shifted] = s
177
+ return shifted
178
+ except ValueError:
179
+ continue
180
+ return self._surrogate("DATE", s)
181
+
182
+ def _redact(self, pattern, label, text, group=0, transform=None):
183
+ def repl(m):
184
+ original = m.group(group)
185
+ surr = transform(original) if transform else self._surrogate(label, original)
186
+ return m.group(0).replace(original, surr)
187
+
188
+ return pattern.sub(repl, text)
189
+
190
+ def deidentify(self, text: str) -> DeidResult:
191
+ t = self._fix_mojibake(text)
192
+
193
+ # Vault pass β€” patient names + NHS numbers from structured tables
194
+ for label in ("PERSON", "NHS"):
195
+ terms = [x for x in self.known.get(label, []) if x]
196
+ if terms:
197
+ alternatives = "|".join(re.escape(x) for x in sorted(terms, key=len, reverse=True))
198
+ pat = re.compile(r"\b(" + alternatives + r")\b")
199
+ t = self._redact(pat, label, t, group=1)
200
+
201
+ # Rule-based recognisers
202
+ t = self._redact(NHS_CONTEXT, "NHS", t, group=1)
203
+ t = self._redact(NHS_NUMBER, "NHS", t, group=0)
204
+ t = self._redact(GMC, "GMC", t, group=1)
205
+ t = self._redact(NMC, "NMC", t, group=1)
206
+ t = self._redact(EMAIL, "EMAIL", t, group=0)
207
+ t = self._redact(PHONE, "PHONE", t, group=0)
208
+ t = self._redact(UK_POSTCODE, "POSTCODE", t, group=1)
209
+ t = self._redact(DOB, "DOB", t, group=1, transform=self._shift_date)
210
+
211
+ # Optional NLP pass β€” catches clinician names and locations not in vault
212
+ for name in _DETECTOR.detect_persons(t):
213
+ if name and len(name) > 2 and name not in self.forward:
214
+ t = t.replace(name, self._surrogate("PERSON", name))
215
+
216
+ return DeidResult(t, dict(self.forward), dict(self.reverse), self._residual_known(t))
217
+
218
+ def _residual_known(self, text: str) -> list:
219
+ # Use word-boundary match, same as the deidentify vault pass, so that
220
+ # short names like "Dia" don't false-positive on "Diastolic"/"Diabetes".
221
+ return [
222
+ v
223
+ for vals in self.known.values()
224
+ for v in vals
225
+ if v and re.search(r"\b" + re.escape(v) + r"\b", text)
226
+ ]
227
+
228
+ def residual_identifiers(self, text: str) -> list[str]:
229
+ """Comprehensive leak check β€” used for the trust metric.
230
+
231
+ Covers:
232
+ - vault names that survived de-id
233
+ - regex patterns (NHS, email, GMC, NMC)
234
+ - orphaned surrogate tokens (invented by the model, no reverse mapping)
235
+ - NLP-detected persons/locations (when Presidio is installed)
236
+ """
237
+ hits: list[str] = list(self._residual_known(text))
238
+
239
+ for pat in (NHS_CONTEXT, NHS_NUMBER, EMAIL, GMC, NMC):
240
+ if pat.search(text):
241
+ hits.append(f"pattern:{pat.pattern[:40]}")
242
+
243
+ # Orphaned surrogates: a [LABEL_n] in the text with no reverse mapping
244
+ # means the model invented a token we cannot restore β€” it's a leak.
245
+ for m in _SURROGATE_PAT.finditer(text):
246
+ tok = m.group(0)
247
+ if tok not in self.reverse:
248
+ hits.append(f"unmapped_token:{tok}")
249
+
250
+ # NLP pass (no-op when Presidio not installed)
251
+ for name in _DETECTOR.detect_persons(text):
252
+ if name:
253
+ hits.append(f"PERSON:{name[:30]}")
254
+
255
+ return list(dict.fromkeys(hits)) # deduplicate, preserve order
256
+
257
+ def assert_clean(self, text: str) -> None:
258
+ """Hard guarantee: raises if any known identifier or regex pattern survives."""
259
+ hits = list(self._residual_known(text))
260
+ for pat in (NHS_CONTEXT, NHS_NUMBER, EMAIL, GMC, NMC):
261
+ if pat.search(text):
262
+ hits.append(pat.pattern[:40])
263
+ if hits:
264
+ raise ValueError(f"NoteGuard guarantee failed: identifiers reached the model boundary: {hits}")
265
+
266
+ def reidentify(self, text: str) -> str:
267
+ for tok, original in sorted(self.reverse.items(), key=lambda kv: len(kv[0]), reverse=True):
268
+ text = text.replace(tok, original)
269
+ return text
270
+
271
+
272
+ if __name__ == "__main__":
273
+ known = {"PERSON": ["Margaret Okafor"], "NHS": ["485 777 3456"]}
274
+ note = (
275
+ "02 Jan, Ward RJ1. Pt Margaret Okafor (NHS 485 777 3456, DOB 14/03/1934) "
276
+ "admitted post-fall. Nurse Chukwuebuka reviewed. "
277
+ "Contact a.okafor@example.com, 020 7946 0991. "
278
+ "GMC No. 7654321. NMC number: 18D6896L."
279
+ )
280
+ print("INPUT:\n", note, "\n")
281
+ ng = NoteGuard(known=known)
282
+ res = ng.deidentify(note)
283
+ print("DE-IDENTIFIED (what the model sees):\n", res.clean_text, "\n")
284
+ print("Residual identifiers:", res.residual)
285
+ ng.assert_clean(res.clean_text)
286
+ print("assert_clean: OK\n")
287
+ restored = NoteGuard(reverse=res.reverse).reidentify(res.clean_text)
288
+ 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,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ # ── deidentify ────────────────────────────────────────────────────────────────
20
+
21
+
22
+ def test_nhs_number_replaced():
23
+ ng = _ng()
24
+ result = ng.deidentify("Patient NHS 485 777 3456 admitted.")
25
+ assert "485 777 3456" not in result.clean_text
26
+ assert "[NHS_" in result.clean_text
27
+
28
+
29
+ def test_person_name_replaced():
30
+ ng = _ng()
31
+ result = ng.deidentify("Pt Margaret Okafor discharged home.")
32
+ assert "Margaret Okafor" not in result.clean_text
33
+ assert "[PERSON_" in result.clean_text
34
+
35
+
36
+ def test_email_replaced():
37
+ ng = _ng()
38
+ result = ng.deidentify("Contact a.okafor@nhs.net for follow-up.")
39
+ assert "@" not in result.clean_text
40
+
41
+
42
+ def test_dob_replaced():
43
+ ng = _ng()
44
+ result = ng.deidentify("DOB 14/03/1934, admitted post-fall.")
45
+ assert "14/03/1934" not in result.clean_text
46
+
47
+
48
+ def test_gmc_replaced():
49
+ ng = _ng()
50
+ result = ng.deidentify("Referring clinician GMC 1234567.")
51
+ assert "1234567" not in result.clean_text
52
+
53
+
54
+ def test_clean_text_passes_through_unchanged():
55
+ ng = _ng()
56
+ note = "Patient admitted post-fall. Hx AF, on warfarin. BP 128/74."
57
+ result = ng.deidentify(note)
58
+ assert result.clean_text == note
59
+
60
+
61
+ # ── GMC / NMC connector-word variants ────────────────────────────────────────
62
+
63
+
64
+ def test_gmc_with_connector_no():
65
+ ng = _ng()
66
+ res = ng.deidentify("Referring clinician GMC No. 7654321.")
67
+ assert "7654321" not in res.clean_text
68
+ assert "[GMC_" in res.clean_text
69
+
70
+
71
+ def test_gmc_with_connector_number():
72
+ ng = _ng()
73
+ res = ng.deidentify("GMC number 7654321 on record.")
74
+ assert "7654321" not in res.clean_text
75
+
76
+
77
+ def test_nmc_with_connector_number_colon():
78
+ ng = _ng()
79
+ res = ng.deidentify("Nurse Chukwuebuka Okafor, NMC number: 18D6896L")
80
+ assert "18D6896L" not in res.clean_text
81
+ assert "[NMC_" in res.clean_text
82
+
83
+
84
+ def test_nmc_with_pin():
85
+ ng = _ng()
86
+ res = ng.deidentify("Registered nurse PIN 18D6896L.")
87
+ assert "18D6896L" not in res.clean_text
88
+
89
+
90
+ def test_nmc_bare():
91
+ ng = _ng()
92
+ res = ng.deidentify("NMC 18D6896L confirmed.")
93
+ assert "18D6896L" not in res.clean_text
94
+
95
+
96
+ # ── Clinician name detection (via expanded vault) ─────────────────────────────
97
+
98
+
99
+ def test_clinician_name_via_vault():
100
+ """Clinician names added to the vault are redacted deterministically."""
101
+ known = {"PERSON": ["Chukwuebuka Okafor", "Margaret Okafor"], "NHS": []}
102
+ ng = NoteGuard(known=known)
103
+ res = ng.deidentify("Nurse Chukwuebuka Okafor assessed the patient.")
104
+ assert "Chukwuebuka Okafor" not in res.clean_text
105
+ assert "[PERSON_" in res.clean_text
106
+
107
+
108
+ def test_full_clinician_nmc_note():
109
+ """Combined: nurse name in vault + NMC number with connector word."""
110
+ known = {"PERSON": ["Chukwuebuka Okafor"], "NHS": []}
111
+ ng = NoteGuard(known=known)
112
+ note = "Patient assessed at triage by Nurse Chukwuebuka Okafor, NMC number: 18D6896L"
113
+ res = ng.deidentify(note)
114
+ assert "Chukwuebuka Okafor" not in res.clean_text
115
+ assert "18D6896L" not in res.clean_text
116
+ ng.assert_clean(res.clean_text)
117
+
118
+
119
+ # ── assert_clean ──────────────────────────────────────────────────────────────
120
+
121
+
122
+ def test_assert_clean_passes_on_safe_text():
123
+ ng = _ng()
124
+ ng.assert_clean("Admitted post-fall. Hx AF. INR 2.4.") # must not raise
125
+
126
+
127
+ def test_assert_clean_raises_on_nhs_number():
128
+ ng = _ng()
129
+ with pytest.raises(ValueError, match="485 777 3456"):
130
+ ng.assert_clean("NHS 485 777 3456 still present.")
131
+
132
+
133
+ def test_assert_clean_raises_on_known_name():
134
+ ng = _ng()
135
+ with pytest.raises(ValueError, match="Margaret Okafor"):
136
+ ng.assert_clean("Patient Margaret Okafor discharged.")
137
+
138
+
139
+ def test_assert_clean_raises_on_nmc():
140
+ ng = _ng()
141
+ with pytest.raises(ValueError):
142
+ ng.assert_clean("NMC number: 18D6896L not redacted.")
143
+
144
+
145
+ # ── residual_identifiers (trust metric) ───────────────────────────────────────
146
+
147
+
148
+ def test_residual_identifiers_catches_orphaned_token():
149
+ """A [LABEL_n] token with no reverse mapping is an unmapped-token leak."""
150
+ ng = NoteGuard(known={}, reverse={"[PERSON_1]": "Real Name"})
151
+ text = "Summary for [PERSON_1] and [PERSON_2]." # PERSON_2 has no mapping
152
+ hits = ng.residual_identifiers(text)
153
+ assert any("unmapped_token" in h for h in hits)
154
+ # PERSON_1 is mapped β€” should NOT appear as orphaned
155
+ assert not any("PERSON_1" in h for h in hits)
156
+
157
+
158
+ def test_residual_identifiers_catches_nmc():
159
+ ng = _ng()
160
+ hits = ng.residual_identifiers("Nurse PIN 18D6896L still present.")
161
+ assert any(h for h in hits) # something was found
162
+
163
+
164
+ # ── reidentify ────────────────────────────────────────────────────────────────
165
+
166
+
167
+ def test_reidentify_restores_surrogate():
168
+ ng = _ng()
169
+ result = ng.deidentify("Pt Margaret Okafor (NHS 485 777 3456) admitted.")
170
+ restored = ng.reidentify(result.clean_text)
171
+ assert "Margaret Okafor" in restored
172
+ assert "485 777 3456" in restored
173
+
174
+
175
+ def test_reidentify_consistent_surrogates():
176
+ """Same original -> same surrogate across multiple notes."""
177
+ ng = _ng()
178
+ r1 = ng.deidentify("Note 1: Margaret Okafor, INR normal.")
179
+ r2 = ng.deidentify("Note 2: Margaret Okafor, discharged.")
180
+ tokens_1 = {tok for tok in r1.clean_text.split() if tok.startswith("[PERSON-")}
181
+ tokens_2 = {tok for tok in r2.clean_text.split() if tok.startswith("[PERSON-")}
182
+ assert tokens_1 == tokens_2
183
+
184
+
185
+ # ── load_known_from_csv ───────────────────────────────────────────────────────
186
+
187
+
188
+ def test_load_known_from_csv(tmp_path):
189
+ csv_file = tmp_path / "patients.csv"
190
+ csv_file.write_text("full_name,nhs_number\nJane Smith,123 456 7890\n")
191
+ known = load_known_from_csv(str(csv_file))
192
+ assert "Jane Smith" in known["PERSON"]
193
+ assert "123 456 7890" in known["NHS"]
194
+
195
+
196
+ def test_load_known_from_csv_admissions(tmp_path):
197
+ """Names in admissions.csv clinician columns are added to the vault."""
198
+ patients = tmp_path / "patients.csv"
199
+ patients.write_text("full_name,nhs_number\nJane Smith,123 456 7890\n")
200
+ admissions = tmp_path / "admissions.csv"
201
+ admissions.write_text("clinician_name,attending\nDr Wei Wang,Nurse Chukwuebuka Okafor\n")
202
+ known = load_known_from_csv(str(patients), str(admissions))
203
+ assert "Dr Wei Wang" in known["PERSON"]
204
+ assert "Nurse Chukwuebuka Okafor" in known["PERSON"]
205
+
206
+
207
+ def test_load_known_from_csv_missing_admissions(tmp_path):
208
+ """Missing admissions.csv is silently ignored."""
209
+ patients = tmp_path / "patients.csv"
210
+ patients.write_text("full_name,nhs_number\nJane Smith,123 456 7890\n")
211
+ known = load_known_from_csv(str(patients), str(tmp_path / "missing.csv"))
212
+ assert "Jane Smith" in known["PERSON"]