"""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