diff --git a/domain/epc/property_overrides/glazing_mix_guard.py b/domain/epc/property_overrides/glazing_mix_guard.py index 36d8fce58..5d59e4fa1 100644 --- a/domain/epc/property_overrides/glazing_mix_guard.py +++ b/domain/epc/property_overrides/glazing_mix_guard.py @@ -5,12 +5,35 @@ from typing import Optional from domain.epc.property_overrides.glazing_type import GlazingType -# "N% " — the era (2002 / pre-2002) is irrelevant to whether it -# is a mix, so only the base type is captured. -_PERCENT_TYPE = re.compile(r"(\d+)%\s*(single|double|triple|secondary)") +# "N% [era]" — the base type decides whether it is a mix; the +# era ("2002 or later" / "pre-2002") is captured too, so a dominant double/triple +# can be resolved deterministically when its era is stated (see the guard below). +# An absent or non-canonical era ("unknown age", "(SAP 9.94)") leaves the era group +# empty, so that dominant split stays the LLM's job. +_PERCENT_TYPE = re.compile( + r"(\d+)%\s*(single|double|triple|secondary)(?:\s*glazing)?" + r"(?:[\s,]*(pre-?2002|2002 or later))?" +) # One base type at or above this share is a uniform assertion, not a mix — applied # as that type (a near-uniform reglaze), not deferred. _UNIFORM_THRESHOLD_PERCENT = 90 +# A dominant base type + stated era → the fully-determined canonical member. Single +# glazing is era-free, so it needs no era. Double/triple with no (or non-canonical) +# era is absent here and falls through to the LLM. +_DOMINANT_MEMBER: dict[tuple[str, Optional[str]], GlazingType] = { + ("single", None): GlazingType.SINGLE, + ("double", "2002 or later"): GlazingType.DOUBLE_POST_2002, + ("double", "pre-2002"): GlazingType.DOUBLE_PRE_2002, + ("triple", "2002 or later"): GlazingType.TRIPLE_POST_2002, + ("triple", "pre-2002"): GlazingType.TRIPLE_PRE_2002, +} + + +def _canonical_era(era: str) -> Optional[str]: + """Normalise a matched era phrase to the key used in ``_DOMINANT_MEMBER``.""" + if not era: + return None + return "2002 or later" if "2002 or later" in era else "pre-2002" def glazing_mix_guard(description: str) -> Optional[GlazingType]: @@ -23,18 +46,36 @@ def glazing_mix_guard(description: str) -> Optional[GlazingType]: must resolve to ``GlazingType.MIXED`` (no overlay → the cert is kept), never to a single dominant type that would flatten every window (ADR-0042). - Recognises the structured ``"N% , M% "`` split and returns - ``MIXED`` when it is a genuine mix — two or more glazing types present and none - dominant (≥ the uniform threshold). Returns ``None`` for a uniform / near- - uniform assertion or an unparseable description, so the LLM classifier still - resolves it (a uniform reglaze is applied; varied phrasings are the LLM's job). + Recognises the structured ``"N% , M% "`` split and returns: + + * ``MIXED`` for a genuine mix — two or more glazing types present and none + dominant (≥ the uniform threshold); + * the fully-determined member for a dominant near-uniform split whose type + carries no era ambiguity — single glazing (era-free), or a dominant + double/triple whose era is stated ("pre-2002" / "2002 or later"); the LLM + would otherwise flatten such a split onto its *minority* type; + * ``None`` otherwise — an unparseable description, or a dominant double/triple + with an unstated/non-canonical era ("unknown age") that still carries genuine + era ambiguity — so the LLM classifier resolves it. """ matches = _PERCENT_TYPE.findall(description.lower()) - base_types = {base_type for _, base_type in matches} + base_types = {base_type for _, base_type, _ in matches} if len(base_types) < 2: # One (or no) glazing type named — a uniform assertion, not a mix. return None - if max(int(percent) for percent, _ in matches) >= _UNIFORM_THRESHOLD_PERCENT: - # One type dominates — treat as that (near-)uniform type, not a mix. - return None + dominant_percent, dominant_type, dominant_era = max( + ((int(percent), base_type, era) for percent, base_type, era in matches), + key=lambda triple: triple[0], + ) + if dominant_percent >= _UNIFORM_THRESHOLD_PERCENT: + # One type dominates — a near-uniform assertion, not a mix. A split is fully + # determined (and safe to apply deterministically) when the dominant type + # carries no era ambiguity: single glazing is era-free, and a dominant + # double/triple whose era is stated ("pre-2002" / "2002 or later") is + # equally pinned. In those cases claim the member ourselves — the LLM + # otherwise flattens the split onto the *minority* type. A dominant + # double/triple with an unstated or non-canonical era ("unknown age") still + # carries genuine era ambiguity, so it stays the LLM's job (falls through to + # ``None``). + return _DOMINANT_MEMBER.get((dominant_type, _canonical_era(dominant_era))) return GlazingType.MIXED diff --git a/scripts/lisasrequest/reclassify_dominant_glazing.py b/scripts/lisasrequest/reclassify_dominant_glazing.py new file mode 100644 index 000000000..95dd59c9f --- /dev/null +++ b/scripts/lisasrequest/reclassify_dominant_glazing.py @@ -0,0 +1,125 @@ +"""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()) diff --git a/tests/domain/epc/test_glazing_mix_guard.py b/tests/domain/epc/test_glazing_mix_guard.py index 04e351cd3..de1faccf0 100644 --- a/tests/domain/epc/test_glazing_mix_guard.py +++ b/tests/domain/epc/test_glazing_mix_guard.py @@ -18,19 +18,65 @@ def test_a_genuine_percentage_mix_classifies_as_mixed() -> None: assert result is GlazingType.MIXED +@pytest.mark.parametrize( + "description", + [ + "4% double glazing 2002 or later, 96% single glazing", + "10% double glazing 2002 or later, 90% single glazing", # exactly at threshold + "5% double glazing pre-2002, 95% single glazing", + ], +) +def test_dominant_single_glazing_resolves_to_single(description: str) -> None: + # The bug: the LLM flattened a >= 90%-single dwelling onto its *minority* double + # type. Single glazing is era-free, so a dominant-single split is fully + # determined — the guard must claim it (SINGLE) rather than defer to the LLM. + + # Act + result = glazing_mix_guard(description) + + # Assert + assert result is GlazingType.SINGLE + + +@pytest.mark.parametrize( + ("description", "expected"), + [ + # >= 90% double with a *stated* era is era-pinned — the LLM otherwise + # flattens it onto the 4% minority single (the symmetric bug to the + # dominant-single case above). + ("96% double glazing 2002 or later, 4% single glazing", GlazingType.DOUBLE_POST_2002), + ("95% double glazing pre-2002, 5% single glazing", GlazingType.DOUBLE_PRE_2002), + ("90% double glazing 2002 or later, 10% single glazing", GlazingType.DOUBLE_POST_2002), + # Triple is era-bearing too — resolve when the era is stated. + ("92% triple glazing pre-2002, 8% single glazing", GlazingType.TRIPLE_PRE_2002), + ("91% triple glazing 2002 or later, 9% single glazing", GlazingType.TRIPLE_POST_2002), + ], +) +def test_dominant_era_stated_double_or_triple_resolves_deterministically( + description: str, expected: GlazingType +) -> None: + # Act + result = glazing_mix_guard(description) + + # Assert — a dominant double/triple whose era is stated is fully determined, so + # the guard claims it rather than trusting the LLM. + assert result is expected + + @pytest.mark.parametrize( "description", [ "100% double glazing 2002 or later", # uniform — one type - "96% double glazing 2002 or later, 4% single glazing", # >= 90% -> uniform + "90% double glazing unknown age, 10% single glazing", # dominant but era unstated "80% double glazing 2002 or later, 20% double glazing pre-2002", # same base type "some double glazing and a bit of single", # unparseable — no percentages "", ], ) -def test_uniform_or_unparseable_glazing_defers_to_the_llm(description: str) -> None: - # The guard only claims a genuine percentage mix; a uniform assertion (applied - # as that type) or a varied phrasing is left for the LLM classifier. +def test_uniform_or_ambiguous_glazing_defers_to_the_llm(description: str) -> None: + # The guard only claims a genuine mix or a fully-determined dominant split. A + # uniform assertion, a dominant split whose era is unstated ("unknown age" — the + # era is genuinely ambiguous), or a varied phrasing is left for the LLM. # Act result = glazing_mix_guard(description)