polanon 0.1.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.
- polanon/__init__.py +255 -0
- polanon/__main__.py +90 -0
- polanon/_detection.py +79 -0
- polanon/_matching.py +624 -0
- polanon/_view.py +102 -0
- polanon/allowlist.py +246 -0
- polanon/guards/__init__.py +0 -0
- polanon/guards/base.py +22 -0
- polanon/guards/company_pl.py +214 -0
- polanon/guards/denylist.py +71 -0
- polanon/guards/international.py +771 -0
- polanon/guards/names_pl.py +823 -0
- polanon/guards/ner.py +427 -0
- polanon/guards/recognizers_pl.py +562 -0
- polanon/guards/secrets.py +162 -0
- polanon/judge/__init__.py +30 -0
- polanon/judge/base.py +39 -0
- polanon/judge/mock.py +27 -0
- polanon/judge/ollama.py +67 -0
- polanon/judge/parsing.py +121 -0
- polanon/judge/prompt.py +48 -0
- polanon/judge/remote.py +76 -0
- polanon/output_sweep.py +79 -0
- polanon/pipeline.py +425 -0
- polanon/policy.py +67 -0
- polanon/py.typed +0 -0
- polanon/stream_restore.py +46 -0
- polanon/types.py +179 -0
- polanon-0.1.0.dist-info/METADATA +376 -0
- polanon-0.1.0.dist-info/RECORD +34 -0
- polanon-0.1.0.dist-info/WHEEL +5 -0
- polanon-0.1.0.dist-info/entry_points.txt +2 -0
- polanon-0.1.0.dist-info/licenses/LICENSE +21 -0
- polanon-0.1.0.dist-info/top_level.txt +1 -0
polanon/__init__.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""polanon: reversible PII anonymization for LLM calls, Polish-first. Zero web/DB dependencies.
|
|
2
|
+
|
|
3
|
+
This package is deliberately self-contained, so it can be dropped into any project: a CLI tool, a
|
|
4
|
+
LangChain callback, an MCP server, an Open WebUI filter. It imports no web or database framework,
|
|
5
|
+
and nothing from the hub built on it (enforced by tests/test_layering.py).
|
|
6
|
+
|
|
7
|
+
Public API (stable):
|
|
8
|
+
|
|
9
|
+
from polanon import Anonymizer, default_guards
|
|
10
|
+
|
|
11
|
+
anonymizer = Anonymizer(default_guards(with_ner=False))
|
|
12
|
+
result = anonymizer.anonymize_sync("Jan Kowalski, PESEL 90010112349") # or: await .anonymize()
|
|
13
|
+
result.anonymized # "<OSOBA_1>, PESEL <PESEL_1>"
|
|
14
|
+
result.mapping # {"<OSOBA_1>": "Jan Kowalski", "<PESEL_1>": "90010112349"}
|
|
15
|
+
Anonymizer.restore("Dzień dobry <OSOBA_1>", result.mapping) # original back in the answer
|
|
16
|
+
restorer = Anonymizer.stream_restorer(result.mapping) # for token-by-token streams
|
|
17
|
+
|
|
18
|
+
Runtime dependencies of the core: the standard library only. `NerGuard` needs spaCy and a language
|
|
19
|
+
model, the remote judges need httpx, and both are imported lazily so neither is required to mask
|
|
20
|
+
identifiers.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import importlib.util
|
|
24
|
+
import re
|
|
25
|
+
from collections.abc import Iterable
|
|
26
|
+
|
|
27
|
+
from polanon._detection import custom_type_problem
|
|
28
|
+
from polanon.allowlist import WELL_KNOWN_NAMES, Allowlist
|
|
29
|
+
from polanon.guards.base import Guard
|
|
30
|
+
from polanon.guards.company_pl import CompanyGuard
|
|
31
|
+
from polanon.guards.denylist import DenylistGuard
|
|
32
|
+
from polanon.guards.international import InternationalGuard
|
|
33
|
+
from polanon.guards.names_pl import PersonNameGuard
|
|
34
|
+
from polanon.guards.recognizers_pl import RegexGuard
|
|
35
|
+
from polanon.guards.secrets import SecretsGuard
|
|
36
|
+
from polanon.judge.base import Judge, JudgeError, JudgeFinding
|
|
37
|
+
from polanon.judge.mock import MockJudge
|
|
38
|
+
from polanon.output_sweep import SweepReport, sweep
|
|
39
|
+
from polanon.pipeline import _anonymize_stage1, anonymize, mask_known_values
|
|
40
|
+
from polanon.policy import ENTITY_TYPES, TOKEN_PATTERN, token_for
|
|
41
|
+
from polanon.stream_restore import StreamRestorer
|
|
42
|
+
from polanon.types import AnonymizationResult, JudgeFallback, Span, merge_spans
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"DEFAULT_ENABLED",
|
|
46
|
+
"ENTITY_TYPES",
|
|
47
|
+
"TOKEN_PATTERN",
|
|
48
|
+
"WELL_KNOWN_NAMES",
|
|
49
|
+
"Allowlist",
|
|
50
|
+
"AnonymizationResult",
|
|
51
|
+
"Anonymizer",
|
|
52
|
+
"CompanyGuard",
|
|
53
|
+
"DenylistGuard",
|
|
54
|
+
"Guard",
|
|
55
|
+
"InternationalGuard",
|
|
56
|
+
"Judge",
|
|
57
|
+
"JudgeError",
|
|
58
|
+
"JudgeFallback",
|
|
59
|
+
"JudgeFinding",
|
|
60
|
+
"MockJudge",
|
|
61
|
+
"PersonNameGuard",
|
|
62
|
+
"RegexGuard",
|
|
63
|
+
"SecretsGuard",
|
|
64
|
+
"Span",
|
|
65
|
+
"StreamRestorer",
|
|
66
|
+
"SweepReport",
|
|
67
|
+
"default_guards",
|
|
68
|
+
"mask_known_values",
|
|
69
|
+
"merge_spans",
|
|
70
|
+
"sweep",
|
|
71
|
+
"token_for",
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
__version__ = "0.1.0"
|
|
75
|
+
|
|
76
|
+
#: The entity types an Anonymizer masks unless told otherwise: every built-in type except DATE.
|
|
77
|
+
#: A date rarely identifies anyone on its own, and masking every deadline and invoice date leaves
|
|
78
|
+
#: the model unable to answer the question the text was sent with. Add it back with
|
|
79
|
+
#: ``DEFAULT_ENABLED | {"DATE"}``.
|
|
80
|
+
DEFAULT_ENABLED = frozenset(ENTITY_TYPES) - {"DATE"}
|
|
81
|
+
|
|
82
|
+
_NER_INSTALL = (
|
|
83
|
+
'pip install "polanon[ner]", then python -m spacy download {model}; '
|
|
84
|
+
"or call default_guards(with_ner=False) to mask with the rules alone"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def default_guards(
|
|
89
|
+
*, denylist: Iterable[str] = (), with_ner: bool = True, spacy_model: str = "pl_core_news_md"
|
|
90
|
+
) -> list[Guard]:
|
|
91
|
+
"""Guards in recommended order. NER guard is imported lazily because spaCy is heavy/optional.
|
|
92
|
+
|
|
93
|
+
The Polish rules come first, so where a Polish and a foreign rule read the same characters —
|
|
94
|
+
a "PL" VAT number is also a NIP — the checksum-validated Polish reading keeps its label.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
denylist: terms that must always be masked, as a list.
|
|
98
|
+
with_ner: add the spaCy guard for places and the names no rule can see.
|
|
99
|
+
spacy_model: the spaCy model that guard loads on first use.
|
|
100
|
+
|
|
101
|
+
Raises:
|
|
102
|
+
TypeError: when `denylist` is a single string, which would be read letter by letter.
|
|
103
|
+
ModuleNotFoundError: when `with_ner` is on and spaCy or the model is not installed. It is
|
|
104
|
+
raised here rather than on the first text to mask, with the command that fixes it.
|
|
105
|
+
"""
|
|
106
|
+
if isinstance(denylist, str):
|
|
107
|
+
# The term itself stays out of the message: it is what the caller wants kept hidden.
|
|
108
|
+
raise TypeError("denylist takes a list of terms, not a single string")
|
|
109
|
+
guards: list[Guard] = [
|
|
110
|
+
RegexGuard(),
|
|
111
|
+
SecretsGuard(),
|
|
112
|
+
CompanyGuard(),
|
|
113
|
+
PersonNameGuard(),
|
|
114
|
+
InternationalGuard(),
|
|
115
|
+
DenylistGuard(list(denylist)),
|
|
116
|
+
]
|
|
117
|
+
if with_ner:
|
|
118
|
+
_require_spacy(spacy_model)
|
|
119
|
+
from polanon.guards.ner import NerGuard
|
|
120
|
+
|
|
121
|
+
guards.append(NerGuard(spacy_model=spacy_model))
|
|
122
|
+
return guards
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _require_spacy(spacy_model: str) -> None:
|
|
126
|
+
"""Fail now, with the command that fixes it, instead of on the first text to mask."""
|
|
127
|
+
hint = _NER_INSTALL.format(model=spacy_model)
|
|
128
|
+
if importlib.util.find_spec("spacy") is None:
|
|
129
|
+
raise ModuleNotFoundError(f"the language model guard needs spaCy: {hint}")
|
|
130
|
+
# A model can also be loaded from a path, which find_spec cannot check; spaCy reports that one.
|
|
131
|
+
if spacy_model.isidentifier() and importlib.util.find_spec(spacy_model) is None:
|
|
132
|
+
raise ModuleNotFoundError(f"the spaCy model {spacy_model!r} is not installed: {hint}")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class Anonymizer:
|
|
136
|
+
"""Facade over the anonymization pipeline.
|
|
137
|
+
|
|
138
|
+
Create one instance per process and pass the per-conversation `mapping` on every call. An
|
|
139
|
+
instance keeps no per-call state, so threads and coroutines can share it.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
def __init__(
|
|
143
|
+
self,
|
|
144
|
+
guards: list[Guard],
|
|
145
|
+
judge: Judge | None = None,
|
|
146
|
+
*,
|
|
147
|
+
enabled: Iterable[str] | None = None,
|
|
148
|
+
judge_fallback: JudgeFallback | str = JudgeFallback.SEND_STAGE1,
|
|
149
|
+
allowlist: Allowlist | None = None,
|
|
150
|
+
) -> None:
|
|
151
|
+
"""Configure the guards, the optional judge and the policy this facade applies.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
guards: stage-1 detectors, run in order.
|
|
155
|
+
judge: optional stage-2 reviewer.
|
|
156
|
+
enabled: entity types to mask. When omitted, :data:`DEFAULT_ENABLED` plus the custom
|
|
157
|
+
types the guards declare in ``Guard.entity_types``.
|
|
158
|
+
judge_fallback: what to do when the judge fails: a :class:`JudgeFallback`, or its
|
|
159
|
+
value as text ("send_stage1", "block").
|
|
160
|
+
allowlist: public names left unmasked, such as :data:`WELL_KNOWN_NAMES`. Nothing is
|
|
161
|
+
exempt when omitted.
|
|
162
|
+
|
|
163
|
+
Raises:
|
|
164
|
+
ValueError: for an entity type that is neither built in nor declared in a guard's
|
|
165
|
+
``entity_types``, such as a typo, which would otherwise switch masking off
|
|
166
|
+
without a word; for a declared type that cannot label a token; and for an
|
|
167
|
+
unknown fallback.
|
|
168
|
+
"""
|
|
169
|
+
for guard in guards:
|
|
170
|
+
for entity_type in sorted(guard.entity_types):
|
|
171
|
+
problem = custom_type_problem(entity_type)
|
|
172
|
+
if problem is not None:
|
|
173
|
+
raise ValueError(f"guard {guard.name!r} declares the entity type {problem}")
|
|
174
|
+
declared = frozenset().union(*(guard.entity_types for guard in guards))
|
|
175
|
+
self.guards = guards
|
|
176
|
+
self.judge = judge
|
|
177
|
+
self.enabled = set(DEFAULT_ENABLED | declared) if enabled is None else set(enabled)
|
|
178
|
+
unknown = self.enabled - set(ENTITY_TYPES) - declared
|
|
179
|
+
if unknown:
|
|
180
|
+
raise ValueError(
|
|
181
|
+
f"unknown entity types {sorted(unknown)}: nothing would mask them. The built-in "
|
|
182
|
+
f"types are {', '.join(ENTITY_TYPES)}; a custom guard lists its own in "
|
|
183
|
+
"Guard.entity_types."
|
|
184
|
+
)
|
|
185
|
+
self.judge_fallback = JudgeFallback(judge_fallback)
|
|
186
|
+
self.allowlist = allowlist
|
|
187
|
+
|
|
188
|
+
async def anonymize(
|
|
189
|
+
self, text: str, mapping: dict[str, str] | None = None
|
|
190
|
+
) -> AnonymizationResult:
|
|
191
|
+
"""Mask `text`, reusing any tokens already allocated in `mapping`.
|
|
192
|
+
|
|
193
|
+
The detectors run in a worker thread, so the event loop stays free. Without an event loop
|
|
194
|
+
to await on, use :meth:`anonymize_sync`.
|
|
195
|
+
"""
|
|
196
|
+
return await anonymize(
|
|
197
|
+
text,
|
|
198
|
+
enabled=self.enabled,
|
|
199
|
+
guards=self.guards,
|
|
200
|
+
judge=self.judge,
|
|
201
|
+
existing_mapping=dict(mapping or {}),
|
|
202
|
+
judge_fallback=self.judge_fallback,
|
|
203
|
+
allowlist=self.allowlist,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
def anonymize_sync(
|
|
207
|
+
self, text: str, mapping: dict[str, str] | None = None
|
|
208
|
+
) -> AnonymizationResult:
|
|
209
|
+
"""Mask `text` without an event loop, reusing any tokens already allocated in `mapping`.
|
|
210
|
+
|
|
211
|
+
The blocking twin of :meth:`anonymize`, for scripts, Django and Flask views, Celery tasks
|
|
212
|
+
and notebooks. It returns the same result for the same input and never touches asyncio, so
|
|
213
|
+
it works whether or not an event loop is running in the calling thread, and from several
|
|
214
|
+
threads at once. Inside a coroutine prefer ``await anonymize()``, which keeps the work off
|
|
215
|
+
the event loop.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
text: the text to mask.
|
|
219
|
+
mapping: token -> original value from earlier turns of the same conversation. It is
|
|
220
|
+
read, never modified; the returned mapping holds it plus any new tokens.
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
The masked text with its spans, entity counts and the conversation's full mapping.
|
|
224
|
+
|
|
225
|
+
Raises:
|
|
226
|
+
RuntimeError: when this anonymizer has a stage-2 judge. Judges are asynchronous, and
|
|
227
|
+
running one behind a blocking call would hide its network I/O and its failures.
|
|
228
|
+
"""
|
|
229
|
+
if self.judge is not None:
|
|
230
|
+
raise RuntimeError(
|
|
231
|
+
f"anonymize_sync() cannot run the stage-2 judge {self.judge.name!r}, because "
|
|
232
|
+
"judges are asynchronous: use 'await anonymizer.anonymize(...)', or an Anonymizer "
|
|
233
|
+
"without a judge."
|
|
234
|
+
)
|
|
235
|
+
return _anonymize_stage1(
|
|
236
|
+
text,
|
|
237
|
+
enabled=self.enabled,
|
|
238
|
+
guards=self.guards,
|
|
239
|
+
existing_mapping=dict(mapping or {}),
|
|
240
|
+
allowlist=self.allowlist,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
@staticmethod
|
|
244
|
+
def restore(text: str, mapping: dict[str, str]) -> str:
|
|
245
|
+
"""Replace every known token with its original value. Unknown tokens are left untouched."""
|
|
246
|
+
return re.sub(TOKEN_PATTERN, lambda m: mapping.get(m.group(0), m.group(0)), text)
|
|
247
|
+
|
|
248
|
+
@staticmethod
|
|
249
|
+
def stream_restorer(mapping: dict[str, str]) -> StreamRestorer:
|
|
250
|
+
"""Return a restorer for a streamed provider response."""
|
|
251
|
+
return StreamRestorer(mapping)
|
|
252
|
+
|
|
253
|
+
def sweep(self, restored_text: str, mapping: dict[str, str]) -> SweepReport:
|
|
254
|
+
"""Check a restored answer for invented tokens and unexpected personal data."""
|
|
255
|
+
return sweep(restored_text, mapping, self.guards, self.enabled, self.allowlist)
|
polanon/__main__.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Command line for the anonymizer.
|
|
2
|
+
|
|
3
|
+
Text comes from the arguments or from standard input. The masked text goes to stdout and the token
|
|
4
|
+
mapping to stderr, so a pipeline can keep the two apart:
|
|
5
|
+
|
|
6
|
+
polanon "Jan Kowalski, PESEL 90010112349"
|
|
7
|
+
cat umowa.txt | polanon --ner > masked.txt 2> mapping.json
|
|
8
|
+
|
|
9
|
+
The mapping is what makes the masking reversible, so treat that second file the way you would treat
|
|
10
|
+
the original document.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import sys
|
|
16
|
+
from collections.abc import Sequence
|
|
17
|
+
from typing import TextIO
|
|
18
|
+
|
|
19
|
+
from polanon import Anonymizer, default_guards
|
|
20
|
+
|
|
21
|
+
DESCRIPTION = "Mask personal data in text before it goes to an LLM; the mapping goes to stderr."
|
|
22
|
+
|
|
23
|
+
#: Exit status when the language model was asked for but is not installed.
|
|
24
|
+
MISSING_MODEL = 3
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
28
|
+
"""Anonymize text from the command line or standard input.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
0 on success, 2 when there was no text to mask, 3 when --ner is given without spaCy.
|
|
32
|
+
"""
|
|
33
|
+
parser = argparse.ArgumentParser(prog="polanon", description=DESCRIPTION)
|
|
34
|
+
parser.add_argument("text", nargs="*", help="text to mask; omit to read standard input")
|
|
35
|
+
parser.add_argument(
|
|
36
|
+
"--ner", action="store_true", help="also load the spaCy model for names and places"
|
|
37
|
+
)
|
|
38
|
+
parser.add_argument(
|
|
39
|
+
"--spacy-model",
|
|
40
|
+
default="pl_core_news_md",
|
|
41
|
+
help="spaCy model to load with --ner (default: %(default)s)",
|
|
42
|
+
)
|
|
43
|
+
args = parser.parse_args(argv)
|
|
44
|
+
|
|
45
|
+
_write_utf8(sys.stdout)
|
|
46
|
+
_write_utf8(sys.stderr)
|
|
47
|
+
text = " ".join(args.text) if args.text else _read_stdin()
|
|
48
|
+
if not text.strip():
|
|
49
|
+
print("nothing to mask: pass text as arguments or on standard input", file=sys.stderr)
|
|
50
|
+
return 2
|
|
51
|
+
try:
|
|
52
|
+
guards = default_guards(with_ner=args.ner, spacy_model=args.spacy_model)
|
|
53
|
+
except ModuleNotFoundError as exc:
|
|
54
|
+
print(f"polanon: {exc}", file=sys.stderr)
|
|
55
|
+
return MISSING_MODEL
|
|
56
|
+
|
|
57
|
+
result = Anonymizer(guards).anonymize_sync(text)
|
|
58
|
+
print(result.anonymized)
|
|
59
|
+
print(json.dumps(result.mapping, ensure_ascii=False, indent=2), file=sys.stderr)
|
|
60
|
+
return 0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _read_stdin() -> str:
|
|
64
|
+
"""Read standard input as UTF-8, whatever the console's code page.
|
|
65
|
+
|
|
66
|
+
Windows decodes piped input with the ANSI code page, so "Wiśniewski" arrived garbled, no guard
|
|
67
|
+
recognised it, and it went out unmasked. Files from older Polish software are cp1250, so that is
|
|
68
|
+
the fallback before replacement characters.
|
|
69
|
+
"""
|
|
70
|
+
stream = getattr(sys.stdin, "buffer", None)
|
|
71
|
+
if stream is None:
|
|
72
|
+
return sys.stdin.read()
|
|
73
|
+
raw = stream.read()
|
|
74
|
+
for encoding in ("utf-8-sig", "cp1250"):
|
|
75
|
+
try:
|
|
76
|
+
return str(raw.decode(encoding))
|
|
77
|
+
except UnicodeDecodeError:
|
|
78
|
+
continue
|
|
79
|
+
return str(raw.decode("utf-8", errors="replace"))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _write_utf8(stream: TextIO) -> None:
|
|
83
|
+
"""Write UTF-8 even where the console would not: the mapping must stay valid JSON."""
|
|
84
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
85
|
+
if reconfigure is not None:
|
|
86
|
+
reconfigure(encoding="utf-8")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
if __name__ == "__main__":
|
|
90
|
+
sys.exit(main())
|
polanon/_detection.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Running the guards over a text, and the checks a guard's output has to pass first.
|
|
2
|
+
|
|
3
|
+
Shared by the pipeline, the output sweep and the facade, which refuses a guard whose declared types
|
|
4
|
+
cannot label a token before it is ever run.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
from polanon._view import View
|
|
12
|
+
from polanon.guards.base import Guard
|
|
13
|
+
from polanon.policy import ENTITY_TYPES
|
|
14
|
+
from polanon.types import Span
|
|
15
|
+
|
|
16
|
+
#: What a custom entity type may be called. It becomes the label inside its tokens, and
|
|
17
|
+
#: TOKEN_PATTERN only recognises uppercase letters and underscores there: a type spelled any other
|
|
18
|
+
#: way produced tokens that restore could not find, and the original stayed in the answer.
|
|
19
|
+
_CUSTOM_TYPE_RE = re.compile(r"[A-Z][A-Z_]*")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
#: Token labels of the built-in types. A custom type spelled like one would share its tokens:
|
|
23
|
+
#: a guard's "OSOBA" made "<OSOBA_2>" a second person, and every count filed it as one.
|
|
24
|
+
_BUILT_IN_LABELS = frozenset(ENTITY_TYPES.values()) - frozenset(ENTITY_TYPES)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def detect(text: str, view: View | None, enabled: set[str], guards: list[Guard]) -> list[Span]:
|
|
28
|
+
"""Run every guard over the text and return their spans, unmerged, in original coordinates.
|
|
29
|
+
|
|
30
|
+
`view` is the text's :func:`~polanon._view.detection_view`, computed once by the caller.
|
|
31
|
+
"""
|
|
32
|
+
seen = text if view is None else view.text
|
|
33
|
+
detected: list[Span] = []
|
|
34
|
+
for guard in guards:
|
|
35
|
+
for span in guard.detect(seen, enabled):
|
|
36
|
+
if _usable(span, guard, len(seen)):
|
|
37
|
+
detected.append(span if view is None else view.to_original(span))
|
|
38
|
+
return detected
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _usable(span: Span, guard: Guard, length: int) -> bool:
|
|
42
|
+
"""Check a span before the pipeline trusts it; an empty one is skipped.
|
|
43
|
+
|
|
44
|
+
A guard with an off-by-one would otherwise corrupt the text without a sound: Python slices take
|
|
45
|
+
a negative start from the end, and the document went out with half of it repeated.
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
ValueError: when the span lies outside the text, or its type cannot be a token label.
|
|
49
|
+
"""
|
|
50
|
+
if not 0 <= span.start <= span.end <= length:
|
|
51
|
+
raise ValueError(
|
|
52
|
+
f"guard {guard.name!r} returned the span {span.start}..{span.end}, "
|
|
53
|
+
f"outside a text of {length} characters"
|
|
54
|
+
)
|
|
55
|
+
problem = custom_type_problem(span.entity_type)
|
|
56
|
+
if problem is not None:
|
|
57
|
+
raise ValueError(f"guard {guard.name!r} returned the entity type {problem}")
|
|
58
|
+
return span.start < span.end
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def custom_type_problem(entity_type: str) -> str | None:
|
|
62
|
+
"""Return why `entity_type` cannot name a custom type, or None when it can.
|
|
63
|
+
|
|
64
|
+
The message completes "returned the entity type" or "declares the entity type", so a guard's
|
|
65
|
+
author reads what to rename and why.
|
|
66
|
+
"""
|
|
67
|
+
if entity_type in ENTITY_TYPES:
|
|
68
|
+
return None
|
|
69
|
+
if not _CUSTOM_TYPE_RE.fullmatch(entity_type):
|
|
70
|
+
return (
|
|
71
|
+
f"{entity_type!r}. A custom type becomes the label of its tokens, so it must be "
|
|
72
|
+
"uppercase letters and underscores, such as 'EMPLOYEE_ID'."
|
|
73
|
+
)
|
|
74
|
+
if entity_type in _BUILT_IN_LABELS:
|
|
75
|
+
return (
|
|
76
|
+
f"{entity_type!r}, which is the token label of a built-in type, so its tokens would "
|
|
77
|
+
"read as that type. Name it differently, such as 'EMPLOYEE_ID'."
|
|
78
|
+
)
|
|
79
|
+
return None
|