Files
C.E.L_Slide_test2/src/text_override_resolver.py
T
KyeongminandClaude 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

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