From b56fd20ae5b7b775c74742727d6c0ab30ed5670e Mon Sep 17 00:00:00 2001 From: kyeongmin Date: Fri, 8 May 2026 18:06:06 +0900 Subject: [PATCH] feat: add Phase Z override CLI and trace support Co-Authored-By: Claude Opus 4.7 (1M context) --- src/phase_z2_pipeline.py | 301 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 288 insertions(+), 13 deletions(-) diff --git a/src/phase_z2_pipeline.py b/src/phase_z2_pipeline.py index 748e1a9..65a4a7f 100644 --- a/src/phase_z2_pipeline.py +++ b/src/phase_z2_pipeline.py @@ -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 [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, + )