File size: 9,279 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
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
from __future__ import annotations

import io
import zipfile
from pathlib import Path
from unittest.mock import patch

import pytest
from docx import Document
from fastapi.testclient import TestClient

from backend.config import settings
from backend.core import ingest, section_mapper, template_discoverer
from backend.core.report_assembler import to_docx, to_preview
from backend.core.rag_store import TIER_MASTER, TIER_REFERENCE, get_rag_store
from backend.models.schema import RatingSystem, RatingValue


def _ingest_bundle(tenant: str, tpl: Path, paras: Path) -> None:
    ingest.ingest_report_template(tenant, tpl)
    ingest.ingest_standard_paragraphs(tenant, paras)
    ingest.ingest_reference(tenant, paras)


def _write_canonical_bundle(tmp_path: Path) -> tuple[Path, Path]:
    tpl = tmp_path / "template.docx"
    doc = Document()
    doc.add_paragraph("RICS Home Survey Level 3")
    doc.save(str(tpl))

    paras = tmp_path / "paragraphs.docx"
    doc2 = Document()
    doc2.add_paragraph("Section D1 - Chimney stacks")
    doc2.add_paragraph("The chimney stacks were inspected from ground level.")
    doc2.add_paragraph("Section D2 - Roof coverings")
    doc2.add_paragraph("The roof covering comprises slate laid to pitched rafters.")
    doc2.save(str(paras))
    return tpl, paras


def _docx_coloured_runs(data: bytes) -> list[str]:
    """Return 6-char hex colour values from DOCX run properties."""
    colours: list[str] = []
    with zipfile.ZipFile(io.BytesIO(data)) as zf:
        xml = zf.read("word/document.xml").decode("utf-8")
    for fragment in xml.split("<w:color"):
        if 'w:val="' not in fragment:
            continue
        start = fragment.find('w:val="') + len('w:val="')
        end = fragment.find('"', start)
        val = fragment[start:end]
        if len(val) == 6 and all(c in "0123456789ABCDEFabcdef" for c in val):
            colours.append(val.upper())
    return colours


def _docx_bold_runs(data: bytes) -> int:
    with zipfile.ZipFile(io.BytesIO(data)) as zf:
        xml = zf.read("word/document.xml").decode("utf-8")
    return xml.count("<w:b/>") + xml.count("<w:b ")


def test_e2e_rating_docx_only_on_has_rating_field(tmp_path, monkeypatch):
    monkeypatch.setattr(settings, "data_dir", str(tmp_path / "data"))
    tpl, paras = _write_canonical_bundle(tmp_path)
    tenant = "t-rated"
    _ingest_bundle(tenant, tpl, paras)

    schema = template_discoverer.load_schema(tenant)
    schema.rating_system = RatingSystem(
        detected=True,
        name="Defect Grade",
        format_template="Defect Grade [VALUE]",
        values=[
            RatingValue(value="1", colour="00B050"),
            RatingValue(value="2", colour="ED7D31"),
        ],
    )
    for sec in schema.sections:
        sec.has_rating_field = sec.id == "D1"
    template_discoverer.save_schema(tenant, schema)

    result = section_mapper.generate_report(
        tenant,
        "D1: mortar open joints defect grade 2\n\nD2: slate slipped to valley",
        property_type="house",
        tenure="freehold",
        interference_level="maximum",
    )
    for sec in result.sections:
        if sec.section_id == "D1":
            sec.rating_value = "2"
        elif sec.section_id == "D3":
            sec.rating_value = None

    docx = to_docx(result, schema)
    colours = _docx_coloured_runs(docx)
    assert colours, "expected rating colour on rated section"
    assert _docx_bold_runs(docx) >= 1


def test_e2e_no_rating_zero_annotations(tmp_path, monkeypatch):
    monkeypatch.setattr(settings, "data_dir", str(tmp_path / "data"))
    tpl, paras = _write_canonical_bundle(tmp_path)
    tenant = "t-norating"
    _ingest_bundle(tenant, tpl, paras)

    schema = template_discoverer.load_schema(tenant)
    schema.rating_system = RatingSystem(detected=False)
    template_discoverer.save_schema(tenant, schema)

    result = section_mapper.generate_report(
        tenant,
        "D1: mortar open joints rating 2\n\nD2: slate slipped",
        property_type="house",
        tenure="freehold",
        interference_level="maximum",
    )
    docx = to_docx(result, schema)
    assert not _docx_coloured_runs(docx)
    assert b"Defect Grade" not in docx


def test_e2e_new_firm_onboarding_fixture(tmp_path, monkeypatch):
    monkeypatch.setattr(settings, "data_dir", str(tmp_path / "data"))
    tpl, paras = _write_canonical_bundle(tmp_path)
    tenant = "new-firm"
    _ingest_bundle(tenant, tpl, paras)

    schema = template_discoverer.load_schema(tenant)
    assert schema.additional_metadata.get("parent_section_count") == 14
    assert "D1" in schema.section_ids()

    result = section_mapper.generate_report(
        tenant, "chimney stack brick spalling noted", interference_level="maximum"
    )
    ids = {s.section_id for s in result.sections if s.status != "empty"}
    assert "D1" in ids


def test_e2e_grounding_adversarial_unmatched(monkeypatch, tmp_path):
    monkeypatch.setattr(settings, "data_dir", str(tmp_path / "data"))
    monkeypatch.setattr(settings, "openai_api_key", "sk-test")

    tpl, paras = _write_canonical_bundle(tmp_path)
    tenant = "t-adv"
    _ingest_bundle(tenant, tpl, paras)

    invented = (
        "The chimney was inspected.\n"
        "[UNMATCHED_OBSERVATION: completely invented asbestos contamination]"
    )

    with patch("backend.core.reference_mapper.openai_client.is_available", return_value=True), patch(
        "backend.core.reference_mapper.openai_client.chat_text",
        return_value=invented,
    ):
        result = section_mapper.generate_report(
            tenant, "D1: chimney hairline crack", interference_level="maximum"
        )

    d1 = next(s for s in result.sections if s.section_id == "D1")
    assert "asbestos" not in d1.text.lower()


def test_e2e_pii_adversarial_name_and_postcode(tmp_path, monkeypatch):
    monkeypatch.setattr(settings, "data_dir", str(tmp_path / "data"))
    tpl, paras = _write_canonical_bundle(tmp_path)
    tenant = "t-pii-adv"
    _ingest_bundle(tenant, tpl, paras)

    notes = "D1: crack near stack\nClient Jane Smith at SW1A 1AA"
    result = section_mapper.generate_report(
        tenant, notes, property_type="flat", tenure="leasehold", interference_level="maximum"
    )
    schema = template_discoverer.load_schema(tenant)
    docx = to_docx(result, schema).decode("latin-1", errors="ignore").lower()
    assert "sw1a" not in docx
    assert "jane smith" not in docx


def test_empty_faiss_returns_400(client, configured_master):
    get_rag_store().clear_tier(settings.default_tenant_id, TIER_REFERENCE)
    r = client.post(
        "/auth/register",
        json={"tenant_id": settings.default_tenant_id, "password": "secret123"},
    )
    token = r.json()["access_token"]
    r = client.post(
        "/api/report/generate",
        json={
            "raw_notes": "roof slate slipped",
            "property_type": "house",
            "tenure": "freehold",
        },
        headers={"Authorization": f"Bearer {token}"},
    )
    assert r.status_code == 400
    assert "ingested" in r.json()["detail"].lower() or "past reports" in r.json()["detail"].lower()


def test_grounding_alert_threshold(client, configured_master, monkeypatch):
    monkeypatch.setattr(settings, "grounding_alert_threshold", 0.0)
    r = client.post(
        "/auth/register",
        json={"tenant_id": settings.default_tenant_id, "password": "secret456"},
    )
    token = r.json()["access_token"]
    r = client.post(
        "/api/report/preview",
        json={
            "raw_notes": "chimney cracked\n\nroof slate",
            "property_type": "house",
            "tenure": "freehold",
        },
        headers={"Authorization": f"Bearer {token}"},
    )
    assert r.status_code == 200
    body = r.json()
    assert "manual_review_required" in body
    assert "review_ratio" in body


def test_reference_upload_http(client, configured_master, tmp_path):
    ref = tmp_path / "past.docx"
    doc = Document()
    doc.add_paragraph("Past report boilerplate about roof coverings at a generic property.")
    doc.save(str(ref))

    r = client.post(
        "/auth/register",
        json={"tenant_id": settings.default_tenant_id, "password": "secret789"},
    )
    token = r.json()["access_token"]
    with ref.open("rb") as fh:
        r = client.post(
            "/api/upload/reference",
            files={"file": ("past.docx", fh, "application/vnd.openxmlformats-officedocument.wordprocessingml.document")},
            headers={"Authorization": f"Bearer {token}"},
        )
    assert r.status_code == 201
    assert r.json()["tier"] == "reference"
    assert r.json()["ingested_chunks"] >= 1


def test_schema_roundtrip_additional_metadata(tmp_path, monkeypatch):
    monkeypatch.setattr(settings, "data_dir", str(tmp_path / "data"))
    schema = template_discoverer.install_canonical_schema("t-meta")
    schema.additional_metadata = {**schema.additional_metadata, "firm": "Acme Surveys"}
    template_discoverer.save_schema("t-meta", schema)
    loaded = template_discoverer.load_schema("t-meta")
    assert loaded is not None
    assert loaded.tenant_id == "t-meta"
    assert loaded.additional_metadata["firm"] == "Acme Surveys"
    assert loaded.additional_metadata.get("parent_section_count") == 14