noirdoc 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.
Files changed (49) hide show
  1. noirdoc/__init__.py +12 -0
  2. noirdoc/_version.py +24 -0
  3. noirdoc/cli.py +255 -0
  4. noirdoc/detection/__init__.py +0 -0
  5. noirdoc/detection/base.py +44 -0
  6. noirdoc/detection/ensemble.py +170 -0
  7. noirdoc/detection/flair_detector.py +57 -0
  8. noirdoc/detection/flair_recognizer.py +69 -0
  9. noirdoc/detection/gliner_detector.py +89 -0
  10. noirdoc/detection/model_manager.py +26 -0
  11. noirdoc/detection/presidio_detector.py +234 -0
  12. noirdoc/file_analysis/__init__.py +4 -0
  13. noirdoc/file_analysis/body_walker.py +459 -0
  14. noirdoc/file_analysis/extractor.py +81 -0
  15. noirdoc/file_analysis/extractors/__init__.py +0 -0
  16. noirdoc/file_analysis/extractors/docx_ext.py +27 -0
  17. noirdoc/file_analysis/extractors/ocr.py +33 -0
  18. noirdoc/file_analysis/extractors/pdf.py +79 -0
  19. noirdoc/file_analysis/extractors/plain.py +8 -0
  20. noirdoc/file_analysis/extractors/xlsx.py +23 -0
  21. noirdoc/file_analysis/mime.py +70 -0
  22. noirdoc/file_analysis/models.py +45 -0
  23. noirdoc/file_analysis/pipeline.py +263 -0
  24. noirdoc/file_analysis/policy.py +28 -0
  25. noirdoc/file_analysis/reconstruction.py +192 -0
  26. noirdoc/file_analysis/xlsx_inference.py +229 -0
  27. noirdoc/file_reidentification/__init__.py +0 -0
  28. noirdoc/file_reidentification/service.py +157 -0
  29. noirdoc/mappings/__init__.py +0 -0
  30. noirdoc/mappings/backends/__init__.py +20 -0
  31. noirdoc/mappings/backends/base.py +25 -0
  32. noirdoc/mappings/backends/file.py +88 -0
  33. noirdoc/mappings/backends/memory.py +44 -0
  34. noirdoc/mappings/backends/redis_backend.py +30 -0
  35. noirdoc/mappings/hydration.py +48 -0
  36. noirdoc/mappings/store.py +130 -0
  37. noirdoc/namespace.py +99 -0
  38. noirdoc/pseudonymization/__init__.py +0 -0
  39. noirdoc/pseudonymization/engine.py +24 -0
  40. noirdoc/pseudonymization/mapper.py +83 -0
  41. noirdoc/pseudonymization/system_prompt.py +78 -0
  42. noirdoc/reidentification/__init__.py +0 -0
  43. noirdoc/reidentification/engine.py +105 -0
  44. noirdoc/sdk.py +339 -0
  45. noirdoc-0.1.0.dist-info/METADATA +221 -0
  46. noirdoc-0.1.0.dist-info/RECORD +49 -0
  47. noirdoc-0.1.0.dist-info/WHEEL +4 -0
  48. noirdoc-0.1.0.dist-info/entry_points.txt +2 -0
  49. noirdoc-0.1.0.dist-info/licenses/LICENSE +21 -0
noirdoc/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """noirdoc — local PII redaction and pseudonymization for documents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from noirdoc.sdk import RedactionResult, Redactor, redact
6
+
7
+ try:
8
+ from noirdoc._version import __version__ # type: ignore[import-not-found]
9
+ except ImportError: # Source checkout without a build step (e.g. plain `pytest`).
10
+ __version__ = "0.0.0+unknown"
11
+
12
+ __all__ = ["RedactionResult", "Redactor", "__version__", "redact"]
noirdoc/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.0'
22
+ __version_tuple__ = version_tuple = (0, 1, 0)
23
+
24
+ __commit_id__ = commit_id = None
noirdoc/cli.py ADDED
@@ -0,0 +1,255 @@
1
+ """Command-line interface for noirdoc."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import click
10
+
11
+ from noirdoc import __version__
12
+ from noirdoc.namespace import DEFAULT_NAMESPACE_ROOT, Namespace, list_namespaces
13
+ from noirdoc.sdk import Redactor
14
+
15
+
16
+ @click.group()
17
+ @click.version_option(__version__, prog_name="noirdoc")
18
+ def main() -> None:
19
+ """Local PII redaction and pseudonymization for documents."""
20
+
21
+
22
+ @main.command()
23
+ @click.argument("inputs", nargs=-1, type=click.Path(exists=True, path_type=Path), required=True)
24
+ @click.option("-o", "--output", type=click.Path(path_type=Path), help="Output file (single input).")
25
+ @click.option("--output-dir", type=click.Path(path_type=Path), help="Output directory (batch).")
26
+ @click.option("--namespace", help="Persistent namespace for reversible pseudonyms.")
27
+ @click.option(
28
+ "--language",
29
+ "-l",
30
+ default="de",
31
+ type=click.Choice(["de", "en"]),
32
+ show_default=True,
33
+ )
34
+ @click.option(
35
+ "--detector",
36
+ default="ensemble",
37
+ type=click.Choice(["presidio", "gliner", "ensemble"]),
38
+ show_default=True,
39
+ )
40
+ @click.option("--score-threshold", type=float, default=0.5, show_default=True)
41
+ @click.option("-v", "--verbose", is_flag=True, help="Print per-file details.")
42
+ def redact(
43
+ inputs: tuple[Path, ...],
44
+ output: Path | None,
45
+ output_dir: Path | None,
46
+ namespace: str | None,
47
+ language: str,
48
+ detector: str,
49
+ score_threshold: float,
50
+ verbose: bool,
51
+ ) -> None:
52
+ """Redact PII in one or more files."""
53
+ files = _expand_inputs(inputs)
54
+ if not files:
55
+ click.echo("No input files found.", err=True)
56
+ sys.exit(1)
57
+
58
+ if output and len(files) > 1:
59
+ click.echo("--output only works with a single input; use --output-dir.", err=True)
60
+ sys.exit(2)
61
+
62
+ r = Redactor(
63
+ namespace=namespace,
64
+ language=language,
65
+ detector=detector, # type: ignore[arg-type]
66
+ score_threshold=score_threshold,
67
+ )
68
+
69
+ success = 0
70
+ for path in files:
71
+ try:
72
+ result = r.redact_file(path, language=language)
73
+ except Exception as exc:
74
+ click.echo(f"{path.name}: {exc}", err=True)
75
+ continue
76
+
77
+ out_path = _choose_output_path(
78
+ path,
79
+ output=output,
80
+ output_dir=output_dir,
81
+ reconstructed=result.reconstructed,
82
+ )
83
+ out_path.parent.mkdir(parents=True, exist_ok=True)
84
+ out_path.write_bytes(result.output_bytes)
85
+
86
+ if verbose:
87
+ types = ", ".join(f"{k}={v}" for k, v in sorted(result.entity_types.items()))
88
+ click.echo(f"{path.name}: {result.entity_count} entities [{types}] -> {out_path}")
89
+ else:
90
+ click.echo(f"{path.name}: {result.entity_count} entities -> {out_path}")
91
+ success += 1
92
+
93
+ if namespace:
94
+ click.echo(f"Namespace {namespace!r}: {r.mapper.entity_count} unique entities total")
95
+
96
+ sys.exit(0 if success else 1)
97
+
98
+
99
+ @main.command()
100
+ @click.argument("input", required=False, type=click.Path(exists=True, path_type=Path))
101
+ @click.option("-o", "--output", type=click.Path(path_type=Path), help="Output file.")
102
+ @click.option("--namespace", required=True, help="Namespace holding the mapping.")
103
+ def reveal(input: Path | None, output: Path | None, namespace: str) -> None:
104
+ """Restore original values from pseudonyms using a namespace.
105
+
106
+ With no INPUT, reads text from stdin and writes to stdout.
107
+ With an INPUT file, writes reidentified bytes (DOCX/XLSX/plain) to OUTPUT.
108
+ PDF/PPTX/images are not roundtrippable; the command warns and copies the
109
+ original bytes.
110
+ """
111
+ ns = Namespace(namespace)
112
+ if not ns.exists():
113
+ click.echo(f"Namespace {namespace!r} does not exist.", err=True)
114
+ sys.exit(1)
115
+
116
+ r = Redactor(namespace=namespace)
117
+
118
+ if input is None:
119
+ text = sys.stdin.read()
120
+ sys.stdout.write(r.reveal_text(text))
121
+ return
122
+
123
+ revealed = r.reveal_file(input, output=output)
124
+ if revealed is None:
125
+ click.echo(
126
+ f"Cannot reveal {input.name}: format not supported for roundtrip. "
127
+ "Original bytes unchanged.",
128
+ err=True,
129
+ )
130
+ if output:
131
+ Path(output).write_bytes(input.read_bytes())
132
+ sys.exit(2)
133
+ click.echo(f"{input.name}: revealed -> {output or '(bytes returned)'}")
134
+
135
+
136
+ @main.command()
137
+ @click.argument("pseudonym")
138
+ @click.option("--namespace", required=True)
139
+ def lookup(pseudonym: str, namespace: str) -> None:
140
+ """Look up the original value behind a pseudonym token."""
141
+ r = Redactor(namespace=namespace)
142
+ original = r.lookup(pseudonym)
143
+ if original is None:
144
+ click.echo(f"{pseudonym}: not found", err=True)
145
+ sys.exit(1)
146
+ click.echo(original)
147
+
148
+
149
+ @main.group()
150
+ def ns() -> None:
151
+ """Manage persistent namespaces."""
152
+
153
+
154
+ @ns.command("list")
155
+ def ns_list() -> None:
156
+ """List all namespaces under ~/.noirdoc/namespaces/."""
157
+ names = list_namespaces()
158
+ if not names:
159
+ click.echo(f"No namespaces found in {DEFAULT_NAMESPACE_ROOT}")
160
+ return
161
+ for n in names:
162
+ click.echo(n)
163
+
164
+
165
+ @ns.command("show")
166
+ @click.argument("namespace")
167
+ def ns_show(namespace: str) -> None:
168
+ """Print the mapping summary for NAMESPACE as JSON."""
169
+ ns_obj = Namespace(namespace)
170
+ if not ns_obj.exists():
171
+ click.echo(f"Namespace {namespace!r} does not exist.", err=True)
172
+ sys.exit(1)
173
+ mapper = ns_obj.load()
174
+ click.echo(json.dumps(mapper.get_mapping_summary(), indent=2, ensure_ascii=False))
175
+
176
+
177
+ @ns.command("delete")
178
+ @click.argument("namespace")
179
+ @click.confirmation_option(prompt="Delete namespace and discard all mappings?")
180
+ def ns_delete(namespace: str) -> None:
181
+ """Delete a namespace directory (irreversible)."""
182
+ ns_obj = Namespace(namespace)
183
+ if not ns_obj.exists():
184
+ click.echo(f"Namespace {namespace!r} does not exist.", err=True)
185
+ sys.exit(1)
186
+ ns_obj.delete()
187
+ click.echo(f"Deleted {namespace!r}")
188
+
189
+
190
+ @main.group()
191
+ def models() -> None:
192
+ """Manage local detection models."""
193
+
194
+
195
+ @models.command("pull")
196
+ @click.option("--language", "-l", multiple=True, default=["de", "en"], show_default=True)
197
+ @click.option("--gliner/--no-gliner", default=True, show_default=True)
198
+ @click.option(
199
+ "--gliner-model",
200
+ default="knowledgator/gliner-pii-edge-v1.0",
201
+ show_default=True,
202
+ )
203
+ def models_pull(language: tuple[str, ...], gliner: bool, gliner_model: str) -> None:
204
+ """Download spaCy models (and optionally GLiNER weights)."""
205
+ from noirdoc.detection.model_manager import ensure_spacy_models
206
+
207
+ click.echo(f"Downloading spaCy models for: {', '.join(language)}")
208
+ ensure_spacy_models(list(language))
209
+
210
+ if gliner:
211
+ click.echo(f"Loading GLiNER model: {gliner_model}")
212
+ try:
213
+ from noirdoc.detection.gliner_detector import GlinerDetector
214
+
215
+ GlinerDetector(model_name=gliner_model)
216
+ except ImportError:
217
+ click.echo(
218
+ "GLiNER not installed. Install with: pip install 'noirdoc[full]'",
219
+ err=True,
220
+ )
221
+ sys.exit(1)
222
+
223
+ click.echo("Models ready.")
224
+
225
+
226
+ # -- helpers ------------------------------------------------------------------
227
+
228
+
229
+ def _expand_inputs(inputs: tuple[Path, ...]) -> list[Path]:
230
+ files: list[Path] = []
231
+ for p in inputs:
232
+ if p.is_dir():
233
+ files.extend(sorted(q for q in p.iterdir() if q.is_file()))
234
+ elif p.is_file():
235
+ files.append(p)
236
+ return files
237
+
238
+
239
+ def _choose_output_path(
240
+ input_path: Path,
241
+ *,
242
+ output: Path | None,
243
+ output_dir: Path | None,
244
+ reconstructed: bool,
245
+ ) -> Path:
246
+ if output:
247
+ return output
248
+ parent = output_dir or input_path.parent
249
+ if reconstructed:
250
+ return parent / f"{input_path.stem}_redacted{input_path.suffix}"
251
+ return parent / f"{input_path.stem}_redacted.txt"
252
+
253
+
254
+ if __name__ == "__main__":
255
+ main()
File without changes
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+
5
+ from pydantic import BaseModel
6
+
7
+ ENTITY_TYPES = {
8
+ "PERSON",
9
+ "EMAIL",
10
+ "PHONE",
11
+ "IBAN",
12
+ "CREDIT_CARD",
13
+ "LOCATION",
14
+ "DATE",
15
+ "ORGANIZATION",
16
+ "IP_ADDRESS",
17
+ "URL",
18
+ "MEDICAL_LICENSE",
19
+ "SVNR",
20
+ "STEUER_ID",
21
+ "CUSTOM",
22
+ }
23
+
24
+
25
+ class DetectedEntity(BaseModel):
26
+ """Eine erkannte PII-Entität mit Position im Text."""
27
+
28
+ entity_type: str
29
+ text: str
30
+ start: int
31
+ end: int
32
+ score: float
33
+ source: str
34
+
35
+
36
+ class BaseDetector(ABC):
37
+ """Interface das jeder Detektor implementieren muss."""
38
+
39
+ @abstractmethod
40
+ async def detect(self, text: str, language: str = "de") -> list[DetectedEntity]: ...
41
+
42
+ @property
43
+ @abstractmethod
44
+ def name(self) -> str: ...
@@ -0,0 +1,170 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import re
5
+
6
+ from noirdoc.detection.base import BaseDetector, DetectedEntity
7
+
8
+ # Strong indicators: if ANY of these appear in a multi-word PERSON entity, reject it.
9
+ _PERSON_STRONG_REJECT: set[str] = {
10
+ # verbs / participles commonly absorbed by spaCy NER
11
+ "wohnhaft",
12
+ "verheiratet",
13
+ "geschieden",
14
+ "ledig",
15
+ "verwitwet",
16
+ "überwiesen",
17
+ "geboren",
18
+ "verstorben",
19
+ "beschäftigt",
20
+ "gemeldet",
21
+ # common nouns that get falsely tagged
22
+ "euro",
23
+ "straße",
24
+ "strasse",
25
+ "nummer",
26
+ "datum",
27
+ }
28
+
29
+ # Weak indicators: reject only if entity text ENDS with one of these
30
+ # (handles boundary absorption like "Hoffmann, und").
31
+ _PERSON_TRAILING_REJECT: set[str] = {
32
+ "und",
33
+ "oder",
34
+ "mit",
35
+ "für",
36
+ "bei",
37
+ "nach",
38
+ "der",
39
+ "die",
40
+ "das",
41
+ "ein",
42
+ "eine",
43
+ "er",
44
+ "sie",
45
+ "es",
46
+ }
47
+
48
+ # Pattern for username-like strings (lowercase + digits, no spaces)
49
+ _USERNAME_PATTERN = re.compile(r"^[a-z0-9._@\-]+$")
50
+
51
+
52
+ def _validate_person(entity: DetectedEntity) -> bool:
53
+ """Return False if a PERSON entity is likely a false positive."""
54
+ if entity.entity_type != "PERSON":
55
+ return True
56
+
57
+ text = entity.text
58
+
59
+ # Reject entities containing commas or newlines (spaCy boundary absorption)
60
+ if "," in text or "\n" in text:
61
+ return False
62
+
63
+ # Reject single-character entities
64
+ if len(text) <= 1:
65
+ return False
66
+
67
+ # Reject username-like patterns (e.g. "cthwhdbfio0854", "al-kurdy8")
68
+ if _USERNAME_PATTERN.match(text):
69
+ return False
70
+
71
+ # Multi-word validation
72
+ words = text.lower().split()
73
+ if len(words) > 1:
74
+ if any(w in _PERSON_STRONG_REJECT for w in words):
75
+ return False
76
+ if words[-1] in _PERSON_TRAILING_REJECT:
77
+ return False
78
+
79
+ return True
80
+
81
+
82
+ # Per-entity-type score thresholds (higher = stricter, fewer FPs).
83
+ # Types not listed here use the global default.
84
+ _TYPE_THRESHOLDS: dict[str, float] = {
85
+ "URL": 0.6,
86
+ "DATE": 0.7,
87
+ }
88
+
89
+
90
+ class EnsembleDetector:
91
+ """Kombiniert mehrere Detektoren und löst Überlappungen auf."""
92
+
93
+ def __init__(
94
+ self,
95
+ detectors: list[BaseDetector],
96
+ score_threshold: float = 0.5,
97
+ ) -> None:
98
+ self.detectors = detectors
99
+ self.score_threshold = score_threshold
100
+
101
+ async def detect(self, text: str, language: str = "de") -> list[DetectedEntity]:
102
+ if not text:
103
+ return []
104
+
105
+ results = await asyncio.gather(
106
+ *(d.detect(text, language) for d in self.detectors),
107
+ return_exceptions=True,
108
+ )
109
+
110
+ all_entities: list[DetectedEntity] = []
111
+ for result in results:
112
+ if isinstance(result, BaseException):
113
+ continue
114
+ all_entities.extend(result)
115
+
116
+ filtered = [
117
+ e
118
+ for e in all_entities
119
+ if e.score >= _TYPE_THRESHOLDS.get(e.entity_type, self.score_threshold)
120
+ ]
121
+ merged = self._merge_entities(filtered)
122
+ validated = [e for e in merged if _validate_person(e)]
123
+ return sorted(validated, key=lambda e: e.start)
124
+
125
+ def _merge_entities(self, entities: list[DetectedEntity]) -> list[DetectedEntity]:
126
+ """
127
+ Overlap Resolution:
128
+ 1. Sort by start, then by span length descending (longer first)
129
+ 2. For each entity, check overlap with already accepted entities
130
+ 3. On overlap with SAME type: higher score wins; tie → longer span; tie → presidio wins
131
+ 4. On overlap with DIFFERENT type: keep both (dual-type annotation)
132
+ 5. No overlap: accept entity
133
+ """
134
+ sorted_ents = sorted(
135
+ entities,
136
+ key=lambda e: (e.start, -(e.end - e.start)),
137
+ )
138
+
139
+ accepted: list[DetectedEntity] = []
140
+ for candidate in sorted_ents:
141
+ same_type_idx = None
142
+ for i, existing in enumerate(accepted):
143
+ if candidate.start < existing.end and existing.start < candidate.end:
144
+ if candidate.entity_type == existing.entity_type:
145
+ same_type_idx = i
146
+ break
147
+
148
+ if same_type_idx is None:
149
+ accepted.append(candidate)
150
+ else:
151
+ existing = accepted[same_type_idx]
152
+ winner = self._pick_winner(existing, candidate)
153
+ accepted[same_type_idx] = winner
154
+
155
+ return accepted
156
+
157
+ @staticmethod
158
+ def _pick_winner(a: DetectedEntity, b: DetectedEntity) -> DetectedEntity:
159
+ if a.score != b.score:
160
+ return a if a.score > b.score else b
161
+ len_a = a.end - a.start
162
+ len_b = b.end - b.start
163
+ if len_a != len_b:
164
+ return a if len_a > len_b else b
165
+ # Tie-break: presidio wins
166
+ if a.source == "presidio":
167
+ return a
168
+ if b.source == "presidio":
169
+ return b
170
+ return a
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+
5
+ from noirdoc.detection.base import BaseDetector, DetectedEntity
6
+
7
+ _TAG_MAP: dict[str, str] = {
8
+ "PER": "PERSON",
9
+ "LOC": "LOCATION",
10
+ "ORG": "ORGANIZATION",
11
+ }
12
+
13
+
14
+ class FlairDetector(BaseDetector):
15
+ """Eigenständiger Flair-basierter NER-Detektor für deutsche Texte.
16
+
17
+ Nutzt flair/ner-german-large (XLM-R, F1=92.3% auf CoNLL-03 DE).
18
+ Robuster bei Lowercase-Text als spaCy.
19
+ """
20
+
21
+ def __init__(self, model_name: str = "flair/ner-german-large") -> None:
22
+ from flair.models import SequenceTagger
23
+
24
+ self._model = SequenceTagger.load(model_name)
25
+
26
+ async def detect(self, text: str, language: str = "de") -> list[DetectedEntity]:
27
+ if not text or language != "de":
28
+ return []
29
+
30
+ return await asyncio.to_thread(self._predict, text)
31
+
32
+ def _predict(self, text: str) -> list[DetectedEntity]:
33
+ from flair.data import Sentence
34
+
35
+ sentence = Sentence(text)
36
+ self._model.predict(sentence)
37
+
38
+ entities: list[DetectedEntity] = []
39
+ for span in sentence.get_spans("ner"):
40
+ entity_type = _TAG_MAP.get(span.tag)
41
+ if entity_type is None:
42
+ continue
43
+ entities.append(
44
+ DetectedEntity(
45
+ entity_type=entity_type,
46
+ text=span.text,
47
+ start=span.start_position,
48
+ end=span.end_position,
49
+ score=round(span.score, 2),
50
+ source="flair",
51
+ ),
52
+ )
53
+ return entities
54
+
55
+ @property
56
+ def name(self) -> str:
57
+ return "flair"
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ from presidio_analyzer import EntityRecognizer, RecognizerResult
4
+
5
+
6
+ class FlairRecognizer(EntityRecognizer):
7
+ """Flair-basierter NER-Recognizer für deutsche Texte.
8
+
9
+ Nutzt flair/ner-german-large (XLM-R, F1=92.3% auf CoNLL-03 DE).
10
+ Robuster bei Lowercase-Text als spaCy.
11
+ """
12
+
13
+ PRESIDIO_EQUIVALENCES = {
14
+ "PER": "PERSON",
15
+ "LOC": "LOCATION",
16
+ "ORG": "ORGANIZATION",
17
+ }
18
+
19
+ def __init__(self, model_name: str = "flair/ner-german-large") -> None:
20
+ # Set attributes BEFORE super().__init__ because it calls self.load()
21
+ self._model_name = model_name
22
+ self._model = None
23
+ super().__init__(
24
+ supported_entities=list(self.PRESIDIO_EQUIVALENCES.values()),
25
+ supported_language="de",
26
+ name="Flair NER",
27
+ )
28
+
29
+ def _ensure_model(self):
30
+ if self._model is None:
31
+ from flair.models import SequenceTagger
32
+
33
+ self._model = SequenceTagger.load(self._model_name)
34
+
35
+ def load(self) -> None:
36
+ self._ensure_model()
37
+
38
+ def analyze(
39
+ self,
40
+ text: str,
41
+ entities: list[str] | None = None,
42
+ nlp_artifacts=None,
43
+ ) -> list[RecognizerResult]:
44
+ if not text:
45
+ return []
46
+
47
+ self._ensure_model()
48
+
49
+ from flair.data import Sentence
50
+
51
+ sentence = Sentence(text)
52
+ self._model.predict(sentence) # type: ignore[attr-defined]
53
+
54
+ results: list[RecognizerResult] = []
55
+ for span in sentence.get_spans("ner"):
56
+ entity_type = self.PRESIDIO_EQUIVALENCES.get(span.tag)
57
+ if entity_type is None:
58
+ continue
59
+ if entities and entity_type not in entities:
60
+ continue
61
+ results.append(
62
+ RecognizerResult(
63
+ entity_type=entity_type,
64
+ start=span.start_position,
65
+ end=span.end_position,
66
+ score=round(span.score, 2),
67
+ ),
68
+ )
69
+ return results