팝업 콘텐츠 (감지 미구현)
# IMP-02 — additive only. enabled/used/fallback_reason + id reconstruction
# trace + count diff. Out of scope: V4 / align / composition.
"stage0_adapter_diagnostics": stage0_adapter_diagnostics,
# IMP-03 — Step 3 handoff (slide-level rich asset list).
# env=OFF / fallback 시 모든 list 가 비어 있음. consumer = Step 3
# rich extractor (PHASE_Z_STEP3_RICH_OBJECTS_ENABLED canary).
"stage0_normalized_assets": stage0_normalized_assets,
},
step_status="partial",
pipeline_path_connected=True,
inputs=["step01_mdx_source.md"],
outputs=["step02_normalized.json"],
note=(
"parse_mdx 결과: title / sections / footer 분리 + raw_content 보존. "
"heading tree 미생성, orphan / details 감지 미완 (Step 2 ⚠ partial — 별 axis). "
"orphans / details 필드는 schema lock — 빈 배열이라도 'detection 미수행' marker. "
"stage0_adapter_diagnostics = IMP-02 chained adapter trace (default OFF canary). "
"stage0_normalized_assets = IMP-03 Step 3 slide-level handoff (popups/images/tables list)."
),
)
# 2. Load V4
v4 = load_v4_result()
# 3. Align sections to V4 granularity (### drill if needed).
# IMP-08 B-3 / Stage 5 R2 : forward override target ids so sub-id
# drag/drop targets force-drill their parent section even when V4
# carries the parent exact key (deterministic drag/drop addressing).
_override_target_sids: list[str] = []
if override_section_assignments:
for _sids in override_section_assignments.values():
for _sid in _sids:
if isinstance(_sid, str) and _sid:
_override_target_sids.append(_sid)
sections = align_sections_to_v4_granularity(
sections,
v4,
override_target_section_ids=_override_target_sids or None,
)
print(f" aligned : sections={len(sections)} ({[s.section_id for s in sections]})")
# ─── Step 5: V4 매칭 evidence (non-reject max-6 후보 list — 사용자 lock 2026-05-08) ───
v4_evidence_list = []
for s in sections:
candidates = lookup_v4_candidates(v4, s.section_id)
v4_evidence_list.append({
"section_id": s.section_id,
"v4_candidates": [
{
"template_id": c.template_id,
"frame_id": c.frame_id,
"frame_number": c.frame_number,
"confidence": c.confidence,
"label": c.label,
}
for c in candidates
],
"candidate_status": "ok" if candidates else "no_non_reject_v4_candidate",
})
_write_step_artifact(
run_dir, 5, "v4_evidence",
data={
"v4_source": str(V4_RESULT_PATH.relative_to(PROJECT_ROOT)),
"aligned_section_ids": [s.section_id for s in sections],
"evidence_per_section": v4_evidence_list,
},
step_status="done",
pipeline_path_connected=True,
inputs=["step02_normalized.json", "tests/matching/v4_full32_result.yaml"],
outputs=["step05_v4_evidence.json"],
note=(
"V4 non-reject max-6 후보 list (Step 9 application_plan input). "
"raw 32 entry 는 tests/matching/v4_full32_result.yaml 에 영속. "
"candidate_status='ok' = 후보 1개 이상 / 'no_non_reject_v4_candidate' = "
"0개 (Step 9 fallback path 입력). "
"Step 6 plan_composition() 은 lookup_v4_match() (rank-1) 그대로 사용 — "
"backward compat (Step 6-A axis 까지)."
),
)
# 4. Composition planner v0 — replaces per-section + select_layout_preset.
# candidate (separate / parent_merged) → score → greedy non-overlapping select →
# layout preset (count-based v0).
section_content_by_id = {s.section_id: s.raw_content for s in sections}
# IMP-08 B-3 : sub-section ordinal id -> legacy V4 key aliases (e.g. "04-2.1").
# Empty list for canonical (top-level) sections — U1 baseline path is exact-only.
section_alias_by_id: dict[str, list] = {
s.section_id: list(getattr(s, "v4_alias_keys", []) or []) for s in sections
}
v4_fallback_traces: dict[str, dict] = {}
def lookup_fn(sid: str) -> Optional[V4Match]:
match, trace = lookup_v4_match_with_fallback(
v4,
sid,
raw_content=section_content_by_id.get(sid),
max_rank=3,
alias_keys=section_alias_by_id.get(sid),
)
v4_fallback_traces[sid] = trace
return match
# Step 6-A axis (사용자 lock 2026-05-08) — V4 raw dict 흡수 fn.
# composition module 은 V4 yaml shape 모름. 본 fn 만 통해 후보 list 받음.
def candidates_lookup_fn(sid: str) -> list[V4Match]:
return lookup_v4_candidates(v4, sid, alias_keys=section_alias_by_id.get(sid))
units, layout_preset, comp_debug = plan_composition(
sections, lookup_fn, V4_LABEL_TO_PHASE_Z_STATUS, MVP1_ALLOWED_STATUSES,
capacity_fit_fn=compute_capacity_fit,
v4_candidates_lookup_fn=candidates_lookup_fn,
)
comp_debug["v4_fallback_selections"] = list(v4_fallback_traces.values())
# IMP-05 L3 (Codex #10 D4) — Step 20 qualifier fields (additive only, no top-level enum change).
# `fallback_selection_count` = number of sections where rank-2/3 was promoted.
# `selection_paths` = per-section selection_path summary (rank_1 / rank_N_fallback / chain_exhausted).
# Top-level slide status enum (PASS / PARTIAL_COVERAGE / ...) remains stable.
_imp05_selection_paths = [
{
"section_id": sid,
"selection_path": t.get("selection_path"),
"selected_rank": t.get("selected_rank"),
"selected_template_id": t.get("selected_template_id"),
"fallback_trigger": t.get("fallback_reason") if t.get("fallback_used") else None,
}
for sid, t in v4_fallback_traces.items()
]
comp_debug["v4_fallback_summary"] = {
"fallback_used_count": sum(1 for t in v4_fallback_traces.values() if t.get("fallback_used")),
"fallback_selection_count": sum(1 for t in v4_fallback_traces.values() if t.get("fallback_used")),
"chain_exhausted_count": sum(
1 for t in v4_fallback_traces.values()
if t.get("selection_path") == "chain_exhausted"
),
"selection_paths": _imp05_selection_paths,
"policy": (
"IMP-05: rank-1 is kept when usable; rank-2/3 may be promoted only when "
"the earlier rank is not auto-renderable, has no catalog contract, or fails "
"capacity precheck. calculate_fit is not used."
),
}
# ── 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
# IMP-06 (#6 / Codex #6/#7/#10/#11/#12 lock) — zone-section assignment override.
# Applied AFTER final layout_preset resolution. ZONE_ID = layout positions.
# The helper validates unknown zone ids / unknown section ids and builds a
# `position_assignment_plan`. Immediately below, Stage 4 Part 2 rebuilds the
# `units` list aligned with that plan: cli_override entries synthesize a
# CompositionUnit, auto entries reuse the original planner unit, and empty/
# collision-skipped entries become None placeholders. The downstream
# zones_data / debug_zones loop then handles None entries by emitting an
# explicit empty zone record (template_id="__empty__") so the slide grid
# preserves position identity without distorting layout allocation.
section_assignment_plan: Optional[list[dict]] = None
section_assignment_summary: Optional[dict] = None
if override_section_assignments and layout_preset is not None:
positions = list(LAYOUT_PRESETS[layout_preset]["positions"])
# Validate ZONE_IDs against active layout positions (fail-fast).
unknown_zones = [z for z in override_section_assignments if z not in positions]
if unknown_zones:
raise ValueError(
f"--override-section-assignment unknown ZONE_ID(s) {unknown_zones} for "
f"layout '{layout_preset}'. Available positions: {positions}"
)
# Validate section_ids against aligned sections (fail-fast).
aligned_section_ids = {s.section_id for s in sections}
sections_by_id = {s.section_id: s for s in sections}
unknown_sections: list[str] = []
for zid, sids in override_section_assignments.items():
for sid in sids:
if sid not in aligned_section_ids:
unknown_sections.append(sid)
if unknown_sections:
raise ValueError(
f"--override-section-assignment unknown section_id(s) {unknown_sections}. "
f"Aligned sections: {sorted(aligned_section_ids)}"
)
section_assignment_plan, section_assignment_summary = _build_position_assignment_plan(
units=units,
positions=positions,
override_section_assignments=override_section_assignments,
sections_by_id=sections_by_id,
override_frames=override_frames,
v4=v4,
)
comp_debug["section_assignment_plan"] = section_assignment_plan
comp_debug["section_assignment_summary"] = section_assignment_summary
print(
f" [override] section_assignment applied: "
f"{section_assignment_summary['applied_count']} position(s), "
f"{section_assignment_summary['skipped_count']} skipped, "
f"uncovered_sections={section_assignment_summary['uncovered_section_ids']}",
file=sys.stderr,
)
# Stage 4 blocker-fix (Codex #13/#14/#15/#16/#17) — rebuild units as a pure
# `list[CompositionUnit]` (renderable only, no None). Position-aware truth
# lives in `render_records` (built after frame_overrides apply) per Codex
# internal contract: units = canonical renderable list, render_records =
# canonical per-position view including empty/skipped entries.
from src.phase_z2_composition import CompositionUnit
plan_units: list = []
# Maintain ordered alignment with section_assignment_plan for the
# render_records build step below: plan_unit_by_position[pos] = unit | None.
plan_unit_by_position: dict[str, object] = {}
for entry in section_assignment_plan:
assignment_source = entry["assignment_source"]
pos = entry["position"]
if assignment_source == "cli_override" and entry["template_id"] is not None:
sids = entry["source_section_ids"]
raw_content_parts = []
title_parts = []
for sid in sids:
sect = sections_by_id.get(sid)
if sect is None:
continue
raw_content_parts.append(sect.raw_content or "")
if sect.title:
title_parts.append(sect.title)
contract = get_contract(entry["template_id"])
contract_frame_id = (contract or {}).get("frame_id") or ""
override_unit = CompositionUnit(
source_section_ids=list(sids),
merge_type="cli_override",
frame_template_id=entry["template_id"],
frame_id=str(contract_frame_id),
frame_number=0,
confidence=0.0,
label="use_as_is",
phase_z_status="matched_zone",
raw_content="\n\n".join(raw_content_parts),
title=" / ".join(title_parts) if title_parts else "+".join(sids),
v4_rank=None,
selection_path="cli_override",
fallback_reason=None,
score=0.0,
rationale={
"section_assignment_override": entry["section_assignment_override"],
"replaced_auto_unit": entry["replaced_auto_unit"],
},
)
plan_units.append(override_unit)
plan_unit_by_position[pos] = override_unit
elif assignment_source == "auto":
# Find original auto unit by source_section_ids.
matched = None
for u in units:
if list(u.source_section_ids) == entry["source_section_ids"]:
matched = u
break
if matched is not None:
plan_units.append(matched)
plan_unit_by_position[pos] = matched
else:
# Unexpected — auto plan entry without a matching original unit.
plan_unit_by_position[pos] = None
else:
# empty / collision-skipped — NO None in units list, but the position
# is preserved in plan_unit_by_position so render_records can emit
# an empty zone record below (after frame_overrides apply).
plan_unit_by_position[pos] = None
units = plan_units
if not units or layout_preset is None:
# composition planner 결과 = 0 units. Sections 가 모두 V4 lookup 실패 또는
# status filter 통과 못 함. error.json 기록 후 abort.
run_dir.mkdir(parents=True, exist_ok=True)
error_data = {
"stage": "composition_planner",
"reason": (
"Composition planner v0 selected 0 viable units. "
f"Either no V4 entries for any section, or all candidates filtered out by "
f"allowed_statuses={sorted(MVP1_ALLOWED_STATUSES)}."
),
"aligned_section_ids": [s.section_id for s in sections],
"composition_debug": comp_debug,
}
err_path = run_dir / "error.json"
err_path.write_text(json.dumps(error_data, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"\n[Phase Z-2 MVP-1.5b] ABORT @ composition_planner", file=sys.stderr)
print(f" reason : 0 viable units after composition v0", file=sys.stderr)
print(f" error : {err_path}", file=sys.stderr)
sys.exit(1)
print(f" preset : {layout_preset} ({len(units)} units, composition v0 count-based)")
for u in units:
print(f" unit : {u.source_section_ids} merge={u.merge_type} → "
f"frame {u.frame_number} ({u.frame_template_id}) "
f"label={u.label} score={u.score:.3f}")
# ─── Step 6: Composition Planning ───
_write_step_artifact(
run_dir, 6, "composition_plan",
data={
"selected_units_count": len(units),
"layout_preset_decided": layout_preset,
"candidates_summary": comp_debug.get("candidates_summary"),
"candidates_total": comp_debug.get("candidates_total"),
"candidates_viable_auto": comp_debug.get("candidates_viable_auto"),
"selected_units": [
{
"source_section_ids": u.source_section_ids,
"merge_type": u.merge_type,
"frame_id": u.frame_id,
"frame_number": u.frame_number,
"frame_template_id": u.frame_template_id,
"label": u.label,
"v4_rank": u.v4_rank,
"selection_path": u.selection_path,
"fallback_reason": u.fallback_reason,
"score": u.score,
"phase_z_status": u.phase_z_status,
"rationale": u.rationale,
"notes": list(u.notes),
# Step 6-A axis (사용자 lock 2026-05-08) — V4 후보 list.
# 단일 frame_* / label / confidence 와 일관 (candidates[0] = rank-1 non-reject).
"v4_candidates": [
{
"template_id": c.template_id,
"frame_id": c.frame_id,
"frame_number": c.frame_number,
"confidence": c.confidence,
"label": c.label,
}
for c in u.v4_candidates
],
}
for u in units
],
},
step_status="done",
pipeline_path_connected=True,
inputs=["step02_normalized.json", "step05_v4_evidence.json"],
outputs=["step06_composition_plan.json"],
note=(
"composition v0 count-based — sections → candidates → score → greedy select. "
"Step 6-A (사용자 lock 2026-05-08): selected_units[i].v4_candidates 추가 "
"(non-reject max-6 후보 list, candidates[0] = 단일 frame_* 와 일관). "
"logic 무변 — runtime 결과 동일. Step 9 application_plan input."
),
)
# 5. Per-unit: synthesize MdxSection → mapper → assets → zone data
# mapper FitError 는 catch — 자동 파이프라인은 다른 zone 계속 진행. abort X.
positions = LAYOUT_PRESETS[layout_preset]["positions"]
zones_data = []
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,
)
# IMP-06 blocker-fix (Codex #13/#14/#15/#16/#17) — build render_records AFTER
# frame_overrides so each record points to the post-override CompositionUnit
# object (Codex #15 Catch R). render_records is the canonical per-position
# plan-derived view: cli_override / auto / empty / collision-skipped all carry
# a record. `unit` is the same instance reference as in `units` when
# renderable, otherwise None. This drives debug_zones/Step 9/Step 20 traces
# without forcing None into the renderable `units` list.
render_records: list[dict] = []
if section_assignment_plan is not None:
for entry in section_assignment_plan:
pos = entry["position"]
unit_ref = plan_unit_by_position.get(pos)
render_records.append({
"position": pos,
"assignment_source": entry["assignment_source"],
"unit": unit_ref,
"source_section_ids": list(entry.get("source_section_ids") or []),
"section_assignment_override": entry.get("section_assignment_override"),
"replaced_auto_unit": entry.get("replaced_auto_unit"),
"skipped_collided_auto_units": list(entry.get("skipped_collided_auto_units") or []),
"uncovered_section_ids": list(entry.get("uncovered_section_ids") or []),
"skipped_reason": entry.get("skipped_reason"),
})
# IMP-06 blocker-fix (Codex #13/#14/#15/#16/#17) — index render_records by
# unit object identity so the renderable zones loop below can stamp each
# zones_data / debug_zones entry with plan-aware fields (assignment_source,
# section_assignment_override, replaced_auto_unit, skipped_collided_auto_units,
# uncovered_section_ids, skipped_reason). Empty positions handled later by
# the post-loop "empty zone records" block — those records carry the same
# plan-aware fields directly from their plan entry.
render_record_by_unit_id: dict[int, dict] = {}
if render_records:
for rec in render_records:
u = rec.get("unit")
if u is not None:
render_record_by_unit_id[id(u)] = rec
for i, unit in enumerate(units):
position = positions[i] if i < len(positions) else f"zone_{i}"
plan_record = render_record_by_unit_id.get(id(unit))
# When render_records exists (override path) prefer its position to
# guard against future drifts. positions[i] is the legacy auto path
# and is byte-identical to plan_record.position in the normal case.
if plan_record is not None and plan_record.get("position"):
position = plan_record["position"]
synth_section = MdxSection(
section_id="+".join(unit.source_section_ids),
section_num=0,
title=unit.title,
raw_content=unit.raw_content,
)
contract = get_contract(unit.frame_template_id)
builder_name = contract["payload"].get("builder")
visual_hints = contract.get("visual_hints") or {}
min_height_px = visual_hints.get("min_height_px", DEFAULT_ZONE_MIN_HEIGHT_PX)
contract_frame_id = contract.get("frame_id")
# ─── trace-only runtime 연결 v0 — B1 → B4 chain (final.html 영향 X) ───
# B1~B4 의 dormant chain 을 *real MDX runtime data* 로 처음 호출.
# 결과 (PlacementPlan) = debug_zones[i].placement_trace 로 *기록만*.
# render path / mapper output / final.html 모두 미변경 — B5 baseline SHA 유지.
# B4 frame selection = catalog declaration order (V4 evidence 미사용 — 별 axis).
# Option 1 (PHASE_Z_B4_SOURCE_SHAPE_ENABLED, default OFF) : pilot = F13 top_bullets only.
b4_source_shape_enabled = (
os.environ.get("PHASE_Z_B4_SOURCE_SHAPE_ENABLED", "").strip().lower()
in {"1", "true", "yes"}
)
b1_source_shape = (
contract.get("source_shape")
if b4_source_shape_enabled and contract.get("source_shape") == "top_bullets"
else None
)
content_objects = extract_content_objects(synth_section, source_shape=b1_source_shape)
placement_plan = plan_placement(
content_objects=content_objects,
frame_contracts=list(load_frame_contracts().values()),
section_id=synth_section.section_id,
)
mapper_frame_template_id = unit.frame_template_id
matches_mapper = (
placement_plan.selected_template_id == mapper_frame_template_id
)
match_note: Optional[str] = None
if not matches_mapper:
if placement_plan.selected_template_id is None:
match_note = "no_frame_covers_content_types"
else:
match_note = (
f"B4 selected '{placement_plan.selected_template_id}' but "
f"mapper uses '{mapper_frame_template_id}' (composition V4 rank-1)"
)
placement_trace = {
**asdict(placement_plan),
"mapper_frame_template_id": mapper_frame_template_id,
"frame_selection_matches_mapper": matches_mapper,
"frame_selection_match_note": match_note,
}
# ─── end trace-only runtime 연결 v0 ───
# ─── B4 gatekeeper (Q-V4B4 / PHASE_Z_B4_GATEKEEPER, default OFF) ───
if (
os.environ.get("PHASE_Z_B4_GATEKEEPER", "").strip().lower()
in {"1", "true", "yes"}
and not matches_mapper
):
adapter_record = {
"position": position,
"source_section_ids": unit.source_section_ids,
"merge_type": unit.merge_type,
"template_id": unit.frame_template_id,
"reason": "v4_b4_mismatch",
"mismatch_detail": {
"v4_template_id": mapper_frame_template_id,
"b4_selected_template_id": placement_plan.selected_template_id,
"match_note": match_note,
},
}
adapter_needed_units.append(adapter_record)
print(f" adapter : zone--{position} {unit.source_section_ids} → "
f"{unit.frame_template_id} v4_b4_mismatch → adapter_needed (skip render)")
continue
# ─── end B4 gatekeeper ───
# mapper 시도 — 실패 (FitError) 시 zone 을 adapter_needed 로 표시하고 skip
try:
slot_payload = map_mdx_to_slots(synth_section, unit.frame_template_id)
except FitError as e:
adapter_record = {
"position": position,
"source_section_ids": unit.source_section_ids,
"merge_type": unit.merge_type,
"template_id": unit.frame_template_id,
"reason": "fit_error",
"fit_error": str(e),
}
adapter_needed_units.append(adapter_record)
print(f" adapter : zone--{position} {unit.source_section_ids} → "
f"{unit.frame_template_id} FitError → adapter_needed (skip render)")
continue
run_dir.mkdir(parents=True, exist_ok=True)
assets_dir = copy_assets(unit.frame_template_id, run_dir)
content_weight = compute_content_weight(synth_section)
truncated_count = slot_payload.get("_truncated_count") # builder 가 truncate 한 경우
# IMP-06 blocker-fix (Codex #13/#14/#15/#16/#17) — plan-aware additive
# fields. When no override CLI was used (plan_record is None), these
# default to None/False/[] so pre-IMP-06 readers see byte-equivalent
# data. Empty zone records appended below (post-loop) carry the same
# field shape from their plan entry directly.
plan_assignment_source = (
plan_record.get("assignment_source") if plan_record else None
)
plan_section_override = (
bool(plan_record.get("section_assignment_override"))
if plan_record else False
)
plan_replaced_auto = (
plan_record.get("replaced_auto_unit") if plan_record else None
)
plan_skipped_collided = list(
plan_record.get("skipped_collided_auto_units") or []
) if plan_record else []
plan_uncovered = list(
plan_record.get("uncovered_section_ids") or []
) if plan_record else []
plan_skipped_reason = (
plan_record.get("skipped_reason") if plan_record else None
)
zones_data.append({
"position": position,
"template_id": unit.frame_template_id,
"slot_payload": slot_payload,
"content_weight": content_weight,
"min_height_px": min_height_px,
"assignment_source": plan_assignment_source,
"section_assignment_override": plan_section_override,
})
debug_zones.append({
"position": position,
"source_section_ids": unit.source_section_ids,
"merge_type": unit.merge_type,
"title": unit.title,
"v4_rank1_frame_id": unit.frame_id,
"v4_rank1_frame_number": unit.frame_number,
"v4_template_id": unit.frame_template_id,
"v4_label": unit.label,
"v4_confidence": unit.confidence,
"v4_selected_rank": unit.v4_rank,
"selection_path": unit.selection_path,
"fallback_reason": unit.fallback_reason,
"fallback_used": bool(unit.selection_path and "fallback" in unit.selection_path),
"phase_z_status": unit.phase_z_status,
"composition_score": unit.score,
"composition_rationale": unit.rationale,
"composition_notes": list(unit.notes), # parent_merged_inferred 정보 신호
"mapper_type": "contract",
"contract_id": unit.frame_template_id,
"contract_frame_id": contract_frame_id,
"builder": builder_name,
"min_height_px": min_height_px,
"slot_payload_keys": sorted(slot_payload.keys()),
"content_truncated_count": truncated_count, # None / N (builder 가 N 개 자름)
"assets_dir": str(assets_dir.relative_to(run_dir)) if assets_dir else None,
"content_weight": content_weight,
# trace-only runtime 연결 v0 — B1 → B2 → B4 chain 결과 (render 미영향).
"placement_trace": placement_trace,
# IMP-06 blocker-fix — plan-aware additive fields.
"assignment_source": plan_assignment_source,
"section_assignment_override": plan_section_override,
"replaced_auto_unit": plan_replaced_auto,
"skipped_collided_auto_units": plan_skipped_collided,
"uncovered_section_ids": plan_uncovered,
"skipped_reason": plan_skipped_reason,
})
# IMP-06 blocker-fix (Codex #10 Catch N) — append explicit empty zone records
# for positions whose section-assignment plan produced no renderable unit
# (cli_override with no resolvable template, ad-hoc multi-section,
# override_collision whole-skip with no replacement, etc.). Empty records
# preserve zone identity in zones_data/debug_zones (template_id="__empty__",
# content_weight=0, min_height_px=0) so layout/grid stay structurally
# consistent without distorting allocation, and the partial-render loop
# short-circuits "__empty__" to an empty string (no TemplateNotFound).
if render_records:
renderable_positions = {z["position"] for z in zones_data}
for record in render_records:
pos = record["position"]
if pos in renderable_positions:
continue
zones_data.append({
"position": pos,
"template_id": "__empty__",
"slot_payload": {},
"content_weight": {"score": 0},
"min_height_px": 0,
"assignment_source": "empty",
"skipped_reason": (
record.get("skipped_reason")
or "section_assignment_override_empty_or_unrenderable"
),
})
debug_zones.append({
"position": pos,
"source_section_ids": list(record.get("source_section_ids") or []),
"merge_type": "empty",
"title": "",
"v4_template_id": "__empty__",
"v4_label": None,
"v4_confidence": 0.0,
"selection_path": "section_assignment_empty",
"fallback_reason": None,
"fallback_used": False,
"phase_z_status": None,
"composition_score": 0.0,
"composition_rationale": {},
"composition_notes": [],
"mapper_type": "empty",
"contract_id": "__empty__",
"contract_frame_id": None,
"assignment_source": record.get("assignment_source"),
"skipped_reason": (
record.get("skipped_reason")
or "section_assignment_override_empty_or_unrenderable"
),
"section_assignment_override": record.get("section_assignment_override"),
"replaced_auto_unit": record.get("replaced_auto_unit"),
"skipped_collided_auto_units": list(record.get("skipped_collided_auto_units") or []),
"uncovered_section_ids": list(record.get("uncovered_section_ids") or []),
})
# ─── Step 3: Content Object 추출 (B1, trace-only) ───
# IMP-03 — slide-level rich ContentObject 추출 (default OFF canary).
# scope-lock 16 조건 (Gitea #3) :
# - 별 함수 (extract_rich_content_objects) — v0 extract_content_objects unchanged
# - slide-level — section_id=None, id=`{mdx_id}.{type}-N`, scope='slide'
# - root-level once (per-zone duplication X)
# - plan_placement() 는 v0 list 만 받음 (B4 회귀 X) — 본 rich 결과는 artifact only
# - transform_table dedup : arrow row 감지 시 skip
rich_flag = os.environ.get("PHASE_Z_STEP3_RICH_OBJECTS_ENABLED", "").strip().lower()
rich_enabled_flag = rich_flag in {"1", "true", "yes"}
_assets_total = (
len(stage0_normalized_assets.get("popups") or [])
+ len(stage0_normalized_assets.get("images") or [])
+ len(stage0_normalized_assets.get("tables") or [])
)
rich_disabled_reason: Optional[str] = None
if not rich_enabled_flag:
rich_disabled_reason = "FLAG_OFF"
elif _assets_total == 0:
rich_disabled_reason = "NO_NORMALIZED_ASSETS"
rich_objects: list = []
rich_skips: list = []
if rich_disabled_reason is None:
mdx_num_match = re.match(r"(\d+)", mdx_path.stem)
rich_mdx_id = mdx_num_match.group(1).zfill(2) if mdx_num_match else "00"
rich_objects, rich_skips = extract_rich_content_objects(
stage0_normalized_assets, mdx_id=rich_mdx_id,
)
# Count/list invariant check (IMP-02 ↔ IMP-03 chain) — soft warning, no fail.
invariant_warnings: list[dict] = []
_adapter_counts = (stage0_adapter_diagnostics or {}).get("adapter_counts") or {}
if _adapter_counts:
for key in ("popups", "images", "tables"):
expected = _adapter_counts.get(key)
actual = len(stage0_normalized_assets.get(key) or [])
if expected is not None and expected != actual:
invariant_warnings.append({
"field": key,
"adapter_counts": expected,
"stage0_normalized_assets_len": actual,
})
_write_step_artifact(
run_dir, 3, "content_objects",
data={
"per_zone": [
{
"position": dz["position"],
"section_ids": dz["source_section_ids"],
"internal_regions": (dz.get("placement_trace") or {}).get("internal_regions") or [],
}
for dz in debug_zones
],
# IMP-03 — slide-level rich trace (additive, trace-only).
"rich_content_objects": [asdict(o) for o in rich_objects],
"rich_content_objects_enabled": rich_disabled_reason is None,
"rich_content_objects_scope": "slide",
"rich_content_objects_source": "stage0_normalized_assets",
"rich_content_objects_disabled_reason": rich_disabled_reason,
"rich_content_objects_skips": rich_skips,
"rich_content_objects_invariant_warnings": invariant_warnings,
},
step_status="trace-only",
pipeline_path_connected=False,
inputs=["step02_normalized.json"],
outputs=["step03_content_objects.json"],
note=(
"현재는 trace 로 기록되지만 render payload 를 직접 만들지는 않음. "
"mapper.py 가 별도로 MDX 직접 파싱. "
"IMP-03 rich_content_objects = slide-level popup/image/table trace "
"(PHASE_Z_STEP3_RICH_OBJECTS_ENABLED canary, default OFF)."
),
)
# ─── Step 4: Section Internal Composition (B2, trace-only) ───
_write_step_artifact(
run_dir, 4, "internal_composition",
data={
"per_zone": [
{
"position": dz["position"],
"section_ids": dz["source_section_ids"],
"selected_template_id": (dz.get("placement_trace") or {}).get("selected_template_id"),
"frame_selection_matches_mapper": (dz.get("placement_trace") or {}).get("frame_selection_matches_mapper"),
"frame_selection_match_note": (dz.get("placement_trace") or {}).get("frame_selection_match_note"),
}
for dz in debug_zones
],
},
step_status="trace-only",
pipeline_path_connected=False,
inputs=["step03_content_objects.json"],
outputs=["step04_internal_composition.json"],
note="현재는 trace 로 기록되지만 render payload 를 직접 만들지는 않음. composition_planner_debug 가 main 결정 (Step 6).",
)
# ─── Step 9: Frame Selection (per zone) ───
_write_step_artifact(
run_dir, 9, "frame_selection",
data={
"per_zone": [
{
"position": dz["position"],
"v4_rank1_frame_number": dz.get("v4_rank1_frame_number"),
"v4_selected_rank": dz.get("v4_selected_rank"),
"v4_template_id": dz.get("v4_template_id"),
"v4_confidence": dz.get("v4_confidence"),
"v4_label": dz.get("v4_label"),
"selection_path": dz.get("selection_path"),
"fallback_reason": dz.get("fallback_reason"),
"phase_z_status": dz.get("phase_z_status"),
"selected_template_id": dz.get("contract_id"),
"mapper_type": dz.get("mapper_type"),
"composition_score": dz.get("composition_score"),
"composition_rationale": dz.get("composition_rationale"),
}
for dz in debug_zones
],
},
step_status="partial",
pipeline_path_connected=True,
inputs=["step05_v4_evidence.json", "step06_composition_plan.json"],
outputs=["step09_frame_selection.json", "step09_frame_matching_candidates.html"],
note="V4 evidence 와 B4 통합 미완 — 별 axis. 현재 = composition planner 의 V4 rank-1 채택.",
)
# Step 9 HTML — V4 top candidates per zone (rank 1~4)
# IMP-08 N-R6 diagnostic exemption : this report path is post-decision
# reporting only. Runtime selection goes through _resolve_v4_section_key
# (4 sites). Direct dict lookup here is intentional — debug_zones carries
# dict-shape entries without v4_alias_keys plumbing, and a miss here only
# yields a "V4 entry missing" report line (runtime impact zero).
try:
with open(V4_RESULT_PATH, encoding="utf-8") as _vf:
_v4_full = yaml.safe_load(_vf)
_v4_sections = (_v4_full or {}).get("mdx_sections", {}) or {}
except Exception:
_v4_sections = {}
_candidates_html = ""
# path from steps/ 에서 figma_to_html_agent/blocks/{frame_id}/index.html
# steps → phase_z2 → run_id → runs → data → design_agent (5 levels up)
_frame_iframe_base = "../../../../../figma_to_html_agent/blocks"
for dz in debug_zones:
_section_ids = dz.get("source_section_ids", [])
_candidates_html += f"zone--{dz['position']} (sections: {', '.join(_section_ids)})
"
for _sid in _section_ids:
_sec = _v4_sections.get(_sid)
if not _sec:
_candidates_html += f"section {_sid}: V4 entry 없음
"
continue
_candidates_html += (
f"section {_sid}: {_sec.get('mdx_title', '?')}
"
f"answer_frame_number: {_sec.get('answer_frame_number', '-')}"
f" | is_holdout: {_sec.get('is_holdout', '-')}
"
f''
)
for _j in (_sec.get("judgments_full32") or [])[:4]:
_rank = _j.get("v4_full_rank")
_frame_id = _j.get("frame_id")
_frame_index = ASSETS_SOURCE_BASE / str(_frame_id) / "index.html"
_has_preview = _frame_index.exists()
_css = "candidate-card candidate-rank1" if _rank == 1 else "candidate-card"
_preview_html = (
f'
'
f''
f'
'
if _has_preview else
f'
'
f'preview missing
'
f'(figma_to_html_agent/blocks/{_frame_id}/index.html 없음)'
f'
'
)
_candidates_html += (
f'
'
f'rank {_rank}: frame {_j.get("frame_number")} '
f'({_j.get("template_id")})
'
f'confidence: {_j.get("confidence", 0):.4f} | '
f'label: {_j.get("label", "-")} | '
f'base: {_j.get("base", 0):.4f} | penalty: {_j.get("penalty", 0)}'
f'{_preview_html}'
f"
"
)
_candidates_html += "
"
_write_step_html(
run_dir, 9, "frame_matching_candidates",
title="V4 Frame Matching Candidates (top 4 per section)",
body_html=(
f"각 zone 의 source section 마다 V4 rank 1~4 후보 표시. "
f"녹색 강조 = rank-1 (현재 선택된 frame).
{_candidates_html}"
),
step_status="done",
inputs=["step05_v4_evidence.json", "tests/matching/v4_full32_result.yaml"],
outputs=["step09_frame_selection.json", "step09_frame_matching_candidates.html"],
)
# ─── Step 10: Frame Contract 확인 ───
contracts_per_zone = []
for dz in debug_zones:
c = get_contract(dz.get("contract_id"))
contracts_per_zone.append({
"position": dz["position"],
"contract_id": dz.get("contract_id"),
"frame_id": (c or {}).get("frame_id"),
"family": (c or {}).get("family"),
"source_shape": (c or {}).get("source_shape"),
"cardinality": (c or {}).get("cardinality"),
"visual_hints": (c or {}).get("visual_hints"),
"accepted_content_types": (c or {}).get("accepted_content_types"),
"sub_zones": (c or {}).get("sub_zones"),
"payload_builder": ((c or {}).get("payload") or {}).get("builder"),
"payload_builder_options": ((c or {}).get("payload") or {}).get("builder_options"),
})
_write_step_artifact(
run_dir, 10, "frame_contract",
data={"per_zone": contracts_per_zone},
step_status="partial",
pipeline_path_connected=True,
inputs=["step09_frame_selection.json", "templates/phase_z2/catalog/frame_contracts.yaml"],
outputs=["step10_frame_contract.json"],
note="full contract dict (sub_zones / cardinality / visual_hints 포함). density envelope 미선언 (별 axis).",
)
# ─── Step 11: Slot Mapping (B4 placement, trace-only) ───
_write_step_artifact(
run_dir, 11, "slot_mapping",
data={
"per_zone": [
{
"position": dz["position"],
"selected_template_id": (dz.get("placement_trace") or {}).get("selected_template_id"),
"slot_assignments": (dz.get("placement_trace") or {}).get("slot_assignments") or [],
"rejection": (dz.get("placement_trace") or {}).get("rejection") or [],
"overflow_buffer": (dz.get("placement_trace") or {}).get("overflow_buffer") or [],
}
for dz in debug_zones
],
},
step_status="trace-only",
pipeline_path_connected=False,
inputs=["step04_internal_composition.json", "step10_frame_contract.json"],
outputs=["step11_slot_mapping.json"],
note="B4 PlacementPlan slot_assignments — render path 미연결. 실제 render slot 매핑은 mapper.py 의 builder.",
)
# ─── Step 12: Slot Payload (actual values, mapper.py 결과) ───
_write_step_artifact(
run_dir, 12, "slot_payload",
data={
"per_zone": [
{
"position": zd["position"],
"template_id": zd["template_id"],
"builder": (get_contract(zd["template_id"]) or {}).get("payload", {}).get("builder"),
"slot_payload": zd["slot_payload"],
"content_weight": zd.get("content_weight"),
"min_height_px": zd.get("min_height_px"),
}
for zd in zones_data
],
},
step_status="done",
pipeline_path_connected=True,
inputs=["step10_frame_contract.json", "step02_normalized.json"],
outputs=["step12_slot_payload.json"],
note="map_with_contract 결과 — actual slot_payload 값 그대로 (key 만 X).",
)
# 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
)
# IMP-09 PR 1 — unified per-zone geometry aggregation across all
# layouts. Spanning zones in 2-D layouts (T / 2x2 from PR 2 onward)
# are handled by _compute_per_zone_geometry; in PR 1 the helper
# operates on row/col-static or row-dynamic / col-dynamic outputs.
per_zone_geo = _compute_per_zone_geometry(layout_css, debug_zones, GRID_GAP)
for dz, geo in zip(debug_zones, per_zone_geo):
dz["height_px"] = geo["zone_height_px"]
dz["ratio"] = geo["zone_height_ratio"]
dz["width_px"] = geo["zone_width_px"]
dz["width_ratio"] = geo["zone_width_ratio"]
if layout_css["dynamic_rows"] and layout_css.get("dynamic_cols"):
print(
f" zones : 2-D heights {layout_css['heights_px']} px, "
f"widths {layout_css['widths_px']} px, "
f"ratios {layout_css['ratios']}, "
f"width_ratios {layout_css['width_ratios']}"
)
elif layout_css["dynamic_rows"]:
print(f" zones : heights {layout_css['heights_px']} px, ratios {layout_css['ratios']}")
elif layout_css.get("dynamic_cols"):
print(f" zones : widths {layout_css['widths_px']} px, width_ratios {layout_css['width_ratios']}")
else:
print(f" zones : fr default ({layout_css['cols']} / {layout_css['rows']})")
# ─── Step 7: Slide-Level Layout (선택된 layout + 후보 list — Step 7-conn) ───
# Step 7-conn axis (사용자 lock 2026-05-08) — select_layout_candidates(unit_count)
# 결과를 artifact 에 기록 (passive). default 결정 자체는 무변 (layout_preset 그대로).
layout_candidates_list = select_layout_candidates(len(units))
_write_step_artifact(
run_dir, 7, "layout",
data={
"layout_preset": layout_preset,
"layout_css": layout_css,
"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,
inputs=["step06_composition_plan.json"],
outputs=["step07_layout.json", "step07_selected_layout.html"],
note=(
"count-based v0 — 들여쓰기 / 정렬 미세 layout 미구현 (Step 7 ⚠ partial). "
"Step 7-conn (사용자 lock 2026-05-08): unit_count → layout_candidates list "
"(select_layout_candidates) artifact 기록. default 결정 자체는 무변 — "
"candidates[0] 가 default (default_selection: true), 나머지는 alternative. "
"Step 9 application_plan input."
),
)
# Step 7 HTML — slide-base 위 실제 zone 박스 위치 시각화 (constants 와 1:1 일치)
_slide_w, _slide_h, _body_h = SLIDE_W, SLIDE_H, SLIDE_BODY_HEIGHT
_zone_palette = ["#dbeafe", "#fef3c7", "#dcfce7", "#fce7f3", "#ede9fe"]
_zone_borders = ["#3b82f6", "#f59e0b", "#16a34a", "#db2777", "#7c3aed"]
_grid_template = layout_css.get("rows") or "1fr"
_zone_visuals_7 = ""
for i, dz in enumerate(debug_zones):
_color = _zone_palette[i % len(_zone_palette)]
_border = _zone_borders[i % len(_zone_borders)]
_zone_visuals_7 += (
f''
f'
zone--{dz["position"]}
'
f'
{dz.get("v4_template_id") or "-"}
'
f'
'
f'height: {dz.get("height_px") or "?"}px / ratio: {dz.get("ratio") or "?"}'
f'
'
)
_slide_visual_7 = (
f''
f'
'
f'
[slide-title placeholder]
'
f'
'
f'{_zone_visuals_7}
'
f'
'
f'[slide-footer]
'
f'
'
)
# Step 7-conn — layout 후보 list 시각 (default = candidates[0]).
_candidates_rows = ""
for i, lc in enumerate(layout_candidates_list):
_is_default = (i == 0)
_is_selected = (lc == layout_preset)
_badge = (
'selected'
if _is_selected else ''
)
_default_mark = (
'default'
if _is_default else
'alternative'
)
_candidates_rows += (
f'{lc}{_default_mark}{_badge}'
)
_candidates_block = (
f'Layout Candidates (unit_count={len(units)})
'
f'source: templates/phase_z2/layouts/layouts.yaml + '
f'select_layout_candidates(unit_count) '
f'(Step 7-B). default = first entry. 현재 single-decision logic 은 '
f'default 만 사용 — Step 9 application_plan 진입 시 후보 평가.
'
f''
)
_write_step_html(
run_dir, 7, "selected_layout",
title=f"Selected Layout — {layout_preset}",
body_html=(
f"Layout Preset: {layout_preset}
"
f"slide canvas {_slide_w}×{_slide_h}px / slide-body {SLIDE_BODY_WIDTH}×{_body_h}px (left {SLIDE_BODY_LEFT} / top {SLIDE_BODY_TOP}). "
f"안 zone 박스 = 실제 비율 그대로.
"
f"{_slide_visual_7}"
f"{_candidates_block}"
f"Layout CSS (raw)
"
f"{json.dumps(layout_css, ensure_ascii=False, indent=2)}
"
),
step_status="partial",
inputs=["step06_composition_plan.json"],
outputs=["step07_layout.json", "step07_selected_layout.html"],
)
# ─── Step 8: Zone + Region Ratio (계획값 only — render 전) ───
# Step 8-conn axis (사용자 lock 2026-05-08) — region/display 후보 list per zone (passive 기록).
# placeholder signals (Step 3/4 부재 종속) :
# region_count = 1 (text-only assumption)
# content_type = "text_block"
# flow_type / role_pattern / has_visual_element / details_presence /
# large_table / long_text / fits_in_region = None or False
# Step 3/4 (Content Object 추출 + Internal Composition Planning) 활성화 시
# 이 placeholder 가 실제 content_object 신호로 교체됨 (별 axis).
_step8_placeholder_signals = {
"region_count": 1,
"content_type": "text_block",
"flow_type": None,
"role_pattern": None,
"ratio_asymmetric": False,
"has_visual_element": False,
"details_presence": False,
"large_table": False,
"long_text": False,
"fits_in_region": None,
"_note": (
"Step 3/4 부재 종속 placeholder — 모든 zone 을 text-only / region_count=1 "
"로 가정. 실제 content_object 신호 활성화는 별 axis."
),
}
zone_region_plans = []
for dz in debug_zones:
contract = get_contract(dz.get("contract_id")) or {}
sub_zones = contract.get("sub_zones") or []
cardinality = contract.get("cardinality") or {}
visual_hints = contract.get("visual_hints") or {}
# Step 8-conn — region / display strategy 후보 (placeholder signals).
_region_cands = select_region_layout_candidates(
region_count=_step8_placeholder_signals["region_count"],
flow_type=_step8_placeholder_signals["flow_type"],
role_pattern=_step8_placeholder_signals["role_pattern"],
ratio_asymmetric=_step8_placeholder_signals["ratio_asymmetric"],
has_visual_element=_step8_placeholder_signals["has_visual_element"],
details_presence=_step8_placeholder_signals["details_presence"],
large_table=_step8_placeholder_signals["large_table"],
long_text=_step8_placeholder_signals["long_text"],
)
_display_cands = select_display_strategy_candidates(
content_type=_step8_placeholder_signals["content_type"],
long_text=_step8_placeholder_signals["long_text"],
large_table=_step8_placeholder_signals["large_table"],
fits_in_region=_step8_placeholder_signals["fits_in_region"],
)
zone_region_plans.append({
"position": dz["position"],
"zone_height_px_planned": dz.get("height_px"),
"zone_ratio_planned": dz.get("ratio"),
"zone_width_px_planned": dz.get("width_px"),
"zone_col_ratio_planned": dz.get("width_ratio"),
"min_height_px": visual_hints.get("min_height_px"),
"frame_cardinality_strict": cardinality.get("strict"),
"sub_zones_planned": [
{
"id": sz.get("id"),
"role": sz.get("role"),
"accepts": sz.get("accepts"),
"cardinality_strict": (sz.get("cardinality") or {}).get("strict"),
"partial_target_path": sz.get("partial_target_path"),
}
for sz in sub_zones
],
"child_distribution_note": (
"현재 child zone (sub_zones 안 sections) 균등 분배 — "
"Step 8 region-level ratio ⚠ partial."
),
# Step 8-conn — region/display 후보 (placeholder signals)
"region_layout_candidates": _region_cands,
"display_strategy_candidates": _display_cands,
})
_write_step_artifact(
run_dir, 8, "zone_region_ratios",
data={
"zone_heights_px_planned": layout_css.get("heights_px"),
"zone_widths_px_planned": layout_css.get("widths_px"),
"zone_ratios_planned": layout_css.get("ratios"),
"zone_col_ratios_planned": layout_css.get("width_ratios"),
"per_zone_plan": zone_region_plans,
# Step 8-conn placeholder signals (사람이 한 곳에서 caveat 확인)
"step8_conn_placeholder_signals": _step8_placeholder_signals,
},
step_status="partial",
pipeline_path_connected=True,
inputs=["step07_layout.json", "step10_frame_contract.json"],
outputs=["step08_zone_region_ratios.json", "step08_zone_content_placement.html"],
note=(
"계획값 only. 실측값은 Step 14 (visual_check). "
"zone-level 만 dynamic, region-level (sub_zone 안 sections) 은 균등 분배 (1/1/1). "
"F29 처럼 비균등 콘텐츠 (transform_table + plain) 는 첫 cell 부족 가능 — Step 8 ⚠ partial. "
"Step 8-conn (사용자 lock 2026-05-08): per_zone_plan[i] 에 region_layout_candidates + "
"display_strategy_candidates 추가 (passive 기록). placeholder signals = text-only / "
"region_count=1 (Step 3/4 부재 종속). 실제 content_object 신호 활성화는 별 axis. "
"Step 9 application_plan input."
),
)
# Step 8 HTML — zone + sub_zone + sections 시각 박스 (균등 분배 보임)
# Step 8-conn — region/display 후보 list 도 zone 별 박스 아래 표시.
_zone_visuals_8 = ""
for i, plan in enumerate(zone_region_plans):
_color = _zone_palette[i % len(_zone_palette)]
_border = _zone_borders[i % len(_zone_borders)]
_zone_h = plan['zone_height_px_planned'] or 300
_sub_zones_html = ""
for sz in plan['sub_zones_planned']:
_card = sz['cardinality_strict'] or 1
_accepts = ', '.join(sz['accepts'] or [])
# sub_zone 안에 section 박스 cardinality 만큼 균등 분배
_section_boxes = "".join(
f'section {k+1} (균등 1/{_card})
'
for k in range(_card)
)
_sub_zones_html += (
f''
f'
'
f'{sz["id"]}
'
f''
f'cardinality.strict={_card} | accepts: {_accepts}'
f''
f'
'
f'{_section_boxes}'
f'
'
)
# Step 8-conn — region / display strategy 후보 시각.
_region_pills = " ".join(
f''
f'{rc}{" ★" if k == 0 else ""}'
for k, rc in enumerate(plan.get("region_layout_candidates") or [])
)
_display_pills = " ".join(
f''
f'{ds}{" ★" if k == 0 else ""}'
for k, ds in enumerate(plan.get("display_strategy_candidates") or [])
)
_zone_visuals_8 += (
f'zone--{plan["position"]} ({_zone_h}px planned, '
f'min_height {plan["min_height_px"]}px, '
f'frame_cardinality_strict {plan["frame_cardinality_strict"]})
'
f''
f'{_sub_zones_html}'
f'
'
f'{plan["child_distribution_note"]}
'
f''
f'
region_layout_candidates (★ default): {_region_pills}
'
f'
display_strategy_candidates (★ default): {_display_pills}
'
f'
'
)
_write_step_html(
run_dir, 8, "zone_content_placement",
title="Zone + Child Zone Placement Plan (pre-render)",
body_html=(
f"중요: 이 페이지는 계획값 only. 실제 측정값 (clientHeight / scrollHeight / overflow) 은 "
f"step14_visual_check.json 에서 봄.
"
f"각 zone 안에 sub_zones 가 columns 로 나열, 각 sub_zone 안 sections 가 cardinality.strict 만큼 균등 row 분배. "
f"F29 process_column 의 첫 section 이 transform_table (AS-IS/TO-BE 표) 인데 균등 분배로 capacity 부족 → 10px overflow 직접 원인.
"
f''
f'Step 8-conn placeholder caveat (사용자 lock 2026-05-08): '
f'region_layout_candidates / display_strategy_candidates 는 현재 '
f'region_count=1 / content_type="text_block" placeholder 신호로만 산출. '
f'Step 3/4 (Content Object 추출 + Internal Composition Planning) 활성화 시 '
f'실제 content_object 신호로 교체 (별 axis).'
f'
'
f"{_zone_visuals_8}"
),
step_status="partial",
inputs=["step07_layout.json", "step10_frame_contract.json"],
outputs=["step08_zone_region_ratios.json", "step08_zone_content_placement.html"],
)
# ─── Step 9 v0: Passive Application Plan (사용자 lock 2026-05-08) ───
# status.md §2 schema 따라 per-unit application_plan 생성.
# v0 = passive — Step 6 default 그대로 사용. runtime 결과 무변.
# invariant 5 가지 (status.md §4) 만족 :
# 1. len(application_candidates) == len(v4_candidates) per unit
# 2. application_candidates[i].template_id == v4_candidates[i].template_id (zip 일관)
# 3. current_default_candidate == v4_candidates[0].template_id (application_status="ok")
# 또는 null (application_status="no_v4_candidate")
# 4. len(units) == Step 6 의 plan_composition 결과 (무변)
# 5. application_status == "ok" iff len(v4_candidates) > 0 iff candidate_status == "ok"
# IMP-06 blocker-fix (Codex #13 Blocker 3 / #16) — pre-build per-unit
# plan-aware lookup so Step 9 application_plan can carry position +
# assignment_source + override-flag fields. Render-record is the canonical
# plan-derived per-position view (built post-frame_overrides); match by
# object identity since render_records[i]["unit"] holds the same instance
# ref as the entry in `units`.
plan_record_by_unit_id = {}
if "render_records" in locals() and render_records:
for rec in render_records:
u = rec.get("unit")
if u is None:
continue
plan_record_by_unit_id[id(u)] = rec
application_plan_units = []
for i, unit in enumerate(units):
unit_id = "+".join(unit.source_section_ids)
# zone_region_plans 는 unit i 와 1:1 (Step 6 unit → Step 8 zone_plan).
zone_plan = zone_region_plans[i] if i < len(zone_region_plans) else {}
has_v4 = bool(unit.v4_candidates)
candidate_status = "ok" if has_v4 else "no_non_reject_v4_candidate"
application_status = "ok" if has_v4 else "no_v4_candidate"
current_default = unit.frame_template_id if has_v4 else None
selection_trace = v4_fallback_traces.get(unit.source_section_ids[0], {})
# IMP-06 blocker-fix (Codex #13 Blocker 3 / #16) — plan-aware additive
# fields. additive = pre-IMP-06 readers (no override CLI used) see
# position=None / assignment_source=None / section_assignment_override
# =False / replaced_auto_unit=None / skipped_collided_auto_units=[] /
# skipped_reason=None — i.e. byte-identical absent overrides.
plan_record = plan_record_by_unit_id.get(id(unit))
plan_position = plan_record.get("position") if plan_record else None
plan_assignment_source = plan_record.get("assignment_source") if plan_record else None
plan_section_override = bool(plan_record.get("section_assignment_override")) if plan_record else False
plan_replaced_auto = plan_record.get("replaced_auto_unit") if plan_record else None
plan_skipped_collided = list(plan_record.get("skipped_collided_auto_units") or []) if plan_record else []
plan_skipped_reason = plan_record.get("skipped_reason") if plan_record 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 에 있으므로).
# IMP-08 B-3 : forward sub-section V4 aliases (decimal heading_number)
# when canonical ordinal id misses; U1 default = empty list (no change).
_first_sid = unit.source_section_ids[0]
v4_all_for_unit = lookup_v4_all_judgments(
v4, _first_sid, alias_keys=section_alias_by_id.get(_first_sid)
)
# application_candidates : V4 후보 zip 으로 application_mode 변환
app_candidates = []
for c in unit.v4_candidates:
mode, auto_app, delegated = APPLICATION_MODE_BY_V4_LABEL.get(
c.label, ("exclude", False, None)
)
app_candidates.append({
"template_id": c.template_id,
"frame_id": c.frame_id,
"v4_label": c.label,
"application_mode": mode,
"auto_applicable": auto_app,
"required_changes": [], # v0 = trace-only
"delegated_to": delegated,
})
# IMP-11 D-2 (u1) — per-candidate min_height_px source = catalog
# frame_contracts[template_id].visual_hints.min_height_px (logical 1280×720 px).
# None when contract unregistered (frontend tolerates undefined).
# Single get_contract lookup binds both catalog_registered and min_height_px.
v4_all_judgments_list = []
for c in v4_all_for_unit:
_contract = get_contract(c.template_id)
v4_all_judgments_list.append({
"template_id": c.template_id,
"frame_id": c.frame_id,
"frame_number": c.frame_number,
"v4_rank": c.v4_rank,
"confidence": c.confidence,
"label": c.label,
"catalog_registered": _contract is not None,
"min_height_px": (_contract or {}).get("visual_hints", {}).get("min_height_px"),
})
application_plan_units.append({
"unit_id": unit_id,
"layout_preset": layout_preset,
"layout_candidates": layout_candidates_list,
"region_layout_candidates": zone_plan.get("region_layout_candidates", []),
"display_strategy_candidates": zone_plan.get("display_strategy_candidates", []),
"candidate_status": candidate_status,
"application_status": application_status,
"current_default_candidate": current_default,
"selected_v4_rank": unit.v4_rank,
"selection_path": unit.selection_path,
"fallback_used": bool(unit.selection_path and "fallback" in unit.selection_path),
"fallback_reason": unit.fallback_reason,
# IMP-05 L2 (Codex #10 D4 / #16 idea A) — Step 9 per-unit candidate evidence.
# candidate_evidence is the primary field for future frontend / AI consumers.
# fallback_chain is kept as a compat alias for any pre-IMP-05 reader.
"candidate_evidence": selection_trace.get("candidates", []),
"fallback_chain": selection_trace.get("candidates", []), # compat alias; prefer candidate_evidence
"v4_candidates": [
{
"template_id": c.template_id,
"frame_id": c.frame_id,
"frame_number": c.frame_number,
"v4_rank": c.v4_rank,
"confidence": c.confidence,
"label": c.label,
}
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 미연결).
# IMP-11 D-2 (u1) : per-candidate min_height_px added (None when unregistered).
"v4_all_judgments": v4_all_judgments_list,
"application_candidates": app_candidates,
# IMP-06 blocker-fix (Codex #13 Blocker 3 / #16) — plan-aware
# additive fields. None / False / [] when no override CLI used.
"position": plan_position,
"assignment_source": plan_assignment_source,
"section_assignment_override": plan_section_override,
"replaced_auto_unit": plan_replaced_auto,
"skipped_collided_auto_units": plan_skipped_collided,
"skipped_reason": plan_skipped_reason,
})
units_with_no_v4 = [
u["unit_id"] for u in application_plan_units
if u["application_status"] == "no_v4_candidate"
]
_write_step_artifact(
run_dir, 9, "application_plan",
data={
"units": application_plan_units,
"candidate_status_summary": {
"units_with_no_v4_candidate": units_with_no_v4,
"units_with_fallback": [
u["unit_id"] for u in application_plan_units if u.get("fallback_used")
],
},
"fallback_policy": comp_debug.get("v4_fallback_summary"),
# Step 7-A axis : user override trace
"frame_overrides_applied": frame_overrides_applied,
"frame_overrides_skipped": frame_overrides_skipped,
# IMP-06 blocker-fix (Codex #13 Blocker 3 / #16) — surface the
# section-assignment plan + summary at Step 9 top level so
# frontend / downstream readers do not have to dive into
# comp_debug to see override impact.
"section_assignment_plan": comp_debug.get("section_assignment_plan"),
"section_assignment_summary": comp_debug.get("section_assignment_summary"),
"v0_lock_note": (
"Step 9 v0 passive (사용자 lock 2026-05-08). "
"Step 6 default 그대로 사용 — runtime 결과 byte-동일. "
"auto decision / scoring 은 Step 9 v1 (별 axis)."
),
},
step_status="partial",
pipeline_path_connected=True,
inputs=[
"step05_v4_evidence.json",
"step06_composition_plan.json",
"step07_layout.json",
"step08_zone_region_ratios.json",
],
outputs=["step09_application_plan.json", "step09_application_plan.html"],
note=(
"Step 9 v0 passive application_plan trace (사용자 lock 2026-05-08). "
"V4 후보 + layout / region / display 후보 + V4 label → application_mode "
"변환을 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 부재). "
"Step 7-A axis (2026-05-08): frame_overrides_applied 가 사용자 LayoutPanel/"
"FramePanel 선택값을 강제 적용한 trace."
),
)
# Step 9 v0 HTML — per-unit table + application_mode pill.
_mode_color = {
"direct_insert": ("#dcfce7", "#166534"), # green
"same_frame_with_adjustment": ("#dbeafe", "#1e40af"), # blue
"layout_or_region_change": ("#fef3c7", "#92400e"), # yellow (manual)
"exclude": ("#fee2e2", "#991b1b"), # red
}
_unit_blocks_9 = ""
for u in application_plan_units:
_status_badge = (
f'application_status: ok'
if u["application_status"] == "ok" else
f'application_status: no_v4_candidate'
)
_default_html = (
f'{u["current_default_candidate"]}'
if u["current_default_candidate"] else 'null'
)
_fallback_html = (
f' | selection_path: {u.get("selection_path")}'
f' | selected_v4_rank: {u.get("selected_v4_rank")}'
f' | fallback_reason: {u.get("fallback_reason")}'
if u.get("fallback_used") else
f' | selection_path: {u.get("selection_path")}'
)
_layout_pills = " ".join(
f'{lc}{" ★" if k == 0 else ""}'
for k, lc in enumerate(u["layout_candidates"])
)
_region_pills = " ".join(
f'{rc}{" ★" if k == 0 else ""}'
for k, rc in enumerate(u["region_layout_candidates"])
)
_display_pills = " ".join(
f'{ds}{" ★" if k == 0 else ""}'
for k, ds in enumerate(u["display_strategy_candidates"])
)
_app_rows = ""
for k, ac in enumerate(u["application_candidates"]):
_bg, _fg = _mode_color.get(ac["application_mode"], ("#f1f5f9", "#475569"))
_is_default = (ac["template_id"] == u["current_default_candidate"])
_default_mark = (
' current_default'
if _is_default else ''
)
_app_rows += (
f''
f''
f'{ac["template_id"]}{_default_mark} | '
f''
f'{ac["v4_label"]} | '
f''
f'{ac["application_mode"]} | '
f''
f'{"✓" if ac["auto_applicable"] else "✗"} | '
f''
f'{ac["delegated_to"] or "—"} | '
f'
'
)
_unit_blocks_9 += (
f''
f'
unit: {u["unit_id"]} {_status_badge}
'
f'
'
f'layout_preset (default): {u["layout_preset"]} | '
f'current_default_candidate: {_default_html}{_fallback_html}
'
f'
layout_candidates (★ default): {_layout_pills}
'
f'
region_layout_candidates (★ default, placeholder): {_region_pills}
'
f'
display_strategy_candidates (★ default, placeholder): {_display_pills}
'
f'
'
f''
f'| template_id | '
f'v4_label | '
f'application_mode | '
f'auto_app. | '
f'delegated_to | '
f'
'
f'{_app_rows}'
f'
'
f'
'
)
_summary_block_9 = (
f''
f'Step 9 v0 lock (사용자 2026-05-08): '
f'passive trace only — Step 6 default 그대로 사용 (runtime 결과 byte-동일). '
f'auto decision / scoring 은 Step 9 v1 (별 axis). '
f'region / display 후보는 Step 8-conn placeholder signal (Step 3/4 부재) 종속.'
f'
'
f'units: {len(application_plan_units)} | '
f'units_with_no_v4_candidate: '
f'{len(units_with_no_v4)} ({", ".join(units_with_no_v4) if units_with_no_v4 else "none"})
'
)
_write_step_html(
run_dir, 9, "application_plan",
title="Step 9 v0 — Passive Application Plan",
body_html=(
f'{_summary_block_9}'
f'{_unit_blocks_9}'
),
step_status="partial",
inputs=[
"step05_v4_evidence.json",
"step06_composition_plan.json",
"step07_layout.json",
"step08_zone_region_ratios.json",
],
outputs=["step09_application_plan.json", "step09_application_plan.html"],
)
# 7. Render single slide
html = render_slide(slide_title, slide_footer, zones_data, layout_preset, layout_css)
# 8. Write final.html
out_path = run_dir / "final.html"
out_path.write_text(html, encoding="utf-8")
print(f" html : {out_path}")
# ─── Step 13: Render ───
_write_step_artifact(
run_dir, 13, "render",
data={
"final_html_path": str(out_path),
"final_html_size_bytes": out_path.stat().st_size,
"render_inputs": {
"slide_title": slide_title,
"slide_footer": slide_footer,
"zones_count": len(zones_data),
"layout_preset": layout_preset,
},
},
step_status="done",
pipeline_path_connected=True,
inputs=["step07_layout.json", "step12_slot_payload.json", "templates/phase_z2/slide_base.html", "templates/phase_z2/families/*.html"],
outputs=["step13_render.json", "step13_draft_render.html", "final.html"],
note="Jinja2 render 결과 = final.html. step13_draft_render.html 은 final.html 동일 복사 (검토용).",
)
# Step 13 HTML — final.html 그대로 복사 (검토용)
(run_dir / "steps" / "step13_draft_render.html").write_text(html, encoding="utf-8")
# 9. Selenium check
print(" visual : running per-zone overflow check ...")
overflow = run_overflow_check(out_path)
# ─── Step 14: Visual Runtime Check (실측값) ───
_write_step_artifact(
run_dir, 14, "visual_check",
data=overflow,
step_status="partial",
pipeline_path_connected=True,
inputs=["step13_render.json", "final.html"],
outputs=["step14_visual_check.json"],
note=(
"Selenium 실측 — clientHeight / scrollHeight / excess_y / frame_slot_metrics. "
"Step 8 의 계획값과 비교 시 어느 cell 이 overflow 했는지 박힘. "
"image_aspect_mismatch 검사 추가 (IMP-15 실행-1, issue #45) — image_events[] + fail_reasons. "
"table 검사 부재 (실행-2 잔류) — Step 14 ⚠ partial (table only)."
),
)
# 10. fit_classifier v0 (A1) — Selenium 결과 → spec §3 category 분류 layer.
# *분류만*. action / router / rerender X. behavior 변경 0.
fit_classification = classify_visual_runtime_check(overflow, debug_zones)
# ─── Step 15: Fit Classification ───
_write_step_artifact(
run_dir, 15, "fit_classification",
data=fit_classification,
step_status="done",
pipeline_path_connected=True,
inputs=["step14_visual_check.json"],
outputs=["step15_fit_classification.json"],
note="A1 — visual_runtime_check 결과를 spec §3 category 로 분류.",
)
# 11. overflow_router v0 (A2) — category → proposed_action 매핑 layer.
# *매핑까지만*. 실행 / rerender / behavior 변경 X.
# classifications 각 entry 에 proposed_action 추가, router_decision summary 반환.
router_decision = route_fit_classification(fit_classification)
router_decision["v4_fallback_summary"] = comp_debug.get("v4_fallback_summary")
router_decision["v4_fallback_selections"] = comp_debug.get("v4_fallback_selections", [])
router_decision["frame_reselect_fallback_status"] = (
"pre_render_rank_2_3_fallback_implemented; "
"post_render visual-fail rerender remains routed through existing action trace"
)
# ─── Step 16: Overflow Router ───
_write_step_artifact(
run_dir, 16, "router_decision",
data=router_decision,
step_status="done",
pipeline_path_connected=True,
inputs=["step15_fit_classification.json"],
outputs=["step16_router_decision.json"],
note="A2 — category → proposed_action 매핑.",
)
# 11.5 zone_ratio_retry action (A3) — A3 locked rules (사용자 잠금) 그대로.
# retry budget = 1, slide-base 고정, donor 룰, (b) revert 정책.
retry_trace = _attempt_zone_ratio_retry(
run_dir=run_dir,
out_path=out_path,
slide_title=slide_title,
slide_footer=slide_footer,
zones_data=zones_data,
debug_zones=debug_zones,
layout_preset=layout_preset,
layout_css=layout_css,
overflow=overflow,
fit_classification=fit_classification,
router_decision=router_decision,
gap_px=GRID_GAP,
)
# retry 가 *성공* 했으면 overflow / fit_classification / router_decision / debug_zones 를
# post-retry 상태로 갱신 (slide_status 가 새 상태 반영하도록).
if retry_trace.get("retry_passed"):
overflow = retry_trace["post_retry_overflow"]
debug_zones = retry_trace["post_retry_debug_zones"]
layout_css = retry_trace["post_retry_layout_css"]
# post-retry classifier / router 재실행 — 새 overflow 가 통과면 router_active=False
fit_classification = classify_visual_runtime_check(overflow, debug_zones)
router_decision = route_fit_classification(fit_classification)
router_decision["v4_fallback_summary"] = comp_debug.get("v4_fallback_summary")
router_decision["v4_fallback_selections"] = comp_debug.get("v4_fallback_selections", [])
router_decision["frame_reselect_fallback_status"] = (
"pre_render_rank_2_3_fallback_implemented; "
"post_render visual-fail rerender remains routed through existing action trace"
)
# 11.6 retry_failure_classifier + next_action_router (A4 — 분류/매핑만, 실행 X)
# retry 실패 시 failure_type 분류 + next_proposed_action 기록 (escalation 후보).
enrich_retry_trace_with_failure_classification(retry_trace)
# 11.7 IMP-12 u9 — Step 17 deterministic salvage cascade.
# Triggered on donor_slack_insufficient / no_donor_candidates: cross_zone_redistribute
# → glue_compression → font_step_compression (terminal → layout_adjust/frame_reselect).
_ft = (retry_trace.get("failure_classification") or {}).get("failure_type")
if _ft in {"donor_slack_insufficient", "no_donor_candidates"}:
_plan = retry_trace.get("plan") or {}
_tpos = _plan.get("target_zone_position")
_tdz = next((dz for dz in debug_zones if dz.get("position") == _tpos), {}) or {}
_excess = float(_plan.get("target_excess_y") or 0.0)
# Synthesize FitAnalysis from debug_zones + per-zone overflow so cross_zone_redistribute
# can compute feasibility against real Phase Z geometry (Phase Q's calculate_fit is
# not invoked in Phase Z — see comp_debug v4_fallback_summary policy at 2839-2843).
# Each position becomes a "role"; all share conceptual zone "slide_body" so
# fit_verifier.redistribute trades shortfalls between them. shortfall_px is signed:
# scrollHeight - clientHeight > 0 = deficit, < 0 = surplus (matches redistribute()).
from src.fit_verifier import FitAnalysis, RoleFit
_zof = {z.get("position"): z for z in (overflow.get("zones") or [])}
_fa_roles, _fa_containers = {}, {}
for _dz in debug_zones:
_pos = _dz.get("position")
if not _pos:
continue
_alloc = float(_dz.get("height_px") or 0.0)
_zm = _zof.get(_pos) or {}
_ch = float(_zm.get("clientHeight") or _alloc)
_sh = float(_zm.get("scrollHeight") or _ch)
_fa_roles[_pos] = RoleFit(role=_pos, allocated_px=_alloc, shortfall_px=_sh - _ch)
_fa_containers[_pos] = {"zone": "slide_body", "height_px": int(_alloc)}
_salvage_trace = _attempt_salvage_chain(
run_dir=run_dir, out_path=out_path,
slide_title=slide_title, slide_footer=slide_footer,
zones_data=zones_data, layout_preset=layout_preset, layout_css=layout_css,
cascade_inputs={
"fit_analysis": FitAnalysis(roles=_fa_roles),
"containers": _fa_containers,
"min_margin_px": None,
"excess_px": _excess, "excess_after_glue_px": _excess,
"block_count": len((_tdz.get("placement_trace") or {}).get("internal_regions") or []) or 1,
"zone_position": _tpos or "",
"current_font_px": float(_tdz.get("font_size_px") or 0.0),
"available_lines": int(_tdz.get("available_lines") or 0),
"chars_per_line": int(_tdz.get("chars_per_line") or 0),
},
initial_failure_type=_ft, gap_px=GRID_GAP,
)
retry_trace.update(_salvage_trace)
if _salvage_trace.get("salvage_passed"):
overflow = (_salvage_trace["salvage_steps"][-1].get("post_salvage_overflow")) or overflow
fit_classification = classify_visual_runtime_check(overflow, debug_zones)
router_decision = route_fit_classification(fit_classification)
router_decision["v4_fallback_summary"] = comp_debug.get("v4_fallback_summary")
router_decision["v4_fallback_selections"] = comp_debug.get("v4_fallback_selections", [])
router_decision["frame_reselect_fallback_status"] = (
"pre_render_rank_2_3_fallback_implemented; "
"post_render visual-fail rerender remains routed through existing action trace"
)
# Refresh failure_classification / next_action_proposal so Step 18 / Step 19
# do not surface the pre-salvage donor_slack_insufficient / no_donor_candidates
# state. classify_retry_failure short-circuits on salvage_passed=True → both
# fields become None (no failure to classify, no escalation pending).
enrich_retry_trace_with_failure_classification(retry_trace)
# ─── Step 17: Implemented Action (retry) ───
_write_step_artifact(
run_dir, 17, "retry_trace",
data=retry_trace,
step_status=(
"done" if retry_trace.get("retry_passed") or retry_trace.get("salvage_passed")
else "failed" if retry_trace.get("retry_attempted") and not retry_trace.get("retry_passed")
else "skipped"
),
pipeline_path_connected=True,
inputs=["step16_router_decision.json"],
outputs=["step17_retry_trace.json"],
note=(
"A3 — zone_ratio_retry + IMP-12 u8/u9 salvage cascade "
"(cross_zone_redistribute → glue_compression → font_step_compression). "
"Terminal actions (layout_adjust / frame_reselect / details_popup_escalation) still MISSING."
),
)
# ─── Step 18: Failure Classification (A4-1) ───
_write_step_artifact(
run_dir, 18, "failure_classification",
data=retry_trace.get("failure_classification") or {},
step_status="done" if retry_trace.get("failure_classification") else "skipped",
pipeline_path_connected=True,
inputs=["step17_retry_trace.json"],
outputs=["step18_failure_classification.json"],
note="A4-1 — retry 실패 시 failure_type 분류.",
)
# ─── Step 19: Next Action Proposal (A4-2) ───
_write_step_artifact(
run_dir, 19, "next_action",
data=retry_trace.get("next_action_proposal") or {},
step_status="partial" if retry_trace.get("next_action_proposal") else "skipped",
pipeline_path_connected=False,
inputs=["step18_failure_classification.json"],
outputs=["step19_next_action.json"],
note="A4-2 — failure_type → next_proposed_action 1-D mapping. 실제 action 자체는 미구현 (impl_status=MISSING).",
)
# 12. Slide status — 자동 파이프라인 결과 보고 (review/UI 개념 X)
slide_status = compute_slide_status(
sections, units, comp_debug, overflow,
adapter_needed_units=adapter_needed_units,
debug_zones=debug_zones,
)
# ─── Step 20: Slide Status ───
_write_step_artifact(
run_dir, 20, "slide_status",
data=slide_status,
step_status="done",
pipeline_path_connected=True,
inputs=["step14_visual_check.json", "step17_retry_trace.json", "step19_next_action.json"],
outputs=["step20_slide_status.json", "step20_final_status.html"],
note="자동 파이프라인 최종 결과 보고. overall = PASS/RENDERED_WITH_VISUAL_REGRESSION/PARTIAL_COVERAGE 등.",
)
# Step 20 HTML — 최종 판정 시각 보고
_overall = slide_status.get("overall", "?")
_ov_class = "pass" if "PASS" in _overall else "fail" if "FAIL" in _overall or "REGRESSION" in _overall else "partial"
_vfs = slide_status.get("visual_fail_reasons") or []
_vfs_html = (
"" + "".join(f"- {v}
" for v in _vfs) + "
"
if _vfs else "없음
"
)
_aligned = slide_status.get("aligned_section_ids") or []
_covered = slide_status.get("covered_section_ids") or []
_filtered = slide_status.get("filtered_section_ids") or []
_write_step_html(
run_dir, 20, "final_status",
title="Final Slide Status",
body_html=(
f'Overall: {_overall}
'
f''
f'| rendered | {slide_status.get("rendered")} |
'
f'| visual_check_passed | {slide_status.get("visual_check_passed")} |
'
f'| full_mdx_coverage | {slide_status.get("full_mdx_coverage")} |
'
f'| aligned_section_ids | {_aligned} |
'
f'| covered_section_ids | {_covered} |
'
f'| filtered_section_ids | {_filtered} |
'
f'| adapter_needed_count | {slide_status.get("adapter_needed_count", 0)} |
'
f'| content_truncated_count | {slide_status.get("content_truncated_count", 0)} |
'
f'
'
f'Visual Fail Reasons
{_vfs_html}'
f'Note
{slide_status.get("note", "")}
'
),
step_status="done",
inputs=["step14_visual_check.json", "step17_retry_trace.json"],
outputs=["step20_slide_status.json", "step20_final_status.html"],
)
# 13. Debug.json
debug_path = write_debug_json(
run_dir, layout_preset, debug_zones, layout_css, overflow,
composition_debug=comp_debug,
slide_status=slide_status,
fit_classification=fit_classification,
router_decision=router_decision,
retry_trace=retry_trace,
)
print(f" debug : {debug_path}")
# ─── Step 21: Debug Trace ───
_write_step_artifact(
run_dir, 21, "debug_index",
data={
"debug_json_path": str(debug_path),
"debug_json_size_bytes": debug_path.stat().st_size,
"placement_trace_recorded_zones": len(debug_zones),
"frame_slot_metrics_count": len(
(overflow or {}).get("frame_slot_metrics")
or (overflow or {}).get("details", {}).get("frame_slot_metrics", [])
),
},
step_status="partial",
pipeline_path_connected=True,
inputs=[],
outputs=["step21_debug_index.json", "debug.json"],
note="placement_trace per-zone + frame_slot_metrics F29 만 기록. region marker partial 미주입 — Step 21 ⚠ partial.",
)
# ─── Step 22: 사용자 확인 / Export (future, UI 영역) ───
_write_step_artifact(
run_dir, 22, "user_export",
data={"status": "future", "scope": "UI 영역 — 자동 파이프라인 범위 외"},
step_status="future",
pipeline_path_connected=False,
inputs=[],
outputs=["step22_user_export.json"],
note="UI / export 단계. 자동 파이프라인 범위 외 — 사용자 직접 확인 / 다운로드.",
)
# 13. Status report
overall = slide_status["overall"]
print(f" status : {overall}")
if slide_status["filtered_section_ids"]:
print(f" filtered_sections = {slide_status['filtered_section_ids']}")
if slide_status["adapter_needed_count"]:
print(f" adapter_needed_count = {slide_status['adapter_needed_count']}")
if slide_status["content_truncated_count"]:
print(f" content_truncated = "
f"{[(c['position'], c['truncated_count']) for c in slide_status['content_truncated_units']]}")
if not slide_status["visual_check_passed"]:
for r in (overflow.get("fail_reasons") or [])[:3]:
print(f" visual_fail = {r}")
# fit_classifier + router 결과 요약
if not fit_classification["visual_check_passed"]:
cats = fit_classification["categories_seen"]
print(f" fit_categories = {cats}")
if router_decision["router_active"]:
actions = router_decision["proposed_actions_summary"]
status = router_decision["implementation_status_summary"]
missing = router_decision["missing_actions_pending_impl"]
print(f" proposed_actions = {actions}")
print(f" impl_status_summary = {status}")
if missing:
print(f" missing_actions = {missing} (현재 미구현 → abort)")
# retry 결과 요약 (A3) + failure classification / next action proposal (A4)
if retry_trace.get("retry_attempted"):
print(f" retry_action = {retry_trace['retry_action']}")
print(f" retry_passed = {retry_trace['retry_passed']}")
if not retry_trace["retry_passed"]:
print(f" retry_failure = {retry_trace.get('retry_failure_reason')}")
fc = retry_trace.get("failure_classification") or {}
nap = retry_trace.get("next_action_proposal") or {}
if fc.get("failure_type"):
print(f" failure_type = {fc['failure_type']}")
if nap.get("next_proposed_action"):
print(f" next_proposed_action = {nap['next_proposed_action']} "
f"(impl_status={nap.get('next_action_implementation_status')})")
# 13. Exit 정책 — visual fail 은 abort, partial coverage 는 abort 안 하지만 PASS 도 아님
if not slide_status["visual_check_passed"]:
err_path = write_overflow_error(run_dir, overflow)
print(f"\n[Phase Z-2 MVP-1.5b] FAIL @ visual_runtime_check ({overall})", file=sys.stderr)
for reason in overflow.get("fail_reasons", [overflow.get("error", "unknown")]):
print(f" - {reason}", file=sys.stderr)
print(f" error : {err_path}", file=sys.stderr)
sys.exit(1)
if not slide_status["full_mdx_coverage"]:
print(
f"\n[Phase Z-2 MVP-1.5b] PARTIAL — visual check OK 지만 "
f"sections {slide_status['filtered_section_ids']} 가 composition planner 에서 "
f"필터됨 (allowed_statuses 미통과). final.html 은 viable units 만 렌더된 "
f"partial artifact. full MDX slide 아님."
)
return out_path
print(
f"\n[Phase Z-2 MVP-1.5b] {overall} — visual check OK + full MDX coverage. "
f"최종 사용자 브라우저 검증 후 ship 가능."
)
return out_path
if __name__ == "__main__":
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"
),
)
# IMP-06 (#6) — zone-section assignment override (backend/CLI/composition only;
# frontend bridge = #38). ZONE_ID = active layout preset position names
# (single=primary, horizontal-2=top/bottom, grid-2x2=top-left/top-right/...).
parser.add_argument(
"--override-section-assignment",
dest="override_section_assignments",
action="append",
default=[],
metavar="ZONE_ID=section_id[,section_id]",
help=(
"zone position 의 section assignment 강제. ZONE_ID = active layout 의 "
"position name (e.g., top, bottom, left, right, top-left, ...). section_id = "
"MDX section identifier (e.g., 03-1). multiple sections per zone = comma-separated. "
"multiple flags: --override-section-assignment top=03-1 "
"--override-section-assignment bottom=03-2,03-3"
),
)
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}
# IMP-06 — parse --override-section-assignment into dict[str, list[str]].
# Hard errors per Codex #2/#3 lock : missing `=` / empty ZONE_ID / empty section
# list / duplicate ZONE_ID / duplicate section across zones (parse-time).
overrides_section_assignments: dict[str, list[str]] = {}
_seen_sections_across_zones: dict[str, str] = {} # section_id -> zone_id (first seen)
for ov in args.override_section_assignments:
if "=" not in ov:
print(
f"[error] --override-section-assignment must be ZONE_ID=section_id[,section_id], "
f"got: '{ov}'",
file=sys.stderr,
)
sys.exit(2)
zid, vals = ov.split("=", 1)
zid = zid.strip()
if not zid:
print(
f"[error] --override-section-assignment ZONE_ID must be non-empty, got: '{ov}'",
file=sys.stderr,
)
sys.exit(2)
section_ids = [s.strip() for s in vals.split(",") if s.strip()]
if not section_ids:
print(
f"[error] --override-section-assignment section list must be non-empty, "
f"got: '{ov}'",
file=sys.stderr,
)
sys.exit(2)
if zid in overrides_section_assignments:
print(
f"[error] --override-section-assignment duplicate ZONE_ID '{zid}' "
f"(first assignment kept). Provide each zone only once.",
file=sys.stderr,
)
sys.exit(2)
for sid in section_ids:
if sid in _seen_sections_across_zones:
print(
f"[error] --override-section-assignment section '{sid}' appears in "
f"multiple zones ('{_seen_sections_across_zones[sid]}' and '{zid}'). "
"A section may be assigned to at most one zone.",
file=sys.stderr,
)
sys.exit(2)
_seen_sections_across_zones[sid] = zid
overrides_section_assignments[zid] = section_ids
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,
override_section_assignments=overrides_section_assignments or None,
)