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 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."""