Replaces #84 UI-noise removal plan with positive operational-alert contract. Five-axis stack lands together: (1) default model literal moved to current Opus-family ID, (2) Anthropic SDK error classifier mapping exceptions to quota/billing/auth/other, (3) api_error_kind plumbed through ai_repair_status summary + per-record retention, (4) Step 0 preflight ping gated under ai_fallback_enabled (default OFF preserved) with fail-fast on invalid model/key, (5) frontend formatter rewritten to surface only operational quota/billing/auth toasts (non-operational paths return null per feedback_auto_pipeline_first silent-pipeline policy). u1 - default model literal claude-opus-4-6-20250415 -> claude-opus-4-7 (src/config.py + tests/test_phase_z2_ai_fallback_config.py lock mirror) u2 - classify_operational_error type+status_code dispatch + Step 12 api_error_kind stamp on except path (src/phase_z2_ai_fallback/client.py + src/phase_z2_ai_fallback/step12.py + tests/phase_z2_ai_fallback/test_step12.py) u3 - _summarize_ai_repair_status aggregates api_error_kinds {quota,billing, auth,other}; error_records[i].api_error_kind retained per-record (src/phase_z2_pipeline.py + tests/test_imp47b_failure_surface.py) u4 - _run_step0_ai_preflight + Step0PreflightError; preflight only fires when ai_fallback_enabled=true; one-token ping; invalid key/model => setup failure before Step 1 (src/phase_z2_pipeline.py + tests/phase_z2/test_pipeline_step0_preflight.py NEW) u5 - AiRepairStatus.api_error_kinds? interface + formatAiRepairHumanReview Message rewritten: operational quota/billing/auth -> Korean copy verbatim from issue body (tie-break quota -> billing -> auth); validation/coverage_violated/unsupported_kind/generic-other/legacy payload -> null (Front/client/src/services/designAgentApi.ts + Front/client/tests/imp47b_human_review_toast.test.tsx) Guardrails respected: - feedback_demo_env_toggle_policy: default OFF preserved; preflight skipped when ai_fallback_enabled=false (test_preflight_skipped_when_disabled asserts anthropic.Anthropic() not called). - feedback_auto_pipeline_first: non-operational AI failures stay silent; only quota/billing/auth reach user toast. - feedback_ai_isolation_contract: AI remains fallback-only; no normal-path migration; MDX preserved. - project_imp46_carveout_caveat: cache_key/fingerprints fields untouched on every record; no overlap with #62 cache region. - feedback_no_hardcoding: zero MDX-sample-specific literals; classifier dispatch by SDK type, not by string parsing. - feedback_artifact_status_naming: operational toast scoped to alert axis, not overall PASS signal. Tests: - Targeted u1+u2+u3+u4: 63 passed - u5 vitest (Front/): 10/10 passed - tests/phase_z2_ai_fallback dir regression: 240 passed - tests/phase_z2 dir regression: 323 passed - IMP-92-adjacent (-k "imp47b or ai_fallback or preflight or step12 or step0"): 299 passed (808 deselected) - u1 baseline lock (test_client_mock.py): 8 passed Zero failures, zero regressions outside scope. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
222 lines
9.4 KiB
Python
222 lines
9.4 KiB
Python
"""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:<hint>``
|
|
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
|