File size: 7,526 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
"""Shared test fixtures.

Tests run fully offline: the FAISS store is backed by a deterministic fake
embedder and no OpenAI key is configured, so mapping/grounding take their
deterministic fallback paths.
"""

from __future__ import annotations

import re
from pathlib import Path

import pytest
from docx import Document

from backend.config import settings
from backend.core import embeddings, notes_parser, pii_scrubber, rag_store
from backend.core.rics_canonical_l3 import build_section_anchor_text, iter_canonical_leaf_sections

_ANCHOR_OVERLAP_STOP = frozenset({
    "about", "elements", "other", "property", "section", "inside", "outside",
    "survey", "rics", "level", "typical", "matters", "engagement", "terms",
    "looked", "overall", "fine", "noted", "specific", "defects", "general",
    "inspection", "opinion", "declaration", "diagram", "recommendations",
    "the", "and", "not", "with", "for", "are", "was", "from", "this", "that",
    "have", "has", "were", "its", "any", "all", "may", "can", "your", "our",
})


class AnchorSemanticFakeEmbedder(embeddings.FakeEmbedder):
    """Deterministic embedder that approximates anchor similarity for offline tests."""

    def __init__(self, dim: int = 384) -> None:
        super().__init__(dim)
        self._anchor_texts: dict[str, str] = {}
        self._anchor_vecs: dict[str, list[float]] = {}

    def register_anchors(self, anchors: dict[str, str]) -> None:
        self._anchor_texts = dict(anchors)
        self._anchor_vecs = {sid: super()._vec(text) for sid, text in anchors.items()}

    @staticmethod
    def _token_set(text: str) -> set[str]:
        return {
            w
            for w in re.findall(r"[a-z0-9]{3,}", (text or "").lower())
            if w not in _ANCHOR_OVERLAP_STOP
        }

    def _vec(self, text: str) -> list[float]:
        base = super()._vec(text)
        if not self._anchor_texts:
            return base
        words = self._token_set(text)
        best_sid: str | None = None
        best_overlap = 0
        for sid, anchor in self._anchor_texts.items():
            overlap = len(words & self._token_set(anchor))
            if overlap > best_overlap:
                best_overlap = overlap
                best_sid = sid
        if best_sid and best_overlap > 0:
            anchor_vec = self._anchor_vecs[best_sid]
            alpha = min(0.95, 0.55 + 0.12 * best_overlap)
            blended = [alpha * a + (1.0 - alpha) * b for a, b in zip(anchor_vec, base)]
            norm = sum(x * x for x in blended) ** 0.5 or 1.0
            return [x / norm for x in blended]
        return base


@pytest.fixture(autouse=True)
def isolate(tmp_path, monkeypatch):
    monkeypatch.setattr(settings, "data_dir", str(tmp_path / "data"))
    monkeypatch.setattr(settings, "openai_api_key", "")
    monkeypatch.setattr(settings, "pii_use_spacy", False)
    monkeypatch.setattr(settings, "reference_auto_ingest_enabled", False)

    fake = AnchorSemanticFakeEmbedder()
    anchors = {
        sid: build_section_anchor_text(sid)
        for sid, _, _, _ in iter_canonical_leaf_sections()
    }
    fake.register_anchors(anchors)
    monkeypatch.setattr(embeddings, "get_embedder", lambda: fake)
    monkeypatch.setattr(rag_store, "get_embedder", lambda: fake)

    embeddings.reset_embedder()
    rag_store.reset_rag_store()
    pii_scrubber.reset_nlp_cache()
    notes_parser.reset_section_anchors()
    notes_parser.initialize_section_anchors()
    yield
    notes_parser.reset_section_anchors()
    rag_store.reset_rag_store()


def _write_report_template(path: Path) -> Path:
    """Minimal report template: section structure only."""
    doc = Document()
    doc.add_paragraph("A About the inspection")
    doc.add_paragraph("E1 Chimney Stacks")
    doc.add_paragraph("E2 Roof Coverings")
    doc.add_paragraph("G4 Central Heating")
    doc.save(str(path))
    return path


def _write_standard_paragraphs(path: Path, *, with_rating: bool = False) -> Path:
    """Standard paragraphs Word master: headings + boilerplate body."""
    doc = Document()
    doc.add_paragraph("Section E1 - Chimney Stacks")
    body = "The chimney stacks were inspected from ground level using binoculars."
    if with_rating:
        body += " Condition Rating 2 where defects require attention."
    doc.add_paragraph(body)
    doc.add_paragraph("Section E2 - Roof Coverings")
    doc.add_paragraph("The roof covering comprises slate laid to pitched timber rafters.")
    doc.add_paragraph("Section G4 - Central Heating")
    doc.add_paragraph("The property is served by a gas-fired central heating system.")
    doc.save(str(path))
    return path


@pytest.fixture
def report_template_docx(tmp_path) -> Path:
    return _write_report_template(tmp_path / "report_template.docx")


@pytest.fixture
def standard_paragraphs_docx(tmp_path) -> Path:
    return _write_standard_paragraphs(tmp_path / "standard_paragraphs.docx")


@pytest.fixture
def standard_paragraphs_rated_docx(tmp_path) -> Path:
    return _write_standard_paragraphs(tmp_path / "standard_paragraphs_rated.docx", with_rating=True)


# Legacy alias used by template_discoverer unit tests.
@pytest.fixture
def master_docx(standard_paragraphs_docx) -> Path:
    return standard_paragraphs_docx


@pytest.fixture
def master_docx_rated(standard_paragraphs_rated_docx) -> Path:
    return standard_paragraphs_rated_docx


@pytest.fixture
def client(configured_master):
    from backend.main import app
    from fastapi.testclient import TestClient

    with TestClient(app) as c:
        yield c


def _write_past_report(path: Path) -> Path:
    """Simulated uploaded past report for REFERENCE-tier generation."""
    doc = Document()
    doc.add_paragraph("Section E1 - Chimney Stacks")
    doc.add_paragraph(
        "The chimney stacks were inspected from ground level using binoculars. "
        "Inspection was limited to visible external surfaces at the time of survey."
    )
    doc.add_paragraph("Section E2 - Roof Coverings")
    doc.add_paragraph(
        "The roof coverings were inspected from ground level using binoculars and, "
        "where accessible, from within the roof void. The main roof covering "
        "comprises natural slate tiles laid to pitched timber rafters with underfelt "
        "and counter battens."
    )
    doc.add_paragraph("Section G4 - Central Heating")
    doc.add_paragraph(
        "The property was served by a gas-fired central heating system with "
        "radiators to most rooms."
    )
    doc.save(str(path))
    return path


@pytest.fixture
def configured_master(monkeypatch, report_template_docx, standard_paragraphs_docx) -> Path:
    """Point config at a split report template + standard paragraphs bundle.

    This fixture models an operator that explicitly configures AND enables a shared
    master, so it opts into ``master_template_auto_ingest`` (default-off in prod).
    """
    folder = report_template_docx.parent
    _write_past_report(folder / "past_report_sample.docx")
    monkeypatch.setattr(settings, "master_template_dir", str(folder))
    monkeypatch.setattr(settings, "report_template_filename", report_template_docx.name)
    monkeypatch.setattr(settings, "standard_paragraphs_filename", standard_paragraphs_docx.name)
    monkeypatch.setattr(settings, "master_template_filename", standard_paragraphs_docx.name)
    monkeypatch.setattr(settings, "master_template_auto_ingest", True)
    monkeypatch.setattr(settings, "reference_auto_ingest_enabled", True)
    return standard_paragraphs_docx