File size: 9,733 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
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
"""Verification suite for the property-type guard on a leasehold flat and for the
Tier-2 NOTES_ONLY fallback's review boundary.

Goals:
  * Confirm the guard does NOT over-filter legitimate flat/leasehold boilerplate
    (a false negative would silently tombstone valid content).
  * Confirm the same content is correctly blocked for a house (directionality /
    anti-contamination) β€” the original bug.
  * Confirm NOTES_ONLY stays flagged for review across every surfacing path and
    is never silently promoted to delivered ("OK") output.
"""

from __future__ import annotations

import io
import zipfile

from docx import Document

from backend.core import ingest, section_mapper, template_discoverer
from backend.core.compat_adapter import section_to_payload
from backend.core.property_blocklist import filter_hits_by_property, is_paragraph_compatible
from backend.core.report_assembler import to_docx, to_preview
from backend.models.report import GeneratedSection, ReportResult
from backend.models.schema import SectionDefinition, TemplateSchema
from backend.config import settings


# Representative legitimate leasehold-flat vocabulary. NONE of these may be
# filtered for a flat β€” each is a false-negative regression if dropped.
_LEGIT_FLAT_BOILERPLATE = [
    "The communal areas and common parts are maintained by the managing agent.",
    "Leaseholders contribute to repairs through the annual service charge.",
    "The management company is responsible for the building's shared entrance.",
    "A PEEP should be prepared for any occupier requiring assisted evacuation.",
    "The fire strategy for the block is currently a stay put policy.",
    "Should the strategy change to simultaneous evacuation, alarms would be required.",
    "Ground rent is payable to the freeholder under the terms of the lease.",
    "The demised premises exclude the structural walls and communal stairwell.",
    "Access to the flat entrance is via a shared, carpeted communal hallway.",
    "A passenger lift serves all floors and is covered by the service charge.",
]


def _flat_ctx() -> dict:
    return {"property_type": "second-floor leasehold flat", "tenure": "leasehold"}


class _Hit:
    """Minimal stand-in for a SearchHit (guard only reads ``.text``)."""

    def __init__(self, text: str) -> None:
        self.text = text


# ─────────────────────────── Property guard: flat ───────────────────────────
def test_flat_guard_keeps_all_legitimate_leasehold_boilerplate():
    ctx = _flat_ctx()
    for paragraph in _LEGIT_FLAT_BOILERPLATE:
        assert is_paragraph_compatible(paragraph, ctx) is True, (
            f"false negative: legitimate flat paragraph wrongly blocked: {paragraph!r}"
        )


def test_flat_guard_filter_hits_drops_nothing_legitimate():
    ctx = _flat_ctx()
    hits = [_Hit(p) for p in _LEGIT_FLAT_BOILERPLATE]
    kept = filter_hits_by_property(hits, ctx)
    assert len(kept) == len(hits)


def test_flat_guard_still_blocks_house_only_features():
    # A flat must not inherit standalone-house features.
    ctx = _flat_ctx()
    for paragraph in (
        "The property benefits from an integral garage accessed from the hall.",
        "There is an own front driveway providing off-street parking.",
        "The rear garden boundary fence is in poor condition.",
    ):
        assert is_paragraph_compatible(paragraph, ctx) is False


# ───────────────────── End-to-end: flat keeps, house blocks ─────────────────
def _seed_roof_reference(tenant: str, tmp_path) -> None:
    tpl = tmp_path / "template.docx"
    doc = Document()
    doc.add_paragraph("D2 Roof coverings")
    doc.save(str(tpl))

    past = tmp_path / "past.docx"
    ref = Document()
    ref.add_paragraph("D2 Roof coverings")
    ref.add_paragraph(
        "The main roof covering is maintained by the managing agent as part of the "
        "communal areas demised under the lease; leaseholders contribute to repairs "
        "via the service charge."
    )
    ref.save(str(past))

    ingest.ingest_report_template(tenant, tpl)
    ingest.ingest_reference(tenant, past)


def test_flat_e2e_retains_communal_baseline(tmp_path, monkeypatch):
    monkeypatch.setattr(settings, "data_dir", str(tmp_path / "data"))
    tenant = "flat-keep"
    _seed_roof_reference(tenant, tmp_path)

    result = section_mapper.generate_report(
        tenant,
        "The roof covering shows minor surface wear.",
        property_type="second-floor leasehold flat",
        tenure="leasehold",
        interference_level="minimum",
    )
    d2 = next(s for s in result.sections if s.section_id == "D2")
    # No over-filtering: the communal baseline survived for a flat.
    assert d2.status == "OK"
    assert "communal" in d2.text.lower() or "managing agent" in d2.text.lower()


def test_house_e2e_blocks_flat_baseline_no_contamination(tmp_path, monkeypatch):
    monkeypatch.setattr(settings, "data_dir", str(tmp_path / "data"))
    tenant = "house-block"
    _seed_roof_reference(tenant, tmp_path)

    result = section_mapper.generate_report(
        tenant,
        "The roof covering shows minor surface wear.",
        property_type="mid-terraced house",
        tenure="freehold",
        interference_level="minimum",
    )
    d2 = next(s for s in result.sections if s.section_id == "D2")
    # The flat-only baseline must be blocked for a house: no communal contamination.
    assert "communal" not in d2.text.lower()
    assert "managing agent" not in d2.text.lower()
    assert "service charge" not in d2.text.lower()
    # The guard left no compatible baseline, so the section is flagged, not OK.
    assert d2.status != "OK"


# ─────────────────────── NOTES_ONLY review boundary ─────────────────────────
def _two_section_schema() -> TemplateSchema:
    return TemplateSchema(
        version=1,
        sections=[
            SectionDefinition(id="D1", title="Chimney stacks", order=0, level=2),
            SectionDefinition(id="D2", title="Roof coverings", order=1, level=2),
        ],
    )


def _result_with_notes_only() -> ReportResult:
    return ReportResult(
        tenant_id="t",
        schema_version=1,
        property_type="mid-terraced house",
        tenure="freehold",
        sections=[
            GeneratedSection(
                section_id="D1",
                title="Chimney stacks",
                text="The chimney stack mortar is sound. Condition rating 1.",
                status="OK",
                grounding_passed=True,
            ),
            GeneratedSection(
                section_id="D2",
                title="Roof coverings",
                text="Welsh slate roof; north slope has slipped tiles.",
                status="NOTES_ONLY",
                grounding_passed=False,
                notes="Authored from surveyor notes β€” requires surveyor review.",
            ),
        ],
    )


def test_notes_only_counts_as_review_not_mapped():
    preview = to_preview(_result_with_notes_only(), _two_section_schema())
    assert preview["sections_mapped"] == 1  # only the OK section
    assert preview["sections_needing_review"] == 1  # the NOTES_ONLY section
    d2 = next(s for s in preview["sections"] if s["section_id"] == "D2")
    assert d2["status"] == "NOTES_ONLY"
    assert d2["grounding_passed"] is False


def test_notes_only_payload_confidence_is_not_delivered_grade():
    section = _result_with_notes_only().sections[1]
    payload = section_to_payload(section, interference_level="minimum", mode="generate")
    # Delivered/OK sections score 0.92; NOTES_ONLY must never reach that grade.
    assert payload["confidence"] < 0.92
    assert payload["status"] == "NOTES_ONLY"
    assert payload["grounding_passed"] is False


def test_notes_only_text_is_preserved_in_docx_but_section_is_flagged():
    schema = _two_section_schema()
    result = _result_with_notes_only()
    docx_bytes = to_docx(result, schema)
    with zipfile.ZipFile(io.BytesIO(docx_bytes)) as zf:
        xml = zf.read("word/document.xml").decode("utf-8")
    # The surveyor's own notes are not dropped (it is their content)...
    assert "welsh slate" in xml.lower()
    # ...but the section remains flagged for review in the structured surfaces.
    preview = to_preview(result, schema)
    assert preview["sections_needing_review"] >= 1


def test_notes_only_e2e_stays_notes_only_with_no_reference(tmp_path, monkeypatch):
    monkeypatch.setattr(settings, "data_dir", str(tmp_path / "data"))
    tenant = "notes-only-e2e"

    tpl = tmp_path / "template.docx"
    doc = Document()
    doc.add_paragraph("D2 Roof coverings")
    doc.save(str(tpl))
    ingest.ingest_report_template(tenant, tpl)
    # Deliberately ingest NO reference report β†’ retrieval is empty for every section.

    result = section_mapper.generate_report(
        tenant,
        "The roof covering has several slipped slate tiles on the north slope.",
        property_type="mid-terraced house",
        tenure="freehold",
        interference_level="minimum",
    )
    d2 = next(s for s in result.sections if s.section_id == "D2")
    # Authored from notes (no baseline) β†’ flagged, never silently delivered.
    assert d2.status == "NOTES_ONLY"
    assert d2.grounding_passed is False
    assert "slate" in d2.text.lower()
    preview = to_preview(result, template_discoverer.load_schema(tenant))
    assert d2.section_id not in {
        s["section_id"] for s in preview["sections"] if s["status"] == "OK"
    }