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:
@@ -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"}
|
||||
|
||||
|
||||
|
||||
15
src/phase_z2_ai_fallback/__init__.py
Normal file
15
src/phase_z2_ai_fallback/__init__.py
Normal 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"]
|
||||
82
src/phase_z2_ai_fallback/cache.py
Normal file
82
src/phase_z2_ai_fallback/cache.py
Normal 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."
|
||||
)
|
||||
92
src/phase_z2_ai_fallback/client.py
Normal file
92
src/phase_z2_ai_fallback/client.py
Normal 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
|
||||
80
src/phase_z2_ai_fallback/prompts.py
Normal file
80
src/phase_z2_ai_fallback/prompts.py
Normal 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),
|
||||
}
|
||||
89
src/phase_z2_ai_fallback/router.py
Normal file
89
src/phase_z2_ai_fallback/router.py
Normal 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
|
||||
50
src/phase_z2_ai_fallback/schema.py
Normal file
50
src/phase_z2_ai_fallback/schema.py
Normal 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
|
||||
141
src/phase_z2_ai_fallback/step12.py
Normal file
141
src/phase_z2_ai_fallback/step12.py
Normal 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
|
||||
111
src/phase_z2_ai_fallback/step17.py
Normal file
111
src/phase_z2_ai_fallback/step17.py
Normal 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
|
||||
83
src/phase_z2_ai_fallback/validate.py
Normal file
83
src/phase_z2_ai_fallback/validate.py
Normal 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}."
|
||||
)
|
||||
Reference in New Issue
Block a user