feat(#90): IMP-56 u1-u19 catch-up before final close (post-u20 push fix)
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>
This commit is contained in:
2026-05-26 06:12:13 +09:00
co-authored by Claude Opus 4.7
parent 943957562f
commit 4da22adb43
29 changed files with 4937 additions and 78 deletions
+267 -1
View File
@@ -109,6 +109,32 @@ from src.phase_z2_ai_fallback.step17 import run_step17_popup_gate
# WRITE the snapshot — restore wiring lands in u4.
from src.phase_z2_reuse_snapshot import build_snapshot, SNAPSHOT_FILENAME
# IMP-56 (#90) u5 — Step 12 text_overrides apply. Pure deterministic; no AI.
# u4 resolver imports are aliased with leading-underscore so the helper at
# `_apply_text_overrides_to_zones` reads as a pipeline-private wrapper
# (mirrors the AI-repair u5 / image-overrides u7 wrapper naming pattern).
from src.text_override_resolver import (
apply_text_override as _apply_text_override,
validate_text_overrides as _validate_text_overrides,
InvalidTextOverride as _InvalidTextOverride,
)
# IMP-56 (#90) u7 — Step 12 structure_overrides apply. Pure deterministic; no AI.
# u6 resolver imports are aliased with leading-underscore so the helper at
# `_apply_structure_overrides_to_zones` reads as a pipeline-private wrapper
# (mirrors the u5 text_overrides wrapper naming pattern). SCOPE LOCKED to
# slot_order + hidden_slots — frame swap stays on the existing `frames`
# axis to preserve Phase Z's no-AI-HTML-structure invariant.
from src.structure_override_resolver import (
apply_structure_override as _apply_structure_override,
validate_structure_overrides as _validate_structure_overrides,
)
# IMP-56 (#90) u9 — Step 13 text_path stamping wired into render_slide.
# u8 stamper injects ``data-text-path="{slot_key}.{line_index}"`` onto each
# ``text-line`` opening tag so the frontend SlideCanvas (u10+) can attribute
# per-line edits back to the ``text_overrides`` axis (u1 schema, u4 resolver,
# u5 Step-12 apply). Pure deterministic; no AI / HTTP / subprocess.
from src.text_path_stamper import stamp_zone_html as _stamp_zone_html
# ─── Constants ──────────────────────────────────────────────────
@@ -882,6 +908,102 @@ def _apply_ai_repair_proposals_to_zones(
record["apply_status"] = "applied:partial_overrides"
def _apply_text_overrides_to_zones(
text_overrides: dict,
zones_data: list[dict],
) -> dict:
"""IMP-56 (#90) u5 — Apply persisted ``text_overrides`` to Step 12 zones.
Captures the audit shape the Step 12 ``text_overrides`` artifact emits
so reviewers can see which user text edits re-applied to the next render
and which silently skipped (stale ``text_path`` after a frame swap /
layout regression). Per-entry tolerant by design mirrors the
``image_overrides`` u7 contract so a single malformed row never blocks
a batch (Phase Z PZ-4 "no silent shrink" surface, don't drop).
raw_content preservation : the function mutates ``zone["slot_payload"]``
in place only ``debug_zones[i].source_section_ids`` and the
``MdxSection`` graph live elsewhere and stay byte-identical. This honours
the Stage 1 binding contract ("raw_content preserved at Step 12") and
the global no-silent-shrink rule (PZ-4).
"""
sanitized = _validate_text_overrides(text_overrides or {})
applied = 0
skipped = 0
per_zone: list[dict] = []
for zone in zones_data:
zone_id = zone.get("position")
if not isinstance(zone_id, str) or zone_id not in sanitized:
continue
slot_payload = zone.get("slot_payload")
if not isinstance(slot_payload, dict):
continue
z_applied = 0
z_skipped = 0
for text_path, value in sanitized[zone_id].items():
try:
ok = _apply_text_override(slot_payload, text_path, value)
except _InvalidTextOverride:
ok = False
if ok:
z_applied += 1
else:
z_skipped += 1
applied += z_applied
skipped += z_skipped
per_zone.append({
"position": zone_id,
"applied": z_applied,
"skipped": z_skipped,
})
return {"applied": applied, "skipped": skipped, "per_zone": per_zone}
def _apply_structure_overrides_to_zones(
structure_overrides: dict,
zones_data: list[dict],
) -> dict:
"""IMP-56 (#90) u7 — Apply persisted ``structure_overrides`` at Step 12.
SCOPE LOCK : only top-level ``slot_payload`` key membership + ordering
is mutated (reorder + hide). NO DOM rebuild. NO frame swap the u6
validate gate drops any non-``slot_order`` / non-``hidden_slots`` inner
key silently. Frame swap stays on the existing ``frames`` axis.
Per-zone tolerant stale slot_keys (frame swap / layout regression
between renders) silently no-op via ``apply_structure_override``'s
``False`` return (counted as ``skipped_zones``).
raw_content preservation : per-slot ``list[str]`` line content is
NEVER inspected or modified here the resolver only reorders /
removes top-level slot_payload keys. ``debug_zones[i].source_section_ids``
+ the ``MdxSection`` graph stay byte-identical (mirrors the u5 wiring
invariant + Stage 1 binding contract).
"""
sanitized = _validate_structure_overrides(structure_overrides or {})
applied_zones = 0
skipped_zones = 0
per_zone: list[dict] = []
for zone in zones_data:
zone_id = zone.get("position")
if not isinstance(zone_id, str) or zone_id not in sanitized:
continue
slot_payload = zone.get("slot_payload")
if not isinstance(slot_payload, dict):
continue
mutated = _apply_structure_override(slot_payload, sanitized[zone_id])
if mutated:
applied_zones += 1
else:
skipped_zones += 1
per_zone.append({"position": zone_id, "mutated": mutated})
return {
"applied_zones": applied_zones,
"skipped_zones": skipped_zones,
"per_zone": per_zone,
}
def _check_post_ai_coverage_invariant(
units,
ai_repair_records: list[dict],
@@ -3131,7 +3253,14 @@ def render_slide(slide_title: str, slide_footer: Optional[str],
rendered_partial,
f"zones_data[{zone_index}] template_id={template_id!r}",
)
zone["partial_html"] = rendered_partial
# IMP-56 (#90) u9 — Step 13 text_path stamp (u8 stamper, pure
# deterministic). Injects ``data-text-path="{slot_key}.{line_index}"``
# onto each ``text-line`` opening tag so the frontend SlideCanvas
# (u10+) can attribute per-line edits back to the text_overrides
# axis. Idempotent + forward-compat: non-list slots are silently
# skipped, excess text-lines pass through unstamped, and an
# already-stamped element is left unchanged.
zone["partial_html"] = _stamp_zone_html(rendered_partial, slot_payload)
base = env.get_template("slide_base.html")
rendered_base = base.render(
@@ -4889,6 +5018,8 @@ def run_phase_z2_mvp1(
override_section_assignments: Optional[dict[str, list[str]]] = None,
override_image_overrides: Optional[dict[str, dict]] = None,
override_slide_css: Optional[str] = None,
override_text_overrides: Optional[dict[str, dict[str, str]]] = None,
override_structure_overrides: Optional[dict[str, dict[str, list[str]]]] = None,
reuse_from: Optional[str] = None,
) -> Path:
"""MVP-1.5b entry — single slide + composition planner v0 + 8 preset vocabulary.
@@ -4912,6 +5043,32 @@ def run_phase_z2_mvp1(
backend contract (KNOWN_AXES u1 + Vite allowlist u2 + typed
client u3 + stamper u4) end-to-end addressable from CLI without
diverging the function signature.
override_text_overrides : {zone_id: {text_path: value}} IMP-56 (#90) u5
axis. ``text_path`` = ``{slot_key}.{line_index}`` stamped
by the u8 ``text_path_stamper`` (pending). Applied at
Step 12 AFTER the AI-repair apply (IMP-47B u5) and
BEFORE the ``step12_slot_payload.json`` artifact emit so
the audit reflects user-edit final state. Per-zone
tolerant stale paths (frame swap / layout regression)
skip silently. raw_content preserved (mutates
``zone['slot_payload']`` only ; ``debug_zones`` graph
untouched). CLI / persistence fallback wiring is u16
scope.
override_structure_overrides : {zone_id: {slot_order|hidden_slots: [slot_key,...]}}
IMP-56 (#90) u7 axis. SCOPE LOCKED to
``slot_order`` (partial reorder) + ``hidden_slots``
(hide). Frame swap is rejected at the u6 validate
gate (stays on the existing ``frames`` axis) so the
Phase Z no-AI-HTML-structure invariant remains
intact. Applied at Step 12 AFTER the u5
text_overrides apply and BEFORE the
``step12_slot_payload.json`` artifact emit. Per-zone
tolerant stale slot_keys silently no-op (counted
as ``skipped_zones``). raw_content preserved (only
top-level slot_payload key membership + ordering
mutated ; per-slot ``list[str]`` line content +
``debug_zones`` graph untouched). CLI / persistence
fallback wiring is u16 scope.
override_slide_css : Optional slide-level CSS string IMP-45 (#74) u4 axis.
Marker-wrapped <style> block injected into ``final.html``
at Step 13 via :func:`src.slide_css_injector.inject_slide_css`
@@ -6607,6 +6764,59 @@ def run_phase_z2_mvp1(
note="IMP-47B u6 — Step 12 AI repair gather + apply records per unit (route, skip_reason, apply_status, proposal). u7 coverage_invariant = pre/post AI source_section_ids set comparison.",
)
# ─── Step 12 IMP-56 #90 u5 — Apply persisted text_overrides ───
# User text edits captured by the frontend (u12 capture path, pending)
# re-apply here so the next render shows them without re-clicking.
# Per-zone tolerant — stale ``text_path`` entries (frame swap / layout
# regression) skip silently. raw_content preserved : the helper only
# mutates ``zone["slot_payload"]`` ; ``debug_zones[i].source_section_ids``
# + ``MdxSection`` graph untouched. Audit artifact emits BEFORE the
# slot_payload artifact so reviewers see the post-override state.
text_overrides_audit = _apply_text_overrides_to_zones(
override_text_overrides or {}, zones_data,
)
_write_step_artifact(
run_dir, 12, "text_overrides",
data=text_overrides_audit,
step_status="done",
pipeline_path_connected=True,
inputs=["data/user_overrides/*.json"],
outputs=["step12_text_overrides.json"],
note=(
"IMP-56 #90 u5 — user text edit re-apply to slot_payload "
"(per-zone tolerant; raw_content preserved). text_path = "
"{slot_key}.{line_index} (u8 stamper contract)."
),
)
# ─── Step 12 IMP-56 #90 u7 — Apply persisted structure_overrides ───
# User reorder / hide choices captured by the frontend (u13 capture
# path, pending) re-apply here. SCOPE LOCKED to slot_order +
# hidden_slots — frame swap rejected at u6 validate gate (stays on
# the existing `frames` axis) so the Phase Z no-AI-HTML-structure
# invariant remains intact. raw_content preserved : helper only
# reorders / removes top-level slot_payload keys ; per-slot line
# lists + ``debug_zones`` + ``MdxSection`` graph untouched. Audit
# artifact emits BEFORE the slot_payload artifact so reviewers see
# the post-override state.
structure_overrides_audit = _apply_structure_overrides_to_zones(
override_structure_overrides or {}, zones_data,
)
_write_step_artifact(
run_dir, 12, "structure_overrides",
data=structure_overrides_audit,
step_status="done",
pipeline_path_connected=True,
inputs=["data/user_overrides/*.json"],
outputs=["step12_structure_overrides.json"],
note=(
"IMP-56 #90 u7 — user reorder / hide re-apply to "
"slot_payload (SCOPE LOCKED to slot_order + hidden_slots ; "
"frame swap stays on the `frames` axis ; raw_content "
"preserved)."
),
)
# ─── Step 12: Slot Payload (actual values, mapper.py 결과) ───
_write_step_artifact(
run_dir, 12, "slot_payload",
@@ -8203,6 +8413,15 @@ if __name__ == "__main__":
)
sys.exit(2)
# IMP-56 (#90) u16 — text_overrides + structure_overrides are file-only
# axes (frontend captures via /api/user-overrides PUT; no CLI flag
# surface, by design — per-line text edits and per-zone slot reorders /
# hides are too granular for argparse). Initialize empty here so the
# user_overrides.json fallback below can fill them, mirroring the
# ``overrides_images`` (IMP-51 #79 u6) post-CLI / pre-_persisted shape.
overrides_text: dict[str, dict[str, str]] = {}
overrides_structure: dict[str, dict[str, list[str]]] = {}
# IMP-52 (#80) u2 — user_overrides.json persistence fallback.
# After argparse fully parses CLI flags, fill ONLY the axes the user
# did NOT pass on the command line. CLI payload always wins over the
@@ -8309,6 +8528,51 @@ if __name__ == "__main__":
except (TypeError, ValueError):
continue
overrides_images = _accepted_img
# text_overrides — file-only (no CLI flag) → fill from file as
# dict[zone_id, dict[text_path, value]]. IMP-56 (#90) u16. Inline
# gate accepts only str-keyed inner dicts; the u4 validator runs
# again at Step 12 apply time, where malformed inner entries
# surface as silent per-zone skips in the ``step12_text_overrides``
# audit (Phase Z PZ-4 "no silent shrink" — count, don't drop).
if not overrides_text:
_file_text = _persisted.get("text_overrides")
if isinstance(_file_text, dict):
_accepted_text: dict[str, dict[str, str]] = {}
for _zid, _payload in _file_text.items():
if isinstance(_zid, str) and isinstance(_payload, dict):
_entries = {
str(_k): str(_v)
for _k, _v in _payload.items()
if isinstance(_k, str) and isinstance(_v, str)
}
if _entries:
_accepted_text[_zid] = _entries
overrides_text = _accepted_text
# structure_overrides — file-only → fill from file as dict[zone_id,
# {slot_order|hidden_slots: list[str]}]. IMP-56 (#90) u16. Inner
# keys locked to the two allowed names; the u6 validator drops any
# other key + frame-swap payloads at apply time so the CLI gate
# only does the list[str] structural coercion. Empty inner dicts
# are dropped here so ``overrides_structure or None`` collapses to
# ``None`` on the call site (matches IMP-51 #79 u6 ``or None`` shape).
if not overrides_structure:
_file_struct = _persisted.get("structure_overrides")
if isinstance(_file_struct, dict):
_accepted_struct: dict[str, dict[str, list[str]]] = {}
for _zid, _payload in _file_struct.items():
if isinstance(_zid, str) and isinstance(_payload, dict):
_entries_s: dict[str, list[str]] = {}
for _k, _v in _payload.items():
if (
_k in ("slot_order", "hidden_slots")
and isinstance(_v, list)
):
_entries_s[_k] = [
s for s in _v if isinstance(s, str)
]
if _entries_s:
_accepted_struct[_zid] = _entries_s
overrides_structure = _accepted_struct
# IMP-43 (#72) u1 — fail-closed reuse_from precondition guard.
# Placed AFTER the user_overrides.json merge so persisted overrides
@@ -8350,5 +8614,7 @@ if __name__ == "__main__":
override_section_assignments=overrides_section_assignments or None,
override_image_overrides=overrides_images or None,
override_slide_css=_final_override_slide_css,
override_text_overrides=overrides_text or None,
override_structure_overrides=overrides_structure or None,
reuse_from=args.reuse_from,
)
+189
View File
@@ -0,0 +1,189 @@
"""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
+143
View File
@@ -0,0 +1,143 @@
"""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
+155
View File
@@ -0,0 +1,155 @@
"""IMP-56 (#90) u8 — text_path stamper for Phase Z final.html.
Annotates rendered ``text-line`` DOM elements with a stable
``data-text-path="{slot_key}.{line_index}"`` attribute so the frontend
SlideCanvas (u10~u12) can attribute per-line edits back to the
``text_overrides`` axis (u1 schema, u4 resolver, u5 Step-12 apply).
DOM contract (single point of truth — mirrored verbatim across the axis) ::
.text-line[data-text-path="{slot_key}.{line_index}"]
The ``{slot_key}.{line_index}`` grammar matches
:func:`src.text_override_resolver.parse_text_path` verbatim (split on LAST
``.`` — compound slot keys with embedded dots are supported).
The text-line element format is emitted by every Phase Z family / frame
template (e.g. ``templates/phase_z2/families/bim_current_problems_paired.html``
line 143)::
<div class="text-line[ ...modifier classes...]">{{ line.text | safe }}</div>
The stamper finds each ``text-line`` opening tag with a permissive regex
and injects ``data-text-path="..."`` as the FIRST attribute. Existing
attributes (class, etc.) are preserved verbatim. The injection is
idempotent — a previously stamped element is left alone.
Stamping order : the stamper iterates ``slot_payload`` in dict-iteration
order and yields one stamp per ``list`` entry. The DOM walk consumes
stamps in left-to-right order; templates currently emit slot lines in
the same order they appear in ``slot_payload`` so the alignment holds.
If a future template diverges, u9 wiring can pre-build the desired
``(slot_key, line_index)`` sequence and pass it explicitly through the
``stamps`` arg of :func:`stamp_zone_html`.
Forward-compat / safety :
- Scalar (non-list) slot values are silently skipped — they render
outside ``text-line`` divs (frame title, pill labels, etc.) and are
not addressable via the line-index grammar.
- Excess ``text-line`` elements beyond ``sum(len(v) for v in
slot_payload.values() if isinstance(v, list))`` are left unstamped.
- Re-stamping (idempotent) preserves the first stamp.
Guardrails (refs : Stage 1 binding contract, Stage 2 unit u8) :
- 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.
- Idempotent : ``data-text-path`` probe short-circuits before re-inject.
- u9 wiring (separate unit) is the only consumer; this module emits no
artifacts and reads no global state.
"""
from __future__ import annotations
import re
from typing import Any, Iterable, Iterator, Mapping
TEXT_PATH_ATTR: str = "data-text-path"
# Matches a ``<div ... class="... text-line ..." ...>`` opening tag.
# Group 1 captures the inner attribute string verbatim (incl. leading
# whitespace) so the rewriter can re-emit it unchanged after injection.
_TEXT_LINE_TAG_RE = re.compile(
r'<div\b((?=[^>]*\bclass\s*=\s*"[^"]*\btext-line\b)[^>]*?)>',
flags=re.IGNORECASE | re.DOTALL,
)
# Probe for an existing ``data-text-path`` attribute (any value, any
# quote) so re-stamping is idempotent.
_HAS_TEXT_PATH_RE = re.compile(r"""\bdata-text-path\s*=""", flags=re.IGNORECASE)
def build_text_path(slot_key: str, line_index: int) -> str:
"""Return the canonical ``{slot_key}.{line_index}`` text_path string.
Mirrors the inverse of :func:`src.text_override_resolver.parse_text_path`
(last-dot split). ``slot_key`` may itself contain ``.`` (compound keys).
"""
if not isinstance(slot_key, str) or not slot_key:
raise ValueError(
f"slot_key must be a non-empty string, got: {slot_key!r}"
)
if isinstance(line_index, bool) or not isinstance(line_index, int):
raise ValueError(
f"line_index must be a non-negative int, got: {line_index!r}"
)
if line_index < 0:
raise ValueError(
f"line_index must be a non-negative int, got: {line_index!r}"
)
return f"{slot_key}.{line_index}"
def iter_zone_stamps(
slot_payload: Mapping[str, Any],
) -> Iterator[tuple[str, int]]:
"""Yield ``(slot_key, line_index)`` for every list-valued slot line.
Iteration order matches ``slot_payload`` dict iteration order. Non-
string / empty slot_keys are skipped. Non-list values are skipped
(scalar slots render outside ``text-line`` divs).
"""
if not isinstance(slot_payload, Mapping):
return
for slot_key, value in slot_payload.items():
if not isinstance(slot_key, str) or not slot_key:
continue
if not isinstance(value, list):
continue
for line_index in range(len(value)):
yield slot_key, line_index
def stamp_zone_html(
zone_html: str,
slot_payload_or_stamps: Mapping[str, Any] | Iterable[tuple[str, int]],
) -> str:
"""Stamp ``text-line`` opening tags in ``zone_html`` with ``data-text-path``.
The second arg accepts either:
- a ``slot_payload`` ``Mapping`` (uses :func:`iter_zone_stamps` order), or
- an iterable of pre-built ``(slot_key, line_index)`` tuples.
Stamps are consumed in left-to-right DOM order. A text-line already
carrying ``data-text-path`` is left unchanged (idempotent). Excess
text-line elements beyond the stamp sequence are also left unchanged.
Returns ``zone_html`` unchanged when there are no stamps to apply or
the input is not a non-empty string.
"""
if not isinstance(zone_html, str) or not zone_html:
return zone_html
if isinstance(slot_payload_or_stamps, Mapping):
stamps = list(iter_zone_stamps(slot_payload_or_stamps))
else:
stamps = [
(sk, li)
for (sk, li) in slot_payload_or_stamps
if isinstance(sk, str) and sk and isinstance(li, int)
and not isinstance(li, bool) and li >= 0
]
if not stamps:
return zone_html
counter = {"i": 0}
def _replace(match: re.Match[str]) -> str:
attrs = match.group(1) or ""
if _HAS_TEXT_PATH_RE.search(attrs):
return match.group(0)
i = counter["i"]
if i >= len(stamps):
return match.group(0)
counter["i"] = i + 1
slot_key, line_index = stamps[i]
path = build_text_path(slot_key, line_index)
return f'<div {TEXT_PATH_ATTR}="{path}"{attrs}>'
return _TEXT_LINE_TAG_RE.sub(_replace, zone_html)
+28 -8
View File
@@ -5,10 +5,18 @@ auto-restores user choices without re-clicking. Source of truth = MDX-keyed
file (stem of the MDX path), NOT ``data/runs/<run_id>/`` which mints a fresh
run_id per ``/api/run`` invocation.
Schema (7 axes; stable order; IMP-51 #79 u1 added ``image_overrides``;
Schema (9 axes; stable order; IMP-51 #79 u1 added ``image_overrides``;
IMP-45 #74 u1 added ``slide_css``; IMP-55 #93 u1 added
``manual_section_assignment`` as a bool intent marker so the backend can
distinguish a user drag-drop from frontend auto-carry zone_sections):
distinguish a user drag-drop from frontend auto-carry zone_sections;
IMP-56 #90 u1 added ``text_overrides`` as a Step-22 text-edit persist axis
keyed by ``{zone_id: {text_path: value}}`` where ``text_path`` is the
``{slot_key}.{line_index}`` stamp emitted by u8; IMP-56 #90 u2 added
``structure_overrides`` as a Step-22 structure-edit persist axis keyed by
``{zone_id: {"slot_order": [<slot_key>, ...], "hidden_slots": [<slot_key>, ...]}}``
— scope is intentionally LOCKED to slot reorder + hide; frame swap stays
on the existing ``frames`` axis to prevent the Phase Z regression of
AI-driven HTML structure mutation):
{
"layout": <string|null>,
@@ -17,7 +25,9 @@ distinguish a user drag-drop from frontend auto-carry zone_sections):
"frames": {<unit_id>: <template_id>},
"image_overrides": {<image_id>: {"x": float, "y": float, "w": float, "h": float}},
"slide_css": <string|null>,
"manual_section_assignment": <bool>
"manual_section_assignment": <bool>,
"text_overrides": {<zone_id>: {<text_path>: <string>}},
"structure_overrides": {<zone_id>: {"slot_order": [<slot_key>, ...], "hidden_slots": [<slot_key>, ...]}}
}
``image_id`` is the stable identifier emitted by the user-content image
@@ -58,13 +68,21 @@ from typing import Any, Optional
_PKG_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_OVERRIDES_ROOT = _PKG_ROOT / "data" / "user_overrides"
# The seven in-scope axes (IMP-51 #79 u1 added ``image_overrides``; IMP-45
# The nine in-scope axes (IMP-51 #79 u1 added ``image_overrides``; IMP-45
# #74 u1 added ``slide_css``; IMP-55 #93 u1 added
# ``manual_section_assignment`` — bool intent marker that gates whether
# persisted ``zone_sections`` are consumed by the backend pipeline). Any
# other top-level key in the file is preserved but ignored by callers —
# keeps the file forward-compatible with future axes (e.g., zone_sizes)
# without a schema bump here.
# persisted ``zone_sections`` are consumed by the backend pipeline; IMP-56
# #90 u1 added ``text_overrides`` — Step-22 text-edit persist axis keyed by
# ``{zone_id: {text_path: value}}`` where ``text_path`` is the
# ``{slot_key}.{line_index}`` stamp emitted by u8 / consumed by u4+u5;
# IMP-56 #90 u2 added ``structure_overrides`` — Step-22 structure-edit
# persist axis keyed by ``{zone_id: {"slot_order": [...], "hidden_slots":
# [...]}}``, scope LOCKED to slot reorder + hide so the resolver (u6) /
# Step-12 apply (u7) cannot mutate frame identity — frame swap stays on
# the existing ``frames`` axis to keep Phase Z's no-AI-HTML-structure
# invariant intact). Any other top-level key in the file is preserved but
# ignored by callers — keeps the file forward-compatible with future axes
# (e.g., zone_sizes) without a schema bump here.
KNOWN_AXES: tuple[str, ...] = (
"layout",
"zone_geometries",
@@ -73,6 +91,8 @@ KNOWN_AXES: tuple[str, ...] = (
"image_overrides",
"slide_css",
"manual_section_assignment",
"text_overrides",
"structure_overrides",
)
# Key validation — MDX stem must be safe for filesystem use. Allow