Files
C.E.L_Slide_test2/tests/phase_z2_ai_fallback/test_step17.py
kyeongmin c864fe0479 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>
2026-05-21 12:46:49 +09:00

209 lines
7.2 KiB
Python

"""IMP-33 u9 — Step 17 AI repair wiring tests (BLOCKED until IMP-34 + IMP-35).
Covers:
* :data:`OVERFLOW_CASCADE_ORDER` canonical order (4 stages).
* :class:`OverflowCascadeStage` member values.
* :data:`STEP17_AI_REPAIR_BLOCKED_REASON` constant value.
* :func:`gather_step17_ai_repair_proposals` BLOCKED contract — every unit
returns ``ai_called=False`` + ``skip_reason=STEP17_AI_REPAIR_BLOCKED_REASON``
+ ``proposal=None`` regardless of provisional / label / route_hint.
* Structural guarantee — the u9 module does NOT import
:func:`src.phase_z2_ai_fallback.router.route_ai_fallback` or the
``anthropic`` SDK. Step 17 AI repair stays structurally blocked.
"""
from __future__ import annotations
import ast
from dataclasses import dataclass, field
from pathlib import Path
from src.phase_z2_ai_fallback import step17 as step17_mod
from src.phase_z2_ai_fallback.step17 import (
OVERFLOW_CASCADE_ORDER,
STEP17_AI_REPAIR_BLOCKED_REASON,
OverflowCascadeStage,
gather_step17_ai_repair_proposals,
)
@dataclass
class FakeUnit:
label: str | None
provisional: bool
frame_template_id: str = "tmpl"
frame_id: str = "fid"
source_section_ids: list[str] = field(default_factory=lambda: ["s1"])
raw_content: str = "raw"
v4_rank: int | None = 1
_ROUTE_HINTS: dict[str | None, str | None] = {
"use_as_is": "direct_render",
"light_edit": "deterministic_minor_adjustment",
"restructure": "ai_adaptation_required",
"reject": "design_reference_only",
None: None,
}
def _route_for_label(label: str | None) -> str | None:
return _ROUTE_HINTS.get(label)
# ─── Stage / order constants ─────────────────────────────────────────
def test_overflow_cascade_order_is_canonical():
assert OVERFLOW_CASCADE_ORDER == (
OverflowCascadeStage.DETERMINISTIC,
OverflowCascadeStage.POPUP,
OverflowCascadeStage.AI_REPAIR,
OverflowCascadeStage.USER_OVERRIDE,
)
def test_overflow_cascade_stage_string_values():
assert OverflowCascadeStage.DETERMINISTIC.value == "deterministic"
assert OverflowCascadeStage.POPUP.value == "popup"
assert OverflowCascadeStage.AI_REPAIR.value == "ai_repair"
assert OverflowCascadeStage.USER_OVERRIDE.value == "user_override"
def test_step17_blocked_reason_constant_value():
assert (
STEP17_AI_REPAIR_BLOCKED_REASON
== "step17_ai_blocked_imp_34_35_prerequisites_missing"
)
# ─── BLOCKED contract: every unit returns blocked record ─────────────
def test_gather_returns_one_record_per_unit():
units = [
FakeUnit(label="restructure", provisional=True),
FakeUnit(label="reject", provisional=False),
FakeUnit(label="use_as_is", provisional=True),
]
records = gather_step17_ai_repair_proposals(units, route_for_label=_route_for_label)
assert len(records) == 3
def test_gather_records_blocked_skip_reason():
"""Every record must carry the IMP-34/IMP-35 prerequisite block reason."""
units = [FakeUnit(label="restructure", provisional=True)]
records = gather_step17_ai_repair_proposals(units, route_for_label=_route_for_label)
assert records[0]["skip_reason"] == STEP17_AI_REPAIR_BLOCKED_REASON
def test_gather_blocks_even_when_route_is_ai_adaptation_required():
"""Provisional + ai_adaptation_required must NOT bypass the u9 block.
Stage 2 contract: AI repair at Step 17 is blocked behind IMP-34 + IMP-35
regardless of V4 route hint. Only u8 (Step 12) is allowed to invoke AI today.
"""
units = [FakeUnit(label="restructure", provisional=True)]
record = gather_step17_ai_repair_proposals(
units, route_for_label=_route_for_label
)[0]
assert record["route_hint"] == "ai_adaptation_required"
assert record["ai_called"] is False
assert record["proposal"] is None
assert record["skip_reason"] == STEP17_AI_REPAIR_BLOCKED_REASON
def test_gather_blocks_reject_units_too():
"""Reject units (design_reference_only) are also blocked at u9 — same reason."""
units = [FakeUnit(label="reject", provisional=False)]
record = gather_step17_ai_repair_proposals(
units, route_for_label=_route_for_label
)[0]
assert record["ai_called"] is False
assert record["skip_reason"] == STEP17_AI_REPAIR_BLOCKED_REASON
def test_gather_records_proposal_none_and_no_error():
units = [FakeUnit(label="restructure", provisional=True)]
record = gather_step17_ai_repair_proposals(
units, route_for_label=_route_for_label
)[0]
assert record["proposal"] is None
assert record["error"] is None
def test_gather_records_cascade_stage_is_ai_repair():
units = [FakeUnit(label="restructure", provisional=True)]
record = gather_step17_ai_repair_proposals(
units, route_for_label=_route_for_label
)[0]
assert record["cascade_stage"] == OverflowCascadeStage.AI_REPAIR.value
def test_gather_preserves_unit_metadata():
units = [
FakeUnit(
label="restructure",
provisional=True,
frame_template_id="frame_05_overview",
source_section_ids=["s1", "s2"],
)
]
record = gather_step17_ai_repair_proposals(
units, route_for_label=_route_for_label
)[0]
assert record["unit_index"] == 0
assert record["frame_template_id"] == "frame_05_overview"
assert record["source_section_ids"] == ["s1", "s2"]
assert record["label"] == "restructure"
assert record["provisional"] is True
def test_gather_with_empty_units_returns_empty_list():
records = gather_step17_ai_repair_proposals([], route_for_label=_route_for_label)
assert records == []
# ─── Structural guarantee: u9 must NOT import route_ai_fallback / anthropic ─
def _u9_imports() -> list[str]:
src_path = Path(step17_mod.__file__)
tree = ast.parse(src_path.read_text(encoding="utf-8"))
imports: list[str] = []
for node in ast.walk(tree):
if isinstance(node, ast.Import):
imports.extend(alias.name for alias in node.names)
elif isinstance(node, ast.ImportFrom):
module = node.module or ""
for alias in node.names:
imports.append(f"{module}.{alias.name}")
return imports
def test_step17_module_does_not_import_route_ai_fallback():
"""u9 must not be able to reach the u7 router — structural block."""
imports = _u9_imports()
forbidden = {
"src.phase_z2_ai_fallback.router.route_ai_fallback",
"src.phase_z2_ai_fallback.router",
}
assert not any(imp in forbidden for imp in imports), imports
assert not hasattr(step17_mod, "route_ai_fallback")
def test_step17_module_does_not_import_anthropic():
"""u9 must not reach the Anthropic SDK directly — AI=0 in this layer."""
imports = _u9_imports()
leaked = [imp for imp in imports if imp.split(".", 1)[0] == "anthropic"]
assert leaked == [], leaked
def test_step17_module_does_not_import_ai_fallback_client():
"""u9 must not instantiate the u4 client either."""
imports = _u9_imports()
forbidden_prefixes = ("src.phase_z2_ai_fallback.client",)
leaked = [
imp for imp in imports if imp.startswith(forbidden_prefixes)
]
assert leaked == [], leaked