Spaces:
Sleeping
Sleeping
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"]
|