feat(#76): IMP-47B reject-as-AI-adaptation activation (u1~u13 backend + tests)
- u1~u9: AI fallback infrastructure (router/prompts/schema/validator) + Step 12 hook - u10: e2e reject chain (writes final.html with AI-repaired slot, full coverage) - u11: frontend wiring deferred to follow-up commit (split from IMP-41 hunks) - u12: coverage_invariant guard - u13: cache save gate (visual_check PASS + user_approved/auto_cache) — Codex #22 verified Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,48 +1,158 @@
|
||||
"""IMP-33 u6 — AI fallback proposal cache (IMP-46 gate, no persistent storage).
|
||||
"""IMP-46 u2 + u3 + u5 — Persistent JSON cache backend for AI fallback proposals.
|
||||
|
||||
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).
|
||||
Replaces the IMP-33 u6 ``NotImplementedError`` stub with a content-addressed
|
||||
store at ``data/frame_cache/{frame_id}/{signature_hash}.json``.
|
||||
|
||||
Behaviour locked by Stage 2 plan (u6):
|
||||
Key format:
|
||||
|
||||
* ``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:
|
||||
* ``read_proposal(key)`` / ``save_proposal(key, ...)`` accept a string ``key``
|
||||
of the form ``"{frame_id}::{signature_hash}"``. The two components are
|
||||
parsed inside this module so that upstream callers (router, step 12)
|
||||
remain unaware of the on-disk layout.
|
||||
* ``read_proposal`` on a malformed (legacy) key silently returns ``None``
|
||||
— the IMP-33 u7 router currently passes a legacy ``cache_key`` string,
|
||||
and u4 will switch to the structural form. Until then, all such reads
|
||||
must miss safely (no exception, no false hit).
|
||||
* ``save_proposal`` on a malformed key raises ``ValueError`` (loud, never
|
||||
silent) — writes are gated and must use the structural form.
|
||||
|
||||
- ``visual_check_passed=False`` -> ``AiFallbackCacheGateError``
|
||||
- ``user_approved=False`` -> ``AiFallbackCacheGateError``
|
||||
Stored payload (one JSON file per (frame_id, signature_hash) pair):
|
||||
|
||||
Only when BOTH gates are True does control reach the storage layer,
|
||||
which currently raises ``NotImplementedError`` (the IMP-46 marker).
|
||||
{
|
||||
"schema_version": 1,
|
||||
"proposal": <AiFallbackProposal.model_dump(mode="json")>,
|
||||
"slide_css": <str | null>,
|
||||
"fingerprints": {"contract_sha": ..., "partial_sha": ..., "catalog_sha": ...}
|
||||
}
|
||||
|
||||
Guardrails:
|
||||
u3 invalidation contract (this module is a *comparator*, not a *computer*):
|
||||
|
||||
* 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`).
|
||||
* ``save_proposal`` persists the ``fingerprints`` dict supplied by the
|
||||
caller verbatim. Cache.py never computes any fingerprint — the three
|
||||
declared shas (``contract_sha`` / ``partial_sha`` / ``catalog_sha``) are
|
||||
computed by callers from the live contract YAML / partial templates /
|
||||
catalog payloads and handed in. Keeping the computation out of cache.py
|
||||
preserves AI isolation (no Phase Z runtime knowledge in the cache
|
||||
module) and keeps the cache schema-agnostic — additional fingerprint
|
||||
axes can be added without editing cache.py.
|
||||
* ``read_proposal`` accepts an optional ``fingerprints`` kwarg. When
|
||||
supplied, the stored ``fingerprints`` dict must equal the caller's dict
|
||||
exactly (strict equality, NOT subset). Any mismatch — including a key
|
||||
the caller demands but the stored entry lacks, OR a key the stored
|
||||
entry has but the caller does not pass — returns ``None``. Default
|
||||
``fingerprints=None`` performs no comparison (back-compat for legacy
|
||||
callers that have not yet adopted fingerprint-aware lookup).
|
||||
|
||||
Guardrails (locked by Stage 2 plan):
|
||||
|
||||
* Both write gates preserved — ``visual_check_passed=False`` always
|
||||
raises ``AiFallbackCacheGateError`` BEFORE any filesystem touch.
|
||||
``user_approved=False`` also raises by default; the IMP-46 u5
|
||||
``auto_cache=True`` override bypasses ONLY the ``user_approved`` gate
|
||||
(``visual_check_passed`` is never bypassed). Gate violation never
|
||||
silently no-ops.
|
||||
* Missing or corrupt files cause ``read_proposal`` to return ``None`` —
|
||||
the cache is a hint, never a hard dependency. Errors are not propagated
|
||||
to callers because the AI fallback path can always recompute.
|
||||
* ``mkdir(parents=True, exist_ok=True)`` is performed lazily on save.
|
||||
* No Anthropic / MDX / Phase Z runtime imports (AI isolation contract).
|
||||
* Cache root is held as a module-level :data:`CACHE_ROOT` so tests can
|
||||
redirect writes via ``monkeypatch.setattr`` without subclassing.
|
||||
|
||||
u5 auto-cache contract (CLI ``--auto-cache`` + ``settings.ai_fallback_auto_cache``):
|
||||
|
||||
* ``save_proposal(..., auto_cache=True)`` only bypasses the
|
||||
``user_approved`` gate; ``visual_check_passed`` remains mandatory.
|
||||
* ``auto_cache`` is keyword-only and defaults to ``False`` — existing
|
||||
callers (and the test suite) see the original dual-gate behaviour
|
||||
unless they opt in explicitly.
|
||||
* The truth table over ``(visual_check_passed, user_approved, auto_cache)``
|
||||
has eight cells; exactly three succeed:
|
||||
``(True, True, False)``, ``(True, True, True)``, and
|
||||
``(True, False, True)``. Every other cell raises
|
||||
``AiFallbackCacheGateError``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
|
||||
from src.phase_z2_ai_fallback.schema import AiFallbackProposal
|
||||
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
KEY_DELIMITER = "::"
|
||||
CACHE_ROOT: pathlib.Path = pathlib.Path("data/frame_cache")
|
||||
|
||||
|
||||
class AiFallbackCacheGateError(RuntimeError):
|
||||
"""Raised when ``save_proposal`` is called without both IMP-46 gates True."""
|
||||
|
||||
|
||||
def read_proposal(key: str) -> AiFallbackProposal | None:
|
||||
def _parse_key(key: str) -> tuple[str, str] | None:
|
||||
"""Parse a ``frame_id::signature_hash`` key. Returns ``None`` if malformed."""
|
||||
if KEY_DELIMITER not in key:
|
||||
return None
|
||||
frame_id, _, signature_hash = key.partition(KEY_DELIMITER)
|
||||
if not frame_id or not signature_hash:
|
||||
return None
|
||||
if KEY_DELIMITER in signature_hash:
|
||||
return None
|
||||
return frame_id, signature_hash
|
||||
|
||||
|
||||
def _cache_path(frame_id: str, signature_hash: str) -> pathlib.Path:
|
||||
return CACHE_ROOT / frame_id / f"{signature_hash}.json"
|
||||
|
||||
|
||||
def read_proposal(
|
||||
key: str,
|
||||
*,
|
||||
fingerprints: dict | None = None,
|
||||
) -> 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.
|
||||
Returns ``None`` for:
|
||||
|
||||
* empty / non-string key → ``ValueError`` (loud);
|
||||
* non-dict ``fingerprints`` (when supplied) → ``TypeError`` (loud,
|
||||
symmetric with :func:`save_proposal`);
|
||||
* legacy key format (no ``::`` delimiter) → silent ``None`` (router
|
||||
back-compat until u4 switches to the structural form);
|
||||
* missing file under ``data/frame_cache/{frame_id}/{signature_hash}.json``;
|
||||
* corrupt JSON / payload schema mismatch — read errors never propagate;
|
||||
* ``fingerprints`` supplied AND stored ``fingerprints`` field is not a
|
||||
dict OR does not equal the supplied dict (strict equality,
|
||||
u3 invalidation).
|
||||
"""
|
||||
if not isinstance(key, str) or not key:
|
||||
raise ValueError("cache key must be a non-empty string")
|
||||
return None
|
||||
if fingerprints is not None and not isinstance(fingerprints, dict):
|
||||
raise TypeError("fingerprints must be a dict or None")
|
||||
parsed = _parse_key(key)
|
||||
if parsed is None:
|
||||
return None
|
||||
frame_id, signature_hash = parsed
|
||||
path = _cache_path(frame_id, signature_hash)
|
||||
if not path.is_file():
|
||||
return None
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
if fingerprints is not None:
|
||||
stored = data.get("fingerprints")
|
||||
if not isinstance(stored, dict) or stored != fingerprints:
|
||||
return None
|
||||
proposal_dict = data.get("proposal")
|
||||
if not isinstance(proposal_dict, dict):
|
||||
return None
|
||||
try:
|
||||
return AiFallbackProposal.model_validate(proposal_dict)
|
||||
except Exception: # noqa: BLE001 — corrupt payload must miss, not raise
|
||||
return None
|
||||
|
||||
|
||||
def save_proposal(
|
||||
@@ -51,13 +161,39 @@ def save_proposal(
|
||||
*,
|
||||
visual_check_passed: bool,
|
||||
user_approved: bool,
|
||||
) -> None:
|
||||
"""Persist ``proposal`` under ``key`` once both IMP-46 gates are True.
|
||||
slide_css: str | None = None,
|
||||
fingerprints: dict | None = None,
|
||||
auto_cache: bool = False,
|
||||
) -> pathlib.Path:
|
||||
"""Persist ``proposal`` under ``key`` once the IMP-46 gates clear.
|
||||
|
||||
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).
|
||||
Gate contract (IMP-46 u5 truth table):
|
||||
|
||||
* ``visual_check_passed=False`` -> :class:`AiFallbackCacheGateError`
|
||||
always (never bypassable; ``auto_cache`` cannot override).
|
||||
* ``user_approved=False`` AND ``auto_cache=False`` ->
|
||||
:class:`AiFallbackCacheGateError`.
|
||||
* ``user_approved=False`` AND ``auto_cache=True`` -> bypass the
|
||||
user-approval gate (IMP-46 u5 CLI / settings opt-in).
|
||||
* Otherwise (``visual_check_passed=True`` AND either
|
||||
``user_approved=True`` OR ``auto_cache=True``) -> persist payload.
|
||||
|
||||
Gate violations are raised BEFORE any filesystem touch — no parent
|
||||
directory is created, no file is written. When the gates clear the
|
||||
JSON payload (schema_version + proposal + slide_css + fingerprints)
|
||||
is written to ``data/frame_cache/{frame_id}/{signature_hash}.json``
|
||||
and the resolved :class:`pathlib.Path` is returned.
|
||||
|
||||
``slide_css`` may be ``None`` (no slide-level CSS captured) or a
|
||||
string. ``fingerprints`` may be ``None`` (treated as empty dict) or a
|
||||
dict mapping fingerprint name to SHA hex digest.
|
||||
|
||||
``auto_cache`` is keyword-only and defaults to ``False``. It is wired
|
||||
from :data:`src.config.settings.ai_fallback_auto_cache`, which the
|
||||
``--auto-cache`` CLI flag in ``src/phase_z2_pipeline.py`` toggles at
|
||||
parse time. The cache module never reads the setting itself — the
|
||||
caller passes the resolved boolean — so AI-isolation contracts
|
||||
(no Phase Z runtime / no Anthropic import) remain intact.
|
||||
"""
|
||||
if not isinstance(key, str) or not key:
|
||||
raise ValueError("cache key must be a non-empty string")
|
||||
@@ -66,17 +202,42 @@ def save_proposal(
|
||||
"proposal must be an AiFallbackProposal instance "
|
||||
f"(got {type(proposal).__name__})"
|
||||
)
|
||||
if not isinstance(auto_cache, bool):
|
||||
raise TypeError("auto_cache must be a bool")
|
||||
if not visual_check_passed:
|
||||
raise AiFallbackCacheGateError(
|
||||
"IMP-46 gate: visual_check_passed=False; refusing to cache an "
|
||||
"unverified proposal."
|
||||
"unverified proposal. (auto_cache cannot bypass this gate.)"
|
||||
)
|
||||
if not user_approved:
|
||||
if not user_approved and not auto_cache:
|
||||
raise AiFallbackCacheGateError(
|
||||
"IMP-46 gate: user_approved=False; refusing to cache without "
|
||||
"explicit user approval."
|
||||
"IMP-46 gate: user_approved=False and auto_cache=False; "
|
||||
"refusing to cache without explicit user approval. Pass "
|
||||
"auto_cache=True (or --auto-cache on the CLI) to bypass."
|
||||
)
|
||||
raise NotImplementedError(
|
||||
"IMP-46 persistent cache storage is not implemented yet; "
|
||||
"this is the IMP-33 u6 stub marker."
|
||||
if slide_css is not None and not isinstance(slide_css, str):
|
||||
raise TypeError("slide_css must be a string or None")
|
||||
if fingerprints is None:
|
||||
fingerprints = {}
|
||||
elif not isinstance(fingerprints, dict):
|
||||
raise TypeError("fingerprints must be a dict or None")
|
||||
parsed = _parse_key(key)
|
||||
if parsed is None:
|
||||
raise ValueError(
|
||||
"cache key must be in "
|
||||
f"'frame_id{KEY_DELIMITER}signature_hash' format; got {key!r}"
|
||||
)
|
||||
frame_id, signature_hash = parsed
|
||||
path = _cache_path(frame_id, signature_hash)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"proposal": proposal.model_dump(mode="json"),
|
||||
"slide_css": slide_css,
|
||||
"fingerprints": dict(fingerprints),
|
||||
}
|
||||
path.write_text(
|
||||
json.dumps(payload, sort_keys=True, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return path
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""IMP-46 u1 — Frame transformation cache signature builder.
|
||||
|
||||
Deterministic SHA256 over the 8 declared structural axes:
|
||||
frame_id, v4_label, cardinality, source_shape,
|
||||
h3_count, char_count_bucket, layout_preset, zone_position
|
||||
|
||||
Guardrails:
|
||||
* No sample/section identifiers in the signature surface (no-hardcoding lock).
|
||||
* source_shape constrained to the bullet/paragraph/table/mixed enum.
|
||||
* char_count_bucket is the *bucket label*; numeric counts must be projected
|
||||
via :func:`bucket_char_count` before being fed to :func:`build_signature`.
|
||||
* Schema version is embedded in the hashed payload so a future axis change
|
||||
breaks the digest by design (cache invalidation on schema bump).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from enum import Enum
|
||||
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
|
||||
class SourceShape(str, Enum):
|
||||
BULLET = "bullet"
|
||||
PARAGRAPH = "paragraph"
|
||||
TABLE = "table"
|
||||
MIXED = "mixed"
|
||||
|
||||
|
||||
_CHAR_COUNT_BUCKETS: tuple[tuple[int, str], ...] = (
|
||||
(50, "0-50"),
|
||||
(150, "51-150"),
|
||||
(400, "151-400"),
|
||||
(1000, "401-1000"),
|
||||
)
|
||||
_CHAR_COUNT_BUCKET_OVERFLOW = "1001+"
|
||||
CHAR_COUNT_BUCKET_LABELS: tuple[str, ...] = tuple(
|
||||
label for _, label in _CHAR_COUNT_BUCKETS
|
||||
) + (_CHAR_COUNT_BUCKET_OVERFLOW,)
|
||||
|
||||
|
||||
def bucket_char_count(char_count: int) -> str:
|
||||
"""Project a non-negative character count to its fixed bucket label."""
|
||||
if isinstance(char_count, bool) or not isinstance(char_count, int):
|
||||
raise TypeError("char_count must be a non-negative int")
|
||||
if char_count < 0:
|
||||
raise ValueError("char_count must be non-negative")
|
||||
for upper, label in _CHAR_COUNT_BUCKETS:
|
||||
if char_count <= upper:
|
||||
return label
|
||||
return _CHAR_COUNT_BUCKET_OVERFLOW
|
||||
|
||||
|
||||
def build_signature(
|
||||
*,
|
||||
frame_id: str,
|
||||
v4_label: str,
|
||||
cardinality: int | None,
|
||||
source_shape: SourceShape | str,
|
||||
h3_count: int,
|
||||
char_count_bucket: str,
|
||||
layout_preset: str,
|
||||
zone_position: str,
|
||||
) -> str:
|
||||
"""Return a deterministic SHA256 hex digest over the 8 declared axes."""
|
||||
if isinstance(source_shape, SourceShape):
|
||||
source_shape_value = source_shape.value
|
||||
elif isinstance(source_shape, str):
|
||||
source_shape_value = SourceShape(source_shape).value
|
||||
else:
|
||||
raise TypeError("source_shape must be SourceShape or str")
|
||||
if char_count_bucket not in CHAR_COUNT_BUCKET_LABELS:
|
||||
raise ValueError(
|
||||
f"char_count_bucket={char_count_bucket!r} is not a known bucket "
|
||||
f"label (expected one of {CHAR_COUNT_BUCKET_LABELS})"
|
||||
)
|
||||
payload = {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"frame_id": frame_id,
|
||||
"v4_label": v4_label,
|
||||
"cardinality": cardinality,
|
||||
"source_shape": source_shape_value,
|
||||
"h3_count": h3_count,
|
||||
"char_count_bucket": char_count_bucket,
|
||||
"layout_preset": layout_preset,
|
||||
"zone_position": zone_position,
|
||||
}
|
||||
encoded = json.dumps(payload, sort_keys=True, ensure_ascii=False).encode("utf-8")
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
@@ -1,32 +1,72 @@
|
||||
"""IMP-33 u8 — Step 12 AI repair wiring (IMP-30 provisional units only).
|
||||
"""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``. Two structural gates
|
||||
preserve the AI isolation contract:
|
||||
``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).
|
||||
* 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.
|
||||
|
||||
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.router import route_ai_fallback
|
||||
from src.phase_z2_ai_fallback.signature import bucket_char_count, build_signature
|
||||
|
||||
|
||||
_AI_ADAPTATION_ROUTE = "ai_adaptation_required"
|
||||
_DESIGN_REFERENCE_ROUTE = "design_reference_only"
|
||||
|
||||
|
||||
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(
|
||||
@@ -38,6 +78,7 @@ def gather_step12_ai_repair_proposals(
|
||||
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.
|
||||
|
||||
@@ -55,8 +96,16 @@ def gather_step12_ai_repair_proposals(
|
||||
"skip_reason": str | None,
|
||||
"proposal": dict | None,
|
||||
"error": str | None,
|
||||
"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
|
||||
@@ -64,6 +113,9 @@ def gather_step12_ai_repair_proposals(
|
||||
"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)
|
||||
@@ -78,15 +130,13 @@ def gather_step12_ai_repair_proposals(
|
||||
"skip_reason": None,
|
||||
"proposal": None,
|
||||
"error": None,
|
||||
"cache_key": None,
|
||||
"fingerprints": 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)
|
||||
@@ -106,15 +156,40 @@ def gather_step12_ai_repair_proposals(
|
||||
if mdx_text_loader is not None
|
||||
else (getattr(unit, "raw_content", "") or "")
|
||||
)
|
||||
cache_key = "::".join(
|
||||
[template_id, ",".join(sorted(record["source_section_ids"]))]
|
||||
|
||||
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": None,
|
||||
"cardinality": cardinality,
|
||||
}
|
||||
try:
|
||||
proposal = route_ai_fallback(
|
||||
|
||||
Reference in New Issue
Block a user