File size: 6,228 Bytes
aad7814
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
"""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)