AItoolstack commited on
Commit
62d8a6c
·
verified ·
1 Parent(s): 68bb068

Match drivers correctly across documents and keep highlights on the value

Browse files

Mirrors GitHub commit ef18c8f: pack-level masking tokens, exact matching for masked driver names, provenance ranking by value coverage / heading / driver row, spaCy lg installed at build time.

Dockerfile CHANGED
@@ -22,7 +22,7 @@ RUN apt-get update \
22
 
23
  COPY requirements.txt ./
24
  RUN pip install --no-cache-dir -r requirements.txt \
25
- && python -m spacy download en_core_web_sm
26
 
27
  COPY config/ ./config/
28
  COPY src/ ./src/
 
22
 
23
  COPY requirements.txt ./
24
  RUN pip install --no-cache-dir -r requirements.txt \
25
+ && python -m spacy download en_core_web_lg
26
 
27
  COPY config/ ./config/
28
  COPY src/ ./src/
docs/hugging-face.md CHANGED
@@ -8,7 +8,7 @@ The root `Dockerfile` does this:
8
 
9
  1. Builds the React UI with Vite.
10
  2. Installs the Python backend dependencies.
11
- 3. Downloads the small spaCy English model used by Presidio.
12
  4. Copies `ui/dist` into the image.
13
  5. Starts FastAPI on port `7860`.
14
  6. Lets FastAPI serve both `/api/*` and the React app.
 
8
 
9
  1. Builds the React UI with Vite.
10
  2. Installs the Python backend dependencies.
11
+ 3. Downloads the spaCy English model (en_core_web_lg) used by Presidio.
12
  4. Copies `ui/dist` into the image.
13
  5. Starts FastAPI on port `7860`.
14
  6. Lets FastAPI serve both `/api/*` and the React app.
requirements.txt CHANGED
@@ -8,7 +8,7 @@ pydantic>=2.7.0
8
  presidio-analyzer>=2.2.354
9
  presidio-anonymizer>=2.2.354
10
  # spaCy model (download separately after install):
11
- # python -m spacy download en_core_web_lg
12
  spacy>=3.7.0
13
 
14
  # ── Utilities ──────────────────────────────────────────────────────────────
 
8
  presidio-analyzer>=2.2.354
9
  presidio-anonymizer>=2.2.354
10
  # spaCy model (download separately after install):
11
+ # python -m spacy download en_core_web_lg (en_core_web_sm also works, with weaker name detection)
12
  spacy>=3.7.0
13
 
14
  # ── Utilities ──────────────────────────────────────────────────────────────
src/arbiter.py CHANGED
@@ -166,6 +166,11 @@ def _check_conflict(
166
  return _first(sched_val, cert_val) # schedule wins (default)
167
 
168
 
 
 
 
 
 
169
  def _find_matching_driver(name: str, candidates: list[Driver]) -> Driver | None:
170
  """
171
  Find the best-matching driver from *candidates* using fuzzy name matching.
@@ -174,6 +179,12 @@ def _find_matching_driver(name: str, candidates: list[Driver]) -> Driver | None:
174
  differences (e.g. "JOHN A SMITH" vs "SMITH JOHN") still match.
175
  Returns None when the best score is below ``_DRIVER_NAME_MATCH_THRESHOLD``.
176
  """
 
 
 
 
 
 
177
  try:
178
  from rapidfuzz import fuzz as rfuzz
179
  except ImportError:
@@ -184,6 +195,8 @@ def _find_matching_driver(name: str, candidates: list[Driver]) -> Driver | None:
184
  best_score = 0
185
  best_driver: Driver | None = None
186
  for candidate in candidates:
 
 
187
  score = rfuzz.token_sort_ratio(name.strip(), candidate.name.strip())
188
  if score > best_score:
189
  best_score = score
 
166
  return _first(sched_val, cert_val) # schedule wins (default)
167
 
168
 
169
+ def _is_masked(name: str) -> bool:
170
+ """True for a placeholder token produced by PIIMasker."""
171
+ return name.strip().startswith("MASKED_")
172
+
173
+
174
  def _find_matching_driver(name: str, candidates: list[Driver]) -> Driver | None:
175
  """
176
  Find the best-matching driver from *candidates* using fuzzy name matching.
 
179
  differences (e.g. "JOHN A SMITH" vs "SMITH JOHN") still match.
180
  Returns None when the best score is below ``_DRIVER_NAME_MATCH_THRESHOLD``.
181
  """
182
+ # Masked names (MASKED_PERSON_1, MASKED_PERSON_2, ...) differ by one
183
+ # character, so any two score ~93 on token_sort_ratio and would "match".
184
+ # Tokens are consistent across a pack, so they must match exactly.
185
+ if _is_masked(name):
186
+ return next((d for d in candidates if d.name.strip() == name.strip()), None)
187
+
188
  try:
189
  from rapidfuzz import fuzz as rfuzz
190
  except ImportError:
 
195
  best_score = 0
196
  best_driver: Driver | None = None
197
  for candidate in candidates:
198
+ if _is_masked(candidate.name):
199
+ continue
200
  score = rfuzz.token_sort_ratio(name.strip(), candidate.name.strip())
201
  if score > best_score:
202
  best_score = score
src/privacy.py CHANGED
@@ -21,7 +21,6 @@ from typing import Optional
21
  from presidio_analyzer import AnalyzerEngine, RecognizerResult
22
  from presidio_analyzer.nlp_engine import NlpEngineProvider
23
  from presidio_anonymizer import AnonymizerEngine
24
- from presidio_anonymizer.entities import OperatorConfig
25
 
26
  from settings import settings
27
 
@@ -79,23 +78,33 @@ class PIIMasker:
79
  self._language = language
80
  self._score_threshold = score_threshold
81
 
82
- # Build NLP engine (spaCy en_core_web_lg preferred; falls back to sm)
 
 
 
83
  nlp_config = {
84
  "nlp_engine_name": "spacy",
85
- "models": [{"lang_code": "en", "model_name": "en_core_web_lg"}],
86
  }
87
- try:
88
- provider = NlpEngineProvider(nlp_configuration=nlp_config)
89
- nlp_engine = provider.create_engine()
90
- except OSError:
91
- # Fall back to the small model if lg is not installed
92
- nlp_config["models"][0]["model_name"] = "en_core_web_sm"
93
- provider = NlpEngineProvider(nlp_configuration=nlp_config)
94
- nlp_engine = provider.create_engine()
95
 
96
  self._analyzer = AnalyzerEngine(nlp_engine=nlp_engine, supported_languages=[language])
97
  self._anonymizer = AnonymizerEngine()
98
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  # ------------------------------------------------------------------
100
  # Public API
101
  # ------------------------------------------------------------------
@@ -125,31 +134,27 @@ class PIIMasker:
125
  if not results:
126
  return text, {}
127
 
128
- # Build per-entity-type counters for unique token names
129
- counters: dict[str, int] = {}
130
  token_map: dict[str, str] = {}
131
- operators: dict[str, OperatorConfig] = {}
132
 
133
  # Sort by position so token numbering is left-to-right and deterministic
134
  results_sorted = sorted(results, key=lambda r: r.start)
135
 
136
- # We need custom lambda operators to generate named tokens.
137
- # Presidio's "replace" operator uses a fixed `new_value`; we work
138
- # around this by building a value map keyed on (entity_type, original).
139
- original_to_token: dict[tuple[str, str], str] = {}
140
-
141
  for r in results_sorted:
142
  original = text[r.start : r.end]
143
- key = (r.entity_type, original)
144
- if key not in original_to_token:
145
- counters[r.entity_type] = counters.get(r.entity_type, 0) + 1
146
- token = f"{_TOKEN_PREFIX}{r.entity_type}_{counters[r.entity_type]}"
147
- original_to_token[key] = token
148
- token_map[token] = original
 
149
 
150
  # Perform replacement manually (Presidio replace operator doesn't
151
  # support per-occurrence dynamic values in a single pass).
152
- masked_text = _replace_spans(text, results_sorted, original_to_token)
153
  return masked_text, token_map
154
 
155
  def restore(self, text: str, token_map: dict[str, str]) -> str:
@@ -169,6 +174,23 @@ class PIIMasker:
169
  # ---------------------------------------------------------------------------
170
 
171
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
  def _replace_spans(
173
  text: str,
174
  results: list[RecognizerResult],
@@ -181,6 +203,6 @@ def _replace_spans(
181
  chars = list(text)
182
  for r in sorted(results, key=lambda r: r.start, reverse=True):
183
  original = text[r.start : r.end]
184
- token = original_to_token.get((r.entity_type, original), original)
185
  chars[r.start : r.end] = list(token)
186
  return "".join(chars)
 
21
  from presidio_analyzer import AnalyzerEngine, RecognizerResult
22
  from presidio_analyzer.nlp_engine import NlpEngineProvider
23
  from presidio_anonymizer import AnonymizerEngine
 
24
 
25
  from settings import settings
26
 
 
78
  self._language = language
79
  self._score_threshold = score_threshold
80
 
81
+ # Build NLP engine: spaCy en_core_web_lg if it is installed, otherwise
82
+ # en_core_web_sm. Checking first matters: Presidio silently downloads a
83
+ # missing model (400 MB for lg) instead of raising, so a try/except
84
+ # fallback never triggers.
85
  nlp_config = {
86
  "nlp_engine_name": "spacy",
87
+ "models": [{"lang_code": "en", "model_name": _pick_spacy_model()}],
88
  }
89
+ provider = NlpEngineProvider(nlp_configuration=nlp_config)
90
+ nlp_engine = provider.create_engine()
 
 
 
 
 
 
91
 
92
  self._analyzer = AnalyzerEngine(nlp_engine=nlp_engine, supported_languages=[language])
93
  self._anonymizer = AnonymizerEngine()
94
 
95
+ # Token assignments persist across mask() calls, so every document in a
96
+ # pack masked by this instance gets the same token for the same value
97
+ # ("Alex Morgan" is MASKED_PERSON_1 in the Schedule and the Certificate).
98
+ # Without this, tokens were numbered per document and the arbiter's
99
+ # driver matching paired the wrong people. Call reset() between packs.
100
+ self._original_to_token: dict[tuple[str, str], str] = {}
101
+ self._counters: dict[str, int] = {}
102
+
103
+ def reset(self) -> None:
104
+ """Forget token assignments, e.g. before masking an unrelated pack."""
105
+ self._original_to_token.clear()
106
+ self._counters.clear()
107
+
108
  # ------------------------------------------------------------------
109
  # Public API
110
  # ------------------------------------------------------------------
 
134
  if not results:
135
  return text, {}
136
 
 
 
137
  token_map: dict[str, str] = {}
 
138
 
139
  # Sort by position so token numbering is left-to-right and deterministic
140
  results_sorted = sorted(results, key=lambda r: r.start)
141
 
142
+ # Presidio's "replace" operator uses a fixed `new_value`, so tokens are
143
+ # assigned here, keyed on (entity_type, normalised original) and shared
144
+ # across calls on this instance.
 
 
145
  for r in results_sorted:
146
  original = text[r.start : r.end]
147
+ key = _token_key(r.entity_type, original)
148
+ token = self._original_to_token.get(key)
149
+ if token is None:
150
+ self._counters[r.entity_type] = self._counters.get(r.entity_type, 0) + 1
151
+ token = f"{_TOKEN_PREFIX}{r.entity_type}_{self._counters[r.entity_type]}"
152
+ self._original_to_token[key] = token
153
+ token_map.setdefault(token, original)
154
 
155
  # Perform replacement manually (Presidio replace operator doesn't
156
  # support per-occurrence dynamic values in a single pass).
157
+ masked_text = _replace_spans(text, results_sorted, self._original_to_token)
158
  return masked_text, token_map
159
 
160
  def restore(self, text: str, token_map: dict[str, str]) -> str:
 
174
  # ---------------------------------------------------------------------------
175
 
176
 
177
+ def _pick_spacy_model() -> str:
178
+ """Return en_core_web_lg if installed, else en_core_web_sm."""
179
+ try:
180
+ import spacy.util
181
+
182
+ if spacy.util.is_package("en_core_web_lg"):
183
+ return "en_core_web_lg"
184
+ except ImportError:
185
+ pass
186
+ return "en_core_web_sm"
187
+
188
+
189
+ def _token_key(entity_type: str, original: str) -> tuple[str, str]:
190
+ """Token lookup key: same entity type, case and runs of whitespace ignored."""
191
+ return entity_type, " ".join(original.split()).casefold()
192
+
193
+
194
  def _replace_spans(
195
  text: str,
196
  results: list[RecognizerResult],
 
203
  chars = list(text)
204
  for r in sorted(results, key=lambda r: r.start, reverse=True):
205
  original = text[r.start : r.end]
206
+ token = original_to_token.get(_token_key(r.entity_type, original), original)
207
  chars[r.start : r.end] = list(token)
208
  return "".join(chars)
src/provenance.py CHANGED
@@ -43,6 +43,9 @@ _SKIP_LEAF_NAMES = {
43
  # they don't contain verbatim PDF values so matching against them is meaningless.
44
  _SKIP_SECTION_NAMES = {"source_document", "field_citations"}
45
 
 
 
 
46
  # Document types whose corpora are unreliable for field-level matching.
47
  # Policy Booklets contain generic boilerplate — matching against them produces
48
  # false positives for almost every field ("Full", "UK", date digits, etc.).
@@ -69,6 +72,7 @@ class CorpusItem:
69
  page: int
70
  bbox: list[float] # [x0%, y0%, x1%, y1%] — top-left origin, 0–100
71
  source_filename: str
 
72
 
73
 
74
  class ProvenanceCorpus:
@@ -118,7 +122,7 @@ class ProvenanceCorpus:
118
  if not text or len(text) < 2:
119
  continue
120
  for prov in getattr(item, "prov", []):
121
- self._add_prov_item(prov, text, filename, page_sizes)
122
 
123
  def _add_prov_item(
124
  self,
@@ -126,6 +130,7 @@ class ProvenanceCorpus:
126
  text: str,
127
  filename: str,
128
  page_sizes: dict[int, tuple[float, float]],
 
129
  ) -> None:
130
  page_no = getattr(prov, "page_no", None)
131
  if page_no is None:
@@ -155,6 +160,7 @@ class ProvenanceCorpus:
155
  page=page_no,
156
  bbox=[round(x0, 3), round(y0, 3), round(x1, 3), round(y1, 3)],
157
  source_filename=filename,
 
158
  ))
159
 
160
  @staticmethod
@@ -208,6 +214,17 @@ def _item_text(item: Any) -> str:
208
  return ""
209
 
210
 
 
 
 
 
 
 
 
 
 
 
 
211
  def _clamp(v: float) -> float:
212
  return max(0.0, min(100.0, v))
213
 
@@ -268,6 +285,7 @@ def build_provenance(
268
  # Track assigned positions to avoid two fields pointing to the same corpus item.
269
  # Key: (source_filename, page, x0, y0) — unpadded, original corpus position.
270
  used_positions: set[tuple] = set()
 
271
 
272
  for field_path, value_str in _walk_record(record):
273
  leaf = field_path.split(".")[-1].strip("[]0123456789")
@@ -291,10 +309,34 @@ def build_provenance(
291
  score_fn = rfuzz.partial_ratio if using_citation else rfuzz.WRatio
292
  threshold = _CITATION_THRESHOLD if using_citation else _MATCH_THRESHOLD
293
 
294
- # Find best match, preferring positions not yet assigned to another field.
295
- best_score = 0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
296
  best_item: CorpusItem | None = None
297
- best_unused_score = 0
298
  best_unused_item: CorpusItem | None = None
299
 
300
  for corpus in trusted_corpora:
@@ -305,22 +347,24 @@ def build_provenance(
305
  alt_score = rfuzz.partial_ratio(alt_search, item.text.lower())
306
  if alt_score > score:
307
  score = alt_score
 
 
 
308
  pos_key = (item.source_filename, item.page, item.bbox[0], item.bbox[1])
309
- if score > best_score:
310
- best_score = score
311
- best_item = item
312
- if score > best_unused_score and pos_key not in used_positions:
313
- best_unused_score = score
314
- best_unused_item = item
315
-
316
- # Prefer an unused position if it scores above threshold,
317
- # otherwise fall back to best overall (may share a location).
318
- if best_unused_item is not None and best_unused_score >= threshold:
319
- chosen_item = best_unused_item
320
- chosen_score = best_unused_score
321
- elif best_item is not None and best_score >= threshold:
322
- chosen_item = best_item
323
- chosen_score = best_score
324
  else:
325
  continue
326
 
@@ -392,6 +436,34 @@ def _count_total_fields(record: Any) -> int:
392
  return sum(1 for _ in _walk_dict(data, ""))
393
 
394
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
395
  # ISO 8601 date/datetime patterns → UK DD/MM/YYYY
396
  _ISO_DATE_RE = re.compile(r'^(\d{4})-(\d{2})-(\d{2})')
397
 
 
43
  # they don't contain verbatim PDF values so matching against them is meaningless.
44
  _SKIP_SECTION_NAMES = {"source_document", "field_citations"}
45
 
46
+ # Docling labels that mark headings rather than values.
47
+ _HEADING_LABELS = {"title", "section_header", "page_header", "page_footer"}
48
+
49
  # Document types whose corpora are unreliable for field-level matching.
50
  # Policy Booklets contain generic boilerplate — matching against them produces
51
  # false positives for almost every field ("Full", "UK", date digits, etc.).
 
72
  page: int
73
  bbox: list[float] # [x0%, y0%, x1%, y1%] — top-left origin, 0–100
74
  source_filename: str
75
+ label: str = "" # Docling item label, e.g. "text", "section_header"
76
 
77
 
78
  class ProvenanceCorpus:
 
122
  if not text or len(text) < 2:
123
  continue
124
  for prov in getattr(item, "prov", []):
125
+ self._add_prov_item(prov, text, filename, page_sizes, _item_label(item))
126
 
127
  def _add_prov_item(
128
  self,
 
130
  text: str,
131
  filename: str,
132
  page_sizes: dict[int, tuple[float, float]],
133
+ label: str = "",
134
  ) -> None:
135
  page_no = getattr(prov, "page_no", None)
136
  if page_no is None:
 
160
  page=page_no,
161
  bbox=[round(x0, 3), round(y0, 3), round(x1, 3), round(y1, 3)],
162
  source_filename=filename,
163
+ label=label,
164
  ))
165
 
166
  @staticmethod
 
214
  return ""
215
 
216
 
217
+ def _item_label(item: Any) -> str:
218
+ """Docling item label as a plain string ("" when unavailable)."""
219
+ label = getattr(item, "label", "")
220
+ return str(getattr(label, "value", label) or "")
221
+
222
+
223
+ def _norm(text: str) -> str:
224
+ """Lowercase and collapse whitespace for containment checks."""
225
+ return " ".join(str(text).split()).lower()
226
+
227
+
228
  def _clamp(v: float) -> float:
229
  return max(0.0, min(100.0, v))
230
 
 
285
  # Track assigned positions to avoid two fields pointing to the same corpus item.
286
  # Key: (source_filename, page, x0, y0) — unpadded, original corpus position.
287
  used_positions: set[tuple] = set()
288
+ driver_values = _driver_values(record)
289
 
290
  for field_path, value_str in _walk_record(record):
291
  leaf = field_path.split(".")[-1].strip("[]0123456789")
 
309
  score_fn = rfuzz.partial_ratio if using_citation else rfuzz.WRatio
310
  threshold = _CITATION_THRESHOLD if using_citation else _MATCH_THRESHOLD
311
 
312
+ # Rank candidates above the threshold by, in order:
313
+ # 1. does the item contain the extracted value (or its UK date form)?
314
+ # 2. is it ordinary text rather than a heading?
315
+ # 3. for driver fields, how many of the same driver's other values it
316
+ # contains, so the field lands on that driver's row, not a label,
317
+ # a section heading or another driver's row;
318
+ # 4. the fuzzy score.
319
+ # A short label inside the quote ("Name" in "Named Driver") can score as
320
+ # high as the real row, so the score alone is not enough.
321
+ value_norm = _norm(value_str)
322
+ uk_norm = _norm(_iso_to_uk_date(value_str) or "")
323
+ driver_idx = _driver_index(field_path)
324
+ sibling_values = [
325
+ v for v in driver_values.get(driver_idx, []) if v and v != value_norm
326
+ ] if driver_idx is not None else []
327
+
328
+ def rank(item: CorpusItem, score: float) -> tuple:
329
+ text_norm = _norm(item.text)
330
+ covers = (len(value_norm) >= _MIN_VALUE_LEN and value_norm in text_norm) or (
331
+ bool(uk_norm) and uk_norm in text_norm
332
+ )
333
+ not_heading = item.label not in _HEADING_LABELS
334
+ row_bonus = sum(1 for v in sibling_values if v in text_norm)
335
+ return (covers, not_heading, row_bonus, score)
336
+
337
+ best_key: tuple | None = None
338
  best_item: CorpusItem | None = None
339
+ best_unused_key: tuple | None = None
340
  best_unused_item: CorpusItem | None = None
341
 
342
  for corpus in trusted_corpora:
 
347
  alt_score = rfuzz.partial_ratio(alt_search, item.text.lower())
348
  if alt_score > score:
349
  score = alt_score
350
+ if score < threshold:
351
+ continue
352
+ key = rank(item, score)
353
  pos_key = (item.source_filename, item.page, item.bbox[0], item.bbox[1])
354
+ # Strictly greater: on a full tie the first item in reading order wins.
355
+ if best_key is None or key > best_key:
356
+ best_key, best_item = key, item
357
+ if pos_key not in used_positions and (best_unused_key is None or key > best_unused_key):
358
+ best_unused_key, best_unused_item = key, item
359
+
360
+ # Prefer an unused position, but only if it is as good on everything
361
+ # except the raw score. A driver's row is shared by several fields
362
+ # (date of birth, relationship, occupation), and an unused heading
363
+ # nearby must not win just because the row is already taken.
364
+ if best_unused_item is not None and best_unused_key[:3] >= best_key[:3]:
365
+ chosen_item, chosen_score = best_unused_item, best_unused_key[-1]
366
+ elif best_item is not None:
367
+ chosen_item, chosen_score = best_item, best_key[-1]
 
368
  else:
369
  continue
370
 
 
436
  return sum(1 for _ in _walk_dict(data, ""))
437
 
438
 
439
+ _DRIVER_PATH_RE = re.compile(r"^driver_details\[(\d+)\]\.")
440
+
441
+
442
+ def _driver_index(field_path: str) -> int | None:
443
+ """Index of the driver a field belongs to, or None for non-driver fields."""
444
+ m = _DRIVER_PATH_RE.match(field_path)
445
+ return int(m.group(1)) if m else None
446
+
447
+
448
+ def _driver_values(record: Any) -> dict[int, list[str]]:
449
+ """Normalised values per driver (UK date form for dates), used to keep a
450
+ driver's fields on that driver's row. Names and booleans are left out:
451
+ names are usually masked tokens and booleans match everywhere."""
452
+ out: dict[int, list[str]] = {}
453
+ for i, driver in enumerate(getattr(record, "driver_details", None) or []):
454
+ vals: list[str] = []
455
+ for key, val in driver.model_dump(exclude_none=True).items():
456
+ if key == "name" or isinstance(val, bool):
457
+ continue
458
+ text = str(val)
459
+ uk = _iso_to_uk_date(text)
460
+ norm = _norm(uk or text)
461
+ if len(norm) >= _MIN_VALUE_LEN:
462
+ vals.append(norm)
463
+ out[i] = vals
464
+ return out
465
+
466
+
467
  # ISO 8601 date/datetime patterns → UK DD/MM/YYYY
468
  _ISO_DATE_RE = re.compile(r'^(\d{4})-(\d{2})-(\d{2})')
469
 
tests/test_arbiter.py CHANGED
@@ -368,3 +368,24 @@ class TestLiveRunRegressions:
368
  for c in conflicts:
369
  expected = c.schedule_value if c.winner == "schedule" else c.certificate_value
370
  assert str(_value_at(golden, c.field)) == expected, c.field
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
368
  for c in conflicts:
369
  expected = c.schedule_value if c.winner == "schedule" else c.certificate_value
370
  assert str(_value_at(golden, c.field)) == expected, c.field
371
+
372
+
373
+ class TestMaskedDriverNames:
374
+ """Masked names differ by one character, so they must match exactly."""
375
+
376
+ def test_different_tokens_do_not_match(self):
377
+ sched = _make_schedule(drivers=[{"name": "MASKED_PERSON_4", "is_main_driver": False}])
378
+ cert = _make_certificate(
379
+ drivers=[{"name": "MASKED_PERSON_1", "is_main_driver": True, "relationship": "Proposer"}]
380
+ )
381
+ golden, conflicts = PolicyArbiter().merge_records(sched, "s.pdf", cert, "c.pdf")
382
+ assert golden.driver_details[0].relationship is None
383
+ assert not any("is_main_driver" in c.field for c in conflicts)
384
+
385
+ def test_same_token_matches(self):
386
+ sched = _make_schedule(drivers=[{"name": "MASKED_PERSON_2", "is_main_driver": True}])
387
+ cert = _make_certificate(
388
+ drivers=[{"name": "MASKED_PERSON_2", "is_main_driver": True, "relationship": "Proposer"}]
389
+ )
390
+ golden, _ = PolicyArbiter().merge_records(sched, "s.pdf", cert, "c.pdf")
391
+ assert golden.driver_details[0].relationship == "Proposer"
tests/test_masking.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Masking must give the same person the same token in every document of a pack.
3
+
4
+ Needs Presidio and a spaCy English model; skipped when they are not installed.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ import pytest
12
+
13
+ sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
14
+
15
+ pytest.importorskip("presidio_analyzer")
16
+ spacy_util = pytest.importorskip("spacy.util")
17
+ if not (spacy_util.is_package("en_core_web_sm") or spacy_util.is_package("en_core_web_lg")):
18
+ pytest.skip("no spaCy English model installed", allow_module_level=True)
19
+
20
+ from privacy import PIIMasker # noqa: E402
21
+
22
+
23
+ def test_same_person_same_token_across_documents():
24
+ masker = PIIMasker()
25
+ schedule, s_map = masker.mask(
26
+ "Policyholder: Alex Morgan. Named drivers: Priya Shah and Jordan Reed."
27
+ )
28
+ certificate, c_map = masker.mask(
29
+ "Persons entitled to drive: Jordan Reed, Alex Morgan, Priya Shah."
30
+ )
31
+ s_tokens = {v: k for k, v in s_map.items()}
32
+ c_tokens = {" ".join(v.split()): k for k, v in c_map.items()}
33
+ for name in ("Alex Morgan", "Priya Shah", "Jordan Reed"):
34
+ if name in s_tokens and name in c_tokens:
35
+ assert s_tokens[name] == c_tokens[name], name
36
+ assert "Alex Morgan" not in certificate
37
+
38
+
39
+ def test_reset_starts_numbering_again():
40
+ masker = PIIMasker()
41
+ first, _ = masker.mask("My name is Alex Morgan.")
42
+ masker.reset()
43
+ second, _ = masker.mask("My name is Priya Shah.")
44
+ assert first.strip() == second.strip()
tests/test_provenance.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests for provenance matching (src/provenance.py).
3
+
4
+ The corpus below mirrors the synthetic demo Schedule: a policyholder section on
5
+ page 1 and a driver table on page 2 whose rows Docling returns as single text
6
+ items. These cases used to land on headings, labels or another driver's row.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+ from datetime import date
12
+ from pathlib import Path
13
+
14
+ sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
15
+
16
+ from provenance import CorpusItem, ProvenanceCorpus, build_provenance # noqa: E402
17
+ from schema import Driver, PolicyHeader, UKMotorGoldenRecord # noqa: E402
18
+
19
+ _F = "Schedule of Insurance - Demo.pdf"
20
+
21
+
22
+ def _corpus() -> ProvenanceCorpus:
23
+ corpus = ProvenanceCorpus(source_filename=_F, doc_type="Schedule")
24
+ rows = [
25
+ (1, "Policyholder details", "section_header"),
26
+ (1, "Policy number", "text"),
27
+ (1, "NBM-DEMO-0427", "text"),
28
+ (1, "Name", "text"),
29
+ (1, "Alex Morgan", "text"),
30
+ (1, "14/03/1991", "text"),
31
+ (1, "Product", "text"),
32
+ (2, "Driver details", "section_header"),
33
+ (2, "Driver name", "text"),
34
+ (2, "14/03/1991 Policyholder Product Manager Full Licence UK / 2/1 / No Yes", "text"),
35
+ (2, "07/08/1995 Named Driver Business Analyst UK Provisional / 1/4 / No No GBP 200.00", "text"),
36
+ (2, "11/10/1985 Named Driver Data Administrator Full Licence UK / 5/0 / No No", "text"),
37
+ ]
38
+ for i, (page, text, label) in enumerate(rows):
39
+ corpus.items.append(CorpusItem(
40
+ text=text, page=page, bbox=[10.0, float(i * 5), 60.0, float(i * 5 + 2)],
41
+ source_filename=_F, label=label,
42
+ ))
43
+ return corpus
44
+
45
+
46
+ def _record() -> UKMotorGoldenRecord:
47
+ record = UKMotorGoldenRecord(
48
+ policy_header=PolicyHeader(policy_number="NBM-DEMO-0427"),
49
+ driver_details=[
50
+ Driver(name="MASKED_PERSON_1", dob=date(1991, 3, 14), relationship="Policyholder",
51
+ occupation="Product Manager", license_type="Full UK", is_main_driver=True),
52
+ Driver(name="MASKED_PERSON_2", dob=date(1995, 8, 7), relationship="Named Driver",
53
+ occupation="Business Analyst", license_type="UK Provisional"),
54
+ Driver(name="MASKED_PERSON_3", dob=date(1985, 10, 11), relationship="Named Driver",
55
+ occupation="Data Administrator", license_type="Full UK"),
56
+ ],
57
+ )
58
+ record.field_citations = {
59
+ "driver_details[0].license_type": "Full Licence UK / 2/1 / No",
60
+ "driver_details[2].license_type": "Full Licence UK / 5/0 / No",
61
+ }
62
+ return record
63
+
64
+
65
+ def _by_path() -> dict:
66
+ return {p.field_path: p for p in build_provenance(_record(), [_corpus()])}
67
+
68
+
69
+ def test_policy_number_lands_on_value():
70
+ assert _by_path()["policy_header.policy_number"].matched_text == "NBM-DEMO-0427"
71
+
72
+
73
+ def test_relationship_not_on_heading_or_label():
74
+ prov = _by_path()
75
+ assert prov["driver_details[0].relationship"].matched_text.startswith("14/03/1991 Policyholder")
76
+ assert prov["driver_details[1].relationship"].matched_text.startswith("07/08/1995")
77
+ assert prov["driver_details[2].relationship"].matched_text.startswith("11/10/1985")
78
+
79
+
80
+ def test_occupation_not_on_fragment():
81
+ assert _by_path()["driver_details[0].occupation"].matched_text.startswith("14/03/1991 Policyholder")
82
+
83
+
84
+ def test_repeated_value_lands_on_the_right_drivers_row():
85
+ prov = _by_path()
86
+ assert prov["driver_details[0].license_type"].matched_text.startswith("14/03/1991")
87
+ assert prov["driver_details[2].license_type"].matched_text.startswith("11/10/1985")
88
+
89
+
90
+ def test_masked_names_are_not_located():
91
+ assert not any(p.endswith(".name") for p in _by_path())