Spaces:
Runtime error
Runtime error
| """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" | |
| } | |