-
Notifications
You must be signed in to change notification settings - Fork 15
Expand file tree
/
Copy pathanonymizer_config.py
More file actions
220 lines (184 loc) · 8.67 KB
/
Copy pathanonymizer_config.py
File metadata and controls
220 lines (184 loc) · 8.67 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
import logging
from pathlib import Path
from urllib.parse import urlparse
from pydantic import BaseModel, Field, field_validator, model_validator
from anonymizer.config.replace_strategies import ReplaceMethod
from anonymizer.config.rewrite import (
DEFAULT_PRESERVE_TEXT,
DEFAULT_PROTECT_TEXT,
EvaluationCriteria,
PrivacyGoal,
RiskTolerance,
)
logger = logging.getLogger(__name__)
def is_remote_input_source(value: str) -> bool:
"""Return True when the input source is an HTTP(S) URL."""
parsed = urlparse(value)
return parsed.scheme in {"http", "https"}
def has_unsupported_url_scheme(value: str) -> bool:
"""Return True when the input looks like a URL but uses an unsupported scheme."""
parsed = urlparse(value)
return "://" in value and bool(parsed.scheme) and parsed.scheme not in {"http", "https"}
def infer_input_source_suffix(value: str) -> str:
"""Infer the lowercase file suffix from a local path or remote URL path."""
if is_remote_input_source(value):
return Path(urlparse(value).path).suffix.lower()
return Path(value).suffix.lower()
class AnonymizerInput(BaseModel):
"""Input source definition for the anonymizer pipeline.
Format is inferred from the file extension of a local path or HTTP(S) URL.
"""
source: str = Field(description="Local path or HTTP(S) URL for a .csv or .parquet input file.")
text_column: str = Field(default="text", min_length=1, description="Column containing the text to anonymize.")
id_column: str | None = Field(default=None, description="Optional column to use as record identifier.")
data_summary: str | None = Field(
default=None, description="Short description of the data. Improves LLM detection accuracy."
)
@field_validator("source")
@classmethod
def validate_source_path(cls, value: str) -> str:
if is_remote_input_source(value):
return value
if has_unsupported_url_scheme(value):
scheme = urlparse(value).scheme
raise ValueError(f"Unsupported input URL scheme: {scheme!r}. Use http:// or https:// URLs.")
source = Path(value)
if not source.exists():
raise ValueError(f"Input path does not exist: {source}")
if not source.is_file():
raise ValueError(f"Input path is not a file: {source}")
return value
class Detect(BaseModel):
"""Configuration for the entity detection stage."""
entity_labels: list[str] | None = Field(
default=None,
description=(
"Labels to detect. None uses the built-in default detection label set. "
"To inspect the default set, use `from anonymizer import DEFAULT_ENTITY_LABELS`."
),
)
gliner_threshold: float = Field(
default=0.3, ge=0.0, le=1.0, description="GLiNER detection confidence threshold (0.0-1.0)."
)
validation_max_entities_per_call: int = Field(
default=100,
gt=0,
description=(
"Maximum number of candidate entities included in a single validator LLM call. "
"When a row has more candidates than this, validation is split into chunks that "
"are dispatched (round-robin) across the validator pool."
),
)
validation_excerpt_window_chars: int = Field(
default=500,
gt=0,
description=(
"Number of characters to include before and after a chunk's entity span when "
"building the text excerpt sent to the validator. Bounds the prompt context the "
"validator sees per chunk; it is NOT the LLM's context window limit."
),
)
@field_validator("entity_labels")
@classmethod
def validate_entity_labels(cls, value: list[str] | None) -> list[str] | None:
if value is None:
return value
cleaned = [label.strip().lower() for label in value if label.strip()]
if not cleaned:
raise ValueError("entity_labels must not be empty. Use None to detect all default labels.")
deduped = sorted(set(cleaned))
if len(deduped) != len(cleaned):
logger.warning("entity_labels contained duplicates, removed automatically.")
return deduped
class Rewrite(BaseModel):
"""Configuration for rewrite-mode execution."""
privacy_goal: PrivacyGoal | None = Field(
default=None, description="Structured privacy goal. Auto-populated with defaults if not provided."
)
instructions: str | None = Field(default=None, description="Additional instructions for the rewrite LLM.")
risk_tolerance: RiskTolerance = Field(
default=RiskTolerance.low,
description="Preset controlling repair thresholds and review flagging.",
)
max_repair_iterations: int = Field(
default=3,
ge=0,
description="Maximum repair rounds. Set to 0 to disable repair.",
)
use_combined_graph: bool = Field(
default=False,
description="Run rewrite and conditional repair iterations in one Data Designer graph.",
)
strict_entity_protection: bool = Field(
default=False,
description="If True, requires every entity to receive a protective disposition during sensitivity analysis.",
)
@model_validator(mode="after")
def populate_default_privacy_goal(self) -> Rewrite:
if self.privacy_goal is None:
self.privacy_goal = PrivacyGoal(
protect=DEFAULT_PROTECT_TEXT,
preserve=DEFAULT_PRESERVE_TEXT,
)
return self
@property
def evaluation(self) -> EvaluationCriteria:
"""Construct `EvaluationCriteria` from this `Rewrite` config for the engine.
`Rewrite` and `EvaluationCriteria` both carry `max_repair_iterations`.
This property keeps them in sync: it passes through `self.risk_tolerance`
and `self.max_repair_iterations`. Leakage thresholds and repair
parameters are derived from `risk_tolerance` via `_RiskToleranceBundle`
(see `rewrite.py`).
Production code that starts from a user-facing `Rewrite` should pass
`rewrite.evaluation` into the engine — never duplicate the mapping
manually. Tests and engine-internal callers may construct
`EvaluationCriteria` directly when they aren't routing through a
user-facing `Rewrite`.
"""
return EvaluationCriteria(
risk_tolerance=self.risk_tolerance,
max_repair_iterations=self.max_repair_iterations,
)
class AnonymizerConfig(BaseModel):
"""Primary user-facing config for anonymization behavior."""
detect: Detect = Field(default_factory=Detect, description="Entity detection configuration.")
replace: ReplaceMethod | None = Field(
default=None,
description="Replacement method (Substitute(), Redact(), Annotate(), or Hash()).",
)
rewrite: Rewrite | None = Field(default=None, description="Optional rewrite-mode parameters. ")
emit_telemetry: bool = Field(
default=True,
description=(
"Whether to emit anonymous Anonymizer telemetry events. See the Telemetry section "
"in the README for what is collected and how to opt out at the environment or CLI level."
),
)
@model_validator(mode="after")
def validate_exactly_one_mode(self) -> AnonymizerConfig:
if self.replace is None and self.rewrite is None:
raise ValueError(
"Exactly one of replace or rewrite must be provided."
" Use replace=Redact() for entity replacement, or rewrite=Rewrite() for LLM rewriting."
)
if self.replace is not None and self.rewrite is not None:
raise ValueError(
"Cannot use both replace and rewrite — choose one mode."
" Use replace=Redact() for entity replacement, or rewrite=Rewrite() for LLM rewriting."
)
return self
class EvaluateConfig(BaseModel):
"""Optional knobs for :meth:`Anonymizer.evaluate`.
Reserved for genuinely evaluation-specific configuration — metric selection,
per-judge model/prompt overrides, scoring thresholds, etc. The anonymization
mode is **not** here: it travels on the ``AnonymizerResult`` /
``PreviewResult`` produced by ``run()`` / ``preview()`` and is read directly
by ``evaluate()``, so users don't restate it and can't mis-state it.
Today this is an empty placeholder; fields will be added as evaluation
knobs are introduced.
"""
# Intentionally empty for now. New fields land here as evaluation
# configurability is introduced.