Frame-aware AI fallback module scaffolded under src/phase_z2_ai_fallback/ with master flag ai_fallback_enabled=False; normal-path AI call count remains 0. AI output constrained to builder_options_patch / partial_overrides / slot_mapping_proposal; MDX / frame_id / raw HTML / raw CSS mutations rejected at schema layer. IMP-46 cache gate (cache.py) raises AiFallbackCacheGateError unless visual_check_passed AND user_approved. Step 12 wires AI repair after IMP-30 provisional payload only; Step 17 stays blocked behind IMP-34 / IMP-35 prerequisites. AST isolation guard forbids fallback package from importing Phase Q / Kei / pipeline runtime symbols. Docs IMP-17 / IMP-31 bound to runtime module surface via 11-row structural test pin (test_docs_sync.py) so drift fails CI. Tests: 116 fallback / 161 phase_z2 regression / 526 scoped full sweep all passing. Existing pre-IMP-33 fixture issue in scripts/test_phase_t_* remains untouched (out of scope). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
157 lines
5.9 KiB
Python
157 lines
5.9 KiB
Python
"""IMP-33 u7 — AI fallback router tests.
|
|
|
|
Scope (Stage 2 plan, u7):
|
|
- flag-off gate returns None and does NOT touch the client / prompt
|
|
- route-mismatch gate returns None and does NOT touch the client / prompt
|
|
- cache-hit short-circuits the client and still re-validates against the
|
|
current frame contract (defence-in-depth)
|
|
- cache-miss calls the client and validates the returned proposal
|
|
- validation errors propagate
|
|
- budget / circuit exceptions from u4 propagate
|
|
- router never imports ``save_proposal`` (cache save is caller-driven
|
|
after visual_check + user_approved per u6 IMP-46 gate)
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from src.phase_z2_ai_fallback import AiFallbackProposal, ProposalKind
|
|
from src.phase_z2_ai_fallback import router as router_mod
|
|
from src.phase_z2_ai_fallback.client import (
|
|
AiFallbackBudgetExceeded,
|
|
AiFallbackCircuitOpen,
|
|
AiFallbackClient,
|
|
)
|
|
from src.phase_z2_ai_fallback.router import route_ai_fallback
|
|
from src.phase_z2_ai_fallback.validate import AiFallbackValidationError
|
|
|
|
|
|
_FRAME_CONTRACT = {
|
|
"frame_id": 1171281190,
|
|
"sub_zones": [{"id": "pillar_1", "accepts": ["text_block"]}],
|
|
"payload": {"builder_options": {"item_parser": "pillar_item"}},
|
|
}
|
|
_REGION = {"id": "zone_top.region_a"}
|
|
_V4_AI = {
|
|
"route": "ai_adaptation_required",
|
|
"cardinality": "many",
|
|
"frame_id": 1171281190,
|
|
"rank": 1,
|
|
}
|
|
_V4_NOT_AI = {"route": "light_edit", "cardinality": "many"}
|
|
|
|
|
|
def _make_proposal(
|
|
kind: ProposalKind = ProposalKind.PARTIAL_OVERRIDES,
|
|
payload: dict | None = None,
|
|
) -> AiFallbackProposal:
|
|
return AiFallbackProposal(
|
|
proposal_kind=kind,
|
|
payload=payload if payload is not None else {"slots": {"pillar_1": "a"}},
|
|
)
|
|
|
|
|
|
def _call_kwargs() -> dict:
|
|
return dict(
|
|
cache_key="frame:1171281190:cardinality:many",
|
|
v4_result=_V4_AI,
|
|
frame_contract=_FRAME_CONTRACT,
|
|
frame_visual_html="<div></div>",
|
|
figma_partial_json={},
|
|
internal_region=_REGION,
|
|
mdx_text="# example\n- a\n- b",
|
|
)
|
|
|
|
|
|
def test_router_returns_none_when_flag_off(monkeypatch):
|
|
monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", False)
|
|
client = MagicMock(spec=AiFallbackClient)
|
|
result = route_ai_fallback(**_call_kwargs(), client=client)
|
|
assert result is None
|
|
client.request_proposal.assert_not_called()
|
|
|
|
|
|
def test_router_returns_none_when_route_not_ai_adaptation(monkeypatch):
|
|
monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True)
|
|
client = MagicMock(spec=AiFallbackClient)
|
|
kwargs = _call_kwargs()
|
|
kwargs["v4_result"] = _V4_NOT_AI
|
|
result = route_ai_fallback(**kwargs, client=client)
|
|
assert result is None
|
|
client.request_proposal.assert_not_called()
|
|
|
|
|
|
def test_router_returns_cached_when_cache_hit(monkeypatch):
|
|
monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True)
|
|
cached = _make_proposal()
|
|
monkeypatch.setattr(router_mod, "read_proposal", lambda key: cached)
|
|
client = MagicMock(spec=AiFallbackClient)
|
|
result = route_ai_fallback(**_call_kwargs(), client=client)
|
|
assert result is cached
|
|
client.request_proposal.assert_not_called()
|
|
|
|
|
|
def test_router_validates_cached_proposal(monkeypatch):
|
|
monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True)
|
|
bad_cached = AiFallbackProposal(
|
|
proposal_kind=ProposalKind.BUILDER_OPTIONS_PATCH,
|
|
payload={"unknown_key": "x"},
|
|
)
|
|
monkeypatch.setattr(router_mod, "read_proposal", lambda key: bad_cached)
|
|
client = MagicMock(spec=AiFallbackClient)
|
|
with pytest.raises(AiFallbackValidationError):
|
|
route_ai_fallback(**_call_kwargs(), client=client)
|
|
client.request_proposal.assert_not_called()
|
|
|
|
|
|
def test_router_calls_client_and_returns_validated_proposal(monkeypatch):
|
|
monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True)
|
|
monkeypatch.setattr(router_mod, "read_proposal", lambda key: None)
|
|
proposal = _make_proposal()
|
|
client = MagicMock(spec=AiFallbackClient)
|
|
client.request_proposal.return_value = proposal
|
|
result = route_ai_fallback(**_call_kwargs(), client=client)
|
|
assert result is proposal
|
|
client.request_proposal.assert_called_once()
|
|
sent_prompt = client.request_proposal.call_args.args[0]
|
|
assert set(sent_prompt.keys()) == {"system", "user"}
|
|
|
|
|
|
def test_router_propagates_validation_error(monkeypatch):
|
|
monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True)
|
|
monkeypatch.setattr(router_mod, "read_proposal", lambda key: None)
|
|
bad = AiFallbackProposal(
|
|
proposal_kind=ProposalKind.BUILDER_OPTIONS_PATCH,
|
|
payload={"unknown_key": "x"},
|
|
)
|
|
client = MagicMock(spec=AiFallbackClient)
|
|
client.request_proposal.return_value = bad
|
|
with pytest.raises(AiFallbackValidationError):
|
|
route_ai_fallback(**_call_kwargs(), client=client)
|
|
|
|
|
|
def test_router_propagates_budget_exceeded(monkeypatch):
|
|
monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True)
|
|
monkeypatch.setattr(router_mod, "read_proposal", lambda key: None)
|
|
client = MagicMock(spec=AiFallbackClient)
|
|
client.request_proposal.side_effect = AiFallbackBudgetExceeded("over")
|
|
with pytest.raises(AiFallbackBudgetExceeded):
|
|
route_ai_fallback(**_call_kwargs(), client=client)
|
|
|
|
|
|
def test_router_propagates_circuit_open(monkeypatch):
|
|
monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True)
|
|
monkeypatch.setattr(router_mod, "read_proposal", lambda key: None)
|
|
client = MagicMock(spec=AiFallbackClient)
|
|
client.request_proposal.side_effect = AiFallbackCircuitOpen("tripped")
|
|
with pytest.raises(AiFallbackCircuitOpen):
|
|
route_ai_fallback(**_call_kwargs(), client=client)
|
|
|
|
|
|
def test_router_does_not_import_save_proposal():
|
|
"""Cache save is caller-driven AFTER visual_check + user_approved (u6 IMP-46
|
|
gate); structurally guaranteed by NOT importing save_proposal in the router."""
|
|
assert not hasattr(router_mod, "save_proposal")
|