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:
@@ -0,0 +1,83 @@
|
||||
"""IMP-33 u5 — AI fallback proposal validator (fallback path only).
|
||||
|
||||
Defence-in-depth layer between the u4 client output (already u2-schema-valid)
|
||||
and the caller. Adds the four Stage 2 guards that u2 cannot express purely at
|
||||
the schema level:
|
||||
|
||||
1. builder-options whitelist (BUILDER_OPTIONS_PATCH may only touch keys
|
||||
already declared in ``frame_contract.payload.builder_options``).
|
||||
2. dropped-slot guard (PARTIAL_OVERRIDES / SLOT_MAPPING_PROPOSAL must keep
|
||||
every declared ``sub_zones[*].id`` populated — text/table/image/details
|
||||
slots cannot disappear; `feedback_ai_isolation_contract`).
|
||||
3. frame-swap guard (no ``frame_id`` mutation inside payload — V4 rank-1
|
||||
protected; `feedback_phase_z_spacing_direction`).
|
||||
4. Internal Region containment (``payload.region_id`` must match the
|
||||
declared Internal Region id when present).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from src.phase_z2_ai_fallback.schema import AiFallbackProposal, ProposalKind
|
||||
|
||||
|
||||
class AiFallbackValidationError(ValueError):
|
||||
"""Raised when a proposal violates an IMP-33 u5 guard."""
|
||||
|
||||
|
||||
_SLOT_KINDS = (ProposalKind.PARTIAL_OVERRIDES, ProposalKind.SLOT_MAPPING_PROPOSAL)
|
||||
|
||||
|
||||
def validate_proposal(
|
||||
proposal: AiFallbackProposal,
|
||||
*,
|
||||
frame_contract: dict[str, Any],
|
||||
internal_region: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Validate an AI fallback proposal against the active frame contract.
|
||||
|
||||
Raises ``AiFallbackValidationError`` on any guard violation. Returns
|
||||
``None`` on success — caller is responsible for downstream application.
|
||||
"""
|
||||
AiFallbackProposal.model_validate(proposal.model_dump())
|
||||
|
||||
payload = proposal.payload
|
||||
frame_id = frame_contract.get("frame_id")
|
||||
if "frame_id" in payload and payload["frame_id"] != frame_id:
|
||||
raise AiFallbackValidationError(
|
||||
f"frame-swap guard: payload.frame_id={payload['frame_id']!r} "
|
||||
f"differs from contract frame_id={frame_id!r}; V4 rank-1 is locked."
|
||||
)
|
||||
|
||||
if proposal.proposal_kind is ProposalKind.BUILDER_OPTIONS_PATCH:
|
||||
declared = (frame_contract.get("payload") or {}).get("builder_options") or {}
|
||||
unknown = set(payload.keys()) - set(declared.keys())
|
||||
if unknown:
|
||||
raise AiFallbackValidationError(
|
||||
f"builder whitelist: keys {sorted(unknown)} not in "
|
||||
f"frame_contract.payload.builder_options {sorted(declared)}."
|
||||
)
|
||||
|
||||
if proposal.proposal_kind in _SLOT_KINDS:
|
||||
declared_slot_ids = [z.get("id") for z in (frame_contract.get("sub_zones") or [])]
|
||||
slots = payload.get("slots")
|
||||
if not isinstance(slots, dict):
|
||||
raise AiFallbackValidationError(
|
||||
"dropped-slot guard: PARTIAL_OVERRIDES / SLOT_MAPPING_PROPOSAL "
|
||||
"payload MUST include a 'slots' mapping."
|
||||
)
|
||||
missing = [sid for sid in declared_slot_ids if sid not in slots]
|
||||
if missing:
|
||||
raise AiFallbackValidationError(
|
||||
f"dropped-slot guard: declared slots {missing} are absent "
|
||||
"from payload.slots (text/table/image/details must remain populated)."
|
||||
)
|
||||
|
||||
region_id = payload.get("region_id")
|
||||
if region_id is not None and internal_region is not None:
|
||||
declared_region_id = internal_region.get("id")
|
||||
if region_id != declared_region_id:
|
||||
raise AiFallbackValidationError(
|
||||
f"Internal Region containment: payload.region_id={region_id!r} "
|
||||
f"differs from internal_region.id={declared_region_id!r}."
|
||||
)
|
||||
Reference in New Issue
Block a user