Model/scripts/lisasrequest/resolve_uprns_for_finaliser.py
Jun-te Kim 0e85da1507 Resolve a landlord mains-gas override to the primary fuel code 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 12:15:54 +00:00

212 lines
8 KiB
Python

"""Step 2 (Durkan portfolio): split step-1 matches, reshape the confident ones.
Reads durkan_domna_filled.csv (step 1) and SPLITS it in two — no re-resolution,
just column work:
* Rows we cannot confidently insert are held back to a client-clarification CSV
(durkan_client_clarification.csv) for Khalim to take to the client. Reasons:
not_found_no_match no UPRN was resolved.
no_flat_level_uprn a block of flats all collapsed onto one building
UPRN — OS/EPC carry no flat-level records, so we
can't tell the flats apart.
unit_number_mismatch the matched house number differs from the input
(e.g. "9 ..." matched "9A ..."), so the property is
ambiguous.
* Every remaining row is reshaped into the columns the finaliser reads
(bulk_upload_finaliser_orchestrator), written to durkan_finaliser_input.csv
ready for step 3:
Address 1/2/3 | postcode | Internal Reference | address2uprn_uprn
| address2uprn_address | address2uprn_lexiscore
Internal Reference is left blank (landlord_property_id null, by decision).
python -m scripts.lisasrequest.resolve_uprns_for_finaliser
This stage hits no APIs. The held rows are not lost — once the client confirms
them they can be appended to the finaliser input by hand.
"""
from __future__ import annotations
import argparse
import csv
import sys
from collections import Counter
from pathlib import Path
from typing import Optional
_REPO_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(_REPO_ROOT)) # worktree root first — avoid the import trap
from scripts.lisasrequest.fill_domna_address import ( # noqa: E402
ADDRESS_COL,
FOUND_ADDRESS_COL,
FOUND_UPRN_COL,
LEXISCORE_COL,
POSTCODE_COL,
SOURCE_COL,
)
from scripts.lisasrequest.review_flags import address_numbers, input_unit # noqa: E402
# Finaliser input columns — must match bulk_upload_finaliser_orchestrator
# (ADDRESS_COLS / POSTCODE_COL / INTERNAL_REF_COL / UPRN_COL /
# MATCHED_ADDRESS_COL / LEXISCORE_COL). Hard-coded to keep this a light,
# stdlib-only reshape; step 3 imports the real orchestrator and will fail loudly
# if these ever drift.
FIN_ADDRESS_1, FIN_ADDRESS_2, FIN_ADDRESS_3 = "Address 1", "Address 2", "Address 3"
FIN_POSTCODE = "postcode"
FIN_INTERNAL_REF = "Internal Reference"
FIN_UPRN = "address2uprn_uprn"
FIN_MATCHED_ADDRESS = "address2uprn_address"
FIN_LEXISCORE = "address2uprn_lexiscore"
_FINALISER_COLS = [
FIN_ADDRESS_1,
FIN_ADDRESS_2,
FIN_ADDRESS_3,
FIN_POSTCODE,
FIN_INTERNAL_REF,
FIN_UPRN,
FIN_MATCHED_ADDRESS,
FIN_LEXISCORE,
]
# Client-clarification report columns (kept human-readable for the client).
CONTEXT_COLS = ["address", "postcode", "No.", "Address Block"]
DOMNA_COLS = [FOUND_ADDRESS_COL, FOUND_UPRN_COL, LEXISCORE_COL, SOURCE_COL]
REASON_COL = "clarification_reason"
ACTION_COL = "action_needed"
_CLARIFY_COLS = CONTEXT_COLS + DOMNA_COLS + [REASON_COL, ACTION_COL]
_REASON_ORDER = {
"not_found_no_match": 0,
"no_flat_level_uprn": 1,
"unit_number_mismatch": 2,
}
_REASON_ACTION = {
"not_found_no_match": "No UPRN found for this address — please confirm the "
"exact address or provide the UPRN.",
"no_flat_level_uprn": "Address registers hold only the building, not the "
"individual flats — please provide a UPRN per flat, or confirm a "
"building-level record is acceptable.",
"unit_number_mismatch": "Closest match has a different unit number (see "
"domna_address_found) — please confirm the correct property / UPRN.",
}
_DEFAULT_IN = _REPO_ROOT / "scripts" / "lisasrequest" / "durkan_domna_filled.csv"
_DEFAULT_FINALISER = _REPO_ROOT / "scripts" / "lisasrequest" / "durkan_finaliser_input.csv"
_DEFAULT_CLARIFY = (
_REPO_ROOT / "scripts" / "lisasrequest" / "durkan_client_clarification.csv"
)
def read_rows(path: Path) -> list[dict[str, str]]:
with path.open(newline="", encoding="utf-8-sig") as fh:
return [dict(row) for row in csv.DictReader(fh)]
def clarification_reason(
row: dict[str, str], uprn_counts: Counter[str]
) -> Optional[str]:
"""Why this row can't be inserted yet, or None if it's safe to finalise."""
uprn = row.get(FOUND_UPRN_COL, "")
if row.get(SOURCE_COL) == "not_found" or not uprn:
return "not_found_no_match"
unit = input_unit(row.get(ADDRESS_COL, ""))
unit_missing = bool(unit) and unit not in address_numbers(
row.get(FOUND_ADDRESS_COL, "")
)
duplicate = uprn_counts[uprn] > 1
if unit_missing:
return "no_flat_level_uprn" if duplicate else "unit_number_mismatch"
if duplicate:
# A shared UPRN with the right unit number still collides at finalise.
return "no_flat_level_uprn"
return None
def to_finaliser_row(row: dict[str, str]) -> dict[str, str]:
"""Rename a confident step-1 row into the finaliser's input columns."""
return {
FIN_ADDRESS_1: row.get(ADDRESS_COL, ""),
FIN_ADDRESS_2: "",
FIN_ADDRESS_3: "",
FIN_POSTCODE: row.get(POSTCODE_COL, ""),
FIN_INTERNAL_REF: "", # landlord_property_id null, by decision
FIN_UPRN: row.get(FOUND_UPRN_COL, ""),
FIN_MATCHED_ADDRESS: row.get(FOUND_ADDRESS_COL, ""),
FIN_LEXISCORE: row.get(LEXISCORE_COL, ""),
}
def to_clarify_row(row: dict[str, str], reason: str) -> dict[str, str]:
out = {col: row.get(col, "") for col in CONTEXT_COLS + DOMNA_COLS}
out[REASON_COL] = reason
out[ACTION_COL] = _REASON_ACTION[reason]
return out
def split(
rows: list[dict[str, str]],
*,
accept_unit_mismatch: bool = False,
) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
"""Return (finaliser_rows, clarification_rows).
``accept_unit_mismatch`` reshapes the ``unit_number_mismatch`` rows (a
near-miss like 9 -> 9A the client has already confirmed) into the finaliser
input instead of holding them back.
"""
uprn_counts: Counter[str] = Counter(
r.get(FOUND_UPRN_COL, "") for r in rows if r.get(FOUND_UPRN_COL)
)
finaliser: list[dict[str, str]] = []
clarify: list[dict[str, str]] = []
for row in rows:
reason = clarification_reason(row, uprn_counts)
if reason is None or (
accept_unit_mismatch and reason == "unit_number_mismatch"
):
finaliser.append(to_finaliser_row(row))
else:
clarify.append(to_clarify_row(row, reason))
clarify.sort(key=lambda r: _REASON_ORDER.get(r[REASON_COL], 9))
return finaliser, clarify
def write_csv(rows: list[dict[str, str]], path: Path, fieldnames: list[str]) -> None:
with path.open("w", newline="", encoding="utf-8") as fh:
writer = csv.DictWriter(fh, fieldnames=fieldnames, extrasaction="ignore")
writer.writeheader()
writer.writerows(rows)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--in", dest="inp", type=Path, default=_DEFAULT_IN)
parser.add_argument("--finaliser-out", type=Path, default=_DEFAULT_FINALISER)
parser.add_argument("--clarify-out", type=Path, default=_DEFAULT_CLARIFY)
parser.add_argument(
"--accept-unit-mismatch",
action="store_true",
help="reshape unit_number_mismatch rows (e.g. 9->9A) into the finaliser "
"input instead of holding them for the client",
)
args = parser.parse_args()
rows = read_rows(args.inp)
finaliser, clarify = split(rows, accept_unit_mismatch=args.accept_unit_mismatch)
write_csv(finaliser, args.finaliser_out, _FINALISER_COLS)
write_csv(clarify, args.clarify_out, _CLARIFY_COLS)
counts = Counter(r[REASON_COL] for r in clarify)
print(f"Read {len(rows)} step-1 rows.")
print(f" -> {len(finaliser)} confident rows reshaped -> {args.finaliser_out}")
print(f" -> {len(clarify)} held for client -> {args.clarify_out}")
for reason in _REASON_ORDER:
print(f" {reason}: {counts.get(reason, 0)}")
return 0
if __name__ == "__main__":
sys.exit(main())