33
44from __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
711from data_designer .config .column_types import ColumnConfigT
12+ from pydantic import ValidationError
813
914from anonymizer .config .models import RewriteModelSelection
1015from anonymizer .config .rewrite import PrivacyGoal
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)
2127from anonymizer .engine .ndd .model_loader import resolve_model_alias
2228from 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
2645def _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+
260417class 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 ]
0 commit comments