"""One-time re-classification of party-ceiling roof overrides mis-read as roofs. #1376: a landlord roof description that is a **party-ceiling** marker ("another / same dwelling or premises above") was occasionally classified by the LLM to an external ``Pitched, N mm loft insulation`` value when it carried a trailing depth — inventing roof heat loss where a party ceiling has ~0 (RdSAP 10 Table 18: "There is no heat loss through the roof of a building part that has the same dwelling or another dwelling above"). ~106 ``property_overrides`` rows (party-ceiling markers on any non-party-ceiling value), inconsistent with the ~13k of the same family already resolving to the party-ceiling member. The live classifier now applies ``roof_party_ceiling_guard`` deterministically (so new intakes are correct); this fixes the rows written before it. The **same guard** decides the correction here, so the backfill and the live path cannot drift. Updates the TEXT ``property_overrides.override_value`` (what the modelling reads — the actual fix) and the ``landlord_roof_type_overrides.value`` classifier cache. The party-ceiling members already exist in the roof pgEnum (13k rows store them), so no FE migration is needed. DRY-RUN BY DEFAULT: prints the row count it would change and writes nothing. Pass ``--apply`` to execute inside a transaction. Idempotent — only rows whose stored value differs from the guard's member are touched, so a second run is a no-op. """ from __future__ import annotations import argparse from collections.abc import Iterable from sqlalchemy import Connection, text from domain.epc.property_overrides.roof_party_ceiling_guard import ( roof_party_ceiling_guard, ) from scripts.e2e_common import build_engine, load_env def party_ceiling_corrections( stored: Iterable[tuple[str, str]], ) -> dict[str, str]: """``(description, stored override_value)`` → the corrected value, for the descriptions the party-ceiling guard resolves whose stored value is not already the guard's member. Descriptions the guard leaves to the LLM (``None``) and rows already on the right member are omitted — so the result is exactly the set to fix, and re-running against corrected data yields an empty map (idempotent). """ corrections: dict[str, str] = {} for description, value in stored: member = roof_party_ceiling_guard(description) if member is not None and value != member.value: corrections[description] = member.value return corrections _DISTINCT = text( """ SELECT DISTINCT lower(original_spreadsheet_description) AS description, override_value AS value FROM property_overrides WHERE override_component = 'roof_type' """ ) _OVERRIDES_UPDATE = text( """ UPDATE property_overrides SET override_value = :new_value WHERE override_component = 'roof_type' AND lower(original_spreadsheet_description) = :description AND override_value <> :new_value """ ) _CACHE_UPDATE = text( """ UPDATE landlord_roof_type_overrides SET value = :new_value, updated_at = now() WHERE lower(description) = :description AND value::text <> :new_value """ ) _OVERRIDES_COUNT = text( """ SELECT count(*) FROM property_overrides WHERE override_component = 'roof_type' AND lower(original_spreadsheet_description) = :description AND override_value <> :new_value """ ) def reclassify(conn: Connection, *, apply: bool) -> int: """NULL-free re-map: set every mis-classified party-ceiling roof override to the guard's member. Returns the number of ``property_overrides`` rows found (that ``--apply`` would / did correct).""" stored = [(r.description, r.value) for r in conn.execute(_DISTINCT)] total = 0 for description, new_value in party_ceiling_corrections(stored).items(): params = {"description": description, "new_value": new_value} total += conn.execute(_OVERRIDES_COUNT, params).scalar() or 0 if apply: conn.execute(_OVERRIDES_UPDATE, params) conn.execute(_CACHE_UPDATE, params) return total def main() -> None: load_env() parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--apply", action="store_true", help="execute the updates (default: dry-run, writes nothing)", ) args = parser.parse_args() engine = build_engine() with engine.begin() as conn: conn.execute(text("SET statement_timeout = 120000")) total = reclassify(conn, apply=args.apply) verb = "re-classified" if args.apply else "would re-classify" print( f"{verb} {total} party-ceiling roof override row(s) from an external " "roof value to the party-ceiling member." ) if not args.apply: print("\nDRY-RUN — nothing written. Re-run with --apply to execute.") if __name__ == "__main__": main()