RICS / app /tests /test_pipeline_quality.py
StormShadow308's picture
Add demo documentation and Docker setup for v2 report generation system
aad7814
Raw
History Blame Contribute Delete
8.93 kB
"""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