"""Guard tests for block-level context inheritance + the routing tier lock. These pin the *structural* contract of how multi-sentence note blocks route — NOT the wording of any particular surveyor's notes. Surveyors pack several sentences (and several elements) into one paragraph; only the first sentence of a block usually carries the section label/code. The parser therefore: * lets a sentence with no signal INHERIT the block's running section, * lets a STRONG signal (explicit code / element label / high-confidence keyword) set or override that running section, * keeps a WEAK general-keyword match from overriding an established section (so generic "repairs and repointing" boilerplate is not dragged to the walls), * always files a HAZARD (I-group) mention in its own section without letting it hijack the surrounding element context. """ from __future__ import annotations from backend.core.notes_keyword_router import ( TIER_EXPLICIT, TIER_GENERAL, TIER_HIGH, TIER_LABEL, TIER_NONE, classify_note_cascade, classify_note_cascade_with_tier, ) from backend.core.notes_parser import UNASSIGNED, parse_notes_to_sections from backend.core.rics_canonical_l3 import build_canonical_template_schema def _schema(): return build_canonical_template_schema() def _by_id(notes) -> dict[str, str]: return {n.section_id: n.text.lower() for n in notes} # ── Routing tier contract (router level, schema-independent) ───────────────── def test_tier_explicit_code(): sid, tier, _ = classify_note_cascade_with_tier("F6, drainage chamber lifted.") assert (sid, tier) == ("F6", TIER_EXPLICIT) def test_tier_element_label(): sid, tier, _ = classify_note_cascade_with_tier("Main roof: slipped slates noted.") assert (sid, tier) == ("D2", TIER_LABEL) def test_tier_high_confidence_keyword(): sid, tier, _ = classify_note_cascade_with_tier("Ceilings show some deflection.") assert (sid, tier) == ("E2", TIER_HIGH) def test_tier_none_for_nonsense(): sid, tier, _ = classify_note_cascade_with_tier("xyzzy plugh frobnicator quux") assert (sid, tier) == (UNASSIGNED, TIER_NONE) def test_cascade_backcompat_scores_unchanged(): """The legacy (section, score, body) contract still scores 1.0 / 0.0.""" assert classify_note_cascade("Ceilings show some deflection.")[1] == 1.0 assert classify_note_cascade("xyzzy plugh frobnicator quux")[1] == 0.0 def test_bare_repointing_is_weak_but_wall_repointing_is_strong(): """Unqualified repointing is ambiguous (weak); wall-qualified stays strong.""" sid, tier, _ = classify_note_cascade_with_tier("Repairs and repointing are recommended.") assert (sid, tier) == ("D4", TIER_GENERAL) sid2, tier2, _ = classify_note_cascade_with_tier("Wall repointing is recommended.") assert (sid2, tier2) == ("D4", TIER_HIGH) # ── Block-level context inheritance (parser level, canonical schema) ───────── def test_signal_less_continuation_inherits_label_block(): notes = parse_notes_to_sections( "Chimney stack: brick chimney generally sound. It should be monitored over time.", _schema(), ) by_id = _by_id(notes) assert "monitored over time" in by_id.get("D1", "") def test_weak_keyword_does_not_override_established_context(): """The core fix: generic repointing inside a chimney block stays with D1.""" notes = parse_notes_to_sections( "Chimney stack: brick chimney inspected. Repairs and repointing are recommended.", _schema(), ) by_id = _by_id(notes) assert "repointing" in by_id.get("D1", "") assert "repointing" not in by_id.get("D4", "") def test_strong_keyword_overrides_established_context(): notes = parse_notes_to_sections( "Roof structure: trusses and purlins inspected from the loft. " "Ceilings throughout show some movement.", _schema(), ) by_id = _by_id(notes) assert "trusses" in by_id.get("E1", "") or "purlins" in by_id.get("E1", "") assert "ceilings" in by_id.get("E2", "") def test_explicit_code_mid_block_breaks_context(): notes = parse_notes_to_sections( "E1 roof structure of traditional design with adequate support. " "E3 damp was noted to an internal partition wall.", _schema(), ) by_id = _by_id(notes) assert "roof structure" in by_id.get("E1", "") assert "damp" in by_id.get("E3", "") def test_weak_hazard_files_to_hazard_without_hijacking_context(): """A weak (general-tier) hazard is filed to I3 but does not capture the block: the following signal-less sentence still inherits the element.""" notes = parse_notes_to_sections( "Staircase: timber staircase inspected. The balustrade is a safety concern. " "Otherwise it remains secure.", _schema(), ) by_id = _by_id(notes) assert "staircase" in by_id.get("E7", "") assert "balustrade" in by_id.get("I3", "") assert "remains secure" in by_id.get("E7", "") assert "balustrade" not in by_id.get("E7", "") def test_block_start_weak_keyword_establishes_context(): """When a block opens with only a weak keyword, it still sets the context so signal-less continuations are not lost to UNASSIGNED.""" notes = parse_notes_to_sections( "Some repointing is needed. It should be carried out by a competent contractor.", _schema(), ) by_id = _by_id(notes) assert "repointing" in by_id.get("D4", "") assert "competent contractor" in by_id.get("D4", "") def test_paragraph_boundary_resets_context(): notes = parse_notes_to_sections( "Chimney stack: brick chimney noted. Needs attention in due course.\n\n" "Windows: timber sash units. Some are painted shut.", _schema(), ) by_id = _by_id(notes) assert "needs attention" in by_id.get("D1", "") assert "painted shut" in by_id.get("D5", "") assert "painted shut" not in by_id.get("D1", "") def test_signal_less_block_with_no_context_stays_unassigned(): notes = parse_notes_to_sections("Xyzzy plugh frobnicator quux.", _schema()) assert any(n.section_id == UNASSIGNED for n in notes)