Files
C.E.L_Slide_test2/src/phase_z2_pipeline.py
kyeongmin 9062931863
Some checks failed
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 22s
feat(#74): IMP-45 u1~u8 slide-level CSS override (frontmatter slide_overrides.css + --override-slide-css/--slide-css-file + idempotent Step 13 injector)
u1 KNOWN_AXES tuple gains slide_css entry in src/user_overrides_io.py
(snake_case parity with image_overrides); round-trip test extends
to 6 axes.
u2 src/mdx_normalizer.py surfaces nested slide_overrides.css from the
MDX frontmatter into the normalize_mdx_content return dict; absent
key -> {}, non-string css drops. 4 unit cases in tests/test_mdx_normalizer.py
(present / absent / non-string / title-only).
u3 src/slide_css_injector.py NEW (88 lines) mirrors the
inject_image_overrides_style contract from src/image_id_stamper.py:
marker pair <!--IMP45-SLIDE-CSS:OPEN--> / <!--IMP45-SLIDE-CSS:CLOSE-->,
idempotent re-injection, </head> > <body> > document-start three-tier
fallback, empty/None -> unchanged. 8 fixtures in
tests/test_slide_css_injector.py mirror test_image_id_stamper.py.
u4 run_phase_z2_mvp1 accepts override_slide_css: Optional[str] = None;
None -> frontmatter slide_overrides.css fallback. Step 13 calls
inject_slide_css after image override injection and before the
final.html disk write, so CLI/CI/regression renders observe the same
backend artifact.
u5 argparse adds mutually-exclusive --override-slide-css TEXT (inline
CSS, <style> wrapper optional) and --slide-css-file PATH (UTF-8 read,
fail-closed sys.exit(2) on missing path / decode error / both flags
present). Resolved string is forwarded as override_slide_css kwarg.
6 cases in tests/test_phase_z2_cli_overrides.py (inline / file / both
/ missing / non-utf8 / neither).
u6 samples/mdx_batch/04.mdx frontmatter gains slide_overrides.css
block (verbatim of the former MDX04_DEFAULT_OVERRIDE_CSS constant,
no sample/frame gate). Subprocess smoke in
tests/test_phase_z2_slide_css_smoke.py verifies the marker pair and
CSS substring land in final.html.
u7 Front/client removes the sample/frame-gated frontend-only injection:
Home.tsx drops the MDX04_DEFAULT_OVERRIDE_CSS constant and the
sample==="04"+frame==="process_product_two_way" branch (-28 lines);
SlideCanvas.tsx drops the iframe contentDocument.head injection of
that prop (-14 lines). Live preview now reads backend final.html only.
u8 tests/regression/fixtures/89a_pre_baseline_sha.json 04.mdx entry
resyncs to the live SHA ddb6bf2f... / 28042 bytes (overwrites the
earlier 5-byte-drift d02c76fd... / 28047). Other entries untouched.
Note: 01.mdx baseline drift (ad6f16a3... / 29089 -> live f26a7fac...
/ 29084) predates this branch and is split to a follow-up issue per
the closed-issue fresh validation rule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 03:26:03 +09:00

8343 lines
392 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Phase Z-2 MVP-1.5b — single slide + Type B + frame-derived adapted blocks.
원래 Phase Z 설계 복귀 (멀티-슬라이드 / native-fit 모두 폐기) :
- MDX 1 = slide 1
- slide-base → slide-body → layout preset (Type B) → zones[] → frame-derived block (zone-compatible adapt)
- frame은 시각 언어 / slot 구성 / 패턴의 source. native geometry 통째 삽입 X.
- AI 는 layout / zone / frame / variant 선택에 관여 X — code / catalog 가 결정.
MVP-1.5b spec :
- 대상 : MDX 03 (회귀)
- 출력 : data/runs/{run_id}/phase_z2/final.html (single slide)
- AI : 미사용 — MDX → slot_payload 결정론적 매핑
- status : matched_zone only — non-matched 발생 시 abort + error.json
- layout : 2 sections → Type B (top + bottom zones)
- Frame partials : templates/phase_z2/families/{template_id}.html (Figma 시각 언어 promote, geometry adapt)
- Assets : render time copy → data/runs/{run_id}/phase_z2/assets/{template_id}/
상세 설계 :
- docs/architecture/PHASE-Z-CATALOG-RUNTIME-DESIGN.md § 17 (frame-derived partial promotion + zone-compatible adapt)
이전 실험 실패 기록 :
- mvp1_test5 : scaffold 임의 — frame 느낌 부재
- mvp1.5_test3 : frame native 통째 — slide 대체
- mvp1.5a_test1 : 멀티-슬라이드 — MDX 1=slide 1 위반
- mvp1.5b_test* : 본 모듈, 원래 설계 라인 합류
"""
import hashlib
import json
import os
import re
import shutil
import sys
import time
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any, Optional
import yaml
from jinja2 import Environment, FileSystemLoader, select_autoescape
from phase_z2_composition import (
LAYOUT_PRESETS,
CompositionUnit,
compose_zone_popup_payload,
derive_parent_id,
plan_composition,
resplit_all_reject_merges,
select_display_strategy_candidates,
select_layout_candidates,
select_region_layout_candidates,
)
from phase_z2_mapper import (
FitError,
compute_capacity_fit,
get_contract,
load_frame_contracts,
load_v4_fallback_policy,
map_with_contract,
)
from phase_z2_classifier import classify_visual_runtime_check
from phase_z2_router import plan_details_popup_escalation, route_fit_classification
from phase_z2_retry import (
DEFAULT_SAFETY_MARGIN_PX,
apply_cross_zone_redistribute_css,
apply_font_step_compression_css,
apply_frame_internal_fit_candidate_css,
apply_glue_compression_css,
apply_image_fit_css,
apply_layout_adjust_layout_css,
apply_retry_to_layout_css,
plan_cross_zone_redistribute,
plan_font_step_compression,
plan_frame_internal_fit_candidate,
plan_glue_compression,
plan_image_fit,
plan_layout_adjust,
plan_zone_ratio_retry,
)
from phase_z2_failure_router import (
enrich_retry_trace_with_failure_classification,
route_retry_failure,
)
# trace-only runtime 연결 v0 — B1 → B4 chain.
# final.html / mapper / render path 미영향. debug_zones[i].placement_trace 만 기록.
from phase_z2_content_extractor import extract_content_objects, extract_rich_content_objects
from phase_z2_placement_planner import plan_placement
# IMP-47B u4 — Step 12 AI repair wiring. gather() short-circuits at the
# router when settings.ai_fallback_enabled is False (default), so import
# at module load is safe for the AI=0 normal path (PZ-1). Activation gate
# stays in src/config.py + src/phase_z2_ai_fallback/router.py.
from src.phase_z2_ai_fallback.step12 import gather_step12_ai_repair_proposals
# IMP-35 (#64) u5 — Step 17 deterministic POPUP gate executor. Runs after
# the salvage cascade exhausts at cascade-terminal action
# ``details_popup_escalation`` (router u3 / failure_router u2) and BEFORE
# the AI_REPAIR cascade stage. Stamps ``popup_escalation_plan`` and the
# idempotent ``has_popup`` marker onto retry_trace per unit. No AI call.
from src.phase_z2_ai_fallback.step17 import run_step17_popup_gate
# IMP-43 (#72) u3 — Step 6 reuse snapshot sidecar (JSON-only). Schema +
# serializers + validator live in u2 (``src.phase_z2_reuse_snapshot``);
# this module's call site at the Step 6 boundary writes the sidecar
# alongside ``steps/step06_composition_plan.json`` so that future
# ``--reuse-from`` runs (u4) can resume at Step 7 without re-deriving
# Step 0/1/2/5/6 state. ``--reuse-from`` is u4 scope; here we only
# WRITE the snapshot — restore wiring lands in u4.
from src.phase_z2_reuse_snapshot import build_snapshot, SNAPSHOT_FILENAME
# ─── Constants ──────────────────────────────────────────────────
PROJECT_ROOT = Path(__file__).parent.parent
TEMPLATE_DIR = PROJECT_ROOT / "templates" / "phase_z2"
ASSETS_SOURCE_BASE = PROJECT_ROOT / "figma_to_html_agent" / "blocks"
V4_RESULT_PATH = PROJECT_ROOT / "tests" / "matching" / "v4_full32_result.yaml"
RUNS_DIR = PROJECT_ROOT / "data" / "runs"
# IMP-39 (#68) u1 — single-source ranking sort policy yaml.
# Loader + apply_ranking_sort helper below `to_phase_z_status`.
RANKING_SORT_POLICY_PATH = (
PROJECT_ROOT / "templates" / "phase_z2" / "catalog" / "ranking_sort_policy.yaml"
)
# V4 label → Phase Z status (§ 7.4 매트릭스)
V4_LABEL_TO_PHASE_Z_STATUS = {
"use_as_is": "matched_zone",
"light_edit": "adapt_matched_zone",
"restructure": "extract_matched_zone",
"reject": "fallback_candidate",
}
MVP1_ALLOWED_STATUSES = {"matched_zone", "adapt_matched_zone"}
# Step 9 v0 (사용자 lock 2026-05-08) — V4 label → application_mode 변환.
# tuple = (application_mode, auto_applicable, delegated_to).
# status.md §2 Q3 / Q7 lock 따라.
APPLICATION_MODE_BY_V4_LABEL = {
"use_as_is": ("direct_insert", True, "step10_contract_check"),
"light_edit": ("same_frame_with_adjustment", True, "step10_contract_check"),
"restructure": ("layout_or_region_change", False, "human_review"),
"reject": ("exclude", False, None),
}
# adapt_matched_zone (V4 light_edit) = frame 구조 동일, 텍스트만 minor edit 필요.
# minor edit 정책 (mapper 의무) :
# 1. MDX item 수 < frame slot 수 → 빈 slot 그대로 (Jinja2 {% if %} 로 스킵)
# 2. MDX item 수 > frame slot 수 → 추가 item 누락 (truncate) — debug.json 에 기록
# 3. 텍스트 길이 mismatch → 그대로 통과 (overflow 는 zone-fit + Selenium check 가 처리)
# 4. slot ↔ MDX item 의미 매핑 → 순서 기반 (간단). V4 anchor_match 정교화는 future
# AI 호출 X — MVP-1.5b 의 "MDX 1:1 결정론적 매핑" 룰 그대로.
# Slide canvas / body geometry — front 기준 정상화 (2026-05-07)
# 참조: D:/ad-hoc/kei/design_agent_front/slide-base.html
# slide-base.html CSS 와 1:1 일치해야 함 (불일치 시 layout 계산 어긋남)
SLIDE_W = 1280
SLIDE_H = 720
SLIDE_BODY_LEFT = 50
SLIDE_BODY_TOP = 76 # 사용자 직설 (divider-body 16px 여백) 2026-05-07
SLIDE_BODY_WIDTH = 1180 # calc(100% - 100px)
SLIDE_BODY_HEIGHT = 585 # 사용자 직설 (body-footer 10px 여백) 2026-05-07
SLIDE_FOOTER_HEIGHT = 41 # was 32, front 기준
GRID_GAP = 14 # zone 간격 (사용자 직설 2026-05-07)
# zone min-height fallback — contract 에 visual_hints.min_height_px 없을 때 사용.
# token-based font (var(--font-body) 11px 등) 기준 최소 가독 높이.
DEFAULT_ZONE_MIN_HEIGHT_PX = 100
# Step 14 image_aspect_mismatch tolerance — |natural_ratio - rendered_ratio| > TOL ⇒ fail.
# Local anchor : IMP-15 실행-1 (Gitea issue #45) — image axis acceptance criteria.
# Spec doc row (PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC) update deferred to IMP-15 실행-4.
IMAGE_ASPECT_DELTA_TOL = 0.05
# Step 14 table_self_overflow tolerance — scrollWclientW or scrollHclientH > TOL ⇒ fail.
# Local anchor : IMP-15 실행-2 (Gitea issue #46) — table axis acceptance criteria.
# Mirrors existing inline 5px tolerance used by slide/zone/clipped scans in run_overflow_check.
TABLE_SCROLL_TOL_PX = 5
# content_weight 계산 가중치
CONTENT_WEIGHT_COEFFS = {
"text_per_chars": 800, # text_len / 800 = score
"top_bullet": 0.4,
"nested_bullet": 0.15,
"table_bonus": 1.5,
"subsection": 0.6,
}
# ─── Data classes ───────────────────────────────────────────────
@dataclass
class MdxSection:
section_id: str
section_num: int
title: str
raw_content: str
# IMP-08 B-3 sub-section schema (additive, defaults preserve 4-positional callers).
# heading_number: decimal "2.1" from MDX `### 2.1 Title` capture (U2-populated).
# v4_alias_keys: legacy V4 keys to try when canonical ordinal id misses (e.g. "04-2.1").
# sub_sections: raw child payloads from section_parser (Stage 0 adapter consumes).
heading_number: Optional[str] = None
v4_alias_keys: list = field(default_factory=list)
sub_sections: list = field(default_factory=list)
@dataclass
class V4Match:
section_id: str
frame_id: str
frame_number: int
template_id: str
confidence: float
label: str
v4_rank: Optional[int] = None
selection_path: str = "rank_1"
fallback_reason: Optional[str] = None
# IMP-30 u1 — provisional first-render flag. True when the selector
# synthesizes a rank-1 V4 candidate after chain_exhausted because the
# opt-in allow_provisional kwarg was set. Default False keeps IMP-05
# behavior byte-identical; downstream surfaces this for zone-level
# "needs adaptation" marking without altering V4 evidence.
provisional: bool = False
def to_phase_z_status(match: V4Match) -> str:
return V4_LABEL_TO_PHASE_Z_STATUS.get(match.label, "unknown")
# ─── IMP-39 (#68) u1 — single-source ranking sort policy ──────────
#
# Single source of (label_priority, tie-break) ordering shared by:
# - backend `lookup_v4_match_with_fallback` selector loop (wired in u2)
# - Step 9 `_build_application_plan_unit` payload (wired in u3)
# - frontend `designAgentApi.ts` candidate builder (wired in u4)
#
# u1 scope = additive only (yaml + loader + helper). No selector wiring,
# no behavior change. Default-fallback matches yaml so missing-file boot
# keeps deterministic ordering identical to the file-loaded policy.
_RANKING_SORT_POLICY_DEFAULT: dict = {
"policy_type": "deterministic_label_priority_then_confidence",
"label_priority": {
"use_as_is": 0,
"light_edit": 1,
"restructure": 2,
"reject": 3,
},
"unknown_label_priority": 99,
"tie_break_axes": ["confidence_desc", "v4_rank_asc"],
}
_RANKING_SORT_POLICY_CACHE: Optional[dict] = None
def load_ranking_sort_policy() -> dict:
"""IMP-39 u1 — ranking sort policy loader (separate yaml, additive).
Returns dict with keys: policy_type, label_priority (dict),
unknown_label_priority (int), tie_break_axes (list[str]).
Graceful fallback: yaml 파일 없을 시 _RANKING_SORT_POLICY_DEFAULT
(위 dict) 그대로 — backward-compat boot-safe.
Cache: module-level, mirrors `load_v4_fallback_policy` pattern.
"""
global _RANKING_SORT_POLICY_CACHE
if _RANKING_SORT_POLICY_CACHE is None:
if RANKING_SORT_POLICY_PATH.exists():
loaded = (
yaml.safe_load(RANKING_SORT_POLICY_PATH.read_text(encoding="utf-8"))
or {}
)
# merge with default so partial yaml falls through cleanly
merged = dict(_RANKING_SORT_POLICY_DEFAULT)
for k, v in loaded.items():
merged[k] = v
_RANKING_SORT_POLICY_CACHE = merged
else:
_RANKING_SORT_POLICY_CACHE = dict(_RANKING_SORT_POLICY_DEFAULT)
return _RANKING_SORT_POLICY_CACHE
def apply_ranking_sort(
records: list,
*,
policy: Optional[dict] = None,
label_key: str = "label",
confidence_key: str = "confidence",
v4_rank_key: str = "v4_rank",
) -> list:
"""IMP-39 u1 — stable sort by (label_priority asc, confidence desc, v4_rank asc).
Shared ordering primitive — backend selector / Step 9 payload / frontend
mirror invariant. Sample-agnostic; no hardcoded sample IDs.
Args:
records: list of dicts (selector loop, trace candidates) OR V4Match
objects. Field access falls through getitem → getattr.
policy: optional explicit policy dict; defaults to `load_ranking_sort_policy()`.
label_key / confidence_key / v4_rank_key: per-record field names.
Returns:
NEW list — input is not mutated. Records lacking a key get the
unknown-label priority / confidence=0.0 / v4_rank=inf so they sink
to the bottom in a deterministic way.
"""
pol = policy if policy is not None else load_ranking_sort_policy()
priority_map: dict = pol.get("label_priority", {}) or {}
unknown_priority: int = int(pol.get("unknown_label_priority", 99))
def _get(rec, key):
if isinstance(rec, dict):
return rec.get(key)
return getattr(rec, key, None)
def _key(rec):
label = _get(rec, label_key)
conf = _get(rec, confidence_key)
v4_rank = _get(rec, v4_rank_key)
label_pri = priority_map.get(label, unknown_priority)
conf_val = float(conf) if conf is not None else 0.0
# confidence desc → negate for asc sort key
rank_val = int(v4_rank) if v4_rank is not None else 10**9
return (label_pri, -conf_val, rank_val)
return sorted(records, key=_key)
def _b4_mapper_source_enabled() -> bool:
"""IMP-89 89-a u1 — PHASE_Z_B4_MAPPER_SOURCE env flag reader (default OFF).
Switches slot_payload source-of-truth from mapper-only (legacy) to B4
PlacementPlan.selected_template_id. Distinct from PHASE_Z_B4_GATEKEEPER
(mismatch render-skip semantics). u2 wires this into the slot_payload
construction site; u3 adds BLOCKED exits for no-cover and FitError under
flag ON. Truthy values: '1', 'true', 'yes' (case-insensitive, trimmed).
"""
return os.environ.get("PHASE_Z_B4_MAPPER_SOURCE", "").strip().lower() in {
"1",
"true",
"yes",
}
def _select_mapper_template_id(
placement_plan, unit_frame_template_id: str
) -> Optional[str]:
"""IMP-89 89-a u2 — slot_payload source-of-truth selector.
Returns the template_id that drives slot_payload construction at the
single switch site in the runtime loop:
flag ON → placement_plan.selected_template_id (B4 PlacementPlan,
Layer A render-active path)
flag OFF → unit_frame_template_id (legacy mapper-only / V4 rank-1;
byte-equivalent default; final.html SHA parity guarded
by u4)
Under flag ON the returned value may be None when B4 found no covering
frame. u3 adds the BLOCKED exit for None and for FitError on the
B4-selected template — NO silent fallback (IMP-87 honesty gate pattern).
"""
if _b4_mapper_source_enabled():
return placement_plan.selected_template_id
return unit_frame_template_id
def _b4_mapper_source_blocked_exit(
reason: str, *, position: str, context: dict
) -> "NoReturn": # type: ignore[name-defined]
"""IMP-89 89-a u3 — BLOCKED exit (sys.exit(1)) when PHASE_Z_B4_MAPPER_SOURCE
is ON and the Layer A render path cannot resolve a covering frame.
Stage 1 Q2 lock: when the user explicitly opts into the B4-driven render
path, a content-bearing zone MUST NOT silently degrade to adapter_needed
or to the legacy V4 rank-1 mapper input. Mirrors IMP-87 u3 honesty-gate
pattern (`_is_blocked_overall` → `sys.exit(1)`): the BLOCKED signal
preempts the silent adapter_needed fallback so the operator sees the
Layer A failure immediately on stderr instead of inheriting a
pseudo-rendered partial.
Reasons (locked enum):
b4_no_cover — PlacementPlan.selected_template_id is None
(B4 found no covering frame on the unit)
b4_selected_fit_error — map_mdx_to_slots raised FitError against the
B4-selected template (frame contract reject)
Always raises SystemExit(1) via sys.exit. The `NoReturn` annotation makes
the call-site control flow explicit for type-checkers; behavior is the
same as IMP-87 u3's sys.exit(1) at L6387.
"""
print(
f"\n[Phase Z-2 IMP-89 89-a u3] BLOCKED @ {reason} (zone--{position})",
file=sys.stderr,
)
print(
" policy : PHASE_Z_B4_MAPPER_SOURCE=ON requires B4-driven render "
"(NO silent fallback — IMP-87 honesty gate pattern)",
file=sys.stderr,
)
for key, value in context.items():
print(f" {key:9}: {value}", file=sys.stderr)
sys.exit(1)
# ─── MDX parsing ────────────────────────────────────────────────
def parse_mdx(mdx_path: Path) -> tuple[str, list[MdxSection], Optional[str]]:
"""basic MDX parser — ## level sections only. V4 무관 (matching artifact 모름).
section.raw_content 에 ### sub-section 그대로 포함. V4 granularity 와 align 은
align_sections_to_v4_granularity() 가 처리.
"""
text = mdx_path.read_text(encoding="utf-8")
fm_match = re.match(r"^---\n(.*?)\n---\n", text, re.DOTALL)
slide_title = ""
if fm_match:
fm = yaml.safe_load(fm_match.group(1))
slide_title = fm.get("title", "")
text = text[fm_match.end():]
footer_match = re.search(r":::note\[[^\]]*\]\n(.*?)\n:::", text, re.DOTALL)
footer_text = None
if footer_match:
body = footer_match.group(1)
bullet_match = re.search(r"\*\s*\*\*([^*]+)\*\*", body)
footer_text = (bullet_match.group(1).strip() if bullet_match else body.strip())
text = text[:footer_match.start()] + text[footer_match.end():]
sections = []
section_pattern = re.compile(r"^##\s+(\d+)\.\s+(.+?)$", re.MULTILINE)
matches = list(section_pattern.finditer(text))
mdx_num_match = re.match(r"(\d+)", mdx_path.stem)
mdx_id = mdx_num_match.group(1).zfill(2) if mdx_num_match else "00"
for i, m in enumerate(matches):
section_num = int(m.group(1))
title_text = m.group(2).strip()
start = m.end()
end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
raw_content = text[start:end].strip()
sections.append(MdxSection(
section_id=f"{mdx_id}-{section_num}",
section_num=section_num,
title=f"{section_num}. {title_text}",
raw_content=raw_content,
))
return slide_title, sections, footer_text
# IMP-02 (Phase Z Step 2) — Stage 0 normalize chained adapter.
# scope-lock 7 조건 (Gitea #2):
# 1. inline helper near parse_mdx()
# 2. PHASE_Z_STAGE0_ADAPTER_ENABLED env flag, default OFF (canary, matches PHASE_Z_B4_*)
# 3. env=1 sample verification required (in review loop)
# 4. fallback_reason: str | None flat — 5 hard cases
# 5. verify normalize_mdx_content(raw_mdx)["sections"] is list
# 6. preserve Step 2 existing fields; stage0_adapter_diagnostics additive only
# 7. out of scope: V4 / align / composition / AI/Kei / frame selection / status semantics
_STAGE0_FALLBACK_REASONS = {
"ADAPTER_EXCEPTION",
"NO_USABLE_SECTIONS",
"MISSING_INVALID_IDS",
"DUPLICATE_IDS",
"NON_POSITIVE_SECTION_NUM",
}
def _stage0_chained_adapter(
mdx_path: Path,
legacy_slide_title: str,
legacy_sections: list[MdxSection],
legacy_footer: Optional[str],
) -> tuple[str, list[MdxSection], Optional[str], dict, dict]:
"""IMP-02 — chained adapter for Stage 0 normalize → Phase Z Step 2 input.
Chain: mdx_normalizer.normalize_mdx_content + section_parser.extract_major_sections
+ section_parser.extract_conclusion_text → reconstructed MdxSection list.
Default OFF (canary, env=`1/true/yes` to enable). When OFF, returns legacy parse_mdx
output with diagnostics indicating disabled. When ON, runs adapter chain; on any
hard contract failure or exception, falls back to legacy and records fallback_reason.
Returns (slide_title, sections, footer, diagnostics, normalized_assets).
normalized_assets = {"popups": [...], "images": [...], "tables": [...]}
— IMP-03 Step 3 handoff. env=OFF or hard fallback 시 빈 list.
"""
diagnostics: dict = {
"enabled": False,
"used": False,
"fallback_reason": None,
"id_reconstruction_log": [],
"adapter_counts": None,
"legacy_counts": {"sections": len(legacy_sections)},
}
# IMP-03 — Step 3 handoff. env=OFF / fallback 시 모든 list 가 비어 있음.
normalized_assets: dict = {"popups": [], "images": [], "tables": []}
raw_flag = os.environ.get("PHASE_Z_STAGE0_ADAPTER_ENABLED", "").strip().lower()
enabled = raw_flag in {"1", "true", "yes"}
diagnostics["enabled"] = enabled
if not enabled:
return legacy_slide_title, legacy_sections, legacy_footer, diagnostics, normalized_assets
try:
# Defer imports — legacy path must not depend on these modules.
from mdx_normalizer import normalize_mdx_content
from section_parser import extract_conclusion_text, extract_major_sections
raw_mdx = mdx_path.read_text(encoding="utf-8")
normalized = normalize_mdx_content(raw_mdx)
if not isinstance(normalized, dict) or not isinstance(normalized.get("sections"), list):
diagnostics["fallback_reason"] = "MISSING_INVALID_IDS"
return legacy_slide_title, legacy_sections, legacy_footer, diagnostics, normalized_assets
majors = extract_major_sections(normalized["sections"])
if not majors:
diagnostics["fallback_reason"] = "NO_USABLE_SECTIONS"
return legacy_slide_title, legacy_sections, legacy_footer, diagnostics, normalized_assets
adapter_title = (normalized.get("title") or "").strip() or legacy_slide_title
conclusion = extract_conclusion_text(raw_mdx)
adapter_footer = conclusion if conclusion else None
mdx_num_match = re.match(r"(\d+)", mdx_path.stem)
mdx_id = mdx_num_match.group(1).zfill(2) if mdx_num_match else "00"
# Pre-scan raw MDX `## N. Title` headings → {title: section_num} map.
# Required to make scope-lock §5 "raw heading reuse first" functionally
# reachable, since extract_major_sections strips the leading `N.` from
# its level=2 group titles (Codex implementation review #6 catch).
raw_heading_map: dict[str, int] = {}
for h in re.finditer(r"^##\s+(\d+)\.\s+(.+?)$", raw_mdx, re.MULTILINE):
raw_heading_map[h.group(2).strip()] = int(h.group(1))
adapter_sections: list[MdxSection] = []
used_nums: set[int] = set()
for idx, m in enumerate(majors, start=1):
mtitle = (m.get("title") or "").strip()
content = (m.get("content") or "").strip()
if mtitle in raw_heading_map:
section_num = raw_heading_map[mtitle]
clean_title = mtitle
reuse_source = "raw_heading"
else:
inline_match = re.match(r"^(\d+)\.\s*(.+)$", mtitle)
if inline_match:
section_num = int(inline_match.group(1))
clean_title = inline_match.group(2).strip()
reuse_source = "raw_heading_inline"
else:
section_num = idx
clean_title = mtitle
reuse_source = "order_fallback"
if section_num <= 0:
diagnostics["fallback_reason"] = "NON_POSITIVE_SECTION_NUM"
return legacy_slide_title, legacy_sections, legacy_footer, diagnostics, normalized_assets
if section_num in used_nums:
diagnostics["fallback_reason"] = "DUPLICATE_IDS"
return legacy_slide_title, legacy_sections, legacy_footer, diagnostics, normalized_assets
used_nums.add(section_num)
diagnostics["id_reconstruction_log"].append({
"input_title": mtitle,
"section_num": section_num,
"reuse_source": reuse_source,
})
adapter_sections.append(MdxSection(
section_id=f"{mdx_id}-{section_num}",
section_num=section_num,
title=f"{section_num}. {clean_title}",
raw_content=content,
))
diagnostics["adapter_counts"] = {
"sections": len(adapter_sections),
"majors": len(majors),
"normalized_sections": len(normalized["sections"]),
"popups": len(normalized.get("popups", []) or []),
"images": len(normalized.get("images", []) or []),
"tables": len(normalized.get("tables", []) or []),
}
diagnostics["diff_vs_legacy"] = {
"title_match": adapter_title == legacy_slide_title,
"count_match": len(adapter_sections) == len(legacy_sections),
"footer_match": adapter_footer == legacy_footer,
}
diagnostics["used"] = True
# IMP-03 — populate Step 3 handoff (success path only).
# All fallback paths leave normalized_assets as empty lists (defined at fn top).
normalized_assets = {
"popups": normalized.get("popups", []) or [],
"images": normalized.get("images", []) or [],
"tables": normalized.get("tables", []) or [],
}
return adapter_title, adapter_sections, adapter_footer, diagnostics, normalized_assets
except Exception as exc: # noqa: BLE001 — adapter must never break legacy path
diagnostics["fallback_reason"] = "ADAPTER_EXCEPTION"
diagnostics["exception"] = repr(exc)
return legacy_slide_title, legacy_sections, legacy_footer, diagnostics, normalized_assets
# ─── V4 lookup ──────────────────────────────────────────────────
def load_v4_result() -> dict:
return yaml.safe_load(V4_RESULT_PATH.read_text(encoding="utf-8"))
def align_sections_to_v4_granularity(
sections: list[MdxSection],
v4: dict,
*,
override_target_section_ids: Optional[list[str]] = None,
) -> list[MdxSection]:
"""Align MDX sections to canonical sub-section granularity.
Default behaviour (V4-driven granularity, backward compatible) :
- V4 has section_id exact key -> keep section unchanged (parent
granularity rendering, parent-level V4 evidence applies).
- V4 missing + H3 sub-sections -> drill into sub-sections, emit
canonical ids ``${section_id}-sub-${ordinal}`` with optional
decimal alias for legacy V4 keys (e.g. ``04-2.1``).
- V4 missing + no H3 -> pass through (downstream V4 lookup
will naturally abort with no_v4_section).
IMP-08 B-3 / Stage 5 R2 blocker-fix — ``override_target_section_ids``
is the list of section ids that drag/drop override CLI flags target.
When any override target matches ``${section_id}-sub-N`` for a section
whose parent is otherwise V4-aligned, that section is force-drilled so
sub-section ids become addressable. This keeps the default rendering
path on V4 granularity while making drag/drop deterministic regardless
of whether V4 carries a parent exact key.
Each drilled sub-section carries :
- heading_number : decimal "2.1" / integer "1" / None (bare H3 title).
- v4_alias_keys : legacy V4 keys to try when the canonical ordinal
id misses. Populated only when ``heading_number`` matches the
decimal pattern ``\\d+\\.\\d+`` (N-R5 guard) — integer-only or
bare H3 produces no alias to avoid sibling-parent V4 collisions.
Design boundary :
- parser (``parse_mdx``) = MDX-only knowledge (V4-agnostic).
- aligner (this function) = canonical sub-id schema, MDX-driven on
force_drill, V4-driven otherwise.
- resolver (``_resolve_v4_section_key``) = exact > alias > None,
never auto-promotes to parent/sibling (axis 7 hybrid lock).
"""
v4_keys = set(v4.get("mdx_sections", {}).keys())
# Build the set of parent ids whose sub-ids are explicitly targeted by
# an override. These sections must be drilled even if V4 also carries
# the parent key exactly. Parents derived from canonical "X-sub-N" ids
# only — non-sub ids (top-level overrides) do not trigger drilling.
force_drill_parents: set[str] = set()
if override_target_section_ids:
for sid in override_target_section_ids:
parent = derive_parent_id(sid)
if parent and sid != parent:
force_drill_parents.add(parent)
aligned: list[MdxSection] = []
# Capture optional heading-number prefix (decimal "2.1" or integer "1")
# plus the heading title. None group = bare "### Title".
sub_pattern = re.compile(
r"^###\s+(?:(\d+(?:\.\d+)?)\s+)?(.+?)$", re.MULTILINE
)
decimal_re = re.compile(r"\d+\.\d+")
for section in sections:
force_drill = section.section_id in force_drill_parents
if section.section_id in v4_keys and not force_drill:
# V4 carries this section exactly and no override targets a
# sub-id under it: keep parent granularity (backward compat).
aligned.append(section)
continue
sub_matches = list(sub_pattern.finditer(section.raw_content))
if not sub_matches:
# No H3 sub-sections: cannot drill. Pass section through;
# downstream V4 lookup aborts with no_v4_section when needed.
aligned.append(section)
continue
mdx_id = section.section_id.split("-")[0] # e.g., "04"
for ordinal, m in enumerate(sub_matches, start=1):
heading_number = m.group(1) # decimal "2.1" / integer "1" / None
sub_title = m.group(2).strip()
start = m.end()
end = (
sub_matches[ordinal].start()
if ordinal < len(sub_matches)
else len(section.raw_content)
)
raw = section.raw_content[start:end].strip()
# N-R5 : alias only for decimal heading numbers. integer-only
# H3 (`### 1`) or undecorated H3 produce no alias to avoid
# sibling-parent V4 collisions (e.g., 05.mdx integer H3s).
alias_keys: list[str] = []
if heading_number and decimal_re.fullmatch(heading_number):
alias_keys.append(f"{mdx_id}-{heading_number}")
title = (
f"{heading_number} {sub_title}" if heading_number else sub_title
)
aligned.append(MdxSection(
section_id=f"{section.section_id}-sub-{ordinal}",
section_num=section.section_num,
title=title,
raw_content=raw,
heading_number=heading_number,
v4_alias_keys=alias_keys,
))
return aligned
def _v4_match_from_judgment(section_id: str, judgment: dict, rank: Optional[int] = None) -> V4Match:
resolved_rank = rank if rank is not None else judgment.get("v4_full_rank")
return V4Match(
section_id=section_id,
frame_id=str(judgment["frame_id"]),
frame_number=int(judgment["frame_number"]),
template_id=judgment["template_id"],
confidence=float(judgment["confidence"]),
label=judgment["label"],
v4_rank=int(resolved_rank) if resolved_rank is not None else None,
)
def _resolve_v4_section_key(
v4: dict,
section_id: str,
*,
alias_keys: Optional[list] = None,
) -> Optional[str]:
"""Resolve a V4 ``mdx_sections`` key for *section_id*.
Resolution order :
1. exact match (canonical ordinal id wins)
2. alias_keys in given order (e.g. legacy decimal ``04-2.1`` for ``04-2-sub-1``)
3. None on miss.
Never promotes to parent or sibling — that would reinterpret V4 evidence
(axis 7 hybrid lock, RULE 0). U1 callers pass alias_keys=None so the
function is byte-identical to the previous exact-match lookup; U2 populates
aliases from MDX heading_number metadata.
"""
keys = v4.get("mdx_sections", {})
if section_id in keys:
return section_id
if alias_keys:
for a in alias_keys:
if a and a in keys:
return a
return None
def lookup_v4_match(
v4: dict, section_id: str, *, alias_keys: Optional[list] = None
) -> Optional[V4Match]:
resolved = _resolve_v4_section_key(v4, section_id, alias_keys=alias_keys)
sec = v4.get("mdx_sections", {}).get(resolved) if resolved else None
if not sec:
return None
judgments = sec.get("judgments_full32", [])
if not judgments:
return None
top = judgments[0]
return _v4_match_from_judgment(section_id, top, rank=1)
# IMP-05 L2/L5 route hint — V4 label → execution route guidance for future consumers
# (frontend zone-level override / AI-assisted adaptation). Codex #2 conceptual model :
# use_as_is → Phase Z direct render
# light_edit → deterministic minor adjustment
# restructure → AI-assisted frame-aware adaptation (deferred to IMP-17 — carve-out, AI fallback only, normal path 밖)
# reject → AI re-construction over the rank-1 reject frame (IMP-47B u1, 2026-05-21);
# policy correction supersedes the legacy "design reference only" disposition.
# Frame visual / contract stays untouched; AI only re-maps MDX content into
# declared slots. Activation still gated by ai_fallback_enabled (default OFF).
_IMP05_ROUTE_HINTS: dict[str, str] = {
"use_as_is": "direct_render",
"light_edit": "deterministic_minor_adjustment",
"restructure": "ai_adaptation_required",
"reject": "ai_adaptation_required",
}
def _imp05_route_hint(label: Optional[str]) -> Optional[str]:
"""Map V4 label to execution route hint. Returns None for unknown labels."""
if label is None:
return None
return _IMP05_ROUTE_HINTS.get(label)
def _load_frame_partial_html(template_id: str) -> str:
"""IMP-47B u4 — Read templates/phase_z2/families/{template_id}.html.
Missing partial (e.g., ``__empty__`` shell from IMP-30) returns an
empty string so gather_step12_ai_repair_proposals can still build a
record with skip_reason without raising on file IO.
"""
partial_path = TEMPLATE_DIR / "families" / f"{template_id}.html"
if not partial_path.is_file():
return ""
return partial_path.read_text(encoding="utf-8")
def _run_step12_ai_repair(units) -> list[dict]:
"""IMP-47B u4 — Wire gather_step12_ai_repair_proposals into Step 12.
Routes provisional units whose IMP-05 hint maps to
``ai_adaptation_required`` (``restructure`` + ``reject`` per u1)
through ``src.phase_z2_ai_fallback.router``. Normal-path units
(``use_as_is`` / ``light_edit`` / non-provisional) record a
skip_reason without invoking the router; flag-off runs short-circuit
at the router (``settings.ai_fallback_enabled=False`` default).
Returns the per-unit record list — u5 consumes records for
PARTIAL_OVERRIDES apply and u6 writes the audit artifact.
"""
return gather_step12_ai_repair_proposals(
units,
route_for_label=_imp05_route_hint,
get_contract_fn=get_contract,
frame_visual_loader=_load_frame_partial_html,
)
_REJECT_SUPPORTED_PROPOSAL_KINDS: frozenset[str] = frozenset({"partial_overrides"})
def _apply_ai_repair_proposals_to_zones(
ai_repair_records: list[dict],
unit_positions: list[str],
zones_data: list[dict],
) -> None:
"""IMP-47B u5 — Apply PARTIAL_OVERRIDES into zones_data.slot_payload.
Mutates each record's ``apply_status`` in place and merges
``proposal.payload.slots`` into the matching zone. Out-of-scope
kinds (``builder_options_patch``, ``slot_mapping_proposal``)
loud-fail with ``unsupported_kind_for_reject_route:<kind>`` — zones
untouched (human_review surfacing → u8). IMP-33 u5 validator
guarantees declared-slot completeness, so ``dict.update`` is the
structural merge (``feedback_ai_isolation_contract``).
"""
zone_by_position = {z["position"]: z for z in zones_data}
for record in ai_repair_records:
proposal = record.get("proposal")
if proposal is None:
record["apply_status"] = "no_proposal"
continue
kind = proposal.get("proposal_kind")
if kind not in _REJECT_SUPPORTED_PROPOSAL_KINDS:
record["apply_status"] = f"unsupported_kind_for_reject_route:{kind}"
print(
f" [ai-repair-apply] unit {record['unit_index']} "
f"proposal_kind='{kind}' out-of-scope for reject route — "
"skipping apply; human_review required.",
file=sys.stderr,
)
continue
unit_index = record["unit_index"]
position = (
unit_positions[unit_index]
if 0 <= unit_index < len(unit_positions) else None
)
zone = zone_by_position.get(position) if position is not None else None
if zone is None:
record["apply_status"] = "no_zone_match"
continue
slots = (proposal.get("payload") or {}).get("slots") or {}
zone["slot_payload"].update(slots)
record["apply_status"] = "applied:partial_overrides"
def _check_post_ai_coverage_invariant(
units,
ai_repair_records: list[dict],
) -> dict:
"""IMP-47B u7 — Verify AI repair preserved every source_section_id.
Compares the union of unit-level ``source_section_ids`` (pre-AI) to
the union present on ``ai_repair_records`` post-apply. Per the AI
isolation contract + dropped 절대 룰
(``feedback_ai_isolation_contract``), AI repair never removes a
unit's section coverage. Any divergence indicates a regression that
u8 surfaces through ``slide_status.ai_repair_status``. The check is
structural (set membership); the per-record ``source_section_ids``
list is a copy populated by ``gather_step12_ai_repair_proposals``
(``step12.py:124``) so apply mutations cannot silently drop it.
"""
pre_ai_ids: set[str] = set()
for unit in units:
pre_ai_ids.update(getattr(unit, "source_section_ids", []) or [])
post_ai_ids: set[str] = set()
for record in ai_repair_records:
post_ai_ids.update(record.get("source_section_ids") or [])
dropped = sorted(pre_ai_ids - post_ai_ids)
return {
"pre_ai_section_ids": sorted(pre_ai_ids),
"post_ai_section_ids": sorted(post_ai_ids),
"dropped_section_ids": dropped,
"status": "ok" if not dropped else "violated",
}
def _persist_ai_repair_proposals_to_cache(
ai_repair_records: list[dict],
*,
visual_check_passed: bool,
user_approved: bool,
auto_cache: bool,
) -> None:
"""IMP-47B u13 — Persist applied AI repair proposals through IMP-46 gates.
Mutates each record in place with a ``cache_save_status`` axis.
Only records whose ``apply_status`` starts with ``"applied:"`` and
that still carry the original ``cache_key`` + ``fingerprints`` + a
serialized ``proposal`` dict are eligible — everything else marked
``not_applied``. Eligible records go through
``cache.save_proposal`` with the IMP-46 dual-gate truth table; the
helper catches :class:`AiFallbackCacheGateError` so a gate block is
surfaced (``gate_blocked:<reason>``) without raising into the
pipeline runtime (the cache is a hint, never a hard dependency —
cache.py contract). ``visual_check_passed`` is never bypassable;
``auto_cache=True`` bypasses ONLY the ``user_approved`` gate per
IMP-46 u5. Pure save layer: no AI call, no MDX touch.
"""
from src.phase_z2_ai_fallback.cache import (
AiFallbackCacheGateError,
save_proposal,
)
from src.phase_z2_ai_fallback.schema import AiFallbackProposal
for record in ai_repair_records:
apply_status = record.get("apply_status") or ""
proposal_dict = record.get("proposal")
cache_key = record.get("cache_key")
fingerprints = record.get("fingerprints")
if (
not apply_status.startswith("applied:")
or not isinstance(proposal_dict, dict)
or not cache_key
or not isinstance(fingerprints, dict)
):
record["cache_save_status"] = "not_applied"
continue
try:
proposal_obj = AiFallbackProposal.model_validate(proposal_dict)
except Exception as exc: # noqa: BLE001 — invalid payload → skip, never raise
record["cache_save_status"] = f"invalid_proposal:{type(exc).__name__}"
continue
try:
save_proposal(
cache_key,
proposal_obj,
visual_check_passed=visual_check_passed,
user_approved=user_approved,
auto_cache=auto_cache,
fingerprints=fingerprints,
)
except AiFallbackCacheGateError as gate_exc:
record["cache_save_status"] = f"gate_blocked:{gate_exc}"
continue
record["cache_save_status"] = "saved"
def _summarize_ai_repair_status(
ai_repair_records: list[dict],
coverage_invariant: dict,
) -> dict:
"""IMP-47B u8 — Classify Step 12 AI repair outcomes for slide_status surfacing.
Reads u4 gather ``error`` + u5 ``apply_status`` + u7 coverage_invariant
to derive a single ``ai_repair_status`` axis attached to
``slide_status``. Failure-axis priority (highest → lowest):
``error`` > ``coverage_violated`` > ``unsupported_kind`` > ``applied`` > ``ok``.
``human_review_required`` flips True on the three failure axes so the
frontend (u11) can surface a notification per the IMP-47B policy
("AI 호출 실패 / proposal validation 실패 / coverage 미달 → frontend notification").
Pure: no IO, no AI call.
IMP-92 u3 — propagate ``api_error_kind`` (quota / billing / auth /
other) stamped by Step 12 (u2 ``classify_operational_error``) through
``ai_repair_status`` so the frontend operational formatter can route
only operational kinds (quota / billing / auth) to user-visible
alerts. ``api_error_kinds`` aggregates counts by kind at the summary
level; ``error_records[i]["api_error_kind"]`` retains the per-record
kind for unit-level surfacing.
"""
counts = {
"total": len(ai_repair_records),
"applied": 0,
"no_proposal": 0,
"no_zone_match": 0,
"unsupported_kind": 0,
"error": 0,
}
api_error_kinds = {"quota": 0, "billing": 0, "auth": 0, "other": 0}
unsupported_records: list[dict] = []
error_records: list[dict] = []
for record in ai_repair_records:
if record.get("error"):
counts["error"] += 1
kind = record.get("api_error_kind")
if kind in api_error_kinds:
api_error_kinds[kind] += 1
error_records.append({
"unit_index": record.get("unit_index"),
"source_section_ids": list(record.get("source_section_ids") or []),
"error": record.get("error"),
"api_error_kind": kind,
})
continue
apply_status = record.get("apply_status") or ""
if apply_status.startswith("applied:"):
counts["applied"] += 1
elif apply_status.startswith("unsupported_kind_for_reject_route:"):
counts["unsupported_kind"] += 1
unsupported_records.append({
"unit_index": record.get("unit_index"),
"source_section_ids": list(record.get("source_section_ids") or []),
"apply_status": apply_status,
})
elif apply_status == "no_zone_match":
counts["no_zone_match"] += 1
else:
counts["no_proposal"] += 1
coverage_status = (coverage_invariant or {}).get("status", "ok")
dropped = list((coverage_invariant or {}).get("dropped_section_ids") or [])
if counts["error"]:
status = "error"
elif coverage_status != "ok":
status = "coverage_violated"
elif counts["unsupported_kind"]:
status = "unsupported_kind"
elif counts["applied"]:
status = "applied"
else:
status = "ok"
return {
"status": status,
"counts": counts,
"api_error_kinds": api_error_kinds,
"unsupported_kind_records": unsupported_records,
"error_records": error_records,
"coverage_status": coverage_status,
"dropped_section_ids": dropped,
"human_review_required": status in {"error", "coverage_violated", "unsupported_kind"},
}
def lookup_v4_match_with_fallback(
v4: dict,
section_id: str,
*,
raw_content: Optional[str] = None,
max_rank: Optional[int] = None,
alias_keys: Optional[list] = None,
allow_provisional: bool = False,
) -> tuple[Optional[V4Match], dict]:
"""Select V4 rank-1, or promote rank-2..N when rank-1 is not auto-renderable.
This is an IMP-05 selector only. It uses existing V4 labels, frame-contract
presence, and the Phase Z capacity precheck; it does not call calculate_fit.
IMP-30 u1 — when ``allow_provisional=True`` and the rank-1..effective_max_rank
chain is exhausted (no candidate passes MVP1 filter + contract + capacity),
the selector synthesizes a *provisional* V4Match from the rank-1 judgment so
the first-render invariant can be satisfied downstream. The synthesized
match carries ``provisional=True``, ``selection_path="provisional_rank_1"``,
and ``fallback_reason`` mirrors the existing chain-exhaust reason. The
candidate trace shape is unchanged (synthetic injection only updates the
top-level ``selection_path`` + ``selected_*`` mirrors). When the rank-1
judgment itself is missing (``empty_v4_judgments`` / ``no_v4_section``),
no provisional is synthesized — the caller handles those cases with a
placeholder zone or empty-shell.
Default ``allow_provisional=False`` keeps the IMP-05 behavior byte-identical.
IMP-38 — dynamic effective max_rank via ``load_v4_fallback_policy()``
(4 round 합의 / Codex #1~#3 + Claude #1~#4 LOCK at #67 comment 23195):
- ``max_rank=None`` (default) → policy applied:
usable_count = candidates in rank 1..default_max_rank passing 3-tier
predicate (status in MVP1 + catalog registered + optional capacity).
usable_count >= usable_threshold → effective_max_rank = default_max_rank.
Otherwise → effective_max_rank = min(extended_max_rank,
len(judgments_full32)) = effective_extended_ceiling (Codex #2 정정).
- ``max_rank`` explicitly passed → caller_override: that value is used
as-is (backward compat for tests / explicit IMP-05/IMP-30 paths).
Trace gains 8 IMP-38 fields: ``requested_max_rank``, ``default_max_rank``,
``configured_extended_max_rank``, ``judgments_count``,
``effective_extended_ceiling``, ``effective_max_rank``, ``usable_count``,
``policy_applied``. ``max_rank`` legacy field kept as alias for backward
compat (= effective_max_rank).
"""
resolved = _resolve_v4_section_key(v4, section_id, alias_keys=alias_keys)
sec = v4.get("mdx_sections", {}).get(resolved) if resolved else None
all_judgments = (sec.get("judgments_full32") if sec else None) or []
judgments_count = len(all_judgments)
# IMP-38 — load policy (graceful: yaml 없을 시 default_max_rank=3, extended=3)
_policy = load_v4_fallback_policy()
default_max_rank = int(_policy.get("default_max_rank", 3))
configured_extended_max_rank = int(_policy.get("extended_max_rank", default_max_rank))
usable_threshold = int(_policy.get("usable_threshold", 1))
# Codex #2 정정: min(configured, len(judgments_full32)) — yaml ceiling 무력화 방지
effective_extended_ceiling = min(configured_extended_max_rank, judgments_count) if judgments_count else default_max_rank
usable_count: Optional[int] = None # set only when policy path active
if max_rank is not None:
# caller override (backward compat — explicit IMP-05/IMP-30 paths, tests)
effective_max_rank = int(max_rank)
policy_applied = "caller_override"
elif judgments_count == 0:
# no judgments — slicing 빈 list 라 어차피 영향 X
effective_max_rank = default_max_rank
policy_applied = "no_judgments"
else:
# IMP-38 policy path — 3-tier predicate usable_count on default window
usable_count = 0
default_window = all_judgments[:default_max_rank]
for _j in default_window:
_m = _v4_match_from_judgment(section_id, _j, rank=0)
if to_phase_z_status(_m) not in MVP1_ALLOWED_STATUSES:
continue
if get_contract(_m.template_id) is None:
continue
if raw_content is not None:
_cap = compute_capacity_fit(_m.template_id, raw_content)
if _cap and _cap.get("fit_status") not in {
"ok", "no_contract", "unknown_source_shape",
}:
continue
usable_count += 1
if usable_count >= usable_threshold:
effective_max_rank = default_max_rank
policy_applied = "default_max_rank"
else:
effective_max_rank = effective_extended_ceiling
policy_applied = "extended_max_rank"
trace = {
"section_id": section_id,
# IMP-38 — 8 trace fields (4 round LOCK)
"requested_max_rank": max_rank,
"default_max_rank": default_max_rank,
"configured_extended_max_rank": configured_extended_max_rank,
"judgments_count": judgments_count,
"effective_extended_ceiling": effective_extended_ceiling,
"effective_max_rank": effective_max_rank,
"usable_count": usable_count,
"policy_applied": policy_applied,
# legacy alias for backward compat (= effective_max_rank)
"max_rank": effective_max_rank,
"selection_path": "no_v4_candidate",
"selected_rank": None,
"selected_template_id": None,
"selected_frame_id": None,
"selected_label": None,
"fallback_used": False,
"fallback_reason": None,
"candidates": [],
}
if not sec:
trace["fallback_reason"] = "no_v4_section"
return None, trace
judgments = all_judgments[:effective_max_rank]
if not judgments:
trace["fallback_reason"] = "empty_v4_judgments"
return None, trace
# IMP-39 (#68) u2 — apply single-source ranking sort policy to the selected
# window AFTER IMP-38 raw-window calc (default_window / usable_count above
# remain RAW all_judgments-based — no silent interaction with fallback
# expansion). Selection order now follows
# (label_priority asc, confidence desc, v4_rank asc)
# so backend selected rank-1 matches frontend frame_candidates[0]
# (designAgentApi.ts:578-597 LABEL_PRIORITY + confidence-desc mirror).
# `v4_rank_key="v4_full_rank"` reads the RAW V4 confidence-rank from each
# judgment dict for tie-break (yaml: tie_break_axes=[confidence_desc,
# v4_rank_asc]). Input list is NOT mutated (apply_ranking_sort returns a
# new list). Trace fields (sorted_candidate_evidence / ranking_sort_policy)
# are forwarded through Step 9 payload in u3.
ranking_sort_policy = load_ranking_sort_policy()
judgments = apply_ranking_sort(
judgments,
policy=ranking_sort_policy,
label_key="label",
confidence_key="confidence",
v4_rank_key="v4_full_rank",
)
trace["ranking_sort_policy_applied"] = ranking_sort_policy.get(
"policy_type", "deterministic_label_priority_then_confidence"
)
first_skip_reason: Optional[str] = None
# IMP-05 L4 dedup (Codex #14 ordering — Claude #16 placement precision) :
# first occurrence claims template_id for the chain regardless of decision
# (selected/non-direct/rejected/missing-contract/capacity-skipped). Defensive
# against V4 anomaly where same template_id appears at multiple ranks with
# different labels — first label/reason is preserved, later duplicates skip.
seen_template_ids: set[str] = set()
for i, judgment in enumerate(judgments, start=1):
match = _v4_match_from_judgment(section_id, judgment, rank=i)
status = to_phase_z_status(match)
# IMP-05 L2 (Codex #10 E4) — informative candidate_evidence schema.
# `v4_label` naming matches Codex schema (Claude #13 §1 lock).
# `filtered_for_direct_execution` + `route_hint` = L5 restructure/reject trace 보존
# 단일 source (frontend/AI future consumer guidance).
is_direct_eligible = status in MVP1_ALLOWED_STATUSES
candidate_trace = {
"rank": i,
"template_id": match.template_id,
"frame_id": match.frame_id,
"frame_number": match.frame_number,
"confidence": match.confidence,
"label": match.label, # existing — kept for backward compat
"v4_label": match.label, # IMP-05 L2 alias (Codex schema)
"phase_z_status": status,
"catalog_registered": get_contract(match.template_id) is not None,
"filtered_for_direct_execution": not is_direct_eligible, # IMP-05 L2/L5
"route_hint": _imp05_route_hint(match.label), # IMP-05 L2/L5
"decision": "skipped",
"reason": None,
}
# IMP-05 L4 dedup — duplicate check BEFORE rank evaluation.
# First occurrence reserves template_id even if non-direct/rejected/skipped.
# Later rank with same template_id is skipped as duplicate, audit fields preserved.
if match.template_id in seen_template_ids:
candidate_trace["reason"] = "duplicate_template_id"
trace["candidates"].append(candidate_trace)
continue
seen_template_ids.add(match.template_id)
if status not in MVP1_ALLOWED_STATUSES:
candidate_trace["reason"] = f"phase_z_status_not_allowed:{status}"
elif get_contract(match.template_id) is None:
candidate_trace["reason"] = "skipped_no_contract"
else:
capacity_fit = None
if raw_content is not None:
capacity_fit = compute_capacity_fit(match.template_id, raw_content)
candidate_trace["capacity_fit"] = capacity_fit
if capacity_fit and capacity_fit.get("fit_status") not in {
"ok", "no_contract", "unknown_source_shape",
}:
candidate_trace["reason"] = f"capacity_mismatch:{capacity_fit.get('fit_status')}"
else:
fallback_used = i > 1
fallback_reason = first_skip_reason if fallback_used else None
match.selection_path = f"rank_{i}" if not fallback_used else f"rank_{i}_fallback"
match.fallback_reason = fallback_reason
candidate_trace["decision"] = "selected"
candidate_trace["reason"] = "primary_selected" if i == 1 else "fallback_selected"
trace["candidates"].append(candidate_trace)
trace.update({
"selection_path": match.selection_path,
"selected_rank": i,
"selected_template_id": match.template_id,
"selected_frame_id": match.frame_id,
"selected_label": match.label,
"fallback_used": fallback_used,
"fallback_reason": fallback_reason,
})
return match, trace
if i == 1:
first_skip_reason = candidate_trace["reason"]
trace["candidates"].append(candidate_trace)
trace["selection_path"] = "chain_exhausted"
trace["fallback_reason"] = first_skip_reason or f"no_auto_renderable_rank_1_to_{effective_max_rank}"
# IMP-30 u1 — opt-in provisional first-render synthesis. When the caller
# signals allow_provisional, promote rank-1 judgment as a provisional
# match so downstream composition can satisfy the first-render invariant.
# Top-level mirrors (selection_path / selected_*) are updated; candidate
# trace entries are left intact (their skip reasons remain accurate).
# Default-off keeps IMP-05 behavior byte-identical.
if allow_provisional:
rank_1_judgment = judgments[0]
provisional_match = _v4_match_from_judgment(
section_id, rank_1_judgment, rank=1
)
provisional_match.selection_path = "provisional_rank_1"
provisional_match.fallback_reason = trace["fallback_reason"]
provisional_match.provisional = True
trace.update({
"selection_path": "provisional_rank_1",
"selected_rank": 1,
"selected_template_id": provisional_match.template_id,
"selected_frame_id": provisional_match.frame_id,
"selected_label": provisional_match.label,
"fallback_used": True,
"provisional": True,
})
return provisional_match, trace
return None, trace
def lookup_v4_all_judgments(
v4: dict, section_id: str, *, alias_keys: Optional[list] = None
) -> 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) 보존.
"""
resolved = _resolve_v4_section_key(v4, section_id, alias_keys=alias_keys)
sec = v4.get("mdx_sections", {}).get(resolved) if resolved else None
if not sec:
return []
judgments = sec.get("judgments_full32", [])
out: list[V4Match] = []
for j in judgments:
out.append(_v4_match_from_judgment(section_id, j))
return out
def _is_visual_pending(template_id: str) -> bool:
"""IMP-#85 u4 — return True iff catalog marks contract as ``visual_pending``.
Data-driven from ``frame_contracts.yaml`` (no hard-coded frame allow-list).
Used by ``lookup_v4_candidates`` to exclude VP frames from the live
candidate set; ``lookup_v4_all_judgments`` raw telemetry stays untouched
(Step 7-A axis preserves full 32-frame evidence for the frontend).
"""
contract = get_contract(template_id)
if not isinstance(contract, dict):
return False
return contract.get("visual_pending") is True
def lookup_v4_candidates(
v4: dict,
section_id: str,
max_n: int = 6,
*,
alias_keys: Optional[list] = None,
) -> list[V4Match]:
"""V4 non-reject 후보 list 반환 (Step 5 보완 axis — 사용자 lock 2026-05-08).
Rule (catalog 와 1:1) :
v4_candidates = [
c for c in judgments_full32
if c["label"] != "reject"
and not visual_pending(c.template_id) # IMP-#85 u4
][:max_n]
Returns:
list[V4Match] — 0~max_n 길이.
0 길이 = "no_non_reject_v4_candidate" 신호 (Step 9 fallback path 입력).
raw 32 entry 는 tests/matching/v4_full32_result.yaml 에 영속 보존.
Backward compat:
lookup_v4_match() (rank-1) 는 그대로. Step 6 의 plan_composition()
호출처 무변. 본 함수는 Step 5 artifact + Step 9 application_plan input
위한 새 entry point.
IMP-#85 u4 — visual_pending frames are excluded from the live candidate
set (catalog scaffolding without registered builder would crash the
mapper). lookup_v4_all_judgments raw telemetry is intentionally NOT
gated here.
"""
resolved = _resolve_v4_section_key(v4, section_id, alias_keys=alias_keys)
sec = v4.get("mdx_sections", {}).get(resolved) if resolved else None
if not sec:
return []
judgments = sec.get("judgments_full32", [])
candidates: list[V4Match] = []
for j in judgments:
if j.get("label") == "reject":
continue
tid = j.get("template_id")
if tid and _is_visual_pending(tid):
continue
candidates.append(_v4_match_from_judgment(section_id, j))
if len(candidates) >= max_n:
break
return candidates
def _apply_frame_override_to_unit(unit, new_tid: str, v4: dict) -> str:
"""IMP-47B u3 — apply a frame override to *unit* in place.
Returns a meta_source string for the override book-keeping. Three
probe layers, in order:
1. ``unit.v4_candidates`` (non-reject, max_n bounded). Copies
frame_id / frame_number / confidence / label from the matching
candidate so Step 9 metadata stays consistent. Returns
``"v4_candidates"``.
2. Full 32 V4 judgments (reject inclusive). When the override
target matches a reject judgment for the unit's primary section,
the unit is promoted to ``provisional=True`` with ``label="reject"``
so Step 12 (IMP-47B u4) admits the AI repair path. Returns
``"v4_reject_judgment_provisional"``.
3. Raw fall-through. Updates only ``frame_template_id``; returns
``"raw_template_id_only"``.
Frame visual / contract stay untouched per the AI isolation contract
(frame auto-swap forbidden — AI re-places content into the existing
frame only). The caller validates catalog contract presence before
invoking this helper.
"""
for cand in (unit.v4_candidates or []):
if getattr(cand, "template_id", None) == new_tid:
unit.frame_template_id = cand.template_id
unit.frame_id = cand.frame_id
unit.frame_number = cand.frame_number
unit.confidence = cand.confidence
unit.label = cand.label
return "v4_candidates"
primary_sid = (
unit.source_section_ids[0] if unit.source_section_ids else None
)
if primary_sid:
for j in lookup_v4_all_judgments(v4, primary_sid):
if j.template_id == new_tid and j.label == "reject":
unit.frame_template_id = j.template_id
unit.frame_id = j.frame_id
unit.frame_number = j.frame_number
unit.confidence = j.confidence
unit.label = "reject"
unit.provisional = True
return "v4_reject_judgment_provisional"
unit.frame_template_id = new_tid
return "raw_template_id_only"
# ─── Content weight + zone layout 계산 ─────────────────────────
# layout preset 선택은 phase_z2_composition.select_layout_preset (composition v0) 가 담당.
# 본 모듈의 select_layout_preset 은 이전 단순 count-based 구현이었고 dead code 로 제거 (2026-04-29).
def compute_content_weight(section: MdxSection) -> dict:
"""Section 의 콘텐츠 부피 측정 — text/bullet/table/subsection 합성 score."""
text = section.raw_content
lines = text.splitlines()
text_len = len(text)
top_bullets = sum(1 for l in lines if re.match(r"^[\*\-]\s", l))
nested_bullets = sum(1 for l in lines if re.match(r"^\s+[\*\-]\s", l))
has_table = bool(re.search(r"\|[^\n]+\|\n[ \t]*\|[\s\-:|]+\|", text))
subsections = len(re.findall(r"^###\s", text, re.MULTILINE))
c = CONTENT_WEIGHT_COEFFS
score = (
text_len / c["text_per_chars"]
+ top_bullets * c["top_bullet"]
+ nested_bullets * c["nested_bullet"]
+ (c["table_bonus"] if has_table else 0)
+ subsections * c["subsection"]
)
return {
"score": round(score, 3),
"text_length": text_len,
"top_bullets": top_bullets,
"nested_bullets": nested_bullets,
"has_table": has_table,
"subsection_count": subsections,
}
def compute_zone_layout(zones_data: list[dict],
total_height: int = SLIDE_BODY_HEIGHT,
gap: int = GRID_GAP) -> dict:
"""zone height 계산 — frame_min_height_px 우선 + 남은 공간 content_weight 비율 분배.
Returns dict with per-zone heights + reasoning trace.
"""
n = len(zones_data)
if n == 0:
return {"heights_px": [], "ratios": [], "zones": []}
available = total_height - gap * (n - 1)
# Step 1: 각 zone 의 min_height 할당 — pipeline 가 zones_data 에 frame contract 의
# visual_hints.min_height_px 를 미리 주입했음. 없으면 DEFAULT_ZONE_MIN_HEIGHT_PX.
min_heights = [
z.get("min_height_px", DEFAULT_ZONE_MIN_HEIGHT_PX)
for z in zones_data
]
total_min = sum(min_heights)
min_scaled = False
if total_min > available:
scale = available / total_min
min_heights = [int(m * scale) for m in min_heights]
total_min = sum(min_heights)
min_scaled = True
remaining = available - total_min
# Step 2: 남은 공간을 content_weight 비율로 분배
weights = [z["content_weight"]["score"] for z in zones_data]
total_w = sum(weights) if sum(weights) > 0 else n
extras = [int(round(remaining * (w / total_w))) for w in weights]
# Step 3: rounding 보정 (마지막 zone 잔여 흡수)
heights = [m + e for m, e in zip(min_heights, extras)]
diff = available - sum(heights)
if diff != 0 and heights:
heights[-1] += diff
ratios = [round(h / total_height, 3) for h in heights]
return {
"computation": "min_height_first + content_weight_distribution",
"slide_body_height": total_height,
"gap": gap,
"available_after_gap": available,
"min_heights_px": min_heights,
"min_scaled": min_scaled,
"total_min_height": total_min,
"remaining_after_min": remaining,
"content_weights": [{"position": z["position"],
"template_id": z["template_id"],
"score": w}
for z, w in zip(zones_data, weights)],
"weight_shares": [round(w / total_w, 3) for w in weights],
"extras_px": extras,
"heights_px": heights,
"ratios": ratios,
}
# ─── IMP-09 PR 1 helpers (8-preset layout vocabulary) ────────────────
# Catalog css_areas / css_cols / css_rows parsing + per-zone aggregation
# + col-axis solver. Symmetric counterparts to compute_zone_layout (row-axis).
def _parse_css_areas(css_areas: str) -> tuple[list[list[str]], list[str]]:
"""Parse CSS grid-template-areas string into (row x col) cell grid.
Input : '"top top" "bottom-left bottom-right"'
Output : (
[["top", "top"], ["bottom-left", "bottom-right"]],
["top", "bottom-left", "bottom-right"],
)
Raises ValueError on empty input, missing quotes, empty row, or
non-rectangular layout (rows with mismatched column counts).
"""
rows: list[list[str]] = []
seen: list[str] = []
quoted = re.findall(r'"([^"]+)"', css_areas)
if not quoted:
raise ValueError(
f"_parse_css_areas: no quoted row strings found in {css_areas!r}"
)
for row_str in quoted:
tokens = row_str.split()
if not tokens:
raise ValueError(
f"_parse_css_areas: empty row in {css_areas!r}"
)
rows.append(tokens)
for token in tokens:
if token not in seen:
seen.append(token)
col_counts = {len(r) for r in rows}
if len(col_counts) > 1:
raise ValueError(
f"_parse_css_areas: non-rectangular grid, row column counts = "
f"{col_counts} in {css_areas!r}"
)
return rows, seen
def _parse_fr_string(spec: str, total: int) -> list[int]:
"""Parse '1fr' / '1fr 1fr' / 'Nfr Mfr' into integer px lengths.
Catalog presets (templates/phase_z2/layouts/layouts.yaml) only use
1fr-only specs; mixed px/fr is out of scope. Raises ValueError on
non-fr tokens or zero total.
"""
fractions: list[float] = []
for token in spec.split():
m = re.fullmatch(r"(\d+(?:\.\d+)?)fr", token)
if not m:
raise ValueError(
f"_parse_fr_string: non-fr token {token!r} in {spec!r}"
)
fractions.append(float(m.group(1)))
if not fractions:
raise ValueError(f"_parse_fr_string: empty spec {spec!r}")
total_fr = sum(fractions)
if total_fr <= 0:
raise ValueError(f"_parse_fr_string: total fr = 0 in {spec!r}")
sizes = [int(round(total * (f / total_fr))) for f in fractions]
sizes[-1] += total - sum(sizes)
return sizes
def compute_zone_layout_cols(zones_data: list[dict],
total_width: int = SLIDE_BODY_WIDTH,
gap: int = GRID_GAP) -> dict:
"""Per-zone column width allocation — weight-only distribution.
Symmetric counterpart of compute_zone_layout for the column axis.
No min_width_px contract exists in frame_contracts.yaml (verified
empty as of IMP-09), so column allocation is purely content_weight
score based.
"""
n = len(zones_data)
if n == 0:
return {"widths_px": [], "width_ratios": [], "zones": []}
available = total_width - gap * (n - 1)
weights = [z["content_weight"]["score"] for z in zones_data]
total_w = sum(weights)
if total_w <= 0:
# Zero-weight guard (override-empty zone where score=0).
widths_px = [available // n] * n
widths_px[-1] += available - sum(widths_px)
weight_shares = [round(1.0 / n, 3)] * n
else:
widths_px = [
int(round(available * (w / total_w))) for w in weights
]
diff = available - sum(widths_px)
if diff != 0:
widths_px[-1] += diff
weight_shares = [round(w / total_w, 3) for w in weights]
width_ratios = [round(w / total_width, 3) for w in widths_px]
return {
"computation": "content_weight_distribution_cols",
"slide_body_width": total_width,
"gap": gap,
"available_after_gap": available,
"content_weights": [
{"position": z["position"],
"template_id": z["template_id"],
"score": w}
for z, w in zip(zones_data, weights)
],
"weight_shares": weight_shares,
"widths_px": widths_px,
"width_ratios": width_ratios,
}
def _aggregate_zone_signals_per_track(
preset: dict,
zones_data: list[dict],
) -> tuple[list[dict], list[dict]]:
"""Build per-row + per-col virtual zones for 2-D dynamic dispatch.
Each virtual zone aggregates content_weight.score (max) and
min_height_px (max) across single-span zones on that track
(occupied_rows == {r} for rows, occupied_cols == {c} for cols).
Falls back to all-span zones (touching every track on the axis)
when a track has no single-span zone.
"""
rows_grid, _ = _parse_css_areas(preset["css_areas"])
R = len(rows_grid)
C = len(rows_grid[0])
occupancy: list[tuple[dict, set[int], set[int]]] = []
for z in zones_data:
pos = z["position"]
occ_rows = {r for r, row in enumerate(rows_grid) if pos in row}
occ_cols = {
c for row in rows_grid for c, tok in enumerate(row) if tok == pos
}
occupancy.append((z, occ_rows, occ_cols))
def _track_virtual(idx: int, axis: str) -> dict:
if axis == "row":
single = [z for z, rr, _cc in occupancy if rr == {idx}]
allspan = [z for z, rr, _cc in occupancy if rr == set(range(R))]
else:
single = [z for z, _rr, cc in occupancy if cc == {idx}]
allspan = [z for z, _rr, cc in occupancy if cc == set(range(C))]
candidates = single or allspan
return {
"position": f"_virtual_{axis}_{idx}",
"template_id": f"_virtual_{axis}_{idx}",
"content_weight": {
"score": max(c["content_weight"]["score"] for c in candidates)
},
"min_height_px": max(
c.get("min_height_px", DEFAULT_ZONE_MIN_HEIGHT_PX)
for c in candidates
),
}
row_virtuals = [_track_virtual(r, "row") for r in range(R)]
col_virtuals = [_track_virtual(c, "col") for c in range(C)]
return row_virtuals, col_virtuals
def _compute_per_zone_geometry(
layout_css: dict,
debug_zones: list[dict],
gap: int = GRID_GAP,
) -> list[dict]:
"""Aggregate grid-track sizes into per-zone dimensions for ALL layouts.
Parses layout_css["areas"] (catalog css_areas) into an R x C cell
grid, then for each zone in debug_zones sums the heights_px of its
occupied rows and widths_px of its occupied columns, including the
inter-track gap absorbed by a spanning zone.
Length contract: layout_css["heights_px"] MUST have length R, and
layout_css["widths_px"] MUST have length C. Mismatch raises
ValueError because that indicates a broken build path, not a
runtime input issue.
"""
rows_grid, _ = _parse_css_areas(layout_css["areas"])
R = len(rows_grid)
C = len(rows_grid[0])
heights_px = layout_css.get("heights_px") or []
widths_px = layout_css.get("widths_px") or []
if len(heights_px) != R:
raise ValueError(
f"_compute_per_zone_geometry: heights_px length "
f"{len(heights_px)} != grid rows R={R} "
f"(css_areas={layout_css.get('areas')!r})"
)
if len(widths_px) != C:
raise ValueError(
f"_compute_per_zone_geometry: widths_px length "
f"{len(widths_px)} != grid cols C={C} "
f"(css_areas={layout_css.get('areas')!r})"
)
per_zone: list[dict] = []
for dz in debug_zones:
pos = dz["position"]
occupied_rows = sorted(
{r for r, row in enumerate(rows_grid) if pos in row}
)
occupied_cols = sorted(
{c for r, row in enumerate(rows_grid)
for c, tok in enumerate(row) if tok == pos}
)
if not occupied_rows or not occupied_cols:
raise ValueError(
f"_compute_per_zone_geometry: zone position {pos!r} "
f"not present in css_areas {rows_grid}"
)
zh = (
sum(heights_px[r] for r in occupied_rows)
+ gap * (len(occupied_rows) - 1)
)
zw = (
sum(widths_px[c] for c in occupied_cols)
+ gap * (len(occupied_cols) - 1)
)
per_zone.append({
"position": pos,
"zone_height_px": zh,
"zone_width_px": zw,
"zone_height_ratio": round(zh / SLIDE_BODY_HEIGHT, 3),
"zone_width_ratio": round(zw / SLIDE_BODY_WIDTH, 3),
})
return per_zone
def _build_fr_default(preset: dict) -> dict:
"""fr-default sink — populate widths_px / heights_px from catalog fr ratios.
Replaces the legacy empty-array sink so that downstream consumers
(Step 7/8 trace, _compute_per_zone_geometry) always receive
length-locked arrays matching the catalog grid dimensions.
"""
rows_grid, _ = _parse_css_areas(preset["css_areas"])
R = len(rows_grid)
C = len(rows_grid[0])
avail_h = SLIDE_BODY_HEIGHT - GRID_GAP * (R - 1)
avail_w = SLIDE_BODY_WIDTH - GRID_GAP * (C - 1)
heights_px = _parse_fr_string(preset["css_rows"], avail_h)
widths_px = _parse_fr_string(preset["css_cols"], avail_w)
return {
"areas": preset["css_areas"],
"cols": preset["css_cols"],
"rows": preset["css_rows"],
"heights_px": heights_px,
"widths_px": widths_px,
"ratios": [round(h / SLIDE_BODY_HEIGHT, 3) for h in heights_px],
"width_ratios": [round(w / SLIDE_BODY_WIDTH, 3) for w in widths_px],
"computation": "fr_default_from_preset",
"dynamic_rows": False,
"dynamic_cols": False,
"raw_zone_layout": None,
}
def _build_rows_dynamic(preset: dict, zones_data: list[dict],
gap: int = GRID_GAP) -> dict:
"""horizontal-2 path — dynamic row heights, static fr column widths.
Preserves the legacy compute_zone_layout output (heights_px / ratios /
computation / raw_zone_layout) byte-for-byte; only adds the new
col-axis keys (widths_px from css_cols fr, width_ratios, dynamic_cols=False).
"""
rows_grid, _ = _parse_css_areas(preset["css_areas"])
C = len(rows_grid[0])
avail_w = SLIDE_BODY_WIDTH - gap * (C - 1)
widths_px = _parse_fr_string(preset["css_cols"], avail_w)
zl = compute_zone_layout(zones_data, gap=gap)
rows_str = " ".join(f"{h}px" for h in zl["heights_px"])
return {
"areas": preset["css_areas"],
"cols": preset["css_cols"],
"rows": rows_str,
"heights_px": zl["heights_px"],
"widths_px": widths_px,
"ratios": zl["ratios"],
"width_ratios": [round(w / SLIDE_BODY_WIDTH, 3) for w in widths_px],
"computation": zl["computation"],
"dynamic_rows": True,
"dynamic_cols": False,
"raw_zone_layout": zl,
}
def _build_grid_dynamic_2d(preset: dict, zones_data: list[dict],
gap: int = GRID_GAP) -> dict:
"""2-D dynamic path — dynamic row heights + dynamic column widths.
IMP-09 PR 2 (B-4) handler for the five preset families whose topology
is neither pure 'rows' nor pure 'cols':
- T (top-1-bottom-2)
- inverted-T (top-2-bottom-1)
- side-T-left (left-1-right-2)
- side-T-right (left-2-right-1)
- 2x2 (grid-2x2)
Strategy:
1) _aggregate_zone_signals_per_track builds R per-row + C per-col
virtual zones (max content_weight.score + max min_height_px of
single-span zones, falling back to all-span zones).
2) Row virtuals → compute_zone_layout → heights_px (R).
3) Col virtuals → compute_zone_layout_cols → widths_px (C).
4) Assemble layout_css dict with computation='2d_dynamic_aggregated'
and dynamic_rows=True, dynamic_cols=True.
raw_zone_layout carries both solver outputs + the virtual zone lists
so step08 trace can explain the per-track aggregation.
"""
row_virtuals, col_virtuals = _aggregate_zone_signals_per_track(
preset, zones_data
)
zl_row = compute_zone_layout(row_virtuals, gap=gap)
zl_col = compute_zone_layout_cols(col_virtuals, gap=gap)
heights_px = zl_row["heights_px"]
widths_px = zl_col["widths_px"]
rows_str = " ".join(f"{h}px" for h in heights_px)
cols_str = " ".join(f"{w}px" for w in widths_px)
return {
"areas": preset["css_areas"],
"cols": cols_str,
"rows": rows_str,
"heights_px": heights_px,
"widths_px": widths_px,
"ratios": [round(h / SLIDE_BODY_HEIGHT, 3) for h in heights_px],
"width_ratios": [round(w / SLIDE_BODY_WIDTH, 3) for w in widths_px],
"computation": "2d_dynamic_aggregated",
"dynamic_rows": True,
"dynamic_cols": True,
"raw_zone_layout": {
"row_layout": zl_row,
"col_layout": zl_col,
"row_virtuals": row_virtuals,
"col_virtuals": col_virtuals,
},
}
def _build_cols_dynamic(preset: dict, zones_data: list[dict],
gap: int = GRID_GAP) -> dict:
"""vertical-2 path — dynamic column widths, static fr row heights.
Mirror of _build_rows_dynamic. Returns a pixel grid-template-columns
string. PR 2 promotes vertical-2 override to dynamic_rows=True; in
PR 1 dynamic_rows stays False (legacy).
"""
rows_grid, _ = _parse_css_areas(preset["css_areas"])
R = len(rows_grid)
avail_h = SLIDE_BODY_HEIGHT - gap * (R - 1)
heights_px = _parse_fr_string(preset["css_rows"], avail_h)
zl = compute_zone_layout_cols(zones_data, gap=gap)
cols_str = " ".join(f"{w}px" for w in zl["widths_px"])
return {
"areas": preset["css_areas"],
"cols": cols_str,
"rows": preset["css_rows"],
"heights_px": heights_px,
"widths_px": zl["widths_px"],
"ratios": [round(h / SLIDE_BODY_HEIGHT, 3) for h in heights_px],
"width_ratios": zl["width_ratios"],
"computation": zl["computation"],
"dynamic_rows": False,
"dynamic_cols": True,
"raw_zone_layout": zl,
}
def _override_to_grid_tracks(
preset: dict,
zones_data: list[dict],
override_zone_geometries: dict[str, dict],
gap: int = GRID_GAP,
) -> dict:
"""2-D override path — derive heights_px (R) + widths_px (C) from
user-supplied zone_id -> {x, y, w, h} (0~1 within slide-body).
IMP-09 PR 2 (B-4) override handler for the five preset families
whose topology is neither pure 'rows' nor pure 'cols':
- T (top-1-bottom-2)
- inverted-T (top-2-bottom-1)
- side-T-left (left-1-right-2)
- side-T-right (left-2-right-1)
- 2x2 (grid-2x2)
Strategy:
1) Parse css_areas into R x C grid.
2) For each row r: aggregate h via max over single-row zones
(occupied_rows == {r}); fallback to all-span zones; else 0.0.
3) For each col c: same with w.
4) Normalize per-axis (divide by total) and multiply by avail_*,
absorbing rounding diff into the last element.
5) If total_h or total_w == 0 (degenerate / empty override),
fall back to _build_grid_dynamic_2d default path.
"""
rows_grid, _ = _parse_css_areas(preset["css_areas"])
R = len(rows_grid)
C = len(rows_grid[0])
# Hot-fix (2026-05-22): partial override 버그 fix — override 없는 track 은
# default 비율로 fallback. 이전엔 0 반환 → normalize 후 다른 track 이 모든 공간 흡수.
_default_result = _build_grid_dynamic_2d(preset, zones_data, gap=gap)
_default_widths = _default_result.get("widths_px", []) or []
_default_heights = _default_result.get("heights_px", []) or []
_sum_w = sum(_default_widths) if _default_widths else 1.0
_sum_h = sum(_default_heights) if _default_heights else 1.0
occupancy: list[tuple[dict, set[int], set[int]]] = []
for z in zones_data:
pos = z["position"]
occ_rows = {r for r, row in enumerate(rows_grid) if pos in row}
occ_cols = {
c for row in rows_grid for c, tok in enumerate(row) if tok == pos
}
occupancy.append((z, occ_rows, occ_cols))
def _track_value(idx: int, axis: str) -> float:
if axis == "row":
single = [z for z, rr, _cc in occupancy if rr == {idx}]
allspan = [z for z, rr, _cc in occupancy if rr == set(range(R))]
key = "h"
_fallback = (_default_heights[idx] / _sum_h) if idx < len(_default_heights) and _sum_h else (1.0 / R)
else:
single = [z for z, _rr, cc in occupancy if cc == {idx}]
allspan = [z for z, _rr, cc in occupancy if cc == set(range(C))]
key = "w"
_fallback = (_default_widths[idx] / _sum_w) if idx < len(_default_widths) and _sum_w else (1.0 / C)
candidates = single or allspan
vals = [
float(override_zone_geometries[z["position"]][key])
for z in candidates
if z["position"] in override_zone_geometries
]
return max(vals) if vals else _fallback
row_values = [_track_value(r, "row") for r in range(R)]
col_values = [_track_value(c, "col") for c in range(C)]
total_h = sum(row_values)
total_w = sum(col_values)
if total_h == 0 or total_w == 0:
return _build_grid_dynamic_2d(preset, zones_data, gap=gap)
row_ratios = [v / total_h for v in row_values]
col_ratios = [v / total_w for v in col_values]
avail_h = SLIDE_BODY_HEIGHT - gap * (R - 1)
avail_w = SLIDE_BODY_WIDTH - gap * (C - 1)
heights_px = [int(round(r * avail_h)) for r in row_ratios]
widths_px = [int(round(r * avail_w)) for r in col_ratios]
diff_h = avail_h - sum(heights_px)
if diff_h != 0 and heights_px:
heights_px[-1] += diff_h
diff_w = avail_w - sum(widths_px)
if diff_w != 0 and widths_px:
widths_px[-1] += diff_w
rows_str = " ".join(f"{h}px" for h in heights_px)
cols_str = " ".join(f"{w}px" for w in widths_px)
return {
"areas": preset["css_areas"],
"cols": cols_str,
"rows": rows_str,
"heights_px": heights_px,
"widths_px": widths_px,
"ratios": [round(rr, 3) for rr in row_ratios],
"width_ratios": [round(rr, 3) for rr in col_ratios],
"computation": "user_override_geometry",
"dynamic_rows": True,
"dynamic_cols": True,
"raw_zone_layout": {
"override_applied": True,
"source": override_zone_geometries,
},
}
# Layout preset → zone position 순서 = LAYOUT_PRESETS[preset]["positions"] 직접 사용.
# 이전 ZONE_POSITIONS_BY_PRESET (type-b 등 legacy 명) 는 dead code 로 제거 (2026-04-29).
def build_layout_css(layout_preset: str, zones_data: list[dict],
gap: int = GRID_GAP,
override_zone_geometries: Optional[dict[str, dict]] = None) -> dict:
"""Composition v0 layout preset → CSS grid 문자열.
IMP-09 PR 1 contract — every layout_css return path carries
matching-length heights_px (= grid rows R) and widths_px (= grid cols C),
plus ratios / width_ratios / dynamic_rows / dynamic_cols. The
horizontal-2 grid CSS strings (areas/cols/rows) remain byte-identical
to the legacy path.
Dynamic dispatch:
- topology="rows" -> _build_rows_dynamic (horizontal-2: row heights)
- topology="cols" -> _build_cols_dynamic (vertical-2: col widths)
- other topologies (single / T / inverted-T / side-T / 2x2) fall
through to _build_fr_default in PR 1; PR 2 enables the 2-D
dispatcher.
Step D-ext (사용자 lock 2026-05-08) — override_zone_geometries (zone_id ->
{x,y,w,h} slide-body 내부 0~1) 가 들어오면 그 비율로 layout_css 강제.
PR 1 lock: horizontal-2 / vertical-2 만 처리 (legacy inline preserve).
다른 preset 은 warn-and-fallthrough (PR 2 가 unified _override_to_grid_tracks
로 promote).
"""
preset = LAYOUT_PRESETS[layout_preset]
positions = preset["positions"]
topology = preset.get("topology")
# ── Step D-ext : user override 처리 ──
if override_zone_geometries:
if layout_preset == "horizontal-2":
# IMP-44 u1 — unknown-key guard: drop foreign-preset keys
# (예: vertical-2 keys {left,right} sent to horizontal-2), emit
# structured warning, keep matching keys. All-unknown → fall
# through to default dynamic dispatch (no false override_applied).
unknown_keys = sorted(
k for k in override_zone_geometries if k not in positions
)
if unknown_keys:
print(
f" [override-warning] layout_preset={layout_preset} "
f"expected_positions={list(positions)} unknown_keys={unknown_keys} "
f"(dropped foreign-preset keys; default split for non-overridden).",
file=sys.stderr,
)
filtered_overrides = {
k: v for k, v in override_zone_geometries.items() if k in positions
}
if filtered_overrides:
# heights_px override — zone 의 h 비율로 SLIDE_BODY_HEIGHT 분배.
# Hot-fix (2026-05-22): partial override = 나머지 공간을 비-override zone 들에
# 균등 분배 (drag boundary intent). 이전엔 0.0 fallback → 100/0 깨짐.
overridden_h = sum(
float(filtered_overrides[p]["h"])
for p in positions if p in filtered_overrides
)
non_overridden = [p for p in positions if p not in filtered_overrides]
per_non = max(0.0, 1.0 - overridden_h) / max(len(non_overridden), 1)
ratios = []
for pos in positions:
geom = filtered_overrides.get(pos)
ratios.append(float(geom["h"]) if geom else per_non)
total = sum(ratios)
if total > 0:
heights_px = [int(round(r / total * SLIDE_BODY_HEIGHT)) for r in ratios]
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,
"widths_px": [SLIDE_BODY_WIDTH],
"ratios": [round(r / total, 3) for r in ratios],
"width_ratios": [1.0],
"computation": "user_override_geometry",
"dynamic_rows": True,
"dynamic_cols": False,
"raw_zone_layout": {"override_applied": True, "source": filtered_overrides},
}
elif layout_preset == "vertical-2":
# IMP-44 u1 — unknown-key guard: drop foreign-preset keys
# (예: horizontal-2 keys {top,bottom} sent to vertical-2), emit
# structured warning, keep matching keys. All-unknown → fall
# through to default dynamic dispatch (no false override_applied).
unknown_keys = sorted(
k for k in override_zone_geometries if k not in positions
)
if unknown_keys:
print(
f" [override-warning] layout_preset={layout_preset} "
f"expected_positions={list(positions)} unknown_keys={unknown_keys} "
f"(dropped foreign-preset keys; default split for non-overridden).",
file=sys.stderr,
)
filtered_overrides = {
k: v for k, v in override_zone_geometries.items() if k in positions
}
if filtered_overrides:
# cols override — zone 의 w 비율로 fr 분배 (legacy: fr-string cols).
# PR 1 keeps fr-string cols for legacy preserve; widths_px is
# populated in pixels for _compute_per_zone_geometry length contract.
# Hot-fix (2026-05-22): partial override = 나머지 공간을 비-override zone 들에
# 균등 분배 (drag boundary intent). 이전엔 0.0 fallback → 100/0 깨짐.
overridden_w = sum(
float(filtered_overrides[p]["w"])
for p in positions if p in filtered_overrides
)
non_overridden = [p for p in positions if p not in filtered_overrides]
per_non = max(0.0, 1.0 - overridden_w) / max(len(non_overridden), 1)
ratios = []
for pos in positions:
geom = filtered_overrides.get(pos)
ratios.append(float(geom["w"]) if geom else per_non)
total = sum(ratios)
if total > 0:
cols = " ".join(f"{round(r / total * 100, 2)}fr" for r in ratios)
normalized = [r / total for r in ratios]
widths_px = [
int(round(rr * (SLIDE_BODY_WIDTH - gap * (len(ratios) - 1))))
for rr in normalized
]
diff = (SLIDE_BODY_WIDTH - gap * (len(ratios) - 1)) - sum(widths_px)
if diff != 0 and widths_px:
widths_px[-1] += diff
return {
"areas": preset["css_areas"],
"cols": cols,
"rows": preset["css_rows"],
"heights_px": [SLIDE_BODY_HEIGHT],
"widths_px": widths_px,
"ratios": [1.0],
"width_ratios": [round(rr, 3) for rr in normalized],
"computation": "user_override_geometry",
"dynamic_rows": False,
"dynamic_cols": True,
"raw_zone_layout": {"override_applied": True, "source": filtered_overrides},
}
elif topology in ("T", "inverted-T", "side-T-left", "side-T-right", "2x2"):
# IMP-09 PR 2 — 2-D override path (T / inverted-T / side-T / 2x2).
# Degenerate inputs (total_h == 0 or total_w == 0) fall back to
# _build_grid_dynamic_2d inside the helper.
#
# IMP-44 u2 — unknown-key guard mirrors u1 (1-D): drop foreign-
# preset keys (예: vertical-2 keys {left,right} sent to T-preset),
# emit structured warning, keep matching keys. All-unknown → fall
# through to _build_grid_dynamic_2d default (no false override_applied).
unknown_keys = sorted(
k for k in override_zone_geometries if k not in positions
)
if unknown_keys:
print(
f" [override-warning] layout_preset={layout_preset} "
f"expected_positions={list(positions)} unknown_keys={unknown_keys} "
f"(dropped foreign-preset keys; default split for non-overridden).",
file=sys.stderr,
)
filtered_overrides = {
k: v for k, v in override_zone_geometries.items() if k in positions
}
if filtered_overrides:
return _override_to_grid_tracks(
preset, zones_data, filtered_overrides, gap=gap
)
return _build_grid_dynamic_2d(preset, zones_data, gap=gap)
else:
# warn-and-fallthrough preserved for remaining presets (single).
# PR 3 territory.
print(
f" [override-warning] zone-geometry override 는 layout '{layout_preset}' 미지원 "
f"(현재 horizontal-2 / vertical-2 / 2-D presets 만). default layout_css 사용.",
file=sys.stderr,
)
# ── Dynamic branch — topology dispatch ──
if topology == "rows":
return _build_rows_dynamic(preset, zones_data, gap)
if topology == "cols":
return _build_cols_dynamic(preset, zones_data, gap)
if topology in ("T", "inverted-T", "side-T-left", "side-T-right", "2x2"):
return _build_grid_dynamic_2d(preset, zones_data, gap)
# PR 3 will dispatch single here.
return _build_fr_default(preset)
# ─── Abort ──────────────────────────────────────────────────────
def abort_with_error(run_dir: Path, section: MdxSection,
match: Optional[V4Match], stage: str, reason: str):
error_data = {
"section": {"id": section.section_id, "title": section.title},
"frame": {
"id": match.frame_id if match else None,
"number": match.frame_number if match else None,
"template_id": match.template_id if match else None,
},
"v4_label": match.label if match else None,
"phase_z_status": to_phase_z_status(match) if match else None,
"confidence": match.confidence if match else None,
"stage": stage,
"reason": reason,
}
run_dir.mkdir(parents=True, exist_ok=True)
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 @ {stage}", file=sys.stderr)
print(f" section : {section.section_id}{section.title}", file=sys.stderr)
if match:
print(f" frame : {match.frame_id} ({match.template_id})", file=sys.stderr)
print(f" status : V4 label '{match.label}' → Phase Z '{to_phase_z_status(match)}'", file=sys.stderr)
print(f" reason : {reason}", file=sys.stderr)
print(f" error : {err_path}", file=sys.stderr)
sys.exit(1)
# ─── IMP-06 Step 6 zone-section assignment override (backend/CLI/composition only) ──
def _build_position_assignment_plan(
units: list,
positions: list[str],
override_section_assignments: Optional[dict[str, list[str]]],
sections_by_id: dict[str, "MdxSection"],
override_frames: Optional[dict[str, str]] = None,
v4: Optional[dict] = None,
) -> tuple[list[dict], dict]:
"""IMP-06 (#6 / Codex #6,#7 15-axis lock) — section-to-position assignment plan.
Pure helper invoked AFTER ``plan_composition()`` returns ``units`` and
AFTER ``override_layout`` has been applied so ``positions`` is the final
layout-preset position vocabulary.
Locked behavior :
- explicit override wins per position
- no section id appears in more than one final rendered position
- overlapping auto units are skipped WHOLE (no split, no cascade, no replan)
- template_id resolution ladder :
(1) ``override_frames`` exact ``unit_id`` (catalog validation downstream)
(2) exact existing auto unit reuse (same ``source_section_ids``)
(3) single-section override -> ``lookup_v4_match_with_fallback`` with
``raw_content=section.raw_content`` (direct executable only)
(4) multi-section ad-hoc override (no exact auto + no override-frame)
-> skipped_reason='ad_hoc_merged_no_template'
- additive trace : ``previous_source_section_ids`` (position-level history),
``skipped_collided_auto_units`` (collision-level), ``uncovered_section_ids``
(post-override coverage gap), ``v4_selector_trace`` (selector failure embed),
``skipped_reason`` for failed assignments
Returns ``(plan, summary)`` where :
- ``plan`` : list[dict] keyed by position with the per-position record
- ``summary`` : dict with applied/skipped counts + uncovered ids
NOTE : the helper does NOT mutate ``units`` and does NOT raise on validation
failures; caller is responsible for fail-fast validation of unknown zone ids
or unknown section ids before calling.
"""
overrides = override_section_assignments or {}
override_frames = override_frames or {}
# Section ids reserved by any explicit override.
overridden_section_ids: set[str] = set()
for _zid, sids in overrides.items():
overridden_section_ids.update(sids)
# Build position -> auto unit baseline. Auto plan uses sequential mapping of
# ``units`` over ``positions`` (the same order Step 6 of the pipeline uses).
auto_by_position: dict[str, object] = {}
for i, pos in enumerate(positions):
auto_by_position[pos] = units[i] if i < len(units) else None
# Reverse lookup : section_id -> auto unit (for collision detection).
auto_unit_by_section: dict[str, object] = {}
for u in units:
for sid in u.source_section_ids:
auto_unit_by_section[sid] = u
# Track auto units that get whole-skipped because of collision.
skipped_collided_unit_ids: set[str] = set()
plan: list[dict] = []
def _unit_id(sids: list[str]) -> str:
return "+".join(sids)
def _resolve_template_for_override(zone_id: str, sids: list[str]) -> tuple[
Optional[str], Optional[str], Optional[dict]
]:
"""template_id resolution ladder. Returns (template_id, skipped_reason, v4_selector_trace)."""
unit_id = _unit_id(sids)
# (1) explicit --override-frame for exact unit_id
if unit_id in override_frames:
return override_frames[unit_id], None, None
# (2) exact existing auto unit reuse
for u in units:
if list(u.source_section_ids) == list(sids):
return getattr(u, "frame_template_id", None) or getattr(u, "template_id", None), None, None
# (3) single-section selector
if len(sids) == 1:
sid = sids[0]
section = sections_by_id.get(sid)
if v4 is None or section is None:
return None, "no_v4_section", None
raw_content = getattr(section, "raw_content", None)
# IMP-08 B-3 : forward sub-section V4 aliases (decimal heading_number)
# when canonical ordinal id misses; safe for top-level sids (empty list).
alias_keys = list(getattr(section, "v4_alias_keys", []) or [])
match, trace = lookup_v4_match_with_fallback(
v4, sid, raw_content=raw_content, alias_keys=alias_keys
)
if match is None:
return None, "no_direct_render_template", trace
return match.template_id, None, trace
# (4) ad-hoc multi-section override without exact auto + without override-frame
return None, "ad_hoc_merged_no_template", None
# Iterate positions deterministically. Explicit overrides win.
for pos in positions:
if pos in overrides:
sids = overrides[pos]
auto_unit = auto_by_position.get(pos)
previous_source_section_ids = (
list(auto_unit.source_section_ids) if auto_unit is not None else []
)
# Sections that the previous auto unit at this position contained but
# the explicit override did not take = uncovered post-override.
sids_set = set(sids)
uncovered_from_previous = [
sid for sid in previous_source_section_ids if sid not in sids_set
]
template_id, skipped_reason, selector_trace = _resolve_template_for_override(pos, sids)
# IMP-06 Stage 4 (Codex #9 R1 + Claude #9 Catch L + Codex #10) — replaced_auto_unit
# populated only when the same position previously had an auto unit and that
# auto unit was different from the requested override. Documents "this auto
# unit was removed from this position to apply the override" as a distinct
# audit fact (vs skipped_collided_auto_units which is cross-position skip).
replaced_auto_unit = None
if auto_unit is not None and list(auto_unit.source_section_ids) != list(sids):
replaced_auto_unit = {
"unit_id": _unit_id(list(auto_unit.source_section_ids)),
"source_section_ids": list(auto_unit.source_section_ids),
"reason": "same_position_override_replacement",
}
plan.append({
"position": pos,
"assignment_source": "cli_override",
"unit_id": _unit_id(sids),
"source_section_ids": list(sids),
"template_id": template_id,
"previous_source_section_ids": previous_source_section_ids,
"section_assignment_override": {
"override_applied": True,
"override_source": "cli",
"zone_id": pos,
"requested_section_ids": list(sids),
},
"replaced_auto_unit": replaced_auto_unit,
"skipped_collided_auto_units": [],
"uncovered_section_ids": uncovered_from_previous,
"skipped_reason": skipped_reason,
"v4_selector_trace": selector_trace,
})
else:
# Auto-retain unless overlap with overridden sections.
auto_unit = auto_by_position.get(pos)
if auto_unit is None:
plan.append({
"position": pos,
"assignment_source": "empty",
"unit_id": None,
"source_section_ids": [],
"template_id": None,
"previous_source_section_ids": [],
"section_assignment_override": None,
"replaced_auto_unit": None,
"skipped_collided_auto_units": [],
"uncovered_section_ids": [],
"skipped_reason": "no_auto_unit_available",
"v4_selector_trace": None,
})
continue
overlap = [sid for sid in auto_unit.source_section_ids if sid in overridden_section_ids]
if overlap:
# Whole-skip the auto unit. Sections in the auto unit that were NOT taken
# by an override become uncovered.
unit_id_str = _unit_id(list(auto_unit.source_section_ids))
skipped_collided_unit_ids.add(unit_id_str)
uncovered = [
sid for sid in auto_unit.source_section_ids
if sid not in overridden_section_ids
]
plan.append({
"position": pos,
"assignment_source": "empty",
"unit_id": None,
"source_section_ids": [],
"template_id": None,
"previous_source_section_ids": list(auto_unit.source_section_ids),
"section_assignment_override": None,
"replaced_auto_unit": None,
"skipped_collided_auto_units": [{
"unit_id": unit_id_str,
"source_section_ids": list(auto_unit.source_section_ids),
"reason": "override_collision",
}],
"uncovered_section_ids": uncovered,
"skipped_reason": "override_collision",
"v4_selector_trace": None,
})
else:
plan.append({
"position": pos,
"assignment_source": "auto",
"unit_id": _unit_id(list(auto_unit.source_section_ids)),
"source_section_ids": list(auto_unit.source_section_ids),
"template_id": (
getattr(auto_unit, "frame_template_id", None)
or getattr(auto_unit, "template_id", None)
),
"previous_source_section_ids": list(auto_unit.source_section_ids),
"section_assignment_override": None,
"replaced_auto_unit": None,
"skipped_collided_auto_units": [],
"uncovered_section_ids": [],
"skipped_reason": None,
"v4_selector_trace": None,
})
# Summary aggregates.
applied = [p for p in plan if p["assignment_source"] == "cli_override"]
skipped_assignments = [p for p in plan if p["skipped_reason"] is not None]
all_uncovered: list[str] = []
for p in plan:
all_uncovered.extend(p.get("uncovered_section_ids", []))
summary = {
"section_assignment_overrides_applied": [
{"position": p["position"], "source_section_ids": p["source_section_ids"]}
for p in applied
],
"section_assignment_overrides_skipped": [
{"position": p["position"], "reason": p["skipped_reason"]}
for p in skipped_assignments
],
"applied_count": len(applied),
"skipped_count": len(skipped_assignments),
"uncovered_section_ids": all_uncovered,
"skipped_collided_auto_unit_ids": sorted(skipped_collided_unit_ids),
}
return plan, summary
# ─── Slot mapping (catalog-only dispatch) ──────────────────────
def _known_contract_ids() -> list[str]:
from phase_z2_mapper import load_frame_contracts
return list(load_frame_contracts().keys())
def map_mdx_to_slots(section: MdxSection, template_id: str) -> dict:
"""template_id → slot_payload via catalog contract only.
F13/F29/F16 등 모든 frame 의 slot 구조 / cardinality / role / payload builder 는
`templates/phase_z2/catalog/frame_contracts.yaml` 에 선언. legacy hand-coded
mapper / MAPPER_BY_TEMPLATE / COLOR_CLASS_BY_KEYWORD / 관련 helper 는
F13/F29/F16 transition (2026-04-29) 후 모두 제거.
template_id 가 catalog 에 없으면 ValueError — fallback 없음.
새 frame 추가 = catalog yaml 에 entry 추가 + (필요시) 새 builder/parser 등록.
"""
contract = get_contract(template_id)
if contract is None:
raise ValueError(
f"No frame_contracts entry for template_id='{template_id}'. "
f"Add an entry in templates/phase_z2/catalog/frame_contracts.yaml. "
f"Known contracts: {sorted(_known_contract_ids())}."
)
return map_with_contract(section, contract)
# ─── Asset copy ─────────────────────────────────────────────────
def copy_assets(template_id: str, run_dir: Path) -> Optional[Path]:
"""Frame asset (Figma) 폴더 복사 — frame_id 는 catalog contract 에서 도출.
contract 에 `frame_id` 없으면 (asset 없는 frame) None 반환.
이전엔 pipeline.py 에 TEMPLATE_TO_FRAME_ID Python dict 가 있었지만 catalog 로 이전 (2026-04-29).
"""
contract = get_contract(template_id)
frame_id = (contract or {}).get("frame_id")
if not frame_id:
return None
src = ASSETS_SOURCE_BASE / str(frame_id) / "assets"
if not src.exists():
return None
dst = run_dir / "assets" / template_id
dst.parent.mkdir(parents=True, exist_ok=True)
if dst.exists():
shutil.rmtree(dst)
shutil.copytree(src, dst)
return dst
# ─── Render (single slide + Type B) ────────────────────────────
def _read_token_css() -> str:
token_dir = PROJECT_ROOT / "templates" / "styles" / "tokens"
files = ["typography.css", "spacing.css", "colors.css"]
parts = []
for f in files:
path = token_dir / f
if path.exists():
parts.append(f"/* === {f} === */\n{path.read_text(encoding='utf-8')}")
return "\n\n".join(parts)
def _attempt_zone_ratio_retry(
*,
run_dir: Path,
out_path: Path,
slide_title: str,
slide_footer: Optional[str],
zones_data: list[dict],
debug_zones: list[dict],
layout_preset: str,
layout_css: dict,
overflow: dict,
fit_classification: dict,
router_decision: dict,
gap_px: int,
) -> dict:
"""A3 zone_ratio_retry orchestration.
locked rules :
- retry budget = 1
- slide-base / spacing / gap 고정
- target zone height 만 증가, sibling donor 에서 같은 양 차감
- donor 룰 strict (visual ok / capacity ok / slack > 0 / min_height 보존)
- (b) revert : redistribution fail 또는 rerender 후 visual fail 시 original final.html 그대로
Returns:
retry_trace dict (always returned, even when no retry attempted) with :
retry_attempted : bool
retry_action : 'zone_ratio_retry' or None
plan : phase_z2_retry.plan_zone_ratio_retry() 결과 (있을 때만)
rerender_attempted : bool
retry_passed : bool
retry_failure_reason : str or None
retried_candidate_path : str or None (rerender 한 경우 진단 artifact 경로)
post_retry_overflow : dict (retry_passed=True 일 때만)
post_retry_debug_zones : list (retry_passed=True 일 때만 — height_px 갱신본)
post_retry_layout_css : dict (retry_passed=True 일 때만)
"""
base_trace = {
"retry_attempted": False,
"retry_action": None,
"plan": None,
"rerender_attempted": False,
"retry_passed": False,
"retry_failure_reason": None,
"retried_candidate_path": None,
"safety_margin_px": DEFAULT_SAFETY_MARGIN_PX,
"policy": (
"A3 locked rules : retry budget=1, slide-base/spacing/gap fixed, "
"donor strict (sibling/visual ok/capacity ok/slack>0/min_height 보존), "
"(b) revert on redistribution fail or rerender visual fail."
),
}
# 1. retry attempt 자체가 적절한지 판단
if not router_decision.get("router_active"):
base_trace["retry_skipped_reason"] = "router_active=False (visual check passed — no overflow)"
return base_trace
proposed = router_decision.get("proposed_actions_summary") or []
if "zone_ratio_retry" not in proposed:
base_trace["retry_skipped_reason"] = (
f"zone_ratio_retry not in proposed_actions {proposed} (다른 action category)"
)
return base_trace
# IMP-09 PR 1 retry gate — row-axis retry is only valid for layouts whose
# row geometry is dynamic. 2-D / dynamic_cols layouts and fr_default sinks
# would either misapply row-only redistribution or produce a no-op trace.
if layout_css.get("dynamic_cols", False):
base_trace["retry_skipped_reason"] = (
"layout has dynamic_cols (2-D topology) — "
"row-axis retry not applicable to 2-D layouts (IMP-09 lock)"
)
return base_trace
if not layout_css.get("dynamic_rows", False):
base_trace["retry_skipped_reason"] = (
"layout is fr_default_from_preset (no dynamic geometry) — retry no-op"
)
return base_trace
# 2. plan
base_trace["retry_attempted"] = True
base_trace["retry_action"] = "zone_ratio_retry"
plan = plan_zone_ratio_retry(
debug_zones=debug_zones,
overflow=overflow,
fit_classification=fit_classification,
router_decision=router_decision,
safety_margin_px=DEFAULT_SAFETY_MARGIN_PX,
)
base_trace["plan"] = plan
if plan is None:
base_trace["retry_failure_reason"] = "plan_zone_ratio_retry returned None — no target classification matched zone_ratio_retry"
return base_trace
if not plan.get("feasible"):
# redistribution check 실패 → rerender 안 함, original final.html 그대로
base_trace["retry_failure_reason"] = plan.get("failure_reason")
print(
f" retry : zone_ratio_retry redistribution INFEASIBLE — "
f"target {plan['target_zone_position']} needs {plan['target_added_px']}px, "
f"{plan.get('failure_reason')}"
)
return base_trace
# 3. feasible — apply plan to layout_css, rerender to candidate path (NOT final.html yet)
new_layout_css = apply_retry_to_layout_css(
layout_css, plan, zones_data,
total_height=SLIDE_BODY_HEIGHT, gap_px=gap_px,
)
candidate_path = run_dir / "retried_candidate.html"
candidate_html = render_slide(
slide_title, slide_footer, zones_data, layout_preset, new_layout_css, gap_px=gap_px,
)
candidate_path.write_text(candidate_html, encoding="utf-8")
base_trace["rerender_attempted"] = True
base_trace["retried_candidate_path"] = str(candidate_path.relative_to(PROJECT_ROOT))
print(
f" retry : zone_ratio_retry attempted — target {plan['target_zone_position']} "
f"+{plan['target_added_px']}px (donor {plan['donor_zone_position']} -{plan['donor_reduced_px']}px) "
f"→ rerender to retried_candidate.html → visual check"
)
# 4. 후 visual check on candidate
candidate_overflow = run_overflow_check(candidate_path)
if candidate_overflow.get("passed", False):
# 성공 — final.html 을 candidate 로 promote
out_path.write_text(candidate_html, encoding="utf-8")
# debug_zones height_px / ratio 갱신 (post-retry 상태)
new_heights = new_layout_css["heights_px"]
new_ratios = new_layout_css["ratios"]
post_retry_debug_zones = []
for i, dz in enumerate(debug_zones):
new_dz = dict(dz)
new_dz["height_px"] = new_heights[i] if i < len(new_heights) else dz.get("height_px")
new_dz["ratio"] = new_ratios[i] if i < len(new_ratios) else dz.get("ratio")
new_dz["zone_height_post_retry"] = True
post_retry_debug_zones.append(new_dz)
base_trace["retry_passed"] = True
base_trace["post_retry_overflow"] = candidate_overflow
base_trace["post_retry_debug_zones"] = post_retry_debug_zones
base_trace["post_retry_layout_css"] = new_layout_css
print(f" retry : PASSED — final.html promoted to retried version")
return base_trace
# 5. rerender 후에도 visual fail → (b) revert : final.html 은 original 그대로 (이미 written)
base_trace["retry_passed"] = False
base_trace["retry_failure_reason"] = (
f"rerender visual_check failed: {candidate_overflow.get('fail_reasons')}. "
f"reverting to original final.html (retried_candidate.html stays as diagnostic only)."
)
base_trace["candidate_overflow_summary"] = {
"passed": False,
"fail_reasons": candidate_overflow.get("fail_reasons", []),
}
print(f" retry : FAILED — candidate visual_check 도 실패. revert to original. ({candidate_path.name} 은 diagnostic 으로 보존)")
return base_trace
# IMP-12 u8 — Step 17 salvage cascade orchestrator (deterministic, no normal-path AI).
# Plan/apply pairs: phase_z2_retry (u4/u5/u6). Routing: failure_router.route_retry_failure (u3).
# Pipeline wiring (cascade_inputs assembly + retry_trace merge) lands in u9.
# IMP-88 u6 — extended with layout_adjust + frame_internal_fit_candidate dispatch.
# Mirror of failure_router.SALVAGE_FAILURE_TYPE_BY_ACTION (single source-of-truth lives
# there; this local map gates which actions the salvage loop can execute and feeds the
# loop-cap range(len(...)) so cascade depth scales with implemented executors).
_SALVAGE_FAIL_BY_ACTION = {
"cross_zone_redistribute": "cross_zone_redistribute_insufficient",
"glue_compression": "glue_absorption_insufficient",
"font_step_compression": "font_step_insufficient",
"layout_adjust": "layout_adjust_insufficient",
"frame_internal_fit_candidate": "frame_internal_fit_candidate_insufficient",
}
def _attempt_salvage_chain(
*, run_dir: Path, out_path: Path, slide_title: str, slide_footer: Optional[str],
zones_data: list[dict], layout_preset: str, layout_css: dict,
cascade_inputs: dict, initial_failure_type: str, gap_px: int,
) -> dict:
"""IMP-12 u8 — deterministic Step 17 salvage cascade (cross_zone → glue → font_step).
Per stage: plan → apply CSS → rerender → run_overflow_check. PASS promotes final.html;
cascade-exit (layout_adjust/frame_reselect/none) or all-fail preserves (b) revert.
Honors IMP-09 dynamic_cols / fr_default gate.
"""
trace = {"salvage_attempted": False, "salvage_passed": False, "salvage_steps": []}
if layout_css.get("dynamic_cols", False) or not layout_css.get("dynamic_rows", False):
trace["salvage_skipped_reason"] = "IMP-09 gate — dynamic_cols/no dynamic_rows; cascade no-op"
return trace
trace["salvage_attempted"] = True
failure_type = initial_failure_type
ci = cascade_inputs
for _ in range(len(_SALVAGE_FAIL_BY_ACTION)):
routing = route_retry_failure(failure_type) or {}
next_action = routing.get("next_proposed_action")
if next_action not in _SALVAGE_FAIL_BY_ACTION:
trace["salvage_terminal_action"] = next_action
trace["salvage_terminal_rationale"] = routing.get("rationale")
return trace
# IMP-88 u6 — layout_adjust takes a distinct render path (fresh
# render_slide with the new preset + remapped zones_data + new
# layout_css), so it is dispatched BEFORE the shared CSS-overlay
# planner cluster below. No common margin / slide-body shrink
# ([[feedback_phase_z_spacing_direction]]) — topology swap only.
if next_action == "layout_adjust":
plan = plan_layout_adjust(
current_layout_preset=layout_preset, zones_data=zones_data)
new_layout_css = (
apply_layout_adjust_layout_css(plan, gap_px=gap_px)
if (plan and plan.get("feasible")) else None
)
candidate_path = run_dir / f"salvage_{next_action}_candidate.html"
candidate_html, candidate_overflow, passed = None, None, False
if new_layout_css:
candidate_html = render_slide(
slide_title, slide_footer,
plan["new_zones_data"], plan["new_layout_preset"],
new_layout_css, gap_px=gap_px,
)
candidate_path.write_text(candidate_html, encoding="utf-8")
candidate_overflow = run_overflow_check(candidate_path)
passed = bool(candidate_overflow.get("passed", False))
step = {
"action": next_action, "plan": plan, "passed": passed,
"new_layout_preset": (
plan.get("new_layout_preset") if isinstance(plan, dict) else None
),
"candidate_path": (
str(candidate_path.relative_to(PROJECT_ROOT))
if new_layout_css else None
),
}
if passed:
out_path.write_text(candidate_html, encoding="utf-8")
step["post_salvage_overflow"] = candidate_overflow
trace["salvage_steps"].append(step)
trace["salvage_passed"] = True
return trace
step["failure_reason"] = (
(plan.get("failure_reason") if isinstance(plan, dict) else None)
or (candidate_overflow.get("fail_reasons") if candidate_overflow else None)
or "infeasible or render emitted no candidate")
trace["salvage_steps"].append(step)
failure_type = _SALVAGE_FAIL_BY_ACTION[next_action]
continue
if next_action == "cross_zone_redistribute":
if ci.get("fit_analysis") is None:
plan = {"action": "cross_zone_redistribute", "feasible": False,
"failure_reason": "cascade_inputs.fit_analysis missing — cross_zone_redistribute requires fit_analysis."}
else:
plan = plan_cross_zone_redistribute(
fit_analysis=ci["fit_analysis"], containers=ci.get("containers") or {},
min_margin_px=ci.get("min_margin_px"))
apply_fn = apply_cross_zone_redistribute_css
elif next_action == "glue_compression":
plan = plan_glue_compression(
excess_px=float(ci.get("excess_px") or 0.0),
block_count=int(ci.get("block_count") or 0),
zone_position=str(ci.get("zone_position") or ""))
apply_fn = apply_glue_compression_css
elif next_action == "frame_internal_fit_candidate":
# IMP-88 u6 — per-zone frame-scoped envelope variant. Resolves
# frame_template_id from zones_data via cascade_inputs.zone_position
# so the planner stays within the frame contract envelope
# (no shared margin shrink per [[feedback_phase_z_spacing_direction]]).
_target_pos = str(ci.get("zone_position") or "")
_target_zone = next(
(z for z in zones_data if z.get("position") == _target_pos), {},
) or {}
_frame_tid = str(_target_zone.get("template_id") or "")
plan = plan_frame_internal_fit_candidate(
frame_template_id=_frame_tid,
overflow_zone={"excess_y": float(ci.get("excess_px") or 0.0)},
)
apply_fn = apply_frame_internal_fit_candidate_css
else:
plan = plan_font_step_compression(
current_font_px=float(ci.get("current_font_px") or 0.0),
excess_after_glue_px=float(ci.get("excess_after_glue_px") or ci.get("excess_px") or 0.0),
available_lines=int(ci.get("available_lines") or 0),
chars_per_line=int(ci.get("chars_per_line") or 0),
zone_position=str(ci.get("zone_position") or ""))
apply_fn = apply_font_step_compression_css
css_override = apply_fn(plan) if (plan and plan.get("feasible")) else ""
candidate_path = run_dir / f"salvage_{next_action}_candidate.html"
candidate_html, candidate_overflow, passed = None, None, False
if css_override:
base = render_slide(slide_title, slide_footer, zones_data, layout_preset, layout_css, gap_px=gap_px)
style = f"<style>\n{css_override}\n</style>"
candidate_html = base.replace("</head>", f"{style}\n</head>", 1) if "</head>" in base else style + base
candidate_path.write_text(candidate_html, encoding="utf-8")
candidate_overflow = run_overflow_check(candidate_path)
passed = bool(candidate_overflow.get("passed", False))
step = {"action": next_action, "plan": plan, "passed": passed,
"css_override": css_override or None,
"candidate_path": str(candidate_path.relative_to(PROJECT_ROOT)) if css_override else None}
if passed:
out_path.write_text(candidate_html, encoding="utf-8")
step["post_salvage_overflow"] = candidate_overflow
trace["salvage_steps"].append(step)
trace["salvage_passed"] = True
return trace
step["failure_reason"] = (
(plan.get("failure_reason") if isinstance(plan, dict) else None)
or (candidate_overflow.get("fail_reasons") if candidate_overflow else None)
or "infeasible or no CSS emitted")
trace["salvage_steps"].append(step)
failure_type = _SALVAGE_FAIL_BY_ACTION[next_action]
return trace
# IMP-88 (#88) u7 — Step 17 image_fit single-pass entry executor.
# image_fit is NOT a salvage cascade stage (deliberately OUT of
# _SALVAGE_FAIL_BY_ACTION per u6 guard). Instead it is a Step 17 ENTRY
# single-pass: aggregate per-image plan/apply via plan_image_fit (u4) +
# apply_image_fit_css (u4), emit one CSS overlay, re-render once,
# run_overflow_check. PASS promotes final.html; FAIL records `image_fit`
# step with failure_reason so failure_router (u2)
# SALVAGE_FAILURE_TYPE_BY_ACTION classifies it as `image_fit_insufficient`
# and the cascade entry block routes onto layout_adjust.
# Honors [[feedback_phase_z_spacing_direction]] — frame-scoped img CSS only,
# no common margin / slide-body / zone gap shrink. AI isolation contract
# (PZ-1) — deterministic data-surface, no AI call.
def _attempt_step17_image_fit_single_pass(
*, run_dir: Path, out_path: Path, slide_title: str, slide_footer: Optional[str],
zones_data: list[dict], layout_preset: str, layout_css: dict,
image_events: list[dict], gap_px: int,
delta_tol: float = IMAGE_ASPECT_DELTA_TOL,
) -> dict:
"""IMP-88 u7 — Step 17 image_fit single-pass executor.
Returns a result dict with shape:
- triggered : bool — True iff any feasible image_fit plan emitted CSS.
- passed : bool — True iff post-rerender overflow check passed.
- step : dict|None — salvage_steps[] entry shape (action="image_fit",
image_fit_event_plans, candidate_path, post_salvage_overflow
on pass / failure_reason on fail).
- candidate_html : str|None — rendered HTML when triggered (None otherwise).
- candidate_overflow : dict|None — run_overflow_check result when triggered.
- event_plans : list[dict] — every plan_image_fit result (feasible + no-op),
surfaced for telemetry even when none emit CSS.
Side effect on PASS only: writes candidate_html to out_path.
"""
event_plans = []
css_chunks = []
for ev in (image_events or []):
plan = plan_image_fit(image_event=ev, delta_tol=delta_tol)
event_plans.append(plan)
if plan.get("feasible"):
css = apply_image_fit_css(plan)
if css:
css_chunks.append(css)
if not css_chunks:
return {
"triggered": False, "passed": False, "step": None,
"candidate_html": None, "candidate_overflow": None,
"event_plans": event_plans,
}
candidate_path = run_dir / "salvage_image_fit_candidate.html"
base = render_slide(
slide_title, slide_footer, zones_data, layout_preset, layout_css,
gap_px=gap_px,
)
style = "<style>\n" + "\n".join(css_chunks) + "\n</style>"
candidate_html = (
base.replace("</head>", f"{style}\n</head>", 1)
if "</head>" in base else style + base
)
candidate_path.write_text(candidate_html, encoding="utf-8")
candidate_overflow = run_overflow_check(candidate_path)
passed = bool(candidate_overflow.get("passed", False))
step = {
"action": "image_fit", "passed": passed,
"image_fit_event_plans": event_plans,
"candidate_path": (
str(candidate_path.relative_to(PROJECT_ROOT))
if candidate_path.is_absolute() else str(candidate_path)
),
}
if passed:
step["post_salvage_overflow"] = candidate_overflow
out_path.write_text(candidate_html, encoding="utf-8")
else:
step["failure_reason"] = (
candidate_overflow.get("fail_reasons")
or "image_fit single-pass: overflow persists"
)
return {
"triggered": True, "passed": passed, "step": step,
"candidate_html": candidate_html, "candidate_overflow": candidate_overflow,
"event_plans": event_plans,
}
def _remeasure_after_frame_reselect(
*, candidate_path: Path, plan: Optional[dict] = None,
) -> dict:
"""IMP-35 (#64) u1 — post-frame remeasure helper for the cascade terminal.
Contract (q4 / Stage 2): frame_reselect_insufficient is detected by an
*explicit overflow re-measure* after a V4 top-k alternate frame swap —
NOT a failure-flag carryover. This helper runs run_overflow_check on the
re-rendered candidate HTML and shapes the salvage_steps entry that
classify_retry_failure / SALVAGE_FAILURE_TYPE_BY_ACTION read.
Future frame_reselect orchestrator (post-IMP-35) writes the candidate
HTML and calls this helper to append the entry to retry_trace.salvage_steps.
On passed=True the orchestrator promotes the candidate to final.html; on
passed=False the classifier emits frame_reselect_insufficient → u2 routes
onto details_popup_escalation (Step 17 POPUP gate / u5).
"""
candidate_overflow = run_overflow_check(candidate_path)
passed = bool(candidate_overflow.get("passed", False))
return {
"action": "frame_reselect",
"plan": plan,
"passed": passed,
"candidate_path": (
str(candidate_path.relative_to(PROJECT_ROOT))
if candidate_path.is_absolute() else str(candidate_path)
),
"post_salvage_overflow": candidate_overflow,
"failure_reason": (
None if passed
else (candidate_overflow.get("fail_reasons") or "post-frame remeasure: overflow persists")
),
}
# ─── IMP-42 u3 (#71) — unconditional Step 12 / Step 13 DIAG log helper ──
def _emit_diag_zones_shape(stage_label: str, zones_data: list[dict], **extra_fields) -> None:
"""IMP-42 u3 (#71) — emit shape-only zone metadata to stdout.
Used at the Step 12 slot_payload emit site and the Step 13 render_slide
entry site to make the silent 3-hop handoff visible in the terminal.
Shape-only — never logs raw slot_payload values — so the diag is
sample-agnostic (RULE 0) and never leaks user content.
No env gate: silence is the bug this IMP fights, so the log fires
unconditionally on every slide loop.
"""
payload = {
"zones_count": len(zones_data),
"zones": [
{
"i": i,
"position": z.get("position"),
"template_id": z.get("template_id"),
"slot_keys": (
sorted(z["slot_payload"].keys())
if isinstance(z.get("slot_payload"), dict) else None
),
}
for i, z in enumerate(zones_data)
],
}
payload.update(extra_fields)
print(
f"[DIAG] phase_z2 {stage_label} "
+ json.dumps(payload, ensure_ascii=False, sort_keys=True),
flush=True,
)
# ─── IMP-42 u2 (#71) — post-render HTML invalid path char detector ──
_INVALID_PATH_ATTR_RE = re.compile(
r"""(src|href)\s*=\s*["']([^"']*)["']|url\(\s*['"]?([^)'"\s]+)['"]?\s*\)""",
re.IGNORECASE,
)
_INVALID_PATH_CHARS = ("\\", "&amp;", "&#39;")
def _scan_rendered_html_for_invalid_path_chars(html: str, context: str) -> None:
"""IMP-42 u2 (#71) — fail loud when rendered HTML asset references
contain invalid path characters in src / href / url(...) values.
Catches three silent fail vectors at the rendered HTML boundary that
surface downstream as 404 / asset-load failures far from upstream cause:
- Windows backslash from str(Path) (e.g. ``assets\\img.png``).
- Autoescape entity ``&amp;`` (raw ``&`` in raw path source string).
- Autoescape entity ``&#39;`` (raw ``'`` in raw path source string).
Raises ValueError on first hit, citing context, attr type, value snippet.
Scope-locked to rendered HTML asset attrs only; never inspects CSS grid
metadata or static template strings.
"""
for match in _INVALID_PATH_ATTR_RE.finditer(html):
if match.group(3) is not None:
attr_label = "url(...)"
value = match.group(3)
else:
attr_label = match.group(1).lower()
value = match.group(2)
for bad in _INVALID_PATH_CHARS:
if bad in value:
snippet = value if len(value) <= 120 else value[:117] + "..."
raise ValueError(
f"render_slide: {context} — invalid path char {bad!r} in "
f"{attr_label} value (value={snippet}). "
"Likely upstream: Windows backslash from str(Path) or "
"autoescape of '&' / \"'\" in raw path string."
)
def render_slide(slide_title: str, slide_footer: Optional[str],
zones_data: list[dict], layout_preset: str,
layout_css: dict, gap_px: int = GRID_GAP,
*, embedded_mode: str = "auto") -> str:
"""Single slide HTML — slide_base.html + 8-preset layout vocabulary.
layout_css = build_layout_css() 결과 — areas/cols/rows 문자열 + 동적 heights flag.
Template 은 layout_css.{areas,cols,rows} 를 grid CSS 에 직접 주입.
embedded_mode (IMP-14): "auto" | "embedded" | "standalone". Controls
slide_base.html body CSS contract. Default "auto" preserves backward-compat
with run_overflow_check standalone path and lets iframe consumers signal via
?embedded=1 or window.self!==window.top.
"""
if embedded_mode not in ("auto", "embedded", "standalone"):
raise ValueError(
f"render_slide: invalid embedded_mode={embedded_mode!r}; "
"expected one of 'auto', 'embedded', 'standalone'"
)
# IMP-42 u3 (#71) — unconditional Step 13 entry DIAG log.
_emit_diag_zones_shape(
"Step 13 render_slide entry",
zones_data,
layout_preset=layout_preset,
embedded_mode=embedded_mode,
)
env = Environment(
loader=FileSystemLoader(str(TEMPLATE_DIR)),
autoescape=select_autoescape(["html"]),
)
for zone_index, zone in enumerate(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
# IMP-42 u1 (#71) — fail-loud precondition for Step 13 partial render.
# Catches the silent fail vector where Step 12 emits a zone dict missing
# `template_id` / `slot_payload`. Error message cites zone_index +
# missing key so the diag is actionable (vs Jinja TemplateNotFound /
# KeyError surfacing far from the upstream emit site).
template_id = zone.get("template_id")
if not isinstance(template_id, str) or not template_id:
raise TypeError(
f"render_slide: zones_data[{zone_index}] precondition failed — "
f"`template_id` must be a non-empty str, got {type(template_id).__name__}={template_id!r}"
)
if "slot_payload" not in zone:
raise TypeError(
f"render_slide: zones_data[{zone_index}] precondition failed — "
f"`slot_payload` key missing (template_id={template_id!r})"
)
slot_payload = zone["slot_payload"]
if not isinstance(slot_payload, dict):
raise TypeError(
f"render_slide: zones_data[{zone_index}] precondition failed — "
f"`slot_payload` must be a dict, got {type(slot_payload).__name__} "
f"(template_id={template_id!r})"
)
partial = env.get_template(f"families/{template_id}.html")
rendered_partial = partial.render(slot_payload=slot_payload)
# IMP-42 u2 (#71) — fail loud on invalid path chars in rendered HTML
# asset refs (src / href / url(...)). Catches Windows backslash and
# autoescape entity vectors before they reach the browser as 404.
_scan_rendered_html_for_invalid_path_chars(
rendered_partial,
f"zones_data[{zone_index}] template_id={template_id!r}",
)
zone["partial_html"] = rendered_partial
base = env.get_template("slide_base.html")
rendered_base = base.render(
slide_title=slide_title,
slide_footer=slide_footer,
zones=zones_data,
layout_preset=layout_preset,
layout_css=layout_css,
gap_px=gap_px,
token_css=_read_token_css(),
embedded_mode=embedded_mode,
)
# IMP-42 u2 (#71) — also scan the assembled slide_base output to cover
# asset refs introduced by the slide-base shell itself (title / footer /
# popup slots) outside the per-zone partial scope.
_scan_rendered_html_for_invalid_path_chars(rendered_base, "slide_base")
return rendered_base
# ─── Selenium check (single slide + per-zone) ──────────────────
def run_overflow_check(html_path: Path) -> dict:
"""Single slide + per-zone overflow + clipping check."""
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.chrome.service import Service
options = Options()
options.add_argument("--headless=new")
options.add_argument("--no-sandbox")
options.add_argument("--disable-dev-shm-usage")
options.add_argument("--window-size=1400,900")
chromedriver_candidates = [
PROJECT_ROOT / "chromedriver",
PROJECT_ROOT / "chromedriver.exe",
]
driver = None
last_err = None
for path in chromedriver_candidates:
if path.is_file():
try:
driver = webdriver.Chrome(service=Service(str(path)), options=options)
break
except Exception as e:
last_err = e
if driver is None:
try:
driver = webdriver.Chrome(options=options)
except Exception as e:
return {"passed": False, "error": f"selenium init failed: {last_err or e}"}
try:
driver.get(html_path.resolve().as_uri())
driver.set_window_size(1400, 900)
driver.implicitly_wait(1)
result = driver.execute_script(r"""
const measure = (el) => ({
clientWidth: el.clientWidth,
clientHeight: el.clientHeight,
scrollWidth: el.scrollWidth,
scrollHeight: el.scrollHeight,
excess_x: Math.max(0, el.scrollWidth - el.clientWidth),
excess_y: Math.max(0, el.scrollHeight - el.clientHeight),
overflowed: (el.scrollWidth > el.clientWidth + 5) ||
(el.scrollHeight > el.clientHeight + 5),
});
const slide = document.querySelector('.slide');
if (!slide) return { error: '.slide not found' };
const slideM = measure(slide);
slideM.size_correct = slide.clientWidth === 1280 && slide.clientHeight === 720;
// A-6 (IMP-01 #1) — slide-relative bbox base
const slideRect = slide.getBoundingClientRect();
const body = document.querySelector('.slide-body');
const bodyM = body ? measure(body) : null;
const zones = [];
const zone_geometries_px = [];
// IMP-15 실행-2 (issue #46) — element-identity dedup map for table_events.
// Map<Element, integer> keyed by DOM node reference (NOT class string) so that
// two wrappers sharing identical className resolve to distinct map entries.
// Populated alongside the existing per-zone clipped_inner scan below.
const clippedWrapperMap = new Map();
let clippedIdxCounter = 0;
slide.querySelectorAll('.zone').forEach((z) => {
const pos = z.getAttribute('data-zone-position') || 'unknown';
const tid = z.getAttribute('data-template-id') || '?';
const m = measure(z);
m.position = pos;
m.template_id = tid;
// A-6 (IMP-01 #1) — zone bbox in slide-relative px (additive trace, no layout side effect)
const zoneRect = z.getBoundingClientRect();
zone_geometries_px.push({
position: pos,
template_id: tid,
x: Math.round(zoneRect.left - slideRect.left),
y: Math.round(zoneRect.top - slideRect.top),
w: Math.round(zoneRect.width),
h: Math.round(zoneRect.height),
});
// 내부 clipping 검사 — frame-family root/cell 단위.
// tolerance / threshold 그대로. inner_content_signals 만 추가 보강 (detection 데이터 늘림).
const clipped = [];
z.querySelectorAll('[class*="f13b"], [class*="f29b"], [class*="f16b"]').forEach((el) => {
const dx = el.scrollWidth - el.clientWidth;
const dy = el.scrollHeight - el.clientHeight;
if (dx > 5 || dy > 5) {
// inner content signals — clipped cell 안에 *어떤 종류의 콘텐츠가 들어있는지* 보고.
// classifier 가 frame_internal_cell 만 봐서는 부족하니 inner 까지 본다.
const inner_signals = [];
if (el.querySelector('.transform-block, .transform-row, .transform-rows')) {
inner_signals.push('structural_unit');
}
if (el.querySelector('table')) {
inner_signals.push('tabular');
}
if (el.querySelector('.text-line')) {
inner_signals.push('text_flow');
}
clipped.push({
class_name: el.className,
inner_content_signals: inner_signals,
excess_x: Math.max(0, dx),
excess_y: Math.max(0, dy),
clientWidth: el.clientWidth,
clientHeight: el.clientHeight,
scrollWidth: el.scrollWidth,
scrollHeight: el.scrollHeight,
});
// IMP-15 실행-2 (issue #46) — element-identity registration.
// Key by DOM node `el`, NOT className: two wrappers with identical
// class string still hash to distinct Map entries.
if (!clippedWrapperMap.has(el)) {
clippedWrapperMap.set(el, clippedIdxCounter);
clippedIdxCounter++;
}
}
});
m.clipped_inner = clipped;
zones.push(m);
});
// B5 v0 — frame_slot_metrics (per-cell measurement of [data-frame-slot-id])
// 현재 F29 partial 만 marker 보유 (process_column / product_column × 3 cells = 6 entries 기대).
// 다른 frame (F13 / F16) 은 marker 미적용 → entry 0 — 정상.
const frame_slot_metrics = [];
slide.querySelectorAll('[data-frame-slot-id]').forEach((cell) => {
const slotId = cell.getAttribute('data-frame-slot-id');
const m2 = measure(cell);
const parentZone = cell.closest('.zone');
const zonePos = parentZone
? (parentZone.getAttribute('data-zone-position') || 'unknown')
: 'unknown';
const zoneTid = parentZone
? (parentZone.getAttribute('data-template-id') || '?')
: '?';
frame_slot_metrics.push({
zone_position: zonePos,
zone_template_id: zoneTid,
frame_slot_id: slotId,
class_name: cell.className,
clientWidth: m2.clientWidth,
clientHeight: m2.clientHeight,
scrollWidth: m2.scrollWidth,
scrollHeight: m2.scrollHeight,
excess_x: m2.excess_x,
excess_y: m2.excess_y,
overflowed: m2.overflowed,
});
});
// IMP-15 실행-1 (issue #45) — image_events[] for image_aspect_mismatch detection.
// 하나의 entry per <img> under .slide. natural vs rendered aspect 비교.
// zone_position : closest('.zone') data-zone-position. 없으면 literal "unknown".
const image_events = [];
slide.querySelectorAll('img').forEach((img) => {
const parentZone = img.closest('.zone');
const zonePos = parentZone
? (parentZone.getAttribute('data-zone-position') || 'unknown')
: 'unknown';
const zoneTid = parentZone
? (parentZone.getAttribute('data-template-id') || '?')
: '?';
const imgRect = img.getBoundingClientRect();
const rendered_w = imgRect.width;
const rendered_h = imgRect.height;
const natural_w = img.naturalWidth;
const natural_h = img.naturalHeight;
const natural_ratio = (natural_w > 0 && natural_h > 0)
? (natural_w / natural_h)
: null;
const rendered_ratio = (rendered_w > 0 && rendered_h > 0)
? (rendered_w / rendered_h)
: null;
const delta = (natural_ratio !== null && rendered_ratio !== null)
? (rendered_ratio - natural_ratio)
: null;
image_events.push({
src: img.getAttribute('src') || '',
zone_position: zonePos,
zone_template_id: zoneTid,
natural_w: natural_w,
natural_h: natural_h,
rendered_w: Math.round(rendered_w),
rendered_h: Math.round(rendered_h),
natural_ratio: natural_ratio,
rendered_ratio: rendered_ratio,
delta: delta,
bbox: {
x: Math.round(imgRect.left - slideRect.left),
y: Math.round(imgRect.top - slideRect.top),
w: Math.round(rendered_w),
h: Math.round(rendered_h),
},
});
});
// IMP-15 실행-2 (issue #46) — table_events[] for table_self_overflow detection.
// One entry per <table> under .slide. wrapper_clipped_index is the integer index
// (from clippedWrapperMap) of the nearest ancestor that is itself in the clipped
// wrapper set, or null. Element-identity walk (NOT className) so that two same-class
// wrappers (W1 clipped, W2 not) resolve independently for any contained <table>.
const table_events = [];
slide.querySelectorAll('table').forEach((tbl) => {
const parentZone = tbl.closest('.zone');
const zonePos = parentZone
? (parentZone.getAttribute('data-zone-position') || 'unknown')
: 'unknown';
const zoneTid = parentZone
? (parentZone.getAttribute('data-template-id') || '?')
: '?';
let wrapper_clipped_index = null;
let node = tbl.parentElement;
while (node && node !== slide) {
if (clippedWrapperMap.has(node)) {
wrapper_clipped_index = clippedWrapperMap.get(node);
break;
}
node = node.parentElement;
}
const tblRect = tbl.getBoundingClientRect();
const dx = tbl.scrollWidth - tbl.clientWidth;
const dy = tbl.scrollHeight - tbl.clientHeight;
table_events.push({
zone_position: zonePos,
zone_template_id: zoneTid,
clientWidth: tbl.clientWidth,
clientHeight: tbl.clientHeight,
scrollWidth: tbl.scrollWidth,
scrollHeight: tbl.scrollHeight,
excess_x: Math.max(0, dx),
excess_y: Math.max(0, dy),
wrapper_clipped_index: wrapper_clipped_index,
bbox: {
x: Math.round(tblRect.left - slideRect.left),
y: Math.round(tblRect.top - slideRect.top),
w: Math.round(tblRect.width),
h: Math.round(tblRect.height),
},
});
});
return { slide: slideM, slide_body: bodyM, zones, frame_slot_metrics, zone_geometries_px, image_events, table_events };
""")
screenshot_path = html_path.parent / "preview.png"
try:
driver.save_screenshot(str(screenshot_path))
result["screenshot"] = str(screenshot_path.relative_to(PROJECT_ROOT))
except Exception as e:
result["screenshot_error"] = str(e)
finally:
driver.quit()
if "error" in result:
return {"passed": False, **result}
fail_reasons = []
if not result["slide"]["size_correct"]:
fail_reasons.append(
f"slide size != 1280x720 (got {result['slide']['clientWidth']}x{result['slide']['clientHeight']})"
)
if result["slide"]["overflowed"]:
fail_reasons.append(
f"slide overflowed by {result['slide']['excess_y']}px (vert) / {result['slide']['excess_x']}px (horiz)"
)
if result.get("slide_body") and result["slide_body"]["overflowed"]:
fail_reasons.append(
f"slide-body overflowed by {result['slide_body']['excess_y']}px (vert)"
)
for z in result["zones"]:
if z["overflowed"]:
fail_reasons.append(
f"zone--{z['position']} ({z['template_id']}) overflowed by {z['excess_y']}px (vert) / {z['excess_x']}px (horiz)"
)
for c in z.get("clipped_inner", []):
fail_reasons.append(
f"zone--{z['position']}: inner clipped .{c['class_name']}"
f"excess {c['excess_y']}px vert / {c['excess_x']}px horiz "
f"(content {c['scrollHeight']} vs container {c['clientHeight']})"
)
# IMP-15 실행-1 (issue #45) — image_aspect_mismatch aggregation.
# |natural_ratio - rendered_ratio| > IMAGE_ASPECT_DELTA_TOL ⇒ fail_reason append.
# Entries with null ratio (image not loaded / natural dims = 0) are skipped (no false positive).
for ev in result.get("image_events", []):
delta = ev.get("delta")
if delta is None:
continue
if abs(delta) > IMAGE_ASPECT_DELTA_TOL:
n_ratio = ev.get("natural_ratio")
r_ratio = ev.get("rendered_ratio")
src = ev.get("src", "")
pos = ev.get("zone_position", "unknown")
tid = ev.get("zone_template_id", "?")
fail_reasons.append(
f"image aspect mismatch in zone--{pos}: "
f"natural={n_ratio:.3f} rendered={r_ratio:.3f} delta={delta:+.3f} "
f"(template={tid}, tol={IMAGE_ASPECT_DELTA_TOL}, src={src})"
)
# IMP-15 실행-2 (issue #46) — table_self_overflow aggregation.
# Emit fail_reason only when (excess_x>TOL OR excess_y>TOL) AND wrapper_clipped_index is None.
# The clipped-wrapper case is already accounted for by the clipped_inner fail_reason above;
# element-identity dedup (clippedWrapperMap keyed by DOM node ref, NOT className) prevents
# double-counting and—critically—prevents two same-class wrappers from masking each other.
for ev in result.get("table_events", []):
if ev.get("wrapper_clipped_index") is not None:
continue
excess_x = ev.get("excess_x", 0) or 0
excess_y = ev.get("excess_y", 0) or 0
if excess_x > TABLE_SCROLL_TOL_PX or excess_y > TABLE_SCROLL_TOL_PX:
pos = ev.get("zone_position", "unknown")
tid = ev.get("zone_template_id", "?")
fail_reasons.append(
f"table self-overflow in zone--{pos}: "
f"excess {excess_y}px vert / {excess_x}px horiz "
f"(content {ev.get('scrollWidth')}x{ev.get('scrollHeight')} vs "
f"container {ev.get('clientWidth')}x{ev.get('clientHeight')}, "
f"template={tid}, tol={TABLE_SCROLL_TOL_PX})"
)
result["passed"] = len(fail_reasons) == 0
result["fail_reasons"] = fail_reasons
return result
def write_overflow_error(run_dir: Path, overflow: dict) -> Path:
error_data = {
"stage": "visual_runtime_check",
"reason": "Visual runtime contract 위반 — slide / slide-body / zone overflow / clipping.",
"fail_reasons": overflow.get("fail_reasons", []),
"details": overflow,
}
err_path = run_dir / "error.json"
err_path.write_text(json.dumps(error_data, ensure_ascii=False, indent=2), encoding="utf-8")
return err_path
# ─── Debug.json (single slide + zones[]) ───────────────────────
def _is_empty_shell_unit(u: CompositionUnit) -> bool:
"""IMP-87 u1 — true when a CompositionUnit is the IMP-30 u4 empty-shell
placeholder (frame_template_id="__empty__" / label="empty_shell" /
merge_type="empty_shell").
Used by compute_slide_status to redefine `full_mdx_coverage` over
rendered content units only: an empty-shell unit attaches the aligned
section_ids to a ``__empty__`` frame for layout purposes, but the slide
surface carries no MDX content for those sections. Counting it as
coverage would violate feedback_artifact_status_naming (overall /
coverage must reflect actual content state, not pipeline completion).
The three markers are checked as independent OR-branches so a
CompositionUnit synthesised by any of the IMP-30 u4 entry points
(phase A / phase B / e2e) is classified consistently.
"""
if getattr(u, "frame_template_id", None) == "__empty__":
return True
if getattr(u, "label", None) == "empty_shell":
return True
if getattr(u, "merge_type", None) == "empty_shell":
return True
return False
def _final_status_html_class(overall: str) -> str:
"""IMP-87 u3 — map ``overall`` enum string → CSS class for the step20
final_status.html dashboard.
EMPTY_SHELL_NO_CONTENT MUST resolve to ``"fail"`` (red) so the Case B
honesty defect (Stage 1 mdx05) surfaces in the same colour band as visual
failures and regressions, not the legacy ``"partial"`` amber band. The
explicit check runs BEFORE the legacy substring-based mapping because
the literal ``"EMPTY_SHELL_NO_CONTENT"`` contains neither ``"PASS"`` nor
``"FAIL"`` / ``"REGRESSION"`` and would otherwise default to
``"partial"`` (Stage 2 axis A5 lock).
All other enums preserve pre-IMP-87 substring semantics so the legacy
PASS / RENDERED_WITH_VISUAL_REGRESSION / PARTIAL_COVERAGE / etc. paths
keep the colour they had before u3.
"""
if overall == "EMPTY_SHELL_NO_CONTENT":
return "fail"
if "PASS" in overall:
return "pass"
if "FAIL" in overall or "REGRESSION" in overall:
return "fail"
return "partial"
def _is_blocked_overall(overall: str) -> bool:
"""IMP-87 u3 — true iff ``overall`` warrants a BLOCKED CLI exit
(returncode 1) independent of the visual_check / full_mdx_coverage axes.
Currently the single blocked enum is EMPTY_SHELL_NO_CONTENT (Stage 1
mdx05 Case B). The CLI consults this helper BEFORE the legacy
visual_fail / partial_coverage branches so a content-empty placeholder
slide that happens to pass Selenium overflow checks (no content → no
overflow) cannot silently return a content-empty artifact without an
exit signal (Stage 2 axis A4 lock).
"""
return overall == "EMPTY_SHELL_NO_CONTENT"
def compute_slide_status(sections: list[MdxSection],
units: list[CompositionUnit],
comp_debug: dict,
overflow: dict,
adapter_needed_units: Optional[list[dict]] = None,
debug_zones: Optional[list[dict]] = None) -> dict:
"""Slide 산출물의 정확한 상태 계산 — 자동 파이프라인 결과 보고.
축 :
- rendered : final.html 이 디스크에 쓰였는가
- visual_check_passed : Selenium per-zone overflow / clipping 통과 여부
- full_mdx_coverage : aligned 된 모든 section_id 가 어떤 selected unit 에 의해 covered
- adapter_needed_count : mapper FitError 로 자동 렌더 못 한 unit 수 (별 review 개념 X — 자동 실패 보고)
- content_truncated_count : builder 가 truncate 한 zone 수 (informational)
- provisional_first_render_count : IMP-30 first-render invariant 로 합성된 unit 수
(u1 V4Match synthesis / u3 last-resort fill /
u4 empty-shell — needs user/AI adaptation 신호)
overall enum :
EMPTY_SHELL_NO_CONTENT — IMP-87 u2 : every selected unit is an
IMP-30 u4 empty-shell placeholder
(no content-rendered units). Takes
precedence over the 4-way ladder below
because such a slide can technically
pass Selenium overflow checks but
carries no MDX content.
PASS — visual OK + full coverage + adapter_needed=0
RENDERED_WITH_VISUAL_REGRESSION — full coverage 이지만 visual fail
PARTIAL_COVERAGE — 일부 section 필터됨, 렌더된 부분만 visual OK
PARTIAL_COVERAGE_WITH_VISUAL_REGRESSION — 둘 다
(adapter_needed > 0 시 status note 추가, overall 은 위 enum 사용)
(IMP-30 u6 : provisional_first_render_count 도 qualifier 일 뿐, overall enum 변경 X.
Stage 1 Q3 + Codex #10 D4 lock.)
"""
aligned_ids = [s.section_id for s in sections]
# IMP-87 u1 — split coverage into legacy display vs honesty axis.
# ``covered`` (legacy, preserved for downstream display / IMP-05 / IMP-06
# readers) still tracks every section attached to any selected unit.
# ``content_covered`` is the new honesty axis: only non-empty-shell units
# count as "rendered with content". Sections attached solely to an
# empty-shell placeholder (IMP-30 u4 frame_template_id="__empty__")
# are routed into ``filtered_section_ids`` so an EMPTY-SHELL-only slide
# cannot inherit full_mdx_coverage=True (Case B honesty defect lock,
# Stage 1 anchor c53722ad).
covered: set = set()
content_covered: set = set()
for u in units:
covered.update(u.source_section_ids)
if not _is_empty_shell_unit(u):
content_covered.update(u.source_section_ids)
filtered_ids = sorted(set(aligned_ids) - content_covered)
full_coverage = len(filtered_ids) == 0
visual_passed = bool(overflow.get("passed", False))
# IMP-87 u2 — Additive empty/content accounting (used by the overall enum
# precedence block below and surfaced on the return dict for downstream
# introspection). ``content_rendered_section_ids`` mirrors the new honesty
# axis (content_covered) at the surface level so consumers can ask "how
# much real MDX content actually rendered" without re-running the helper.
# ``empty_shell_section_ids`` exposes the placeholder counterpart for the
# same reason. Both pairs are purely additive — no existing field is
# removed or repurposed (legacy ``covered_section_ids`` display semantics
# locked by u1).
empty_shell_units_list = [u for u in units if _is_empty_shell_unit(u)]
content_units_list = [u for u in units if not _is_empty_shell_unit(u)]
empty_shell_section_ids = sorted({
sid
for u in empty_shell_units_list
for sid in (u.source_section_ids or [])
})
content_rendered_section_ids = sorted(content_covered)
adapter_needed_units = list(adapter_needed_units or [])
content_truncated = []
fallback_selections = []
for z in (debug_zones or []):
if z.get("fallback_used"):
fallback_selections.append({
"position": z["position"],
"source_section_ids": z["source_section_ids"],
"template_id": z["v4_template_id"],
"selected_v4_rank": z.get("v4_selected_rank"),
"selection_path": z.get("selection_path"),
"fallback_reason": z.get("fallback_reason"),
})
tc = z.get("content_truncated_count")
if tc:
content_truncated.append({
"position": z["position"],
"source_section_ids": z["source_section_ids"],
"template_id": z["v4_template_id"],
"truncated_count": tc,
})
# 필터된 section 의 사유 (auto pipeline 결정 트레이스 — review 개념 X)
filtered_section_reasons = []
for c in comp_debug.get("candidates_summary", []):
if c.get("selection_state") == "selected":
continue
cand_ids = c.get("source_section_ids", [])
if any(sid in filtered_ids for sid in cand_ids):
filtered_section_reasons.append({
"section_ids": cand_ids,
"merge_type": c.get("merge_type"),
"template_id": c.get("template_id"),
"v4_label": c.get("label"),
"phase_z_status": c.get("phase_z_status"),
"score": c.get("score"),
"selection_state": c.get("selection_state"), # filtered_status / filtered_weak / filtered_lost
"filter_reasons": c.get("filter_reasons", []),
})
# IMP-06 blocker-fix (Codex #10 Catch O schema + Codex #16 coverage invariant) —
# surface override-uncovered sections as additive list entries in
# `filtered_section_reasons` and ensure `filtered_section_ids` includes them
# so coverage does not silently miss sections that were dropped by an explicit
# zone-section override.
v4_fb_summary = comp_debug.get("v4_fallback_summary", {}) or {}
section_assignment_summary = comp_debug.get("section_assignment_summary") or {}
section_assignment_uncovered_ids: list[str] = list(
section_assignment_summary.get("uncovered_section_ids") or []
)
if section_assignment_uncovered_ids:
# Codex #16 invariant : final filtered_section_ids must contain the
# override-uncovered ids even if they were originally "covered" by the
# pre-override auto plan. full_coverage must be re-evaluated too so
# Step 20 `overall` enum reflects the post-override reality.
for sid in section_assignment_uncovered_ids:
if sid not in filtered_ids:
filtered_ids.append(sid)
filtered_ids = sorted(set(filtered_ids))
full_coverage = len(filtered_ids) == 0
# Append a separate list entry per override-uncovered section so existing
# readers of filtered_section_reasons (list-shaped) keep working.
plan_by_position = {
(p.get("position") or ""): p
for p in (comp_debug.get("section_assignment_plan") or [])
}
for sid in section_assignment_uncovered_ids:
# Find the position whose plan entry recorded this uncovered id.
source_position = None
for pos, entry in plan_by_position.items():
if sid in (entry.get("uncovered_section_ids") or []):
source_position = pos
break
filtered_section_reasons.append({
"section_ids": [sid],
"merge_type": None,
"template_id": None,
"v4_label": None,
"phase_z_status": None,
"score": None,
"selection_state": "section_assignment_override_uncovered",
"filter_reasons": ["section_assignment_override_uncovered"],
"source": "section_assignment_override",
"position": source_position,
})
# IMP-87 u2 — EMPTY_SHELL_NO_CONTENT precedence over the legacy 4-way
# ladder (Stage 2 axis A3). If the slide has aligned MDX sections but
# every selected unit is an IMP-30 u4 empty-shell placeholder (zero
# content units selected, at least one empty-shell unit selected), the
# visible artifact carries no real content — ``overall`` MUST report
# EMPTY_SHELL_NO_CONTENT so the u3 CLI exit / final_status.html styling
# can branch on it. The check runs before the visual/coverage ladder
# because a content-empty placeholder slide can technically pass Selenium
# overflow checks (no content → no overflow); without this precedence the
# ladder would mislabel it PASS (Stage 1 mdx05 Case B honesty defect).
is_empty_shell_only = (
len(aligned_ids) > 0
and len(empty_shell_units_list) > 0
and len(content_units_list) == 0
)
if is_empty_shell_only:
overall = "EMPTY_SHELL_NO_CONTENT"
elif full_coverage and visual_passed:
overall = "PASS"
elif full_coverage and not visual_passed:
overall = "RENDERED_WITH_VISUAL_REGRESSION"
elif not full_coverage and visual_passed:
overall = "PARTIAL_COVERAGE"
else:
overall = "PARTIAL_COVERAGE_WITH_VISUAL_REGRESSION"
# IMP-05 L3 (Codex #10 D4 / #17 idea F / Claude #21 idea J) — Step 20 qualifier fields.
# Additive only — top-level overall enum unchanged. Defensive defaults so non-fallback
# paths (empty v4_fallback_summary) do not crash and report 0 / [] cleanly.
_v4_fb_summary = comp_debug.get("v4_fallback_summary", {}) or {}
_fallback_selection_count = _v4_fb_summary.get("fallback_selection_count", 0)
_selection_paths = _v4_fb_summary.get("selection_paths", [])
# IMP-30 u6 — Step 20 additive qualifier fields for the first-render invariant.
# provisional_first_render_count = number of selected units whose .provisional
# flag is True (set by u1 V4Match synthesis → u2 CompositionUnit propagation,
# u3 last-resort fill, or u4 empty-shell synthesis). The list mirrors the shape
# of fallback_selections / adapter_needed_units for symmetry. Top-level overall
# enum stays unchanged per IMP-05 Codex #10 D4 + Stage 1 Q3 decision: this
# signal is a qualifier, not a new failure class. Defensive getattr keeps the
# function safe when units come from legacy code paths predating u2.
provisional_first_render_units: list[dict] = []
for u in units:
if not getattr(u, "provisional", False):
continue
provisional_first_render_units.append({
"source_section_ids": list(getattr(u, "source_section_ids", []) or []),
"phase_z_status": getattr(u, "phase_z_status", None),
"frame_template_id": getattr(u, "frame_template_id", None),
"frame_id": getattr(u, "frame_id", None),
"label": getattr(u, "label", None),
"selection_path": getattr(u, "selection_path", None),
"fallback_reason": getattr(u, "fallback_reason", None),
"v4_rank": getattr(u, "v4_rank", None),
})
return {
"rendered": True,
"visual_check_passed": visual_passed,
"full_mdx_coverage": full_coverage,
"aligned_section_ids": aligned_ids,
"covered_section_ids": sorted(covered),
# IMP-87 u2 — additive empty/content accounting (overall enum precedence above).
"content_rendered_section_ids": content_rendered_section_ids,
"content_rendered_unit_count": len(content_units_list),
"empty_shell_section_ids": empty_shell_section_ids,
"empty_shell_unit_count": len(empty_shell_units_list),
"filtered_section_ids": filtered_ids,
"filtered_section_reasons": filtered_section_reasons,
"selection_path": "fallback_used" if fallback_selections else "rank_1",
"fallback_used": bool(fallback_selections),
"fallback_selections": fallback_selections,
# IMP-05 L3 qualifier fields — grouped near existing fallback fields for readability.
"fallback_selection_count": _fallback_selection_count,
"selection_paths": _selection_paths,
"visual_fail_reasons": list(overflow.get("fail_reasons") or []),
"adapter_needed_count": len(adapter_needed_units),
"adapter_needed_units": adapter_needed_units,
"content_truncated_count": len(content_truncated),
"content_truncated_units": content_truncated,
# IMP-30 u6 — additive provisional qualifiers (overall enum unchanged).
"provisional_first_render_count": len(provisional_first_render_units),
"provisional_first_render_units": provisional_first_render_units,
"overall": overall,
"note": (
"자동 파이프라인 결과 보고. review/UI 개념 X. final.html 파일명 != PASS 의미. "
"overall == PASS 는 visual OK + full coverage + adapter_needed=0 일 때만. "
"adapter_needed_count > 0 = mapper 가 contract 와 안 맞아 자동 렌더 못 한 zone 존재. "
"content_truncated_count > 0 = builder 가 truncate 한 zone 존재 (rendered 됐지만 일부 콘텐츠 손실). "
"provisional_first_render_count > 0 = IMP-30 first-render invariant 가 작동한 unit 존재 "
"(empty_shell / chain_exhausted_provisional / 등 — needs user/AI adaptation)."
),
}
# ─── Per-step artifact write (locked schema) ────────────────────
# 모든 step JSON 공통 필드: step_num, step_name, step_status,
# pipeline_path_connected, input, output, note, data
# 사용자 lock — 한 슬라이드 결과물 고치지 말고 시스템 layer 박기 (오답노트 #2)
def _write_step_artifact(
run_dir: Path,
step_num: int,
name: str,
data,
*,
step_status: str = "done",
pipeline_path_connected: bool = True,
inputs: Optional[list[str]] = None,
outputs: Optional[list[str]] = None,
note: Optional[str] = None,
) -> Path:
"""Write per-step JSON artifact to {run_dir}/steps/step{NN}_{name}.json.
Locked schema (사용자 직설):
step_num, step_name, step_status, pipeline_path_connected,
input, output, note, data.
Status values: 'done' / 'partial' / 'trace-only' / 'future' / 'failed'.
"""
steps_dir = run_dir / "steps"
steps_dir.mkdir(exist_ok=True)
fname = f"step{step_num:02d}_{name}.json"
fpath = steps_dir / fname
payload = {
"step_num": step_num,
"step_name": name,
"step_status": step_status,
"pipeline_path_connected": pipeline_path_connected,
"input": inputs or [],
"output": outputs or [fname],
"note": note,
"data": data,
}
fpath.write_text(
json.dumps(payload, ensure_ascii=False, indent=2, default=str),
encoding="utf-8",
)
return fpath
# IMP-43 (#72) u3 — Step 6 reuse snapshot sidecar writer.
#
# Scope (u3 only — Stage 2 unit split):
# * Writes ``run_dir/_reuse_snapshot.json`` *after* the Step 6 artifact.
# * JSON-only (per Stage 2 guardrail — pickle forbidden); schema +
# ``build_snapshot`` live in u2 (``src.phase_z2_reuse_snapshot``).
# * Write failure WARNS and CONTINUES — the snapshot is an OPTIONAL
# sidecar; absence means future ``--reuse-from`` (u4) will fail
# closed when it cannot find / load the file. The main pipeline
# run must not abort on snapshot write failure.
# * Returns the run_dir-relative path (``"_reuse_snapshot.json"``) on
# success, ``None`` on failure. The caller stamps the returned value
# (or the constant when known ahead of time) into the Step 6 artifact.
def _write_reuse_snapshot(
run_dir: Path,
*,
mdx_source_text: str,
slide_title: Optional[str],
slide_footer: Optional[str],
sections: list,
stage0_adapter_diagnostics: Optional[dict],
stage0_normalized_assets: Optional[dict],
v4_evidence: list,
layout_preset_pre_override: Optional[str],
units: list,
comp_debug: Optional[dict],
v4_fallback_traces: Optional[dict],
ai_preflight: Optional[dict],
) -> Optional[str]:
try:
mdx_sha256 = hashlib.sha256(mdx_source_text.encode("utf-8")).hexdigest()
snapshot = build_snapshot(
mdx_sha256=mdx_sha256,
slide_title=slide_title,
slide_footer=slide_footer,
sections=sections,
stage0_adapter_diagnostics=stage0_adapter_diagnostics,
stage0_normalized_assets=stage0_normalized_assets,
v4_evidence=v4_evidence,
layout_preset_pre_override=layout_preset_pre_override,
units=units,
comp_debug=comp_debug,
v4_fallback_traces=v4_fallback_traces,
ai_preflight=ai_preflight,
)
fpath = run_dir / SNAPSHOT_FILENAME
fpath.write_text(
json.dumps(snapshot, ensure_ascii=False, indent=2),
encoding="utf-8",
)
return SNAPSHOT_FILENAME
except Exception as exc:
print(
f" [reuse-snapshot] WARN — failed to write {SNAPSHOT_FILENAME} "
f"(reason={type(exc).__name__}: {exc}); --reuse-from will not be "
f"available from this run.",
file=sys.stderr,
)
return None
# IMP-43 (#72) u4 — --reuse-from copy + restore + entry helpers.
#
# Scope (u4 only — Stage 2 unit split):
# * Pure path resolution / file copy / snapshot load+validate /
# section + unit rehydration / marker writing.
# * NO edits to ``run_phase_z2_mvp1`` body — the kwarg threading and
# the entry-point branch that invokes these helpers land in u5.
# * NO sys.exit(2) translation — helpers RAISE
# (FileNotFoundError / SnapshotValidationError / OSError); u4b adds
# the stderr + exit-code-2 wrapper, the prev_run_dir == new_run_dir
# accidental-write guard, and the mdx_sha256 mismatch surface
# fingerprint.
#
# Restore contract (Stage 2 boundary): Step 0/1/2/5/6 artifacts +
# ``_reuse_snapshot.json``. Step numbers 3 / 4 are deliberately absent
# — the pipeline DOES write ``step03_content_objects.json`` and
# ``step04_internal_composition.json`` AFTER the Step 6 artifact and
# BEFORE the Step 7 artifact (see ``_write_step_artifact`` call sites
# for ``run_dir, 3`` and ``run_dir, 4`` above the ``run_dir, 7`` call
# in this file), but both are emitted with
# ``step_status="trace-only"`` and ``pipeline_path_connected=False``:
# they are diagnostic projections derived from the Step 6
# ``debug_zones`` snapshot, not deterministic inputs that Step 7+
# consume. Restoring them is unnecessary because downstream code
# reads ``debug_zones`` directly (rehydrated from the snapshot), and
# copying trace-only files would muddle the boundary audit. Stage 2
# boundary lock = pipeline-path-connected pre-Step 7 artifacts only.
_REUSE_STEP_ARTIFACTS: tuple[str, ...] = (
"step00_preconditions.json",
"step01_mdx_upload.json",
"step01_mdx_source.md",
"step02_normalized.json",
"step05_v4_evidence.json",
"step06_composition_plan.json",
)
REUSE_MARKER_FILENAME = "_reuse_marker.json"
def _resolve_reuse_from_prev_run_dir(reuse_from: str) -> Path:
"""Resolve ``--reuse-from PREV_RUN_ID`` to its ``phase_z2`` run_dir.
Pure path computation — does NOT check existence. u4b adds the
fail-closed prev-run-missing translation around this helper.
"""
return RUNS_DIR / reuse_from / "phase_z2"
def _copy_reuse_artifacts_from_prev_run(
prev_run_dir: Path, new_run_dir: Path
) -> dict[str, str]:
"""Copy Step 0/1/2/5/6 artifacts + ``_reuse_snapshot.json`` into new_run_dir.
Returns ``{artifact_name: new_run_dir-relative_path}`` for all
copied files. Raises ``FileNotFoundError`` when any required
artifact is missing in ``prev_run_dir`` (u4b translates to exit 2).
"""
new_steps = new_run_dir / "steps"
new_steps.mkdir(parents=True, exist_ok=True)
copied: dict[str, str] = {}
for fname in _REUSE_STEP_ARTIFACTS:
src = prev_run_dir / "steps" / fname
if not src.exists():
raise FileNotFoundError(
f"reuse artifact missing in prev_run_dir: steps/{fname} "
f"(expected at {src})"
)
shutil.copyfile(src, new_steps / fname)
copied[fname] = f"steps/{fname}"
snap_src = prev_run_dir / SNAPSHOT_FILENAME
if not snap_src.exists():
raise FileNotFoundError(
f"reuse snapshot missing in prev_run_dir: {SNAPSHOT_FILENAME} "
f"(expected at {snap_src})"
)
shutil.copyfile(snap_src, new_run_dir / SNAPSHOT_FILENAME)
copied[SNAPSHOT_FILENAME] = SNAPSHOT_FILENAME
return copied
def _load_and_validate_reuse_snapshot(
new_run_dir: Path, *, mdx_source_text: str
) -> dict:
"""Load + validate the reuse snapshot already copied into ``new_run_dir``.
Computes the expected ``mdx_sha256`` from ``mdx_source_text`` UTF-8
bytes — same derivation as ``_write_reuse_snapshot`` so the
integrity check is symmetric. Delegates structural validation to
u2's ``validate_snapshot``; that raises
``SnapshotValidationError`` (subclass of ``ValueError``) on
schema_version mismatch, mdx_sha256 mismatch, missing required
keys, or malformed wrappers — u4b catches and translates.
"""
from src.phase_z2_reuse_snapshot import validate_snapshot
snap_path = new_run_dir / SNAPSHOT_FILENAME
snapshot = json.loads(snap_path.read_text(encoding="utf-8"))
expected_sha = hashlib.sha256(mdx_source_text.encode("utf-8")).hexdigest()
validate_snapshot(snapshot, expected_mdx_sha256=expected_sha)
return snapshot
@dataclass
class _RehydratedV4Candidate:
"""V4Match-shape duck type restored from snapshot ``v4_candidates``.
Exposes the 6-attribute contract that the reuse path's downstream
consumers read off ``unit.v4_candidates`` entries:
* template_id / frame_id / frame_number / confidence / label —
read by ``_apply_frame_override_to_unit`` (frame swap).
* v4_rank — read by ``_build_application_plan_unit`` (Step 9
payload, ``data.application_plan.zones[i].v4_candidates[j]``).
Default ``None`` keeps the dataclass safe to construct from
legacy snapshots that pre-date the u4 fix where the snapshot
serializer did not persist per-candidate rank.
Kept local — circular-dep-free; the production ``V4Match`` dataclass
additionally carries section_id / selection_path / fallback_reason /
provisional that the reuse boundary deliberately does not require.
"""
template_id: str
frame_id: str
frame_number: int
confidence: float
label: str
v4_rank: Optional[int] = None
def _rehydrate_mdx_sections_from_snapshot(snapshot: dict) -> list:
"""Rebuild ``list[MdxSection]`` from snapshot ``sections`` wrapper.
Mirrors the ``serialize_section`` field list (u2 source of truth).
Returns a Python list of ``MdxSection`` dataclass instances so the
Step 7+ pipeline code that does ``[s.section_id for s in sections]``
keeps byte-for-byte behavior.
"""
entries = snapshot["sections"]["value"]
return [
MdxSection(
section_id=e["section_id"],
section_num=e["section_num"],
title=e["title"],
raw_content=e["raw_content"],
heading_number=e.get("heading_number"),
v4_alias_keys=list(e.get("v4_alias_keys") or []),
sub_sections=list(e.get("sub_sections") or []),
)
for e in entries
]
def _rehydrate_composition_units_from_snapshot(snapshot: dict) -> list:
"""Rebuild ``list[CompositionUnit]`` from snapshot ``units`` wrapper.
``v4_candidates`` entries are restored as ``_RehydratedV4Candidate``
instances so attribute access (``cand.template_id`` etc.) works
end-to-end through ``_apply_frame_override_to_unit`` without
serializing the production ``V4Match`` dataclass shape.
Uses the ``src.phase_z2_composition`` import path to match
line 4976 / 5125's local re-imports — the module is loaded under
both ``phase_z2_composition`` and ``src.phase_z2_composition`` due
to historical sys.path duality, so a top-level CompositionUnit
reference would create a class-identity mismatch against tests and
downstream code that imports via the ``src.`` path.
"""
from src.phase_z2_composition import CompositionUnit as _CompositionUnit
entries = snapshot["units"]["value"]
units: list = []
for e in entries:
cands = [
_RehydratedV4Candidate(
template_id=c["template_id"],
frame_id=c["frame_id"],
frame_number=int(c["frame_number"]),
confidence=float(c["confidence"]),
label=c["label"],
v4_rank=(
int(c["v4_rank"])
if c.get("v4_rank") is not None
else None
),
)
for c in (e.get("v4_candidates") or [])
]
units.append(_CompositionUnit(
source_section_ids=list(e["source_section_ids"]),
merge_type=e["merge_type"],
frame_template_id=e["frame_template_id"],
frame_id=e["frame_id"],
frame_number=int(e["frame_number"]),
confidence=float(e["confidence"]),
label=e["label"],
phase_z_status=e["phase_z_status"],
raw_content=e["raw_content"],
title=e["title"],
v4_rank=e.get("v4_rank"),
selection_path=e.get("selection_path") or "rank_1",
fallback_reason=e.get("fallback_reason"),
score=float(e.get("score") or 0.0),
rationale=dict(e.get("rationale") or {}),
auto_selectable=bool(e.get("auto_selectable", True)),
filter_reasons=list(e.get("filter_reasons") or []),
notes=list(e.get("notes") or []),
v4_candidates=cands,
provisional=bool(e.get("provisional", False)),
))
return units
REUSE_MARKER_SCHEMA_VERSION = 1
def _write_reuse_marker(
new_run_dir: Path,
*,
prev_run_id: str,
copied_artifacts: dict[str, str],
) -> Path:
"""Write ``_reuse_marker.json`` to ``new_run_dir`` for audit trail.
Records prev_run_id, copied artifact map, the locked Step 0/1/2/5/6
boundary, and ``resume_at_step=7``. Informational sidecar — absence
does not break the reused run; presence lets operators trace which
prev_run_id the reuse path was sourced from. u5 invokes this after
a successful copy + restore.
"""
marker = {
"schema_version": REUSE_MARKER_SCHEMA_VERSION,
"reuse_from_prev_run_id": prev_run_id,
"snapshot_filename": SNAPSHOT_FILENAME,
"copied_artifacts": dict(copied_artifacts),
"boundary_steps": list(_REUSE_STEP_ARTIFACTS),
"resume_at_step": 7,
"note": (
"IMP-43 (#72) u4 — this run was sourced from prev_run_id via "
"--reuse-from. Steps 0/1/2/5/6 artifacts copied; Step 7+ "
"re-executed in this run_dir."
),
}
fpath = new_run_dir / REUSE_MARKER_FILENAME
fpath.write_text(
json.dumps(marker, ensure_ascii=False, indent=2),
encoding="utf-8",
)
return fpath
# IMP-43 (#72) u4b — fail-closed wrapper around the u4 helpers.
#
# Scope (u4b only — Stage 2 unit split):
# * Translate the u4 raises (FileNotFoundError, SnapshotValidationError,
# json.JSONDecodeError, OSError) into the CLI fail-closed contract:
# stderr message + ``sys.exit(2)``.
# * Add the prev_run_dir == new_run_dir accidental-write guard BEFORE
# any copy attempt — otherwise ``_copy_reuse_artifacts_from_prev_run``
# would overwrite prev_run_dir's own step files with itself and
# mutate the "read-only" reuse source.
# * Add the missing-prev-run-dir surface so the user gets a clean
# "run id not found" message instead of the raw FileNotFoundError
# stack from inside _copy_reuse_artifacts_from_prev_run.
# * Surface the mdx_sha256 mismatch as its OWN axis (distinct from
# generic snapshot validation failures) so the operator can tell
# "wrong --mdx-path for this prev_run_id" apart from "snapshot file
# is broken".
#
# Out of scope: signature threading into ``run_phase_z2_mvp1`` (u5),
# the actual call site dispatch into Step 7+ (u5).
#
# Diagnostic format (factual-verification guardrail):
# [error] --reuse-from fail-closed: <axis>
# value: <repr>
# path: <fs path / resource locator>
# upstream: <where the value originated>
# reason: <type>: <message> (only when exc != None)
#
# axis vocabulary (closed enum — tests pin this set):
# * prev_run_dir_missing
# * prev_run_dir_equals_new_run_dir
# * reuse_artifact_missing
# * reuse_copy_os_error # OSError != FileNotFoundError during copy
# # (PermissionError, IsADirectoryError,
# # OSError(errno.EXDEV), full-disk, etc.)
# * snapshot_missing_after_copy
# * snapshot_corrupt_json
# * snapshot_read_os_error # OSError != FileNotFoundError during
# # snapshot read (permission denied,
# # path-became-dir, lower-level IO)
# * mdx_sha256_mismatch
# * snapshot_validation_failed
REUSE_FAIL_CLOSED_AXES: frozenset[str] = frozenset({
"prev_run_dir_missing",
"prev_run_dir_equals_new_run_dir",
"reuse_artifact_missing",
"reuse_copy_os_error",
"snapshot_missing_after_copy",
"snapshot_corrupt_json",
"snapshot_read_os_error",
"mdx_sha256_mismatch",
"snapshot_validation_failed",
})
def _abort_reuse_from(
*,
axis: str,
value: Any,
path: str,
upstream: str,
exc: Optional[BaseException] = None,
) -> "NoReturn":
"""Print provenance-tagged stderr message and ``sys.exit(2)``.
All four `value+path+upstream+axis` fields are mandatory so the
operator can pinpoint the failed precondition without grepping the
pipeline source. ``exc`` (when supplied) adds the underlying type
+ message — useful for ``json.JSONDecodeError`` line/col info or
OSError errno.
"""
if axis not in REUSE_FAIL_CLOSED_AXES:
raise AssertionError(
f"_abort_reuse_from: unknown axis {axis!r} "
f"(expected one of {sorted(REUSE_FAIL_CLOSED_AXES)})"
)
lines = [
f"[error] --reuse-from fail-closed: {axis}",
f" value: {value!r}",
f" path: {path}",
f" upstream: {upstream}",
]
if exc is not None:
lines.append(f" reason: {type(exc).__name__}: {exc}")
print("\n".join(lines), file=sys.stderr)
sys.exit(2)
def _paths_equivalent(a: Path, b: Path) -> bool:
"""Return True when ``a`` and ``b`` resolve to the same filesystem
location, falling back to lexical equality when ``a`` doesn't
exist yet (Path.resolve(strict=False) still normalizes case + sep
on Windows + collapses ``..`` segments).
"""
try:
return a.resolve(strict=False) == b.resolve(strict=False)
except (OSError, RuntimeError):
return a == b
def execute_reuse_from_or_fail_closed(
*,
reuse_from: str,
new_run_dir: Path,
mdx_source_text: str,
) -> tuple[Path, dict[str, str], dict]:
"""Orchestrate u4 helpers under the u4b fail-closed contract.
Returns ``(prev_run_dir, copied_artifacts, snapshot)`` on success.
Calls ``sys.exit(2)`` on any of the seven fail-closed axes; does
NOT return in that case.
The caller (u5, into ``run_phase_z2_mvp1``) does NOT need to wrap
this in its own try/except — every reachable failure inside this
function terminates the process directly.
"""
from src.phase_z2_reuse_snapshot import SnapshotValidationError
prev_run_dir = _resolve_reuse_from_prev_run_dir(reuse_from)
# Guard 1: prev_run_dir must exist.
if not prev_run_dir.exists():
_abort_reuse_from(
axis="prev_run_dir_missing",
value=reuse_from,
path=str(prev_run_dir),
upstream="--reuse-from CLI argument",
)
# Guard 2: prev_run_dir must NOT be the same as new_run_dir.
# Without this, the copy step would overwrite prev_run_dir's own
# files with themselves and break the RO guarantee on the reuse
# source. The check resolves both sides so a relative-vs-absolute
# or symlinked collision still trips it.
if _paths_equivalent(prev_run_dir, new_run_dir):
_abort_reuse_from(
axis="prev_run_dir_equals_new_run_dir",
value=reuse_from,
path=str(prev_run_dir),
upstream=(
"_resolve_reuse_from_prev_run_dir(reuse_from) == new_run_dir "
"(would overwrite prev_run_dir during copy)"
),
)
# Copy step 0/1/2/5/6 + snapshot from prev_run_dir → new_run_dir.
# FileNotFoundError MUST be caught before the bare OSError handler —
# it is a subclass of OSError and the missing-artifact case has its
# own dedicated axis.
try:
copied = _copy_reuse_artifacts_from_prev_run(prev_run_dir, new_run_dir)
except FileNotFoundError as exc:
_abort_reuse_from(
axis="reuse_artifact_missing",
value=str(exc),
path=str(prev_run_dir),
upstream=(
"Step 0/1/2/5/6 deterministic artifacts + "
f"{SNAPSHOT_FILENAME} under prev_run_dir/steps/"
),
exc=exc,
)
except OSError as exc:
# PermissionError, IsADirectoryError, OSError(errno.EXDEV) when
# crossing filesystems with shutil.copyfile, disk-full, etc.
# Without this branch the raw traceback would escape the wrapper
# and contradict the docstring contract ("every reachable
# failure inside this function terminates the process directly").
_abort_reuse_from(
axis="reuse_copy_os_error",
value=str(exc),
path=str(prev_run_dir),
upstream=(
"_copy_reuse_artifacts_from_prev_run "
"(OSError != FileNotFoundError; shutil.copyfile or "
"Path.mkdir surface)"
),
exc=exc,
)
# Load + validate snapshot. Exception fan-out below mirrors the
# u4 helper raise surface; each fail-closed axis is reported
# separately so operators can tell the cases apart.
# FileNotFoundError MUST be caught before the bare OSError handler.
try:
snapshot = _load_and_validate_reuse_snapshot(
new_run_dir, mdx_source_text=mdx_source_text,
)
except FileNotFoundError as exc:
# Should not happen — copy step would have failed first — but
# left explicit to make the contract symmetric.
_abort_reuse_from(
axis="snapshot_missing_after_copy",
value=str(exc),
path=str(new_run_dir / SNAPSHOT_FILENAME),
upstream="_copy_reuse_artifacts_from_prev_run side effect",
exc=exc,
)
except json.JSONDecodeError as exc:
_abort_reuse_from(
axis="snapshot_corrupt_json",
value=str(exc),
path=str(new_run_dir / SNAPSHOT_FILENAME),
upstream=f"json.loads({SNAPSHOT_FILENAME})",
exc=exc,
)
except OSError as exc:
# Permission denied on the copied snapshot, snap_path turned out
# to be a directory, lower-level IO error. JSONDecodeError is
# ValueError (independent of OSError) so order with that branch
# does not matter; this branch only needs to follow FNF.
_abort_reuse_from(
axis="snapshot_read_os_error",
value=str(exc),
path=str(new_run_dir / SNAPSHOT_FILENAME),
upstream=(
"_load_and_validate_reuse_snapshot "
"(OSError != FileNotFoundError; Path.read_text surface)"
),
exc=exc,
)
except SnapshotValidationError as exc:
msg = str(exc)
if "mdx_sha256 mismatch" in msg:
_abort_reuse_from(
axis="mdx_sha256_mismatch",
value=msg,
path=str(new_run_dir / SNAPSHOT_FILENAME),
upstream=(
"sha256(mdx_source_text) vs "
f"{SNAPSHOT_FILENAME}#/mdx_sha256"
),
exc=exc,
)
else:
_abort_reuse_from(
axis="snapshot_validation_failed",
value=msg,
path=str(new_run_dir / SNAPSHOT_FILENAME),
upstream="src.phase_z2_reuse_snapshot.validate_snapshot",
exc=exc,
)
return prev_run_dir, copied, snapshot
def _write_step_html(
run_dir: Path,
step_num: int,
name: str,
title: str,
body_html: str,
*,
step_status: str = "done",
inputs: Optional[list[str]] = None,
outputs: Optional[list[str]] = None,
) -> Path:
"""Write per-step HTML artifact with locked header (input/output/status).
HTML 산출물 = 사용자가 시각으로 판단해야 하는 step (7/8/9/13/20).
"""
steps_dir = run_dir / "steps"
steps_dir.mkdir(exist_ok=True)
fname = f"step{step_num:02d}_{name}.html"
fpath = steps_dir / fname
inputs_lines = "\n".join(f" - {i}" for i in (inputs or []))
outputs_lines = "\n".join(f" - {o}" for o in (outputs or [fname]))
status_class = (
"pass" if step_status == "done"
else "fail" if step_status == "failed"
else "partial"
)
inputs_li = "".join(f"<li><code>{i}</code></li>" for i in (inputs or []))
outputs_li = "".join(f"<li><code>{o}</code></li>" for o in (outputs or [fname]))
full_html = f"""<!--
Step {step_num:02d}: {title}
Status: {step_status}
Input:
{inputs_lines}
Output:
{outputs_lines}
-->
<!DOCTYPE html>
<html lang="ko"><head>
<meta charset="UTF-8">
<title>Step {step_num:02d}{title}</title>
<style>
body {{ font-family: 'Pretendard', 'Noto Sans KR', sans-serif; padding: 24px; max-width: 1280px; margin: 0 auto; color: #1e293b; }}
h1 {{ border-bottom: 2px solid #333; padding-bottom: 8px; margin-top: 0; }}
h2 {{ margin-top: 32px; }}
.meta {{ background: #f8fafc; padding: 16px; border-left: 4px solid #2563eb; margin-bottom: 24px; }}
.meta ul {{ margin: 4px 0; padding-left: 20px; }}
.meta li {{ margin-bottom: 2px; }}
.pass {{ color: #16a34a; font-weight: 700; }}
.fail {{ color: #dc2626; font-weight: 700; }}
.partial {{ color: #ca8a04; font-weight: 700; }}
table {{ border-collapse: collapse; width: 100%; margin-top: 12px; }}
th, td {{ border: 1px solid #cbd5e1; padding: 8px 12px; text-align: left; vertical-align: top; }}
th {{ background: #f1f5f9; }}
code {{ background: #f1f5f9; padding: 2px 6px; border-radius: 3px; font-size: 0.9em; }}
.grid-preview {{ border: 2px solid #cbd5e1; padding: 8px; margin-top: 12px; }}
.zone-box {{ background: #e2e8f0; border: 1px solid #94a3b8; padding: 12px; margin: 4px 0; border-radius: 4px; }}
.candidate-card {{ border: 1px solid #cbd5e1; padding: 12px; margin: 8px 0; border-radius: 4px; }}
.candidate-rank1 {{ border-left: 4px solid #16a34a; background: #f0fdf4; }}
</style>
</head><body>
<h1>Step {step_num:02d}{title}</h1>
<div class="meta">
<strong>Status:</strong> <span class="{status_class}">{step_status}</span><br>
<strong>Input:</strong>
<ul>{inputs_li}</ul>
<strong>Output:</strong>
<ul>{outputs_li}</ul>
</div>
{body_html}
</body>
</html>"""
fpath.write_text(full_html, encoding="utf-8")
return fpath
def write_debug_json(run_dir: Path, layout_preset: str,
debug_zones: list[dict],
layout_css: dict,
visual_runtime_check: Optional[dict] = None,
composition_debug: Optional[dict] = None,
slide_status: Optional[dict] = None,
fit_classification: Optional[dict] = None,
router_decision: Optional[dict] = None,
retry_trace: Optional[dict] = None) -> Path:
debug = {
"v4_source": str(V4_RESULT_PATH.relative_to(PROJECT_ROOT)),
"v4_label_to_phase_z_status": V4_LABEL_TO_PHASE_Z_STATUS,
"mvp1_allowed_statuses": sorted(MVP1_ALLOWED_STATUSES),
"mode": "composition_v0_layout_8preset",
"mode_note": (
"MVP-1.5b w/ composition planner v0 — sections → candidates (separate / "
"parent_merged) → score → greedy select → 8-preset layout vocabulary "
"(single / horizontal-2 / vertical-2 / top-1-bottom-2 / top-2-bottom-1 / "
"left-1-right-2 / left-2-right-1 / grid-2x2). v0 layout = count-based; "
"v1 axes (cardinality_fit / hierarchy_coherence / density_score) 추후."
),
"layout_preset": layout_preset,
"layout_css": layout_css,
"slide_status": slide_status,
"fit_classification": fit_classification,
"router_decision": router_decision,
"retry_trace": retry_trace,
"composition_planner_debug": composition_debug,
"zones": debug_zones,
"visual_runtime_check": visual_runtime_check,
# A-6 (IMP-01 #1) — additive top-level zone bbox trace (slide-relative px)
"zone_geometries_px": (visual_runtime_check or {}).get("zone_geometries_px", []),
# IMP-15 실행-4 (issue #48) — additive top-level Step 14 event streams
"image_events": (visual_runtime_check or {}).get("image_events", []),
"table_events": (visual_runtime_check or {}).get("table_events", []),
}
debug_path = run_dir / "debug.json"
debug_path.write_text(json.dumps(debug, ensure_ascii=False, indent=2), encoding="utf-8")
return debug_path
# ─── Step 9 application-plan helpers (IMP-32 u1) ───────────────
def _application_candidates_for_unit(unit) -> list[dict]:
"""Step 9 (IMP-32 u1) — application candidate dicts from unit.v4_candidates.
Pure extraction of inline block at src/phase_z2_pipeline.py:4487-4501.
Behavior preserved: key set/order, APPLICATION_MODE_BY_V4_LABEL lookup,
required_changes placeholder = [] (v0 = trace-only).
"""
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,
})
return app_candidates
def _v4_all_judgments_for_unit(v4_all_for_unit) -> list[dict]:
"""Step 9 (IMP-32 u2) — V4 all-judgment dicts (reject 포함) for a unit.
Pure extraction of inline block at src/phase_z2_pipeline.py:4529-4545
(post-u1 line numbers). IMP-11 D-2 markers preserved in this helper:
single `_contract = get_contract(c.template_id)` bind, `catalog_registered`
boolean, and `min_height_px` chain `(_contract or {}).get("visual_hints", {}).get("min_height_px")`.
Key set/order unchanged: template_id, frame_id, frame_number, v4_rank,
confidence, label, catalog_registered, min_height_px.
"""
# 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"),
})
return v4_all_judgments_list
def _build_application_plan_unit(
unit,
zone_plan,
selection_trace,
plan_record,
v4_all_for_unit,
layout_preset,
layout_candidates_list,
) -> dict:
"""Step 9 (IMP-32 u3) — per-unit application_plan dict assembly.
Pure extraction of the inline `application_plan_units.append({...})` block
currently at src/phase_z2_pipeline.py:4577-4623 (post-u1/u2 line numbers).
Byte-identical output (key set + key order + value identity) when called
with the same per-unit inputs:
- unit : Step 6 unit (source_section_ids, v4_candidates,
v4_rank, selection_path, fallback_reason,
frame_template_id).
- zone_plan : Step 8 per-unit zone_plan dict (region_layout_
candidates, display_strategy_candidates).
- selection_trace : v4_fallback_traces[unit.source_section_ids[0]]
(candidates list for candidate_evidence /
fallback_chain compat alias).
- plan_record : plan_record_by_unit_id[id(unit)] or None
(IMP-06 plan-aware additive fields).
- v4_all_for_unit : lookup_v4_all_judgments(...) result (Step 7-A
axis trace — reject 포함 모든 V4 후보).
- layout_preset : Step 7 preset name (e.g., "Type A").
- layout_candidates_list : Step 7 candidate list.
Per-index/per-id lookups (zone_region_plans[i], v4_fallback_traces.get(...),
plan_record_by_unit_id.get(id(unit)), section_alias_by_id, lookup_v4_all_
judgments(...)) stay at the call-site (u4).
Invariants preserved:
- candidate_evidence = selection_trace.get("candidates", []) — primary field.
- fallback_chain = same list — compat alias for pre-IMP-05 readers.
- v4_candidates list comprehension fields + order unchanged.
- IMP-06 additive plan fields (position / assignment_source / section_
assignment_override / replaced_auto_unit / skipped_collided_auto_units /
skipped_reason) — None / False / [] when no override CLI used.
IMP-39 u3 (issue #68) additive fields :
- ``ranking_sort_policy`` : full policy dict from
``load_ranking_sort_policy()`` (cached). Forwards the single-source
ordering contract (label_priority map + tie_break_axes) to the Step 9
payload so the frontend (``designAgentApi.ts``) can mirror the backend
sort without re-implementing the policy locally. u4 wires consumption.
- ``sorted_candidate_evidence`` : explicit alias of the policy-sorted
``selection_trace["candidates"]`` list. Identical contents to
``candidate_evidence`` (u2 sorted the underlying ``judgments`` window
before the selector loop appended ``trace["candidates"]``), but the
explicit name documents the post-u2 contract for the frontend.
"""
unit_id = "+".join(unit.source_section_ids)
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
# IMP-39 u3 (issue #68) — forward the single-source ranking policy to the
# Step 9 per-unit payload. ``load_ranking_sort_policy()`` is module-cached
# (``_RANKING_SORT_POLICY_CACHE``), so the per-unit call is O(1) after
# first invocation. The full policy dict (not just ``policy_type``) is
# forwarded so the frontend can mirror label_priority + tie_break_axes
# without re-declaring the contract locally.
ranking_sort_policy = load_ranking_sort_policy()
# 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_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
app_candidates = _application_candidates_for_unit(unit)
v4_all_judgments_list = _v4_all_judgments_for_unit(v4_all_for_unit)
return {
"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,
# IMP-39 u3 (issue #68) — single-source ranking policy forwarded to
# frontend so backend selector "rank 1" and frontend
# ``frame_candidates[0]`` share one ordering contract. Additive only;
# pre-u3 readers ignore both keys.
"ranking_sort_policy": ranking_sort_policy,
"sorted_candidate_evidence": selection_trace.get("candidates", []),
}
# ─── Main entry ────────────────────────────────────────────────
class Step0PreflightError(RuntimeError):
"""IMP-92 u4 — Step 0 AI preflight fail-fast surface.
Raised at boot when ``settings.ai_fallback_enabled`` is True and the
Anthropic API ping reveals a persistent setup problem (invalid API
key, invalid model ID, billing / permission denied). Transient errors
(429 / 5xx) do NOT fail boot — they are recorded as ``"transient"``
in the Step 0 artifact and the pipeline proceeds; the in-pipeline
retry layer + u2 operational classifier handle them downstream.
"""
def _run_step0_ai_preflight() -> dict:
"""IMP-92 u4 — Boot-time AI fallback preflight ping (gated).
When ``settings.ai_fallback_enabled`` is False (default), returns
``{"status": "skipped", "reason": "ai_fallback_disabled", ...}``
without instantiating ``anthropic.Anthropic`` — preserves the PZ-1
AI=0 normal path and the ``feedback_demo_env_toggle_policy``
default-OFF contract (no API call on normal runs).
When enabled, issues a single 1-token Anthropic ``messages.create``
to validate the configured ``(ai_fallback_model, anthropic_api_key)``
pair. Persistent setup errors raise ``Step0PreflightError`` so the
pipeline fails fast at boot rather than at first AI repair attempt.
Transient errors are recorded as ``"transient"`` and the pipeline
continues.
Setup errors (fail-fast):
* ``anthropic.AuthenticationError`` (401) — invalid API key
* ``anthropic.PermissionDeniedError`` (403) — billing / permission
* ``anthropic.NotFoundError`` (404) — invalid model ID
* generic ``anthropic.APIStatusError`` (402) — billing / payment
required (Anthropic surfaces 402 without a typed subclass;
dispatched here by HTTP status code, mirroring u2
``classify_operational_error``).
Transient (record + continue):
* ``anthropic.RateLimitError`` (429)
* ``anthropic.InternalServerError`` (5xx)
* generic ``anthropic.APIStatusError`` with HTTP 429 / 5xx
"""
import anthropic
from src.config import settings as _settings
if not _settings.ai_fallback_enabled:
return {
"status": "skipped",
"reason": "ai_fallback_disabled",
"model": _settings.ai_fallback_model,
}
try:
client = anthropic.Anthropic(
api_key=_settings.anthropic_api_key,
timeout=_settings.ai_fallback_timeout_s,
)
client.messages.create(
model=_settings.ai_fallback_model,
max_tokens=1,
messages=[{"role": "user", "content": "ping"}],
)
except (
anthropic.AuthenticationError,
anthropic.PermissionDeniedError,
anthropic.NotFoundError,
) as exc:
raise Step0PreflightError(
f"Anthropic API preflight failed for model "
f"{_settings.ai_fallback_model!r}: "
f"{type(exc).__name__}: {exc}. "
"Check ANTHROPIC_API_KEY / ai_fallback_model in .env."
) from exc
except (anthropic.RateLimitError, anthropic.InternalServerError) as exc:
return {
"status": "transient",
"model": _settings.ai_fallback_model,
"transient_error": f"{type(exc).__name__}: {exc}",
}
except anthropic.APIStatusError as exc:
# IMP-92 u4 — fall back to HTTP status code dispatch when the SDK
# surfaces a setup error as the generic ``APIStatusError`` instead
# of a typed subclass. Mirrors u2 ``classify_operational_error``
# so HTTP 402 (Payment Required / billing) becomes a fail-fast
# Step0PreflightError, matching the issue body's explicit
# operational contract.
status_code = getattr(exc, "status_code", None)
if status_code is None:
status_code = getattr(getattr(exc, "response", None), "status_code", None)
if status_code == 429 or (status_code is not None and 500 <= status_code < 600):
return {
"status": "transient",
"model": _settings.ai_fallback_model,
"transient_error": f"{type(exc).__name__}: {exc}",
}
raise Step0PreflightError(
f"Anthropic API preflight failed for model "
f"{_settings.ai_fallback_model!r}: "
f"HTTP {status_code} {type(exc).__name__}: {exc}. "
"Check ANTHROPIC_API_KEY / ai_fallback_model in .env."
) from exc
return {
"status": "passed",
"model": _settings.ai_fallback_model,
}
def _resolve_slide_css_from_frontmatter(mdx_source_text: str) -> Optional[str]:
"""IMP-45 (#74) u4 — minimal frontmatter probe for ``slide_overrides.css``.
Targeted re-parse of the same YAML frontmatter block that
:func:`parse_mdx` reads at line 415-418 — extracts only the
nested ``slide_overrides.css`` string when present. Kept inline
rather than routed through :func:`src.mdx_normalizer.normalize_mdx_content`
so Step 13 render does not depend on the Stage 0 normalize adapter
(project lock 2026-05-08).
Mirrors the validation rules in
:func:`src.mdx_normalizer._extract_slide_overrides` (u2) :
- No frontmatter / unparseable YAML / non-mapping → ``None``.
- Missing ``slide_overrides`` mapping → ``None``.
- ``slide_overrides.css`` non-string or empty → ``None``.
- ``slide_overrides.css`` non-empty ``str`` → that string.
"""
fm_match = re.match(r"^---\n(.*?)\n---\n", mdx_source_text, re.DOTALL)
if fm_match is None:
return None
try:
fm = yaml.safe_load(fm_match.group(1))
except yaml.YAMLError:
return None
if not isinstance(fm, dict):
return None
overrides = fm.get("slide_overrides")
if not isinstance(overrides, dict):
return None
css = overrides.get("css")
if isinstance(css, str) and css:
return css
return None
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,
override_section_assignments: Optional[dict[str, list[str]]] = None,
override_image_overrides: Optional[dict[str, dict]] = None,
override_slide_css: Optional[str] = None,
reuse_from: Optional[str] = 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 만 변경.
override_image_overrides : {image_id: {x, y, w, h}} — IMP-51 (#79) u5 axis.
image_id = stable id stamped on user-content `<img>` tags by
``src/image_id_stamper.py`` (u4). x/y/w/h are percent-of-slide
coordinates (0100, slide-absolute). Forward-compat kwarg: the
render-time CSS injection that consumes this mapping lands in
u7; until u7 wires the consumer, accepting the kwarg keeps the
backend contract (KNOWN_AXES u1 + Vite allowlist u2 + typed
client u3 + stamper u4) end-to-end addressable from CLI without
diverging the function signature.
override_slide_css : Optional slide-level CSS string — IMP-45 (#74) u4 axis.
Marker-wrapped <style> block injected into ``final.html``
at Step 13 via :func:`src.slide_css_injector.inject_slide_css`
(mirrors the image_id_stamper injection contract). When
``None``, the MDX frontmatter ``slide_overrides.css`` value
is used as fallback via
:func:`_resolve_slide_css_from_frontmatter`. Kwarg always
wins over frontmatter when provided. The CLI surface
(``--override-slide-css`` inline + ``--slide-css-file``
PATH) lands in u5.
Incremental rerun (IMP-43 #72, u5) :
reuse_from : Optional PREV_RUN_ID. When set, Steps 0/1/2/5/6 artifacts
are copied from ``RUNS_DIR / PREV_RUN_ID / phase_z2``
and the in-memory state (sections, units, layout_preset,
comp_debug, v4_fallback_traces, slide_title/footer,
stage0_*, v4_evidence, ai_preflight) is rehydrated
from ``_reuse_snapshot.json`` via the u4 helpers,
wrapped by u4b's fail-closed contract. Step 7+ then
re-executes against ``override_frames`` in this new
run_dir. ``None`` preserves the legacy single-pass
behaviour (Steps 0-6 derive state from scratch).
The post-merge u1 guard at the CLI surface rejects
any layout / zone_geometry / zone_section / image
override under ``--reuse-from`` so only frame
overrides reach this kwarg's reuse branch.
"""
mdx_path = Path(mdx_path)
if run_id is None:
run_id = time.strftime("%Y%m%d_%H%M%S") + "_phase_z2"
run_dir = RUNS_DIR / run_id / "phase_z2"
run_dir.mkdir(parents=True, exist_ok=True) # step artifacts 위해 미리 생성
print(f"[Phase Z-2 MVP-1.5b] start — mdx={mdx_path.name}, run_id={run_id}")
# IMP-43 (#72) u5 — Steps 0/1/2/5/6 entry-point branch.
# ``reuse_from is None`` = normal pipeline (Steps 0-6 derive state).
# ``reuse_from is not None`` = restore Steps 0/1/2/5/6 state from
# prev_run snapshot via the u4 helpers wrapped by u4b's fail-closed
# contract, then fall through to the shared Step 7+ block below.
# The post-merge u1 guard has already rejected any layout /
# zone_geometry / zone_section / image override on the reuse path,
# so only --override-frame (handled at the Step 7-A axis below the
# branch) survives into this code path.
if reuse_from is None:
# ─── Step 0: 사전 준비 (precondition snapshot) ───
# IMP-92 u4 — boot-time AI fallback preflight (gated on
# settings.ai_fallback_enabled; default OFF = skipped, no API call).
# Persistent setup errors raise Step0PreflightError before Step 1.
ai_preflight = _run_step0_ai_preflight()
_write_step_artifact(
run_dir, 0, "preconditions",
data={
"v4_source": str(V4_RESULT_PATH.relative_to(PROJECT_ROOT)),
"templates_dir": str(TEMPLATE_DIR.relative_to(PROJECT_ROOT)),
"assets_source_base": str(ASSETS_SOURCE_BASE.relative_to(PROJECT_ROOT)),
"frame_contracts_loaded": len(load_frame_contracts()),
"frame_contracts_template_ids": sorted(load_frame_contracts().keys()),
"v4_label_to_phase_z_status": V4_LABEL_TO_PHASE_Z_STATUS,
"mvp1_allowed_statuses": sorted(MVP1_ALLOWED_STATUSES),
"ai_preflight": ai_preflight,
},
step_status="partial",
pipeline_path_connected=True,
inputs=[
"templates/phase_z2/catalog/frame_contracts.yaml",
"tests/matching/v4_full32_result.yaml",
"templates/phase_z2/families/*.html",
"figma_to_html_agent/blocks/",
],
outputs=["step00_preconditions.json"],
note=(
"frame_contracts.yaml 에 등록된 frame 만 mapping 가능. "
"V4 결과의 32 frame 중 다수 (F11/F14/F18 등) 미등록 — Step 0 ⚠ partial."
),
)
# ─── Step 1: MDX 업로드 ───
mdx_source_text = mdx_path.read_text(encoding="utf-8")
(run_dir / "steps").mkdir(exist_ok=True)
(run_dir / "steps" / "step01_mdx_source.md").write_text(mdx_source_text, encoding="utf-8")
_write_step_artifact(
run_dir, 1, "mdx_upload",
data={
"mdx_path": str(mdx_path),
"run_id": run_id,
"run_dir": str(run_dir),
"mdx_source_size_bytes": len(mdx_source_text.encode("utf-8")),
"mdx_source_lines": mdx_source_text.count("\n") + 1,
},
step_status="done",
pipeline_path_connected=True,
inputs=[str(mdx_path)],
outputs=["step01_mdx_upload.json", "step01_mdx_source.md"],
note="MDX 원본 그대로 step01_mdx_source.md 에 복사.",
)
# 1. Parse MDX (V4 무관)
legacy_slide_title, legacy_sections, legacy_footer = parse_mdx(mdx_path)
# IMP-02 — Stage 0 chained adapter dispatch (default OFF canary).
# When env PHASE_Z_STAGE0_ADAPTER_ENABLED=1/true/yes the adapter chain
# (mdx_normalizer + section_parser) replaces legacy parse_mdx output;
# on any contract failure or exception, falls back to legacy with
# fallback_reason recorded in stage0_adapter_diagnostics.
# IMP-03 — 5-tuple return adds stage0_normalized_assets (Step 3 handoff).
(
slide_title,
sections,
slide_footer,
stage0_adapter_diagnostics,
stage0_normalized_assets,
) = _stage0_chained_adapter(
mdx_path, legacy_slide_title, legacy_sections, legacy_footer,
)
_adapter_tag = (
"adapter-used" if stage0_adapter_diagnostics["used"]
else f"legacy({stage0_adapter_diagnostics['fallback_reason'] or 'disabled'})"
)
print(f" parsed : title='{slide_title}', sections={len(sections)} "
f"({[s.section_id for s in sections]}), footer={'yes' if slide_footer else 'no'}, "
f"stage0={_adapter_tag}")
# ─── Step 2: MDX 정규화 ───
# orphans / details 필드는 schema lock — 빈 배열이라도 박혀야
# "검사 안 함" vs "없음" 구분 가능 (사용자 직설 2026-05-07).
# 실제 orphan / details 감지 로직은 별 axis (Step 2 보강).
_write_step_artifact(
run_dir, 2, "normalized",
data={
"slide_title": slide_title,
"slide_footer": slide_footer,
"sections_count": len(sections),
"sections": [
{
"section_id": s.section_id,
"section_num": s.section_num,
"title": s.title,
"raw_content_length": len(s.raw_content),
"raw_content": s.raw_content,
}
for s in sections
],
"orphans": [], # schema lock — 중목차에 안 속한 텍스트 (감지 미구현)
"details": [], # schema lock — <details> 팝업 콘텐츠 (감지 미구현)
# 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),
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."
),
}
# IMP-47B u12 — mixed direct+reject first-render admission.
# When initial plan_composition produces a viable layout but at least one
# section remains uncovered (typically chain_exhausted / reject), re-run
# with allow_provisional in the lookup + allow_provisional_fill=True so
# reject sections gain a provisional rank-1 V4Match and a last-resort
# provisional candidate fill. This admits the mixed direct+reject case
# to the AI repair path (IMP-47B u4/u5) on first render. Skipped under
# --override-section-assignments to preserve the operator's plan and
# mirror the IMP-30 u4 retry's section_assignment_plan gate. All-direct
# slides have no uncovered sections so this is a no-op. The all-reject
# case is still handled by the IMP-30 u4 retry block below (initial
# plan_composition returns units=[]).
if units and layout_preset is not None and not override_section_assignments:
_u12_covered_ids: set[str] = set()
for _u in units:
_u12_covered_ids.update(_u.source_section_ids)
_u12_uncovered_ids = [
s.section_id for s in sections if s.section_id not in _u12_covered_ids
]
if _u12_uncovered_ids:
def _lookup_fn_mixed_admission(sid: str) -> Optional[V4Match]:
match, trace = lookup_v4_match_with_fallback(
v4,
sid,
raw_content=section_content_by_id.get(sid),
alias_keys=section_alias_by_id.get(sid),
allow_provisional=True,
)
v4_fallback_traces[sid] = trace
return match
units_mixed, layout_preset_mixed, _comp_debug_mixed = plan_composition(
sections,
_lookup_fn_mixed_admission,
V4_LABEL_TO_PHASE_Z_STATUS,
MVP1_ALLOWED_STATUSES,
capacity_fit_fn=compute_capacity_fit,
v4_candidates_lookup_fn=candidates_lookup_fn,
allow_provisional_fill=True,
)
if units_mixed and layout_preset_mixed is not None:
units = units_mixed
layout_preset = layout_preset_mixed
comp_debug["v4_fallback_selections"] = list(v4_fallback_traces.values())
comp_debug["imp47b_u12_mixed_admission"] = {
"applied": True,
"uncovered_before": _u12_uncovered_ids,
"result_unit_count": len(units_mixed),
"result_layout_preset": layout_preset_mixed,
}
# ── 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:
# IMP-30 u4 — first-render invariant. The pre-u4 path here was
# `sys.exit(1)` after writing error.json. That violated the invariant
# ("final.html + Step 20 slide_status MUST be written for every input
# where Step 0~5 succeed") whenever V4 evidence for any section was
# restructure/reject (chain_exhausted) or missing (no_v4_section /
# empty_v4_judgments).
#
# Recovery has two phases:
# Phase A — provisional retry (u1 + u3 opt-in). Re-run plan_composition
# with allow_provisional=True (in lookup_fn) and allow_provisional_fill
# =True. Synthesizes rank-1 provisional V4Match on chain_exhausted
# (u1) and last-resort-fills uncovered sections with provisional
# candidates (u3). Skipped when the CLI override path was used —
# re-running plan_composition there would discard the override.
# Phase B — terminal empty-shell. If retry still yields zero units
# (true "no rank-1 V4 anywhere" case, or override path with no
# resolvable assignments), synthesize a single placeholder
# CompositionUnit with frame_template_id="__empty__", layout_preset
# ="single". The per-unit loop's __empty__ guard emits a placeholder
# zones_data / debug_zones record; final.html renders the slide
# base shell (title + footer + empty zone) so the first-render
# invariant holds. Provisional flag = True surfaces the "needs
# adaptation" signal (u5 zone class + u6 status qualifier).
provisional_recovered = False
if section_assignment_plan is None:
def _lookup_fn_provisional(sid: str) -> Optional[V4Match]:
match, trace = lookup_v4_match_with_fallback(
v4,
sid,
raw_content=section_content_by_id.get(sid),
alias_keys=section_alias_by_id.get(sid),
allow_provisional=True,
)
v4_fallback_traces[sid] = trace
return match
units_retry, layout_preset_retry, comp_debug_retry = plan_composition(
sections,
_lookup_fn_provisional,
V4_LABEL_TO_PHASE_Z_STATUS,
MVP1_ALLOWED_STATUSES,
capacity_fit_fn=compute_capacity_fit,
v4_candidates_lookup_fn=candidates_lookup_fn,
allow_provisional_fill=True,
)
comp_debug["imp30_u4_provisional_retry"] = {
"applied": True,
"result_unit_count": len(units_retry),
"result_layout_preset": layout_preset_retry,
"candidates_summary": comp_debug_retry.get("candidates_summary"),
}
if units_retry and layout_preset_retry is not None:
units = units_retry
layout_preset = layout_preset_retry
provisional_recovered = True
# v4_fallback_traces was overwritten by _lookup_fn_provisional;
# refresh the IMP-05 selection_paths telemetry so Step 20 reflects
# the actual selection (provisional_rank_1) rather than the stale
# chain_exhausted state from the first attempt.
_imp05_selection_paths_retry = [
{
"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_selections"] = list(v4_fallback_traces.values())
if "v4_fallback_summary" in comp_debug:
comp_debug["v4_fallback_summary"]["selection_paths"] = (
_imp05_selection_paths_retry
)
print(
f" [IMP-30 u4] provisional retry recovered {len(units)} unit(s) "
f"— first-render invariant preserved.",
file=sys.stderr,
)
if not provisional_recovered:
# Phase B — terminal empty-shell. No rank-1 V4 evidence for any
# section, or override path produced no renderable assignments.
from src.phase_z2_composition import CompositionUnit as _CompositionUnit
run_dir.mkdir(parents=True, exist_ok=True)
empty_shell_unit = _CompositionUnit(
source_section_ids=[s.section_id for s in sections],
merge_type="empty_shell",
frame_template_id="__empty__",
frame_id="__empty__",
frame_number=0,
confidence=0.0,
label="empty_shell",
phase_z_status="empty_shell",
raw_content="\n\n".join((s.raw_content or "") for s in sections),
title=" / ".join((s.title or "") for s in sections),
v4_rank=None,
selection_path="empty_shell",
fallback_reason="no_v4_rank_1_for_any_section",
score=0.0,
rationale={
"imp30_u4": "terminal_first_render_empty_shell",
"reason": (
"no_rank_1_V4_evidence_in_any_section"
if section_assignment_plan is None
else "section_assignment_override_yielded_no_renderable_units"
),
"aligned_section_ids": [s.section_id for s in sections],
},
provisional=True,
)
units = [empty_shell_unit]
layout_preset = "single"
comp_debug["imp30_u4_empty_shell"] = {
"applied": True,
"reason": (
"no_rank_1_V4_for_any_section"
if section_assignment_plan is None
else "section_assignment_override_yielded_no_renderable_units"
),
"aligned_section_ids": [s.section_id for s in sections],
}
print(
f"\n[Phase Z-2 IMP-30 u4] EMPTY-SHELL @ composition_planner",
file=sys.stderr,
)
print(
f" reason : "
f"{'no rank-1 V4 evidence for any section' if section_assignment_plan is None else 'override produced no renderable units'}",
file=sys.stderr,
)
print(
f" shell : 1 placeholder unit, preset='single' "
f"(sections={[s.section_id for s in sections]})",
file=sys.stderr,
)
# IMP-48 (#77) — re-split merged-reject units into per-section singles.
# One-shot, deterministic (AI=0) post-pass. Fires AFTER all Step 6 settling
# chains (initial plan_composition / u12 mixed admission / u4 provisional
# retry / empty-shell) and AFTER section_assignment_plan is known, but
# BEFORE the Step 6 artifact write below — so the artifact reflects the
# post-resplit unit list. SKIPS when --override-section-assignments is
# active (IMP-06 / #6 is the ground truth). Helper guardrails (coverage
# equality / beneficial split / layout cap ≤ 4) keep mdx03 byte-identical
# (no-op on use_as_is / light_edit slides). u5 re-derives layout_preset
# below using the audit payload.
units, _imp48_audit = resplit_all_reject_merges(
units,
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,
section_assignment_override=section_assignment_plan is not None,
)
comp_debug["imp48_resplit"] = _imp48_audit
# u5 — re-derive layout_preset from helper audit (post-split count via
# select_layout_preset(out_units)). Helper guarantees post_split_unit_count
# ≤ 4 (layout cap abort), so the derived preset is always renderable by
# LAYOUT_PRESETS. Respect --override-layout when present (user's explicit
# choice wins over auto-redrive; mirrors the override gate above at L3697).
if _imp48_audit.get("applied"):
_imp48_post_preset = _imp48_audit.get("post_split_layout_preset")
if _imp48_post_preset and not layout_override_applied:
if _imp48_post_preset != layout_preset:
print(
f" [IMP-48] layout_preset re-derived: {layout_preset}"
f"{_imp48_post_preset} (post-split unit count="
f"{_imp48_audit.get('post_split_unit_count')})",
file=sys.stderr,
)
layout_preset = _imp48_post_preset
print(
f" [IMP-48] re-split applied — "
f"split={len(_imp48_audit.get('split_units', []))} "
f"skipped={len(_imp48_audit.get('skipped_units', []))} "
f"post_count={_imp48_audit.get('post_split_unit_count')} "
f"post_preset={_imp48_audit.get('post_split_layout_preset')!r}",
file=sys.stderr,
)
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
],
# IMP-48 (#77) — re-split audit. Additive field. AI=0 deterministic
# one-shot post-pass on Step 6 settling result. applied=True means
# ≥1 parent_merged / parent_merged_inferred reject unit was split
# into per-section singles; selected_units already reflects the
# post-split list. Skipped reasons (incomplete_rebuild /
# no_beneficial_split / layout_cap_exceeded) keep the merged unit
# for IMP-47B (#76) AI handoff. section_assignment_override skip
# honors IMP-06 (#6) zoneSections ground truth.
"imp48_resplit": _imp48_audit,
# IMP-43 (#72) u3 — additive informational field recording the
# run_dir-relative location of the ``--reuse-from`` sidecar
# (written immediately after this artifact). Path is stamped
# unconditionally so that a future ``--reuse-from`` consumer
# (u4) can locate the expected sidecar even when its write
# failed (u4 then fail-closes on missing/invalid sidecar via
# u2's ``validate_snapshot``).
"reuse_snapshot_path": SNAPSHOT_FILENAME,
},
step_status="done",
pipeline_path_connected=True,
inputs=["step02_normalized.json", "step05_v4_evidence.json"],
outputs=["step06_composition_plan.json", SNAPSHOT_FILENAME],
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_* 와 일관). "
"IMP-48 (#77, 2026-05-22): merged-reject 자동 분리 post-pass — "
"parent_merged / parent_merged_inferred + label=reject + ≥2 sections "
"→ per-section singles (each own rank-1 V4 evidence + raw_content 보존). "
"guardrails: coverage equality / beneficial split (≥1 non-reject) / "
"layout cap (≤4 units). imp48_resplit audit additive. "
"logic 무변 — runtime 결과 동일. Step 9 application_plan input. "
"IMP-43 (#72) u3: _reuse_snapshot.json sidecar written next to "
"this artifact (run_dir level) for future --reuse-from (u4) "
"consumption. Optional sidecar — write failure warns + continues."
),
)
# IMP-43 (#72) u3 — write Step 6 reuse snapshot sidecar AFTER the
# step06 artifact. The sidecar captures the in-memory state that
# downstream steps need but that the canonical step02 / step05 /
# step06 artifacts do not preserve in a deserialize-ready form (e.g.
# ``CompositionUnit`` instances, raw ``comp_debug``, untruncated
# ``v4_fallback_traces``, pre-override ``layout_preset``). Helper
# warns + returns ``None`` on failure — does NOT abort the run.
# Restore wiring (``--reuse-from``) lands in u4.
_write_reuse_snapshot(
run_dir,
mdx_source_text=mdx_source_text,
slide_title=slide_title,
slide_footer=slide_footer,
sections=sections,
stage0_adapter_diagnostics=stage0_adapter_diagnostics,
stage0_normalized_assets=stage0_normalized_assets,
v4_evidence=v4_evidence_list,
layout_preset_pre_override=layout_preset,
units=units,
comp_debug=comp_debug,
v4_fallback_traces=v4_fallback_traces,
ai_preflight=ai_preflight,
)
else:
# IMP-43 (#72) u5 — reuse path: restore Steps 0/1/2/5/6 state
# from prev_run snapshot. u4b's execute_reuse_from_or_fail_closed
# handles all nine fail-closed axes (prev_run_dir_missing,
# snapshot_corrupt_json, mdx_sha256_mismatch, etc.) — on success
# it returns ``(prev_run_dir, copied_artifacts, snapshot)``;
# any reachable failure terminates the process before this branch
# binds a local.
#
# State variable shape matches the locals produced by Steps 0-6
# above so the Step 7+ block reads them transparently:
# ai_preflight : Step 0 preflight dict
# slide_title / slide_footer : parse_mdx output
# sections : list[MdxSection], post-align
# stage0_adapter_diagnostics : Stage 0 adapter trace dict
# stage0_normalized_assets : Step 3 handoff dict (popups/...)
# v4_evidence_list : list[dict] (Step 5 artifact)
# layout_preset : Step 6 post-IMP-48 preset
# units : list[CompositionUnit]
# comp_debug : Step 6 debug dict
# v4_fallback_traces : dict[sid -> trace dict]
#
# NOT serialized (deterministic from external sources or restored
# sections — recomputed here):
# v4 : load_v4_result() — V4_RESULT_PATH on disk
# section_alias_by_id : derived from restored sections
#
# u1 guard ensures override_layout is None on the reuse path, so
# layout_override_applied / auto_layout_preset reflect the
# restored Step 6 preset for the Step 7 artifact.
mdx_source_text = mdx_path.read_text(encoding="utf-8")
(run_dir / "steps").mkdir(exist_ok=True)
_prev_run_dir, _copied_artifacts, _snapshot = execute_reuse_from_or_fail_closed(
reuse_from=reuse_from,
new_run_dir=run_dir,
mdx_source_text=mdx_source_text,
)
ai_preflight = _snapshot["ai_preflight"]["value"]
slide_title = _snapshot["slide_title"]["value"]
slide_footer = _snapshot["slide_footer"]["value"]
sections = _rehydrate_mdx_sections_from_snapshot(_snapshot)
stage0_adapter_diagnostics = _snapshot["stage0_adapter_diagnostics"]["value"]
stage0_normalized_assets = _snapshot["stage0_normalized_assets"]["value"]
v4_evidence_list = _snapshot["v4_evidence"]["value"]
layout_preset = _snapshot["layout_preset_pre_override"]["value"]
units = _rehydrate_composition_units_from_snapshot(_snapshot)
comp_debug = _snapshot["comp_debug"]["value"]
v4_fallback_traces = _snapshot["v4_fallback_traces"]["value"]
v4 = load_v4_result()
section_alias_by_id = {
s.section_id: list(getattr(s, "v4_alias_keys", []) or [])
for s in sections
}
auto_layout_preset = layout_preset
layout_override_applied = False
# IMP-43 (#72) u4 fix — shared Step 7+ block reads
# ``section_assignment_plan`` unconditionally at the render_records
# gate below, and ``section_assignment_summary`` is mirrored into
# comp_debug via the normal-path override branch. Both stay at
# their "no override applied" defaults on the reuse path because
# u1's fail-closed guard already rejected --override-section-
# assignment when --reuse-from is set. Without these explicit
# defaults the reuse branch falls through to ``if
# section_assignment_plan is not None:`` (line ~5754) with an
# unbound local and the run aborts with UnboundLocalError before
# Step 7 can begin (see Codex #14 rewind report).
section_assignment_plan: Optional[list[dict]] = None
section_assignment_summary: Optional[dict] = None
_write_reuse_marker(
run_dir,
prev_run_id=reuse_from,
copied_artifacts=_copied_artifacts,
)
print(
f" reuse : sections={len(sections)} "
f"({[s.section_id for s in sections]}), "
f"units={len(units)}, layout={layout_preset}, "
f"prev_run_id={reuse_from}"
)
# 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 의 메타가 일관됨. IMP-47B u3 (2026-05-21) : v4_candidates miss 시
# 전 32 judgments 까지 probe — reject 라벨 frame 을 사용자가 선택한 경우
# unit 을 provisional=True 로 승격해 Step 12 AI 재구성 게이트를 통과시킴
# (frame 유지, 자동 frame swap 금지 — [[feedback_ai_isolation_contract]]).
# 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
meta_source = _apply_frame_override_to_unit(unit, new_tid, v4)
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"]
# IMP-30 u4 — empty-shell synthesized unit. frame_template_id="__empty__"
# has no catalog contract by design; bypass mapper/contract path and emit
# a placeholder zone record so render_slide() short-circuits to empty
# partial_html (existing `__empty__` branch at render_slide:2106). The
# slide_base still renders title + footer + empty grid cell so the
# first-render invariant holds; u5 will surface the provisional flag as
# a zone class + needs-adaptation badge.
if unit.frame_template_id == "__empty__":
# IMP-35 u7 — popup payload wiring. Empty-shell units never go
# through the Step 17 POPUP gate (no raw content to escalate),
# so compose_zone_popup_payload returns the no-popup branch
# (has_popup=False, popup_html=None, preview_text=None).
_popup_payload = compose_zone_popup_payload(unit, 0)
zones_data.append({
"position": position,
"template_id": "__empty__",
"slot_payload": {},
"content_weight": {"score": 0},
"min_height_px": 0,
"assignment_source": "imp30_u4_empty_shell",
"section_assignment_override": False,
"provisional": bool(getattr(unit, "provisional", False)),
**_popup_payload,
})
debug_zones.append({
"position": position,
"source_section_ids": list(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": "__empty__",
"v4_label": unit.label,
"v4_confidence": float(unit.confidence or 0.0),
"v4_selected_rank": unit.v4_rank,
"selection_path": unit.selection_path,
"fallback_reason": unit.fallback_reason,
"fallback_used": False,
"phase_z_status": unit.phase_z_status,
"composition_score": float(unit.score or 0.0),
"composition_rationale": dict(unit.rationale or {}),
"composition_notes": list(unit.notes),
"mapper_type": "empty_shell",
"contract_id": "__empty__",
"contract_frame_id": None,
"builder": None,
"min_height_px": 0,
"slot_payload_keys": [],
"content_truncated_count": None,
"assets_dir": None,
"content_weight": {"score": 0},
"placement_trace": None,
"assignment_source": "imp30_u4_empty_shell",
"section_assignment_override": False,
"replaced_auto_unit": None,
"skipped_collided_auto_units": [],
"uncovered_section_ids": [],
"skipped_reason": "imp30_u4_empty_shell_no_v4_evidence",
"provisional": bool(getattr(unit, "provisional", False)),
})
continue
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 ───
# ─── IMP-89 89-a u2 — slot_payload source-of-truth switch ───
# PHASE_Z_B4_MAPPER_SOURCE (u1 flag, default OFF):
# ON → mapper input = B4 PlacementPlan.selected_template_id
# (Layer A render-active; B4 drives slot_payload)
# OFF → mapper input = unit.frame_template_id (legacy mapper-only /
# V4 rank-1; byte-equivalent default — final.html SHA parity
# guarded by u4)
# u3 layers BLOCKED exits for (selected_template_id is None OR
# FitError on B4-selected template) under flag ON — NO silent
# fallback (IMP-87 honesty gate pattern). Under flag OFF semantics
# preserved verbatim.
mapper_template_id = _select_mapper_template_id(
placement_plan, unit.frame_template_id
)
# IMP-89 89-a u3 — BLOCKED exit on B4 no-cover under flag ON.
# When PHASE_Z_B4_MAPPER_SOURCE=ON and PlacementPlan produced no
# covering frame, refuse to fall back to the legacy V4 rank-1 mapper
# input. NO silent fallback (Stage 1 Q2 lock; IMP-87 honesty gate
# pattern). Under flag OFF this branch is never entered, so the
# default render path remains byte-identical.
if _b4_mapper_source_enabled() and mapper_template_id is None:
_b4_mapper_source_blocked_exit(
"b4_no_cover",
position=position,
context={
"unit": (
f"source_section_ids={list(unit.source_section_ids)} "
f"merge_type={unit.merge_type}"
),
"v4_rank1": unit.frame_template_id,
"b4_pick": placement_plan.selected_template_id,
},
)
# mapper 시도 — 실패 (FitError) 시 zone 을 adapter_needed 로 표시하고 skip
try:
slot_payload = map_mdx_to_slots(synth_section, mapper_template_id)
except FitError as e:
_fit_error_str = str(e)
# IMP-89 89-a u3 — BLOCKED exit on B4-selected FitError under flag
# ON. When PHASE_Z_B4_MAPPER_SOURCE=ON the mapper rejecting the
# B4-selected template is a Layer A honesty failure — adapter_needed
# would mask it (Stage 1 Q2 lock). Under flag OFF the legacy
# adapter_needed silent-fallback path executes unchanged.
if _b4_mapper_source_enabled():
_b4_mapper_source_blocked_exit(
"b4_selected_fit_error",
position=position,
context={
"template": f"{mapper_template_id} (B4 selected)",
"unit": (
f"source_section_ids={list(unit.source_section_ids)}"
),
"v4_rank1": unit.frame_template_id,
"fit_error": _fit_error_str,
},
)
_unit_provisional = bool(getattr(unit, "provisional", False))
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": _fit_error_str,
}
adapter_needed_units.append(adapter_record)
# IMP-86 u1 — placeholder zones_data + debug_zone keep the failed
# unit's preset position so downstream build_layout_css /
# _compute_per_zone_geometry observe len(zones_data) == active
# layout preset's css_areas rows (R). Without this both arrays
# drift relative to R, raising
# ValueError("heights_px length N != grid rows R=M") at
# _compute_per_zone_geometry. Mirrors the IMP-30 empty-shell
# pattern: zones_data uses template_id="__empty__" so render_slide
# short-circuits to empty partial_html; debug_zone keeps the
# original V4 evidence and adapter_needed_units stays the
# authoritative adapter signal.
# IMP-86 u5 — per-record telemetry. adapter_needed=True +
# mapper_fit_error=<str(FitError)> + provisional mirror the
# adapter signal directly on each placeholder record so
# debug.json / final.html consumers can identify the adapter
# contract surface from the zones array alone, without joining
# against slide_status.adapter_needed_units. adapter_needed_units
# itself is unchanged (still the authoritative per-slide list).
_placeholder_popup = compose_zone_popup_payload(unit, 0)
zones_data.append({
"position": position,
"template_id": "__empty__",
"slot_payload": {},
"content_weight": {"score": 0},
"min_height_px": min_height_px,
"assignment_source": "imp86_u1_adapter_needed",
"section_assignment_override": False,
"provisional": _unit_provisional,
"adapter_needed": True,
"mapper_fit_error": _fit_error_str,
**_placeholder_popup,
})
debug_zones.append({
"position": position,
"source_section_ids": list(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": float(unit.confidence or 0.0),
"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": float(unit.score or 0.0),
"composition_rationale": dict(unit.rationale or {}),
"composition_notes": list(unit.notes),
"mapper_type": "adapter_needed",
"contract_id": unit.frame_template_id,
"contract_frame_id": contract_frame_id,
"builder": builder_name,
"min_height_px": min_height_px,
"slot_payload_keys": [],
"content_truncated_count": None,
"assets_dir": None,
"content_weight": {"score": 0},
"placement_trace": placement_trace,
"assignment_source": "imp86_u1_adapter_needed",
"section_assignment_override": False,
"replaced_auto_unit": None,
"skipped_collided_auto_units": [],
"uncovered_section_ids": [],
"skipped_reason": "imp86_u1_adapter_needed_mapper_fit_error",
"provisional": _unit_provisional,
"adapter_needed": True,
"mapper_fit_error": _fit_error_str,
})
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
)
# IMP-30 u5 — `provisional` flag flows as data through V4Match (u1) →
# CompositionUnit (u2) → zones_data here. slide_base.html reads
# zone.provisional to apply the `zone--provisional` class + inline
# needs-adaptation badge. Default False keeps non-provisional zones
# byte-identical to pre-u5; only u1-synthesized rank-1 fills or u4
# empty-shell synthesize provisional=True units.
#
# IMP-35 u7 — popup payload wiring. `compose_zone_popup_payload(unit,
# min_height_px)` reads u6 binding (yaml strategy + popup_body_source)
# AND derives a px-budgeted preview from min_height_px. Surfaces three
# uniform render-context fields per zone (has_popup / popup_html /
# preview_text) plus the full u6 binding under `popup_binding` for
# u8 / u9 downstream consumers. Non-popup units (has_popup=False)
# return the no-popup branch — byte-identical zone shape pre-u7.
_popup_payload = compose_zone_popup_payload(unit, min_height_px)
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,
"provisional": bool(getattr(unit, "provisional", False)),
**_popup_payload,
})
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-30 u5 — provisional signal mirror for debug.json consumers.
"provisional": bool(getattr(unit, "provisional", False)),
})
# 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
# IMP-35 u7 — popup payload wiring for unrenderable empty
# plan record. No CompositionUnit exists for this branch
# (section-assignment plan produced no unit), so we stamp the
# no-popup defaults directly. Keeps the zone shape uniform
# across all three append paths so slide_base.html (u8) does
# not have to branch on the presence of popup fields.
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"
),
"has_popup": False,
"popup_html": None,
"preview_text": None,
"popup_binding": None,
})
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"<h2>zone--{dz['position']} (sections: {', '.join(_section_ids)})</h2>"
for _sid in _section_ids:
_sec = _v4_sections.get(_sid)
if not _sec:
_candidates_html += f"<p>section <code>{_sid}</code>: V4 entry 없음</p>"
continue
_candidates_html += (
f"<h3>section <code>{_sid}</code>: {_sec.get('mdx_title', '?')}</h3>"
f"<p>answer_frame_number: <strong>{_sec.get('answer_frame_number', '-')}</strong>"
f" | is_holdout: {_sec.get('is_holdout', '-')}</p>"
f'<div style="display:grid;grid-template-columns:repeat(2,1fr);gap:16px;margin-bottom:24px;">'
)
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'<div style="margin-top:8px;width:320px;height:180px;overflow:hidden;'
f'border:1px solid #cbd5e1;background:#fff;">'
f'<iframe src="{_frame_iframe_base}/{_frame_id}/index.html" '
f'style="width:1280px;height:720px;border:none;'
f'transform:scale(0.25);transform-origin:0 0;"></iframe>'
f'</div>'
if _has_preview else
f'<div style="margin-top:8px;width:320px;height:180px;'
f'border:1px dashed #cbd5e1;background:#f8fafc;display:flex;'
f'align-items:center;justify-content:center;color:#94a3b8;font-size:13px;">'
f'preview missing<br>'
f'(figma_to_html_agent/blocks/{_frame_id}/index.html 없음)'
f'</div>'
)
_candidates_html += (
f'<div class="{_css}">'
f'<strong>rank {_rank}: frame {_j.get("frame_number")} '
f'(<code>{_j.get("template_id")}</code>)</strong><br>'
f'confidence: {_j.get("confidence", 0):.4f} | '
f'label: <strong>{_j.get("label", "-")}</strong> | '
f'base: {_j.get("base", 0):.4f} | penalty: {_j.get("penalty", 0)}'
f'{_preview_html}'
f"</div>"
)
_candidates_html += "</div>"
_write_step_html(
run_dir, 9, "frame_matching_candidates",
title="V4 Frame Matching Candidates (top 4 per section)",
body_html=(
f"<p>각 zone 의 source section 마다 V4 rank 1~4 후보 표시. "
f"녹색 강조 = rank-1 (현재 선택된 frame).</p>{_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 IMP-47B u4 — AI repair proposal gather ───
# Wire gather_step12_ai_repair_proposals so reject / restructure
# provisional units reach the AI fallback router. Normal-path units
# (use_as_is / light_edit / non-provisional) skip via the catch-all
# route gate; flag-off runs short-circuit at the router. Stored locally
# for u5 (PARTIAL_OVERRIDES apply) + u6 (step12_ai_repair.json audit).
ai_repair_records = _run_step12_ai_repair(units)
# ─── Step 12 IMP-47B u5 — Apply PARTIAL_OVERRIDES proposals ───
# Mirror the per-unit position derivation from the render loop above
# (L3789-3796); apply merges slots into zone slot_payload, loud-fails
# unsupported kinds via apply_status marker.
unit_positions: list[str] = []
for _i, _unit in enumerate(units):
_pos = positions[_i] if _i < len(positions) else f"zone_{_i}"
_plan_record = render_record_by_unit_id.get(id(_unit))
if _plan_record is not None and _plan_record.get("position"):
_pos = _plan_record["position"]
unit_positions.append(_pos)
_apply_ai_repair_proposals_to_zones(ai_repair_records, unit_positions, zones_data)
# ─── Step 12 IMP-47B u7 — Post-AI source_section_ids coverage invariant ───
# Structural defense: AI repair must not silently drop a unit's
# source_section_ids. dropped 절대 룰 — text_block / table / image /
# details deletion forbidden. Result feeds u6 audit (below) and
# u8 slide_status.ai_repair_status surfacing.
ai_repair_coverage_invariant = _check_post_ai_coverage_invariant(
units, ai_repair_records,
)
# ─── Step 12 IMP-47B u6 — AI repair audit artifact ───
# Persist per-unit gather/apply outcomes (route_hint, skip_reason,
# apply_status, ai_called, proposal kind, cache_key, fingerprints)
# so reviewers can audit which units reached the AI fallback router
# and what happened. Flag-off default → every record has
# ai_called=False + apply_status='no_proposal'; flag-on +
# provisional reject/restructure → router_short_circuit (cache miss
# without client) or applied:partial_overrides (cache hit / live AI).
# u7 coverage_invariant rides alongside per_unit for reviewers.
_write_step_artifact(
run_dir, 12, "ai_repair",
data={
"per_unit": ai_repair_records,
"coverage_invariant": ai_repair_coverage_invariant,
},
step_status="done",
pipeline_path_connected=True,
inputs=["step10_frame_contract.json", "step02_normalized.json"],
outputs=["step12_ai_repair.json"],
note="IMP-47B u6 — Step 12 AI repair gather + apply records per unit (route, skip_reason, apply_status, proposal). u7 coverage_invariant = pre/post AI source_section_ids set comparison.",
)
# ─── 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).",
)
# IMP-42 u3 (#71) — unconditional Step 12 slot_payload emit DIAG log.
_emit_diag_zones_shape("Step 12 slot_payload emit", zones_data)
# 6. Build layout CSS — horizontal-2 = dynamic heights (regression preserve), 그 외 = fr default.
# Step D-ext : override_zone_geometries 가 들어오면 layout_css 강제.
# IMP-86 u2 — pre-build layout invariant guard. zones_data / debug_zones
# MUST be cardinality- and position-aligned with the active layout
# preset's css_areas tokens before build_layout_css derives heights_px
# / widths_px and _compute_per_zone_geometry validates them against R/C.
# If a mapper FitError path (IMP-86 u1) — or any future zone-drop path —
# forgets to append a placeholder, the resulting shape drift would
# surface as a confusing `heights_px length N != grid rows R=M`
# ValueError deep inside the geometry helper. This guard fails fast at
# the pipeline boundary with preset / expected positions / actual
# positions / count diagnostics so the root cause is obvious from the
# log line (factual_verification: value + path + upstream).
_active_preset = LAYOUT_PRESETS[layout_preset]
_expected_positions = _parse_css_areas(_active_preset["css_areas"])[1]
_actual_positions = [zd["position"] for zd in zones_data]
_debug_positions = [dz["position"] for dz in debug_zones]
if (
len(zones_data) != len(_expected_positions)
or len(debug_zones) != len(_expected_positions)
or sorted(_actual_positions) != sorted(_expected_positions)
or sorted(_debug_positions) != sorted(_expected_positions)
):
raise ValueError(
"phase_z2_pipeline pre-build layout invariant violation: "
f"layout_preset={layout_preset!r} "
f"css_areas={_active_preset['css_areas']!r} "
f"expected_positions={_expected_positions!r} "
f"zones_data_positions={_actual_positions!r} "
f"debug_zones_positions={_debug_positions!r} "
f"zones_count={len(zones_data)} "
f"debug_count={len(debug_zones)} "
f"expected_count={len(_expected_positions)}"
)
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'<div style="background:{_color};border:3px solid {_border};'
f'display:flex;flex-direction:column;justify-content:center;align-items:center;'
f'padding:16px;text-align:center;">'
f'<div style="font-size:18px;font-weight:700;margin-bottom:6px;">zone--{dz["position"]}</div>'
f'<div style="font-size:14px;"><code>{dz.get("v4_template_id") or "-"}</code></div>'
f'<div style="font-size:13px;color:#475569;margin-top:4px;">'
f'height: {dz.get("height_px") or "?"}px / ratio: {dz.get("ratio") or "?"}'
f'</div></div>'
)
_slide_visual_7 = (
f'<div style="width:{_slide_w}px;border:2px solid #1e293b;background:#fff;margin:16px 0;'
f'box-shadow:0 4px 16px rgba(0,0,0,0.1);">'
f'<div style="padding:14px 40px;border-bottom:1px solid #cbd5e1;background:#f8fafc;">'
f'<div style="font-size:15px;color:#64748b;">[slide-title placeholder]</div></div>'
f'<div style="padding:0 40px;height:{_body_h}px;display:grid;'
f'grid-template-rows:{_grid_template};grid-template-columns:{layout_css.get("cols") or "1fr"};'
f'gap:{GRID_GAP}px;box-sizing:border-box;padding-top:12px;padding-bottom:12px;">'
f'{_zone_visuals_7}</div>'
f'<div style="padding:10px 40px;border-top:1px solid #cbd5e1;background:#f8fafc;text-align:right;">'
f'<span style="font-size:13px;color:#64748b;">[slide-footer]</span></div>'
f'</div>'
)
# 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 = (
'<span style="background:#dcfce7;color:#166534;padding:2px 8px;border-radius:3px;'
'font-size:11px;font-weight:600;margin-left:6px;">selected</span>'
if _is_selected else ''
)
_default_mark = (
'<span style="background:#e0e7ff;color:#3730a3;padding:2px 8px;border-radius:3px;'
'font-size:11px;font-weight:600;margin-left:6px;">default</span>'
if _is_default else
'<span style="background:#f1f5f9;color:#64748b;padding:2px 8px;border-radius:3px;'
'font-size:11px;font-weight:600;margin-left:6px;">alternative</span>'
)
_candidates_rows += (
f'<li style="padding:6px 0;"><code>{lc}</code>{_default_mark}{_badge}</li>'
)
_candidates_block = (
f'<h2>Layout Candidates (unit_count={len(units)})</h2>'
f'<p>source: <code>templates/phase_z2/layouts/layouts.yaml</code> + '
f'<code>select_layout_candidates(unit_count)</code> '
f'(Step 7-B). default = first entry. <strong>현재 single-decision logic 은 '
f'default 만 사용</strong> — Step 9 application_plan 진입 시 후보 평가.</p>'
f'<ul>{_candidates_rows}</ul>'
)
_write_step_html(
run_dir, 7, "selected_layout",
title=f"Selected Layout — {layout_preset}",
body_html=(
f"<h2>Layout Preset: <code>{layout_preset}</code></h2>"
f"<p>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 박스 = 실제 비율 그대로.</p>"
f"{_slide_visual_7}"
f"{_candidates_block}"
f"<h2>Layout CSS (raw)</h2>"
f"<pre><code>{json.dumps(layout_css, ensure_ascii=False, indent=2)}</code></pre>"
),
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'<div style="flex:1;border:1px dashed #94a3b8;background:rgba(255,255,255,0.7);'
f'padding:6px;display:flex;align-items:center;justify-content:center;'
f'font-size:12px;color:#475569;">section {k+1} (균등 1/{_card})</div>'
for k in range(_card)
)
_sub_zones_html += (
f'<div style="flex:1;background:rgba(59,130,246,0.08);border:2px solid #3b82f6;'
f'padding:8px;display:flex;flex-direction:column;gap:4px;">'
f'<div style="font-weight:700;font-size:13px;padding-bottom:6px;border-bottom:1px solid #3b82f6;">'
f'<code>{sz["id"]}</code><br>'
f'<span style="font-size:11px;font-weight:400;color:#475569;">'
f'cardinality.strict={_card} | accepts: {_accepts}'
f'</span>'
f'</div>'
f'{_section_boxes}'
f'</div>'
)
# Step 8-conn — region / display strategy 후보 시각.
_region_pills = " ".join(
f'<span style="background:#dbeafe;color:#1e40af;padding:2px 10px;border-radius:12px;'
f'font-size:11px;font-weight:600;margin-right:4px;">'
f'{rc}{"" if k == 0 else ""}</span>'
for k, rc in enumerate(plan.get("region_layout_candidates") or [])
)
_display_pills = " ".join(
f'<span style="background:#dcfce7;color:#166534;padding:2px 10px;border-radius:12px;'
f'font-size:11px;font-weight:600;margin-right:4px;">'
f'{ds}{"" if k == 0 else ""}</span>'
for k, ds in enumerate(plan.get("display_strategy_candidates") or [])
)
_zone_visuals_8 += (
f'<h3>zone--{plan["position"]} ({_zone_h}px planned, '
f'min_height {plan["min_height_px"]}px, '
f'frame_cardinality_strict {plan["frame_cardinality_strict"]})</h3>'
f'<div style="height:{min(_zone_h, 360)}px;background:{_color};border:3px solid {_border};'
f'display:flex;gap:8px;padding:12px;box-sizing:border-box;margin-bottom:8px;">'
f'{_sub_zones_html}'
f'</div>'
f'<p style="margin-top:0;color:#64748b;font-size:13px;"><em>{plan["child_distribution_note"]}</em></p>'
f'<div style="background:#f8fafc;border-left:3px solid #94a3b8;padding:8px 12px;margin-bottom:16px;font-size:13px;">'
f'<div style="margin-bottom:4px;"><strong>region_layout_candidates</strong> (★ default): {_region_pills}</div>'
f'<div><strong>display_strategy_candidates</strong> (★ default): {_display_pills}</div>'
f'</div>'
)
_write_step_html(
run_dir, 8, "zone_content_placement",
title="Zone + Child Zone Placement Plan (pre-render)",
body_html=(
f"<p><strong>중요:</strong> 이 페이지는 <em>계획값</em> only. 실제 측정값 (clientHeight / scrollHeight / overflow) 은 "
f"<code>step14_visual_check.json</code> 에서 봄.</p>"
f"<p>각 zone 안에 sub_zones 가 columns 로 나열, 각 sub_zone 안 sections 가 cardinality.strict 만큼 균등 row 분배. "
f"F29 process_column 의 첫 section 이 transform_table (AS-IS/TO-BE 표) 인데 균등 분배로 capacity 부족 → 10px overflow 직접 원인.</p>"
f'<div style="background:#fef3c7;border-left:3px solid #f59e0b;padding:8px 12px;margin-bottom:16px;font-size:13px;">'
f'<strong>Step 8-conn placeholder caveat (사용자 lock 2026-05-08):</strong> '
f'region_layout_candidates / display_strategy_candidates 는 현재 '
f'<code>region_count=1 / content_type="text_block"</code> placeholder 신호로만 산출. '
f'Step 3/4 (Content Object 추출 + Internal Composition Planning) 활성화 시 '
f'실제 content_object 신호로 교체 (별 axis).'
f'</div>'
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):
# 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 {}
selection_trace = v4_fallback_traces.get(unit.source_section_ids[0], {})
plan_record = plan_record_by_unit_id.get(id(unit))
# 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)
)
# IMP-32 u4 — per-unit application_plan dict assembly extracted into
# _build_application_plan_unit(...). Per-index/per-id lookups
# (zone_region_plans[i], v4_fallback_traces.get(...),
# plan_record_by_unit_id.get(id(unit)), section_alias_by_id,
# lookup_v4_all_judgments(...)) stay at the call-site.
application_plan_units.append(
_build_application_plan_unit(
unit,
zone_plan,
selection_trace,
plan_record,
v4_all_for_unit,
layout_preset,
layout_candidates_list,
)
)
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'<span style="background:#dcfce7;color:#166534;padding:2px 8px;border-radius:3px;'
f'font-size:11px;font-weight:600;margin-left:6px;">application_status: ok</span>'
if u["application_status"] == "ok" else
f'<span style="background:#fee2e2;color:#991b1b;padding:2px 8px;border-radius:3px;'
f'font-size:11px;font-weight:600;margin-left:6px;">application_status: no_v4_candidate</span>'
)
_default_html = (
f'<code>{u["current_default_candidate"]}</code>'
if u["current_default_candidate"] else '<em>null</em>'
)
_fallback_html = (
f' | <strong>selection_path:</strong> <code>{u.get("selection_path")}</code>'
f' | <strong>selected_v4_rank:</strong> {u.get("selected_v4_rank")}'
f' | <strong>fallback_reason:</strong> <code>{u.get("fallback_reason")}</code>'
if u.get("fallback_used") else
f' | <strong>selection_path:</strong> <code>{u.get("selection_path")}</code>'
)
_layout_pills = " ".join(
f'<span style="background:#e0e7ff;color:#3730a3;padding:1px 8px;border-radius:10px;'
f'font-size:11px;margin-right:3px;">{lc}{"" if k == 0 else ""}</span>'
for k, lc in enumerate(u["layout_candidates"])
)
_region_pills = " ".join(
f'<span style="background:#dbeafe;color:#1e40af;padding:1px 8px;border-radius:10px;'
f'font-size:11px;margin-right:3px;">{rc}{"" if k == 0 else ""}</span>'
for k, rc in enumerate(u["region_layout_candidates"])
)
_display_pills = " ".join(
f'<span style="background:#dcfce7;color:#166534;padding:1px 8px;border-radius:10px;'
f'font-size:11px;margin-right:3px;">{ds}{"" if k == 0 else ""}</span>'
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 = (
' <span style="background:#fef3c7;color:#92400e;padding:1px 6px;border-radius:3px;'
'font-size:10px;font-weight:600;">current_default</span>'
if _is_default else ''
)
_app_rows += (
f'<tr>'
f'<td style="padding:6px 10px;border-bottom:1px solid #e2e8f0;">'
f'<code>{ac["template_id"]}</code>{_default_mark}</td>'
f'<td style="padding:6px 10px;border-bottom:1px solid #e2e8f0;font-size:12px;">'
f'{ac["v4_label"]}</td>'
f'<td style="padding:6px 10px;border-bottom:1px solid #e2e8f0;">'
f'<span style="background:{_bg};color:{_fg};padding:2px 8px;border-radius:3px;'
f'font-size:11px;font-weight:600;">{ac["application_mode"]}</span></td>'
f'<td style="padding:6px 10px;border-bottom:1px solid #e2e8f0;font-size:12px;">'
f'{"" if ac["auto_applicable"] else ""}</td>'
f'<td style="padding:6px 10px;border-bottom:1px solid #e2e8f0;font-size:11px;color:#64748b;">'
f'{ac["delegated_to"] or ""}</td>'
f'</tr>'
)
_unit_blocks_9 += (
f'<div style="background:#fff;border:1px solid #cbd5e1;border-radius:6px;padding:16px;margin-bottom:20px;">'
f'<h3 style="margin-top:0;">unit: <code>{u["unit_id"]}</code> {_status_badge}</h3>'
f'<p style="margin:4px 0;font-size:13px;color:#475569;">'
f'<strong>layout_preset (default):</strong> <code>{u["layout_preset"]}</code> | '
f'<strong>current_default_candidate:</strong> {_default_html}{_fallback_html}</p>'
f'<p style="margin:4px 0;font-size:13px;"><strong>layout_candidates (★ default):</strong> {_layout_pills}</p>'
f'<p style="margin:4px 0;font-size:13px;"><strong>region_layout_candidates (★ default, placeholder):</strong> {_region_pills}</p>'
f'<p style="margin:4px 0;font-size:13px;"><strong>display_strategy_candidates (★ default, placeholder):</strong> {_display_pills}</p>'
f'<table style="width:100%;border-collapse:collapse;margin-top:10px;font-size:13px;">'
f'<thead><tr style="background:#f8fafc;">'
f'<th style="padding:6px 10px;text-align:left;border-bottom:2px solid #cbd5e1;">template_id</th>'
f'<th style="padding:6px 10px;text-align:left;border-bottom:2px solid #cbd5e1;">v4_label</th>'
f'<th style="padding:6px 10px;text-align:left;border-bottom:2px solid #cbd5e1;">application_mode</th>'
f'<th style="padding:6px 10px;text-align:left;border-bottom:2px solid #cbd5e1;">auto_app.</th>'
f'<th style="padding:6px 10px;text-align:left;border-bottom:2px solid #cbd5e1;">delegated_to</th>'
f'</tr></thead>'
f'<tbody>{_app_rows}</tbody>'
f'</table>'
f'</div>'
)
_summary_block_9 = (
f'<div style="background:#fef3c7;border-left:3px solid #f59e0b;padding:8px 12px;margin-bottom:16px;font-size:13px;">'
f'<strong>Step 9 v0 lock (사용자 2026-05-08):</strong> '
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'</div>'
f'<p><strong>units:</strong> {len(application_plan_units)} | '
f'<strong>units_with_no_v4_candidate:</strong> '
f'{len(units_with_no_v4)} ({", ".join(units_with_no_v4) if units_with_no_v4 else "none"})</p>'
)
_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)
# IMP-51 (#79) u4 + u7 — stamp user-content imgs with stable id /
# role attrs, then inject persisted `image_overrides` CSS so the
# next render re-applies the user-edited geometry.
#
# Forward-compat: `stage0_normalized_assets["images"]` is empty in
# every current Phase Z run (Q1 = A confirmed at Stage 1), so the
# stamper returns an empty `stamped_image_ids` list and the CSS
# builder short-circuits to "". The HTML is therefore byte-for-byte
# identical to the pre-IMP-51 output until Phase Z starts emitting
# user-content imgs (separate axis, out of scope for #79).
from src.image_id_stamper import (
build_image_overrides_style,
inject_image_overrides_style,
stamp_user_content_images,
)
_user_content_image_srcs = [
(entry.get("path") or entry.get("src") or "")
for entry in (stage0_normalized_assets.get("images") or [])
if isinstance(entry, dict)
]
html, _stamped_image_ids = stamp_user_content_images(
html, sources=_user_content_image_srcs,
)
if override_image_overrides:
_image_overrides_css = build_image_overrides_style(
override_image_overrides, _stamped_image_ids,
)
if _image_overrides_css:
html = inject_image_overrides_style(html, _image_overrides_css)
# IMP-45 (#74) u4 — slide-level CSS override injection.
# Kwarg wins; otherwise fall back to MDX frontmatter
# ``slide_overrides.css``. Called AFTER image override injection so
# editor-authored slide CSS wins ties at the same specificity in the
# cascade (slide_css_injector.py module docstring lock).
_effective_slide_css = override_slide_css
if _effective_slide_css is None:
_effective_slide_css = _resolve_slide_css_from_frontmatter(mdx_source_text)
if _effective_slide_css:
from src.slide_css_injector import inject_slide_css
html = inject_slide_css(html, _effective_slide_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)."
),
)
# ─── IMP-47B u13: Persist validated AI repair proposals to cache ───
# Saves each applied PARTIAL_OVERRIDES proposal AFTER Step 14 visual
# check + per IMP-46 dual-gate. ``visual_check_passed`` reads the
# Selenium overflow result; ``auto_cache`` sourced from Settings
# (CLI --auto-cache wires settings.ai_fallback_auto_cache at parse
# time, src/phase_z2_pipeline.py:5631-5633). ``user_approved`` stays
# False — the pipeline has no UX approval gate; the auto_cache
# opt-in is the documented bypass per IMP-46 u5. Gate violations
# surface as ``cache_save_status='gate_blocked:<reason>'`` on the
# record (cache is a hint, never a hard dependency).
from src.config import settings as _ai_cache_settings
_persist_ai_repair_proposals_to_cache(
ai_repair_records,
visual_check_passed=bool(overflow.get("passed")),
user_approved=False,
auto_cache=bool(_ai_cache_settings.ai_fallback_auto_cache),
)
# 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)
# 11.7.1 IMP-88 (#88) u7 — Step 17 image_fit single-pass entry trigger.
# Activates when router proposes `image_fit` (image_aspect_mismatch
# classifications, ACTION_BY_CATEGORY row added in u1). Single-pass executor
# (`_attempt_step17_image_fit_single_pass`): per-image plan/apply via
# plan_image_fit + apply_image_fit_css → aggregated CSS overlay → single
# re-render → run_overflow_check. PASS promotes final.html and refreshes
# Step 17/18/19 state; FAIL records `image_fit` step with failure_reason
# so failure_router (u2) SALVAGE_FAILURE_TYPE_BY_ACTION classifies it as
# `image_fit_insufficient` and the §11.7.2 cascade entry block routes
# onto layout_adjust. image_fit stays OUT of _SALVAGE_FAIL_BY_ACTION
# (u6 guard) — Step 17 ENTRY single-pass, not a salvage cascade stage.
if (
not retry_trace.get("retry_passed")
and not retry_trace.get("salvage_passed")
and "image_fit" in set(router_decision.get("proposed_actions_summary") or [])
):
_img_result = _attempt_step17_image_fit_single_pass(
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,
image_events=overflow.get("image_events") or [],
gap_px=GRID_GAP,
)
if _img_result["triggered"]:
retry_trace.setdefault("salvage_steps", []).append(_img_result["step"])
retry_trace["salvage_attempted"] = True
if _img_result["passed"]:
retry_trace["salvage_passed"] = True
overflow = _img_result["candidate_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"
)
enrich_retry_trace_with_failure_classification(retry_trace)
# 11.7.2 IMP-88 (#88) u7 — direct entry triggers for layout_adjust /
# frame_internal_fit_candidate when the donor_slack pathway above did not
# engage but the router proposes one of these actions (e.g. moderate_overflow
# / layout_zone_mismatch → layout_adjust; frame_capacity_mismatch →
# frame_internal_fit_candidate per u1 ACTION_BY_CATEGORY). Re-uses
# _attempt_salvage_chain with a synthetic initial_failure_type that
# routes (via failure_router NEXT_ACTION_BY_FAILURE / u2) onto the proposed
# action, so the cascade tail (frame_reselect → details_popup_escalation)
# stays consistent with the donor_slack pathway. Also handles the
# image_fit single-pass FAIL → image_fit_insufficient escalation onto
# the cascade (u2 row routes image_fit_insufficient → layout_adjust).
if not retry_trace.get("retry_passed") and not retry_trace.get("salvage_passed"):
_u7_proposed = set(router_decision.get("proposed_actions_summary") or [])
_u7_last_step = (retry_trace.get("salvage_steps") or [])
_u7_image_fit_failed = bool(
_u7_last_step
and _u7_last_step[-1].get("action") == "image_fit"
and _u7_last_step[-1].get("passed") is False
)
_u7_initial = None
if _u7_image_fit_failed:
_u7_initial = "image_fit_insufficient"
elif "layout_adjust" in _u7_proposed:
_u7_initial = "font_step_insufficient"
elif "frame_internal_fit_candidate" in _u7_proposed:
_u7_initial = "layout_adjust_insufficient"
if _u7_initial:
_u7_cls_list = fit_classification.get("classifications") or []
_u7_entry_cls = next(
(c for c in _u7_cls_list
if c.get("proposed_action") in {
"layout_adjust", "frame_internal_fit_candidate", "image_fit",
}),
{},
)
_u7_tpos = _u7_entry_cls.get("zone_position") or ""
_u7_tdz = next(
(dz for dz in debug_zones if dz.get("position") == _u7_tpos), {},
) or {}
_u7_zof = {z.get("position"): z for z in (overflow.get("zones") or [])}
_u7_zm = _u7_zof.get(_u7_tpos) or {}
_u7_excess = max(
0.0,
float(_u7_zm.get("scrollHeight") or 0.0)
- float(_u7_zm.get("clientHeight") or 0.0),
)
from src.fit_verifier import FitAnalysis, RoleFit
_u7_fa_roles, _u7_fa_containers = {}, {}
for _u7_dz in debug_zones:
_u7_pos = _u7_dz.get("position")
if not _u7_pos:
continue
_u7_alloc = float(_u7_dz.get("height_px") or 0.0)
_u7_zmi = _u7_zof.get(_u7_pos) or {}
_u7_ch = float(_u7_zmi.get("clientHeight") or _u7_alloc)
_u7_sh = float(_u7_zmi.get("scrollHeight") or _u7_ch)
_u7_fa_roles[_u7_pos] = RoleFit(
role=_u7_pos, allocated_px=_u7_alloc,
shortfall_px=_u7_sh - _u7_ch,
)
_u7_fa_containers[_u7_pos] = {
"zone": "slide_body", "height_px": int(_u7_alloc),
}
_u7_salvage = _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=_u7_fa_roles),
"containers": _u7_fa_containers,
"min_margin_px": None,
"excess_px": _u7_excess, "excess_after_glue_px": _u7_excess,
"block_count": len(
(_u7_tdz.get("placement_trace") or {}).get("internal_regions") or []
) or 1,
"zone_position": _u7_tpos,
"current_font_px": float(_u7_tdz.get("font_size_px") or 0.0),
"available_lines": int(_u7_tdz.get("available_lines") or 0),
"chars_per_line": int(_u7_tdz.get("chars_per_line") or 0),
},
initial_failure_type=_u7_initial, gap_px=GRID_GAP,
)
_u7_prior_steps = retry_trace.get("salvage_steps") or []
_u7_new_steps = _u7_salvage.get("salvage_steps") or []
retry_trace.update(_u7_salvage)
retry_trace["salvage_steps"] = _u7_prior_steps + _u7_new_steps
if _u7_salvage.get("salvage_passed"):
overflow = (_u7_new_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"
)
enrich_retry_trace_with_failure_classification(retry_trace)
# 11.8 IMP-35 (#64) u5 — Step 17 deterministic POPUP gate executor.
# Runs after the salvage cascade exits at cascade-terminal action
# `details_popup_escalation` (router u3 IMPLEMENTED + failure_router u2
# cascade row). Stamps popup_escalation_plan + idempotent has_popup
# marker per unit onto retry_trace["popup_gate_records"]. Deterministic
# gate — no AI call (feedback_ai_isolation_contract); the u4
# api_gated split-decision hook is a separate cascade-stage record
# consumed only when a future IMP activates the Anthropic API.
# Consumer side (composition popup binding / render wiring) lands in
# u6 / u7. q1 (per-unit), q2 (idempotent via has_popup), q3
# (deterministic from fit_classification) — see Stage 2 plan.
# next_proposed_action is the single canonical signal: it is set by
# enrich_retry_trace_with_failure_classification via failure_router u2
# (NEXT_ACTION_BY_FAILURE), which routes frame_reselect_insufficient ->
# details_popup_escalation. This check is independent of whether the
# salvage chain block ran, so the popup gate fires for any retry path
# that lands on the cascade-terminal popup action.
_next_action = (
retry_trace.get("next_action_proposal") or {}
).get("next_proposed_action")
if _next_action == "details_popup_escalation":
_popup_cls_by_zone = {
c.get("zone_position"): c
for c in (fit_classification.get("classifications") or [])
if c.get("category") in {
"structural_major_overflow",
"tabular_overflow",
}
}
_zone_by_ssids = {
tuple(z.get("source_section_ids") or []): z.get("position")
for z in debug_zones
}
def _classification_for_unit(u):
ssids = tuple(getattr(u, "source_section_ids", []) or [])
zone_pos = _zone_by_ssids.get(ssids)
return _popup_cls_by_zone.get(zone_pos) if zone_pos else None
retry_trace["popup_gate_records"] = run_step17_popup_gate(
units,
classification_for_unit=_classification_for_unit,
route_for_label=_imp05_route_hint,
plan_for_classification=plan_details_popup_escalation,
)
retry_trace["popup_gate_executed"] = True
retry_trace["popup_gate_terminal_action"] = "details_popup_escalation"
# ─── 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). "
"IMP-88 u6/u7 — salvage cascade extended with layout_adjust + "
"frame_internal_fit_candidate; image_fit single-pass entry triggered "
"from router (cascade tail frame_reselect remains PARTIAL pre-render only)."
),
)
# ─── 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,
)
# IMP-47B u8 — Surface Step 12 AI repair outcomes through slide_status.
# Composes u4 gather errors + u5 apply_status + u7 coverage_invariant
# into a single ``ai_repair_status`` axis the frontend (u11) reads to
# render human_review notifications. Auto pipeline first
# ([[feedback_auto_pipeline_first]]) — no review_queue insertion;
# explicit status enum + human_review_required flag.
slide_status["ai_repair_status"] = _summarize_ai_repair_status(
ai_repair_records, ai_repair_coverage_invariant,
)
# ─── 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", "?")
# IMP-87 u3 — route the dashboard CSS class through _final_status_html_class
# so EMPTY_SHELL_NO_CONTENT renders as "fail" (red) instead of the legacy
# substring-default "partial" amber. Other enums keep their pre-IMP-87
# colour (Stage 2 axis A5 lock).
_ov_class = _final_status_html_class(_overall)
_vfs = slide_status.get("visual_fail_reasons") or []
_vfs_html = (
"<ul>" + "".join(f"<li>{v}</li>" for v in _vfs) + "</ul>"
if _vfs else "<p>없음</p>"
)
_aligned = slide_status.get("aligned_section_ids") or []
_covered = slide_status.get("covered_section_ids") or []
_filtered = slide_status.get("filtered_section_ids") or []
_ai_repair = slide_status.get("ai_repair_status") or {}
_ai_repair_label = (
f'{_ai_repair.get("status", "?")} '
f'(human_review_required={_ai_repair.get("human_review_required", False)})'
)
_write_step_html(
run_dir, 20, "final_status",
title="Final Slide Status",
body_html=(
f'<h2>Overall: <span class="{_ov_class}">{_overall}</span></h2>'
f'<table>'
f'<tr><th>rendered</th><td>{slide_status.get("rendered")}</td></tr>'
f'<tr><th>visual_check_passed</th><td>{slide_status.get("visual_check_passed")}</td></tr>'
f'<tr><th>full_mdx_coverage</th><td>{slide_status.get("full_mdx_coverage")}</td></tr>'
f'<tr><th>aligned_section_ids</th><td>{_aligned}</td></tr>'
f'<tr><th>covered_section_ids</th><td>{_covered}</td></tr>'
f'<tr><th>filtered_section_ids</th><td>{_filtered}</td></tr>'
f'<tr><th>adapter_needed_count</th><td>{slide_status.get("adapter_needed_count", 0)}</td></tr>'
f'<tr><th>content_truncated_count</th><td>{slide_status.get("content_truncated_count", 0)}</td></tr>'
f'<tr><th>ai_repair_status</th><td>{_ai_repair_label}</td></tr>'
f'</table>'
f'<h2>Visual Fail Reasons</h2>{_vfs_html}'
f'<h2>Note</h2><p>{slide_status.get("note", "")}</p>'
),
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 도 아님
# IMP-87 u3 — BLOCKED exit on EMPTY_SHELL_NO_CONTENT precedes the legacy
# visual_fail / partial_coverage branches (Stage 2 axis A4). A slide whose
# every selected unit is an IMP-30 u4 empty-shell placeholder carries no
# MDX content; without this branch a Case B run could pass Selenium
# overflow checks (nothing to overflow) and silently return overall
# EMPTY_SHELL_NO_CONTENT without any CLI exit signal, violating
# feedback_artifact_status_naming.
if _is_blocked_overall(overall):
_aligned = slide_status.get("aligned_section_ids") or []
_empty_ids = slide_status.get("empty_shell_section_ids") or []
_empty_count = slide_status.get("empty_shell_unit_count", 0)
_content_count = slide_status.get("content_rendered_unit_count", 0)
print(
f"\n[Phase Z-2 IMP-87 u3] BLOCKED @ empty_shell_no_content ({overall})",
file=sys.stderr,
)
print(
" reason : every selected unit is IMP-30 u4 empty-shell placeholder "
"(no MDX content rendered)",
file=sys.stderr,
)
print(f" aligned : {_aligned}", file=sys.stderr)
print(f" shell : {_empty_ids}", file=sys.stderr)
print(
f" units : empty_shell_unit_count={_empty_count} "
f"content_rendered_unit_count={_content_count}",
file=sys.stderr,
)
sys.exit(1)
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"
),
)
# IMP-51 (#79) u5 — image override CLI flag. IMAGE_ID = stable id stamped
# on user-content `<img>` tags by src/image_id_stamper.py (u4). X,Y,W,H =
# percent-of-slide coordinates (0100, slide-absolute), consistent with
# the typed client `ImageOverride` shape (u3, userOverridesApi.ts) and
# the persisted `image_overrides` axis (u1, KNOWN_AXES). The render-time
# CSS injection consuming this mapping lands in u7; u5 is the CLI surface.
parser.add_argument(
"--override-image",
dest="override_image_overrides",
action="append",
default=[],
metavar="IMAGE_ID=X,Y,W,H",
help=(
"user-content image 의 slide-absolute geometry 강제. IMAGE_ID = "
"src/image_id_stamper.py 가 stamp 한 `data-image-id` value "
"(e.g., img-1a2b3c4d5e). X,Y,W,H = percent-of-slide (0100, "
"slide-absolute) — typed client ImageOverride shape 와 일치. "
"multiple flags: --override-image img-abc=10,15,30,25 "
"--override-image img-def=50,15,40,40"
),
)
# IMP-45 (#74) u5 — slide-level CSS override CLI surface. Two mutually
# exclusive flags carrying the same axis: ``--override-slide-css TEXT``
# passes inline CSS verbatim; ``--slide-css-file PATH`` reads UTF-8
# CSS from disk. Both forward into the single ``override_slide_css``
# kwarg on :func:`run_phase_z2_mvp1` (u4), which already mirrors the
# marker-wrapped image_id_stamper injection contract at Step 13.
# Manual mutual-exclusion (not ``add_mutually_exclusive_group``) so the
# error stderr can name the axis pair explicitly with ``sys.exit(2)``,
# matching sibling override surfaces (--override-frame /
# --override-zone-geometry / --override-image).
parser.add_argument(
"--override-slide-css",
dest="override_slide_css",
default=None,
metavar="CSS_TEXT",
help=(
"slide-level CSS override (inline). value is passed verbatim "
"to the run_phase_z2_mvp1 override_slide_css kwarg; an "
"optional outer `<style>` wrapper is allowed because the "
"injector wraps the payload in its own marker-bracketed "
"`<style>` block regardless. mutually exclusive with "
"--slide-css-file."
),
)
parser.add_argument(
"--slide-css-file",
dest="slide_css_file",
default=None,
type=Path,
metavar="PATH",
help=(
"slide-level CSS override (file). UTF-8-encoded CSS payload "
"read from PATH and forwarded to the override_slide_css "
"kwarg. fail-closed: missing file / non-UTF-8 bytes exit 2 "
"with stderr message. mutually exclusive with "
"--override-slide-css."
),
)
# IMP-46 u5 — auto-cache opt-in. When set, ``cache.save_proposal``
# bypasses the ``user_approved`` gate only (``visual_check_passed``
# is never bypassable). Source of truth is
# ``settings.ai_fallback_auto_cache`` (src/config.py); this flag
# mutates the setting in-process so downstream callers read the
# same value through Settings rather than parsing args themselves.
parser.add_argument(
"--auto-cache",
dest="auto_cache",
action="store_true",
default=False,
help=(
"Allow cache.save_proposal to bypass the user_approved gate "
"(visual_check_passed remains mandatory). Sets "
"settings.ai_fallback_auto_cache=True for this run."
),
)
# IMP-43 (#72) u1 — incremental rerun reuse pointer. Reuse target
# = Step 0/1/2/5/6 deterministic artifacts from a prior run; Step 7
# onward re-executes against the new frame overrides. Only frame
# overrides preserve the reusable subset (Stage 2 boundary lock);
# layout/geometry/section/image overrides invalidate it and are
# rejected by the post-merge guard below. Signature threading +
# snapshot copy/restore land in u5 and u4 respectively; this unit
# only adds the CLI surface + fail-closed precondition guard.
parser.add_argument(
"--reuse-from",
dest="reuse_from",
default=None,
metavar="PREV_RUN_ID",
help=(
"Reuse Step 0/1/2/5/6 artifacts from a previous run id "
"(directory under data/runs/<PREV_RUN_ID>/phase_z2) and resume "
"execution at Step 7. Only --override-frame is preserved; "
"--override-layout / --override-zone-geometry / "
"--override-section-assignment / --override-image invalidate "
"the reusable boundary and will be rejected."
),
)
args = parser.parse_args()
if args.auto_cache:
from src.config import settings as _settings
_settings.ai_fallback_auto_cache = True
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
# IMP-51 (#79) u5 — parse --override-image into dict[str, dict[str, float]].
# Mirrors --override-zone-geometry parsing pattern: each flag is
# IMAGE_ID=X,Y,W,H with 4 floats; multiple flags accumulate. Hard errors
# on missing `=` / wrong float count / non-numeric values / empty IMAGE_ID
# / duplicate IMAGE_ID. The on-disk schema (u1 KNOWN_AXES) and typed
# client (u3 ImageOverride) both expect percent-of-slide values in
# 0100; the CLI accepts floats without range clamping here so the
# error remains the user's mistake to read rather than a silent shift.
overrides_images: dict[str, dict[str, float]] = {}
for ov in args.override_image_overrides:
if "=" not in ov:
print(
f"[error] --override-image must be IMAGE_ID=X,Y,W,H, got: '{ov}'",
file=sys.stderr,
)
sys.exit(2)
iid, vals = ov.split("=", 1)
iid = iid.strip()
if not iid:
print(
f"[error] --override-image IMAGE_ID must be non-empty, got: '{ov}'",
file=sys.stderr,
)
sys.exit(2)
if iid in overrides_images:
print(
f"[error] --override-image duplicate IMAGE_ID '{iid}' "
f"(first assignment kept). Provide each image only once.",
file=sys.stderr,
)
sys.exit(2)
parts = vals.split(",")
if len(parts) != 4:
print(
f"[error] --override-image 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-image floats parse fail: '{vals}'",
file=sys.stderr,
)
sys.exit(2)
overrides_images[iid] = {"x": x, "y": y, "w": w, "h": h}
# IMP-45 (#74) u5 — resolve slide-level CSS override CLI surface to a
# single string. Mutually exclusive: both flags set → exit 2. File
# path read is UTF-8 strict; missing path or non-UTF-8 bytes → exit
# 2. When neither flag is set, ``override_slide_css`` stays ``None``
# and Step 13 falls back to the MDX ``slide_overrides.css``
# frontmatter via ``_resolve_slide_css_from_frontmatter`` (u4).
if args.override_slide_css is not None and args.slide_css_file is not None:
print(
"[error] --override-slide-css and --slide-css-file are mutually "
"exclusive; pass only one.",
file=sys.stderr,
)
sys.exit(2)
_final_override_slide_css: Optional[str] = args.override_slide_css
if args.slide_css_file is not None:
try:
_final_override_slide_css = args.slide_css_file.read_text(
encoding="utf-8"
)
except FileNotFoundError:
print(
f"[error] --slide-css-file path does not exist: "
f"{args.slide_css_file}",
file=sys.stderr,
)
sys.exit(2)
except UnicodeDecodeError as _exc:
print(
f"[error] --slide-css-file must be UTF-8 encoded "
f"({args.slide_css_file}): {_exc}",
file=sys.stderr,
)
sys.exit(2)
# IMP-52 (#80) u2 — user_overrides.json persistence fallback.
# After argparse fully parses CLI flags, fill ONLY the axes the user
# did NOT pass on the command line. CLI payload always wins over the
# persisted file (Stage 2 lock: "CLI > file, 결손 축만 채움").
# MDX stem keys the persistence file; invalid stems / corrupt file
# degrade gracefully (warning to stderr + no override injected).
from src.user_overrides_io import (
InvalidOverrideKey,
load as _load_user_overrides,
validate_key as _validate_overrides_key,
)
_final_override_layout = args.override_layout
try:
_ov_key = _validate_overrides_key(Path(args.mdx_path).stem)
except InvalidOverrideKey as _exc:
print(
f"[user_overrides] warning: cannot derive persistence key from "
f"mdx_path {args.mdx_path!r}: {_exc}; skipping fallback.",
file=sys.stderr,
)
_ov_key = None
if _ov_key is not None:
_persisted = _load_user_overrides(_ov_key)
# layout — CLI None → fill from file (must be str).
if _final_override_layout is None:
_file_layout = _persisted.get("layout")
if isinstance(_file_layout, str) and _file_layout:
_final_override_layout = _file_layout
# frames — CLI empty → fill from file (must be dict[str, str]).
if not overrides_frames:
_file_frames = _persisted.get("frames")
if isinstance(_file_frames, dict):
overrides_frames = {
str(k): str(v)
for k, v in _file_frames.items()
if isinstance(k, str) and isinstance(v, str)
}
# zone_geometries — CLI empty → fill from file (dict[str, dict]).
if not overrides_geoms:
_file_geoms = _persisted.get("zone_geometries")
if isinstance(_file_geoms, dict):
_accepted: dict[str, dict] = {}
for _zid, _g in _file_geoms.items():
if (
isinstance(_zid, str)
and isinstance(_g, dict)
and all(k in _g for k in ("x", "y", "w", "h"))
):
try:
_accepted[_zid] = {
"x": float(_g["x"]),
"y": float(_g["y"]),
"w": float(_g["w"]),
"h": float(_g["h"]),
}
except (TypeError, ValueError):
continue
overrides_geoms = _accepted
# zone_sections — CLI empty → fill from file (dict[str, list[str]]).
if not overrides_section_assignments:
_file_sections = _persisted.get("zone_sections")
if isinstance(_file_sections, dict):
_accepted_sec: dict[str, list[str]] = {}
for _zid, _sec_list in _file_sections.items():
if isinstance(_zid, str) and isinstance(_sec_list, list):
_sids = [s for s in _sec_list if isinstance(s, str) and s]
if _sids:
_accepted_sec[_zid] = _sids
overrides_section_assignments = _accepted_sec
# image_overrides — CLI empty → fill from file (dict[str, dict]).
# IMP-51 (#79) u6 — mirrors zone_geometries validation: only accept
# mappings of {image_id: {x,y,w,h}} with float-coercible values.
if not overrides_images:
_file_images = _persisted.get("image_overrides")
if isinstance(_file_images, dict):
_accepted_img: dict[str, dict] = {}
for _iid, _g in _file_images.items():
if (
isinstance(_iid, str)
and _iid
and isinstance(_g, dict)
and all(k in _g for k in ("x", "y", "w", "h"))
):
try:
_accepted_img[_iid] = {
"x": float(_g["x"]),
"y": float(_g["y"]),
"w": float(_g["w"]),
"h": float(_g["h"]),
}
except (TypeError, ValueError):
continue
overrides_images = _accepted_img
# IMP-43 (#72) u1 — fail-closed reuse_from precondition guard.
# Placed AFTER the user_overrides.json merge so persisted overrides
# are evaluated against the same reuse boundary as CLI overrides
# (Stage 2 lock: "fail-closed guard after user_overrides.json merge
# and before dispatch"). Reuse target = Step 0/1/2/5/6 deterministic
# artifacts; only frame overrides preserve that subset. layout /
# zone_geometry / zone_section / image overrides each invalidate at
# least one of Step 0/1/2/5/6 and must reject. Frame-only is allowed
# (no rejected axes → falls through to dispatch). Error stderr names
# every rejected axis so the user can either drop the rejected axes
# or rerun without --reuse-from.
if args.reuse_from is not None:
_rejected_axes: list[str] = []
if _final_override_layout is not None:
_rejected_axes.append("layout")
if overrides_geoms:
_rejected_axes.append("zone_geometry")
if overrides_section_assignments:
_rejected_axes.append("zone_section")
if overrides_images:
_rejected_axes.append("image")
if _rejected_axes:
print(
f"[error] --reuse-from incompatible with override axes: "
f"{', '.join(_rejected_axes)}. Only --override-frame is "
f"preserved across Step 0/1/2/5/6 reuse; drop the rejected "
f"overrides or rerun without --reuse-from.",
file=sys.stderr,
)
sys.exit(2)
run_phase_z2_mvp1(
args.mdx_path,
args.run_id,
override_layout=_final_override_layout,
override_frames=overrides_frames or None,
override_zone_geometries=overrides_geoms or None,
override_section_assignments=overrides_section_assignments or None,
override_image_overrides=overrides_images or None,
override_slide_css=_final_override_slide_css,
reuse_from=args.reuse_from,
)