Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 20s
u1: text_overrides axis in user_overrides_io u2: structure_overrides axis in user_overrides_io u3: vite allowlist for new endpoints u4: text_override_resolver u5: Step 12 text_overrides apply in phase_z2_pipeline u6: structure_override_resolver u7: text_path_stamper u8: SlideCanvas text-edit capture u9: SlideCanvas structure-edit overlay u10: userOverridesApi service extension u11: designAgent types extension u12: slidePlanUtils restore u13: user_overrides endpoint tests u14: user_overrides restore tests u15: pipeline fallback tests u16: edit-mode state + gating tests u17: slide_base print mode CSS u18: /api/connect endpoint (vite) u19: /api/export endpoint (vite) Recovery scope: 29 files (12 modified + 17 new). u20 already pushed in 9439575; this commit lands u1-u19 that were authored but not committed before #90 was externally closed. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
190 lines
7.5 KiB
Python
190 lines
7.5 KiB
Python
"""IMP-56 (#90) u6 — structure_override resolver (validator + apply).
|
|
|
|
Step-22 user structure-edit persist axis. Consumed by Step 12 (u7 wiring)
|
|
so a prior render's reorder / hide choices re-apply to the next render
|
|
without re-clicking.
|
|
|
|
Schema (defined verbatim in ``src/user_overrides_io.py:30`` u2) ::
|
|
|
|
structure_overrides = {
|
|
<zone_id>: {
|
|
"slot_order": [<slot_key>, ...], # optional, partial reorder
|
|
"hidden_slots": [<slot_key>, ...], # optional, hide these slot_keys
|
|
},
|
|
...
|
|
}
|
|
|
|
SCOPE LOCK (Stage 2 u6 contract, IMP-56 #90 u2 docstring) :
|
|
|
|
The only allowed inner keys are ``slot_order`` and ``hidden_slots``.
|
|
Any other key (e.g., ``frame_id``, ``template_id``, ``unit_id``,
|
|
``slot_payload``) is treated as a frame-swap / DOM-rebuild attempt and
|
|
is DROPPED at validate time. Frame swap stays on the existing
|
|
``frames`` axis so the Phase Z no-AI-HTML-structure invariant remains
|
|
intact. There is intentionally NO escape hatch through this axis.
|
|
|
|
API (deterministic, no AI) :
|
|
|
|
- ``validate_structure_overrides(overrides)`` → sanitized copy. Per-entry
|
|
tolerant (drops malformed rows; never rejects the whole batch — mirrors
|
|
``src.text_override_resolver.validate_text_overrides`` u4 contract).
|
|
- ``apply_structure_override(zone, override)`` → ``True`` if the slot-payload
|
|
mapping was mutated (any hide or any reorder), ``False`` otherwise. The
|
|
``zone`` argument is the slot-payload mapping at Step 12 (a mutable
|
|
mapping whose keys are slot_keys and whose values are typically
|
|
``list[str]`` of lines). Identity-preserving: mutates in-place via
|
|
``clear`` + ``update`` so caller references remain valid.
|
|
|
|
Guardrails (refs : Stage 1 binding contract, Stage 2 unit u6) :
|
|
|
|
- raw_content preservation is a wiring-layer (u7) responsibility — the
|
|
resolver only ever reorders / removes top-level slot_payload entries.
|
|
Per-slot ``list[str]`` line content is never inspected or mutated here.
|
|
- AI-isolation : pure deterministic Python; no LLM calls.
|
|
- Carve-out (IMP-46 #62) : brand-new module, does not touch the #76
|
|
commit ``1186ad8`` cache region.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Mapping, MutableMapping
|
|
|
|
|
|
class InvalidStructureOverride(ValueError):
|
|
"""Reserved for future strict-mode parse errors.
|
|
|
|
Currently unused — the resolver follows the u4 per-entry-tolerant
|
|
contract and silently drops malformed rows at validate time rather
|
|
than raising. Kept as a public surface so u7 wiring (and future
|
|
strict-mode callers) can distinguish source-malformation from
|
|
stale-DOM misses without an API rev.
|
|
"""
|
|
|
|
|
|
_ALLOWED_INNER_KEYS: frozenset[str] = frozenset({"slot_order", "hidden_slots"})
|
|
|
|
|
|
def _sanitize_slot_list(raw: Any) -> list[str]:
|
|
"""Return a fresh list of non-empty string slot_keys (drop the rest)."""
|
|
if not isinstance(raw, list):
|
|
return []
|
|
out: list[str] = []
|
|
seen: set[str] = set()
|
|
for slot in raw:
|
|
if not isinstance(slot, str) or not slot:
|
|
continue
|
|
if slot in seen:
|
|
# De-dup defensively — a duplicate slot_key in slot_order would
|
|
# be meaningless (dicts can hold each key once); duplicate in
|
|
# hidden_slots is redundant. Drop subsequent occurrences.
|
|
continue
|
|
seen.add(slot)
|
|
out.append(slot)
|
|
return out
|
|
|
|
|
|
def validate_structure_overrides(
|
|
overrides: Any,
|
|
) -> dict[str, dict[str, list[str]]]:
|
|
"""Return a sanitized copy of ``overrides`` (per-entry tolerant).
|
|
|
|
Drops:
|
|
- non-string or empty zone_ids,
|
|
- non-mapping per-zone payloads,
|
|
- per-zone inner keys other than ``slot_order`` / ``hidden_slots``
|
|
(frame-swap attempts are dropped at this gate — see SCOPE LOCK),
|
|
- non-list ``slot_order`` / ``hidden_slots`` values,
|
|
- non-string or empty slot_key entries within those lists,
|
|
- per-zone payloads that contain neither a non-empty ``slot_order``
|
|
nor a non-empty ``hidden_slots`` after sanitization (empty intent
|
|
carries no signal).
|
|
|
|
Returns a fresh ``dict`` AND fresh nested dicts / lists so callers can
|
|
use the result as a working buffer without aliasing the persisted
|
|
payload from ``user_overrides_io.load``.
|
|
"""
|
|
if not isinstance(overrides, Mapping):
|
|
return {}
|
|
out: dict[str, dict[str, list[str]]] = {}
|
|
for zone_id, mapping in overrides.items():
|
|
if not isinstance(zone_id, str) or not zone_id:
|
|
continue
|
|
if not isinstance(mapping, Mapping):
|
|
continue
|
|
zone_out: dict[str, list[str]] = {}
|
|
for inner_key, inner_value in mapping.items():
|
|
if inner_key not in _ALLOWED_INNER_KEYS:
|
|
# Frame-swap attempt or unknown key — drop silently per
|
|
# SCOPE LOCK. No mechanism through this axis.
|
|
continue
|
|
sanitized = _sanitize_slot_list(inner_value)
|
|
if sanitized:
|
|
zone_out[inner_key] = sanitized
|
|
if zone_out:
|
|
out[zone_id] = zone_out
|
|
return out
|
|
|
|
|
|
def apply_structure_override(
|
|
zone: MutableMapping[str, Any],
|
|
override: Mapping[str, Any],
|
|
) -> bool:
|
|
"""Apply ONE structure override to ``zone`` in-place.
|
|
|
|
``zone`` is the slot-payload mapping at Step 12 — i.e. a mutable
|
|
mapping whose keys are slot_keys and whose values are the per-slot
|
|
line lists (or other content payload). Mutation is restricted to
|
|
top-level key membership + ordering; per-slot values are NEVER
|
|
inspected or modified here.
|
|
|
|
``override`` is the per-zone payload after :func:`validate_structure_overrides`
|
|
sanitization — i.e. a mapping with only ``slot_order`` and / or
|
|
``hidden_slots`` keys, each holding a list of non-empty str slot_keys.
|
|
This function is also defensive: if non-list values leak through, they
|
|
are treated as empty (no raise).
|
|
|
|
Semantics :
|
|
1. ``hidden_slots`` are popped first. Entries absent from ``zone``
|
|
are silently skipped (stale slot_keys from a prior frame).
|
|
2. ``slot_order`` partially reorders the surviving slot_keys:
|
|
listed keys (that are present in ``zone``) move to the front in
|
|
the given order; remaining keys keep their original relative
|
|
order at the tail. Unknown slot_keys are silently skipped.
|
|
|
|
Returns ``True`` if the zone's slot-payload mapping was mutated (any
|
|
hide that removed a key OR any reorder that changed key order),
|
|
``False`` otherwise. Identity-preserving: rebuilds via
|
|
``clear`` + ``update`` so the caller's reference to ``zone`` remains
|
|
valid.
|
|
"""
|
|
mutated = False
|
|
|
|
raw_hidden = override.get("hidden_slots") if isinstance(override, Mapping) else None
|
|
hidden = _sanitize_slot_list(raw_hidden)
|
|
for slot in hidden:
|
|
if slot in zone:
|
|
del zone[slot]
|
|
mutated = True
|
|
|
|
raw_order = override.get("slot_order") if isinstance(override, Mapping) else None
|
|
desired_order_seed = _sanitize_slot_list(raw_order)
|
|
|
|
current_order = list(zone.keys())
|
|
desired_order: list[str] = []
|
|
seen: set[str] = set()
|
|
for slot in desired_order_seed:
|
|
if slot in zone and slot not in seen:
|
|
desired_order.append(slot)
|
|
seen.add(slot)
|
|
for slot in current_order:
|
|
if slot not in seen:
|
|
desired_order.append(slot)
|
|
seen.add(slot)
|
|
|
|
if desired_order != current_order:
|
|
snapshot = {k: zone[k] for k in desired_order}
|
|
zone.clear()
|
|
zone.update(snapshot)
|
|
mutated = True
|
|
|
|
return mutated
|