"""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 # --------------------------------------------------------------------------- # @pytest.mark.asyncio 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 @pytest.mark.asyncio 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