Skip to content

Commit a6017ab

Browse files
feat(rewrite): tolerate small-model drift on rewrite schemas + server-side disposition
Stacked on the detection-schema PR. Loosen the LLM-facing rewrite wire schemas and move disposition to a two-step (loose-wire → server-reconstruct) pipeline so small models no longer drop records: - shared: loose-list-wrapper helpers so DD's jsonschema pre-check accepts a bare top-level list as well as the canonical wrapper. - domain/meaning-unit/QA/disposition wire schemas typed as str with before-validators that coerce enum/scalar drift into range. - SimpleDispositionResult/Item: loose wire contract; reconstruct_full_disposition pairs it with trusted entity context to build the strict EntityDispositionSchema, with a pessimistic fallback that prevents whole-record drops. Schema-constraint cleanups (per review): - Drop min_length on server-reconstructed EntityDisposition.entity_label / entity_value / protection_reason (these are built from trusted context, not raw model output; the bounds were redundant tripwires). - Drop protection_reason max_length and instead cap a passthrough reason in the reconstructor (silent truncate to 500 chars) so a rambling reason can neither drop the record nor flow unbounded into the rewrite prompt/parquet. - Drop the now-redundant min/max_length on PrivacyAnswerItem.reason (the _truncate_reason before-validator is the sole, always-applied guard). - Keep the two list-level min_length=1 tripwires on the strict disposition containers: they never see raw model output and assert a real pipeline invariant (non-empty disposition when entities were detected). Description cleanups: wire-loose str fields (domain, aspect, importance, category, sensitivity, protection_method_suggestion) now enumerate their valid values inline in Field(description=...), derived from the backing enum, instead of referencing an unresolvable Python path — the enum is absent from the JSON schema the model sees, so the description is its only source of truth.
1 parent 81ecba0 commit a6017ab

12 files changed

Lines changed: 2002 additions & 69 deletions

src/anonymizer/engine/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@
9191
COL_DOMAIN_SUPPLEMENT = "_domain_supplement"
9292
COL_DOMAIN_SUPPLEMENT_PRIVACY = "_domain_supplement_privacy"
9393
COL_SENSITIVITY_DISPOSITION = "_sensitivity_disposition"
94+
COL_SIMPLE_DISPOSITION = "_simple_disposition" # internal hand-off: loose LLM wire output
9495
COL_SENSITIVITY_DISPOSITION_BLOCK = "_sensitivity_disposition_block"
9596
COL_REWRITE_DISPOSITION_BLOCK = "_rewrite_disposition_block"
9697
COL_REPLACEMENT_MAP_FOR_PROMPT = "_replacement_map_for_prompt"

src/anonymizer/engine/rewrite/disposition_derivation.py

Lines changed: 409 additions & 0 deletions
Large diffs are not rendered by default.

src/anonymizer/engine/rewrite/qa_generation.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,18 @@
3939
)
4040

4141
# Derived from the schema so the Jinja key stays in sync with the field name.
42+
# Prefer an annotation-typed lookup (strict-mode contract); fall back to a
43+
# name-based lookup so wire-loose typing of ``domain`` (str instead of Domain
44+
# enum) still resolves to the same field. The Domain enum hint is preserved
45+
# in the field description and the ``_normalize_domain`` before-validator.
4246
_DOMAIN_KEY = next(
4347
(name for name, info in DomainClassificationSchema.model_fields.items() if info.annotation is Domain),
4448
None,
4549
)
50+
if _DOMAIN_KEY is None and "domain" in DomainClassificationSchema.model_fields:
51+
_DOMAIN_KEY = "domain"
4652
if _DOMAIN_KEY is None:
47-
raise RuntimeError("DomainClassificationSchema must define a field annotated with Domain")
53+
raise RuntimeError("DomainClassificationSchema must define a 'domain' field")
4854

4955
# ---------------------------------------------------------------------------
5056
# Stage 1 pre-step: format disposition → disposition block

src/anonymizer/engine/rewrite/sensitivity_disposition.py

Lines changed: 191 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,13 @@
33

44
from __future__ import annotations
55

6-
from data_designer.config.column_configs import LLMStructuredColumnConfig
6+
import logging
7+
from typing import Any
8+
9+
from data_designer.config import custom_column_generator
10+
from data_designer.config.column_configs import CustomColumnConfig, LLMStructuredColumnConfig
711
from data_designer.config.column_types import ColumnConfigT
12+
from pydantic import ValidationError
813

914
from anonymizer.config.models import RewriteModelSelection
1015
from anonymizer.config.rewrite import PrivacyGoal
@@ -14,13 +19,27 @@
1419
COL_ENTITIES_BY_VALUE,
1520
COL_LATENT_ENTITIES,
1621
COL_SENSITIVITY_DISPOSITION,
22+
COL_SIMPLE_DISPOSITION,
1723
COL_TAG_NOTATION,
1824
COL_TAGGED_TEXT,
1925
_jinja,
2026
)
2127
from anonymizer.engine.ndd.model_loader import resolve_model_alias
2228
from anonymizer.engine.prompt_utils import substitute_placeholders
23-
from anonymizer.engine.schemas import SensitivityDispositionSchema, StrictSensitivityDispositionSchema
29+
from anonymizer.engine.rewrite.disposition_derivation import (
30+
_flatten_context,
31+
derive_combined_risk_level,
32+
reconstruct_full_disposition,
33+
template_protection_reason,
34+
)
35+
from anonymizer.engine.schemas import (
36+
EntityDispositionSchema,
37+
SensitivityDispositionSchema,
38+
SimpleDispositionResult,
39+
)
40+
from anonymizer.engine.schemas.rewrite import _ENTITY_LABEL_TO_CATEGORY
41+
42+
logger = logging.getLogger(__name__)
2443

2544

2645
def _get_sensitivity_disposition_prompt(
@@ -257,6 +276,144 @@ def _get_sensitivity_disposition_prompt(
257276
# ---------------------------------------------------------------------------
258277

259278

279+
# ---------------------------------------------------------------------------
280+
# Pessimistic fallback when reconstruction yields nothing
281+
# ---------------------------------------------------------------------------
282+
283+
284+
def _pessimistic_fallback_disposition(
285+
entities_by_value: object,
286+
latent_entities: object,
287+
) -> SensitivityDispositionSchema:
288+
"""Build a worst-case disposition from the entity context alone.
289+
290+
Used when ``reconstruct_full_disposition`` returns an empty list — e.g.
291+
every ``SimpleDispositionItem`` was an orphan, or the LLM emitted no
292+
items at all. Without this fallback, downstream
293+
``parse_sensitivity_disposition`` raises ``ValidationError`` on
294+
``min_length=1`` and the row drops, defeating the whole loose-wire +
295+
server-reconstruction architecture this PR exists to add.
296+
297+
Policy (per Lipika/Andre's review on PR #130, addressing the
298+
record-drop concern):
299+
* ``direct_identifier`` -> ``replace`` (high risk, must be masked).
300+
* everything else -> ``generalize`` (medium risk, mask but keep
301+
rough semantics for utility).
302+
303+
Categories come from the per-entity ``entity_label`` via the
304+
``_ENTITY_LABEL_TO_CATEGORY`` map (the same source of truth the
305+
reconstructor uses for entity-label-stuffed-into-category drift);
306+
unmapped labels fall back to ``quasi_identifier``.
307+
"""
308+
flat = _flatten_context(entities_by_value, latent_entities)
309+
items: list[EntityDispositionSchema] = []
310+
for idx, slot in enumerate(flat, start=1):
311+
label = (slot.get("entity_label") or "").strip()
312+
value = (slot.get("entity_value") or "").strip()
313+
source = slot.get("source") or "tagged"
314+
if not label or not value:
315+
continue
316+
if source == "latent":
317+
category = "latent_identifier"
318+
else:
319+
category = _ENTITY_LABEL_TO_CATEGORY.get(label, "quasi_identifier")
320+
method = "replace" if category == "direct_identifier" else "generalize"
321+
sensitivity = "high" if category == "direct_identifier" else "medium"
322+
combined_risk = derive_combined_risk_level(category, method, sensitivity)
323+
reason = template_protection_reason(category, method, sensitivity)
324+
items.append(
325+
EntityDispositionSchema(
326+
id=idx,
327+
source=source,
328+
category=category,
329+
sensitivity=sensitivity,
330+
entity_label=label,
331+
entity_value=value,
332+
protection_method_suggestion=method,
333+
combined_risk_level=combined_risk,
334+
protection_reason=reason,
335+
)
336+
)
337+
if not items:
338+
# Genuinely no entities at all in context — the orchestrator should
339+
# have short-circuited before this step. Still better to raise here
340+
# than to silently emit garbage; SensitivityDispositionSchema's
341+
# min_length=1 invariant will surface the bug.
342+
return SensitivityDispositionSchema(sensitivity_disposition=items)
343+
return SensitivityDispositionSchema(sensitivity_disposition=items)
344+
345+
346+
# ---------------------------------------------------------------------------
347+
# Reconstruction column
348+
# ---------------------------------------------------------------------------
349+
350+
351+
@custom_column_generator(required_columns=[COL_SIMPLE_DISPOSITION, COL_ENTITIES_BY_VALUE, COL_LATENT_ENTITIES])
352+
def _reconstruct_full_disposition_column(row: dict[str, Any]) -> dict[str, Any]:
353+
"""Rebuild the strict EntityDispositionSchema list from the loose LLM
354+
output in ``COL_SIMPLE_DISPOSITION`` plus the entity context columns.
355+
356+
Writes ``COL_SENSITIVITY_DISPOSITION`` so every downstream consumer
357+
reads the same column name / shape as before this refactor.
358+
359+
Empty-result fallback: when the model returns nothing usable (every
360+
item is an orphan, or the LLM omitted the field entirely), build a
361+
pessimistic disposition from the entity context (direct identifiers
362+
-> replace, everything else -> generalize). This addresses the
363+
record-drop concern Lipika and Andre raised on PR #130 — emitting an
364+
empty disposition would have failed downstream
365+
``parse_sensitivity_disposition``'s ``min_length=1`` check anyway.
366+
"""
367+
simple_raw = row.get(COL_SIMPLE_DISPOSITION, {}) or {}
368+
if isinstance(simple_raw, SimpleDispositionResult):
369+
simple = simple_raw
370+
else:
371+
if isinstance(simple_raw, str):
372+
import json as _json
373+
374+
try:
375+
simple_raw = _json.loads(simple_raw)
376+
except Exception:
377+
simple_raw = {}
378+
try:
379+
simple = SimpleDispositionResult.model_validate(simple_raw)
380+
except ValidationError as exc:
381+
logger.warning(
382+
"reconstruct: SimpleDispositionResult failed to validate (%s); "
383+
"falling back to pessimistic disposition from entity context",
384+
str(exc)[:200],
385+
)
386+
simple = SimpleDispositionResult()
387+
388+
entities_by_value = row.get(COL_ENTITIES_BY_VALUE)
389+
latent_entities = row.get(COL_LATENT_ENTITIES)
390+
391+
if not simple.sensitivity_disposition:
392+
logger.warning(
393+
"reconstruct: empty SimpleDispositionResult for row; "
394+
"falling back to pessimistic disposition from entity context"
395+
)
396+
full = _pessimistic_fallback_disposition(entities_by_value, latent_entities)
397+
else:
398+
try:
399+
full = reconstruct_full_disposition(simple, entities_by_value, latent_entities)
400+
except ValidationError as exc:
401+
logger.warning(
402+
"reconstruct: ValidationError after orphan-skipping (likely all items out of context range); "
403+
"falling back to pessimistic disposition. detail=%s",
404+
str(exc)[:200],
405+
)
406+
full = _pessimistic_fallback_disposition(entities_by_value, latent_entities)
407+
408+
row[COL_SENSITIVITY_DISPOSITION] = full.model_dump()
409+
return row
410+
411+
412+
# ---------------------------------------------------------------------------
413+
# Workflow
414+
# ---------------------------------------------------------------------------
415+
416+
260417
class SensitivityDispositionWorkflow:
261418
def columns(
262419
self,
@@ -266,17 +423,46 @@ def columns(
266423
data_summary: str | None = None,
267424
strict_entity_protection: bool = False,
268425
) -> list[ColumnConfigT]:
426+
"""Two-step pipeline for small-model robustness:
427+
428+
1. LLM column emits the loose ``SimpleDispositionResult`` to a
429+
hidden ``COL_SIMPLE_DISPOSITION`` column. The wire schema has
430+
no enum/required/minLength constraints, so DataDesigner's
431+
jsonschema pre-validate gate accepts drifted small-model
432+
output that strict ``SensitivityDispositionSchema`` would
433+
reject. ``drop=True`` keeps this internal hand-off out of the
434+
user-facing preview DataFrame.
435+
2. Pure-python reconstruction column rebuilds the strict
436+
``SensitivityDispositionSchema`` from the loose wire output
437+
plus the entity-context columns. No LLM call; deterministic;
438+
handles id pairing, category/method drift normalization,
439+
``combined_risk_level`` derivation, and pessimistic fallback
440+
when the LLM produces nothing usable.
441+
442+
``strict_entity_protection`` continues to flow into the prompt's
443+
``<strict_entity_protection>`` block — the contract is enforced
444+
at prompt time. The output_format selection between
445+
``SensitivityDispositionSchema`` and
446+
``StrictSensitivityDispositionSchema`` is no longer needed
447+
because we always emit ``SimpleDispositionResult`` on the wire
448+
and reconstruct into the canonical (non-strict) schema, which
449+
downstream consumers already accept.
450+
"""
269451
disposition_alias = resolve_model_alias("disposition_analyzer", selected_models)
270-
output_schema = StrictSensitivityDispositionSchema if strict_entity_protection else SensitivityDispositionSchema
271452
return [
272453
LLMStructuredColumnConfig(
273-
name=COL_SENSITIVITY_DISPOSITION,
454+
name=COL_SIMPLE_DISPOSITION,
274455
prompt=_get_sensitivity_disposition_prompt(
275456
privacy_goal,
276457
data_summary,
277458
strict_entity_protection=strict_entity_protection,
278459
),
279460
model_alias=disposition_alias,
280-
output_format=output_schema,
461+
output_format=SimpleDispositionResult,
462+
drop=True,
463+
),
464+
CustomColumnConfig(
465+
name=COL_SENSITIVITY_DISPOSITION,
466+
generator_function=_reconstruct_full_disposition_column,
281467
),
282468
]

src/anonymizer/engine/schemas/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@
5353
RewriteOutputSchema,
5454
SensitivityDispositionSchema,
5555
SensitivityLevel,
56+
SimpleDispositionItem,
57+
SimpleDispositionResult,
5658
StrictCombinedRiskLevel,
5759
StrictEntityDispositionSchema,
5860
StrictProtectionMethod,
@@ -109,6 +111,8 @@
109111
"RewriteOutputSchema",
110112
"SensitivityDispositionSchema",
111113
"SensitivityLevel",
114+
"SimpleDispositionItem",
115+
"SimpleDispositionResult",
112116
"StrictCombinedRiskLevel",
113117
"StrictEntityDispositionSchema",
114118
"StrictProtectionMethod",

0 commit comments

Comments
 (0)