Model/scripts/lisasrequest/reclassify_dominant_glazing.py
Jun-te Kim deec22302a Resolve dominant double/triple glazing when the era is stated
Addresses PR #1402 review: the guard only claimed dominant-single, leaving
the symmetric bug open — the LLM can flatten a dominant-double/triple split
onto its minority single (e.g. "96% Double glazing 2002 or later, 4% Single").

A dominant double/triple whose era is explicit ("pre-2002" / "2002 or later")
is just as fully determined as era-free single, so the guard now claims it via
_DOMINANT_MEMBER. Only a genuinely ambiguous era ("unknown age", unstated) still
defers to the LLM — the "96% double -> None" contract now holds solely for the
era-unknown case, not the era-stated one.

Backfill script reuses the same guard, so it now corrects any dominant split;
renamed reclassify_dominant_single_glazing.py -> reclassify_dominant_glazing.py
to match. Tests cover double/triple x pre-2002/2002-or-later and the still-
deferred unknown-age case; 14 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 08:07:13 +00:00

125 lines
4.7 KiB
Python

"""Backfill glazing overrides where a dominant (>= 90%) split was flattened onto
its *minority* type.
Sibling to ``scripts/reclassify_mixed_glazing.py``. That script rescued genuine
mixes (no type >= 90%) to ``MIXED``; it deliberately left near-uniform rows to the
LLM classifier. But for a dominant split the LLM sometimes latched onto the
minority type — a dominant-*single* dwelling ("4% Double glazing 2002 or later,
96% Single Glazing") written as "Double glazing…", or the symmetric dominant-double
case written as "Single glazing". ``glazing_mix_guard`` now resolves any
era-unambiguous dominant split deterministically — SINGLE (era-free), or a
double/triple whose era is stated — so this backfill fixes the rows written before
that guard change.
Uses the SAME guard as the live path, so the backfill and the classifier cannot
drift. SCOPED TO ONE PORTFOLIO (``--portfolio``, default 796 = Hyde) and only
touches ``property_overrides.override_value`` (TEXT — what the modelling reads);
the global ``landlord_glazing_overrides`` classifier cache is left alone.
DRY-RUN BY DEFAULT: prints what it would change and writes nothing. Pass
``--apply`` to execute inside a transaction; it also writes an audit CSV of every
row changed (property_id, uprn, old value, new value) so the change is reversible.
Idempotent — only rows whose stored value differs from the guard's target member
are touched.
python -m scripts.lisasrequest.reclassify_dominant_glazing # dry run
python -m scripts.lisasrequest.reclassify_dominant_glazing --apply # write
"""
from __future__ import annotations
import argparse
import csv
import sys
from collections import Counter
from pathlib import Path
from sqlalchemy import text
_REPO_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(_REPO_ROOT))
from domain.epc.property_overrides.glazing_mix_guard import glazing_mix_guard # noqa: E402
from scripts.e2e_common import ENV_PATH, build_engine, load_env # noqa: E402
_SELECT = text(
"""
SELECT po.property_id, pr.uprn,
po.original_spreadsheet_description AS description,
po.override_value AS value
FROM property_overrides po
JOIN property pr ON pr.id = po.property_id
WHERE po.portfolio_id = :portfolio
AND po.override_component = 'glazing'
"""
)
_UPDATE = text(
"""
UPDATE property_overrides
SET override_value = :new_value
WHERE portfolio_id = :portfolio
AND override_component = 'glazing'
AND property_id = :property_id
AND override_value <> :new_value
"""
)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--portfolio", type=int, default=796)
parser.add_argument(
"--apply",
action="store_true",
help="execute the updates (default: dry-run, writes nothing)",
)
args = parser.parse_args()
load_env(ENV_PATH)
engine = build_engine()
audit: list[tuple[int, object, str, str, str]] = [] # pid, uprn, descr, old, new
tally: Counter[str] = Counter()
with engine.begin() as conn:
conn.execute(text("SET statement_timeout = 120000"))
for property_id, uprn, description, value in conn.execute(
_SELECT, {"portfolio": args.portfolio}
):
member = glazing_mix_guard(description or "")
if member is None or value == member.value:
continue
tally[f"{value!r} -> {member.value!r}"] += 1
audit.append((property_id, uprn, description, value, member.value))
if args.apply:
conn.execute(
_UPDATE,
{
"portfolio": args.portfolio,
"property_id": property_id,
"new_value": member.value,
},
)
if not args.apply:
conn.rollback()
verb = "re-classified" if args.apply else "would re-classify"
print(f"portfolio {args.portfolio}: {verb} {len(audit)} glazing override row(s)")
for change, n in tally.most_common():
print(f" {n:5d} {change}")
if args.apply and audit:
out = _REPO_ROOT / "scripts" / "lisasrequest" / (
f"reclassify_dominant_glazing_{args.portfolio}_audit.csv"
)
with out.open("w", newline="") as fh:
w = csv.writer(fh)
w.writerow(["property_id", "uprn", "description", "old_value", "new_value"])
w.writerows(audit)
print(f"\naudit trail: {out}")
if not args.apply:
print("\nDRY-RUN — nothing written. Re-run with --apply to execute.")
return 0
if __name__ == "__main__":
raise SystemExit(main())