File size: 8,075 Bytes
762cd13
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
"""Pure Scrub Key model helpers for SolidPrivacy Scrub.

A Scrub Key is a local mapping between original values and scrubbed
placeholders. The helpers in this module deliberately avoid UI integration,
cloud processing, file-system persistence and time-based side effects.
"""

from __future__ import annotations

import json
from collections.abc import Iterable, Mapping
from typing import Any

SCRUB_KEY_SCHEMA = "solidprivacy.scrub_key"
SCRUB_KEY_SCHEMA_VERSION = "1.0"

REQUIRED_ITEM_FIELDS = (
    "original_value",
    "placeholder",
    "entity_type",
    "type_label",
    "source",
    "review_status",
    "include_state",
    "timestamp",
)

TRUE_VALUES = {"1", "true", "yes", "ja", "y", "on", "checked", "aangevinkt", "included", "meenemen"}
FALSE_VALUES = {"0", "false", "no", "nee", "n", "off", "unchecked", "uit", "excluded", "niet meenemen"}

FIELD_ALIASES: dict[str, tuple[str, ...]] = {
    "original_value": (
        "original_value",
        "original",
        "found_text",
        "text",
        "Gevonden tekst",
        "gevonden_tekst",
    ),
    "placeholder": (
        "placeholder",
        "replacement",
        "replace_with",
        "scrubbed_value",
        "Vervangen door",
        "vervangen_door",
    ),
    "entity_type": (
        "entity_type",
        "entity",
        "type",
        "Entity Type",
        "Entiteitstype",
    ),
    "type_label": (
        "type_label",
        "user_facing_type_label",
        "display_label",
        "Type gegeven",
        "type_gegeven",
    ),
    "source": (
        "source",
        "Bron",
        "bron",
    ),
    "review_status": (
        "review_status",
        "review_status_label",
        "status",
        "Status",
    ),
    "include": (
        "include",
        "included",
        "include_state",
        "Meenemen",
        "meenemen",
    ),
    "timestamp": (
        "timestamp",
        "created_at",
        "reviewed_at",
        "updated_at",
        "Tijdstip",
        "tijdstip",
    ),
    "document_label": (
        "document_label",
        "project_label",
        "dossier_label",
        "Document",
        "Project",
        "Dossier",
    ),
}


def _normalise_rows(rows: Any) -> list[dict[str, Any]]:
    """Return review rows as dictionaries without importing pandas."""
    if rows is None:
        return []
    if hasattr(rows, "to_dict"):
        try:
            records = rows.to_dict(orient="records")
            return [dict(row) for row in records]
        except TypeError:
            pass
    if isinstance(rows, Mapping):
        return [dict(rows)]
    if isinstance(rows, Iterable) and not isinstance(rows, (str, bytes)):
        return [dict(row) for row in rows if isinstance(row, Mapping)]
    return []


def _text(value: Any) -> str:
    return str(value or "").strip()


def _lower(value: Any) -> str:
    return _text(value).lower()


def _first_text(row: Mapping[str, Any], field_name: str) -> str:
    for alias in FIELD_ALIASES[field_name]:
        if alias in row:
            return _text(row.get(alias))
    return ""


def _include_state(row: Mapping[str, Any]) -> str:
    raw_value = None
    for alias in FIELD_ALIASES["include"]:
        if alias in row:
            raw_value = row.get(alias)
            break

    if isinstance(raw_value, bool):
        return "included" if raw_value else "excluded"
    if raw_value is None:
        return "excluded"
    if isinstance(raw_value, (int, float)):
        return "included" if raw_value != 0 else "excluded"

    normalised = _lower(raw_value)
    if normalised in TRUE_VALUES:
        return "included"
    if normalised in FALSE_VALUES:
        return "excluded"
    return "excluded"


def build_scrub_key(rows: Any, document_label: str | None = None) -> dict[str, Any]:
    """Build a deterministic Scrub Key from reviewed replacement rows.

    Excluded rows are omitted by design. This keeps the first pure model aligned
    with current export semantics: only rows selected by the reviewer become part
    of the reversible mapping.
    """
    records = _normalise_rows(rows)
    fallback_document_label = _text(document_label) or None
    items: list[dict[str, Any]] = []

    for row in records:
        include_state = _include_state(row)
        if include_state != "included":
            continue

        item_document_label = _first_text(row, "document_label") or fallback_document_label
        item = {
            "original_value": _first_text(row, "original_value"),
            "placeholder": _first_text(row, "placeholder"),
            "entity_type": _first_text(row, "entity_type"),
            "type_label": _first_text(row, "type_label"),
            "source": _first_text(row, "source"),
            "review_status": _first_text(row, "review_status"),
            "include_state": include_state,
            "timestamp": _first_text(row, "timestamp"),
            "document_label": item_document_label,
        }
        items.append(item)

    return {
        "schema": SCRUB_KEY_SCHEMA,
        "schema_version": SCRUB_KEY_SCHEMA_VERSION,
        "workflow": "Scrub → Review → Scrub Key → AI → Reinsert → Export → Audit",
        "privacy_model": "pseudonymization_not_full_anonymization",
        "reversible": True,
        "storage_policy": "local_only_protect_key",
        "external_ai_policy": "do_not_share_key_unless_explicitly_intended_and_allowed",
        "excluded_rows_policy": "omitted",
        "document_label": fallback_document_label,
        "item_count": len(items),
        "items": items,
    }


def scrub_key_to_json(scrub_key: Mapping[str, Any]) -> str:
    """Serialize a Scrub Key to stable, human-readable JSON."""
    return json.dumps(dict(scrub_key), ensure_ascii=False, indent=2, sort_keys=True)


def scrub_key_from_json(text: str) -> dict[str, Any]:
    """Load a Scrub Key JSON string into a dictionary."""
    loaded = json.loads(text)
    if not isinstance(loaded, dict):
        raise ValueError("Scrub Key JSON must contain an object at the top level.")
    return loaded


def validate_scrub_key(scrub_key: Any) -> list[str]:
    """Return validation messages for a Scrub Key.

    An empty list means the key is structurally valid. Validation is deliberately
    conservative and does not attempt to verify whether values are real PII.
    """
    issues: list[str] = []

    if not isinstance(scrub_key, Mapping):
        return ["Scrub Key must be a dictionary."]

    if scrub_key.get("schema") != SCRUB_KEY_SCHEMA:
        issues.append(f"Missing or invalid schema: expected {SCRUB_KEY_SCHEMA}.")
    if not scrub_key.get("schema_version"):
        issues.append("Missing schema_version.")
    if scrub_key.get("reversible") is not True:
        issues.append("Scrub Key must explicitly mark reversible=true.")
    if scrub_key.get("privacy_model") != "pseudonymization_not_full_anonymization":
        issues.append("Scrub Key must explicitly mark pseudonymization, not full anonymization.")
    if scrub_key.get("excluded_rows_policy") != "omitted":
        issues.append("Scrub Key must state excluded_rows_policy=omitted for this model version.")

    items = scrub_key.get("items")
    if not isinstance(items, list):
        issues.append("Missing or invalid items list.")
        return issues

    if scrub_key.get("item_count") != len(items):
        issues.append("item_count does not match the number of items.")

    for index, item in enumerate(items):
        if not isinstance(item, Mapping):
            issues.append(f"Item {index} must be a dictionary.")
            continue
        for field_name in REQUIRED_ITEM_FIELDS:
            if field_name not in item:
                issues.append(f"Item {index} missing required field: {field_name}.")
            elif _text(item.get(field_name)) == "":
                issues.append(f"Item {index} has empty required field: {field_name}.")
        if item.get("include_state") not in {"included", "excluded"}:
            issues.append(f"Item {index} has invalid include_state.")

    return issues