feat(#80): IMP-52 user_overrides.json persistence (u1~u10 backend + frontend + tests)
4-axis MDX-stem keyed persistence so layout / zone_geometries / zone_sections / frames
survive across `/api/run` sessions. Auto-restore on MDX reopen; CLI > file precedence
on backend pipeline entry; 300ms-debounced PUT flushed before Generate.
u1 src/user_overrides_io.py — load/save/validate_key (MDX-stem regex), 4-axis schema,
miss={}, corrupt warning+{}, atomic tmp+rename, foreign-key preserve.
u2 src/phase_z2_pipeline.py — post-argparse fallback fills only missing axes.
u3 Front/vite.config.ts — GET /api/user-overrides/:key (200 {} on miss, 400 traversal).
u4 Front/vite.config.ts — PUT /api/user-overrides/:key, 4-axis allowlist, partial merge.
u5 Front/client/src/services/userOverridesApi.ts — typed get/save + flushUserOverrides
with 300ms debounce and mutated-axis partial payloads.
u6 Front/client/src/pages/Home.tsx + slidePlanUtils.ts — restore on MDX upload (non-frame
axes immediately, frames remapped post-loadRun unit_id → region.id).
u7 Home.tsx — persist on 4 mutation handlers (section drop, layout select, zone resize,
frame select); zone_sizes and Generate excluded.
u8 tests/test_user_overrides_io.py — round-trip, unknown-key passthrough, missing/corrupt,
invalid keys (26 tests).
u9 tests/test_user_overrides_pipeline_fallback.py — per-axis fill, CLI-wins, no-file noop,
corrupt warning+skip (16 tests).
u10 Home.tsx + user_overrides_write.test.ts — await flushUserOverrides() before runPipeline
in handleGenerate try-block head; source-pattern regression assertions (20 → 22 tests).
Backend pytest 42/42 green. Frontend vitest 113/113 green (endpoint 42 / restore 21 /
service 28 / write 22). HEAD baseline ee97f4f; no spillover to phase_z2 templates /
families / frames / pipeline orchestration outside the IMP-52 surface.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1689,6 +1689,14 @@ def _override_to_grid_tracks(
|
||||
R = len(rows_grid)
|
||||
C = len(rows_grid[0])
|
||||
|
||||
# Hot-fix (2026-05-22): partial override 버그 fix — override 없는 track 은
|
||||
# default 비율로 fallback. 이전엔 0 반환 → normalize 후 다른 track 이 모든 공간 흡수.
|
||||
_default_result = _build_grid_dynamic_2d(preset, zones_data, gap=gap)
|
||||
_default_widths = _default_result.get("widths_px", []) or []
|
||||
_default_heights = _default_result.get("heights_px", []) or []
|
||||
_sum_w = sum(_default_widths) if _default_widths else 1.0
|
||||
_sum_h = sum(_default_heights) if _default_heights else 1.0
|
||||
|
||||
occupancy: list[tuple[dict, set[int], set[int]]] = []
|
||||
for z in zones_data:
|
||||
pos = z["position"]
|
||||
@@ -1703,17 +1711,19 @@ def _override_to_grid_tracks(
|
||||
single = [z for z, rr, _cc in occupancy if rr == {idx}]
|
||||
allspan = [z for z, rr, _cc in occupancy if rr == set(range(R))]
|
||||
key = "h"
|
||||
_fallback = (_default_heights[idx] / _sum_h) if idx < len(_default_heights) and _sum_h else (1.0 / R)
|
||||
else:
|
||||
single = [z for z, _rr, cc in occupancy if cc == {idx}]
|
||||
allspan = [z for z, _rr, cc in occupancy if cc == set(range(C))]
|
||||
key = "w"
|
||||
_fallback = (_default_widths[idx] / _sum_w) if idx < len(_default_widths) and _sum_w else (1.0 / C)
|
||||
candidates = single or allspan
|
||||
vals = [
|
||||
float(override_zone_geometries[z["position"]][key])
|
||||
for z in candidates
|
||||
if z["position"] in override_zone_geometries
|
||||
]
|
||||
return max(vals) if vals else 0.0
|
||||
return max(vals) if vals else _fallback
|
||||
|
||||
row_values = [_track_value(r, "row") for r in range(R)]
|
||||
col_values = [_track_value(c, "col") for c in range(C)]
|
||||
@@ -1793,10 +1803,18 @@ def build_layout_css(layout_preset: str, zones_data: list[dict],
|
||||
if override_zone_geometries:
|
||||
if layout_preset == "horizontal-2":
|
||||
# heights_px override — zone 의 h 비율로 SLIDE_BODY_HEIGHT 분배.
|
||||
# Hot-fix (2026-05-22): partial override = 나머지 공간을 비-override zone 들에
|
||||
# 균등 분배 (drag boundary intent). 이전엔 0.0 fallback → 100/0 깨짐.
|
||||
overridden_h = sum(
|
||||
float(override_zone_geometries[p]["h"])
|
||||
for p in positions if p in override_zone_geometries
|
||||
)
|
||||
non_overridden = [p for p in positions if p not in override_zone_geometries]
|
||||
per_non = max(0.0, 1.0 - overridden_h) / max(len(non_overridden), 1)
|
||||
ratios = []
|
||||
for pos in positions:
|
||||
geom = override_zone_geometries.get(pos)
|
||||
ratios.append(float(geom["h"]) if geom else 0.0)
|
||||
ratios.append(float(geom["h"]) if geom else per_non)
|
||||
total = sum(ratios)
|
||||
if total > 0:
|
||||
heights_px = [int(round(r / total * SLIDE_BODY_HEIGHT)) for r in ratios]
|
||||
@@ -1818,10 +1836,18 @@ def build_layout_css(layout_preset: str, zones_data: list[dict],
|
||||
# cols override — zone 의 w 비율로 fr 분배 (legacy: fr-string cols).
|
||||
# PR 1 keeps fr-string cols for legacy preserve; widths_px is
|
||||
# populated in pixels for _compute_per_zone_geometry length contract.
|
||||
# Hot-fix (2026-05-22): partial override = 나머지 공간을 비-override zone 들에
|
||||
# 균등 분배 (drag boundary intent). 이전엔 0.0 fallback → 100/0 깨짐.
|
||||
overridden_w = sum(
|
||||
float(override_zone_geometries[p]["w"])
|
||||
for p in positions if p in override_zone_geometries
|
||||
)
|
||||
non_overridden = [p for p in positions if p not in override_zone_geometries]
|
||||
per_non = max(0.0, 1.0 - overridden_w) / max(len(non_overridden), 1)
|
||||
ratios = []
|
||||
for pos in positions:
|
||||
geom = override_zone_geometries.get(pos)
|
||||
ratios.append(float(geom["w"]) if geom else 0.0)
|
||||
ratios.append(float(geom["w"]) if geom else per_non)
|
||||
total = sum(ratios)
|
||||
if total > 0:
|
||||
cols = " ".join(f"{round(r / total * 100, 2)}fr" for r in ratios)
|
||||
@@ -5917,10 +5943,81 @@ if __name__ == "__main__":
|
||||
_seen_sections_across_zones[sid] = zid
|
||||
overrides_section_assignments[zid] = section_ids
|
||||
|
||||
# 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
|
||||
# persisted file (Stage 2 lock: "CLI > file, 결손 축만 채움").
|
||||
# MDX stem keys the persistence file; invalid stems / corrupt file
|
||||
# degrade gracefully (warning to stderr + no override injected).
|
||||
from src.user_overrides_io import (
|
||||
InvalidOverrideKey,
|
||||
load as _load_user_overrides,
|
||||
validate_key as _validate_overrides_key,
|
||||
)
|
||||
|
||||
_final_override_layout = args.override_layout
|
||||
try:
|
||||
_ov_key = _validate_overrides_key(Path(args.mdx_path).stem)
|
||||
except InvalidOverrideKey as _exc:
|
||||
print(
|
||||
f"[user_overrides] warning: cannot derive persistence key from "
|
||||
f"mdx_path {args.mdx_path!r}: {_exc}; skipping fallback.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
_ov_key = None
|
||||
if _ov_key is not None:
|
||||
_persisted = _load_user_overrides(_ov_key)
|
||||
# layout — CLI None → fill from file (must be str).
|
||||
if _final_override_layout is None:
|
||||
_file_layout = _persisted.get("layout")
|
||||
if isinstance(_file_layout, str) and _file_layout:
|
||||
_final_override_layout = _file_layout
|
||||
# frames — CLI empty → fill from file (must be dict[str, str]).
|
||||
if not overrides_frames:
|
||||
_file_frames = _persisted.get("frames")
|
||||
if isinstance(_file_frames, dict):
|
||||
overrides_frames = {
|
||||
str(k): str(v)
|
||||
for k, v in _file_frames.items()
|
||||
if isinstance(k, str) and isinstance(v, str)
|
||||
}
|
||||
# zone_geometries — CLI empty → fill from file (dict[str, dict]).
|
||||
if not overrides_geoms:
|
||||
_file_geoms = _persisted.get("zone_geometries")
|
||||
if isinstance(_file_geoms, dict):
|
||||
_accepted: dict[str, dict] = {}
|
||||
for _zid, _g in _file_geoms.items():
|
||||
if (
|
||||
isinstance(_zid, str)
|
||||
and isinstance(_g, dict)
|
||||
and all(k in _g for k in ("x", "y", "w", "h"))
|
||||
):
|
||||
try:
|
||||
_accepted[_zid] = {
|
||||
"x": float(_g["x"]),
|
||||
"y": float(_g["y"]),
|
||||
"w": float(_g["w"]),
|
||||
"h": float(_g["h"]),
|
||||
}
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
overrides_geoms = _accepted
|
||||
# zone_sections — CLI empty → fill from file (dict[str, list[str]]).
|
||||
if not overrides_section_assignments:
|
||||
_file_sections = _persisted.get("zone_sections")
|
||||
if isinstance(_file_sections, dict):
|
||||
_accepted_sec: dict[str, list[str]] = {}
|
||||
for _zid, _sec_list in _file_sections.items():
|
||||
if isinstance(_zid, str) and isinstance(_sec_list, list):
|
||||
_sids = [s for s in _sec_list if isinstance(s, str) and s]
|
||||
if _sids:
|
||||
_accepted_sec[_zid] = _sids
|
||||
overrides_section_assignments = _accepted_sec
|
||||
|
||||
run_phase_z2_mvp1(
|
||||
args.mdx_path,
|
||||
args.run_id,
|
||||
override_layout=args.override_layout,
|
||||
override_layout=_final_override_layout,
|
||||
override_frames=overrides_frames or None,
|
||||
override_zone_geometries=overrides_geoms or None,
|
||||
override_section_assignments=overrides_section_assignments or None,
|
||||
|
||||
162
src/user_overrides_io.py
Normal file
162
src/user_overrides_io.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""IMP-52 (#80) u1 — user_overrides.json persistence layer (backend IO).
|
||||
|
||||
Persists the four CLI-wired override axes per MDX so a subsequent render
|
||||
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 (4 axes; stable order):
|
||||
|
||||
{
|
||||
"layout": <string|null>,
|
||||
"zone_geometries": {<zone_id>: {"x": float, "y": float, "w": float, "h": float}},
|
||||
"zone_sections": {<zone_id>: [<section_id>, ...]},
|
||||
"frames": {<unit_id>: <template_id>}
|
||||
}
|
||||
|
||||
``unit_id`` is the convention already used by ``--override-frame`` :
|
||||
``"+".join(source_section_ids)`` (e.g., ``"03-1"`` or ``"03-1+03-2"``).
|
||||
|
||||
Behavior :
|
||||
- ``load(key)`` — file missing or corrupt → ``{}`` (warning to stderr on corrupt).
|
||||
- ``save(key, partial)`` — merges only the supplied axes onto the existing
|
||||
file, preserving (a) unknown top-level keys (foreign-key preserve) and
|
||||
(b) axes not present in the partial payload. Atomic write via tmp+rename.
|
||||
- ``override_path(key, root=None)`` — resolves the persistence path under
|
||||
``data/user_overrides/<key>.json``.
|
||||
|
||||
Guardrails (refs : ``user_overrides_io`` Stage 2 lock) :
|
||||
- Deterministic code, no AI fallback.
|
||||
- ``key`` validation rejects path traversal / separators / dot-prefix.
|
||||
- ``save`` is a deep-shallow merge — per-axis dict mutation does not delete
|
||||
prior keys unless caller passes ``None`` for that axis (explicit clear).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
# Persistence root — MDX-keyed, decoupled from data/runs/<run_id>/.
|
||||
# Resolved at call time so tests can monkeypatch via ``root=`` parameter.
|
||||
_PKG_ROOT = Path(__file__).resolve().parent.parent
|
||||
DEFAULT_OVERRIDES_ROOT = _PKG_ROOT / "data" / "user_overrides"
|
||||
|
||||
# The four in-scope axes. 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, image_overrides) without a schema bump here.
|
||||
KNOWN_AXES: tuple[str, ...] = ("layout", "zone_geometries", "zone_sections", "frames")
|
||||
|
||||
# Key validation — MDX stem must be safe for filesystem use. Allow
|
||||
# alphanumerics, underscore, hyphen, and dot in the middle (sample stems
|
||||
# are e.g. ``01``, ``03``, ``03__DX...``). Reject leading dot, path
|
||||
# separators, and traversal.
|
||||
_KEY_RE = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_.\-]*$")
|
||||
|
||||
|
||||
class InvalidOverrideKey(ValueError):
|
||||
"""Raised when ``key`` is not a safe MDX stem."""
|
||||
|
||||
|
||||
def validate_key(key: str) -> str:
|
||||
"""Validate that ``key`` is a safe MDX stem; return it unchanged.
|
||||
|
||||
Rejects empty strings, path separators (``/`` ``\\``), traversal
|
||||
(``..``), and leading dot. Callers should pass ``Path(mdx_path).stem``.
|
||||
"""
|
||||
if not isinstance(key, str) or not key:
|
||||
raise InvalidOverrideKey(f"key must be a non-empty string, got: {key!r}")
|
||||
if not _KEY_RE.match(key):
|
||||
raise InvalidOverrideKey(
|
||||
f"key must match {_KEY_RE.pattern!r} (alphanumerics, '_', '-', '.'; "
|
||||
f"no leading dot, no separators); got: {key!r}"
|
||||
)
|
||||
if ".." in key:
|
||||
raise InvalidOverrideKey(f"key must not contain '..'; got: {key!r}")
|
||||
return key
|
||||
|
||||
|
||||
def override_path(key: str, root: Optional[Path] = None) -> Path:
|
||||
"""Resolve the on-disk path for ``key``'s override file."""
|
||||
validate_key(key)
|
||||
base = Path(root) if root is not None else DEFAULT_OVERRIDES_ROOT
|
||||
return base / f"{key}.json"
|
||||
|
||||
|
||||
def load(key: str, root: Optional[Path] = None) -> dict[str, Any]:
|
||||
"""Load persisted overrides for ``key``.
|
||||
|
||||
Missing file → ``{}``. Corrupt JSON → warning to stderr + ``{}``.
|
||||
Returns the raw mapping (including any foreign keys); callers should
|
||||
pick the four KNOWN_AXES they care about.
|
||||
"""
|
||||
path = override_path(key, root=root)
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
print(
|
||||
f"[user_overrides_io] warning: failed to read {path} ({exc}); "
|
||||
f"treating as empty.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return {}
|
||||
if not isinstance(data, dict):
|
||||
print(
|
||||
f"[user_overrides_io] warning: {path} is not a JSON object "
|
||||
f"(got {type(data).__name__}); treating as empty.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return {}
|
||||
return data
|
||||
|
||||
|
||||
def save(key: str, partial: dict[str, Any], root: Optional[Path] = None) -> Path:
|
||||
"""Merge ``partial`` onto the persisted overrides for ``key`` and write atomically.
|
||||
|
||||
Merge semantics :
|
||||
- Only keys present in ``partial`` are mutated. Other axes (including
|
||||
foreign keys outside KNOWN_AXES) are preserved verbatim.
|
||||
- For each axis present in ``partial``, the new value REPLACES the prior
|
||||
value (no per-zone deep-merge). Callers that want to add a single
|
||||
zone must read → mutate → save with the full updated axis dict.
|
||||
- Pass ``None`` for an axis to clear it (remove the key from the file).
|
||||
"""
|
||||
if not isinstance(partial, dict):
|
||||
raise TypeError(
|
||||
f"partial must be a dict, got {type(partial).__name__}: {partial!r}"
|
||||
)
|
||||
path = override_path(key, root=root)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
current = load(key, root=root)
|
||||
for axis_key, axis_value in partial.items():
|
||||
if axis_value is None:
|
||||
current.pop(axis_key, None)
|
||||
else:
|
||||
current[axis_key] = axis_value
|
||||
_atomic_write_json(path, current)
|
||||
return path
|
||||
|
||||
|
||||
def _atomic_write_json(path: Path, data: dict[str, Any]) -> None:
|
||||
"""Write ``data`` to ``path`` atomically via tmp file + os.replace."""
|
||||
fd, tmp_name = tempfile.mkstemp(
|
||||
prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent)
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2, sort_keys=True)
|
||||
f.write("\n")
|
||||
os.replace(tmp_name, path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp_name)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
Reference in New Issue
Block a user