Spaces:
Running
Running
Keep the Schedule main-driver value; ignore whitespace-only conflicts
Browse files- src/arbiter.py +20 -3
- src/schema.py +1 -1
- tests/test_arbiter.py +67 -0
- ui/src/RecordPane.tsx +4 -3
- ui/src/types.ts +1 -1
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
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 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 |
-
|
| 61 |
-
|
| 62 |
-
|
|
|
|
| 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 |
|