mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
135 lines
4.8 KiB
Python
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())
|