maskflow-cli 0.6.0__tar.gz → 0.7.0__tar.gz
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.
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/PKG-INFO +4 -2
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/pyproject.toml +11 -2
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/commands/explain_cmd.py +32 -2
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/doctor.py +11 -0
- maskflow_cli-0.7.0/src/maskflow_cli/evidence.py +83 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/explain.py +20 -2
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/tests/test_cli_explain.py +41 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/.dockerignore +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/.gitignore +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/Dockerfile +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/README.md +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/examples/README.md +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/examples/generate_sample.py +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/examples/sample-llm-traffic.jsonl +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/packaging/README.md +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/packaging/_entry.py +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/packaging/maskflow.spec +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/__init__.py +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/app.py +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/commands/__init__.py +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/commands/config_cmd.py +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/commands/doctor_cmd.py +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/doctor_render.py +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/explain_render.py +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/render.py +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/__init__.py +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/aggregate.py +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/checkpoint.py +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/cmd.py +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/errors.py +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/fieldsel.py +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/pipeline.py +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/report/__init__.py +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/report/assets.py +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/report/build.py +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/report/csv_out.py +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/report/html.py +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/report/json_out.py +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/report/summary.py +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/severity.py +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/sources/__init__.py +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/sources/_api_common.py +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/sources/_files.py +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/sources/_http.py +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/sources/_meta.py +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/sources/base.py +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/sources/csv.py +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/sources/dir.py +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/sources/helicone.py +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/sources/jsonl.py +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/sources/langfuse.py +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/sources/langsmith.py +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/sources/postgres.py +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/sources/s3.py +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/spec.py +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/src/maskflow_cli/scan/worker.py +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/tests/conftest.py +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/tests/fixtures/partial.toml +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/tests/fixtures/typo.toml +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/tests/fixtures/valid.toml +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/tests/scan/__init__.py +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/tests/scan/_fuzz_corpus.py +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/tests/scan/conftest.py +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/tests/scan/test_aggregate_and_severity.py +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/tests/scan/test_example_file.py +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/tests/scan/test_fieldsel.py +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/tests/scan/test_pipeline_and_report.py +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/tests/scan/test_report_no_pii_leak.py +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/tests/scan/test_sources_api.py +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/tests/scan/test_sources_files.py +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/tests/test_cli_doctor.py +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/tests/test_cli_show.py +0 -0
- {maskflow_cli-0.6.0 → maskflow_cli-0.7.0}/tests/test_cli_validate.py +0 -0
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: maskflow-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: Command-line interface for MaskFlow: .maskflowrc config validation and inspection
|
|
5
5
|
License: MIT
|
|
6
6
|
Requires-Python: >=3.10
|
|
7
7
|
Requires-Dist: httpx>=0.27
|
|
8
|
-
Requires-Dist: maskflow-core[yaml]<0.
|
|
8
|
+
Requires-Dist: maskflow-core[yaml]<0.8,>=0.6.0
|
|
9
9
|
Requires-Dist: maskflow-pack-india<0.6,>=0.1.0
|
|
10
10
|
Requires-Dist: maskflow-pack-intl<0.4,>=0.3.0
|
|
11
11
|
Requires-Dist: rich>=13.0
|
|
@@ -13,6 +13,8 @@ Requires-Dist: tomli-w>=1.0
|
|
|
13
13
|
Requires-Dist: typer>=0.12
|
|
14
14
|
Provides-Extra: dev
|
|
15
15
|
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
16
|
+
Provides-Extra: evidence
|
|
17
|
+
Requires-Dist: maskflow-evidence<0.2,>=0.1.0; extra == 'evidence'
|
|
16
18
|
Provides-Extra: postgres
|
|
17
19
|
Requires-Dist: psycopg[binary]>=3.1; extra == 'postgres'
|
|
18
20
|
Provides-Extra: s3
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "maskflow-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.7.0"
|
|
4
4
|
description = "Command-line interface for MaskFlow: .maskflowrc config validation and inspection"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.10"
|
|
@@ -14,7 +14,7 @@ dependencies = [
|
|
|
14
14
|
# -- pack-intl/pack-india now require it, so the floor moves with theirs
|
|
15
15
|
# even though this package doesn't import maskflow_core.recognizer
|
|
16
16
|
# directly.
|
|
17
|
-
"maskflow-core[yaml]>=0.6.0,<0.
|
|
17
|
+
"maskflow-core[yaml]>=0.6.0,<0.8",
|
|
18
18
|
# maskflow_pack_intl is imported (side-effect only, to populate the
|
|
19
19
|
# PIIType registry) for `maskflow config validate`'s soft entity-name
|
|
20
20
|
# cross-check -- see app.py. pack-intl 0.1.0 itself requires core<0.3,
|
|
@@ -53,6 +53,14 @@ maskflow = "maskflow_cli.app:main"
|
|
|
53
53
|
dev = [
|
|
54
54
|
"pytest>=8.0",
|
|
55
55
|
]
|
|
56
|
+
# `maskflow explain --evidence` / `--evidence-file`: metadata-only evidence
|
|
57
|
+
# events (issue #41). Opt-in so a bare CLI install (and the standalone
|
|
58
|
+
# PyInstaller binary) doesn't require it; `maskflow_cli.evidence` imports it
|
|
59
|
+
# lazily and every other command runs without it. Base pull is the
|
|
60
|
+
# stdlib-only sinks; [webhook]/[otlp] stay opt-in on maskflow-evidence.
|
|
61
|
+
evidence = [
|
|
62
|
+
"maskflow-evidence>=0.1.0,<0.2",
|
|
63
|
+
]
|
|
56
64
|
# `maskflow scan s3://...` -- only pulled by an explicit
|
|
57
65
|
# `pip install 'maskflow-cli[s3]'`, so boto3 (large) never taxes the common
|
|
58
66
|
# jsonl/csv/dir scan or the rest of the CLI.
|
|
@@ -68,6 +76,7 @@ postgres = [
|
|
|
68
76
|
maskflow-core = { workspace = true }
|
|
69
77
|
maskflow-pack-intl = { workspace = true }
|
|
70
78
|
maskflow-pack-india = { workspace = true }
|
|
79
|
+
maskflow-evidence = { workspace = true }
|
|
71
80
|
|
|
72
81
|
[tool.uv]
|
|
73
82
|
package = true
|
|
@@ -7,7 +7,8 @@ from pathlib import Path
|
|
|
7
7
|
import typer
|
|
8
8
|
from maskflow_core.config.resolve import ConfigResolutionError, resolve_config
|
|
9
9
|
|
|
10
|
-
from ..
|
|
10
|
+
from ..evidence import EvidenceExtraMissing, emit_explain_evidence
|
|
11
|
+
from ..explain import build_explain_result, detect_for_explain
|
|
11
12
|
from ..explain_render import make_console, render_explain
|
|
12
13
|
|
|
13
14
|
_CONFIG_OPTION = typer.Option(
|
|
@@ -19,6 +20,17 @@ _SET_OPTION = typer.Option(
|
|
|
19
20
|
_FULL_OPTION = typer.Option(
|
|
20
21
|
False, "--full", help="Show the entire matched value instead of truncating to 8 chars."
|
|
21
22
|
)
|
|
23
|
+
_EVIDENCE_OPTION = typer.Option(
|
|
24
|
+
None,
|
|
25
|
+
"--evidence/--no-evidence",
|
|
26
|
+
help="Emit metadata-only evidence events for this run (default: the "
|
|
27
|
+
"[evidence] section of your .maskflowrc). Never contains a detected value.",
|
|
28
|
+
)
|
|
29
|
+
_EVIDENCE_FILE_OPTION = typer.Option(
|
|
30
|
+
None,
|
|
31
|
+
"--evidence-file",
|
|
32
|
+
help="Write evidence events (one JSON object per line) to this file. Implies --evidence.",
|
|
33
|
+
)
|
|
22
34
|
|
|
23
35
|
|
|
24
36
|
def explain(
|
|
@@ -26,6 +38,8 @@ def explain(
|
|
|
26
38
|
full: bool = _FULL_OPTION,
|
|
27
39
|
config: Path | None = _CONFIG_OPTION,
|
|
28
40
|
set_: list[str] = _SET_OPTION,
|
|
41
|
+
evidence: bool | None = _EVIDENCE_OPTION,
|
|
42
|
+
evidence_file: Path | None = _EVIDENCE_FILE_OPTION,
|
|
29
43
|
) -> None:
|
|
30
44
|
"""Show, span by span, why each piece of text was (or wasn't) detected
|
|
31
45
|
as PII: the pattern/NER hit, checksum result, context boost, and the
|
|
@@ -39,5 +53,21 @@ def explain(
|
|
|
39
53
|
typer.echo(line, err=True)
|
|
40
54
|
raise typer.Exit(code=1) from exc
|
|
41
55
|
|
|
42
|
-
|
|
56
|
+
accepted, rejected = detect_for_explain(text, resolved.config)
|
|
57
|
+
result = build_explain_result(text, accepted, rejected, resolved.config, full=full)
|
|
43
58
|
render_explain(make_console(), result)
|
|
59
|
+
|
|
60
|
+
enabled = resolved.config.evidence.enabled if evidence is None else evidence
|
|
61
|
+
try:
|
|
62
|
+
emitted = emit_explain_evidence(
|
|
63
|
+
accepted,
|
|
64
|
+
rejected,
|
|
65
|
+
resolved.config,
|
|
66
|
+
enabled=enabled,
|
|
67
|
+
evidence_file=evidence_file,
|
|
68
|
+
)
|
|
69
|
+
except EvidenceExtraMissing as exc:
|
|
70
|
+
typer.echo(str(exc), err=True)
|
|
71
|
+
raise typer.Exit(code=1) from exc
|
|
72
|
+
if emitted and evidence_file is not None:
|
|
73
|
+
typer.echo(f"\n{emitted} evidence event(s) written to {evidence_file}", err=True)
|
|
@@ -127,6 +127,16 @@ def _check_config() -> tuple[ComponentCheck, ResolvedConfig | None]:
|
|
|
127
127
|
return ComponentCheck(".maskflowrc", None, status, detail), resolved
|
|
128
128
|
|
|
129
129
|
|
|
130
|
+
def _check_evidence(resolved: ResolvedConfig | None) -> ComponentCheck:
|
|
131
|
+
"""Reports whether the metadata-only evidence layer is on, and to where.
|
|
132
|
+
Off by default -- an informational line, never an error."""
|
|
133
|
+
section = resolved.config.evidence if resolved is not None else None
|
|
134
|
+
if section is None or not section.enabled:
|
|
135
|
+
return ComponentCheck("evidence", None, "ok", "disabled (default) -- no events emitted")
|
|
136
|
+
where = section.path if section.sink == "file" else (section.url or section.sink)
|
|
137
|
+
return ComponentCheck("evidence", None, "ok", f"enabled -- sink: {section.sink} ({where})")
|
|
138
|
+
|
|
139
|
+
|
|
130
140
|
def _check_redis() -> ComponentCheck:
|
|
131
141
|
# RedisMappingStore (maskflow_core.mapping_store) is an interface-only
|
|
132
142
|
# stub -- every method raises NotImplementedError regardless of whether
|
|
@@ -177,6 +187,7 @@ def run_checks() -> DoctorReport:
|
|
|
177
187
|
config_check, resolved = _check_config()
|
|
178
188
|
components.append(config_check)
|
|
179
189
|
|
|
190
|
+
components.append(_check_evidence(resolved))
|
|
180
191
|
components.append(_check_redis())
|
|
181
192
|
|
|
182
193
|
entities = _entity_checks(resolved, spacy_ready=model_check.status == "ok")
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""`maskflow explain --evidence`: emit one metadata-only evidence event per
|
|
2
|
+
detected (or near-miss) entity type for this run.
|
|
3
|
+
|
|
4
|
+
Thin glue over ``maskflow_evidence``, which is an **optional** dependency
|
|
5
|
+
(``pip install maskflow-cli[evidence]``) -- imported lazily so a bare CLI
|
|
6
|
+
install without it still runs every other command.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import secrets
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from maskflow_core.config.schema import RootConfig
|
|
16
|
+
from maskflow_core.entities import Span
|
|
17
|
+
|
|
18
|
+
_MISSING = (
|
|
19
|
+
"the --evidence / --evidence-file flags need the evidence extra -- "
|
|
20
|
+
"install it with: pip install 'maskflow-cli[evidence]'"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class EvidenceExtraMissing(RuntimeError):
|
|
25
|
+
"""Raised when --evidence is requested but maskflow-evidence isn't installed."""
|
|
26
|
+
|
|
27
|
+
def __init__(self) -> None:
|
|
28
|
+
super().__init__(_MISSING)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def emit_explain_evidence(
|
|
32
|
+
accepted: Sequence[Span],
|
|
33
|
+
rejected: Sequence[Span],
|
|
34
|
+
root_config: RootConfig,
|
|
35
|
+
*,
|
|
36
|
+
enabled: bool,
|
|
37
|
+
evidence_file: Path | None,
|
|
38
|
+
) -> int:
|
|
39
|
+
"""Emit events for one `explain` run. Returns the count emitted.
|
|
40
|
+
|
|
41
|
+
``enabled`` is the resolved on/off (``--evidence`` / ``[evidence]
|
|
42
|
+
enabled``); ``evidence_file`` forces a file sink at that path. Returns 0
|
|
43
|
+
without importing anything when evidence is off.
|
|
44
|
+
"""
|
|
45
|
+
want = evidence_file is not None or enabled
|
|
46
|
+
if not want and not root_config.evidence.enabled:
|
|
47
|
+
return 0
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
from dataclasses import replace
|
|
51
|
+
|
|
52
|
+
from maskflow_evidence import (
|
|
53
|
+
EventContext,
|
|
54
|
+
EvidenceConfig,
|
|
55
|
+
build_emitter,
|
|
56
|
+
events_from_spans,
|
|
57
|
+
)
|
|
58
|
+
except ModuleNotFoundError as exc:
|
|
59
|
+
if evidence_file is not None or enabled:
|
|
60
|
+
raise EvidenceExtraMissing() from exc
|
|
61
|
+
return 0 # [evidence] enabled in config but the extra isn't installed
|
|
62
|
+
|
|
63
|
+
config = EvidenceConfig.from_rootconfig(root_config)
|
|
64
|
+
if evidence_file is not None:
|
|
65
|
+
config = replace(config, enabled=True, sink="file", path=str(evidence_file))
|
|
66
|
+
elif enabled:
|
|
67
|
+
config = replace(config, enabled=True)
|
|
68
|
+
if not config.enabled:
|
|
69
|
+
return 0
|
|
70
|
+
|
|
71
|
+
ctx = EventContext(
|
|
72
|
+
session_id="cli_" + secrets.token_hex(8),
|
|
73
|
+
service=config.service,
|
|
74
|
+
environment=config.environment,
|
|
75
|
+
)
|
|
76
|
+
emitter = build_emitter(config)
|
|
77
|
+
try:
|
|
78
|
+
events = events_from_spans(accepted, ctx, dropped=rejected)
|
|
79
|
+
for event in events:
|
|
80
|
+
emitter.emit(event)
|
|
81
|
+
return len(events)
|
|
82
|
+
finally:
|
|
83
|
+
emitter.close()
|
|
@@ -70,16 +70,29 @@ def suggested_threshold(score: float) -> float:
|
|
|
70
70
|
return math.floor(score * 20) / 20
|
|
71
71
|
|
|
72
72
|
|
|
73
|
-
def
|
|
73
|
+
def detect_for_explain(text: str, root_config: RootConfig) -> tuple[list[Span], list[Span]]:
|
|
74
|
+
"""The `(accepted, rejected)` span split `explain` is built on. Exposed
|
|
75
|
+
so the command can reuse the same spans for `--evidence` without a
|
|
76
|
+
second detect() pass."""
|
|
74
77
|
compiled = compile_config(root_config)
|
|
75
|
-
|
|
76
78
|
accepted, rejected = detect(
|
|
77
79
|
text,
|
|
78
80
|
min_confidence=DEFAULT_MIN_CONFIDENCE,
|
|
79
81
|
return_rejected=True,
|
|
80
82
|
**compiled.detect_kwargs(),
|
|
81
83
|
)
|
|
84
|
+
return list(accepted), list(rejected)
|
|
82
85
|
|
|
86
|
+
|
|
87
|
+
def build_explain_result(
|
|
88
|
+
text: str,
|
|
89
|
+
accepted: list[Span],
|
|
90
|
+
rejected: list[Span],
|
|
91
|
+
root_config: RootConfig,
|
|
92
|
+
*,
|
|
93
|
+
full: bool = False,
|
|
94
|
+
) -> ExplainResult:
|
|
95
|
+
compiled = compile_config(root_config)
|
|
83
96
|
masked = [_view(s, full=full, threshold=None) for s in accepted]
|
|
84
97
|
near_misses = [
|
|
85
98
|
_view(
|
|
@@ -90,3 +103,8 @@ def run_explain(text: str, root_config: RootConfig, *, full: bool = False) -> Ex
|
|
|
90
103
|
for s in rejected
|
|
91
104
|
]
|
|
92
105
|
return ExplainResult(text_length=len(text), masked=masked, near_misses=near_misses)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def run_explain(text: str, root_config: RootConfig, *, full: bool = False) -> ExplainResult:
|
|
109
|
+
accepted, rejected = detect_for_explain(text, root_config)
|
|
110
|
+
return build_explain_result(text, accepted, rejected, root_config, full=full)
|
|
@@ -69,3 +69,44 @@ def test_explain_bad_config_exits_nonzero(fixtures_dir: Path) -> None:
|
|
|
69
69
|
result = runner.invoke(app, ["explain", "--config", str(fixtures_dir / "typo.toml"), "hello"])
|
|
70
70
|
assert result.exit_code == 1
|
|
71
71
|
assert "did you mean 'threshold'?" in result.stderr
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# --- --evidence -------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_explain_evidence_file_writes_metadata_only(tmp_path: Path) -> None:
|
|
78
|
+
import json
|
|
79
|
+
|
|
80
|
+
out = tmp_path / "evidence.log"
|
|
81
|
+
result = runner.invoke(
|
|
82
|
+
app,
|
|
83
|
+
["explain", "--evidence-file", str(out), "Email me at john.doe@example.com please"],
|
|
84
|
+
)
|
|
85
|
+
assert result.exit_code == 0
|
|
86
|
+
lines = out.read_text().strip().splitlines()
|
|
87
|
+
assert lines
|
|
88
|
+
record = json.loads(lines[0])
|
|
89
|
+
assert record["entity_type"] == "EMAIL"
|
|
90
|
+
assert record["action"] == "masked"
|
|
91
|
+
assert record["recognizer"] == "pattern:EMAIL"
|
|
92
|
+
# the value itself never appears
|
|
93
|
+
assert "john.doe@example.com" not in out.read_text()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_explain_no_evidence_by_default(tmp_path: Path) -> None:
|
|
97
|
+
# no --evidence, no [evidence] config -> nothing written anywhere
|
|
98
|
+
result = runner.invoke(app, ["explain", "Email me at john.doe@example.com please"])
|
|
99
|
+
assert result.exit_code == 0
|
|
100
|
+
assert not (tmp_path / "evidence.log").exists()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_explain_evidence_reports_near_misses_as_passed(tmp_path: Path) -> None:
|
|
104
|
+
import json
|
|
105
|
+
|
|
106
|
+
out = tmp_path / "e.log"
|
|
107
|
+
text = "Random unrelated text with a number 123456789 in it, nothing special."
|
|
108
|
+
result = runner.invoke(app, ["explain", "--evidence-file", str(out), text])
|
|
109
|
+
assert result.exit_code == 0
|
|
110
|
+
actions = {json.loads(line)["action"] for line in out.read_text().strip().splitlines()}
|
|
111
|
+
assert "passed" in actions
|
|
112
|
+
assert "123456789" not in out.read_text()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|