Model/scripts/lisasrequest/review_flags.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

135 lines
4.8 KiB
Python

"""Flag step-1 matches that need a human eye, for review before finalising.
Reads durkan_domna_filled.csv (the step-1 output) and writes a review CSV of
only the rows carrying at least one flag, newest-doubt-first:
not_found no UPRN resolved at all.
unit_not_in_match the input flat/house number does NOT appear in the matched
address — the high-precision "wrong property" signal. Two
shapes: a near-miss ("9 VANBRUGH" matched "9A, VANBRUGH")
or a flat collapsing onto its building ("FLAT 1, 20 WARWICK"
matched "20, WARWICK ROAD").
dup_uprn the same UPRN was resolved for >1 input row — typically a
block of flats all collapsing onto the building UPRN; all
but one will be dropped at finalise.
low_score lexiscore < 0.70 (a weak match, just over the OS bar). NOTE:
on its own this is noisy — truncated EPC addresses and extra
locality tokens push correct matches below 0.70. Treat it as
informational unless paired with one of the flags above.
python -m scripts.lisasrequest.review_flags
"""
from __future__ import annotations
import argparse
import csv
import re
import sys
from collections import Counter
from pathlib import Path
_REPO_ROOT = Path(__file__).resolve().parents[2]
ADDRESS_COL = "address"
POSTCODE_COL = "postcode"
FOUND_ADDRESS_COL = "domna_address_found"
FOUND_UPRN_COL = "domna_address_uprn"
LEXISCORE_COL = "domna_lexiscore"
SOURCE_COL = "domna_source"
LOW_SCORE = 0.70
_DEFAULT_IN = _REPO_ROOT / "scripts" / "lisasrequest" / "durkan_domna_filled.csv"
_DEFAULT_OUT = _REPO_ROOT / "scripts" / "lisasrequest" / "durkan_review_flags.csv"
_REVIEW_COLS = [
ADDRESS_COL,
POSTCODE_COL,
FOUND_ADDRESS_COL,
FOUND_UPRN_COL,
LEXISCORE_COL,
SOURCE_COL,
"flags",
]
def input_unit(address: str) -> str:
"""The salient unit number of an input address: the FLAT number if present,
else the leading house number ("" if neither). Upper-cased."""
upper = address.upper()
flat = re.search(r"\bFLAT\s+(\d+[A-Z]?)", upper)
if flat:
return flat.group(1)
lead = re.match(r"\s*(\d+[A-Z]?)\b", upper)
return lead.group(1) if lead else ""
def address_numbers(address: str) -> set[str]:
"""All standalone number tokens in an address (e.g. {"3", "20"}). Upper-cased."""
return set(re.findall(r"\b\d+[A-Z]?\b", address.upper()))
def _score(value: str) -> float:
try:
return float(value)
except (TypeError, ValueError):
return 0.0
def flag_rows(rows: list[dict[str, str]]) -> list[dict[str, str]]:
"""Return the flagged subset, each with a ';'-joined ``flags`` field."""
uprn_counts = Counter(
r.get(FOUND_UPRN_COL, "") for r in rows if r.get(FOUND_UPRN_COL)
)
flagged: list[dict[str, str]] = []
for row in rows:
uprn = row.get(FOUND_UPRN_COL, "")
source = row.get(SOURCE_COL, "")
flags: list[str] = []
if source == "not_found" or not uprn:
flags.append("not_found")
else:
unit = input_unit(row.get(ADDRESS_COL, ""))
if unit and unit not in address_numbers(row.get(FOUND_ADDRESS_COL, "")):
flags.append("unit_not_in_match")
if uprn_counts[uprn] > 1:
flags.append("dup_uprn")
if _score(row.get(LEXISCORE_COL, "")) < LOW_SCORE:
flags.append("low_score")
if flags:
flagged.append({**{c: row.get(c, "") for c in _REVIEW_COLS[:-1]},
"flags": ";".join(flags)})
# not_found first, then mismatches, then dup/low.
order = {"not_found": 0, "unit_not_in_match": 1, "dup_uprn": 2, "low_score": 3}
flagged.sort(key=lambda r: order.get(r["flags"].split(";")[0], 9))
return flagged
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--in", dest="inp", type=Path, default=_DEFAULT_IN)
parser.add_argument("--out", type=Path, default=_DEFAULT_OUT)
args = parser.parse_args()
with args.inp.open(newline="", encoding="utf-8-sig") as fh:
rows = [dict(r) for r in csv.DictReader(fh)]
flagged = flag_rows(rows)
with args.out.open("w", newline="", encoding="utf-8") as fh:
writer = csv.DictWriter(fh, fieldnames=_REVIEW_COLS, extrasaction="ignore")
writer.writeheader()
writer.writerows(flagged)
counts = Counter(f for r in flagged for f in r["flags"].split(";"))
print(f"{len(flagged)}/{len(rows)} rows flagged for review -> {args.out}")
for name in ("not_found", "unit_not_in_match", "dup_uprn", "low_score"):
print(f" {name}: {counts.get(name, 0)}")
return 0
if __name__ == "__main__":
sys.exit(main())