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>
101 lines
3.5 KiB
Python
101 lines
3.5 KiB
Python
"""IMP-33 u3 — fallback prompt builder tests.
|
|
|
|
Scope (Stage 2 plan, u3):
|
|
- Prompt is built only when V4 route == 'ai_adaptation_required'.
|
|
- System prompt declares MDX READ-ONLY and pins the u2 whitelist.
|
|
- System prompt forbids the u2 forbidden kinds + frame_id swap.
|
|
- User payload carries all 6 declared inputs and labels MDX READ_ONLY.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from src.phase_z2_ai_fallback.prompts import (
|
|
SYSTEM_PROMPT,
|
|
V4_ROUTE_AI_ADAPTATION,
|
|
build_ai_fallback_prompt,
|
|
)
|
|
from src.phase_z2_ai_fallback.schema import FORBIDDEN_KINDS, ProposalKind
|
|
|
|
|
|
def _v4(route: str = V4_ROUTE_AI_ADAPTATION) -> dict:
|
|
return {
|
|
"route": route,
|
|
"cardinality": {"strict": 3},
|
|
"label": "restructure",
|
|
"frame_id": 1171281190,
|
|
"rank": 1,
|
|
}
|
|
|
|
|
|
def _inputs(route: str = V4_ROUTE_AI_ADAPTATION) -> dict:
|
|
return {
|
|
"v4_result": _v4(route),
|
|
"frame_contract": {"template_id": "three_parallel_requirements"},
|
|
"frame_visual_html": "<section class='f13b'/>",
|
|
"figma_partial_json": {"nodes": []},
|
|
"internal_region": {"id": "region_top", "bbox": [0, 0, 1200, 320]},
|
|
"mdx_text": "# 대목차\n- 항목 1\n- 항목 2\n- 항목 3",
|
|
}
|
|
|
|
|
|
def test_system_prompt_declares_mdx_read_only() -> None:
|
|
assert "READ-ONLY" in SYSTEM_PROMPT
|
|
|
|
|
|
def test_system_prompt_lists_all_whitelisted_kinds() -> None:
|
|
for kind in ProposalKind:
|
|
assert kind.value in SYSTEM_PROMPT
|
|
|
|
|
|
def test_system_prompt_forbids_all_forbidden_kinds() -> None:
|
|
for forbidden in FORBIDDEN_KINDS:
|
|
assert forbidden in SYSTEM_PROMPT
|
|
|
|
|
|
def test_system_prompt_locks_frame_id_swap() -> None:
|
|
assert "frame_id" in SYSTEM_PROMPT
|
|
|
|
|
|
def test_build_prompt_returns_system_and_user() -> None:
|
|
prompt = build_ai_fallback_prompt(**_inputs())
|
|
assert set(prompt.keys()) == {"system", "user"}
|
|
assert prompt["system"] == SYSTEM_PROMPT
|
|
|
|
|
|
def test_user_payload_carries_all_inputs_and_marks_mdx_read_only() -> None:
|
|
prompt = build_ai_fallback_prompt(**_inputs())
|
|
payload = json.loads(prompt["user"])
|
|
assert payload["v4"]["route"] == V4_ROUTE_AI_ADAPTATION
|
|
assert payload["v4"]["cardinality"] == {"strict": 3}
|
|
assert payload["v4"]["frame_id"] == 1171281190
|
|
assert payload["frame_contract"]["template_id"] == "three_parallel_requirements"
|
|
assert payload["frame_visual_html"] == "<section class='f13b'/>"
|
|
assert payload["figma_partial_json"] == {"nodes": []}
|
|
assert payload["internal_region"]["id"] == "region_top"
|
|
assert "mdx_text_READ_ONLY" in payload
|
|
assert payload["mdx_text_READ_ONLY"].startswith("# 대목차")
|
|
assert "mdx_text" not in payload # only the READ_ONLY key, not a writable alias
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"route", ["direct_render", "deterministic_minor_adjustment", "design_reference_only", None]
|
|
)
|
|
def test_non_ai_route_rejected(route) -> None:
|
|
inputs = _inputs(route=route) if route is not None else _inputs()
|
|
if route is None:
|
|
inputs["v4_result"].pop("route")
|
|
with pytest.raises(ValueError, match=V4_ROUTE_AI_ADAPTATION):
|
|
build_ai_fallback_prompt(**inputs)
|
|
|
|
|
|
def test_cardinality_signature_alias_accepted() -> None:
|
|
"""Some V4 callers expose ``cardinality_signature``; both keys must resolve."""
|
|
inputs = _inputs()
|
|
inputs["v4_result"].pop("cardinality")
|
|
inputs["v4_result"]["cardinality_signature"] = {"strict": 4}
|
|
payload = json.loads(build_ai_fallback_prompt(**inputs)["user"])
|
|
assert payload["v4"]["cardinality"] == {"strict": 4}
|