scrub / scrub_key_reinsert.py
solidprivacy-nl
Add deterministic Scrub Key reinsert helper
2f23001
Raw
History Blame
6.07 kB
"""Pure deterministic Scrub Key reinsert helpers for SolidPrivacy Scrub.
These helpers prepare the v13.3 local reinsert workflow. They deliberately avoid
Streamlit UI integration, AI calls, cloud processing, document export changes,
file-system persistence and any other side effects.
"""
from __future__ import annotations
import re
from collections import Counter
from collections.abc import Mapping
from typing import Any
from scrub_key import validate_scrub_key
PLACEHOLDER_PATTERN = re.compile(r"\[[A-Z][A-Z0-9_:-]*_[0-9]+\]")
def _text(value: Any) -> str:
return str(value or "")
def _normalised_text(value: Any) -> str:
return str(value or "").strip()
def _items(scrub_key: Any) -> list[Mapping[str, Any]]:
if not isinstance(scrub_key, Mapping):
return []
raw_items = scrub_key.get("items")
if not isinstance(raw_items, list):
return []
return [item for item in raw_items if isinstance(item, Mapping)]
def _unique_sorted(values: list[str] | set[str]) -> list[str]:
return sorted({value for value in values if value})
def detect_placeholders(text: Any) -> list[str]:
"""Return unique placeholder-looking tokens found in text.
This is intentionally conservative and aimed at Scrub placeholders such as
``[PERSOON_1]`` or ``[ZAAKNUMMER_1]``. It does not attempt to parse arbitrary
bracketed text.
"""
return _unique_sorted(PLACEHOLDER_PATTERN.findall(_text(text)))
def build_reinsert_mapping(scrub_key: Any) -> dict[str, Any]:
"""Build a deterministic placeholder-to-original mapping from a Scrub Key.
Invalid Scrub Keys return validation issues and an empty mapping. Items with
``include_state`` other than ``included`` are ignored. Duplicate placeholders
are reported and excluded from the mapping to avoid ambiguous reinsertion.
"""
validation_issues = validate_scrub_key(scrub_key)
items = _items(scrub_key)
included_items = [item for item in items if item.get("include_state") == "included"]
excluded_items = [item for item in items if item.get("include_state") != "included"]
if validation_issues:
return {
"mapping": {},
"item_count": len(items),
"active_item_count": 0,
"excluded_item_count": len(excluded_items),
"duplicate_placeholders": [],
"validation_issues": validation_issues,
}
placeholders = [_normalised_text(item.get("placeholder")) for item in included_items]
duplicate_placeholders = _unique_sorted(
[placeholder for placeholder, count in Counter(placeholders).items() if count > 1]
)
duplicate_set = set(duplicate_placeholders)
mapping: dict[str, str] = {}
for item in included_items:
placeholder = _normalised_text(item.get("placeholder"))
original_value = _text(item.get("original_value"))
if not placeholder or placeholder in duplicate_set:
continue
mapping[placeholder] = original_value
return {
"mapping": mapping,
"item_count": len(items),
"active_item_count": len(included_items),
"excluded_item_count": len(excluded_items),
"duplicate_placeholders": duplicate_placeholders,
"validation_issues": [],
}
def reinsert_from_scrub_key(text: Any, scrub_key: Any) -> dict[str, Any]:
"""Reinsert original values into scrubbed text using a valid Scrub Key.
The function is deterministic and side-effect free. It returns both the
reinserted text and an audit summary. Invalid keys do not modify the text;
validation issues are returned clearly.
"""
original_text = _text(text)
mapping_result = build_reinsert_mapping(scrub_key)
mapping: dict[str, str] = dict(mapping_result.get("mapping", {}))
validation_issues = list(mapping_result.get("validation_issues", []))
duplicate_placeholders = list(mapping_result.get("duplicate_placeholders", []))
placeholders_in_text = detect_placeholders(original_text)
known_placeholders = set(mapping.keys())
if validation_issues:
return {
"text": original_text,
"replacement_count": 0,
"item_count": mapping_result.get("item_count", 0),
"active_item_count": mapping_result.get("active_item_count", 0),
"excluded_item_count": mapping_result.get("excluded_item_count", 0),
"placeholders_not_found": [],
"unknown_placeholders": placeholders_in_text,
"duplicate_placeholders": duplicate_placeholders,
"validation_issues": validation_issues,
"reinserted": False,
"local_only": True,
"ai_processing": False,
"cloud_processing": False,
}
reinserted_text = original_text
replacement_count = 0
placeholders_not_found: list[str] = []
for placeholder, original_value in mapping.items():
occurrences = reinserted_text.count(placeholder)
if occurrences == 0:
placeholders_not_found.append(placeholder)
continue
reinserted_text = reinserted_text.replace(placeholder, original_value)
replacement_count += occurrences
ambiguous_placeholders = set(duplicate_placeholders)
unknown_placeholders = _unique_sorted(
set(placeholders_in_text) - known_placeholders - ambiguous_placeholders
)
return {
"text": reinserted_text,
"replacement_count": replacement_count,
"item_count": mapping_result.get("item_count", 0),
"active_item_count": mapping_result.get("active_item_count", 0),
"excluded_item_count": mapping_result.get("excluded_item_count", 0),
"placeholders_not_found": _unique_sorted(placeholders_not_found),
"unknown_placeholders": unknown_placeholders,
"duplicate_placeholders": duplicate_placeholders,
"validation_issues": [],
"reinserted": replacement_count > 0,
"local_only": True,
"ai_processing": False,
"cloud_processing": False,
}