"""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, fingerprints: dict | 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). ``fingerprints`` is forwarded into ``read_proposal`` so that contract / partial / catalog SHA mismatches invalidate stale cache entries (IMP-46 #62 Axis R). When ``None`` the cache layer skips fingerprint comparison (legacy behaviour). """ 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, fingerprints=fingerprints) 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