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>
144 lines
5.5 KiB
Python
144 lines
5.5 KiB
Python
"""IMP-56 (#90) u4 — text_override resolver (validator + apply).
|
|
|
|
Step-22 user text-edit persist axis. Consumed by Step 12 (u5 wiring) so a
|
|
prior render's text edits re-apply to the next render without re-clicking.
|
|
|
|
Schema (defined verbatim in ``src/user_overrides_io.py:29`` u1) ::
|
|
|
|
text_overrides = {
|
|
<zone_id>: {<text_path>: <value: str>},
|
|
...
|
|
}
|
|
|
|
``text_path`` is the ``{slot_key}.{line_index}`` stamp emitted at Step 13
|
|
by the u8 ``text_path_stamper`` (pending unit) and surfaced to the frontend
|
|
SlideCanvas (u12) as ``data-text-path`` attributes on editable text nodes.
|
|
The ``{slot_key}`` is a frame contract slot identifier (e.g.,
|
|
``slot_title``); the ``{line_index}`` is the 0-based ordinal of the line
|
|
within that slot's rendered text (typically one bullet / one paragraph).
|
|
|
|
API (deterministic, no AI) :
|
|
|
|
- ``parse_text_path(text_path)`` → ``(slot_key, line_index)`` or raises.
|
|
- ``validate_text_overrides(overrides)`` → sanitized copy (drops malformed
|
|
per-entry; never rejects the whole batch — mirrors the per-entry
|
|
tolerance contract of ``src.image_id_stamper.build_image_overrides_style``
|
|
IMP-51 #79 u7).
|
|
- ``apply_text_override(zone, text_path, value)`` → ``True`` on in-place
|
|
mutation; ``False`` if the path is absent / out-of-range. The ``zone``
|
|
argument is the slot-lines mapping at Step 12 — i.e. a mutable mapping
|
|
where ``zone[slot_key]`` is a ``list[str]`` of line strings. Wiring at
|
|
Step 12 (u5) is responsible for extracting that mapping from whatever
|
|
composition object holds it; this resolver is decoupled from the wrapper
|
|
shape so it can be re-targeted at Stage 5 (Step 12) layer-A or layer-B
|
|
composition data without an API rev.
|
|
|
|
Guardrails (refs : Stage 1 binding contract, Stage 2 unit u4) :
|
|
|
|
- raw_content preservation is a wiring-layer (u5) responsibility — the
|
|
resolver itself only ever mutates the lines mapping it was handed.
|
|
- 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 InvalidTextOverride(ValueError):
|
|
"""Raised when a ``text_path`` is malformed (parse-time)."""
|
|
|
|
|
|
def parse_text_path(text_path: str) -> tuple[str, int]:
|
|
"""Parse ``{slot_key}.{line_index}`` into ``(slot_key, line_index)``.
|
|
|
|
``slot_key`` may itself contain ``.`` (e.g., compound keys), so the
|
|
parse splits on the LAST ``.`` only — ``rpartition`` semantics.
|
|
"""
|
|
if not isinstance(text_path, str) or not text_path:
|
|
raise InvalidTextOverride(
|
|
f"text_path must be a non-empty string, got: {text_path!r}"
|
|
)
|
|
if "." not in text_path:
|
|
raise InvalidTextOverride(
|
|
f"text_path must contain '.' separator, got: {text_path!r}"
|
|
)
|
|
slot_key, _, idx_str = text_path.rpartition(".")
|
|
if not slot_key or not idx_str:
|
|
raise InvalidTextOverride(
|
|
f"text_path slot_key and line_index must both be non-empty, "
|
|
f"got: {text_path!r}"
|
|
)
|
|
try:
|
|
idx = int(idx_str)
|
|
except ValueError as exc:
|
|
raise InvalidTextOverride(
|
|
f"text_path line_index must be int, got: {text_path!r}"
|
|
) from exc
|
|
if idx < 0:
|
|
raise InvalidTextOverride(
|
|
f"text_path line_index must be >= 0, got: {idx} in {text_path!r}"
|
|
)
|
|
return slot_key, idx
|
|
|
|
|
|
def validate_text_overrides(overrides: Any) -> dict[str, dict[str, str]]:
|
|
"""Return a sanitized copy of ``overrides`` (per-entry tolerant).
|
|
|
|
Drops:
|
|
- non-string or empty zone_ids,
|
|
- non-mapping per-zone payloads,
|
|
- non-string text_path keys, non-string values,
|
|
- text_paths that fail :func:`parse_text_path`.
|
|
|
|
Returns a fresh ``dict`` so callers can mutate without aliasing the
|
|
persisted payload from ``user_overrides_io.load``.
|
|
"""
|
|
if not isinstance(overrides, Mapping):
|
|
return {}
|
|
out: dict[str, dict[str, 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, str] = {}
|
|
for text_path, value in mapping.items():
|
|
if not isinstance(text_path, str) or not isinstance(value, str):
|
|
continue
|
|
try:
|
|
parse_text_path(text_path)
|
|
except InvalidTextOverride:
|
|
continue
|
|
zone_out[text_path] = value
|
|
if zone_out:
|
|
out[zone_id] = zone_out
|
|
return out
|
|
|
|
|
|
def apply_text_override(
|
|
zone: MutableMapping[str, Any],
|
|
text_path: str,
|
|
value: str,
|
|
) -> bool:
|
|
"""Apply ONE text override to ``zone`` in-place.
|
|
|
|
``zone`` is the slot-lines mapping at Step 12 — i.e. a mutable mapping
|
|
where ``zone[slot_key]`` is a ``list[str]`` of line strings.
|
|
|
|
Returns ``True`` when the value was replaced. Returns ``False`` (no
|
|
mutation) when the ``slot_key`` is absent, the slot is not a list, or
|
|
``line_index`` is out of range. Out-of-range / absent paths are NOT an
|
|
error — they happen naturally when a prior render's overrides target a
|
|
slot the new render no longer emits (frame swap, layout regression).
|
|
"""
|
|
slot_key, idx = parse_text_path(text_path)
|
|
if slot_key not in zone:
|
|
return False
|
|
lines = zone[slot_key]
|
|
if not isinstance(lines, list) or idx >= len(lines):
|
|
return False
|
|
lines[idx] = value
|
|
return True
|