privyscope 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.
privyscope/__init__.py ADDED
@@ -0,0 +1,30 @@
1
+ """privyscope — multilingual PII detection & masking engine (Apache-2.0).
2
+
3
+ This is the language-neutral **core**: the two-stage hybrid engine (regex filter
4
+ + ONNX BIOES/Viterbi NER), the ``privyscope`` console command, and the plugin
5
+ machinery that discovers installed language packs (``privyscope-ko``,
6
+ ``privyscope-en``, …). Install at least one language plugin to do real work::
7
+
8
+ pip install privyscope-ko # pulls in this core automatically
9
+
10
+ Public surface::
11
+
12
+ from privyscope import Privyscope
13
+ engine = Privyscope.from_pretrained(lang="ko") # one language
14
+ auto = Privyscope.auto() # route per text
15
+ """
16
+ from ._api import Privyscope
17
+ from ._core.schema import RedactionResult, DetectedSpan, SCHEMA_VERSION
18
+ from ._core.plugins import LanguagePlugin, installed_languages
19
+
20
+ __version__ = "0.1.0"
21
+
22
+ __all__ = [
23
+ "Privyscope",
24
+ "RedactionResult",
25
+ "DetectedSpan",
26
+ "SCHEMA_VERSION",
27
+ "LanguagePlugin",
28
+ "installed_languages",
29
+ "__version__",
30
+ ]
privyscope/__main__.py ADDED
@@ -0,0 +1,130 @@
1
+ """Unified CLI entrypoint: ``privyscope redact | eval | train`` (SRS FR-2.1)."""
2
+ from __future__ import annotations
3
+
4
+ import sys
5
+ from typing import List, Optional, Sequence
6
+
7
+ from ._api import AutoPrivyscope, Privyscope
8
+ from ._cli.args import build_parser
9
+ from ._cli.render import print_result, supports_color
10
+ from ._core.plugins import installed_languages, resolve_plugin
11
+
12
+ _PROVIDERS = {
13
+ "cpu": ["CPUExecutionProvider"],
14
+ "cuda": ["CUDAExecutionProvider", "CPUExecutionProvider"],
15
+ "coreml": ["CoreMLExecutionProvider", "CPUExecutionProvider"],
16
+ }
17
+
18
+
19
+ def _parse_operating_point(value: str):
20
+ """A preset name, or comma-separated raw transition biases (FR-2.5)."""
21
+ if value and "," in value:
22
+ return [float(x) for x in value.split(",")]
23
+ return value
24
+
25
+
26
+ def _read_input(args) -> Optional[str]:
27
+ """Resolve redact input from arg / file / stdin; None triggers interactive mode."""
28
+ if args.text is not None:
29
+ return args.text
30
+ if args.file:
31
+ with open(args.file, "r", encoding="utf-8") as fh:
32
+ return fh.read().rstrip("\n")
33
+ if not sys.stdin.isatty():
34
+ data = sys.stdin.read()
35
+ return data.rstrip("\n") if data else None
36
+ return None
37
+
38
+
39
+ def _build_engine(args):
40
+ """Build the inference engine for redact/eval.
41
+
42
+ ``--lang X`` → that language. No ``--lang`` with one plugin installed → that
43
+ plugin. No ``--lang`` with several installed → an auto-routing dispatcher.
44
+ Returns an ``Privyscope`` or an ``AutoPrivyscope`` (same redact surface).
45
+ """
46
+ operating_point = _parse_operating_point(getattr(args, "operating_point", "balanced"))
47
+ cache_dir = getattr(args, "cache_dir", None)
48
+ regex_only = getattr(args, "regex_only", False)
49
+ providers = _PROVIDERS.get(getattr(args, "device", "cpu"), None)
50
+ lang = getattr(args, "lang", None)
51
+
52
+ if lang is None and len(installed_languages()) > 1:
53
+ return AutoPrivyscope(
54
+ operating_point=operating_point,
55
+ cache_dir=cache_dir,
56
+ providers=providers,
57
+ regex_only=regex_only,
58
+ )
59
+ return Privyscope.from_pretrained(
60
+ operating_point=operating_point,
61
+ lang=lang,
62
+ cache_dir=cache_dir,
63
+ revision=getattr(args, "revision", None),
64
+ regex_only=regex_only,
65
+ providers=providers,
66
+ )
67
+
68
+
69
+ def _cmd_redact(args) -> int:
70
+ engine = _build_engine(args)
71
+ entity_types = args.entity_types.split(",") if args.entity_types else None
72
+ text = _read_input(args)
73
+ as_json = args.json or not supports_color()
74
+
75
+ if text is None: # interactive mode (FR-2.1)
76
+ print("privyscope interactive redact — enter text (Ctrl-D to exit)", file=sys.stderr)
77
+ for line in sys.stdin:
78
+ line = line.rstrip("\n")
79
+ if not line:
80
+ continue
81
+ print_result(engine.redact(line, entity_types, args.output_mode), as_json)
82
+ return 0
83
+
84
+ print_result(engine.redact(text, entity_types, args.output_mode), as_json)
85
+ return 0
86
+
87
+
88
+ def _cmd_eval(args) -> int:
89
+ from ._eval.runner import run_eval
90
+
91
+ engine = _build_engine(args)
92
+ report = run_eval(engine, args.dataset, eval_mode=args.eval_mode, limit=args.limit)
93
+ import json
94
+
95
+ print(json.dumps(report, ensure_ascii=False, indent=2))
96
+ return 0
97
+
98
+
99
+ def _cmd_train(args) -> int:
100
+ try:
101
+ from ._train.runner import run_train
102
+ except ImportError as exc:
103
+ print(
104
+ f"training requires the optional extra: pip install 'privyscope[train]' ({exc})",
105
+ file=sys.stderr,
106
+ )
107
+ return 2
108
+ # Default the base checkpoint from the selected language plugin (FR-2.6).
109
+ if not getattr(args, "base_model", None):
110
+ plugin = resolve_plugin(getattr(args, "lang", None))
111
+ if plugin.default_base_model:
112
+ args.base_model = plugin.default_base_model
113
+ summary = run_train(args)
114
+ import json
115
+
116
+ print(json.dumps(summary, ensure_ascii=False, indent=2))
117
+ return 0
118
+
119
+
120
+ def main(argv: Optional[Sequence[str]] = None) -> int:
121
+ args = build_parser().parse_args(argv)
122
+ return {
123
+ "redact": _cmd_redact,
124
+ "eval": _cmd_eval,
125
+ "train": _cmd_train,
126
+ }[args.command](args)
127
+
128
+
129
+ if __name__ == "__main__":
130
+ raise SystemExit(main())
privyscope/_api.py ADDED
@@ -0,0 +1,258 @@
1
+ """Public Python API (SRS FR-2.7).
2
+
3
+ from privyscope import Privyscope # or: from privyscope_ko import Privyscope
4
+
5
+ # one language (explicit, or the sole one installed)
6
+ engine = Privyscope.from_pretrained(lang="ko")
7
+ result = engine.redact("홍길동의 전화번호는 010-1234-5678")
8
+ result.masked_text # "<PER>의 전화번호는 <PHONE>"
9
+
10
+ # multiple languages installed → route each text automatically
11
+ auto = Privyscope.auto()
12
+ auto.redact("홍길동 010-1234-5678") # → Korean engine
13
+ auto.redact("John Smith 555-123-4567") # → English engine
14
+
15
+ The engine is a two-stage hybrid pipeline (SRS §3.4): a regex filter (always
16
+ available) plus an optional ONNX NER stage. ``regex_only=True`` runs the engine
17
+ with no model weights — useful offline and for structurally-obvious PII.
18
+
19
+ Language-specific data (regex rules, entity config, default weights repo) comes
20
+ from an installed language plugin discovered via the ``privyscope.languages``
21
+ entry-point group; the core hardcodes nothing language-specific.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import os
26
+ import threading
27
+ from pathlib import Path
28
+ from typing import Dict, Iterable, List, Optional, Sequence, Union
29
+
30
+ from ._core.bioes import Span
31
+ from ._core.detect import detect_language
32
+ from ._core.download import resolve_weights
33
+ from ._core.pipeline import assemble, merge_union
34
+ from ._core.plugins import LanguagePlugin, installed_languages, resolve_plugin
35
+ from ._core.regex_filter import RegexFilter
36
+ from ._core.schema import RedactionResult
37
+ from ._core.transitions import CATEGORIES, resolve_operating_point
38
+
39
+ OperatingPoint = Union[str, Sequence[float], dict, None]
40
+
41
+
42
+ def _regex_rules_path(plugin: LanguagePlugin) -> Path:
43
+ """Locate the active language's regex ruleset (env override wins)."""
44
+ env = os.environ.get("PRIVYSCOPE_REGEX_RULES")
45
+ if env:
46
+ return Path(env)
47
+ return plugin.regex_rules_path()
48
+
49
+
50
+ class Privyscope:
51
+ """Two-stage PII redaction engine for a single language."""
52
+
53
+ def __init__(
54
+ self,
55
+ regex_filter: RegexFilter,
56
+ runtime=None,
57
+ operating_point: OperatingPoint = "balanced",
58
+ ) -> None:
59
+ self._regex = regex_filter
60
+ self._runtime = runtime
61
+ self._operating_point = operating_point
62
+
63
+ # -- construction -------------------------------------------------------
64
+ @classmethod
65
+ def from_pretrained(
66
+ cls,
67
+ operating_point: OperatingPoint = "balanced",
68
+ *,
69
+ lang: Optional[str] = None,
70
+ repo_id: Optional[str] = None,
71
+ revision: Optional[str] = None,
72
+ cache_dir: Optional[str] = None,
73
+ regex_rules: Optional[str] = None,
74
+ providers: Optional[Sequence[str]] = None,
75
+ regex_only: bool = False,
76
+ ) -> "Privyscope":
77
+ """Load the engine for one language, fetching ONNX weights on first use.
78
+
79
+ ``lang`` selects the language plugin; if omitted, the sole installed
80
+ plugin is used (raises if several are installed — pass ``lang`` or use
81
+ :meth:`auto`). Pass ``regex_only=True`` to skip the NER stage (no weights
82
+ required). ``cache_dir`` pointing at a local bundle enables offline use.
83
+ """
84
+ plugin = resolve_plugin(lang)
85
+ rules_path = regex_rules or _regex_rules_path(plugin)
86
+ regex = RegexFilter.from_yaml(rules_path)
87
+
88
+ runtime = None
89
+ if not regex_only:
90
+ from ._core.runtime import NerRuntime
91
+
92
+ repo = repo_id or plugin.default_repo
93
+ bundle = resolve_weights(repo_id=repo, revision=revision, cache_dir=cache_dir)
94
+ runtime = NerRuntime(bundle, providers=providers)
95
+ return cls(regex, runtime, operating_point)
96
+
97
+ @classmethod
98
+ def regex_only(
99
+ cls, *, lang: Optional[str] = None, regex_rules: Optional[str] = None
100
+ ) -> "Privyscope":
101
+ """Convenience constructor for a Stage-1-only engine (no weights)."""
102
+ return cls.from_pretrained(regex_only=True, lang=lang, regex_rules=regex_rules)
103
+
104
+ @classmethod
105
+ def auto(
106
+ cls,
107
+ operating_point: OperatingPoint = "balanced",
108
+ *,
109
+ cache_dir: Optional[str] = None,
110
+ providers: Optional[Sequence[str]] = None,
111
+ regex_only: bool = False,
112
+ ) -> "AutoPrivyscope":
113
+ """Return a dispatcher that routes each text to its language engine.
114
+
115
+ Languages are detected per text (Unicode-script heuristic) and restricted
116
+ to the installed plugins. Per-language engines are built lazily and cached.
117
+ """
118
+ return AutoPrivyscope(
119
+ operating_point=operating_point,
120
+ cache_dir=cache_dir,
121
+ providers=providers,
122
+ regex_only=regex_only,
123
+ )
124
+
125
+ # -- inference ----------------------------------------------------------
126
+ def _effective_biases(self, operating_point: OperatingPoint) -> List[float]:
127
+ """Learned baseline biases shifted by the operating-point offset (FR-2.5)."""
128
+ offset = resolve_operating_point(
129
+ operating_point if operating_point is not None else self._operating_point
130
+ )
131
+ base = self._runtime.default_biases if self._runtime else [0.0] * len(CATEGORIES)
132
+ return [b + o for b, o in zip(base, offset)]
133
+
134
+ def redact(
135
+ self,
136
+ text: str,
137
+ entity_types: Optional[Iterable[str]] = None,
138
+ output_mode: str = "typed",
139
+ operating_point: OperatingPoint = None,
140
+ ) -> RedactionResult:
141
+ """Detect and mask PII in a single string (FR-2.2)."""
142
+ regex_spans: List[Span] = self._regex.find(text)
143
+ ner_spans: List[Span] = []
144
+ mismatch = False
145
+ if self._runtime is not None:
146
+ ner_spans, mismatch = self._runtime.predict(text, self._effective_biases(operating_point))
147
+ merged = merge_union(regex_spans, ner_spans)
148
+ return assemble(text, merged, output_mode, entity_types, mismatch)
149
+
150
+ def batch_redact(
151
+ self,
152
+ texts: Sequence[str],
153
+ entity_types: Optional[Iterable[str]] = None,
154
+ output_mode: str = "typed",
155
+ operating_point: OperatingPoint = None,
156
+ ) -> List[RedactionResult]:
157
+ return [self.redact(t, entity_types, output_mode, operating_point) for t in texts]
158
+
159
+ @property
160
+ def has_ner(self) -> bool:
161
+ return self._runtime is not None
162
+
163
+
164
+ class AutoPrivyscope:
165
+ """Multi-language dispatcher (see :meth:`Privyscope.auto`).
166
+
167
+ Mirrors the :class:`Privyscope` inference surface (``redact`` /
168
+ ``batch_redact`` / ``has_ner``) and routes each call to a lazily-built,
169
+ cached per-language engine chosen by :func:`detect_language`.
170
+
171
+ **Thread-safety:** per-language engine construction is guarded by a lock, so
172
+ concurrent callers (e.g. a multi-threaded web server) never build or download
173
+ the same engine twice. Once built, ONNX Runtime sessions are safe for
174
+ concurrent inference. Call :meth:`preload` at startup to build engines
175
+ eagerly and avoid a first-request latency spike.
176
+ """
177
+
178
+ def __init__(
179
+ self,
180
+ *,
181
+ operating_point: OperatingPoint,
182
+ cache_dir: Optional[str],
183
+ providers: Optional[Sequence[str]],
184
+ regex_only: bool,
185
+ ) -> None:
186
+ self._available = sorted(installed_languages())
187
+ if not self._available:
188
+ raise ValueError(
189
+ "no privyscope language plugin installed; install one, "
190
+ "e.g. pip install privyscope-ko"
191
+ )
192
+ self._kwargs = dict(
193
+ operating_point=operating_point,
194
+ cache_dir=cache_dir,
195
+ providers=providers,
196
+ regex_only=regex_only,
197
+ )
198
+ self._regex_only = regex_only
199
+ self._engines: Dict[str, Privyscope] = {}
200
+ self._lock = threading.Lock()
201
+
202
+ @property
203
+ def available_languages(self) -> List[str]:
204
+ return list(self._available)
205
+
206
+ def _engine_for(self, lang: str) -> Privyscope:
207
+ # Fast path: already built (dict reads are atomic under the GIL).
208
+ engine = self._engines.get(lang)
209
+ if engine is not None:
210
+ return engine
211
+ # Slow path: build under lock with a double-check so concurrent callers
212
+ # for the same language share one engine (and one weights download).
213
+ with self._lock:
214
+ engine = self._engines.get(lang)
215
+ if engine is None:
216
+ engine = Privyscope.from_pretrained(lang=lang, **self._kwargs)
217
+ self._engines[lang] = engine
218
+ return engine
219
+
220
+ def preload(self, langs: Optional[Iterable[str]] = None) -> "AutoPrivyscope":
221
+ """Eagerly build (and cache) engines so the first request is warm.
222
+
223
+ Builds every installed language by default, or just ``langs``. Returns
224
+ ``self`` for chaining, e.g. ``engine = Privyscope.auto().preload()``.
225
+ Ideal at web-server startup: fail fast on missing weights and avoid a
226
+ cold-start spike on the first redact call.
227
+ """
228
+ for lang in (langs if langs is not None else self._available):
229
+ self._engine_for(lang)
230
+ return self
231
+
232
+ def route(self, text: str) -> str:
233
+ """Return the language code chosen for ``text``."""
234
+ return detect_language(text, self._available) or self._available[0]
235
+
236
+ def redact(
237
+ self,
238
+ text: str,
239
+ entity_types: Optional[Iterable[str]] = None,
240
+ output_mode: str = "typed",
241
+ operating_point: OperatingPoint = None,
242
+ ) -> RedactionResult:
243
+ return self._engine_for(self.route(text)).redact(
244
+ text, entity_types, output_mode, operating_point
245
+ )
246
+
247
+ def batch_redact(
248
+ self,
249
+ texts: Sequence[str],
250
+ entity_types: Optional[Iterable[str]] = None,
251
+ output_mode: str = "typed",
252
+ operating_point: OperatingPoint = None,
253
+ ) -> List[RedactionResult]:
254
+ return [self.redact(t, entity_types, output_mode, operating_point) for t in texts]
255
+
256
+ @property
257
+ def has_ner(self) -> bool:
258
+ return not self._regex_only
@@ -0,0 +1,4 @@
1
+ """CLI argument parsing and terminal rendering."""
2
+ from .args import build_parser
3
+
4
+ __all__ = ["build_parser"]
@@ -0,0 +1,75 @@
1
+ """CLI argument parsing (SRS FR-2.1)."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+
6
+ from .. import __version__
7
+
8
+
9
+ def _version_string() -> str:
10
+ """Core version plus the language plugins discovered at runtime."""
11
+ from .._core.plugins import installed_languages
12
+
13
+ langs = installed_languages()
14
+ if langs:
15
+ detail = ", ".join(f"{c} ({p.display_name})" for c, p in sorted(langs.items()))
16
+ else:
17
+ detail = "none — install one, e.g. pip install privyscope-ko"
18
+ return f"privyscope {__version__} (languages: {detail})"
19
+
20
+
21
+ def _add_lang(parser: argparse.ArgumentParser) -> None:
22
+ parser.add_argument(
23
+ "--lang",
24
+ help="language code (e.g. ko, en); omit to auto-detect per text when "
25
+ "several are installed, or use the sole installed language",
26
+ )
27
+
28
+
29
+ def build_parser() -> argparse.ArgumentParser:
30
+ p = argparse.ArgumentParser(
31
+ prog="privyscope",
32
+ description="privyscope — multilingual PII detection & masking (redact / eval / train)",
33
+ )
34
+ p.add_argument("--version", action="version", version=_version_string())
35
+ sub = p.add_subparsers(dest="command", required=True)
36
+
37
+ # -- redact -------------------------------------------------------------
38
+ r = sub.add_parser("redact", help="detect and mask PII in text")
39
+ r.add_argument("text", nargs="?", help="text to redact; omit to read stdin / interactive")
40
+ r.add_argument("-f", "--file", help="read input from a file")
41
+ _add_lang(r)
42
+ r.add_argument("--device", choices=["cpu", "cuda", "coreml"], default="cpu")
43
+ r.add_argument(
44
+ "--operating-point", default="balanced",
45
+ help="high_recall | balanced | high_precision, or comma-separated raw biases",
46
+ )
47
+ r.add_argument("--output-mode", choices=["typed", "redacted"], default="typed")
48
+ r.add_argument("--entity-types", help="comma-separated subset, e.g. PER,PHONE")
49
+ r.add_argument("--regex-only", action="store_true", help="skip the NER stage (no weights)")
50
+ r.add_argument("--cache-dir", help="local weight bundle directory (offline)")
51
+ r.add_argument("--revision", help="pin a HF Hub weight revision")
52
+ r.add_argument("--json", action="store_true", help="force JSON output")
53
+
54
+ # -- eval ---------------------------------------------------------------
55
+ e = sub.add_parser("eval", help="evaluate against a labelled JSONL dataset")
56
+ e.add_argument("dataset", help="JSONL with text + spans")
57
+ _add_lang(e)
58
+ e.add_argument("--eval-mode", choices=["typed", "untyped"], default="typed")
59
+ e.add_argument("--operating-point", default="balanced")
60
+ e.add_argument("--regex-only", action="store_true")
61
+ e.add_argument("--cache-dir")
62
+ e.add_argument("--limit", type=int, default=None)
63
+
64
+ # -- train --------------------------------------------------------------
65
+ t = sub.add_parser("train", help="fine-tune the NER model on custom JSONL")
66
+ t.add_argument("dataset", help="training JSONL")
67
+ _add_lang(t)
68
+ t.add_argument("--validation-dataset")
69
+ t.add_argument("--label-space-json", help="custom label map (FR-2.6 / §8.2)")
70
+ t.add_argument("--output-dir", required=True)
71
+ t.add_argument("--base-model", default=None)
72
+ t.add_argument("--epochs", type=int, default=3)
73
+ t.add_argument("--batch-size", type=int, default=16)
74
+ t.add_argument("--learning-rate", type=float, default=3e-5)
75
+ return p
@@ -0,0 +1,65 @@
1
+ """Terminal rendering for the CLI (SRS FR-2.1 ANSI span previews)."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import sys
6
+ from typing import List
7
+
8
+ from .._core.schema import RedactionResult
9
+
10
+ # Per-label ANSI background colours; falls back to a default for custom labels.
11
+ _COLORS = {
12
+ "PER": "\033[48;5;31m", "PHONE": "\033[48;5;130m", "ID_NUM": "\033[48;5;90m",
13
+ "EMAIL": "\033[48;5;28m", "LOC": "\033[48;5;24m", "BANK": "\033[48;5;94m",
14
+ "DATE": "\033[48;5;55m", "SECRET": "\033[48;5;124m",
15
+ }
16
+ _DEFAULT_COLOR = "\033[48;5;240m"
17
+ _RESET = "\033[0m"
18
+ _DIM = "\033[2m"
19
+ _BOLD = "\033[1m"
20
+
21
+
22
+ def supports_color(stream=sys.stdout) -> bool:
23
+ return hasattr(stream, "isatty") and stream.isatty()
24
+
25
+
26
+ def highlight(result: RedactionResult, color: bool = True) -> str:
27
+ """Return the input with detected spans visually marked."""
28
+ text = result.text
29
+ out: List[str] = []
30
+ cursor = 0
31
+ for s in sorted(result.detected_spans, key=lambda d: d.start):
32
+ out.append(text[cursor : s.start])
33
+ chunk = text[s.start : s.end]
34
+ if color:
35
+ c = _COLORS.get(s.label, _DEFAULT_COLOR)
36
+ out.append(f"{c}{chunk}{_RESET}{_DIM}[{s.label}]{_RESET}")
37
+ else:
38
+ out.append(f"[{chunk}]({s.label})")
39
+ cursor = s.end
40
+ out.append(text[cursor:])
41
+ return "".join(out)
42
+
43
+
44
+ def render_human(result: RedactionResult, color: bool = True) -> str:
45
+ lines = [
46
+ highlight(result, color),
47
+ "",
48
+ f"{_BOLD if color else ''}redacted:{_RESET if color else ''} {result.redacted_text}",
49
+ f"{_DIM if color else ''}spans: {result.summary['span_count']} "
50
+ f"by_label: {result.summary['by_label']}"
51
+ + (" (decode mismatch)" if result.decoded_mismatch else "")
52
+ + (_RESET if color else ""),
53
+ ]
54
+ return "\n".join(lines)
55
+
56
+
57
+ def render_json(result: RedactionResult) -> str:
58
+ return json.dumps(result.to_dict(), ensure_ascii=False, indent=2)
59
+
60
+
61
+ def print_result(result: RedactionResult, as_json: bool) -> None:
62
+ if as_json:
63
+ print(render_json(result))
64
+ else:
65
+ print(render_human(result, color=supports_color()))
@@ -0,0 +1,20 @@
1
+ """Runtime core: loading, regex/NER stages, decoding, span assembly."""
2
+ from .bioes import Span, bioes_to_spans, spans_to_bioes
3
+ from .pipeline import assemble, merge_union
4
+ from .regex_filter import RegexFilter
5
+ from .schema import DetectedSpan, RedactionResult, SCHEMA_VERSION
6
+ from .transitions import OPERATING_POINTS, resolve_operating_point
7
+
8
+ __all__ = [
9
+ "Span",
10
+ "bioes_to_spans",
11
+ "spans_to_bioes",
12
+ "assemble",
13
+ "merge_union",
14
+ "RegexFilter",
15
+ "DetectedSpan",
16
+ "RedactionResult",
17
+ "SCHEMA_VERSION",
18
+ "OPERATING_POINTS",
19
+ "resolve_operating_point",
20
+ ]