diff --git a/src/phase_z2_pipeline.py b/src/phase_z2_pipeline.py index f8062a5..72ff1cf 100644 --- a/src/phase_z2_pipeline.py +++ b/src/phase_z2_pipeline.py @@ -1299,6 +1299,12 @@ def render_slide(slide_title: str, slide_footer: Optional[str], autoescape=select_autoescape(["html"]), ) for zone in zones_data: + # Stage 4 Part 2 (Codex #10 Catch N) — empty zone produced by section + # assignment override has no partial template; render an empty string so + # the slide_base zones loop preserves grid identity without TemplateNotFound. + if zone.get("template_id") == "__empty__": + zone["partial_html"] = "" + continue partial = env.get_template(f"families/{zone['template_id']}.html") zone["partial_html"] = partial.render(slot_payload=zone["slot_payload"]) @@ -2058,12 +2064,16 @@ def run_phase_z2_mvp1( layout_preset = override_layout layout_override_applied = True - # IMP-06 (#6 / Codex #6/#7/#10 lock) — zone-section assignment override. + # 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`. The plan drives downstream zones_data / - # debug_zones / Step 9 application_plan / Step 20 coverage (see Stage 4 - # block further below where `units_by_position` is materialized). + # `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: @@ -2106,6 +2116,61 @@ def run_phase_z2_mvp1( file=sys.stderr, ) + # Stage 4 Part 2 — rebuild units list aligned with position_assignment_plan so + # downstream zones_data / debug_zones / Step 9 / Step 20 derive from the plan + # rather than the original auto-selected sequence. None placeholders preserve + # position identity for empty/collision-skipped zones (Codex #10 Catch N). + from src.phase_z2_composition import CompositionUnit + plan_units: list = [] + for entry in section_assignment_plan: + assignment_source = entry["assignment_source"] + 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) + 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 + plan_units.append(matched) # may be None if not found (unexpected) + else: + # empty / collision-skipped + plan_units.append(None) + units = plan_units + if not units or layout_preset is None: # composition planner 결과 = 0 units. Sections 가 모두 V4 lookup 실패 또는 # status filter 통과 못 함. error.json 기록 후 abort. @@ -2254,6 +2319,53 @@ def run_phase_z2_mvp1( for i, unit in enumerate(units): position = positions[i] if i < len(positions) else f"zone_{i}" + # Stage 4 Part 2 — empty position (override plan produced no renderable unit). + # Preserve zone identity with an explicit empty record so layout/grid stay + # structurally consistent without distorting allocation (Codex #10 Catch N). + if unit is None: + plan_entry = None + if section_assignment_plan is not None and i < len(section_assignment_plan): + plan_entry = section_assignment_plan[i] + zones_data.append({ + "position": position, + "template_id": "__empty__", + "slot_payload": {}, + "content_weight": {"score": 0}, + "min_height_px": 0, + "assignment_source": "empty", + "skipped_reason": ( + (plan_entry or {}).get("skipped_reason") + or "section_assignment_override_empty_or_unrenderable" + ), + }) + debug_zones.append({ + "position": position, + "source_section_ids": [], + "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": "empty", + "skipped_reason": ( + (plan_entry or {}).get("skipped_reason") + or "section_assignment_override_empty_or_unrenderable" + ), + "replaced_auto_unit": (plan_entry or {}).get("replaced_auto_unit"), + "skipped_collided_auto_units": (plan_entry or {}).get("skipped_collided_auto_units", []), + "uncovered_section_ids": (plan_entry or {}).get("uncovered_section_ids", []), + }) + continue synth_section = MdxSection( section_id="+".join(unit.source_section_ids), section_num=0,