"""Verification suite for the property-type guard on a leasehold flat and for the Tier-2 NOTES_ONLY fallback's review boundary. Goals: * Confirm the guard does NOT over-filter legitimate flat/leasehold boilerplate (a false negative would silently tombstone valid content). * Confirm the same content is correctly blocked for a house (directionality / anti-contamination) — the original bug. * Confirm NOTES_ONLY stays flagged for review across every surfacing path and is never silently promoted to delivered ("OK") output. """ from __future__ import annotations import io import zipfile from docx import Document from backend.core import ingest, section_mapper, template_discoverer from backend.core.compat_adapter import section_to_payload from backend.core.property_blocklist import filter_hits_by_property, is_paragraph_compatible from backend.core.report_assembler import to_docx, to_preview from backend.models.report import GeneratedSection, ReportResult from backend.models.schema import SectionDefinition, TemplateSchema from backend.config import settings # Representative legitimate leasehold-flat vocabulary. NONE of these may be # filtered for a flat — each is a false-negative regression if dropped. _LEGIT_FLAT_BOILERPLATE = [ "The communal areas and common parts are maintained by the managing agent.", "Leaseholders contribute to repairs through the annual service charge.", "The management company is responsible for the building's shared entrance.", "A PEEP should be prepared for any occupier requiring assisted evacuation.", "The fire strategy for the block is currently a stay put policy.", "Should the strategy change to simultaneous evacuation, alarms would be required.", "Ground rent is payable to the freeholder under the terms of the lease.", "The demised premises exclude the structural walls and communal stairwell.", "Access to the flat entrance is via a shared, carpeted communal hallway.", "A passenger lift serves all floors and is covered by the service charge.", ] def _flat_ctx() -> dict: return {"property_type": "second-floor leasehold flat", "tenure": "leasehold"} class _Hit: """Minimal stand-in for a SearchHit (guard only reads ``.text``).""" def __init__(self, text: str) -> None: self.text = text # ─────────────────────────── Property guard: flat ─────────────────────────── def test_flat_guard_keeps_all_legitimate_leasehold_boilerplate(): ctx = _flat_ctx() for paragraph in _LEGIT_FLAT_BOILERPLATE: assert is_paragraph_compatible(paragraph, ctx) is True, ( f"false negative: legitimate flat paragraph wrongly blocked: {paragraph!r}" ) def test_flat_guard_filter_hits_drops_nothing_legitimate(): ctx = _flat_ctx() hits = [_Hit(p) for p in _LEGIT_FLAT_BOILERPLATE] kept = filter_hits_by_property(hits, ctx) assert len(kept) == len(hits) def test_flat_guard_still_blocks_house_only_features(): # A flat must not inherit standalone-house features. ctx = _flat_ctx() for paragraph in ( "The property benefits from an integral garage accessed from the hall.", "There is an own front driveway providing off-street parking.", "The rear garden boundary fence is in poor condition.", ): assert is_paragraph_compatible(paragraph, ctx) is False # ───────────────────── End-to-end: flat keeps, house blocks ───────────────── def _seed_roof_reference(tenant: str, tmp_path) -> None: tpl = tmp_path / "template.docx" doc = Document() doc.add_paragraph("D2 Roof coverings") doc.save(str(tpl)) past = tmp_path / "past.docx" ref = Document() ref.add_paragraph("D2 Roof coverings") ref.add_paragraph( "The main roof covering is maintained by the managing agent as part of the " "communal areas demised under the lease; leaseholders contribute to repairs " "via the service charge." ) ref.save(str(past)) ingest.ingest_report_template(tenant, tpl) ingest.ingest_reference(tenant, past) def test_flat_e2e_retains_communal_baseline(tmp_path, monkeypatch): monkeypatch.setattr(settings, "data_dir", str(tmp_path / "data")) tenant = "flat-keep" _seed_roof_reference(tenant, tmp_path) result = section_mapper.generate_report( tenant, "The roof covering shows minor surface wear.", property_type="second-floor leasehold flat", tenure="leasehold", interference_level="minimum", ) d2 = next(s for s in result.sections if s.section_id == "D2") # No over-filtering: the communal baseline survived for a flat. assert d2.status == "OK" assert "communal" in d2.text.lower() or "managing agent" in d2.text.lower() def test_house_e2e_blocks_flat_baseline_no_contamination(tmp_path, monkeypatch): monkeypatch.setattr(settings, "data_dir", str(tmp_path / "data")) tenant = "house-block" _seed_roof_reference(tenant, tmp_path) result = section_mapper.generate_report( tenant, "The roof covering shows minor surface wear.", property_type="mid-terraced house", tenure="freehold", interference_level="minimum", ) d2 = next(s for s in result.sections if s.section_id == "D2") # The flat-only baseline must be blocked for a house: no communal contamination. assert "communal" not in d2.text.lower() assert "managing agent" not in d2.text.lower() assert "service charge" not in d2.text.lower() # The guard left no compatible baseline, so the section is flagged, not OK. assert d2.status != "OK" # ─────────────────────── NOTES_ONLY review boundary ───────────────────────── def _two_section_schema() -> TemplateSchema: return TemplateSchema( version=1, sections=[ SectionDefinition(id="D1", title="Chimney stacks", order=0, level=2), SectionDefinition(id="D2", title="Roof coverings", order=1, level=2), ], ) def _result_with_notes_only() -> ReportResult: return ReportResult( tenant_id="t", schema_version=1, property_type="mid-terraced house", tenure="freehold", sections=[ GeneratedSection( section_id="D1", title="Chimney stacks", text="The chimney stack mortar is sound. Condition rating 1.", status="OK", grounding_passed=True, ), GeneratedSection( section_id="D2", title="Roof coverings", text="Welsh slate roof; north slope has slipped tiles.", status="NOTES_ONLY", grounding_passed=False, notes="Authored from surveyor notes — requires surveyor review.", ), ], ) def test_notes_only_counts_as_review_not_mapped(): preview = to_preview(_result_with_notes_only(), _two_section_schema()) assert preview["sections_mapped"] == 1 # only the OK section assert preview["sections_needing_review"] == 1 # the NOTES_ONLY section d2 = next(s for s in preview["sections"] if s["section_id"] == "D2") assert d2["status"] == "NOTES_ONLY" assert d2["grounding_passed"] is False def test_notes_only_payload_confidence_is_not_delivered_grade(): section = _result_with_notes_only().sections[1] payload = section_to_payload(section, interference_level="minimum", mode="generate") # Delivered/OK sections score 0.92; NOTES_ONLY must never reach that grade. assert payload["confidence"] < 0.92 assert payload["status"] == "NOTES_ONLY" assert payload["grounding_passed"] is False def test_notes_only_text_is_preserved_in_docx_but_section_is_flagged(): schema = _two_section_schema() result = _result_with_notes_only() docx_bytes = to_docx(result, schema) with zipfile.ZipFile(io.BytesIO(docx_bytes)) as zf: xml = zf.read("word/document.xml").decode("utf-8") # The surveyor's own notes are not dropped (it is their content)... assert "welsh slate" in xml.lower() # ...but the section remains flagged for review in the structured surfaces. preview = to_preview(result, schema) assert preview["sections_needing_review"] >= 1 def test_notes_only_e2e_stays_notes_only_with_no_reference(tmp_path, monkeypatch): monkeypatch.setattr(settings, "data_dir", str(tmp_path / "data")) tenant = "notes-only-e2e" tpl = tmp_path / "template.docx" doc = Document() doc.add_paragraph("D2 Roof coverings") doc.save(str(tpl)) ingest.ingest_report_template(tenant, tpl) # Deliberately ingest NO reference report → retrieval is empty for every section. result = section_mapper.generate_report( tenant, "The roof covering has several slipped slate tiles on the north slope.", property_type="mid-terraced house", tenure="freehold", interference_level="minimum", ) d2 = next(s for s in result.sections if s.section_id == "D2") # Authored from notes (no baseline) → flagged, never silently delivered. assert d2.status == "NOTES_ONLY" assert d2.grounding_passed is False assert "slate" in d2.text.lower() preview = to_preview(result, template_discoverer.load_schema(tenant)) assert d2.section_id not in { s["section_id"] for s in preview["sections"] if s["status"] == "OK" }