Chaeyoon Claude Opus 4.8 commited on
Commit
84981a4
·
1 Parent(s): b7c4eb2

Refactor to Gold-RAP structure: src/ package, tests/, docs/, data/, output/

Browse files

Structure
- Rename package noteguard/ -> src/; British spelling recognizers -> recognisers
(file, method, and all imports; Presidio's add_recognizer untouched).
- Streamlit app moved to repo root (streamlit_app.py); run_eval.py -> tests/.
- Generated artifacts now write to output/ (was data/out/).

Declutter
- Remove empty app/ and experiments/ dirs, the committed results.json, and the
stale docs/failed_experiments.md. Update docs/tool_card.md.

Gold RAP ("analysis as a product")
- pyproject.toml: pip-installable package + ruff/pytest config.
- CI (.github/workflows/ci.yml) runs ruff + pytest on push/PR.
- CHANGELOG.md; logging in the eval entry point.

Notes
- src/ stays one cohesive package (its modules import each other); evaluate.py is a
library used by both src/trust_demo.py and tests/run_eval.py, so both are kept.

Verified: pip install -e ".[dev]"; ruff clean; 23 tests pass; run_eval writes output/results.json.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

.claude/skills/run-evaluation/SKILL.md CHANGED
@@ -7,8 +7,8 @@ description: How to run the NoteGuard evaluation (detection P/R/F1 + residual le
7
  The eval is the project's pass/fail signal — it proves sanitisation actually removes PII, with numbers.
8
 
9
  1. Data: either `NOTEGUARD_DATA_DIR=<folder with the 3 CSVs>` (offline) or let it auto-download from HF.
10
- 2. Run `python run_eval.py --compare --limit 300` (use a larger `--limit` for the headline; `--method
11
- pseudonym` to measure leakage under pseudonymisation). Writes `results.json`.
12
  3. It joins each note to its patient/admission record (the EVAL-ONLY oracle) to get ground truth, then
13
  reports, per detector:
14
  - **detection P / R / F1** per entity type (precision is a conservative lower bound — removing PII
@@ -18,7 +18,7 @@ The eval is the project's pass/fail signal — it proves sanitisation actually r
18
  ## How to read it
19
  - `--compare` prints two rows: **rules** → **presidio+rules** (the shipping detector). The leakage
20
  should drop sharply between them.
21
- - Watch residual leakage as the headline. If it regresses after a change to `noteguard/recognizers.py`,
22
  `detect.py`, or `transform.py`, fix it before continuing.
23
 
24
  Log anything that didn't work in `experiments/FAILED.md`.
 
7
  The eval is the project's pass/fail signal — it proves sanitisation actually removes PII, with numbers.
8
 
9
  1. Data: either `NOTEGUARD_DATA_DIR=<folder with the 3 CSVs>` (offline) or let it auto-download from HF.
10
+ 2. Run `python tests/run_eval.py --compare --limit 300` (use a larger `--limit` for the headline;
11
+ `--method pseudonym` to measure leakage under pseudonymisation). Writes `output/results.json`.
12
  3. It joins each note to its patient/admission record (the EVAL-ONLY oracle) to get ground truth, then
13
  reports, per detector:
14
  - **detection P / R / F1** per entity type (precision is a conservative lower bound — removing PII
 
18
  ## How to read it
19
  - `--compare` prints two rows: **rules** → **presidio+rules** (the shipping detector). The leakage
20
  should drop sharply between them.
21
+ - Watch residual leakage as the headline. If it regresses after a change to `src/recognisers.py`,
22
  `detect.py`, or `transform.py`, fix it before continuing.
23
 
24
  Log anything that didn't work in `experiments/FAILED.md`.
.github/workflows/ci.yml ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main, "dev/**", "feat/**"]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: actions/setup-python@v5
14
+ with:
15
+ python-version: "3.11"
16
+ - name: Install package + dev tools
17
+ run: |
18
+ python -m pip install --upgrade pip
19
+ pip install -e ".[dev]"
20
+ - name: Lint (ruff)
21
+ run: ruff check .
22
+ - name: Unit tests (pytest)
23
+ run: pytest -q
.gitignore CHANGED
@@ -1,14 +1,19 @@
1
- # NHS PHI safety: raw data, de-identified output, and the re-identification vault NEVER leave the repo
2
- data/raw/
3
- data/out/
 
 
4
  *.vault.json
5
 
6
- # Python
7
  .venv/
8
  __pycache__/
9
  *.pyc
10
  .pytest_cache/
 
11
  *.egg-info/
 
 
12
 
13
  # macOS / zip extraction junk
14
  .DS_Store
 
1
+ # NHS PHI safety: raw data, generated output, and the re-identification vault NEVER leave the repo
2
+ data/*
3
+ !data/.gitkeep
4
+ output/*
5
+ !output/.gitkeep
6
  *.vault.json
7
 
8
+ # Python / packaging
9
  .venv/
10
  __pycache__/
11
  *.pyc
12
  .pytest_cache/
13
+ .ruff_cache/
14
  *.egg-info/
15
+ build/
16
+ dist/
17
 
18
  # macOS / zip extraction junk
19
  .DS_Store
CHANGELOG.md ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Changelog
2
+
3
+ All notable changes are documented here. Format follows [Keep a Changelog](https://keepachangelog.com);
4
+ the project uses [semantic versioning](https://semver.org).
5
+
6
+ ## [1.0.0] — 2026-06-20
7
+
8
+ Gold-RAP restructure ("analysis as a product").
9
+
10
+ ### Added
11
+ - Standard RAP directory layout: `src/` (package), `tests/` (unit tests + `run_eval.py`),
12
+ `docs/`, `data/` (inputs), `output/` (generated artifacts).
13
+ - `pyproject.toml` — the project is now pip-installable (`pip install -e .`).
14
+ - Continuous integration (`.github/workflows/ci.yml`) running `ruff` + `pytest` on every push/PR.
15
+ - `ruff` lint configuration and `logging` in the evaluation entry point.
16
+ - This changelog.
17
+
18
+ ### Changed
19
+ - Renamed the package `noteguard/` → `src/`; all imports updated to `src.*`.
20
+ - Generated outputs (metrics, two-Trust artifacts) now write to `output/` instead of `data/out/`.
21
+ - `run_eval.py` moved under `tests/` as the evaluation entry point.
22
+
23
+ ### Removed
24
+ - Decluttered `experiments/` (failure log moved to `docs/failed_experiments.md`).
25
+ - Removed the committed `results.json` artifact (now regenerated into `output/`, gitignored).
CLAUDE.md CHANGED
@@ -9,29 +9,29 @@ data leaves a Trust. Encode Club "Trusted Data & AI Infrastructure" hackathon; f
9
  python -m venv .venv; .\.venv\Scripts\Activate.ps1
10
  pip install -r requirements.txt; python -m spacy download en_core_web_lg
11
 
12
- python run_eval.py --compare --limit 300 # VERIFIABLE SIGNAL: rules vs presidio+rules -> results.json
13
- python -m noteguard.trust_demo # two NHS Trusts share only de-identified data -> data/out/
14
- streamlit run app/streamlit_app.py # demo (Try-it / Metrics / Governance / Two-Trust)
15
  python -m pytest tests/ -v
16
 
17
  # Offline data: set NOTEGUARD_DATA_DIR to a folder holding the 3 CSVs (else auto-downloaded from HF).
18
  ```
19
 
20
  ## Architecture
21
- - `noteguard/` — `data` (load + ground-truth join, EVAL-ONLY oracle) · `recognizers` (pure-Python
22
  rules) · `detect` (Rule / Presidio, graceful fallback) · `transform`
23
  (redact | patient-consistent pseudonymise + date-shift, Faker) · `evaluate` (P/R/F1 + residual
24
  leakage) · `pipeline` · `trust_demo`.
25
- - `run_eval.py` CLI · `app/streamlit_app.py` demo · `tests/` mirror `noteguard/`.
26
 
27
  ## Code style
28
  - Python 3.10+, type hints on function signatures. The pure-Python rule layer must stay importable
29
  WITHOUT spaCy/Presidio (the fallback path). snake_case / PascalCase.
30
 
31
  ## Data rules (treat the synthetic notes as if real NHS PHI)
32
- - `data/raw/`, `data/out/`, and any vault export are gitignored — never commit. Never paste note text
33
  into prompts; point at file paths.
34
- - The note→patient join (`noteguard/data.py` ground truth) is the EVAL-ONLY oracle. It must NEVER feed
35
  detection/transform — that is data leakage and invalidates the metric.
36
  - Never silently fall back to an older/cached dataset — fail loudly.
37
 
@@ -54,6 +54,5 @@ python -m pytest tests/ -v
54
  - Default spaCy model is now `en_core_web_lg`; the `PII_SPACY_MODEL` env var still overrides.
55
 
56
  ## Working with Claude
57
- - After editing `noteguard/recognizers.py` / `detect.py` / `transform.py`, run
58
- `python run_eval.py --compare` and check residual leakage didn't regress. Log dead ends in
59
- `experiments/FAILED.md`.
 
9
  python -m venv .venv; .\.venv\Scripts\Activate.ps1
10
  pip install -r requirements.txt; python -m spacy download en_core_web_lg
11
 
12
+ python tests/run_eval.py --compare --limit 300 # VERIFIABLE SIGNAL: rules vs presidio+rules -> output/results.json
13
+ python -m src.trust_demo # two NHS Trusts share only de-identified data -> output/
14
+ streamlit run streamlit_app.py # demo (Try-it / Metrics / Governance / Two-Trust)
15
  python -m pytest tests/ -v
16
 
17
  # Offline data: set NOTEGUARD_DATA_DIR to a folder holding the 3 CSVs (else auto-downloaded from HF).
18
  ```
19
 
20
  ## Architecture
21
+ - `src/` — `data` (load + ground-truth join, EVAL-ONLY oracle) · `recognisers` (pure-Python
22
  rules) · `detect` (Rule / Presidio, graceful fallback) · `transform`
23
  (redact | patient-consistent pseudonymise + date-shift, Faker) · `evaluate` (P/R/F1 + residual
24
  leakage) · `pipeline` · `trust_demo`.
25
+ - `tests/run_eval.py` CLI · `streamlit_app.py` demo · `tests/` mirror `src/`. Packaged via `pyproject.toml`.
26
 
27
  ## Code style
28
  - Python 3.10+, type hints on function signatures. The pure-Python rule layer must stay importable
29
  WITHOUT spaCy/Presidio (the fallback path). snake_case / PascalCase.
30
 
31
  ## Data rules (treat the synthetic notes as if real NHS PHI)
32
+ - `data/raw/`, `output/`, and any vault export are gitignored — never commit. Never paste note text
33
  into prompts; point at file paths.
34
+ - The note→patient join (`src/data.py` ground truth) is the EVAL-ONLY oracle. It must NEVER feed
35
  detection/transform — that is data leakage and invalidates the metric.
36
  - Never silently fall back to an older/cached dataset — fail loudly.
37
 
 
54
  - Default spaCy model is now `en_core_web_lg`; the `PII_SPACY_MODEL` env var still overrides.
55
 
56
  ## Working with Claude
57
+ - After editing `src/recognisers.py` / `detect.py` / `transform.py`, run
58
+ `python tests/run_eval.py --compare` and check residual leakage didn't regress.
 
README.md CHANGED
@@ -62,11 +62,23 @@ The rules→engine drop is the headline: it shows, with numbers, exactly what th
62
  └────────────────────────────┘
63
  ```
64
 
65
- `noteguard/` — `data` (load + ground-truth join, **eval-only oracle**) · `recognizers` (pure-Python
66
- rules: NHS checksum/context, postcode, date, phone, email, GMC/NMC/ODS, UUID) · `detect`
67
- (`RuleDetector` / `PresidioDetector`) · `transform`
68
- (redaction | patient-consistent pseudonymisation + date-shift, Faker vault) · `evaluate` (P/R/F1 +
69
- residual leakage) · `pipeline` · `trust_demo`.
 
 
 
 
 
 
 
 
 
 
 
 
70
 
71
  ## Trust & governance — mapped to the NHS Five Safes
72
  - **Safe data** — PII removed to DAPB1523/ICO standard across patient + staff + org identifiers.
@@ -78,19 +90,36 @@ residual leakage) · `pipeline` · `trust_demo`.
78
  ## Run it
79
 
80
  ```bash
81
- python -m venv .venv; .\.venv\Scripts\Activate.ps1 # Windows PowerShell
82
- pip install -r requirements.txt
83
- python -m spacy download en_core_web_sm
84
-
85
- python run_eval.py --compare --limit 300 # reproduce the table → results.json
86
- python -m noteguard.trust_demo # two NHS Trusts share only de-identified data → data/out/
87
- streamlit run app/streamlit_app.py # demo: Try-it · Metrics · Governance · Two-Trust
88
- python -m pytest tests/ -v
 
 
 
 
 
 
 
89
  ```
90
 
91
  The dataset is pulled automatically on first run. To run fully offline, drop the three CSVs in a
92
  folder and set `NOTEGUARD_DATA_DIR=/path/to/csvs`.
93
 
 
 
 
 
 
 
 
 
 
 
94
  ## Data notes (found by inspecting the data, not assuming)
95
  - NHS numbers in this synthetic set are **9 digits** (real ones are 10 + mod-11 check). We catch both:
96
  checksum-validated 10-digit anywhere, **and** context-anchored numbers after an "NHS …" label.
 
62
  └────────────────────────────┘
63
  ```
64
 
65
+ **Project layout** (Gold-RAP "analysis as a product"):
66
+
67
+ ```
68
+ src/ the package
69
+ data.py load CSVs + ground-truth join (EVAL-ONLY oracle)
70
+ recognisers.py pure-Python rules: NHS checksum/context, postcode, date, phone, email, GMC/NMC/ODS, UUID
71
+ detect.py RuleDetector / PresidioDetector behind one Detector interface
72
+ transform.py redaction | patient-consistent pseudonymisation + DOB date-shift (Faker vault)
73
+ pipeline.py single-note detect -> sanitise -> audit
74
+ evaluate.py detection P/R/F1 + residual-leakage metric
75
+ trust_demo.py two-Trust sanitise-at-source demo
76
+ tests/ unit tests + run_eval.py (the evaluation CLI)
77
+ docs/ tool card + project docs
78
+ data/ input CSVs (gitignored)
79
+ output/ generated artifacts: results.json, manifests (gitignored)
80
+ streamlit_app.py the demo UI pyproject.toml packaging + lint/test config
81
+ ```
82
 
83
  ## Trust & governance — mapped to the NHS Five Safes
84
  - **Safe data** — PII removed to DAPB1523/ICO standard across patient + staff + org identifiers.
 
90
  ## Run it
91
 
92
  ```bash
93
+ # 1) create AND activate the virtual environment
94
+ python -m venv .venv
95
+ .\.venv\Scripts\Activate.ps1 # Windows PowerShell
96
+ # source .venv/Scripts/activate # ... or Windows Git Bash
97
+ # source .venv/bin/activate # ... or macOS / Linux
98
+
99
+ # 2) install the package (editable) + the spaCy model
100
+ pip install -e ".[app,dev]"
101
+ python -m spacy download en_core_web_lg # or en_core_web_sm for a faster, lighter run
102
+
103
+ # 3) run
104
+ python tests/run_eval.py --compare --limit 300 # reproduce the table -> output/results.json
105
+ python -m src.trust_demo # two NHS Trusts share only de-identified data -> output/
106
+ streamlit run streamlit_app.py # demo: Try-it · Metrics · Governance · Two-Trust
107
+ pytest -q # unit tests
108
  ```
109
 
110
  The dataset is pulled automatically on first run. To run fully offline, drop the three CSVs in a
111
  folder and set `NOTEGUARD_DATA_DIR=/path/to/csvs`.
112
 
113
+ ## Deploy the live demo (Hugging Face Spaces)
114
+
115
+ ```bash
116
+ pip install -U huggingface_hub # provides the `hf` CLI
117
+ hf auth login # paste a WRITE token from https://huggingface.co/settings/tokens
118
+ hf repos create <user>/noteguard --repo-type space --space-sdk docker
119
+ git remote add space https://huggingface.co/spaces/<user>/noteguard
120
+ git push space HEAD:main # builds the image and serves streamlit_app.py
121
+ ```
122
+
123
  ## Data notes (found by inspecting the data, not assuming)
124
  - NHS numbers in this synthetic set are **9 digits** (real ones are 10 + mod-11 check). We catch both:
125
  checksum-validated 10-digit anywhere, **and** context-anchored numbers after an "NHS …" label.
data/.gitkeep ADDED
@@ -0,0 +1 @@
 
 
1
+ # Input data lives here (e.g. data/raw/...). Contents are gitignored — never commit PHI.
docs/tool_card.md CHANGED
@@ -31,7 +31,7 @@ NoteGuard is a **de-identification gate** for free-text NHS clinical notes. It d
31
  | Patient name (`PERSON`) | spaCy `en_core_web_lg` NER | 100% recall in benchmarks |
32
  | NHS number (`UK_NHS`) | Regex + Modulus-11 checksum + 9-digit context anchor | Catches both standard and synthetic dataset forms |
33
  | Date of birth (`DATE_TIME`) | Presidio + date regex | 100% recall |
34
- | Site / hospital name (`LOCATION`, `ORGANIZATION`) | spaCy NER + rule-based suffix anchor | "X Hospital / Infirmary / NHS Trust" patterns |
35
  | UK postcode (`UK_POSTCODE`) | Regex | Outward-code only after pseudonymisation |
36
  | Clinician GMC / NMC (`GMC`, `NMC`) | Context-anchored regex | "GMC 1234567", "NMC PIN 12A3456B" |
37
  | ODS org code (`NHS_ODS`) | Context-anchored regex | "ODS A12345", "Practice Code A12345" |
@@ -92,7 +92,7 @@ This matches the real NHS Information Governance workflow and makes the tool's a
92
  - **Pseudonymised data is still personal data** under UK GDPR — the vault is the re-identification key and must stay Trust-local.
93
  - **Precision is a conservative lower bound**: clinician names and unlisted locations correctly detected count as false positives in the evaluation (ground truth is patient-table-only).
94
  - **Not clinically validated**: evaluated on the `NHSEDataScience/synthetic_clinical_notes` dataset. Real deployment requires validation on representative Trust data.
95
- - **Clinical transformer models** (e.g. `obi/deid_roberta_i2b2`) were tested and performed worse on UK names than `en_core_web_lg` (i2b2 training data is US-centric). See `experiments/FAILED.md`.
96
 
97
  ---
98
 
 
31
  | Patient name (`PERSON`) | spaCy `en_core_web_lg` NER | 100% recall in benchmarks |
32
  | NHS number (`UK_NHS`) | Regex + Modulus-11 checksum + 9-digit context anchor | Catches both standard and synthetic dataset forms |
33
  | Date of birth (`DATE_TIME`) | Presidio + date regex | 100% recall |
34
+ | Site / hospital name (`LOCATION`) | spaCy NER + rule-based suffix anchor | "X Hospital / Infirmary / NHS Trust" patterns (ORGANIZATION is excluded — it over-tags labels) |
35
  | UK postcode (`UK_POSTCODE`) | Regex | Outward-code only after pseudonymisation |
36
  | Clinician GMC / NMC (`GMC`, `NMC`) | Context-anchored regex | "GMC 1234567", "NMC PIN 12A3456B" |
37
  | ODS org code (`NHS_ODS`) | Context-anchored regex | "ODS A12345", "Practice Code A12345" |
 
92
  - **Pseudonymised data is still personal data** under UK GDPR — the vault is the re-identification key and must stay Trust-local.
93
  - **Precision is a conservative lower bound**: clinician names and unlisted locations correctly detected count as false positives in the evaluation (ground truth is patient-table-only).
94
  - **Not clinically validated**: evaluated on the `NHSEDataScience/synthetic_clinical_notes` dataset. Real deployment requires validation on representative Trust data.
95
+ - **Clinical transformer models** (e.g. `obi/deid_roberta_i2b2`) were tested and performed worse on UK names than `en_core_web_lg` (i2b2 training data is US-centric).
96
 
97
  ---
98
 
experiments/FAILED.md DELETED
@@ -1,17 +0,0 @@
1
- # Failed experiments / dead ends
2
-
3
- Log approaches that did not work so we don't repeat them. One entry per attempt:
4
- what we tried, why it failed, what we did instead.
5
-
6
- ## Assumed UK_POSTCODE / UK_NINO / UK_PASSPORT / UK_VEHICLE_REGISTRATION were Presidio built-ins
7
- The supported-entities docs list these as UK entities, so the plan said "enable built-ins". But the
8
- released `presidio-analyzer==2.2.362` only ships `UK_NHS` (verified via `get_supported_entities()` ->
9
- only `UK_NHS` under UK). A leakage test caught `SW1A 1AA` surviving. Fix: added custom recognizers in
10
- `src/recognizers/uk_entities.py` emitting the same entity names. Lesson: verify the installed version's
11
- recognizers, don't trust the docs page (which tracks `main`).
12
-
13
- <!-- Example:
14
- ## spaCy en_core_web_sm alone for names
15
- Tried relying only on NER for PERSON detection. Missed "Surname, First Middle" forms common in the
16
- notes, leaking names. Fix: added the roster lookup recognizer (src/recognizers/roster.py) as a backstop.
17
- -->
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
output/.gitkeep ADDED
@@ -0,0 +1 @@
 
 
1
+ # Generated artifacts (results.json, two-Trust manifests) are written here. Contents are gitignored.
pyproject.toml ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "noteguard"
7
+ version = "1.0.0"
8
+ description = "Sanitise-at-source PII removal for NHS clinical notes (Presidio + spaCy)"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.10"
12
+ dependencies = [
13
+ "presidio-analyzer",
14
+ "presidio-anonymizer",
15
+ "spacy>=3.7,<3.9",
16
+ "faker",
17
+ "ftfy",
18
+ "huggingface_hub",
19
+ "pandas",
20
+ ]
21
+
22
+ [project.optional-dependencies]
23
+ app = ["streamlit"]
24
+ dev = ["pytest", "ruff"]
25
+
26
+ [tool.setuptools]
27
+ packages = ["src"]
28
+
29
+ [tool.pytest.ini_options]
30
+ pythonpath = ["."]
31
+ testpaths = ["tests"]
32
+
33
+ [tool.ruff]
34
+ line-length = 120
35
+ target-version = "py310"
36
+
37
+ [tool.ruff.lint]
38
+ select = ["E", "F", "W", "I", "B", "UP"]
39
+ ignore = ["E501"] # long descriptive lines/docstrings tolerated
results.json DELETED
@@ -1,203 +0,0 @@
1
- {
2
- "rules": {
3
- "detector": "rules",
4
- "transform": "redaction",
5
- "notes_evaluated": 1602,
6
- "detection": {
7
- "overall": {
8
- "precision": 0.1031,
9
- "recall": 0.2522,
10
- "f1": 0.1464,
11
- "tp": 259,
12
- "fp": 2252,
13
- "fn": 768
14
- },
15
- "per_entity": {
16
- "DATE_TIME": {
17
- "precision": 0.0739,
18
- "recall": 1.0,
19
- "f1": 0.1377,
20
- "support": 84
21
- },
22
- "GMC": {
23
- "precision": 0.0,
24
- "recall": 0.0,
25
- "f1": 0.0,
26
- "support": 0
27
- },
28
- "NMC": {
29
- "precision": 0.0,
30
- "recall": 0.0,
31
- "f1": 0.0,
32
- "support": 0
33
- },
34
- "PERSON": {
35
- "precision": 0.0,
36
- "recall": 0.0,
37
- "f1": 0.0,
38
- "support": 764
39
- },
40
- "RECORD_ID": {
41
- "precision": 0.0,
42
- "recall": 0.0,
43
- "f1": 0.0,
44
- "support": 0
45
- },
46
- "UK_NHS": {
47
- "precision": 0.9777,
48
- "recall": 0.9777,
49
- "f1": 0.9777,
50
- "support": 179
51
- }
52
- }
53
- },
54
- "leakage": {
55
- "total_known_pii_occurrences": 1027,
56
- "residual_leaks_after_sanitisation": 768,
57
- "leakage_rate": 0.7478,
58
- "leakage_rate_pct": 74.78
59
- }
60
- },
61
- "presidio+rules": {
62
- "detector": "presidio+rules",
63
- "transform": "redaction",
64
- "notes_evaluated": 1602,
65
- "detection": {
66
- "overall": {
67
- "precision": 0.0682,
68
- "recall": 0.7624,
69
- "f1": 0.1251,
70
- "tp": 783,
71
- "fp": 10706,
72
- "fn": 244
73
- },
74
- "per_entity": {
75
- "DATE_TIME": {
76
- "precision": 0.022,
77
- "recall": 1.0,
78
- "f1": 0.0431,
79
- "support": 84
80
- },
81
- "GMC": {
82
- "precision": 0.0,
83
- "recall": 0.0,
84
- "f1": 0.0,
85
- "support": 0
86
- },
87
- "LOCATION": {
88
- "precision": 0.0,
89
- "recall": 0.0,
90
- "f1": 0.0,
91
- "support": 0
92
- },
93
- "NMC": {
94
- "precision": 0.0,
95
- "recall": 0.0,
96
- "f1": 0.0,
97
- "support": 0
98
- },
99
- "PERSON": {
100
- "precision": 0.0925,
101
- "recall": 0.6819,
102
- "f1": 0.1629,
103
- "support": 764
104
- },
105
- "RECORD_ID": {
106
- "precision": 0.0,
107
- "recall": 0.0,
108
- "f1": 0.0,
109
- "support": 0
110
- },
111
- "UK_NHS": {
112
- "precision": 0.978,
113
- "recall": 0.9944,
114
- "f1": 0.9861,
115
- "support": 179
116
- },
117
- "URL": {
118
- "precision": 0.0,
119
- "recall": 0.0,
120
- "f1": 0.0,
121
- "support": 0
122
- }
123
- }
124
- },
125
- "leakage": {
126
- "total_known_pii_occurrences": 1027,
127
- "residual_leaks_after_sanitisation": 87,
128
- "leakage_rate": 0.0847,
129
- "leakage_rate_pct": 8.47
130
- }
131
- },
132
- "presidio+rules+roster": {
133
- "detector": "presidio+rules+gazetteer",
134
- "transform": "redaction",
135
- "notes_evaluated": 1602,
136
- "detection": {
137
- "overall": {
138
- "precision": 0.0705,
139
- "recall": 0.7965,
140
- "f1": 0.1295,
141
- "tp": 818,
142
- "fp": 10787,
143
- "fn": 209
144
- },
145
- "per_entity": {
146
- "DATE_TIME": {
147
- "precision": 0.022,
148
- "recall": 1.0,
149
- "f1": 0.0431,
150
- "support": 84
151
- },
152
- "GMC": {
153
- "precision": 0.0,
154
- "recall": 0.0,
155
- "f1": 0.0,
156
- "support": 0
157
- },
158
- "LOCATION": {
159
- "precision": 0.0,
160
- "recall": 0.0,
161
- "f1": 0.0,
162
- "support": 0
163
- },
164
- "NMC": {
165
- "precision": 0.0,
166
- "recall": 0.0,
167
- "f1": 0.0,
168
- "support": 0
169
- },
170
- "PERSON": {
171
- "precision": 0.0967,
172
- "recall": 0.7277,
173
- "f1": 0.1707,
174
- "support": 764
175
- },
176
- "RECORD_ID": {
177
- "precision": 0.0,
178
- "recall": 0.0,
179
- "f1": 0.0,
180
- "support": 0
181
- },
182
- "UK_NHS": {
183
- "precision": 0.978,
184
- "recall": 0.9944,
185
- "f1": 0.9861,
186
- "support": 179
187
- },
188
- "URL": {
189
- "precision": 0.0,
190
- "recall": 0.0,
191
- "f1": 0.0,
192
- "support": 0
193
- }
194
- }
195
- },
196
- "leakage": {
197
- "total_known_pii_occurrences": 1027,
198
- "residual_leaks_after_sanitisation": 1,
199
- "leakage_rate": 0.001,
200
- "leakage_rate_pct": 0.1
201
- }
202
- }
203
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
scripts/post_edit_check.py CHANGED
@@ -1,11 +1,11 @@
1
  """PostToolUse hook: re-run the de-identification tests after edits to the scrubbing logic.
2
 
3
- Wired in `.claude/settings.json`. The team guide's lesson is "verify everything" — recognizers and
4
  anonymisation operators are exactly where a silent regression would let PII leak, so we re-check them
5
  on every edit.
6
 
7
  Safe by design:
8
- - Only acts on edits to `src/recognizers/` or `src/anonymize.py`; otherwise exits 0 silently.
9
  - Gated behind the `PII_ENABLE_HOOK=1` env var so it never disrupts an unrelated session or a
10
  half-installed environment. Turn it on once the venv + tests exist.
11
  - Exits 0 (never blocks) if the venv or pytest is missing.
@@ -19,7 +19,7 @@ import sys
19
  from pathlib import Path
20
 
21
  REPO = Path(__file__).resolve().parent.parent
22
- WATCHED = ("noteguard/recognizers.py", "noteguard/detect.py", "noteguard/transform.py")
23
 
24
 
25
  def main() -> int:
 
1
  """PostToolUse hook: re-run the de-identification tests after edits to the scrubbing logic.
2
 
3
+ Wired in `.claude/settings.json`. The team guide's lesson is "verify everything" — recognisers and
4
  anonymisation operators are exactly where a silent regression would let PII leak, so we re-check them
5
  on every edit.
6
 
7
  Safe by design:
8
+ - Only acts on edits to the scrubbing modules in `src/`; otherwise exits 0 silently.
9
  - Gated behind the `PII_ENABLE_HOOK=1` env var so it never disrupts an unrelated session or a
10
  half-installed environment. Turn it on once the venv + tests exist.
11
  - Exits 0 (never blocks) if the venv or pytest is missing.
 
19
  from pathlib import Path
20
 
21
  REPO = Path(__file__).resolve().parent.parent
22
+ WATCHED = ("src/recognisers.py", "src/detect.py", "src/transform.py")
23
 
24
 
25
  def main() -> int:
{noteguard → src}/__init__.py RENAMED
File without changes
{noteguard → src}/data.py RENAMED
File without changes
{noteguard → src}/detect.py RENAMED
@@ -10,7 +10,7 @@ from __future__ import annotations
10
 
11
  from typing import Protocol
12
 
13
- from .recognizers import Span, find_rule_spans
14
 
15
 
16
  class Detector(Protocol):
@@ -70,10 +70,10 @@ class PresidioDetector:
70
  self.engine = AnalyzerEngine(nlp_engine=provider.create_engine())
71
  self.score_threshold = score_threshold
72
  self.review_threshold = review_threshold
73
- self._register_uk_recognizers()
74
 
75
- def _register_uk_recognizers(self) -> None:
76
- """Register UK entity recognizers that Presidio documents but does not ship."""
77
  from presidio_analyzer import Pattern, PatternRecognizer
78
 
79
  custom = [
 
10
 
11
  from typing import Protocol
12
 
13
+ from .recognisers import Span, find_rule_spans
14
 
15
 
16
  class Detector(Protocol):
 
70
  self.engine = AnalyzerEngine(nlp_engine=provider.create_engine())
71
  self.score_threshold = score_threshold
72
  self.review_threshold = review_threshold
73
+ self._register_uk_recognisers()
74
 
75
+ def _register_uk_recognisers(self) -> None:
76
+ """Register UK entity recognisers that Presidio documents but does not ship."""
77
  from presidio_analyzer import Pattern, PatternRecognizer
78
 
79
  custom = [
{noteguard → src}/evaluate.py RENAMED
@@ -21,7 +21,7 @@ from datetime import datetime
21
 
22
  from .data import NoteRecord
23
  from .detect import Detector
24
- from .recognizers import Span
25
  from .transform import REDACTION, PseudonymVault, apply_transform
26
 
27
  _DATE_FORMATS = ["%d/%m/%Y", "%d-%m-%Y", "%Y-%m-%d", "%d/%m/%y", "%d-%m-%y", "%d %b %Y", "%d %B %Y"]
 
21
 
22
  from .data import NoteRecord
23
  from .detect import Detector
24
+ from .recognisers import Span
25
  from .transform import REDACTION, PseudonymVault, apply_transform
26
 
27
  _DATE_FORMATS = ["%d/%m/%Y", "%d-%m-%Y", "%Y-%m-%d", "%d/%m/%y", "%d-%m-%y", "%d %b %Y", "%d %B %Y"]
{noteguard → src}/pipeline.py RENAMED
@@ -8,7 +8,7 @@ from collections import Counter
8
  from dataclasses import dataclass, field
9
 
10
  from .detect import Detector, build_detector
11
- from .recognizers import Span
12
  from .transform import REDACTION, PseudonymVault, Replacement, apply_transform
13
 
14
 
 
8
  from dataclasses import dataclass, field
9
 
10
  from .detect import Detector, build_detector
11
+ from .recognisers import Span
12
  from .transform import REDACTION, PseudonymVault, Replacement, apply_transform
13
 
14
 
noteguard/recognizers.py → src/recognisers.py RENAMED
File without changes
{noteguard → src}/transform.py RENAMED
@@ -21,7 +21,7 @@ import string
21
  from dataclasses import dataclass, field
22
  from datetime import datetime, timedelta
23
 
24
- from .recognizers import Span
25
 
26
  REDACTION = "redaction"
27
  PSEUDONYM = "pseudonym"
@@ -57,7 +57,7 @@ _POSTCODE_INWARD = re.compile(r"\s*\d[A-Za-z]{2}\s*$")
57
  try: # realistic surrogates if Faker is available
58
  from faker import Faker
59
 
60
- _FAKER: "Faker | None" = Faker("en_GB")
61
  except Exception: # pragma: no cover - keeps the pure-Python path working
62
  _FAKER = None
63
 
@@ -156,7 +156,7 @@ def _fake_vehicle(value: str) -> str:
156
 
157
  def _fake_nhs_number(value: str) -> str:
158
  """Deterministic, checksum-VALID fake NHS number (stable per original)."""
159
- from .recognizers import nhs_number_is_valid
160
 
161
  seed = _seed(value)
162
  for _ in range(1000):
@@ -211,7 +211,7 @@ def apply_transform(
211
 
212
 
213
  if __name__ == "__main__":
214
- from .recognizers import find_rule_spans
215
 
216
  txt = "Pt John seen 12/03/1981, NHS 943 476 5919. Reviewed again 20/03/1981."
217
  spans = find_rule_spans(txt)
 
21
  from dataclasses import dataclass, field
22
  from datetime import datetime, timedelta
23
 
24
+ from .recognisers import Span
25
 
26
  REDACTION = "redaction"
27
  PSEUDONYM = "pseudonym"
 
57
  try: # realistic surrogates if Faker is available
58
  from faker import Faker
59
 
60
+ _FAKER: Faker | None = Faker("en_GB")
61
  except Exception: # pragma: no cover - keeps the pure-Python path working
62
  _FAKER = None
63
 
 
156
 
157
  def _fake_nhs_number(value: str) -> str:
158
  """Deterministic, checksum-VALID fake NHS number (stable per original)."""
159
+ from .recognisers import nhs_number_is_valid
160
 
161
  seed = _seed(value)
162
  for _ in range(1000):
 
211
 
212
 
213
  if __name__ == "__main__":
214
+ from .recognisers import find_rule_spans
215
 
216
  txt = "Pt John seen 12/03/1981, NHS 943 476 5919. Reviewed again 20/03/1981."
217
  spans = find_rule_spans(txt)
{noteguard → src}/trust_demo.py RENAMED
@@ -6,8 +6,8 @@ de-identified text + a content-free audit manifest to a shared pool. Raw notes a
6
  vaults never leave the Trust. This is the sanitise-at-source gate that sits in
7
  front of a federated SDE / FLock.io training round.
8
 
9
- python -m noteguard.trust_demo # pseudonymise, 300 notes
10
- python -m noteguard.trust_demo redaction 600
11
  """
12
  from __future__ import annotations
13
 
@@ -18,11 +18,11 @@ from pathlib import Path
18
 
19
  from .data import NoteRecord, load_notes
20
  from .detect import build_detector
21
- from .evaluate import ground_truth_spans, value_variants, _find_all
22
  from .pipeline import Pipeline
23
  from .transform import PSEUDONYM, PseudonymVault
24
 
25
- OUT_DIR = Path(__file__).resolve().parent.parent / "data" / "out"
26
  TRUST_NAMES = {0: "Trust A (Northgate NHS Foundation Trust)", 1: "Trust B (Riverside NHS Trust)"}
27
 
28
 
 
6
  vaults never leave the Trust. This is the sanitise-at-source gate that sits in
7
  front of a federated SDE / FLock.io training round.
8
 
9
+ python -m src.trust_demo # pseudonymise, 300 notes
10
+ python -m src.trust_demo redaction 600
11
  """
12
  from __future__ import annotations
13
 
 
18
 
19
  from .data import NoteRecord, load_notes
20
  from .detect import build_detector
21
+ from .evaluate import _find_all, ground_truth_spans, value_variants
22
  from .pipeline import Pipeline
23
  from .transform import PSEUDONYM, PseudonymVault
24
 
25
+ OUT_DIR = Path(__file__).resolve().parent.parent / "output"
26
  TRUST_NAMES = {0: "Trust A (Northgate NHS Foundation Trust)", 1: "Trust B (Riverside NHS Trust)"}
27
 
28
 
app/streamlit_app.py → streamlit_app.py RENAMED
@@ -3,7 +3,7 @@
3
  Run from the repo root: streamlit run app/streamlit_app.py
4
 
5
  Try-it (detect & sanitise) · Metrics & leakage · Governance (Five Safes) · Two-Trust sharing.
6
- Built on the noteguard package (pluggable detectors + patient-consistent transforms).
7
  """
8
  from __future__ import annotations
9
 
@@ -18,14 +18,14 @@ import streamlit as st
18
  REPO = Path(__file__).resolve().parent.parent
19
  sys.path.insert(0, str(REPO))
20
 
21
- from noteguard.data import load_notes # noqa: E402
22
- from noteguard.detect import build_detector # noqa: E402
23
- from noteguard.evaluate import evaluate # noqa: E402
24
- from noteguard.pipeline import Pipeline # noqa: E402
25
- from noteguard.transform import PSEUDONYM, REDACTION, PseudonymVault # noqa: E402
26
 
27
- OUT_DIR = REPO / "data" / "out"
28
- RESULTS = REPO / "results.json"
29
 
30
  ENTITY_COLORS = {
31
  "PERSON": "#ffd6e0", "UK_NHS": "#ffe9b3", "DATE_TIME": "#d4f4dd", "UK_POSTCODE": "#cfe8ff",
@@ -271,14 +271,14 @@ with tab_trust:
271
  )
272
  summary = load_json(OUT_DIR / "trust_demo_summary.json")
273
  if st.button("▶ Run two-Trust demo"):
274
- from noteguard.trust_demo import main as run_trust
275
  with st.spinner("Sanitising at each Trust…"):
276
  run_trust()
277
  summary = load_json(OUT_DIR / "trust_demo_summary.json")
278
 
279
  if summary:
280
  cols = st.columns(len(summary["trusts"]) + 1)
281
- for col, t in zip(cols, summary["trusts"]):
282
  with col:
283
  st.markdown(f"#### 🏥 {t['trust'].split('(')[0].strip()}")
284
  st.metric("Notes de-identified", t["notes_deidentified"])
@@ -292,4 +292,4 @@ with tab_trust:
292
  st.metric("Total residual leaks", summary["total_residual_leaks"])
293
  st.caption("→ ready for federated AI / FLock.io")
294
  else:
295
- st.info("Click **Run two-Trust demo** or run `python -m noteguard.trust_demo`.")
 
3
  Run from the repo root: streamlit run app/streamlit_app.py
4
 
5
  Try-it (detect & sanitise) · Metrics & leakage · Governance (Five Safes) · Two-Trust sharing.
6
+ Built on the NoteGuard package (src/) — pluggable detectors + patient-consistent transforms.
7
  """
8
  from __future__ import annotations
9
 
 
18
  REPO = Path(__file__).resolve().parent.parent
19
  sys.path.insert(0, str(REPO))
20
 
21
+ from src.data import load_notes # noqa: E402
22
+ from src.detect import build_detector # noqa: E402
23
+ from src.evaluate import evaluate # noqa: E402
24
+ from src.pipeline import Pipeline # noqa: E402
25
+ from src.transform import PSEUDONYM, REDACTION, PseudonymVault # noqa: E402
26
 
27
+ OUT_DIR = REPO / "output"
28
+ RESULTS = REPO / "output" / "results.json"
29
 
30
  ENTITY_COLORS = {
31
  "PERSON": "#ffd6e0", "UK_NHS": "#ffe9b3", "DATE_TIME": "#d4f4dd", "UK_POSTCODE": "#cfe8ff",
 
271
  )
272
  summary = load_json(OUT_DIR / "trust_demo_summary.json")
273
  if st.button("▶ Run two-Trust demo"):
274
+ from src.trust_demo import main as run_trust
275
  with st.spinner("Sanitising at each Trust…"):
276
  run_trust()
277
  summary = load_json(OUT_DIR / "trust_demo_summary.json")
278
 
279
  if summary:
280
  cols = st.columns(len(summary["trusts"]) + 1)
281
+ for col, t in zip(cols, summary["trusts"], strict=False):
282
  with col:
283
  st.markdown(f"#### 🏥 {t['trust'].split('(')[0].strip()}")
284
  st.metric("Notes de-identified", t["notes_deidentified"])
 
292
  st.metric("Total residual leaks", summary["total_residual_leaks"])
293
  st.caption("→ ready for federated AI / FLock.io")
294
  else:
295
+ st.info("Click **Run two-Trust demo** or run `python -m src.trust_demo`.")
run_eval.py → tests/run_eval.py RENAMED
@@ -1,20 +1,30 @@
1
  """Run the NoteGuard evaluation over the NHSE synthetic dataset.
2
 
3
- python run_eval.py --limit 300 # quick run
4
- python run_eval.py --method pseudonym # leakage under pseudonymisation
5
- python run_eval.py --compare # rules-only vs presidio+rules
6
 
7
- Writes results.json (consumed by the demo's metrics panel) and prints a summary.
 
8
  """
9
  from __future__ import annotations
10
 
11
  import argparse
12
  import json
 
 
 
13
 
14
- from noteguard.data import load_notes
15
- from noteguard.detect import RuleDetector, build_detector
16
- from noteguard.evaluate import EvalResult, evaluate
17
- from noteguard.transform import REDACTION
 
 
 
 
 
 
18
 
19
 
20
  def _print_summary(res: EvalResult) -> None:
@@ -33,18 +43,19 @@ def _print_summary(res: EvalResult) -> None:
33
 
34
 
35
  def main() -> None:
 
36
  ap = argparse.ArgumentParser()
37
  ap.add_argument("--limit", type=int, default=300, help="max notes (None=all)")
38
  ap.add_argument("--method", default=REDACTION, choices=["redaction", "pseudonym"])
39
  ap.add_argument("--no-presidio", action="store_true", help="rules only")
40
  ap.add_argument("--compare", action="store_true", help="rules vs presidio+rules")
41
- ap.add_argument("--out", default="results.json")
42
  args = ap.parse_args()
43
 
44
- print(f"[noteguard] loading notes (limit={args.limit}) ...")
45
  records = load_notes(limit=args.limit)
46
- print(f"[noteguard] {len(records)} notes; "
47
- f"{sum(len(r.ground_truth) for r in records)} known PII values joined.")
48
 
49
  runs: dict[str, EvalResult] = {}
50
  if args.compare:
@@ -52,8 +63,7 @@ def main() -> None:
52
  runs["rules"] = evaluate(records, RuleDetector(), args.method)
53
  _print_summary(runs["rules"])
54
  print("\n=== presidio+rules (shipping headline detector) ===")
55
- presidio = build_detector(True)
56
- runs["presidio+rules"] = evaluate(records, presidio, args.method)
57
  _print_summary(runs["presidio+rules"])
58
  else:
59
  det = RuleDetector() if args.no_presidio else build_detector(True)
@@ -61,10 +71,10 @@ def main() -> None:
61
  _print_summary(res)
62
  runs[res.detector_name] = res
63
 
64
- payload = {name: r.to_dict() for name, r in runs.items()}
65
- with open(args.out, "w") as f:
66
- json.dump(payload, f, indent=2)
67
- print(f"\n[noteguard] wrote {args.out}")
68
 
69
 
70
  if __name__ == "__main__":
 
1
  """Run the NoteGuard evaluation over the NHSE synthetic dataset.
2
 
3
+ python tests/run_eval.py --limit 300 # quick run
4
+ python tests/run_eval.py --method pseudonym # leakage under pseudonymisation
5
+ python tests/run_eval.py --compare # rules-only vs presidio+rules
6
 
7
+ Writes output/results.json (consumed by the Streamlit metrics panel) and prints a summary.
8
+ This is the pipeline's evaluation entry point; it lives under tests/ alongside the unit tests.
9
  """
10
  from __future__ import annotations
11
 
12
  import argparse
13
  import json
14
+ import logging
15
+ import sys
16
+ from pathlib import Path
17
 
18
+ REPO = Path(__file__).resolve().parent.parent
19
+ sys.path.insert(0, str(REPO)) # make the `src` package importable when run as a script
20
+
21
+ from src.data import load_notes # noqa: E402
22
+ from src.detect import RuleDetector, build_detector # noqa: E402
23
+ from src.evaluate import EvalResult, evaluate # noqa: E402
24
+ from src.transform import REDACTION # noqa: E402
25
+
26
+ OUTPUT_DIR = REPO / "output"
27
+ logger = logging.getLogger("noteguard.eval")
28
 
29
 
30
  def _print_summary(res: EvalResult) -> None:
 
43
 
44
 
45
  def main() -> None:
46
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
47
  ap = argparse.ArgumentParser()
48
  ap.add_argument("--limit", type=int, default=300, help="max notes (None=all)")
49
  ap.add_argument("--method", default=REDACTION, choices=["redaction", "pseudonym"])
50
  ap.add_argument("--no-presidio", action="store_true", help="rules only")
51
  ap.add_argument("--compare", action="store_true", help="rules vs presidio+rules")
52
+ ap.add_argument("--out", default=None, help="output JSON path (default: output/results.json)")
53
  args = ap.parse_args()
54
 
55
+ logger.info("loading notes (limit=%s) ...", args.limit)
56
  records = load_notes(limit=args.limit)
57
+ logger.info("%d notes; %d known PII values joined.",
58
+ len(records), sum(len(r.ground_truth) for r in records))
59
 
60
  runs: dict[str, EvalResult] = {}
61
  if args.compare:
 
63
  runs["rules"] = evaluate(records, RuleDetector(), args.method)
64
  _print_summary(runs["rules"])
65
  print("\n=== presidio+rules (shipping headline detector) ===")
66
+ runs["presidio+rules"] = evaluate(records, build_detector(True), args.method)
 
67
  _print_summary(runs["presidio+rules"])
68
  else:
69
  det = RuleDetector() if args.no_presidio else build_detector(True)
 
71
  _print_summary(res)
72
  runs[res.detector_name] = res
73
 
74
+ OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
75
+ out_path = Path(args.out) if args.out else OUTPUT_DIR / "results.json"
76
+ out_path.write_text(json.dumps({n: r.to_dict() for n, r in runs.items()}, indent=2), encoding="utf-8")
77
+ logger.info("wrote %s", out_path)
78
 
79
 
80
  if __name__ == "__main__":
tests/test_nhs_number.py CHANGED
@@ -1,5 +1,5 @@
1
  """NHS number Modulus-11 checksum and all surface forms the dataset uses."""
2
- from noteguard.recognizers import UK_NHS, find_rule_spans, nhs_number_is_valid
3
 
4
 
5
  def _has_nhs(text: str) -> bool:
 
1
  """NHS number Modulus-11 checksum and all surface forms the dataset uses."""
2
+ from src.recognisers import UK_NHS, find_rule_spans, nhs_number_is_valid
3
 
4
 
5
  def _has_nhs(text: str) -> bool:
tests/test_pipeline.py CHANGED
@@ -2,9 +2,9 @@
2
 
3
  Uses the pure-Python RuleDetector so the test is fast and needs no spaCy/Presidio.
4
  """
5
- from noteguard.detect import RuleDetector
6
- from noteguard.pipeline import Pipeline
7
- from noteguard.transform import PSEUDONYM
8
 
9
 
10
  def test_pipeline_removes_known_pii():
 
2
 
3
  Uses the pure-Python RuleDetector so the test is fast and needs no spaCy/Presidio.
4
  """
5
+ from src.detect import RuleDetector
6
+ from src.pipeline import Pipeline
7
+ from src.transform import PSEUDONYM
8
 
9
 
10
  def test_pipeline_removes_known_pii():
tests/{test_recognizers.py → test_recognisers.py} RENAMED
@@ -1,6 +1,12 @@
1
  """Rule-layer recognisers: NHS checksum + the folded-in NHS/staff/org identifiers."""
2
- from noteguard.recognizers import (
3
- GMC, NHS_ODS, NMC, RECORD_ID, UK_NHS, find_rule_spans, nhs_number_is_valid,
 
 
 
 
 
 
4
  )
5
 
6
 
@@ -39,10 +45,10 @@ def test_record_uuid():
39
 
40
 
41
  def test_uk_nino():
42
- from noteguard.recognizers import UK_NINO
43
  assert UK_NINO in _types("NI: AB 12 34 56 C")
44
 
45
 
46
  def test_uk_vehicle_registration():
47
- from noteguard.recognizers import UK_VEHICLE_REGISTRATION
48
  assert UK_VEHICLE_REGISTRATION in _types("vehicle AB12 CDE")
 
1
  """Rule-layer recognisers: NHS checksum + the folded-in NHS/staff/org identifiers."""
2
+ from src.recognisers import (
3
+ GMC,
4
+ NHS_ODS,
5
+ NMC,
6
+ RECORD_ID,
7
+ UK_NHS,
8
+ find_rule_spans,
9
+ nhs_number_is_valid,
10
  )
11
 
12
 
 
45
 
46
 
47
  def test_uk_nino():
48
+ from src.recognisers import UK_NINO
49
  assert UK_NINO in _types("NI: AB 12 34 56 C")
50
 
51
 
52
  def test_uk_vehicle_registration():
53
+ from src.recognisers import UK_VEHICLE_REGISTRATION
54
  assert UK_VEHICLE_REGISTRATION in _types("vehicle AB12 CDE")
tests/test_transform.py CHANGED
@@ -2,9 +2,13 @@
2
  import re
3
  from datetime import datetime
4
 
5
- from noteguard.recognizers import find_rule_spans, nhs_number_is_valid
6
- from noteguard.transform import (
7
- PSEUDONYM, REDACTION, PseudonymVault, _fake_nhs_number, apply_transform,
 
 
 
 
8
  )
9
 
10
 
 
2
  import re
3
  from datetime import datetime
4
 
5
+ from src.recognisers import find_rule_spans, nhs_number_is_valid
6
+ from src.transform import (
7
+ PSEUDONYM,
8
+ REDACTION,
9
+ PseudonymVault,
10
+ _fake_nhs_number,
11
+ apply_transform,
12
  )
13
 
14