Files
C.E.L_Slide_test2/src/structure_override_resolver.py
T
kmlee1andClaude Opus 4.7 4da22adb43
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 20s
feat(#90): IMP-56 u1-u19 catch-up before final close (post-u20 push fix)
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>
2026-05-26 06:12:13 +09:00

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