File size: 32,492 Bytes
b76f199
 
 
 
 
a1e2ff8
b76f199
da8a68d
b76f199
 
 
da8a68d
 
 
 
b76f199
a1e2ff8
b76f199
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a1e2ff8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
732b14f
a1e2ff8
 
732b14f
a1e2ff8
 
 
 
 
 
 
 
 
 
 
 
 
da8a68d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
751f4aa
 
 
 
da8a68d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62f2173
 
da8a68d
 
751f4aa
62f2173
 
 
da8a68d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62f2173
 
 
 
 
da8a68d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b76f199
 
 
 
 
 
 
 
 
 
 
 
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
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
"""Sanity checks for OpenAI inspector tool definitions (no live API calls)."""

import pytest
from httpx import ASGITransport, AsyncClient

from app.agentic.agents import RiskAssessmentAgent, _risk_priority_key
from app.agentic.inspector_loop import (
    _enforce_verify_submit_payload,
    _openai_inspector_tool_definitions,
    _section_c_requires_rating_summary,
    _validate_condition_rating_summary,
    _verify_audit_payload,
    _verify_condition_payload,
    _verify_plan_payload,
    _verify_risks,
)
from app.agentic.models import RiskItem
from app.agentic.runtime_status import inspector_public_status, is_openai_inspector_live
from app.templates.registry import get_survey_pack


def test_inspector_openai_tools_include_core_names() -> None:
    tools = _openai_inspector_tool_definitions()
    names = [t["function"]["name"] for t in tools]
    assert "retrieve_survey_rag" in names
    assert "retrieve_rics_kb" in names
    assert "scan_note_duplicates" in names
    assert "submit_extraction_audit" in names
    assert "submit_section_plan" in names
    assert "submit_condition_rating_summary" in names
    assert "submit_inspection_section" in names
    assert len(tools) == 7


def test_section_c_rating_summary_gate_depends_on_pack_not_only_level_3() -> None:
    """Level 1 pack still includes rated element sections; Section C should require the summary tool."""
    pack1 = get_survey_pack(1)
    assert _section_c_requires_rating_summary(section_code="C", pack=pack1) is True
    assert _section_c_requires_rating_summary(section_code="E1", pack=pack1) is False


def test_section_c_rating_summary_gate_level_2_and_3_packs() -> None:
    """HomeBuyer (2) and Building Survey (3) packs include rated elements β€” Section C gate applies."""
    for level in (2, 3):
        pack = get_survey_pack(level)
        assert _section_c_requires_rating_summary(section_code="C", pack=pack) is True
        assert _section_c_requires_rating_summary(section_code="A", pack=pack) is False


def test_validate_condition_rating_summary_accepts_markdown_table() -> None:
    table = "| Cat | NI | 3 | 2 | 1 |\n| --- | --- | --- | --- | --- |\n| Roof | 0 | 1 | 2 | 3 |\n"
    err, payload = _validate_condition_rating_summary({"ratings_not_applicable": False, "summary_markdown_table": table})
    assert err == ""
    assert payload is not None
    assert payload["ratings_not_applicable"] is False


def test_inspector_public_status_shape(monkeypatch: pytest.MonkeyPatch) -> None:
    from app.config import settings

    monkeypatch.setattr(settings, "openai_api_key", "")
    monkeypatch.setattr(settings, "inspector_tool_agent", True)
    assert is_openai_inspector_live() is False
    st = inspector_public_status()
    assert st["effective_mode"] == "legacy_pipeline"
    assert st["openai_api_key_configured"] is False
    assert "tool_agent_endpoints" in st

    monkeypatch.setattr(settings, "openai_api_key", "sk-test")
    monkeypatch.setattr(settings, "inspector_tool_agent", True)
    assert is_openai_inspector_live() is True
    assert inspector_public_status()["effective_mode"] == "openai_tool_agent"

    monkeypatch.setattr(settings, "inspector_tool_agent", False)
    assert is_openai_inspector_live() is False


def test_risk_priority_key_sorts_critical_above_low() -> None:
    """Regression: the LLM-based RiskAssessmentAgent prompt allows severity="Critical",
    but the old _risk_priority_key dict only mapped High/Medium/Low and fell back to a
    sort key of 3 β€” sinking Critical *below* Low. That broke `consolidated[:5]` in
    `generate_full_report`, dropping the most severe defects from the executive summary.
    """
    items = [
        RiskItem(category="A", risk="low item", severity="Low", likelihood="Low", action=""),
        RiskItem(category="B", risk="critical item", severity="Critical", likelihood="High", action=""),
        RiskItem(category="C", risk="high item", severity="High", likelihood="Medium", action=""),
        RiskItem(category="D", risk="medium item", severity="Medium", likelihood="Low", action=""),
        RiskItem(category="E", risk="garbage severity", severity="bogus", likelihood="Low", action=""),
    ]
    ordered = sorted(items, key=_risk_priority_key)
    assert [r.severity for r in ordered] == ["Critical", "High", "Medium", "Low", "bogus"], (
        "Critical must sort first; unknown severities must fall to the bottom, never above Low."
    )


def test_risk_priority_key_is_case_insensitive() -> None:
    a = RiskItem(category="x", risk="y", severity="CRITICAL", likelihood="HIGH", action="")
    b = RiskItem(category="x", risk="y", severity="critical", likelihood="high", action="")
    assert _risk_priority_key(a) == _risk_priority_key(b) == (0, 0)


def test_coerce_risk_item_drops_unknown_keys() -> None:
    """Regression: previously `RiskItem(**item)` raised TypeError on any extra
    LLM key (e.g. "description"), and the broad except silently degraded the
    entire batch to keyword heuristics β€” discarding the contextual analysis.
    """
    raw = {
        "category": "Moisture",
        "risk": "Damp ingress",
        "severity": "Medium",
        "likelihood": "Medium",
        "action": "Investigate source.",
        "description": "Visible staining on north wall.",
        "details": {"location": "kitchen"},
        "reasoning": "Based on the photographs.",
    }
    item = RiskAssessmentAgent._coerce_risk_item(raw)
    assert item is not None
    assert item.category == "Moisture"
    assert item.risk == "Damp ingress"
    assert item.severity == "Medium"
    assert item.likelihood == "Medium"
    assert item.action == "Investigate source."


def test_coerce_risk_item_skips_when_required_key_missing() -> None:
    """A single item missing a required key must be skipped, not raise β€” so the
    rest of the batch survives instead of falling back wholesale.
    """
    assert (
        RiskAssessmentAgent._coerce_risk_item(
            {
                "category": "Electrical",
                "risk": "Old wiring",
                "severity": "High",
                "likelihood": "Medium",
                # action intentionally omitted
            }
        )
        is None
    )


def test_coerce_risk_item_rejects_non_dict() -> None:
    assert RiskAssessmentAgent._coerce_risk_item("not a dict") is None
    assert RiskAssessmentAgent._coerce_risk_item(None) is None
    assert RiskAssessmentAgent._coerce_risk_item(["category", "risk"]) is None


def test_coerce_risk_item_coerces_non_string_values_and_strips() -> None:
    item = RiskAssessmentAgent._coerce_risk_item(
        {
            "category": "  Structural  ",
            "risk": "Cracking",
            "severity": "high",
            "likelihood": "Medium",
            "action": None,  # null in JSON β€” must become empty string, not crash
        }
    )
    assert item is not None
    assert item.category == "Structural"
    assert item.action == ""


@pytest.mark.asyncio
async def test_assess_survives_partial_llm_drift(monkeypatch) -> None:
    """End-to-end: when the LLM returns one drifted item alongside three valid
    ones, the assessor returns the three valid items rather than collapsing to
    the keyword fallback.
    """
    monkeypatch.setattr("app.config.settings.openai_api_key", "sk-test")

    drifted_payload = (
        '['
        '  {"category":"Moisture","risk":"Damp","severity":"Medium",'
        '   "likelihood":"Medium","action":"Investigate.","description":"extra"},'
        '  {"category":"Electrical","risk":"Old wiring","severity":"High",'
        '   "likelihood":"Medium"},'  # missing "action" β€” must be skipped, not fatal
        '  {"category":"Structural","risk":"Cracking","severity":"Critical",'
        '   "likelihood":"Medium","action":"Engineer review.","reasoning":"x"},'
        '  {"category":"Gas","risk":"Old boiler","severity":"High",'
        '   "likelihood":"Low","action":"Service.","evidence":"photo-1"}'
        ']'
    )

    async def _fake_chat(**kwargs):
        return drifted_payload

    monkeypatch.setattr("app.llm.openai_chat.chat_completions_create", _fake_chat)

    agent = RiskAssessmentAgent()
    risks = await agent.assess(section_code="E2", bullets=["roof shows damp staining"])

    assert len(risks) == 3, (
        f"expected 3 valid items kept, got {len(risks)} β€” drifted item likely "
        "collapsed the whole batch to keyword fallback"
    )
    categories = {r.category for r in risks}
    assert categories == {"Moisture", "Structural", "Gas"}
    assert {r.severity for r in risks} == {"Medium", "Critical", "High"}


@pytest.mark.asyncio
async def test_enforce_verify_submit_payload_strips_invented_address(monkeypatch) -> None:
    """Regression: the agentic ``submit_inspection_section`` payload bypassed the
    non-invention guard, so invented identity facts ("10 Kingsley Avenue, Sutton",
    "robust steel frame") shipped straight to the user-visible report.

    Verify the helper now runs the regex pass on every field β€” invented postcodes
    and capitalised place-name pairs not present in bullets/snippets must be
    replaced with an approved missing-information phrase. The OpenAI key is set
    to empty so only the cheap regex layer fires (deterministic, no live API).
    """
    monkeypatch.setattr("app.config.settings.openai_api_key", "")

    payload = {
        "executive_summary": "The property at 10 Kingsley Avenue is in fair order.",
        "property_description": "Main walls are constructed of a robust steel frame.",
        "condition_assessment": "Condition rating 3 noted to roof timbers.",
        "defects_and_risks": "Damp staining at SW1A 1AA postcode.",
        "recommendations": "Engage a structural engineer.",
    }
    bullets = [
        "Roof timbers β€” condition rating 3, evidence of damp staining",
        "Recommend structural engineer review",
    ]

    out = await _enforce_verify_submit_payload(
        payload,
        bullets=bullets,
        snippets=[],
        peer_sections=None,
    )

    # Invented address ("Kingsley Avenue") and postcode ("SW1A 1AA") must be
    # stripped; the bullets-grounded "structural engineer" recommendation must
    # survive untouched.
    assert "Kingsley Avenue" not in out["executive_summary"]
    assert "SW1A 1AA" not in out["defects_and_risks"]
    assert "structural engineer" in out["recommendations"].lower()


@pytest.mark.asyncio
async def test_enforce_verify_submit_payload_preserves_grounded_facts() -> None:
    """Facts that DO appear in bullets/snippets must pass through unchanged.

    The regex pass is deliberately conservative β€” only specific facts not found
    verbatim (allowing pluralisation / case differences) get replaced. A
    grounded postcode and a grounded named entity must both survive.
    """
    payload = {
        "executive_summary": "The property at SW1A 1AA was inspected.",
        "property_description": "Hampstead Heath is nearby.",
        "condition_assessment": "",
        "defects_and_risks": "",
        "recommendations": "",
    }
    out = await _enforce_verify_submit_payload(
        payload,
        bullets=["Address: SW1A 1AA", "Property near Hampstead Heath"],
        snippets=[],
        peer_sections=None,
    )

    assert "SW1A 1AA" in out["executive_summary"]
    assert "Hampstead Heath" in out["property_description"]


@pytest.mark.asyncio
async def test_enforce_verify_submit_payload_treats_peer_sections_as_trusted() -> None:
    """Peer-section drafts (the surveyor's own notes for sibling sections) are
    legitimate cross-section context and must be merged into the trusted
    source set, not flagged as invention.
    """
    payload = {
        "executive_summary": "Damp evident throughout the rear elevation.",
        "property_description": "",
        "condition_assessment": "",
        "defects_and_risks": "",
        "recommendations": "",
    }
    out = await _enforce_verify_submit_payload(
        payload,
        bullets=[],  # current section has no bullets
        snippets=[],
        peer_sections={
            "F1": "Damp readings of 22% noted at the rear elevation in section F1.",
        },
    )
    assert "rear elevation" in out["executive_summary"]


def test_verify_audit_payload_strips_invented_postcode_and_name() -> None:
    """Regression: extraction_audit fields ship in `inspector_meta`, so an
    invented surveyor name or postcode there is just as user-visible as the
    five body fields. The regex pass must clean those without an LLM call.
    """
    audit = {
        "property_address": "10 Kingsley Avenue, Sutton, SW1A 1AA",
        "surveyor_name": "Jonathan Pemberton MRICS",
        "rics_number": "Information not provided in source document.",
        "company_name": "Ridge & Partners",
        "inspection_date": "2024-03-12",
        "report_reference": "RPT-001",
        "property_type": "Detached house",
        "construction_details": "Cavity brick wall.",
        "services": "Mains gas, mains water.",
        "client_names": ["Sophia Wright"],
        "observed_defects": ["Damp staining to rear elevation"],
    }
    bullets = [
        "Cavity brick wall construction; mains gas and mains water connected",
        "Damp staining noted at rear elevation",
    ]

    out = _verify_audit_payload(audit, bullets=bullets, snippets=[])
    assert out is not None
    # Invented capitalised entities not in source must be stripped.
    assert "Kingsley Avenue" not in out["property_address"]
    assert "SW1A 1AA" not in out["property_address"]
    assert "Jonathan Pemberton" not in out["surveyor_name"]
    assert "Sophia Wright" not in (out.get("client_names") or [None])[0]
    # Grounded facts survive.
    assert "cavity" in out["construction_details"].lower()
    assert "rear elevation" in out["observed_defects"][0].lower()


def test_verify_plan_payload_cleans_claim_strings() -> None:
    """Section plan claims are LLM narrative β€” must run through the regex pass."""
    plan = {
        "claims": [
            {
                "claim": "Inspection at 10 Kingsley Avenue revealed damp to rear elevation.",
                "evidence_refs": ["bullet_1"],
                "risk": "medium",
            }
        ],
        "non_claims": ["Property valued at Β£999,999 by Acme Surveyors Ltd"],
    }
    bullets = ["Damp staining noted at rear elevation"]

    out = _verify_plan_payload(plan, bullets=bullets, snippets=[])
    assert out is not None
    assert "Kingsley Avenue" not in out["claims"][0]["claim"]
    assert "rear elevation" in out["claims"][0]["claim"].lower()
    # Invented company name in a non_claim is stripped too.
    assert "Acme Surveyors" not in out["non_claims"][0]


def test_verify_condition_payload_cleans_justification_and_table() -> None:
    """Section C summary table + justification can carry invented facts β€”
    e.g. an LLM might cite a postcode or named contractor in the markdown.
    """
    cond = {
        "ratings_not_applicable": False,
        "justification": "",
        "summary_markdown_table": (
            "| Element | NI | 3 | 2 | 1 |\n"
            "| --- | --- | --- | --- | --- |\n"
            "| Roof at 10 Kingsley Avenue | 0 | 1 | 0 | 0 |\n"
        ),
    }
    bullets = ["Roof condition rating 3"]

    out = _verify_condition_payload(cond, bullets=bullets, snippets=[])
    assert out is not None
    assert "Kingsley Avenue" not in out["summary_markdown_table"]
    # Genuine table tokens (NI, 3, 2, 1) must survive.
    assert "NI" in out["summary_markdown_table"]


def test_verify_risks_strips_invented_firm_in_action() -> None:
    """The LLM RiskAssessmentAgent emits {risk, action} strings β€” both can
    invent named contractors or postcodes. Regex pass must clean both.
    """
    risks = [
        RiskItem(
            category="Structural",
            risk="Movement evident at flank wall",
            severity="High",
            likelihood="Medium",
            action="Engage Acme Structural Engineers Ltd of Sutton SW1A 1AA.",
        ),
    ]
    bullets = ["Crack to flank wall β€” recommend structural engineer review"]

    verified = _verify_risks(risks, bullets=bullets, snippets=[])
    assert len(verified) == 1
    r = verified[0]
    # Real category + severity untouched (constrained enum values).
    assert r.category == "Structural"
    assert r.severity == "High"
    # Invented firm name + invented postcode stripped from `action`.
    assert "Acme Structural Engineers" not in r.action
    assert "SW1A 1AA" not in r.action
    # Grounded claim survives.
    assert "flank wall" in r.risk.lower()


def test_inspector_system_prompt_demands_depth_not_summary() -> None:
    """The user reported the agentic output read like a one-paragraph summary.
    Root cause: the system prompt told the LLM to ground facts but never to
    produce *detailed* analysis, and gpt-4o-mini defaulted to terse output.
    Lock the anti-summarisation directive AND the per-tier depth directive
    into the prompt so a future edit can't silently regress either.
    """
    from app.agentic.inspector_loop import _inspector_system_prompt
    from app.templates.registry import get_survey_pack

    prompt = _inspector_system_prompt(
        tenant_id="t1",
        primary_document_id="d1",
        survey_level=3,
        pack=get_survey_pack(3),
        section_code="E1",
        section_title="Roof structure",
        style_profile=None,
    )
    lower = prompt.lower()
    assert "not summaries" in lower or "not a summary" in lower
    assert "comprehensive" in lower
    # L3 depth directive: cause / implications / options layering.
    assert "diagnostic mode" in lower
    assert "cause" in lower and "implications" in lower and "options" in lower
    # Per-tier word targets must be present in the prompt. We calibrate these
    # per-section (not whole-report) so L3 condition_assessment is a smaller
    # band than the prior Option-B "very long per section" configuration.
    assert "60–120" in prompt or "60-120" in prompt


def test_inspector_system_prompt_l1_not_directive() -> None:
    """Level 1 condition reports must not be told to advise/recommend repairs."""
    from app.agentic.inspector_loop import _inspector_system_prompt
    from app.templates.registry import get_survey_pack

    prompt = _inspector_system_prompt(
        tenant_id="t1",
        primary_document_id="d1",
        survey_level=1,
        pack=get_survey_pack(1),
        section_code="C",
        section_title="Overall assessment",
        style_profile=None,
    )
    lower = prompt.lower()
    # L1 directive must call out observation-only mode.
    assert "observation mode" in lower or "do not advise" in lower
    # L3-only language must NOT leak into L1.
    assert "diagnostic mode" not in lower


def test_inspector_system_prompt_pins_identity_block() -> None:
    """Identity facts (extracted from the surveyor's bullets/uploaded survey)
    must be carried into the inspector system prompt as NON-NEGOTIABLE so the
    LLM can never invent a different address/postcode/property type."""
    from app.agentic.inspector_loop import _inspector_system_prompt
    from app.templates.registry import get_survey_pack

    identity_block = (
        "- Address: 2 Cunliffe Road, Epsom (use exactly this; do not introduce any other address)\n"
        "- Postcode: KT19 0RL (use exactly this; do not invent postcodes)"
    )
    prompt = _inspector_system_prompt(
        tenant_id="t1",
        primary_document_id="d1",
        survey_level=3,
        pack=get_survey_pack(3),
        section_code="A",
        section_title="Introduction",
        style_profile=None,
        identity_block=identity_block,
    )
    assert "2 Cunliffe Road" in prompt
    assert "KT19 0RL" in prompt
    assert "non-negotiable" in prompt.lower()


def test_inspector_submit_schema_includes_per_field_word_targets() -> None:
    """The submit schema's field descriptions must carry length guidance,
    otherwise the LLM treats each field as "as short as possible". The
    schema now defers exact ranges to the per-tier system prompt
    word_targets, so we assert on the deferral language rather than on
    a hard-coded number that's wrong for two of the three tiers.
    """
    tools = _openai_inspector_tool_definitions()
    submit = next(
        t for t in tools if t["function"]["name"] == "submit_inspection_section"
    )
    props = submit["function"]["parameters"]["properties"]
    for field in (
        "executive_summary",
        "property_description",
        "condition_assessment",
        "defects_and_risks",
        "recommendations",
    ):
        desc = props[field].get("description", "").lower()
        assert "word" in desc, f"{field} description missing word target"
        assert "tier" in desc, (
            f"{field} description must defer length to tier (otherwise L3 ranges "
            f"baked here will misguide L1/L2 generations)"
        )


def test_inspector_submit_schema_no_baked_l3_ranges() -> None:
    """Schema field descriptions must NOT bake in concrete L3 word ranges
    like "200-500 words" β€” those are correct for L3 but cause the model to
    over-generate at L1 (40-90 word target) and approach L3 length at L2.
    The per-tier system prompt is the single source of truth.
    """
    tools = _openai_inspector_tool_definitions()
    submit = next(
        t for t in tools if t["function"]["name"] == "submit_inspection_section"
    )
    props = submit["function"]["parameters"]["properties"]
    for field, desc in (
        (name, props[name].get("description", "")) for name in props
    ):
        # Hard-coded L3-leaning ranges that previously appeared in this schema.
        assert "60–180" not in desc, f"{field} schema still bakes L3 60-180 range"
        assert "150–400" not in desc, f"{field} schema still bakes L3 150-400 range"
        assert "200–500" not in desc, f"{field} schema still bakes L3 200-500 range"
        assert "100–300" not in desc, f"{field} schema still bakes L3 100-300 range"


def test_inspector_system_prompt_l2_proportionate_directive() -> None:
    """Level 2 (HomeBuyer) must receive a *proportionate* depth directive β€”
    not the L3 cause/implications/options diagnostic frame, and not the L1
    observation-only frame. Lock the contract so future edits can't
    silently collapse L2 into one of the neighbouring tiers."""
    from app.agentic.inspector_loop import _inspector_system_prompt
    from app.templates.registry import get_survey_pack

    prompt = _inspector_system_prompt(
        tenant_id="t1",
        primary_document_id="d1",
        survey_level=2,
        pack=get_survey_pack(2),
        section_code="E1",
        section_title="Roof structure",
        style_profile=None,
    )
    lower = prompt.lower()
    # L2 marker β€” proportionate / HomeBuyer / buyer-focused.
    assert "homebuyer" in lower or "proportionate" in lower
    # L3-only diagnostic markers must NOT leak into L2.
    assert "diagnostic mode" not in lower
    # L1-only observation-only markers must NOT leak into L2.
    assert "observation mode" not in lower
    # Per-tier word ranges visible in the prompt must be the L2 set, not L3.
    # Calibrated-to-total profile: Inspector L2 condition_assessment = ~45–90.
    assert "45–90" in prompt or "45-90" in prompt


def test_inspector_word_ranges_match_total_aware_profile() -> None:
    """Condition-assessment ranges should reflect full-report totals.

    With only 5 sections in L1, per-section L1 can be larger than L2.
    """
    from app.agentic.inspector_loop import _inspector_system_prompt
    from app.templates.registry import get_survey_pack
    import re

    def _condition_upper_bound(level: int) -> int:
        prompt = _inspector_system_prompt(
            tenant_id="t1",
            primary_document_id="d1",
            survey_level=level,
            pack=get_survey_pack(level),
            section_code="E1",
            section_title="Roof",
            style_profile=None,
        )
        # Match: condition_assessment ~LOW–HIGH words
        m = re.search(
            r"condition_assessment\s*~\s*\d+[\u2013\-]\s*(\d+)\s*words",
            prompt,
        )
        assert m, f"condition_assessment word range not found in L{level} prompt"
        return int(m.group(1))

    l1_upper = _condition_upper_bound(1)
    l2_upper = _condition_upper_bound(2)
    l3_upper = _condition_upper_bound(3)
    assert l1_upper > l2_upper, (
        f"Expected L1 per-section budget > L2 (fewer total sections); got {l1_upper}, {l2_upper}"
    )
    assert l3_upper > l2_upper, (
        f"Expected L3 diagnostic budget > L2; got {l3_upper}, {l2_upper}"
    )


def test_strip_l1_advice_payload_used_only_when_l1() -> None:
    """`strip_l1_advice_payload` is the agentic loop's L1 sanitiser.
    L2 and L3 outputs intentionally include recommendations and must NOT
    pass through it. This test pins the contract by checking the import
    surface β€” :mod:`postprocess` exposes the helper, and the inspector
    loop imports it. If a future refactor accidentally calls the helper
    in the L2/L3 paths the test in
    test_l1_advice_strips_advisory_phrases_in_payload below catches the
    behaviour, but this one keeps the public surface tidy.
    """
    from app.generator import postprocess

    assert hasattr(postprocess, "strip_l1_advice")
    assert hasattr(postprocess, "strip_l1_advice_payload")


def test_l1_advice_strips_advisory_phrases_in_payload() -> None:
    """End-to-end behaviour of the payload-level sanitiser used by the
    agentic inspector loop on L1 outputs: advice is dropped, observation
    survives, and a fully-advisory recommendations field becomes the L1
    placeholder so the renderer never shows an empty heading."""
    from app.generator.postprocess import strip_l1_advice_payload

    payload = {
        "executive_summary": (
            "The two-bedroom flat is in serviceable condition. "
            "We recommend further investigation of the rear elevation."
        ),
        "property_description": "A two-bedroom first-floor flat with double-glazed windows.",
        "condition_assessment": (
            "The kitchen units showed light wear consistent with age. "
            "The boiler should be replaced ahead of completion."
        ),
        "defects_and_risks": (
            "Hairline cracks were noted at the rear elevation. "
            "Obtain a structural engineer's report before proceeding."
        ),
        "recommendations": "We recommend a full electrical inspection.",
    }
    cleaned = strip_l1_advice_payload(payload)
    # Advisory sentences are stripped from each field.
    assert "we recommend" not in cleaned["executive_summary"].lower()
    assert "should be replaced" not in cleaned["condition_assessment"].lower()
    assert "structural engineer" not in cleaned["defects_and_risks"].lower()
    # All-advisory fields surface the L1 placeholder.
    assert "level 1" in cleaned["recommendations"].lower()
    # Observation-only field is preserved verbatim.
    assert (
        cleaned["property_description"].strip()
        == payload["property_description"].strip()
    )


@pytest.mark.asyncio
async def test_generate_full_report_l1_fallback_does_not_leak_advice() -> None:
    """Regression for the L1 exec-summary exception fallback.

    The previous fallback inside ``except Exception`` in
    :func:`app.agentic.agents.generate_full_report` was a single hardcoded
    string that contained "recommended next steps" β€” a phrase
    `_L1_ADVICE_KEYWORDS_RE` flags as forbidden L1 advice. Worse, the L1
    sanitiser was called only inside the try block, so on adapter failure
    an L1 product would ship directive phrasing in its top-level
    executive summary.

    This test forces the adapter to raise, runs ``generate_full_report``
    with ``survey_level=1`` and an empty bullets map (so each section
    short-circuits to its NA placeholder and we don't need a real DB or
    inspector loop), and asserts the resulting ``executive_summary`` is
    free of the advice phrases the rest of the L1 enforcement removes.
    """
    from unittest.mock import MagicMock, patch

    from app.agentic.agents import generate_full_report
    from app.generator.postprocess import _L1_ADVICE_KEYWORDS_RE
    from app.models.schemas import WritingStyleProfile

    # Adapter that raises for the exec-summary call. The per-section path
    # also calls the adapter via the inspector loop or legacy pipeline,
    # but we route around that by passing an empty bullets_by_section so
    # every section uses its NA-placeholder branch (no LLM call).
    raising_adapter = MagicMock()
    raising_adapter.generate_section.side_effect = RuntimeError(
        "simulated adapter outage"
    )

    style = WritingStyleProfile(
        tone="formal",
        formality_level="professional",
        avg_sentence_complexity="moderate",
        vocabulary_level="technical",
        common_phrases=[],
        structural_patterns=[],
        writing_style_summary="L1 fallback test",
        example_paragraphs=[],
    )

    with patch(
        "app.generator.adapter.get_llm_adapter", return_value=raising_adapter
    ):
        result = await generate_full_report(
            db=None,
            tenant_id="t1",
            primary_document_id="doc1",
            bullets_by_section={},
            style_profile=style,
            ai_percent=50,
            survey_level=1,
        )

    exec_text = result.get("executive_summary", "")
    # The fallback path was reached (no LLM-generated text exists in this run).
    assert exec_text, "expected exception fallback to populate executive_summary"
    # Critical: no advice / directive phrasing in an L1 product, even on
    # adapter failure. Run the same regex the agentic loop uses to detect
    # leakage so this catches any future regression that re-introduces
    # "recommended", "we suggest", "should be replaced", etc.
    assert not _L1_ADVICE_KEYWORDS_RE.search(exec_text), (
        f"L1 fallback exec_text leaks advice phrasing: {exec_text!r}"
    )
    # And specifically the phrase the user-supplied bug report cited.
    assert "recommended next steps" not in exec_text.lower()


@pytest.mark.asyncio
async def test_generate_full_report_l3_fallback_keeps_recommendations() -> None:
    """Counter-test for the L1 fallback fix: the L3 fallback may legitimately
    reference "recommended next steps" because L3 (Building Survey) is an
    advice product. This locks in tier-aware fallback behaviour so a future
    edit can't accidentally apply the L1 sanitiser to L3 / strip
    legitimate advice from a Building Survey on adapter failure.
    """
    from unittest.mock import MagicMock, patch

    from app.agentic.agents import generate_full_report
    from app.models.schemas import WritingStyleProfile

    raising_adapter = MagicMock()
    raising_adapter.generate_section.side_effect = RuntimeError("outage")

    style = WritingStyleProfile(
        tone="formal", formality_level="professional",
        avg_sentence_complexity="moderate", vocabulary_level="technical",
        common_phrases=[], structural_patterns=[],
        writing_style_summary="L3 fallback test", example_paragraphs=[],
    )

    with patch(
        "app.generator.adapter.get_llm_adapter", return_value=raising_adapter
    ):
        result = await generate_full_report(
            db=None,
            tenant_id="t1",
            primary_document_id="doc1",
            bullets_by_section={},
            style_profile=style,
            ai_percent=50,
            survey_level=3,
        )

    exec_text = result.get("executive_summary", "")
    assert exec_text
    # L3 advice phrasing IS allowed and expected.
    assert "recommended" in exec_text.lower() or "next steps" in exec_text.lower()


@pytest.mark.asyncio
async def test_health_includes_rics_inspector_block() -> None:
    from app.main import create_app

    app = create_app()
    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
        res = await client.get("/health")
    assert res.status_code == 200
    data = res.json()
    assert "rics_inspector" in data
    assert data["rics_inspector"]["effective_mode"] in ("openai_tool_agent", "legacy_pipeline")
    assert "summary" in data["rics_inspector"]