File size: 15,026 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
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
#!/usr/bin/env python3
"""Live integration smoke tests for Vision -> Mapping -> Grounding pipeline.

Run from the backend package root::

    cd backend
    python scripts/test_pipeline_live.py

Requires OPENAI_API_KEY in the environment (or .env at repo root).
"""

from __future__ import annotations

import io
import logging
import os
import sys
import tempfile
import traceback
from pathlib import Path
from typing import Any
from unittest.mock import patch

# ── Path bootstrap (backend/scripts -> repo root) ─────────────────────────────
REPO_ROOT = Path(__file__).resolve().parents[2]
if str(REPO_ROOT) not in sys.path:
    sys.path.insert(0, str(REPO_ROOT))

# Prefer live key from the shell or repo .env before importing cached settings.
def _load_dotenv_file(path: Path) -> None:
    if not path.is_file():
        return
    for line in path.read_text(encoding="utf-8").splitlines():
        stripped = line.strip()
        if not stripped or stripped.startswith("#") or "=" not in stripped:
            continue
        key, _, value = stripped.partition("=")
        key = key.strip()
        value = value.strip().strip('"').strip("'")
        if key and key not in os.environ:
            os.environ[key] = value


_load_dotenv_file(REPO_ROOT / ".env")
_load_dotenv_file(REPO_ROOT / "backend" / ".env")

_env_key = (os.environ.get("OPENAI_API_KEY") or "").strip()
if _env_key:
    os.environ["OPENAI_API_KEY"] = _env_key

from backend.config import get_settings, settings  # noqa: E402

if _env_key:
    get_settings.cache_clear()
    settings.openai_api_key = _env_key

from docx import Document  # noqa: E402
from PIL import Image  # noqa: E402

from backend.core import ingest, photo_store, section_mapper  # noqa: E402
from backend.core import template_discoverer  # noqa: E402
from backend.llm import openai_client  # noqa: E402
from backend.llm.openai_client import _parse_llm_json  # noqa: E402

# ── Console helpers ─────────────────────────────────────────────────────────
GREEN = "\033[92m"
RED = "\033[91m"
YELLOW = "\033[93m"
CYAN = "\033[96m"
RESET = "\033[0m"

_log_handler: logging.Handler | None = None
_captured_logs: list[str] = []


def _pass(msg: str) -> None:
    print(f"{GREEN}[PASS]{RESET} {msg}")


def _fail(msg: str) -> None:
    print(f"{RED}[FAIL]{RESET} {msg}")


def _info(msg: str) -> None:
    print(f"{CYAN}[INFO]{RESET} {msg}")


def _warn(msg: str) -> None:
    print(f"{YELLOW}[WARN]{RESET} {msg}")


def _section(title: str) -> None:
    print(f"\n{'=' * 72}\n{title}\n{'=' * 72}")


def _install_log_capture() -> None:
    global _log_handler, _captured_logs
    _captured_logs = []

    class _ListHandler(logging.Handler):
        def emit(self, record: logging.LogRecord) -> None:
            _captured_logs.append(record.getMessage())

    _log_handler = _ListHandler()
    _log_handler.setLevel(logging.INFO)
    logging.getLogger("backend.core.section_mapper").addHandler(_log_handler)


def _remove_log_capture() -> None:
    global _log_handler
    if _log_handler is not None:
        logging.getLogger("backend.core.section_mapper").removeHandler(_log_handler)
        _log_handler = None


def _log_contains(fragment: str) -> bool:
    return any(fragment in line for line in _captured_logs)


def _tiny_jpeg() -> bytes:
    buf = io.BytesIO()
    Image.new("RGB", (64, 64), color=(180, 180, 180)).save(buf, format="JPEG")
    return buf.getvalue()


def _write_fixture_docx(tmp: Path) -> tuple[Path, Path]:
    """Template + reference bundle with a known D2 baseline paragraph."""
    tpl = tmp / "template.docx"
    doc = Document()
    doc.add_paragraph("D2 Roof coverings")
    doc.save(str(tpl))

    past = tmp / "past.docx"
    doc2 = Document()
    doc2.add_paragraph("Section D2 - Roof Coverings")
    doc2.add_paragraph(
        "The roof covering comprised slate tiles laid to pitched timber rafters. "
        "Inspection was limited to ground level using binoculars."
    )
    doc2.save(str(past))
    return tpl, past


def _bootstrap_tenant(tmp: Path, tenant: str) -> str:
    """Ingest template + reference; return draft id with one selected D2 photo."""
    tpl, past = _write_fixture_docx(tmp)
    ingest.ingest_report_template(tenant, tpl)
    ingest.ingest_reference(tenant, past)
    template_discoverer.ensure_canonical_schema(tenant)

    draft = f"live-draft-{tenant}"
    row = photo_store.add_section_photo(
        tenant,
        draft,
        "D2",
        _tiny_jpeg(),
        content_type="image/jpeg",
    )
    photo_store.set_ai_selection(tenant, draft, "D2", [row.id])
    return draft


def _d2_section(result: Any) -> Any:
    for section in result.sections:
        if section.section_id.upper() == "D2":
            return section
    raise AssertionError("D2 section missing from report result")


def _guard_live_llm() -> bool:
    key = (os.environ.get("OPENAI_API_KEY") or settings.openai_api_key or "").strip()
    if not key:
        _fail("OPENAI_API_KEY is not set β€” export it or add it to .env at repo root.")
        return False
    settings.openai_api_key = key
    if not openai_client.is_available():
        _fail("openai_client.is_available() is False after loading OPENAI_API_KEY.")
        return False
    _info(f"OpenAI client ready (key prefix: {key[:7]}…).")
    return True


def test_case_a_pristine_passthrough(tmp: Path) -> bool:
    """Empty notes + zero photo observations β†’ baseline passthrough, no auditor LLM."""
    _section("TEST CASE A β€” THE PRISTINE PASSTHROUGH")
    tenant = "live-passthrough"
    draft = _bootstrap_tenant(tmp, tenant)
    baseline_snippet = "binoculars"

    stabilize_calls = 0
    chat_text_calls = 0
    chat_json_async_calls = 0

    real_stabilize = section_mapper.stabilize_section
    real_chat_text = openai_client.chat_text
    real_chat_json_async = openai_client.chat_json_async

    async def _counting_stabilize(*args: Any, **kwargs: Any) -> Any:
        nonlocal stabilize_calls
        stabilize_calls += 1
        return await real_stabilize(*args, **kwargs)

    def _counting_chat_text(*args: Any, **kwargs: Any) -> str:
        nonlocal chat_text_calls
        chat_text_calls += 1
        return real_chat_text(*args, **kwargs)

    async def _counting_chat_json_async(*args: Any, **kwargs: Any) -> dict:
        nonlocal chat_json_async_calls
        chat_json_async_calls += 1
        return await real_chat_json_async(*args, **kwargs)

    try:
        _install_log_capture()
        with patch(
            "backend.core.section_mapper.vision_observations_for_section",
            return_value=([], None),
        ):
            with patch.object(section_mapper, "stabilize_section", side_effect=_counting_stabilize):
                with patch.object(openai_client, "chat_text", side_effect=_counting_chat_text):
                    with patch.object(
                        openai_client,
                        "chat_json_async",
                        side_effect=_counting_chat_json_async,
                    ):
                        result = section_mapper.generate_report(
                            tenant,
                            "",
                            interference_level="minimum",
                            report_draft_id=draft,
                            only_section_ids=["D2"],
                        )

        section = _d2_section(result)
        checks: list[tuple[bool, str]] = [
            (section.status == "OK", f"expected status OK, got {section.status!r}"),
            (
                baseline_snippet in section.text.lower(),
                "section text should retain retrieved baseline prose",
            ),
            (
                section.grounding_passed is True,
                f"expected grounding_passed=True, got {section.grounding_passed!r}",
            ),
            (
                stabilize_calls == 0,
                f"stabilize_section must not run (calls={stabilize_calls})",
            ),
            (
                chat_json_async_calls == 0,
                f"grounding auditor chat_json_async must not run (calls={chat_json_async_calls})",
            ),
            (
                _log_contains("baseline_passthrough_no_observations"),
                "expected baseline_passthrough_no_observations log line",
            ),
        ]

        ok = True
        for passed, detail in checks:
            if passed:
                _pass(detail)
            else:
                _fail(detail)
                ok = False

        if chat_text_calls:
            _warn(
                f"chat_text invoked {chat_text_calls} time(s) "
                "(notes expansion / mapping should be skipped for empty observations)."
            )
        else:
            _pass("no chat_text mapping calls during passthrough")

        return ok
    except Exception as exc:
        _fail(f"unexpected error: {exc}")
        traceback.print_exc()
        return False
    finally:
        _remove_log_capture()


def test_case_b_obscured_limitations_pass(tmp: Path) -> bool:
    """Limitations-only vision payload β†’ grounding accepts defensive limitation prose."""
    _section("TEST CASE B β€” THE OBSCURED LIMITATIONS PASS")
    tenant = "live-limitations"
    draft = _bootstrap_tenant(tmp, tenant)
    limitation_phrase = "restricted view due to heavy insulation storage"
    vision_note = f"Photo limitations: {limitation_phrase}."

    try:
        with patch(
            "backend.core.section_mapper.vision_observations_for_section",
            return_value=([], vision_note),
        ):
            result = section_mapper.generate_report(
                tenant,
                "",
                interference_level="maximum",
                report_draft_id=draft,
                only_section_ids=["D2"],
            )

        section = _d2_section(result)
        text_lower = section.text.lower()
        checks: list[tuple[bool, str]] = [
            (section.status == "OK", f"expected status OK, got {section.status!r}"),
            (
                section.grounding_passed is True,
                f"expected grounding_passed=True, got {section.grounding_passed!r}",
            ),
            (
                "insulation" in text_lower
                or "restricted" in text_lower
                or "view" in text_lower
                or limitation_phrase.lower() in text_lower,
                "section prose should reflect the photo limitation anchor",
            ),
            (
                vision_note.split("Photo limitations:", 1)[1].strip().lower() in text_lower
                or any(token in text_lower for token in ("insulation", "restricted", "view")),
                "grounding should not strip limitation-defensive language as hallucination",
            ),
        ]

        ok = True
        for passed, detail in checks:
            if passed:
                _pass(detail)
            else:
                _fail(detail)
                ok = False

        _info(f"D2 output excerpt: {section.text[:220]}…")
        return ok
    except Exception as exc:
        _fail(f"unexpected error: {exc}")
        traceback.print_exc()
        return False


def test_case_c_json_fence_sanitization() -> bool:
    """_parse_llm_json strips markdown fences without JSONDecodeError."""
    _section("TEST CASE C β€” JSON FENCE SANITIZATION TRAP")
    samples = [
        '```json\n{"status": "ok"}\n```',
        '```\n{"status": "ok", "count": 1}\n```',
        '{"status": "bare"}',
    ]

    ok = True
    for index, raw in enumerate(samples, start=1):
        try:
            parsed = _parse_llm_json(raw)
            if not isinstance(parsed, dict):
                _fail(f"sample {index}: expected dict, got {type(parsed).__name__}")
                ok = False
                continue
            if parsed.get("status") != "ok" and index < 3:
                _fail(f"sample {index}: unexpected payload {parsed!r}")
                ok = False
                continue
            if index == 3 and parsed != {"status": "bare"}:
                _fail(f"sample {index}: unexpected payload {parsed!r}")
                ok = False
                continue
            _pass(f"sample {index}: fence-safe parse β†’ {parsed!r}")
        except Exception as exc:
            _fail(f"sample {index}: raised {exc!r}")
            traceback.print_exc()
            ok = False

    # Optional live chat_json path: monkey-patch completion to return fenced JSON.
    if openai_client.is_available():
        try:
            fenced_payload = '```json\n{"passed": true, "_audit_summary": "fence trap"}\n```'

            def _fake_create(**kwargs: Any) -> Any:
                class _Msg:
                    content = fenced_payload

                class _Choice:
                    message = _Msg()

                class _Resp:
                    choices = [_Choice()]

                return _Resp()

            with patch.object(openai_client._get_client().chat.completions, "create", _fake_create):
                live = openai_client.chat_json(
                    [{"role": "user", "content": "return json"}],
                    model=settings.grounding_model,
                )
            if live.get("passed") is True:
                _pass("chat_json live wrapper parsed fenced auditor JSON")
            else:
                _fail(f"chat_json live wrapper returned unexpected payload: {live!r}")
                ok = False
        except Exception as exc:
            _fail(f"chat_json fenced integration check failed: {exc}")
            traceback.print_exc()
            ok = False
    else:
        _warn("Skipping live chat_json fence trap (OpenAI unavailable).")

    return ok


def main() -> int:
    print(f"{CYAN}RICS pipeline live integration runner{RESET}")
    print(f"Repo root: {REPO_ROOT}")

    if not _guard_live_llm():
        return 1

    results: dict[str, bool] = {}

    with tempfile.TemporaryDirectory(prefix="rics-live-") as tmp_name:
        tmp = Path(tmp_name)
        settings.data_dir = str(tmp / "data")
        _info(f"Using isolated data_dir={settings.data_dir}")

        results["A_pristine_passthrough"] = test_case_a_pristine_passthrough(tmp)
        results["B_obscured_limitations"] = test_case_b_obscured_limitations_pass(tmp)
        results["C_json_fence_sanitization"] = test_case_c_json_fence_sanitization()

    _section("SUMMARY")
    failed = [name for name, ok in results.items() if not ok]
    for name, ok in results.items():
        if ok:
            _pass(name)
        else:
            _fail(name)

    if failed:
        print(f"\n{RED}{len(failed)} test block(s) failed:{RESET} {', '.join(failed)}")
        return 1

    print(f"\n{GREEN}All pipeline live checks passed.{RESET}")
    return 0


if __name__ == "__main__":
    sys.exit(main())