phi-gate 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
phi_gate/policy.py ADDED
@@ -0,0 +1,97 @@
1
+ """Turn findings into a decision: allow, warn or block.
2
+
3
+ Rules, in order:
4
+ 1. Any HIGH finding blocks. These identify a person on their own.
5
+ 2. A clinical fact (a labelled diagnosis code, a date of service) blocks
6
+ when an identifier (a labelled name, phone, email, address) sits within
7
+ `record_window` characters. PHI is identity plus health information:
8
+ "Jane Doe ... Dx: F84.0" is a patient record.
9
+ 3. Other MEDIUM findings warn. A name next to an email is a package.json
10
+ author field or a staff directory, not PHI by itself.
11
+ 4. LOW findings (provider NPI/DEA, public IPs) are reported, never blocking.
12
+
13
+ A change to a file (a commit) is judged by what it adds, read in the context
14
+ of the whole file: `changed` says which findings the change touched. Only
15
+ those block or warn, so a file already holding PHI that was allowed in before
16
+ does not block every later edit; but a record pair counts when either side is
17
+ new, because a diagnosis added next to a patient's name makes a record even
18
+ though the name was there already.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from collections import Counter
24
+ from collections.abc import Callable
25
+ from dataclasses import dataclass, field
26
+
27
+ from .detectors import CLINICAL, CONTACT, HIGH, LOW, MEDIUM, PERSON, Finding
28
+
29
+ ALLOW, WARN, BLOCK = "allow", "warn", "block"
30
+ IDENTITY_GROUPS = (PERSON, CONTACT)
31
+
32
+
33
+ @dataclass
34
+ class Verdict:
35
+ decision: str
36
+ findings: list[Finding] = field(default_factory=list)
37
+ reasons: list[str] = field(default_factory=list)
38
+
39
+ @property
40
+ def blocking(self) -> bool:
41
+ return self.decision == BLOCK
42
+
43
+ def counts(self, include_low: bool = False) -> Counter:
44
+ return Counter(
45
+ f.category for f in self.findings if include_low or f.severity != LOW
46
+ )
47
+
48
+ def summary(self, include_low: bool = False) -> str:
49
+ c = self.counts(include_low)
50
+ return ", ".join(f"{k} x{v}" for k, v in sorted(c.items())) or "none"
51
+
52
+ def lines(self, limit: int = 8) -> str:
53
+ nums = sorted({f.line for f in self.findings if f.severity != LOW and f.line})
54
+ if not nums:
55
+ return ""
56
+ shown = ", ".join(str(n) for n in nums[:limit])
57
+ more = f" (+{len(nums) - limit} more)" if len(nums) > limit else ""
58
+ label = "line" if len(nums) == 1 else "lines"
59
+ return f"{label} {shown}{more}"
60
+
61
+
62
+ def evaluate(
63
+ findings: list[Finding],
64
+ record_window: int = 400,
65
+ changed: Callable[[Finding], bool] | None = None,
66
+ ) -> Verdict:
67
+ """The decision for `findings`. With `changed`, only the findings it
68
+ accepts are judged, and the rest are context for the record rule."""
69
+ new = findings if changed is None else [f for f in findings if changed(f)]
70
+ reasons: list[str] = []
71
+ high = [f for f in new if f.severity == HIGH]
72
+ if high:
73
+ reasons.append("direct identifiers: " + ", ".join(sorted({f.category for f in high})))
74
+ return Verdict(BLOCK, new, reasons)
75
+
76
+ medium = sorted((f for f in findings if f.severity == MEDIUM), key=lambda f: f.start)
77
+ # An identifier a clinical fact can pair with. A HIGH one has blocked on its
78
+ # own already unless it was in the file before the change, and then it is
79
+ # the strongest identifier a new diagnosis could sit beside.
80
+ identities = [f for f in findings if f.severity == HIGH or (f.severity == MEDIUM and f.group in IDENTITY_GROUPS)]
81
+ fresh = {id(f) for f in new}
82
+ for anchor in medium:
83
+ if anchor.group != CLINICAL:
84
+ continue
85
+ for other in identities:
86
+ if id(anchor) not in fresh and id(other) not in fresh:
87
+ continue
88
+ gap = max(other.start - anchor.end, anchor.start - other.end, 0)
89
+ if gap <= record_window:
90
+ reasons.append(f"record-like: {anchor.category} near {other.category}")
91
+ pair = [f for f in (anchor, other) if id(f) not in fresh]
92
+ return Verdict(BLOCK, new + pair, reasons)
93
+
94
+ if any(f.severity == MEDIUM for f in new):
95
+ reasons.append("possible identifiers: " + ", ".join(sorted({f.category for f in new if f.severity == MEDIUM})))
96
+ return Verdict(WARN, new, reasons)
97
+ return Verdict(ALLOW, new, reasons)
phi_gate/py.typed ADDED
File without changes
phi_gate/redact.py ADDED
@@ -0,0 +1,236 @@
1
+ """Reversible, typed redaction.
2
+
3
+ `redact("DOB: <a date>, SSN <an SSN>")` returns
4
+ `"DOB: [DOB_1], SSN [SSN_1]"` plus a mapping from placeholder to original
5
+ value. The same value always gets the same placeholder within one call, so
6
+ relationships survive ("[PERSON_NAME_1] called back about [MRN_1]").
7
+
8
+ A placeholder is never one that the input already contains as literal text:
9
+ numbering skips any `[NAME_N]` already present. `restore` substitutes only the
10
+ placeholders in its mapping, in one pass, so it cannot write a value where
11
+ the document had only text.
12
+
13
+ The mapping contains the original PHI. Keep it local and never commit it.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import bisect
19
+ import re
20
+
21
+ from .config import Config
22
+ from .detectors import (
23
+ DIRECT,
24
+ FIRST_NAME_COLUMNS,
25
+ HIGH,
26
+ LAST_NAME_COLUMNS,
27
+ LOW,
28
+ QUASI_COLUMNS,
29
+ SEVERITY_RANK,
30
+ Finding,
31
+ Table,
32
+ cell_spans,
33
+ detect,
34
+ find_tables,
35
+ is_direct_column,
36
+ is_rule_row,
37
+ normalize,
38
+ )
39
+
40
+ # Columns that describe care or an organisation rather than a person. Safe
41
+ # Harbor removes identifiers; this content can stay, which is what keeps a
42
+ # redacted export useful ("group denials by diagnosis").
43
+ _KEEP_COLUMNS = frozenset({"diagnosis", "dx", "icd10", "icd_10", "icd10_code", "diagnosis_code"})
44
+ _KEEP_TOKENS = frozenset({"payer", "plan", "provider", "facility", "clinic", "carrier", "organization"})
45
+
46
+ # Words that mark a column as identifying someone. This list errs on the side
47
+ # of redacting: the cost of a false match is one less useful column.
48
+ _IDENTIFIER_TOKENS = frozenset({
49
+ "name", "first", "last", "given", "family", "surname", "patient", "pt", "client", "member", "mbr",
50
+ "subscriber", "insured", "beneficiary", "guarantor", "contact", "address", "address1", "address2",
51
+ "street", "city", "zip", "zipcode", "postal", "phone", "mobile", "fax", "email", "ssn", "dob", "mrn",
52
+ "mbi", "hicn", "birth", "birthdate", "account", "acct", "policy", "medicaid", "medicare", "claim",
53
+ "encounter", "note", "notes", "comment", "comments", "memo", "narrative",
54
+ })
55
+
56
+ _PLACEHOLDER_TOKEN = re.compile(r"\[[A-Z][A-Z0-9_]*_\d+\]")
57
+
58
+
59
+ def is_identifier_column(col: str) -> bool:
60
+ """Whether redaction removes a column's values. A column that identifies
61
+ someone on its own always goes, even when it also names a payer or plan."""
62
+ if is_direct_column(col):
63
+ return True
64
+ if col in _KEEP_COLUMNS:
65
+ return False
66
+ tokens = set(col.split("_"))
67
+ if tokens & _KEEP_TOKENS:
68
+ return False
69
+ return col in FIRST_NAME_COLUMNS or col in LAST_NAME_COLUMNS or col in QUASI_COLUMNS or bool(tokens & _IDENTIFIER_TOKENS)
70
+
71
+
72
+ def _value_bounds(text: str, a: int, b: int) -> tuple[int, int]:
73
+ """A cell's value without its padding or quotes, so they survive."""
74
+ while a < b and text[a] in ' \t"':
75
+ a += 1
76
+ while b > a and text[b - 1] in ' \t"':
77
+ b -= 1
78
+ return a, b
79
+
80
+
81
+ def _cell_finding(text: str, a: int, b: int, category: str) -> Finding | None:
82
+ """A finding for a cell's value, or None for an empty cell."""
83
+ a, b = _value_bounds(text, a, b)
84
+ return Finding(category, HIGH, DIRECT, a, b) if a < b else None
85
+
86
+
87
+ # A table row redacted column by column: its offsets and its cells' offsets.
88
+ _Row = tuple[int, int, list[tuple[int, int]]]
89
+
90
+
91
+ def _table_cells(text: str, table: Table) -> tuple[list[Finding], list[_Row]]:
92
+ """Turn one table into per-cell findings for identifier columns, so the
93
+ redacted table keeps its shape and its non-identifying columns. A row that
94
+ does not line up with the header cannot be trusted column by column, so
95
+ all of it goes. Also returns the rows handled cell by cell."""
96
+ cols = table.header.columns
97
+ targets = [i for i, c in enumerate(cols) if is_identifier_column(c)]
98
+ if not targets:
99
+ return [Finding("tabular_phi", HIGH, DIRECT, table.start, table.end)], []
100
+ cells: list[Finding] = []
101
+ rows: list[_Row] = []
102
+ for start, end in table.rows:
103
+ row = text[start:end]
104
+ if is_rule_row(row):
105
+ continue
106
+ spans = cell_spans(row, table.header.delimiter)
107
+ if len(spans) != len(cols):
108
+ found = _cell_finding(text, start, end, "row")
109
+ cells += [found] if found else []
110
+ continue
111
+ rows.append((start, end, [(start + a, start + b) for a, b in spans]))
112
+ for idx in targets:
113
+ a, b = spans[idx]
114
+ found = _cell_finding(text, start + a, start + b, cols[idx])
115
+ cells += [found] if found else []
116
+ return cells, rows
117
+
118
+
119
+ def _piece(text: str, f: Finding, a: int, b: int) -> list[Finding]:
120
+ """The part of `f` in [a, b) without surrounding whitespace, if any."""
121
+ while a < b and text[a].isspace():
122
+ a += 1
123
+ while b > a and text[b - 1].isspace():
124
+ b -= 1
125
+ return [Finding(f.category, f.severity, f.group, a, b)] if a < b else []
126
+
127
+
128
+ def _within_cells(text: str, f: Finding, rows: list[_Row]) -> list[Finding]:
129
+ """A finding that overlaps table rows, cut at the cell boundaries: every
130
+ cell it touches goes whole, and the delimiters and line breaks stay, so
131
+ each row keeps its width and the rest of its columns stay readable."""
132
+ k = max(bisect.bisect_right(rows, (f.start, float("inf"))) - 1, 0)
133
+ out: list[Finding] = []
134
+ cursor = f.start
135
+ while k < len(rows) and rows[k][0] < f.end:
136
+ start, end, cells = rows[k]
137
+ k += 1
138
+ if end <= f.start:
139
+ continue
140
+ out += _piece(text, f, cursor, start)
141
+ for a, b in cells:
142
+ if a < f.end and f.start < b:
143
+ out += _piece(text, f, *_value_bounds(text, a, b))
144
+ cursor = max(cursor, end)
145
+ return out + _piece(text, f, cursor, f.end)
146
+
147
+
148
+ def _select(findings: list[Finding], include_low: bool) -> list[Finding]:
149
+ """Non-overlapping spans to replace. Overlapping findings merge into one
150
+ span covering both, so no part of either value is left behind; it takes
151
+ the category of the wider finding, or the more severe one on a tie."""
152
+ chosen: list[Finding] = []
153
+ for f in sorted(findings, key=lambda f: (f.start, -(f.end - f.start), -SEVERITY_RANK[f.severity])):
154
+ if f.severity == LOW and not include_low:
155
+ continue
156
+ if chosen and f.start < chosen[-1].end:
157
+ prev = chosen[-1]
158
+ wider = (f.end - f.start, SEVERITY_RANK[f.severity]) > (prev.end - prev.start, SEVERITY_RANK[prev.severity])
159
+ kind = f if wider else prev
160
+ chosen[-1] = Finding(kind.category, kind.severity, kind.group, prev.start, max(prev.end, f.end))
161
+ continue
162
+ chosen.append(f)
163
+ return chosen
164
+
165
+
166
+ def _findings(text: str, cfg: Config | None) -> list[Finding]:
167
+ """Findings in original-text offsets, with tables split into cells."""
168
+ disabled = frozenset(cfg.disable) if cfg else frozenset()
169
+ view = normalize(text)
170
+ found = detect(view.text, disabled)
171
+ tables = {(t.start, t.end): t for t in find_tables(view.text)} if any(
172
+ f.category == "tabular_phi" for f in found
173
+ ) else {}
174
+ kept = []
175
+ for f in found:
176
+ s, e = view.to_original(f.start, f.end)
177
+ if not (cfg and cfg.allow_values and cfg.is_allowed_value(text[s:e])):
178
+ kept.append(f)
179
+ parts: list[Finding] = []
180
+ rows: list[_Row] = []
181
+ for f in kept:
182
+ table = tables.get((f.start, f.end)) if f.category == "tabular_phi" else None
183
+ if table is not None:
184
+ cells, table_rows = _table_cells(view.text, table)
185
+ parts += cells
186
+ rows += table_rows
187
+ rows.sort()
188
+ for f in kept:
189
+ if f.category == "tabular_phi" and (f.start, f.end) in tables:
190
+ continue
191
+ parts += _within_cells(view.text, f, rows) if rows else [f]
192
+ out: list[Finding] = []
193
+ for part in parts:
194
+ ps, pe = view.to_original(part.start, part.end)
195
+ out.append(Finding(part.category, part.severity, part.group, ps, pe))
196
+ return out
197
+
198
+
199
+ def redact(text: str, cfg: Config | None = None, include_low: bool = False) -> tuple[str, dict[str, str]]:
200
+ """A copy of `text` with every finding replaced by a typed placeholder,
201
+ and the mapping from placeholder to original value. LOW findings stay
202
+ unless `include_low`."""
203
+ chosen = _select(_findings(text, cfg), include_low)
204
+ literal = set(_PLACEHOLDER_TOKEN.findall(text))
205
+
206
+ by_value: dict[tuple[str, str], str] = {}
207
+ counters: dict[str, int] = {}
208
+ mapping: dict[str, str] = {}
209
+ out: list[str] = []
210
+ cursor = 0
211
+ for f in chosen:
212
+ value = text[f.start:f.end]
213
+ key = (f.category, value)
214
+ if key not in by_value:
215
+ label = f.category.upper()
216
+ placeholder = ""
217
+ while not placeholder or placeholder in literal:
218
+ counters[label] = counters.get(label, 0) + 1
219
+ placeholder = f"[{label}_{counters[label]}]"
220
+ by_value[key] = placeholder
221
+ mapping[placeholder] = value
222
+ out.append(text[cursor:f.start])
223
+ out.append(by_value[key])
224
+ cursor = f.end
225
+ out.append(text[cursor:])
226
+ return "".join(out), mapping
227
+
228
+
229
+ def restore(text: str, mapping: dict[str, str]) -> str:
230
+ """Put the original values back. Only the placeholders in `mapping` are
231
+ replaced, in a single pass, so a restored value that happens to contain
232
+ placeholder-like text is never substituted again."""
233
+ if not mapping:
234
+ return text
235
+ pattern = re.compile("|".join(re.escape(p) for p in sorted(mapping, key=len, reverse=True)))
236
+ return pattern.sub(lambda m: mapping[m.group(0)], text)