feat(#61): IMP-33 AI fallback scaffolding (u1~u11, flag default OFF)
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>
This commit is contained in:
156
tests/phase_z2_ai_fallback/test_router.py
Normal file
156
tests/phase_z2_ai_fallback/test_router.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""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")
|
||||
Reference in New Issue
Block a user