AItoolstack commited on
Commit
7db0ced
·
verified ·
1 Parent(s): 140c7b5

Keep the Schedule main-driver value; ignore whitespace-only conflicts

Browse files
src/arbiter.py CHANGED
@@ -131,6 +131,15 @@ def _first(*values):
131
  return None
132
 
133
 
 
 
 
 
 
 
 
 
 
134
  def _check_conflict(
135
  conflicts: list[ConflictEntry],
136
  field: str,
@@ -145,7 +154,7 @@ def _check_conflict(
145
  ``winner`` must be ``"schedule"`` or ``"certificate"``.
146
  """
147
  if sched_val is not None and cert_val is not None:
148
- if str(sched_val).strip().lower() != str(cert_val).strip().lower():
149
  conflicts.append(ConflictEntry(
150
  field=field,
151
  schedule_value=str(sched_val),
@@ -225,7 +234,12 @@ def _merge_drivers(
225
  for sd in sched_drivers:
226
  cd = _find_matching_driver(sd.name, cert_drivers)
227
 
228
- if cd is not None and sd.is_main_driver != cd.is_main_driver:
 
 
 
 
 
229
  conflicts.append(ConflictEntry(
230
  field=f"driver_details[{sd.name}].is_main_driver",
231
  schedule_value=str(sd.is_main_driver),
@@ -239,7 +253,10 @@ def _merge_drivers(
239
  relationship=_first(sd.relationship, cd.relationship if cd else None),
240
  occupation=_first(sd.occupation, cd.occupation if cd else None),
241
  license_type=_first(sd.license_type, cd.license_type if cd else None),
242
- is_main_driver=sd.is_main_driver or (cd.is_main_driver if cd else False),
 
 
 
243
  specific_excess=_first(sd.specific_excess, cd.specific_excess if cd else None),
244
  ))
245
  return merged
 
131
  return None
132
 
133
 
134
+ def _normalise(value) -> str:
135
+ """Comparison key: case-insensitive, with runs of whitespace collapsed.
136
+
137
+ PDF text extraction often leaves double spaces inside a value
138
+ ("Motor Insurance"), which is not a real disagreement.
139
+ """
140
+ return " ".join(str(value).split()).lower()
141
+
142
+
143
  def _check_conflict(
144
  conflicts: list[ConflictEntry],
145
  field: str,
 
154
  ``winner`` must be ``"schedule"`` or ``"certificate"``.
155
  """
156
  if sched_val is not None and cert_val is not None:
157
+ if _normalise(sched_val) != _normalise(cert_val):
158
  conflicts.append(ConflictEntry(
159
  field=field,
160
  schedule_value=str(sched_val),
 
234
  for sd in sched_drivers:
235
  cd = _find_matching_driver(sd.name, cert_drivers)
236
 
237
+ if (
238
+ cd is not None
239
+ and sd.is_main_driver is not None
240
+ and cd.is_main_driver is not None
241
+ and sd.is_main_driver != cd.is_main_driver
242
+ ):
243
  conflicts.append(ConflictEntry(
244
  field=f"driver_details[{sd.name}].is_main_driver",
245
  schedule_value=str(sd.is_main_driver),
 
253
  relationship=_first(sd.relationship, cd.relationship if cd else None),
254
  occupation=_first(sd.occupation, cd.occupation if cd else None),
255
  license_type=_first(sd.license_type, cd.license_type if cd else None),
256
+ # Schedule wins, as recorded in the conflict entry above. Only a
257
+ # missing value (None) is filled from the Certificate; an explicit
258
+ # False on the Schedule is kept.
259
+ is_main_driver=_first(sd.is_main_driver, cd.is_main_driver if cd else None),
260
  specific_excess=_first(sd.specific_excess, cd.specific_excess if cd else None),
261
  ))
262
  return merged
src/schema.py CHANGED
@@ -83,7 +83,7 @@ class Driver(BaseModel):
83
  relationship: Optional[str] = None
84
  occupation: Optional[str] = None
85
  license_type: Optional[str] = None
86
- is_main_driver: bool = False
87
  specific_excess: Optional[float] = None
88
 
89
 
 
83
  relationship: Optional[str] = None
84
  occupation: Optional[str] = None
85
  license_type: Optional[str] = None
86
+ is_main_driver: Optional[bool] = None # None = the document did not say
87
  specific_excess: Optional[float] = None
88
 
89
 
tests/test_arbiter.py CHANGED
@@ -301,3 +301,70 @@ class TestFieldCitationsMerge:
301
  golden, _ = arbiter.merge_records(sched, "s.pdf", cert, "c.pdf")
302
  # Neither side has citations → merged record has None
303
  assert golden.field_citations is None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
301
  golden, _ = arbiter.merge_records(sched, "s.pdf", cert, "c.pdf")
302
  # Neither side has citations → merged record has None
303
  assert golden.field_citations is None
304
+
305
+
306
+ # ---------------------------------------------------------------------------
307
+ # Regressions found on the live demo run (Oct 2026)
308
+ # ---------------------------------------------------------------------------
309
+
310
+ def _value_at(record: UKMotorGoldenRecord, field: str):
311
+ """Resolve a ConflictEntry field path, including driver_details[NAME].attr."""
312
+ import re
313
+ m = re.match(r"driver_details\[(.+)\]\.(\w+)$", field)
314
+ if m:
315
+ name, attr = m.groups()
316
+ driver = next(d for d in record.driver_details if d.name == name)
317
+ return getattr(driver, attr)
318
+ obj = record
319
+ for part in field.split("."):
320
+ obj = getattr(obj, part)
321
+ return obj
322
+
323
+
324
+ class TestLiveRunRegressions:
325
+ def test_internal_whitespace_is_not_a_conflict(self):
326
+ sched = _make_schedule(insurer="Northbridge Mutual Motor Insurance Ltd",
327
+ class_of_use="Social, domestic and pleasure")
328
+ cert = _make_certificate(insurer="Northbridge Mutual Motor Insurance Ltd",
329
+ class_of_use="Social, domestic and pleasure")
330
+ _, conflicts = PolicyArbiter().merge_records(sched, "s.pdf", cert, "c.pdf")
331
+ assert [c.field for c in conflicts] == []
332
+
333
+ def test_schedule_false_main_driver_is_kept(self):
334
+ sched = _make_schedule(drivers=[
335
+ {"name": "ALICE SMITH", "is_main_driver": True},
336
+ {"name": "BOB JONES", "is_main_driver": False},
337
+ ])
338
+ cert = _make_certificate(drivers=[
339
+ {"name": "ALICE SMITH", "is_main_driver": False},
340
+ {"name": "BOB JONES", "is_main_driver": True},
341
+ ])
342
+ golden, conflicts = PolicyArbiter().merge_records(sched, "s.pdf", cert, "c.pdf")
343
+ main = [d.name for d in golden.driver_details if d.is_main_driver]
344
+ assert main == ["ALICE SMITH"]
345
+ assert len([c for c in conflicts if c.field.endswith("is_main_driver")]) == 2
346
+
347
+ def test_missing_main_driver_is_filled_from_certificate(self):
348
+ sched = _make_schedule(drivers=[{"name": "ALICE SMITH"}])
349
+ cert = _make_certificate(drivers=[{"name": "ALICE SMITH", "is_main_driver": True}])
350
+ golden, conflicts = PolicyArbiter().merge_records(sched, "s.pdf", cert, "c.pdf")
351
+ assert golden.driver_details[0].is_main_driver is True
352
+ assert conflicts == []
353
+
354
+ def test_record_matches_conflict_ledger(self):
355
+ """For every recorded conflict, the Golden Record holds the winner's value."""
356
+ sched = _make_schedule(
357
+ policy_number="SCHED-100", class_of_use="Social Only",
358
+ drivers=[{"name": "ALICE SMITH", "is_main_driver": True},
359
+ {"name": "BOB JONES", "is_main_driver": False}],
360
+ )
361
+ cert = _make_certificate(
362
+ policy_number="CERT-200", class_of_use="Social, Domestic and Pleasure",
363
+ drivers=[{"name": "ALICE SMITH", "is_main_driver": False},
364
+ {"name": "BOB JONES", "is_main_driver": True}],
365
+ )
366
+ golden, conflicts = PolicyArbiter().merge_records(sched, "s.pdf", cert, "c.pdf")
367
+ assert conflicts
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
ui/src/RecordPane.tsx CHANGED
@@ -57,9 +57,10 @@ export function RecordPane({ sessionId }: Props) {
57
  <span className="font-mono text-[12px] text-paper-muted">{c.field}</span>
58
  <span className="block mt-0.5">
59
  Winner: <span className="font-semibold capitalize">{c.winner}</span>
60
- {c.schedule_value && c.certificate_value ? (
61
- <span className="text-paper-muted"> · schedule and certificate wording differ</span>
62
- ) : null}
 
63
  </span>
64
  </li>
65
  ))}
 
57
  <span className="font-mono text-[12px] text-paper-muted">{c.field}</span>
58
  <span className="block mt-0.5">
59
  Winner: <span className="font-semibold capitalize">{c.winner}</span>
60
+ </span>
61
+ <span className="block mt-0.5 text-[12px] text-paper-muted">
62
+ Schedule: <span className="text-paper-ink">{c.schedule_value ?? '—'}</span>
63
+ {' · '}Certificate: <span className="text-paper-ink">{c.certificate_value ?? '—'}</span>
64
  </span>
65
  </li>
66
  ))}
ui/src/types.ts CHANGED
@@ -56,7 +56,7 @@ export interface Driver {
56
  relationship?: string
57
  occupation?: string
58
  license_type?: string
59
- is_main_driver: boolean
60
  specific_excess?: number
61
  }
62
 
 
56
  relationship?: string
57
  occupation?: string
58
  license_type?: string
59
+ is_main_driver: boolean | null
60
  specific_excess?: number
61
  }
62