radionAI / phi.py
htgudi's picture
deploy 6bcac61
5bb3295
Raw History Blame Contribute Delete
5.81 kB
"""
Patient identifiers — YashTech Ltd. AI Labs.
Three rules, enforced here rather than trusted to the UI:
1. **Identifiers never reach the model.** They label the report; they are not
clinical context. MedGemma runs on shared multi-tenant hardware, so a name
in a prompt is a name on someone else's GPU. `scrub()` strips them from
free text on the way in.
2. **Pseudonymised by default.** The report header shows a stable token such
as `PT-K4X9M2QF`, derived by HMAC from the identifier and a secret salt.
Same patient, same token, across reports and sessions — which is what makes
a pseudonym useful — without ever writing the identifier down.
3. **No mapping table.** Re-identification is by recomputing the token from an
identifier you already hold, not by looking it up. Nothing to leak, nothing
to subject-access-request, nothing to delete.
Showing a real identifier on a report is a deliberate act by someone holding the
`show_identifiers` permission, and the report says so in its header.
`PSEUDONYM_SALT` must be set as a secret. Without it a random salt is generated
at boot: tokens stay unguessable but stop being stable across restarts, and the
admin console says so rather than quietly producing a different token for the
same patient tomorrow.
"""
from __future__ import annotations
import base64
import dataclasses
import hashlib
import hmac
import os
import re
import secrets
PSEUDONYMISE = "pseudonymise"
OMIT = "omit"
FULL = "full"
MODES = (
(PSEUDONYMISE, "Pseudonymised token (recommended)"),
(OMIT, "No identifiers at all"),
(FULL, "Show as entered (requires permission)"),
)
MODE_CHOICES = [(label, value) for value, label in MODES]
DEFAULT_MODE = PSEUDONYMISE
_EPHEMERAL_SALT = secrets.token_hex(32)
def salt() -> tuple[bytes, bool]:
"""(salt, is_stable). An unset salt is usable but not stable across restarts."""
configured = os.getenv("PSEUDONYM_SALT", "").strip()
if configured:
return configured.encode("utf-8"), True
return _EPHEMERAL_SALT.encode("utf-8"), False
def salt_status() -> str:
_key, stable = salt()
return (
"configured — tokens are stable across restarts"
if stable
else "NOT SET — tokens change on every restart; set PSEUDONYM_SALT as a secret"
)
def _normalise(value: str) -> str:
"""So `NHS 123 456 7890`, `nhs1234567890` and `123-456-7890` agree."""
return re.sub(r"[^a-z0-9]", "", (value or "").strip().lower())
def pseudonym(identifier: str, prefix: str = "PT") -> str:
key, _stable = salt()
norm = _normalise(identifier)
if not norm:
return ""
digest = hmac.new(key, norm.encode("utf-8"), hashlib.sha256).digest()
token = base64.b32encode(digest).decode("ascii").rstrip("=")[:8]
return f"{prefix}-{token}"
@dataclasses.dataclass(frozen=True)
class Patient:
patient_id: str = ""
name: str = ""
mode: str = DEFAULT_MODE
@property
def supplied(self) -> bool:
return bool(self.patient_id.strip() or self.name.strip())
@property
def token(self) -> str:
"""Stable token from the ID if there is one, otherwise from the name."""
return pseudonym(self.patient_id) or pseudonym(self.name, prefix="PN")
def terms(self) -> list[str]:
"""Strings that must never appear in a prompt."""
out = []
for value in (self.patient_id, self.name):
value = (value or "").strip()
if len(value) >= 3:
out.append(value)
out.extend(p for p in re.split(r"[\s,]+", value) if len(p) >= 3)
return sorted(set(out), key=len, reverse=True)
def resolve(patient: Patient, may_show_identifiers: bool) -> tuple[Patient, str | None]:
"""Downgrade `full` to pseudonymised when the user lacks the permission."""
if patient.mode == FULL and not may_show_identifiers:
return (
dataclasses.replace(patient, mode=PSEUDONYMISE),
"Your role cannot show identifiers on a report — exported pseudonymised instead.",
)
if patient.mode not in {m for m, _ in MODES}:
return dataclasses.replace(patient, mode=DEFAULT_MODE), None
return patient, None
def header_lines(patient: Patient) -> list[str]:
"""Markdown bullets for the report header. Never raises on bad input."""
if not patient.supplied or patient.mode == OMIT:
return ["- **Patient:** not recorded (identifiers omitted)"]
if patient.mode == FULL:
lines = ["- **Patient identifiers: SHOWN IN FULL — handle as clinical record**"]
if patient.patient_id.strip():
lines.append(f"- **Patient ID:** {patient.patient_id.strip()}")
if patient.name.strip():
lines.append(f"- **Patient name:** {patient.name.strip()}")
lines.append(f"- **Pseudonym:** {patient.token}")
return lines
lines = [f"- **Patient:** {patient.token} (pseudonymised)"]
_key, stable = salt()
if not stable:
lines.append("- **Note:** pseudonym salt not configured — this token is not reproducible")
return lines
def scrub(text: str, patient: Patient) -> str:
"""Remove identifiers from text heading for the model.
Deliberately blunt. This is a backstop for a name typed into the clinical
indication box, not a de-identification engine: it cannot catch an
identifier nobody entered into the patient fields.
"""
if not text or not patient.supplied:
return text
cleaned = text
for term in patient.terms():
cleaned = re.sub(re.escape(term), "[redacted]", cleaned, flags=re.IGNORECASE)
return cleaned
def prompt_context(patient: Patient) -> str:
"""What the model is allowed to know: nothing identifying."""
return ""