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:
2026-05-21 12:46:49 +09:00
parent c412f1ea75
commit c864fe0479
24 changed files with 2119 additions and 5 deletions

View File

@@ -14,6 +14,18 @@ class Settings(BaseSettings):
slide_width: int = 1280
slide_height: int = 720
# IMP-33 u1 — AI fallback policy. Fallback-path only; normal path AI=0.
# Defaults locked by Stage 2 plan; do NOT inline literals downstream.
ai_fallback_enabled: bool = False
ai_fallback_model: str = "claude-opus-4-6-20250415"
ai_fallback_timeout_s: float = 60.0
ai_fallback_max_retries: int = 3
ai_fallback_backoff_base_s: float = 1.0
ai_fallback_backoff_cap_s: float = 8.0
ai_fallback_backoff_jitter: float = 0.3
ai_fallback_budget_per_run: int = 10
ai_fallback_circuit_breaker_threshold: int = 5
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}

View File

@@ -0,0 +1,15 @@
"""IMP-33 AI fallback package (fallback path only).
Module path locked by IMP-31-GATE-AUDIT.md (Stage 1 binding).
Normal path AI call count MUST remain 0; this package only executes under
classified fallback routes (reject / restructure / overflow). See
`feedback_ai_isolation_contract`.
"""
from __future__ import annotations
from src.phase_z2_ai_fallback.schema import (
AiFallbackProposal,
ProposalKind,
)
__all__ = ["AiFallbackProposal", "ProposalKind"]

View File

@@ -0,0 +1,82 @@
"""IMP-33 u6 — AI fallback proposal cache (IMP-46 gate, no persistent storage).
This module defines the cache contract that IMP-33 callers use to remember
AI fallback proposals across runs. The persistent storage layer itself is
out-of-scope for IMP-33 and is owned by IMP-46 (frame transformation cache).
Behaviour locked by Stage 2 plan (u6):
* ``read_proposal(key)`` always returns ``None`` until IMP-46 lands a
persistent backend. Callers MUST handle the cache-miss path.
* ``save_proposal(key, proposal, *, visual_check_passed, user_approved)``
enforces the IMP-46 gate before any storage write is attempted:
- ``visual_check_passed=False`` -> ``AiFallbackCacheGateError``
- ``user_approved=False`` -> ``AiFallbackCacheGateError``
Only when BOTH gates are True does control reach the storage layer,
which currently raises ``NotImplementedError`` (the IMP-46 marker).
Guardrails:
* No Anthropic import; cache is pure proposal bookkeeping.
* No MDX read/write; proposals are u2 ``AiFallbackProposal`` instances.
* No silent persistence: gate violations are loud, not skipped writes
(`feedback_artifact_status_naming`).
"""
from __future__ import annotations
from src.phase_z2_ai_fallback.schema import AiFallbackProposal
class AiFallbackCacheGateError(RuntimeError):
"""Raised when ``save_proposal`` is called without both IMP-46 gates True."""
def read_proposal(key: str) -> AiFallbackProposal | None:
"""Look up a previously cached proposal by ``key``.
IMP-33 ships without a persistent backend; this stub always returns
``None`` so callers exercise the cache-miss path. The persistent
backend will be wired by IMP-46.
"""
if not isinstance(key, str) or not key:
raise ValueError("cache key must be a non-empty string")
return None
def save_proposal(
key: str,
proposal: AiFallbackProposal,
*,
visual_check_passed: bool,
user_approved: bool,
) -> None:
"""Persist ``proposal`` under ``key`` once both IMP-46 gates are True.
Raises ``AiFallbackCacheGateError`` if either gate is False — the
proposal is NOT written. When both gates are True, storage raises
``NotImplementedError`` (the IMP-46 persistent backend has not landed
yet).
"""
if not isinstance(key, str) or not key:
raise ValueError("cache key must be a non-empty string")
if not isinstance(proposal, AiFallbackProposal):
raise TypeError(
"proposal must be an AiFallbackProposal instance "
f"(got {type(proposal).__name__})"
)
if not visual_check_passed:
raise AiFallbackCacheGateError(
"IMP-46 gate: visual_check_passed=False; refusing to cache an "
"unverified proposal."
)
if not user_approved:
raise AiFallbackCacheGateError(
"IMP-46 gate: user_approved=False; refusing to cache without "
"explicit user approval."
)
raise NotImplementedError(
"IMP-46 persistent cache storage is not implemented yet; "
"this is the IMP-33 u6 stub marker."
)

View File

@@ -0,0 +1,92 @@
"""IMP-33 u4 — AI fallback Anthropic client (fallback path only).
Wraps ``anthropic.Anthropic.messages.create`` with the timeout / retry /
backoff / budget / circuit-breaker policy locked in u1 ``Settings``. NO
inline policy literals: every knob is sourced from ``src.config.settings``.
Transient errors (timeout / connection / 429 / 5xx) are retried with
capped exponential backoff + jitter; all other errors propagate without
retry. PZ-1 invariant: this module is fallback-path only and MUST NOT be
imported on the normal pipeline path.
"""
from __future__ import annotations
import json
import random
import time
from dataclasses import dataclass
from typing import Any
import anthropic
from src.config import settings
from src.phase_z2_ai_fallback.schema import AiFallbackProposal
_TRANSIENT_ERRORS: tuple[type[BaseException], ...] = (
anthropic.APITimeoutError,
anthropic.APIConnectionError,
anthropic.RateLimitError,
anthropic.InternalServerError,
)
# Output cap is an Anthropic API requirement, not a policy knob (u1).
_MAX_OUTPUT_TOKENS = 4096
class AiFallbackBudgetExceeded(RuntimeError):
"""Per-run AI call budget (u1 ai_fallback_budget_per_run) exhausted."""
class AiFallbackCircuitOpen(RuntimeError):
"""Circuit breaker tripped (u1 ai_fallback_circuit_breaker_threshold)."""
@dataclass
class AiFallbackClient:
"""Stateful per-run fallback client (budget + circuit accounting)."""
client: Any = None
_calls: int = 0
_consecutive_failures: int = 0
def __post_init__(self) -> None:
if self.client is None:
self.client = anthropic.Anthropic(
api_key=settings.anthropic_api_key,
timeout=settings.ai_fallback_timeout_s,
)
def request_proposal(self, prompt: dict[str, str]) -> AiFallbackProposal:
if self._calls >= settings.ai_fallback_budget_per_run:
raise AiFallbackBudgetExceeded(
f"per-run budget {settings.ai_fallback_budget_per_run} exhausted"
)
if self._consecutive_failures >= settings.ai_fallback_circuit_breaker_threshold:
raise AiFallbackCircuitOpen(
f"circuit open after {self._consecutive_failures} consecutive failures"
)
self._calls += 1
last_error: BaseException | None = None
for attempt in range(settings.ai_fallback_max_retries + 1):
try:
response = self.client.messages.create(
model=settings.ai_fallback_model,
max_tokens=_MAX_OUTPUT_TOKENS,
system=prompt["system"],
messages=[{"role": "user", "content": prompt["user"]}],
)
text = "".join(
block.text for block in response.content if hasattr(block, "text")
)
self._consecutive_failures = 0
return AiFallbackProposal.model_validate(json.loads(text))
except _TRANSIENT_ERRORS as err:
last_error = err
if attempt >= settings.ai_fallback_max_retries:
break
base = settings.ai_fallback_backoff_base_s * (2 ** attempt)
delay = min(settings.ai_fallback_backoff_cap_s, base)
delay += random.uniform(0, delay * settings.ai_fallback_backoff_jitter)
time.sleep(delay)
self._consecutive_failures += 1
assert last_error is not None
raise last_error

View File

@@ -0,0 +1,80 @@
"""IMP-33 u3 — AI fallback prompt builder (fallback path only).
System+user prompt for the Anthropic client (u4). MDX is READ-ONLY
(`feedback_ai_isolation_contract`); output is constrained to the u2
schema; frame_id swap is forbidden (V4 rank-1 protected,
`feedback_phase_z_spacing_direction`). Inputs per Stage 2 plan: V4
result (route=ai_adaptation_required, cardinality), frame_contract,
frame_visual HTML, figma_to_html_agent partial JSON, Internal Region,
MDX text.
"""
from __future__ import annotations
import json
from typing import Any
from src.phase_z2_ai_fallback.schema import FORBIDDEN_KINDS, ProposalKind
V4_ROUTE_AI_ADAPTATION = "ai_adaptation_required"
_ALLOWED_KINDS = ", ".join(sorted(k.value for k in ProposalKind))
_FORBIDDEN_KINDS = ", ".join(sorted(FORBIDDEN_KINDS))
SYSTEM_PROMPT = (
"You are an IMP-33 AI fallback adapter for Phase Z slide composition.\n"
"STRICT RULES:\n"
" 1. MDX text in the user payload is READ-ONLY. Do NOT rewrite, "
"compress, or paraphrase MDX.\n"
" 2. Output MUST be a single JSON object conforming to AiFallbackProposal.\n"
f" 3. proposal_kind MUST be one of: {_ALLOWED_KINDS}.\n"
f" 4. Do NOT propose any of: {_FORBIDDEN_KINDS}.\n"
" 5. Do NOT change frame_id — V4 rank-1 frame is locked.\n"
" 6. Keep declared frame slots (text/table/image/details) populated.\n"
" 7. Respect Internal Region containment; place content units within "
"the declared region only."
)
def build_ai_fallback_prompt(
*,
v4_result: dict[str, Any],
frame_contract: dict[str, Any],
frame_visual_html: str,
figma_partial_json: dict[str, Any],
internal_region: dict[str, Any],
mdx_text: str,
) -> dict[str, str]:
"""Build system+user prompt strings for the fallback AI adapter.
Raises:
ValueError: when ``v4_result.route`` is not
``ai_adaptation_required`` — the fallback prompt MUST NOT be
built outside this route (normal-path AI call count must
remain 0; PZ-1).
"""
route = v4_result.get("route") or v4_result.get("imp05_route_hint")
if route != V4_ROUTE_AI_ADAPTATION:
raise ValueError(
f"build_ai_fallback_prompt: v4_result.route={route!r} is not "
f"{V4_ROUTE_AI_ADAPTATION!r}; fallback prompt MUST NOT be built "
"outside the AI adaptation route."
)
user_payload = {
"v4": {
"route": route,
"cardinality": v4_result.get("cardinality")
or v4_result.get("cardinality_signature"),
"label": v4_result.get("label"),
"frame_id": v4_result.get("frame_id"),
"rank": v4_result.get("rank"),
},
"frame_contract": frame_contract,
"frame_visual_html": frame_visual_html,
"figma_partial_json": figma_partial_json,
"internal_region": internal_region,
"mdx_text_READ_ONLY": mdx_text,
}
return {
"system": SYSTEM_PROMPT,
"user": json.dumps(user_payload, ensure_ascii=False),
}

View File

@@ -0,0 +1,89 @@
"""IMP-33 u7 — AI fallback router (fallback path only).
Composes the IMP-33 fallback flow:
1. flag gate (``settings.ai_fallback_enabled`` default OFF)
2. V4 route gate (route must equal ``ai_adaptation_required``)
3. cache read (u6 stub returns ``None`` until IMP-46 lands)
4. build prompt (u3)
5. call client (u4 ``request_proposal``)
6. validate (u5 ``validate_proposal``)
Returns the validated ``AiFallbackProposal``. Save to cache is NOT
performed here — it is caller-driven AFTER ``visual_check_passed=True``
AND ``user_approved=True``, per the u6 IMP-46 gate. The router does not
import ``save_proposal``; this is the structural guarantee that the
router cannot persist a proposal before the caller's visual + user
checks (`feedback_artifact_status_naming`).
Guardrails:
* PZ-1 — normal-path AI call count stays 0: flag-off OR route-mismatch
short-circuits BEFORE the prompt builder or client are touched.
* ``feedback_ai_isolation_contract`` — MDX READ-ONLY (u3 enforces in
prompt; this module never reads or writes MDX).
* ``feedback_phase_z_spacing_direction`` — V4 rank-1 protected (u5
enforces; router only forwards the contract).
"""
from __future__ import annotations
from typing import Any
from src.config import settings
from src.phase_z2_ai_fallback.cache import read_proposal
from src.phase_z2_ai_fallback.client import AiFallbackClient
from src.phase_z2_ai_fallback.prompts import (
V4_ROUTE_AI_ADAPTATION,
build_ai_fallback_prompt,
)
from src.phase_z2_ai_fallback.schema import AiFallbackProposal
from src.phase_z2_ai_fallback.validate import validate_proposal
def route_ai_fallback(
*,
cache_key: str,
v4_result: dict[str, Any],
frame_contract: dict[str, Any],
frame_visual_html: str,
figma_partial_json: dict[str, Any],
internal_region: dict[str, Any],
mdx_text: str,
client: AiFallbackClient | None = None,
) -> AiFallbackProposal | None:
"""Route a fallback request through cache → prompt → client → validate.
Returns ``None`` when the master flag is OFF or when the V4 route is
not ``ai_adaptation_required`` — both gates short-circuit BEFORE any
prompt/client work, so the normal-path AI call count stays at 0
(PZ-1).
"""
if not settings.ai_fallback_enabled:
return None
route = v4_result.get("route") or v4_result.get("imp05_route_hint")
if route != V4_ROUTE_AI_ADAPTATION:
return None
cached = read_proposal(cache_key)
if cached is not None:
validate_proposal(
cached,
frame_contract=frame_contract,
internal_region=internal_region,
)
return cached
prompt = build_ai_fallback_prompt(
v4_result=v4_result,
frame_contract=frame_contract,
frame_visual_html=frame_visual_html,
figma_partial_json=figma_partial_json,
internal_region=internal_region,
mdx_text=mdx_text,
)
active_client = client if client is not None else AiFallbackClient()
proposal = active_client.request_proposal(prompt)
validate_proposal(
proposal,
frame_contract=frame_contract,
internal_region=internal_region,
)
return proposal

View File

@@ -0,0 +1,50 @@
"""IMP-33 u2 — AI fallback proposal schema.
Whitelisted proposal kinds (Stage 2 plan):
- builder_options_patch : zone/frame builder option overrides
- partial_overrides : Internal Region / Frame Slot content overrides
- slot_mapping_proposal : restructuring proposal (content unit mapping)
Forbidden output forms (rejected by validator):
- mdx_text (MDX read-only — `feedback_ai_isolation_contract`)
- frame_id_change (V4 rank-1 protected — `feedback_phase_z_spacing_direction`)
- raw_html (HTML structure is code-decided, not AI-generated)
- raw_css (same)
"""
from __future__ import annotations
from enum import Enum
from typing import Any
from pydantic import BaseModel, ConfigDict, Field, field_validator
class ProposalKind(str, Enum):
BUILDER_OPTIONS_PATCH = "builder_options_patch"
PARTIAL_OVERRIDES = "partial_overrides"
SLOT_MAPPING_PROPOSAL = "slot_mapping_proposal"
FORBIDDEN_KINDS: frozenset[str] = frozenset(
{"mdx_text", "frame_id_change", "raw_html", "raw_css"}
)
class AiFallbackProposal(BaseModel):
"""Single AI fallback proposal (output contract for u4 client)."""
model_config = ConfigDict(extra="forbid")
proposal_kind: ProposalKind
payload: dict[str, Any] = Field(default_factory=dict)
rationale: str = ""
@field_validator("proposal_kind", mode="before")
@classmethod
def _reject_forbidden_kind(cls, value: Any) -> Any:
if isinstance(value, str) and value in FORBIDDEN_KINDS:
raise ValueError(
f"proposal_kind={value!r} is forbidden (MDX/frame/raw HTML/CSS "
"mutations are not permitted under IMP-33)."
)
return value

View File

@@ -0,0 +1,141 @@
"""IMP-33 u8 — Step 12 AI repair wiring (IMP-30 provisional units only).
Phase Z Step 12 = slot_payload (the runtime "light_edit / restructure" surface
where AI-assisted frame-aware adaptation is allowed per IMP-17 carve-out).
This module is the only call site that pipes Phase Z composition units into
``src.phase_z2_ai_fallback.router.route_ai_fallback``. Two structural gates
preserve the AI isolation contract:
* IMP-30 provisional gate — units with ``provisional=False`` are skipped
before any route classification. AI repair is reserved for first-render
invariant survivors (no rank-1 V4 evidence, recovered as provisional).
* Reject gate — units whose V4 label maps to ``design_reference_only``
(``reject``) are skipped with ``skip_reason="design_reference_only_no_ai"``.
Reject path is design reference only — never an AI call.
Combined with the u7 router's flag-off + route-gate short-circuits, the
default Phase Z run path performs zero AI calls (PZ-1). Save to cache is
NOT performed here — that is the caller's responsibility AFTER
``visual_check_passed=True`` AND ``user_approved=True`` (u6 IMP-46 gate).
"""
from __future__ import annotations
from typing import Any, Callable, Iterable
from src.phase_z2_ai_fallback.router import route_ai_fallback
_AI_ADAPTATION_ROUTE = "ai_adaptation_required"
_DESIGN_REFERENCE_ROUTE = "design_reference_only"
def gather_step12_ai_repair_proposals(
units: Iterable[Any],
*,
route_for_label: Callable[[str | None], str | None],
get_contract_fn: Callable[[str], dict | None],
frame_visual_loader: Callable[[str], str],
figma_partial_loader: Callable[[str], dict] | None = None,
internal_region_lookup: Callable[[Any], dict] | None = None,
mdx_text_loader: Callable[[Any], str] | None = None,
) -> list[dict]:
"""Return one record per unit describing the Step 12 AI repair decision.
The record schema is stable across all gate decisions so the Step 12
artifact consumer can rely on a single shape:
{
"unit_index": int,
"source_section_ids": list[str],
"frame_template_id": str,
"label": str | None,
"route_hint": str | None,
"provisional": bool,
"ai_called": bool,
"skip_reason": str | None,
"proposal": dict | None,
"error": str | None,
}
``ai_called`` is True only when ``route_ai_fallback`` was invoked AND
returned a proposal OR raised. Flag-off / route-mismatch returns
``None`` from the router and is surfaced as ``ai_called=False`` with
``skip_reason="router_short_circuit"`` so the caller can distinguish
"router decided not to run" from "router ran and returned a proposal".
"""
records: list[dict] = []
for index, unit in enumerate(units):
label = getattr(unit, "label", None)
route_hint = route_for_label(label)
record: dict = {
"unit_index": index,
"source_section_ids": list(getattr(unit, "source_section_ids", []) or []),
"frame_template_id": getattr(unit, "frame_template_id", None),
"label": label,
"route_hint": route_hint,
"provisional": bool(getattr(unit, "provisional", False)),
"ai_called": False,
"skip_reason": None,
"proposal": None,
"error": None,
}
if not record["provisional"]:
record["skip_reason"] = "not_provisional"
records.append(record)
continue
if route_hint == _DESIGN_REFERENCE_ROUTE:
record["skip_reason"] = "design_reference_only_no_ai"
records.append(record)
continue
if route_hint != _AI_ADAPTATION_ROUTE:
record["skip_reason"] = f"route_not_ai_adaptation:{route_hint}"
records.append(record)
continue
template_id = record["frame_template_id"] or ""
frame_contract = get_contract_fn(template_id) or {}
frame_visual_html = frame_visual_loader(template_id)
figma_partial_json = (
figma_partial_loader(template_id) if figma_partial_loader is not None else {}
)
internal_region = (
internal_region_lookup(unit) if internal_region_lookup is not None else {}
)
mdx_text = (
mdx_text_loader(unit)
if mdx_text_loader is not None
else (getattr(unit, "raw_content", "") or "")
)
cache_key = "::".join(
[template_id, ",".join(sorted(record["source_section_ids"]))]
)
v4_result = {
"route": route_hint,
"label": label,
"frame_id": getattr(unit, "frame_id", None),
"rank": getattr(unit, "v4_rank", None),
"cardinality": None,
}
try:
proposal = route_ai_fallback(
cache_key=cache_key,
v4_result=v4_result,
frame_contract=frame_contract,
frame_visual_html=frame_visual_html,
figma_partial_json=figma_partial_json,
internal_region=internal_region,
mdx_text=mdx_text,
)
except Exception as exc: # noqa: BLE001 — record + continue, no AI re-raise
record["ai_called"] = True
record["error"] = f"{type(exc).__name__}: {exc}"
records.append(record)
continue
if proposal is None:
record["skip_reason"] = "router_short_circuit"
records.append(record)
continue
record["ai_called"] = True
record["proposal"] = proposal.model_dump()
records.append(record)
return records

View File

@@ -0,0 +1,111 @@
"""IMP-33 u9 — Step 17 AI repair wiring (BLOCKED until IMP-34 + IMP-35 land).
Phase Z Step 17 = retry / salvage cascade (see ``src.phase_z2_pipeline``
section 11.7 ``_attempt_salvage_chain`` and the existing IMP-12 u8/u9
deterministic chain at ``src/phase_z2_pipeline.py:1994`` and
``src/phase_z2_pipeline.py:4948``).
Per IMP-17 carve-out (``docs/architecture/IMP-17-CARVE-OUT.md`` lines 16,
40-44), AI repair at Step 17 is permitted ONLY after the full deterministic
chain is exhausted AND popup escalation is exhausted AND a user-approved
fallback budget remains. IMP-34 (zone resize + compact retry) and IMP-35
(``details_popup_escalation``) are explicit prerequisites under the IMP-33
out-of-scope contract — neither has landed yet. Therefore Step 17 AI repair
is STRUCTURALLY BLOCKED at u9.
This module:
1. **SPECIFIES** the canonical overflow cascade order via
:data:`OVERFLOW_CASCADE_ORDER` — ``deterministic`` → ``popup`` →
``ai_repair`` → ``user_override``. Downstream Step 17 consumers can rely
on this single source of truth.
2. **KEEPS** Step 17 AI repair structurally blocked. The entry point
:func:`gather_step17_ai_repair_proposals` does NOT import
``route_ai_fallback`` (u7), does NOT instantiate ``AiFallbackClient`` (u4),
and does NOT call any Anthropic API. Every unit is recorded with
``skip_reason="step17_ai_blocked_imp_34_35_prerequisites_missing"`` so
the caller can distinguish "blocked by carve-out gate" from any other
skip path (e.g., u8 ``not_provisional`` / ``design_reference_only_no_ai``).
Once IMP-34 + IMP-35 land AND a user-approved fallback budget is granted,
this module will gain the actual ``route_ai_fallback`` wiring guarded by
the cascade-stage conjunction. Today the gate is closed.
"""
from __future__ import annotations
from enum import Enum
from typing import Any, Callable, Iterable
class OverflowCascadeStage(str, Enum):
"""Step 17 overflow cascade stages — canonical order (u9 single source of truth).
Members are ordered to match the AI isolation contract:
* ``DETERMINISTIC`` — IMP-12 u4/u5/u6 (``cross_zone_redistribute`` /
``glue_compression`` / ``font_step_compression``) + IMP-12 terminal
actions (``layout_adjust`` / ``frame_reselect``) + IMP-34
(``zone resize + compact retry``, pending). No AI in any sub-stage.
* ``POPUP`` — IMP-35 (``details_popup_escalation``, pending). Content
popup escalation as the final deterministic resort before any AI.
* ``AI_REPAIR`` — IMP-33 (this carve-out) + IMP-46 cache. Only reachable
after DETERMINISTIC and POPUP are both exhausted AND user-approved
fallback budget remains.
* ``USER_OVERRIDE`` — explicit user override after all auto stages.
"""
DETERMINISTIC = "deterministic"
POPUP = "popup"
AI_REPAIR = "ai_repair"
USER_OVERRIDE = "user_override"
OVERFLOW_CASCADE_ORDER: tuple[OverflowCascadeStage, ...] = (
OverflowCascadeStage.DETERMINISTIC,
OverflowCascadeStage.POPUP,
OverflowCascadeStage.AI_REPAIR,
OverflowCascadeStage.USER_OVERRIDE,
)
STEP17_AI_REPAIR_BLOCKED_REASON = (
"step17_ai_blocked_imp_34_35_prerequisites_missing"
)
def gather_step17_ai_repair_proposals(
units: Iterable[Any],
*,
route_for_label: Callable[[str | None], str | None],
) -> list[dict]:
"""Return one BLOCKED record per unit. No AI call is performed at u9.
The record schema mirrors :func:`src.phase_z2_ai_fallback.step12
.gather_step12_ai_repair_proposals` so the Step 17 artifact consumer can
reuse the same shape, with one addition: ``cascade_stage`` pins the
stage this record belongs to (always ``ai_repair`` here).
Per Stage 2 contract (IMP-33 u9): Step 17 AI repair is blocked behind
IMP-34 + IMP-35. Every unit returns with
``skip_reason=STEP17_AI_REPAIR_BLOCKED_REASON`` and ``ai_called=False``.
"""
records: list[dict] = []
for index, unit in enumerate(units):
label = getattr(unit, "label", None)
record: dict = {
"unit_index": index,
"source_section_ids": list(
getattr(unit, "source_section_ids", []) or []
),
"frame_template_id": getattr(unit, "frame_template_id", None),
"label": label,
"route_hint": route_for_label(label),
"provisional": bool(getattr(unit, "provisional", False)),
"cascade_stage": OverflowCascadeStage.AI_REPAIR.value,
"ai_called": False,
"skip_reason": STEP17_AI_REPAIR_BLOCKED_REASON,
"proposal": None,
"error": None,
}
records.append(record)
return records

View File

@@ -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}."
)