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 +30 -0
- privyscope/__main__.py +130 -0
- privyscope/_api.py +258 -0
- privyscope/_cli/__init__.py +4 -0
- privyscope/_cli/args.py +75 -0
- privyscope/_cli/render.py +65 -0
- privyscope/_core/__init__.py +20 -0
- privyscope/_core/bioes.py +132 -0
- privyscope/_core/decoder.py +41 -0
- privyscope/_core/detect.py +121 -0
- privyscope/_core/download.py +47 -0
- privyscope/_core/entities.py +78 -0
- privyscope/_core/pipeline.py +58 -0
- privyscope/_core/plugins.py +98 -0
- privyscope/_core/regex_filter.py +56 -0
- privyscope/_core/runtime.py +70 -0
- privyscope/_core/schema.py +74 -0
- privyscope/_core/transitions.py +111 -0
- privyscope/_eval/__init__.py +4 -0
- privyscope/_eval/dataset.py +31 -0
- privyscope/_eval/metrics.py +75 -0
- privyscope/_eval/runner.py +27 -0
- privyscope/_model/__init__.py +4 -0
- privyscope/_model/config.py +54 -0
- privyscope/_train/__init__.py +11 -0
- privyscope/_train/modeling.py +74 -0
- privyscope/_train/runner.py +118 -0
- privyscope-0.1.0.dist-info/METADATA +123 -0
- privyscope-0.1.0.dist-info/RECORD +33 -0
- privyscope-0.1.0.dist-info/WHEEL +5 -0
- privyscope-0.1.0.dist-info/entry_points.txt +2 -0
- privyscope-0.1.0.dist-info/licenses/LICENSE +201 -0
- privyscope-0.1.0.dist-info/top_level.txt +1 -0
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
|
privyscope/_cli/args.py
ADDED
|
@@ -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
|
+
]
|