Spaces:
Running
Running
github-actions[bot] commited on
Commit Β·
4477b4e
0
Parent(s):
Deploy 8244dcbebc2e76b6843857728efe8f835b7426b4 from main
Browse files- .editorconfig +18 -0
- .env.example +13 -0
- .github/PULL_REQUEST_TEMPLATE.md +12 -0
- .github/workflows/ci.yml +41 -0
- .github/workflows/deploy-hf.yml +47 -0
- .gitignore +25 -0
- .pre-commit-config.yaml +24 -0
- CHANGELOG.md +141 -0
- CLAUDE.md +79 -0
- CODE_OF_CONDUCT.md +65 -0
- CONTRIBUTING.md +51 -0
- Dockerfile +27 -0
- LICENSE +21 -0
- Makefile +47 -0
- README.md +238 -0
- VERSION +1 -0
- agent/__init__.py +0 -0
- agent/graph.py +239 -0
- app/__init__.py +0 -0
- app/api.py +295 -0
- app/static/index.html +585 -0
- docs/architecture.md +162 -0
- docs/rap_compliance.md +60 -0
- docs/report.md +94 -0
- docs/tool_card.md +123 -0
- docs/user_guide.md +142 -0
- eval/__init__.py +0 -0
- eval/run_eval.py +103 -0
- langgraph.json +7 -0
- pyproject.toml +61 -0
- src/__init__.py +3 -0
- src/deid.py +288 -0
- src/fetch_dataset.py +41 -0
- streamlit_app.py +77 -0
- tests/__init__.py +0 -0
- tests/test_deid.py +212 -0
.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 |
+
[](https://github.com/GIScience/badges#experimental)
|
| 12 |
+
[](https://github.com/chaeyoonyunakim/noteguard-agent/actions/workflows/ci.yml)
|
| 13 |
+
[](https://nhsdigital.github.io/rap-community-of-practice/introduction_to_RAP/levels_of_RAP/)
|
| 14 |
+
[](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¬e_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()">✕</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">🔀 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,'&').replace(/</g,'<').replace(/>/g,'>');
|
| 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"]
|