Spaces:
Runtime error
Runtime error
| """Regression tests for the section-scope / shared-context / property-type | |
| reconciliation pipeline hardening (Batch 1). | |
| These cover the deterministic units that affect every report: | |
| - Section D introduction-only contract + scope fence (no defects in D) | |
| - Property-type reconciliation (house + converted-to-flats coherence) | |
| - Surveyor-code vs RICS-code re-labelling in the notes router | |
| - Universal quality rules (subject pronoun / polarity / boilerplate / unknown | |
| term) injected into every section prompt | |
| """ | |
| from __future__ import annotations | |
| import pytest | |
| from app.generator.prompts import build_user_prompt | |
| from app.generator.section_scope import is_introduction_section, section_scope_block | |
| from app.services.generation import ( | |
| _identity_facts_block, | |
| _wrap_structured_skeleton, | |
| resolve_property_type, | |
| ) | |
| # --------------------------------------------------------------------------- # | |
| # Section D scope fence + introduction contract | |
| # --------------------------------------------------------------------------- # | |
| def test_section_D_scope_fence_excludes_defects() -> None: | |
| fence = section_scope_block("D", "About the property") | |
| low = fence.lower() | |
| # In scope: property type + legal status. | |
| assert "property type" in low | |
| assert "tenure" in low | |
| # Out of scope: element-level defects must be pushed elsewhere. | |
| for term in ("roof", "damp", "wall defects", "floor", "services"): | |
| assert term in low # listed as OUT OF SCOPE destinations | |
| def test_wrap_skeleton_D_is_introduction_only() -> None: | |
| skeleton = _wrap_structured_skeleton( | |
| survey_level=3, | |
| code="D", | |
| title="About the property", | |
| base_skeleton="Overview of the property.", | |
| has_condition_rating=False, | |
| ) | |
| # The generic element scaffold (which causes the "dump everything" bug) | |
| # must NOT be present for Section D. | |
| assert "Defects and Risks" not in skeleton | |
| assert "Condition Assessment" not in skeleton | |
| # The introduction contract + closing cross-reference must be present. | |
| assert "2\u20133 sentences" in skeleton or "2-3 sentences" in skeleton | |
| assert "sections E through K" in skeleton | |
| def test_wrap_skeleton_element_section_keeps_scaffold_and_scope() -> None: | |
| skeleton = _wrap_structured_skeleton( | |
| survey_level=3, | |
| code="E1", | |
| title="Chimney stacks", | |
| base_skeleton="Chimney stack condition.", | |
| has_condition_rating=True, | |
| ) | |
| assert "Defects and Risks" in skeleton | |
| assert "SECTION SCOPE" in skeleton | |
| assert is_introduction_section("E1") is False | |
| assert is_introduction_section("D") is True | |
| # --------------------------------------------------------------------------- # | |
| # Property-type reconciliation | |
| # --------------------------------------------------------------------------- # | |
| def test_property_type_house_converted_to_flats() -> None: | |
| notes = ( | |
| "it's a house, so advise the solicitor on a freehold house. " | |
| "alteration converted to flats, the exact date could not be determined" | |
| ) | |
| resolved = resolve_property_type(notes) | |
| assert resolved["property_type"] == "house converted to flats" | |
| assert "verif" in resolved["tenure_note"].lower() | |
| def test_property_type_house_only() -> None: | |
| assert resolve_property_type("detached house, brick built").get( | |
| "property_type" | |
| ) == "house" | |
| def test_property_type_flat_only() -> None: | |
| assert resolve_property_type("second floor flat, leasehold").get( | |
| "property_type" | |
| ) == "flat" | |
| def test_identity_block_carries_resolved_type_and_shared_context() -> None: | |
| block = _identity_facts_block( | |
| { | |
| "property_type": "house converted to flats", | |
| "tenure_note": "legal tenure should be verified by the solicitor", | |
| } | |
| ) | |
| assert "house converted to flats" in block | |
| # The block must instruct the model NOT to write the two facts as separate | |
| # present-tense truths in one sentence (the contradiction bug). | |
| assert "RESOLVED" in block | |
| assert "Tenure:" in block | |
| # Shared-context rule prevents per-section repetition. | |
| assert "Shared context rule" in block | |
| # --------------------------------------------------------------------------- # | |
| # Surveyor-code vs RICS-code re-labelling | |
| # --------------------------------------------------------------------------- # | |
| async def test_section_mapping_roof_structure_relabelled() -> None: | |
| from app.generator.notes_router import route_notes | |
| # Surveyor typed "E1" but described roof structure; RICS E1 = Chimney | |
| # stacks, roof structure = F1. The router must re-label to F1. | |
| result = await route_notes( | |
| ["E1 roof structure, traditional rafters and purlins, insulation noted"], | |
| survey_level=3, | |
| use_llm=False, | |
| ) | |
| assert "F1" in result.bullets_by_section | |
| assert "E1" not in result.bullets_by_section | |
| async def test_explicit_code_preserved_when_description_matches() -> None: | |
| from app.generator.notes_router import route_notes | |
| # "E1 chimney stack repointing needed" matches RICS E1 — keep it. | |
| result = await route_notes( | |
| ["E1 chimney stack, brick, repointing needed to flaunching"], | |
| survey_level=3, | |
| use_llm=False, | |
| ) | |
| assert "E1" in result.bullets_by_section | |
| # --------------------------------------------------------------------------- # | |
| # Universal quality rules injected into every section prompt | |
| # --------------------------------------------------------------------------- # | |
| def test_quality_rules_present_in_user_prompt() -> None: | |
| prompt = build_user_prompt( | |
| skeleton="E1 — Chimney stacks\n\n[content]", | |
| bullets=["chimney stack is brick", "repointing needed"], | |
| snippets=[], | |
| survey_level=3, | |
| ai_percent=50, | |
| interference_level="medium", | |
| ) | |
| low = prompt.lower() | |
| assert "sentence structure" in low | |
| assert "polarity preservation" in low | |
| assert "boilerplate escalation" in low or "no boilerplate escalation" in low | |
| assert "unverified_term" in low | |
| # --------------------------------------------------------------------------- # | |
| # Unknown-term pre-pass (RC2 prompt 2) | |
| # --------------------------------------------------------------------------- # | |
| def test_unknown_term_flagged() -> None: | |
| from app.generator.term_glossary import preprocess_notes | |
| out = preprocess_notes("the astratin support was noted in the cellar") | |
| assert '[UNVERIFIED_TERM: "astratin"]' in out | |
| # The standalone bare term must not survive unflagged. | |
| assert "astratin support" not in out | |
| def test_approved_surveying_term_not_flagged() -> None: | |
| from app.generator.term_glossary import preprocess_notes | |
| # Legitimate but low-frequency surveying vocabulary must pass through. | |
| out = preprocess_notes("efflorescence and spalling observed to the brickwork") | |
| assert "UNVERIFIED_TERM" not in out | |
| assert "efflorescence" in out | |
| def test_typo_normalisation() -> None: | |
| from app.generator.term_glossary import preprocess_notes | |
| out = preprocess_notes("lather and plaster ceilings show some cracking") | |
| assert "lath and plaster" in out | |
| assert "lather" not in out.lower() | |
| # --------------------------------------------------------------------------- # | |
| # Post-generation audit (RC5 non-destructive flag, RC6 coverage, RC7 involvement) | |
| # --------------------------------------------------------------------------- # | |
| def test_aggregate_ai_involvement_average_and_high_sections() -> None: | |
| from app.generator.report_audit import aggregate_ai_involvement | |
| summary = aggregate_ai_involvement({"D": 8, "E1": 52, "E2": 100, "F1": None}) | |
| # Average of 8, 52, 100 == 53 (None excluded). | |
| assert summary.average_percent == 53 | |
| assert ("E2", 100) in summary.high_sections | |
| assert all(code != "D" for code, _ in summary.high_sections) | |
| def test_detect_misplaced_knotweed_not_in_F() -> None: | |
| from app.generator.report_audit import detect_misplaced_content | |
| flags = detect_misplaced_content( | |
| {"F2": "Japanese knotweed was observed near the boundary."} | |
| ) | |
| assert any(f.section_code == "F2" and f.suggested_section == "I3" for f in flags) | |
| def test_misplacement_not_flagged_when_in_scope() -> None: | |
| from app.generator.report_audit import detect_misplaced_content | |
| flags = detect_misplaced_content( | |
| {"I3": "Japanese knotweed was observed; refer to a specialist."} | |
| ) | |
| assert flags == [] | |
| def test_coverage_check_surfaces_dropped_positive() -> None: | |
| from app.generator.report_audit import coverage_check | |
| gaps = coverage_check( | |
| raw_notes=["taps are functional", "staircase is okay"], | |
| section_texts={"E1": "The chimney stack is of brick construction."}, | |
| ) | |
| joined = " ".join(g.claim.lower() for g in gaps) | |
| assert "taps" in joined | |
| assert "staircase" in joined | |