"""IMP-33 u8 + IMP-46 u4 — Step 12 AI repair wiring with structural cache key. 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``. One structural gate preserves 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). Per IMP-47B u1+u2, the ``reject`` V4 label routes to ``ai_adaptation_required`` (no longer ``design_reference_only``) and is admitted to the AI repair path; the legacy "reject gate" short-circuit is removed. Any unit whose ``route_hint`` is not ``ai_adaptation_required`` still falls through to the catch-all ``route_not_ai_adaptation:`` skip — that single gate continues to enforce the AI=0 normal path. 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). IMP-46 u4 — structural cache key + fingerprints ------------------------------------------------ The legacy ``cache_key`` was ``"{template_id}::{sorted(source_section_ids)}"`` which leaked sample / section identity into the cache surface (no-hardcoding lock violation: structurally identical content with different MDX section ids would miss). u4 replaces it with ``"{frame_id}::{signature_hash}"`` where ``signature_hash`` is the deterministic SHA256 over the 8 declared structural axes (see ``src.phase_z2_ai_fallback.signature``). Per-unit signature inputs are read from unit attributes: * ``cardinality`` (int | None) — also forwarded to ``v4_result`` * ``layout_preset`` (str) * ``zone_position`` (str) * ``source_shape`` (str) — bullet / paragraph / table / mixed * ``h3_count`` (int) * ``char_count`` (int) — bucketed via ``bucket_char_count`` In parallel the three invalidation fingerprints (``contract_sha`` / ``partial_sha`` / ``catalog_sha``) are computed and attached to the record. The cache.py module remains a *comparator* — all fingerprint *computation* happens here (or via injected loaders) so the cache schema-agnostic contract is preserved. The router's existing ``read_proposal(cache_key)`` continues to perform exact-match lookup only (fuzzy is deferred per Stage 2 plan); read-side fingerprint validation through the router is a follow-up axis. """ from __future__ import annotations import hashlib import json from typing import Any, Callable, Iterable from src.phase_z2_ai_fallback.client import classify_operational_error from src.phase_z2_ai_fallback.router import route_ai_fallback from src.phase_z2_ai_fallback.signature import bucket_char_count, build_signature _AI_ADAPTATION_ROUTE = "ai_adaptation_required" def _sha256_of(payload: Any) -> str: """Deterministic SHA256 hex digest over a JSON-serialisable payload.""" encoded = json.dumps(payload, sort_keys=True, ensure_ascii=False).encode("utf-8") return hashlib.sha256(encoded).hexdigest() 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, catalog_sha_loader: Callable[[], 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, "api_error_kind": str | None, # IMP-92 u2 (quota|billing|auth|other) "cache_key": str | None, # IMP-46 u4 "fingerprints": dict | None, # IMP-46 u4 } ``cache_key`` and ``fingerprints`` are populated only when the unit reaches the AI-eligible code path (provisional + ai_adaptation route). Skipped units retain ``None`` for both — the structural axes (layout_preset / zone_position / source_shape / h3_count / char_count) are not guaranteed to be set for non-AI paths. ``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] = [] catalog_sha = ( catalog_sha_loader() if catalog_sha_loader is not None else "" ) 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, "api_error_kind": None, "cache_key": None, "fingerprints": None, } if not record["provisional"]: record["skip_reason"] = "not_provisional" 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 "") ) frame_id_value = getattr(unit, "frame_id", "") or "" cardinality = getattr(unit, "cardinality", None) layout_preset = getattr(unit, "layout_preset", "") or "" zone_position = getattr(unit, "zone_position", "") or "" source_shape = getattr(unit, "source_shape", "paragraph") or "paragraph" h3_count = int(getattr(unit, "h3_count", 0) or 0) char_count = int(getattr(unit, "char_count", 0) or 0) char_count_bucket = bucket_char_count(char_count) signature_hash = build_signature( frame_id=frame_id_value, v4_label=label or "", cardinality=cardinality, source_shape=source_shape, h3_count=h3_count, char_count_bucket=char_count_bucket, layout_preset=layout_preset, zone_position=zone_position, ) cache_key = f"{frame_id_value}::{signature_hash}" fingerprints = { "contract_sha": _sha256_of(frame_contract), "partial_sha": _sha256_of(figma_partial_json), "catalog_sha": catalog_sha, } record["cache_key"] = cache_key record["fingerprints"] = fingerprints v4_result = { "route": route_hint, "label": label, "frame_id": getattr(unit, "frame_id", None), "rank": getattr(unit, "v4_rank", None), "cardinality": cardinality, } 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, fingerprints=fingerprints, ) except Exception as exc: # noqa: BLE001 — record + continue, no AI re-raise record["ai_called"] = True record["error"] = f"{type(exc).__name__}: {exc}" record["api_error_kind"] = classify_operational_error(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