nyms 0.0.2__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.
- nyms/__init__.py +62 -0
- nyms/__main__.py +26 -0
- nyms/core.py +358 -0
- nyms/data/__init__.py +1 -0
- nyms/data/names/__init__.py +1 -0
- nyms/data/names/first_names_da.txt +1 -0
- nyms/data/names/first_names_en.txt +1 -0
- nyms/data/skills/nyms/SKILL.md +36 -0
- nyms/data/wordlists/NOTICE.md +3 -0
- nyms/data/wordlists/__init__.py +1 -0
- nyms/data/wordlists/eff_short_wordlist_2_0.txt +1296 -0
- nyms/detect.py +692 -0
- nyms/keys.py +153 -0
- nyms/mapping.py +376 -0
- nyms/policy.py +140 -0
- nyms/spans.py +101 -0
- nyms/surrogates.py +272 -0
- nyms/taxonomy.py +120 -0
- nyms/tools.py +187 -0
- nyms-0.0.2.dist-info/METADATA +116 -0
- nyms-0.0.2.dist-info/RECORD +24 -0
- nyms-0.0.2.dist-info/WHEEL +4 -0
- nyms-0.0.2.dist-info/entry_points.txt +2 -0
- nyms-0.0.2.dist-info/licenses/LICENSE +21 -0
nyms/__init__.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""nyms: reversible pseudonymisation of text before it reaches an AI.
|
|
2
|
+
|
|
3
|
+
A deterministic (non-AI) pass detects personal and sensitive identifiers in Danish and English
|
|
4
|
+
text, replaces them with surrogates from a configurable strategy, remembers the mapping per
|
|
5
|
+
project so the same person is the same pseudonym in every document, and puts the originals
|
|
6
|
+
back into whatever the AI answers.
|
|
7
|
+
|
|
8
|
+
>>> from nyms import Pseudonymiser
|
|
9
|
+
>>> p = Pseudonymiser(store={})
|
|
10
|
+
>>> p.pseudonymise("Mail hans.jensen@example.dk, tlf. 12 34 56 78").text
|
|
11
|
+
'Mail [EMAIL_1], tlf. [PHONE_1]'
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from nyms.core import CAVEAT, Pseudonymiser, Reidentified, Result
|
|
15
|
+
from nyms.detect import (
|
|
16
|
+
Detector,
|
|
17
|
+
DetectorLike,
|
|
18
|
+
default_detectors,
|
|
19
|
+
detect,
|
|
20
|
+
gazetteer_detector,
|
|
21
|
+
known_values_detectors,
|
|
22
|
+
)
|
|
23
|
+
from nyms.mapping import MappingStore, local_mapping_store, normalise
|
|
24
|
+
from nyms.policy import Policy
|
|
25
|
+
from nyms.spans import Span, apply_replacements, resolve_overlaps
|
|
26
|
+
from nyms.surrogates import (
|
|
27
|
+
Aliases,
|
|
28
|
+
Counter,
|
|
29
|
+
KeyedWords,
|
|
30
|
+
RandomWords,
|
|
31
|
+
readable,
|
|
32
|
+
words_from_bytes,
|
|
33
|
+
)
|
|
34
|
+
from nyms.taxonomy import ENTITY_TYPES, EntityType
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"CAVEAT",
|
|
38
|
+
"Pseudonymiser",
|
|
39
|
+
"Reidentified",
|
|
40
|
+
"Result",
|
|
41
|
+
"Detector",
|
|
42
|
+
"default_detectors",
|
|
43
|
+
"detect",
|
|
44
|
+
"gazetteer_detector",
|
|
45
|
+
"DetectorLike",
|
|
46
|
+
"known_values_detectors",
|
|
47
|
+
"MappingStore",
|
|
48
|
+
"local_mapping_store",
|
|
49
|
+
"normalise",
|
|
50
|
+
"Policy",
|
|
51
|
+
"Span",
|
|
52
|
+
"apply_replacements",
|
|
53
|
+
"resolve_overlaps",
|
|
54
|
+
"Aliases",
|
|
55
|
+
"Counter",
|
|
56
|
+
"KeyedWords",
|
|
57
|
+
"RandomWords",
|
|
58
|
+
"readable",
|
|
59
|
+
"words_from_bytes",
|
|
60
|
+
"ENTITY_TYPES",
|
|
61
|
+
"EntityType",
|
|
62
|
+
]
|
nyms/__main__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""``python -m nyms`` / ``nyms``: the CLI, built from the same functions the library exposes.
|
|
2
|
+
|
|
3
|
+
nyms pseudonymise --path letter.md --project case-2026 --names "Rasmus Jensen" --out letter.pseud.md
|
|
4
|
+
nyms reidentify --path answer.md --project case-2026
|
|
5
|
+
nyms detect "Rasmus Jensen, tlf. 12 34 56 78" # spans and the policy's decision, nothing stored
|
|
6
|
+
nyms check --path draft.md --project case-2026 # would this leak?
|
|
7
|
+
nyms readable 010190-1234 # a keyed hash as words
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import cw
|
|
11
|
+
|
|
12
|
+
from nyms.tools import _dispatch_funcs
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main() -> int:
|
|
16
|
+
# Positional parameters stay positional even with a default (the text); keyword-only
|
|
17
|
+
# parameters become flags (--path, --project, ...).
|
|
18
|
+
return cw.dispatch(
|
|
19
|
+
_dispatch_funcs,
|
|
20
|
+
prog="nyms",
|
|
21
|
+
convention=cw.Convention(naming=cw.BY_NAME_IF_KWONLY),
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
if __name__ == "__main__":
|
|
26
|
+
raise SystemExit(main())
|
nyms/core.py
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""The facade: detect, pseudonymise, re-identify, check. One object, six seams.
|
|
2
|
+
|
|
3
|
+
::
|
|
4
|
+
|
|
5
|
+
from nyms import Pseudonymiser
|
|
6
|
+
p = Pseudonymiser('case-2026', names=['Rasmus Jensen'])
|
|
7
|
+
result = p.pseudonymise(text) # result.text goes to the AI; result.report says what happened
|
|
8
|
+
answer = p.reidentify(ai_output) # answer.text has the originals back; answer.unmatched lists invented tokens
|
|
9
|
+
p.check(outgoing_text) # originals that would leak, before anything is sent
|
|
10
|
+
|
|
11
|
+
The seams, each one keyword argument of :class:`Pseudonymiser`:
|
|
12
|
+
|
|
13
|
+
============== ===================================================== ==========================================
|
|
14
|
+
seam v1 default (no new dependency) replacement it allows
|
|
15
|
+
============== ===================================================== ==========================================
|
|
16
|
+
detectors :func:`nyms.detect.default_detectors` (regex, checksum, gazetteer) any callable ``text -> spans``; a Presidio adapter
|
|
17
|
+
post_detectors none the model-based second pass (spaCy/DaCy, GLiNER, an LLM via ``aix``/``creel``), run on the already-pseudonymised text
|
|
18
|
+
policy :meth:`nyms.policy.Policy.default` ``Policy.strict()``, ``identifiers()``, a policy file
|
|
19
|
+
strategy :class:`nyms.surrogates.Counter` (``[PERSON_1]``) ``KeyedWords``, ``RandomWords``, ``Aliases``
|
|
20
|
+
store ``dol`` files under ``~/.local/share/nyms/<project>/mapping`` any ``MutableMapping``; ``encrypt=True`` for Fernet at rest
|
|
21
|
+
key :func:`nyms.keys.resolve_key` (env var, key file) ``keyring``, ``config2py``, a passphrase
|
|
22
|
+
============== ===================================================== ==========================================
|
|
23
|
+
|
|
24
|
+
Not seams, written directly on purpose: the span data model, the report's shape, the
|
|
25
|
+
tolerant re-identification matcher, the order of the stages.
|
|
26
|
+
|
|
27
|
+
How a run works. The text is NFC-normalised. Stage 1: every detector's spans, overlap
|
|
28
|
+
resolution (strong spans before weak, longer before shorter), the policy's split into
|
|
29
|
+
replaced and kept; an accepted span nested inside a kept one (a CPR number inside a URL the
|
|
30
|
+
policy keeps) is still replaced. New values enter the mapping, and a second pass adds
|
|
31
|
+
detectors for everything the project knows (its values, name components, its own surrogates)
|
|
32
|
+
so every occurrence is consistent. Stage 2, when ``post_detectors`` are given: they run on the
|
|
33
|
+
pseudonymised text, where the surrogates are protected from re-detection, and what they find
|
|
34
|
+
is replaced and added to the mapping too.
|
|
35
|
+
|
|
36
|
+
>>> p = Pseudonymiser(store={})
|
|
37
|
+
>>> r = p.pseudonymise("Rasmus Jensen (CPR 010190-1234) skylder 12.500 kr. Rasmus Jensens advokat ringede.")
|
|
38
|
+
>>> r.text
|
|
39
|
+
'[PERSON_1] (CPR [CPR_1]) skylder [AMOUNT_1]. [PERSON_1]s advokat ringede.'
|
|
40
|
+
>>> p.reidentify("Svar: [PERSON_1] skal betale [AMOUNT_1]. [person 1]s sag.").text
|
|
41
|
+
'Svar: Rasmus Jensen skal betale 12.500 kr. Rasmus Jensens sag.'
|
|
42
|
+
>>> p.check("Rasmus Jensen bor her")
|
|
43
|
+
['Rasmus Jensen']
|
|
44
|
+
>>> p.pseudonymise(r.text).text == r.text # idempotent: surrogates are never re-detected
|
|
45
|
+
True
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
from __future__ import annotations
|
|
49
|
+
|
|
50
|
+
import re
|
|
51
|
+
from collections.abc import Iterable, MutableMapping
|
|
52
|
+
from dataclasses import dataclass, field
|
|
53
|
+
from functools import cached_property
|
|
54
|
+
|
|
55
|
+
from nyms.detect import (
|
|
56
|
+
DetectorLike,
|
|
57
|
+
default_detectors,
|
|
58
|
+
detect,
|
|
59
|
+
detect_raw,
|
|
60
|
+
detector_name,
|
|
61
|
+
known_values_detectors,
|
|
62
|
+
normalise_text,
|
|
63
|
+
)
|
|
64
|
+
from nyms.keys import resolve_key
|
|
65
|
+
from nyms.mapping import MappingStore, canon, encrypted, local_mapping_store, normalise
|
|
66
|
+
from nyms.policy import Policy
|
|
67
|
+
from nyms.spans import Span, apply_replacements, resolve_overlaps
|
|
68
|
+
from nyms.surrogates import Counter, Strategy
|
|
69
|
+
from nyms.taxonomy import SURROGATE_TYPE, tier_of
|
|
70
|
+
|
|
71
|
+
CAVEAT = (
|
|
72
|
+
"Pseudonymised, not anonymous: the mapping store and key re-identify everything replaced; "
|
|
73
|
+
"a deterministic pass catches patterns and listed names, not unlisted names, roles, rare "
|
|
74
|
+
"events or context that identifies without an identifier."
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class Result:
|
|
80
|
+
"""What :meth:`Pseudonymiser.pseudonymise` returns. Spans refer to the NFC-normalised input."""
|
|
81
|
+
|
|
82
|
+
text: str
|
|
83
|
+
replaced: list[Span]
|
|
84
|
+
kept: list[Span]
|
|
85
|
+
replacements: list[tuple[Span, str]]
|
|
86
|
+
report: dict = field(default_factory=dict)
|
|
87
|
+
#: Stage-2 replacements (``post_detectors``), with offsets in the stage-1 output.
|
|
88
|
+
post_replacements: list[tuple[Span, str]] = field(default_factory=list)
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def mapping(self) -> dict[str, str]:
|
|
92
|
+
"""``{surrogate: original}`` for this run only. Sensitive."""
|
|
93
|
+
return {
|
|
94
|
+
sur: span.text
|
|
95
|
+
for span, sur in [*self.replacements, *self.post_replacements]
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class Reidentified:
|
|
101
|
+
"""What :meth:`Pseudonymiser.reidentify` returns."""
|
|
102
|
+
|
|
103
|
+
text: str
|
|
104
|
+
restored: int
|
|
105
|
+
unmatched: list[str]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class Pseudonymiser:
|
|
109
|
+
"""The facade. See the module docstring for the seams."""
|
|
110
|
+
|
|
111
|
+
def __init__(
|
|
112
|
+
self,
|
|
113
|
+
project: str = "default",
|
|
114
|
+
*,
|
|
115
|
+
detectors: Iterable[DetectorLike] | None = None,
|
|
116
|
+
post_detectors: Iterable[DetectorLike] = (),
|
|
117
|
+
policy: Policy | None = None,
|
|
118
|
+
strategy: Strategy | None = None,
|
|
119
|
+
store: MutableMapping[str, dict] | None = None,
|
|
120
|
+
key: bytes | None = None,
|
|
121
|
+
names: Iterable[str] = (),
|
|
122
|
+
rootdir=None,
|
|
123
|
+
encrypt: bool = False,
|
|
124
|
+
prefilter: bool = False,
|
|
125
|
+
):
|
|
126
|
+
self.project = project
|
|
127
|
+
self.policy = Policy.default() if policy is None else policy
|
|
128
|
+
self._key = key
|
|
129
|
+
self.names = [n for n in names if n and n.strip()]
|
|
130
|
+
self.detectors = list(
|
|
131
|
+
default_detectors(names=self.names) if detectors is None else detectors
|
|
132
|
+
)
|
|
133
|
+
self.post_detectors = list(post_detectors)
|
|
134
|
+
if prefilter:
|
|
135
|
+
self.detectors = self.policy.detector_filter(self.detectors)
|
|
136
|
+
if store is None:
|
|
137
|
+
codec = encrypted(self.key) if encrypt else None
|
|
138
|
+
store = local_mapping_store(project, rootdir=rootdir, codec=codec)
|
|
139
|
+
self.mapping = MappingStore(
|
|
140
|
+
store, strategy=Counter() if strategy is None else strategy
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
@cached_property
|
|
144
|
+
def key(self) -> bytes:
|
|
145
|
+
return self._key if self._key is not None else resolve_key(self.project)
|
|
146
|
+
|
|
147
|
+
# -- the four verbs -------------------------------------------------------
|
|
148
|
+
def detect(self, text: str) -> list[Span]:
|
|
149
|
+
return detect(normalise_text(text), self.detectors)
|
|
150
|
+
|
|
151
|
+
def pseudonymise(self, text: str) -> Result:
|
|
152
|
+
text = normalise_text(text)
|
|
153
|
+
# Pass 1: the detectors plus what the project already knows (its values, the name
|
|
154
|
+
# components of the known parties, its own surrogates); new values enter the mapping.
|
|
155
|
+
known = known_values_detectors(self.mapping, extra_names=self.names)
|
|
156
|
+
replaced, _ = self._decide(text, self.detectors + known)
|
|
157
|
+
for s in replaced:
|
|
158
|
+
self.mapping.resolve(s.entity_type, s.value or s.text)
|
|
159
|
+
# Pass 2: the same, now knowing this document's new values, so every occurrence is
|
|
160
|
+
# consistent; a span pass 1 attributed to a real detector keeps that attribution.
|
|
161
|
+
first = {(s.start, s.end): s for s in replaced}
|
|
162
|
+
replaced, kept = self._decide(
|
|
163
|
+
text,
|
|
164
|
+
self.detectors
|
|
165
|
+
+ known_values_detectors(self.mapping, extra_names=self.names),
|
|
166
|
+
)
|
|
167
|
+
replaced = [
|
|
168
|
+
first.get((s.start, s.end), s) if s.detector == "known_values" else s
|
|
169
|
+
for s in replaced
|
|
170
|
+
]
|
|
171
|
+
replacements = self._replacements(replaced)
|
|
172
|
+
out = apply_replacements(text, replacements)
|
|
173
|
+
# Stage 2: detectors that run on the pseudonymised text (the model pass seam).
|
|
174
|
+
post_replacements: list[tuple[Span, str]] = []
|
|
175
|
+
if self.post_detectors:
|
|
176
|
+
protect = known_values_detectors(self.mapping)
|
|
177
|
+
post_replaced, post_kept = self._decide(
|
|
178
|
+
out, list(self.post_detectors) + protect
|
|
179
|
+
)
|
|
180
|
+
post_replaced = [s for s in post_replaced if s.detector != "known_values"]
|
|
181
|
+
post_replacements = self._replacements(post_replaced)
|
|
182
|
+
out = apply_replacements(out, post_replacements)
|
|
183
|
+
kept = kept + [
|
|
184
|
+
k for k in post_kept if k.detector not in ("known_values", "surrogates")
|
|
185
|
+
]
|
|
186
|
+
return Result(
|
|
187
|
+
out,
|
|
188
|
+
replaced,
|
|
189
|
+
kept,
|
|
190
|
+
replacements,
|
|
191
|
+
self._report(replaced, kept, post_replacements),
|
|
192
|
+
post_replacements,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
def _replacements(self, spans: list[Span]) -> list[tuple[Span, str]]:
|
|
196
|
+
out = []
|
|
197
|
+
for s in spans:
|
|
198
|
+
# A span with a value stands for a known name; its text excludes any genitive by
|
|
199
|
+
# construction (the gazetteer leaves the "s" outside the span), so no suffix.
|
|
200
|
+
surrogate, suffix = self.mapping.resolve(s.entity_type, s.value or s.text)
|
|
201
|
+
out.append((s, surrogate + ("" if s.value else suffix)))
|
|
202
|
+
return out
|
|
203
|
+
|
|
204
|
+
def _decide(
|
|
205
|
+
self, text: str, detectors: list[DetectorLike]
|
|
206
|
+
) -> tuple[list[Span], list[Span]]:
|
|
207
|
+
"""Resolve overlaps over every detector's spans, let the policy split them, then promote
|
|
208
|
+
accepted spans nested inside kept ones (an identifier inside a kept URL)."""
|
|
209
|
+
raw = detect_raw(text, detectors)
|
|
210
|
+
winners = resolve_overlaps(raw)
|
|
211
|
+
accepted, rejected = self.policy.split(
|
|
212
|
+
w for w in winners if w.entity_type != SURROGATE_TYPE
|
|
213
|
+
)
|
|
214
|
+
for k in rejected:
|
|
215
|
+
inner = [
|
|
216
|
+
s
|
|
217
|
+
for s in raw
|
|
218
|
+
if _promotable(s)
|
|
219
|
+
and k.contains(s)
|
|
220
|
+
and s.length < k.length
|
|
221
|
+
and self.policy.accepts(s)
|
|
222
|
+
]
|
|
223
|
+
accepted.extend(resolve_overlaps(inner))
|
|
224
|
+
accepted = resolve_overlaps(accepted)
|
|
225
|
+
kept = [k for k in rejected if not any(a.contains(k) for a in accepted)]
|
|
226
|
+
return accepted, kept
|
|
227
|
+
|
|
228
|
+
def reidentify(self, text: str) -> Reidentified:
|
|
229
|
+
records = list(self.mapping.records())
|
|
230
|
+
if not records:
|
|
231
|
+
return Reidentified(text, 0, _leftover_tokens(text))
|
|
232
|
+
by_canon = {canon(r.surrogate): r.original for r in records}
|
|
233
|
+
exact = {r.surrogate: r.original for r in records}
|
|
234
|
+
pattern = _tolerant_pattern([r.surrogate for r in records])
|
|
235
|
+
count = 0
|
|
236
|
+
|
|
237
|
+
def sub(m: re.Match) -> str:
|
|
238
|
+
nonlocal count
|
|
239
|
+
hit = m.group(0)
|
|
240
|
+
original = (
|
|
241
|
+
exact.get(hit) if not hit.startswith("[") else by_canon.get(canon(hit))
|
|
242
|
+
)
|
|
243
|
+
if original is None:
|
|
244
|
+
original = (
|
|
245
|
+
by_canon.get(canon(hit))
|
|
246
|
+
if hit.startswith("[") or _looks_bracketed(hit)
|
|
247
|
+
else None
|
|
248
|
+
)
|
|
249
|
+
if original is None:
|
|
250
|
+
return hit
|
|
251
|
+
count += 1
|
|
252
|
+
return original
|
|
253
|
+
|
|
254
|
+
restored = pattern.sub(sub, text)
|
|
255
|
+
return Reidentified(restored, count, _leftover_tokens(restored))
|
|
256
|
+
|
|
257
|
+
def check(self, text: str) -> list[str]:
|
|
258
|
+
"""Originals from the mapping that occur in *text*: what would leak if it were sent."""
|
|
259
|
+
text = normalise_text(text)
|
|
260
|
+
return [
|
|
261
|
+
rec.original
|
|
262
|
+
for rec in self.mapping.records()
|
|
263
|
+
if _contains_value(text, rec.entity_type, rec.original)
|
|
264
|
+
]
|
|
265
|
+
|
|
266
|
+
# -- reporting ------------------------------------------------------------
|
|
267
|
+
def _report(
|
|
268
|
+
self, replaced: list[Span], kept: list[Span], post: list[tuple[Span, str]]
|
|
269
|
+
) -> dict:
|
|
270
|
+
by_type: dict[str, int] = {}
|
|
271
|
+
by_detector: dict[str, int] = {}
|
|
272
|
+
for s in [*replaced, *(s for s, _ in post)]:
|
|
273
|
+
by_type[s.entity_type] = by_type.get(s.entity_type, 0) + 1
|
|
274
|
+
by_detector[s.detector] = by_detector.get(s.detector, 0) + 1
|
|
275
|
+
return {
|
|
276
|
+
"project": self.project,
|
|
277
|
+
"policy": self.policy.name,
|
|
278
|
+
"replaced": len(replaced) + len(post),
|
|
279
|
+
"replaced_by_type": dict(sorted(by_type.items())),
|
|
280
|
+
"replaced_by_detector": dict(sorted(by_detector.items())),
|
|
281
|
+
"kept": [
|
|
282
|
+
{
|
|
283
|
+
"entity_type": s.entity_type,
|
|
284
|
+
"text": s.text,
|
|
285
|
+
"score": s.score,
|
|
286
|
+
"detector": s.detector,
|
|
287
|
+
}
|
|
288
|
+
for s in kept
|
|
289
|
+
],
|
|
290
|
+
"distinct_values_in_project": len(self.mapping),
|
|
291
|
+
"detectors_run": sorted(
|
|
292
|
+
{detector_name(d) for d in self.detectors}
|
|
293
|
+
| {"known_values", "known_components"}
|
|
294
|
+
),
|
|
295
|
+
"post_detectors_run": sorted(detector_name(d) for d in self.post_detectors),
|
|
296
|
+
"caveat": CAVEAT,
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
#: Entity types promoted out of a kept container span: structured identifiers, whose match is
|
|
301
|
+
#: unambiguous. Names are not (a first name inside a kept date: "1. Maj 2024").
|
|
302
|
+
PROMOTABLE_TYPES = frozenset(
|
|
303
|
+
{"CPR", "CVR", "EMAIL", "PHONE", "IBAN", "BANK_ACCOUNT", "CASE_ID", "CREDENTIAL"}
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _promotable(span: Span) -> bool:
|
|
308
|
+
return span.entity_type in PROMOTABLE_TYPES or tier_of(span.entity_type) == 0
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
# --------------------------------------------------------------------------- matching helpers
|
|
312
|
+
|
|
313
|
+
_TOKEN_RE = re.compile(r"\[\s*[A-Z][A-Z_]*\s*[_:\-\s]\s*[\w\-]+\s*\]")
|
|
314
|
+
_BRACKETED_RE = re.compile(r"^\[.*\]$|^\[?[A-Z][A-Z_]*[_:\-\s]\s*[\w\-]+\]?$")
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _looks_bracketed(s: str) -> bool:
|
|
318
|
+
return bool(_BRACKETED_RE.match(s.strip()))
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _tolerant_pattern(surrogates: list[str]) -> re.Pattern:
|
|
322
|
+
"""One alternation over every surrogate, longest first.
|
|
323
|
+
|
|
324
|
+
Bracketed surrogates (``[PERSON_1]``) match loosely: brackets optional, separators loose,
|
|
325
|
+
any case, a genitive allowed after the closing bracket, and a word boundary required when
|
|
326
|
+
the bracket is missing so ``PERSON_1`` never matches inside ``PERSON_11``. Unbracketed
|
|
327
|
+
surrogates (``Alder Brook``, a user alias) match exactly, case included, at word
|
|
328
|
+
boundaries, so ordinary prose that happens to contain the words is left alone.
|
|
329
|
+
"""
|
|
330
|
+
alts = []
|
|
331
|
+
for s in sorted(surrogates, key=len, reverse=True):
|
|
332
|
+
if s.startswith("[") and s.endswith("]"):
|
|
333
|
+
parts = [re.escape(p) for p in canon(s).split("_")]
|
|
334
|
+
body = r"[\s_\-]*".join(parts)
|
|
335
|
+
alts.append(rf"\[\s*(?i:{body})\s*\]|(?i:{body})(?![\w])")
|
|
336
|
+
else:
|
|
337
|
+
alts.append(r"\s+".join(re.escape(p) for p in s.split()) + r"(?![\w])")
|
|
338
|
+
return re.compile(r"(?<![\w])(?:" + "|".join(alts) + r")")
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _leftover_tokens(text: str) -> list[str]:
|
|
342
|
+
"""Bracketed typed tokens still in *text*: ones the model invented or we never issued."""
|
|
343
|
+
return sorted({m.group(0) for m in _TOKEN_RE.finditer(text)})
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _contains_value(text: str, entity_type: str, original: str) -> bool:
|
|
347
|
+
norm = normalise(entity_type, original)
|
|
348
|
+
if not norm:
|
|
349
|
+
return False
|
|
350
|
+
if entity_type in ("PERSON", "ORG", "ADDRESS"):
|
|
351
|
+
return (
|
|
352
|
+
re.search(rf"(?<![\w]){re.escape(original)}(?![\w])", text) is not None
|
|
353
|
+
or re.search(
|
|
354
|
+
rf"(?<![\w]){re.escape(norm)}(?![\w])", normalise(entity_type, text)
|
|
355
|
+
)
|
|
356
|
+
is not None
|
|
357
|
+
)
|
|
358
|
+
return norm in normalise(entity_type, text) or original in text
|
nyms/data/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Bundled data files (data), shipped inside the wheel."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Bundled data files (names), shipped inside the wheel."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Anders Mette Lars Søren Jens Peter Hans Niels Kirsten Anne Hanne Lone Bente Mads Mikkel Frederik Emma Sofie Ida Freja Clara Laura Caroline Mathilde Nikolaj Christian Jakob Jacob Tobias Oliver Noah William Lucas Oscar Alfred Carl Victor Emil Magnus Malthe Elias Aksel Johan Jonas Thomas Henrik Michael Jan Martin Morten Ole Erik Poul Kim Jørgen Steen Flemming Torben Carsten Kenneth Brian Allan Knud Svend Bent Finn Leif Arne Mogens Kaj Helle Susanne Lene Marianne Charlotte Pia Tina Maria Louise Camilla Pernille Rikke Line Julie Cecilie Signe Anna Karen Inge Birthe Else Grethe Ruth Jette Gitte Dorthe Annette Tine Trine Nanna Katrine Kathrine Christina Kristine Maja Sara Amalie Astrid Agnes Alma Ella Olivia Josefine Victoria Isabella Liva Ellen Esther Rosa Nora Rasmus Kasper Jesper Simon Daniel Rune Troels Jonas Sebastian Andreas Alexander Mathias Christoffer Kristian Kristoffer Benjamin Marcus Gustav Villads Valdemar Anton Storm Viggo Felix Theo Arthur Lauge Otto Asger Bertram Hugo Albert Johannes August Konrad Sigurd Ludvig Thor Bjørn Jeppe Lasse Mark Stig Preben Vagn Villy Egon Børge Gunnar Gert Ivan Tage Verner Åge Aage Birgitte Bodil Dorte Eva Hanna Ingrid Jytte Karin Kirstine Lisbeth Lis Lissi Merete Mia Nina Sanne Solveig Sonja Tove Ulla Vibeke Winnie Yrsa Zara Agnete Benedikte Cathrine Ditte Elisabeth Frederikke Gry Helena Iben Johanne Kamilla Lærke Malou Mathilda Mille Nadia Pil Rebecca Rikke Sarah Selma Sofia Stine Thea Tilde Vera Wilma Ane Asta Dagmar Ebba Edith Hedvig Ingeborg Lykke Marie Mille Naja Oda Saga Vilma
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
James John Robert Michael William David Richard Joseph Thomas Charles Christopher Daniel Matthew Anthony Donald Steven Paul Andrew Joshua Kenneth Kevin Brian George Timothy Ronald Edward Jason Jeffrey Ryan Jacob Gary Nicholas Eric Jonathan Stephen Larry Justin Scott Brandon Benjamin Samuel Gregory Alexander Patrick Raymond Dennis Jerry Tyler Aaron Jose Adam Nathan Henry Douglas Zachary Peter Kyle Noah Ethan Jeremy Walter Christian Keith Roger Terry Austin Sean Gerald Carl Harold Dylan Arthur Lawrence Jordan Jesse Bryan Gabriel Logan Alan Juan Albert Willie Elijah Wayne Randy Vincent Mason Roy Ralph Russell Bradley Philip Eugene Mary Patricia Jennifer Linda Elizabeth Barbara Susan Jessica Sarah Karen Lisa Nancy Betty Sandra Margaret Ashley Kimberly Emily Donna Michelle Amanda Melissa Deborah Stephanie Dorothy Rebecca Sharon Laura Cynthia Amy Kathleen Angela Shirley Brenda Emma Anna Pamela Nicole Samantha Katherine Christine Helen Debra Rachel Carolyn Janet Maria Catherine Heather Diane Olivia Julie Joyce Victoria Ruth Virginia Lauren Kelly Christina Joan Evelyn Judith Andrea Hannah Megan Cheryl Jacqueline Martha Madison Teresa Gloria Sara Janice Ann Kathryn Abigail Sophia Frances Alice Judy Isabella Julia Amber Denise Danielle Marilyn Beverly Charlotte Natalie Theresa Diana Brittany Doris Kayla Alexis Lori Marie Oliver Harry Jack Charlie Oscar Leo Alfie Freddie Archie Theodore Isla Amelia Ava Mia Grace Poppy Lily Evie Florence Rosie Sienna Willow Phoebe Harriet Imogen Eleanor Matilda Elsie Edward Arthur Louis Hugo Felix Rupert Sebastian Toby Finley Rowan Nathaniel Jasper Reuben Elliot Sophie Chloe Zoe Lucy Molly Alice Ella Millie Daisy Ruby Freya Layla Maya Isabelle Esme Nancy
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: nyms
|
|
3
|
+
description: Pseudonymise documents before any of their content reaches an AI model, re-identify the model's answer, and check that nothing leaks, with the nyms package (Danish and English; CPR, CVR, names, addresses, phone, e-mail, IBAN, amounts, credentials). Use whenever a user wants to send personal, legal, medical or financial documents to an AI, asks to "anonymise", "redact", "remove names", "mask the CPR numbers", "de-identify", "pseudonymise", "protect privacy before sending to ChatGPT/Claude", or when you are about to paste someone else's personal data into a prompt. Also use to put the real names back into an answer ("re-identify", "restore the names").
|
|
4
|
+
metadata:
|
|
5
|
+
audience: users
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# nyms: pseudonymise, send, re-identify, check
|
|
9
|
+
|
|
10
|
+
The deterministic pass runs **before** any content reaches a model. That order is the point: the model only ever sees surrogates for what the pass caught.
|
|
11
|
+
|
|
12
|
+
## The loop
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
nyms pseudonymise --path <file> --project <project> --names "<Party One>,<Party Two>" --out <file>.pseud.md
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
1. **Pick a project name** and keep it for the whole matter (`case-2026`, `tenancy-dispute`). The project is the unit of consistency: the same person is `[PERSON_1]` in every document, and the mapping and key are stored per project (`~/.local/share/nyms/<project>/`, `~/.config/nyms/keys/<project>.key`).
|
|
19
|
+
2. **Pass the names you know** with `--names`: the parties, their lawyers, the company. No detector can know them; the list makes them certain.
|
|
20
|
+
3. **Read the report** the command returns: `replaced_by_type`, and `kept`, the spans it detected but left (dates, postcodes, URLs under the default policy). If something in `kept` should go, rerun with `--policy strict`, or pass it in `--names`.
|
|
21
|
+
4. **Send the `.pseud.md` file**, never the original. Before sending any other text you wrote about the matter, run `nyms check "<text>" --project <project>` (or `--path <file>`); a non-empty `leaks` list is a stop.
|
|
22
|
+
5. **Re-identify the answer**: `nyms reidentify --path <answer file> --project <project>`. The result lists `unmatched` tokens (ones the model invented); tell the user about them rather than guessing.
|
|
23
|
+
|
|
24
|
+
From Python, the same four verbs are `Pseudonymiser(project, names=[...]).pseudonymise / reidentify / check / detect`; `store={}` keeps a run in memory.
|
|
25
|
+
|
|
26
|
+
## What to tell the user
|
|
27
|
+
|
|
28
|
+
- "Pseudonymised, not anonymous." The mapping store and key re-identify everything; whoever has them has the originals. Never say "anonymised".
|
|
29
|
+
- What the pass does not catch: names it has never seen and the heuristic misses, roles, rare facts and relationships that identify someone without any identifier, inflected compounds, OCR noise. Offer `nyms detect --path <file>` to show every span and the decision, so the user can review before sending.
|
|
30
|
+
- Surrogate forms: the default `[PERSON_1]` is what models alter least; `--strategy keyed-words` gives readable pseudonyms (`Alder Brook`) that are the same on any machine with the key; `--aliases "Acme ApS=the landlord"` lets the user pick.
|
|
31
|
+
|
|
32
|
+
## Never
|
|
33
|
+
|
|
34
|
+
- Never send the original when a pseudonymised copy exists; never paste `nyms mapping` output or the mapping directory into a prompt, an issue or a chat.
|
|
35
|
+
- Never claim a numeric privacy guarantee. Report coverage: counts by type, what was kept, which detectors ran.
|
|
36
|
+
- Never resolve an `unmatched` token by guessing which person it is.
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
# Word lists
|
|
2
|
+
|
|
3
|
+
`eff_short_wordlist_2_0.txt`: the Electronic Frontier Foundation's short wordlist 2.0 (1,296 words, unique three-letter prefixes), from https://www.eff.org/dice, licensed CC BY 3.0 (https://creativecommons.org/licenses/by/3.0/). Downloaded 2026-11-08, unchanged. Used by `nyms.surrogates` to render keyed hashes and random bytes as readable words.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Bundled data files (wordlists), shipped inside the wheel."""
|