feat: add Phase Z override CLI and trace support

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 18:06:06 +09:00
parent 0a9327c50c
commit b56fd20ae5

View File

@@ -270,6 +270,33 @@ def lookup_v4_match(v4: dict, section_id: str) -> Optional[V4Match]:
)
def lookup_v4_all_judgments(v4: dict, section_id: str) -> list[V4Match]:
"""V4 raw 32 entry 그대로 반환 — reject 포함, max_n filter 없음.
Step 7-A axis 보강 (사용자 lock 2026-05-08) — 사용자 UI 가 모든 frame 의
png 를 보여줄 수 있도록 reject 까지 trace. lookup_v4_candidates 는 변경 없음
(backward compat — non-reject + max_n 만 반환).
Returns :
list[V4Match] — 0~32 길이. raw judgments_full32 순서 (= V4 score desc) 보존.
"""
sec = v4.get("mdx_sections", {}).get(section_id)
if not sec:
return []
judgments = sec.get("judgments_full32", [])
out: list[V4Match] = []
for j in judgments:
out.append(V4Match(
section_id=section_id,
frame_id=str(j["frame_id"]),
frame_number=int(j["frame_number"]),
template_id=j["template_id"],
confidence=float(j["confidence"]),
label=j["label"],
))
return out
def lookup_v4_candidates(
v4: dict, section_id: str, max_n: int = 6
) -> list[V4Match]:
@@ -412,15 +439,67 @@ def compute_zone_layout(zones_data: list[dict],
def build_layout_css(layout_preset: str, zones_data: list[dict],
gap: int = GRID_GAP) -> dict:
gap: int = GRID_GAP,
override_zone_geometries: Optional[dict[str, dict]] = None) -> dict:
"""Composition v0 layout preset → CSS grid 문자열.
horizontal-2 (= old type-b, 2-zone vertical stack) 만 dynamic heights 유지
(MDX 03 회귀 보존 — content_weight 기반). 다른 preset 은 fr default.
향후 cardinality_fit / density_score axis 가 score_candidate 에 들어가면
cols/rows 도 dynamic 으로 확장 가능.
Step D-ext (사용자 lock 2026-05-08) — override_zone_geometries (zone_id → {x,y,w,h}
slide-body 내부 0~1) 가 들어오면 그 비율로 layout_css 강제. horizontal-2 / vertical-2
만 처리. 다른 preset 은 일단 무시 + warning. 비율 합 != 1 이면 normalize.
"""
preset = LAYOUT_PRESETS[layout_preset]
positions = preset["positions"]
# ── Step D-ext : user override 처리 ──
if override_zone_geometries:
if layout_preset == "horizontal-2":
# heights_px override — zone 의 h 비율로 SLIDE_BODY_HEIGHT 분배.
ratios = []
for pos in positions:
geom = override_zone_geometries.get(pos)
ratios.append(float(geom["h"]) if geom else 0.0)
total = sum(ratios)
if total > 0:
heights_px = [int(round(r / total * SLIDE_BODY_HEIGHT)) for r in ratios]
rows = " ".join(f"{h}px" for h in heights_px)
return {
"areas": preset["css_areas"],
"cols": preset["css_cols"],
"rows": rows,
"heights_px": heights_px,
"ratios": [round(r / total, 3) for r in ratios],
"computation": "user_override_geometry",
"dynamic_rows": True,
"raw_zone_layout": {"override_applied": True, "source": override_zone_geometries},
}
elif layout_preset == "vertical-2":
# cols override — zone 의 w 비율로 fr 분배.
ratios = []
for pos in positions:
geom = override_zone_geometries.get(pos)
ratios.append(float(geom["w"]) if geom else 0.0)
total = sum(ratios)
if total > 0:
cols = " ".join(f"{round(r / total * 100, 2)}fr" for r in ratios)
return {
"areas": preset["css_areas"],
"cols": cols,
"rows": preset["css_rows"],
"heights_px": [],
"ratios": [round(r / total, 3) for r in ratios],
"computation": "user_override_geometry",
"dynamic_rows": False,
"raw_zone_layout": {"override_applied": True, "source": override_zone_geometries},
}
else:
print(
f" [override-warning] zone-geometry override 는 layout '{layout_preset}' 미지원 "
f"(현재 horizontal-2 / vertical-2 만). default layout_css 사용.",
file=sys.stderr,
)
if layout_preset == "horizontal-2":
zl = compute_zone_layout(zones_data, gap=gap)
@@ -1157,12 +1236,26 @@ def write_debug_json(run_dir: Path, layout_preset: str,
# ─── Main entry ────────────────────────────────────────────────
def run_phase_z2_mvp1(mdx_path: Path, run_id: Optional[str] = None) -> Path:
def run_phase_z2_mvp1(
mdx_path: Path,
run_id: Optional[str] = None,
*,
override_layout: Optional[str] = None,
override_frames: Optional[dict[str, str]] = None,
override_zone_geometries: Optional[dict[str, dict]] = None,
) -> Path:
"""MVP-1.5b entry — single slide + composition planner v0 + 8 preset vocabulary.
Pipeline :
parse_mdx → align_sections_to_v4_granularity → plan_composition →
mapper per unit → render slide_base + frame partial → Selenium check
User overrides (Step 7-A axis, 2026-05-08) :
override_layout : 자동 결정된 layout_preset 을 사용자 선택값으로 강제 (8 preset 중 하나).
override_frames : {unit_id: template_id} — 자동 결정된 frame template 을 사용자 선택값
으로 강제. unit_id = "+".join(source_section_ids) (e.g., "03-1"
또는 "03-1+03-2"). 매칭 unit 의 v4_candidates 에 있는 entry 면
그 entry 의 score / label 도 함께 갱신. 없으면 template_id 만 변경.
"""
mdx_path = Path(mdx_path)
if run_id is None:
@@ -1321,6 +1414,25 @@ def run_phase_z2_mvp1(mdx_path: Path, run_id: Optional[str] = None) -> Path:
v4_candidates_lookup_fn=candidates_lookup_fn,
)
# ── Step 7-A axis : layout override ──
# 사용자가 LayoutPanel 에서 다른 preset 을 선택했을 때 자동 결정값을 강제 변경.
# 길이 mismatch (positions count vs unit count) 는 zone loop 의 fallback (zone_{i})
# 으로 처리됨. 알 수 없는 preset 이면 ValueError.
auto_layout_preset = layout_preset
layout_override_applied = False
if override_layout is not None and override_layout != layout_preset:
if override_layout not in LAYOUT_PRESETS:
raise ValueError(
f"--override-layout '{override_layout}' is not a known preset. "
f"Available: {sorted(LAYOUT_PRESETS.keys())}"
)
print(
f" [override] layout_preset: {layout_preset}{override_layout}",
file=sys.stderr,
)
layout_preset = override_layout
layout_override_applied = True
if not units or layout_preset is None:
# composition planner 결과 = 0 units. Sections 가 모두 V4 lookup 실패 또는
# status filter 통과 못 함. error.json 기록 후 abort.
@@ -1404,6 +1516,66 @@ def run_phase_z2_mvp1(mdx_path: Path, run_id: Optional[str] = None) -> Path:
debug_zones = []
adapter_needed_units: list[dict] = []
# ── Step 7-A axis : frame override ──
# {unit_id: template_id} 형식. unit_id 매칭 시 unit.frame_template_id 강제 변경.
# v4_candidates 안에서 같은 template_id 를 가진 entry 를 찾으면 frame_id /
# frame_number / confidence / label 까지 그 entry 에서 가져와 갱신 — 그래야 step09
# artifact 의 메타가 일관됨.
# frame contract 가 catalog 에 등록 안 된 template_id 면 skip + warning —
# crash 방지 (V4 score 는 매겨지지만 catalog partial 은 없는 후보 존재).
frame_overrides_applied: list[dict] = []
frame_overrides_skipped: list[dict] = []
if override_frames:
for unit in units:
unit_id = "+".join(unit.source_section_ids)
if unit_id not in override_frames:
continue
new_tid = override_frames[unit_id]
old_tid = unit.frame_template_id
if new_tid == old_tid:
continue
# catalog contract 존재 확인 — 없으면 override 거부.
new_contract = get_contract(new_tid)
if new_contract is None:
frame_overrides_skipped.append({
"unit_id": unit_id,
"from": old_tid,
"to": new_tid,
"reason": "no_frame_contract_in_catalog",
})
print(
f" [override-skip] unit {unit_id}: '{new_tid}' has no entry in "
f"frame_contracts catalog — keeping {old_tid}",
file=sys.stderr,
)
continue
match = None
for cand in (unit.v4_candidates or []):
if getattr(cand, "template_id", None) == new_tid:
match = cand
break
if match is not None:
unit.frame_template_id = match.template_id
unit.frame_id = match.frame_id
unit.frame_number = match.frame_number
unit.confidence = match.confidence
unit.label = match.label
meta_source = "v4_candidates"
else:
unit.frame_template_id = new_tid
meta_source = "raw_template_id_only"
frame_overrides_applied.append({
"unit_id": unit_id,
"from": old_tid,
"to": new_tid,
"meta_source": meta_source,
})
print(
f" [override] unit {unit_id} frame: {old_tid}{new_tid} "
f"({meta_source})",
file=sys.stderr,
)
for i, unit in enumerate(units):
position = positions[i] if i < len(positions) else f"zone_{i}"
synth_section = MdxSection(
@@ -1749,8 +1921,11 @@ def run_phase_z2_mvp1(mdx_path: Path, run_id: Optional[str] = None) -> Path:
note="map_with_contract 결과 — actual slot_payload 값 그대로 (key 만 X).",
)
# 6. Build layout CSS — horizontal-2 = dynamic heights (regression preserve), 그 외 = fr default
layout_css = build_layout_css(layout_preset, zones_data)
# 6. Build layout CSS — horizontal-2 = dynamic heights (regression preserve), 그 외 = fr default.
# Step D-ext : override_zone_geometries 가 들어오면 layout_css 강제.
layout_css = build_layout_css(
layout_preset, zones_data, override_zone_geometries=override_zone_geometries
)
if layout_css["dynamic_rows"]:
for dz, h, r in zip(debug_zones, layout_css["heights_px"], layout_css["ratios"]):
dz["height_px"] = h
@@ -1771,6 +1946,9 @@ def run_phase_z2_mvp1(mdx_path: Path, run_id: Optional[str] = None) -> Path:
"zones_count": len(zones_data),
"unit_count": len(units),
"layout_candidates": layout_candidates_list,
# Step 7-A axis : user override trace
"layout_override_applied": layout_override_applied,
"auto_layout_preset": auto_layout_preset,
},
step_status="partial",
pipeline_path_connected=True,
@@ -2059,6 +2237,12 @@ def run_phase_z2_mvp1(mdx_path: Path, run_id: Optional[str] = None) -> Path:
unit.v4_candidates[0].template_id if has_v4 else None
)
# Step 7-A axis 보강 — reject 포함 모든 V4 judgments (frontend UI 가
# 모든 frame 의 png 를 카드로 보여주기 위함).
# unit_id = source_section_ids join. parent_merged 는 첫 section 의
# judgments 사용 (parent V4 entry 가 그 section 에 있으므로).
v4_all_for_unit = lookup_v4_all_judgments(v4, unit.source_section_ids[0])
# application_candidates : V4 후보 zip 으로 application_mode 변환
app_candidates = []
for c in unit.v4_candidates:
@@ -2094,6 +2278,23 @@ def run_phase_z2_mvp1(mdx_path: Path, run_id: Optional[str] = None) -> Path:
}
for c in unit.v4_candidates
],
# Step 7-A axis 보강 (사용자 lock 2026-05-08) — frontend UI 가 reject
# 포함 모든 V4 후보를 시각 차별 (회색) 로 보여줄 수 있도록 trace.
# length = 0~32. label 별 count : v4_candidates 는 non-reject only,
# v4_all_judgments 는 reject 포함.
# catalog_registered = frame_contracts.yaml 에 contract 있는지 여부.
# false 면 사용자가 override 시도해도 Step 7-A 가 skip (render path 미연결).
"v4_all_judgments": [
{
"template_id": c.template_id,
"frame_id": c.frame_id,
"frame_number": c.frame_number,
"confidence": c.confidence,
"label": c.label,
"catalog_registered": get_contract(c.template_id) is not None,
}
for c in v4_all_for_unit
],
"application_candidates": app_candidates,
})
@@ -2109,6 +2310,9 @@ def run_phase_z2_mvp1(mdx_path: Path, run_id: Optional[str] = None) -> Path:
"candidate_status_summary": {
"units_with_no_v4_candidate": units_with_no_v4,
},
# Step 7-A axis : user override trace
"frame_overrides_applied": frame_overrides_applied,
"frame_overrides_skipped": frame_overrides_skipped,
"v0_lock_note": (
"Step 9 v0 passive (사용자 lock 2026-05-08). "
"Step 6 default 그대로 사용 — runtime 결과 byte-동일. "
@@ -2130,7 +2334,9 @@ def run_phase_z2_mvp1(mdx_path: Path, run_id: Optional[str] = None) -> Path:
"변환을 side-by-side 로 기록. v0 invariant 5 가지 (status.md §4) 만족. "
"Step 6 의 default 결정 그대로 (current_default_candidate). "
"auto decision 은 Step 9 v1 (별 axis). region/display 후보는 Step 8-conn "
"의 placeholder signal 종속 (Step 3/4 부재)."
"의 placeholder signal 종속 (Step 3/4 부재). "
"Step 7-A axis (2026-05-08): frame_overrides_applied 가 사용자 LayoutPanel/"
"FramePanel 선택값을 강제 적용한 trace."
),
)
@@ -2548,9 +2754,78 @@ def run_phase_z2_mvp1(mdx_path: Path, run_id: Optional[str] = None) -> Path:
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python phase_z2_pipeline.py <mdx_path> [run_id]", file=sys.stderr)
sys.exit(2)
mdx = Path(sys.argv[1])
rid = sys.argv[2] if len(sys.argv) > 2 else None
run_phase_z2_mvp1(mdx, rid)
import argparse
parser = argparse.ArgumentParser(
prog="python -m src.phase_z2_pipeline",
description="Phase Z-2 MVP-1.5b pipeline (MDX → 1 slide HTML).",
)
parser.add_argument("mdx_path", type=Path, help="MDX 파일 경로")
parser.add_argument(
"run_id", nargs="?", default=None,
help="run_id (출력 디렉토리 이름). 없으면 자동 생성 (basename + timestamp).",
)
parser.add_argument(
"--override-layout", dest="override_layout", default=None,
metavar="PRESET",
help=(
"layout_preset 강제 (8 preset 중 하나 — single, horizontal-2, vertical-2, "
"top-1-bottom-2, top-2-bottom-1, left-1-right-2, left-2-right-1, grid-2x2). "
"없으면 자동 결정 (count-based v0)."
),
)
parser.add_argument(
"--override-frame", dest="override_frames", action="append", default=[],
metavar="UNIT_ID=TEMPLATE_ID",
help=(
"unit_id 의 frame template 강제 변경. UNIT_ID 는 \"+\".join(source_section_ids) "
"(e.g., 03-1 또는 03-1+03-2). multiple 가능: --override-frame 03-1=foo "
"--override-frame 03-2=bar"
),
)
parser.add_argument(
"--override-zone-geometry", dest="override_zone_geometries", action="append", default=[],
metavar="ZONE_ID=X,Y,W,H",
help=(
"zone position (top/bottom/left/right/...) 의 slide-body 내부 비율 (0~1) "
"강제. horizontal-2 / vertical-2 만 지원. multiple 가능: "
"--override-zone-geometry top=0,0,1,0.3 --override-zone-geometry bottom=0,0.3,1,0.7"
),
)
args = parser.parse_args()
overrides_frames: dict[str, str] = {}
for ov in args.override_frames:
if "=" not in ov:
print(
f"[error] --override-frame must be UNIT_ID=TEMPLATE_ID, got: '{ov}'",
file=sys.stderr,
)
sys.exit(2)
k, v = ov.split("=", 1)
overrides_frames[k.strip()] = v.strip()
overrides_geoms: dict[str, dict] = {}
for ov in args.override_zone_geometries:
if "=" not in ov:
print(f"[error] --override-zone-geometry must be ZONE_ID=X,Y,W,H, got: '{ov}'", file=sys.stderr)
sys.exit(2)
zid, vals = ov.split("=", 1)
parts = vals.split(",")
if len(parts) != 4:
print(f"[error] --override-zone-geometry expects 4 floats X,Y,W,H, got: '{vals}'", file=sys.stderr)
sys.exit(2)
try:
x, y, w, h = (float(p) for p in parts)
except ValueError:
print(f"[error] --override-zone-geometry floats parse fail: '{vals}'", file=sys.stderr)
sys.exit(2)
overrides_geoms[zid.strip()] = {"x": x, "y": y, "w": w, "h": h}
run_phase_z2_mvp1(
args.mdx_path,
args.run_id,
override_layout=args.override_layout,
override_frames=overrides_frames or None,
override_zone_geometries=overrides_geoms or None,
)