- u1~u9: AI fallback infrastructure (router/prompts/schema/validator) + Step 12 hook - u10: e2e reject chain (writes final.html with AI-repaired slot, full coverage) - u11: frontend wiring deferred to follow-up commit (split from IMP-41 hunks) - u12: coverage_invariant guard - u13: cache save gate (visual_check PASS + user_approved/auto_cache) — Codex #22 verified Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
5867 lines
273 KiB
Python
5867 lines
273 KiB
Python
"""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 json
|
||
import os
|
||
import re
|
||
import shutil
|
||
import sys
|
||
import time
|
||
from dataclasses import asdict, dataclass, field
|
||
from pathlib import Path
|
||
from typing import Optional
|
||
|
||
import yaml
|
||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||
|
||
from phase_z2_composition import (
|
||
LAYOUT_PRESETS,
|
||
CompositionUnit,
|
||
derive_parent_id,
|
||
plan_composition,
|
||
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 route_fit_classification
|
||
from phase_z2_retry import (
|
||
DEFAULT_SAFETY_MARGIN_PX,
|
||
apply_cross_zone_redistribute_css,
|
||
apply_font_step_compression_css,
|
||
apply_glue_compression_css,
|
||
apply_retry_to_layout_css,
|
||
plan_cross_zone_redistribute,
|
||
plan_font_step_compression,
|
||
plan_glue_compression,
|
||
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
|
||
|
||
|
||
# ─── 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"
|
||
|
||
# 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 — scrollW−clientW or scrollH−clientH > 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")
|
||
|
||
|
||
# ─── 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.
|
||
"""
|
||
counts = {
|
||
"total": len(ai_repair_records),
|
||
"applied": 0,
|
||
"no_proposal": 0,
|
||
"no_zone_match": 0,
|
||
"unsupported_kind": 0,
|
||
"error": 0,
|
||
}
|
||
unsupported_records: list[dict] = []
|
||
error_records: list[dict] = []
|
||
for record in ai_repair_records:
|
||
if record.get("error"):
|
||
counts["error"] += 1
|
||
error_records.append({
|
||
"unit_index": record.get("unit_index"),
|
||
"source_section_ids": list(record.get("source_section_ids") or []),
|
||
"error": record.get("error"),
|
||
})
|
||
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,
|
||
"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
|
||
|
||
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 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"
|
||
][: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.
|
||
"""
|
||
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
|
||
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])
|
||
|
||
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"
|
||
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"
|
||
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 0.0
|
||
|
||
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":
|
||
# heights_px override — zone 의 h 비율로 SLIDE_BODY_HEIGHT 분배.
|
||
ratios = []
|
||
for pos in positions:
|
||
geom = override_zone_geometries.get(pos)
|
||
ratios.append(float(geom["h"]) if geom else 0.0)
|
||
total = sum(ratios)
|
||
if total > 0:
|
||
heights_px = [int(round(r / total * SLIDE_BODY_HEIGHT)) for r in ratios]
|
||
rows = " ".join(f"{h}px" for h in heights_px)
|
||
return {
|
||
"areas": preset["css_areas"],
|
||
"cols": preset["css_cols"],
|
||
"rows": rows,
|
||
"heights_px": heights_px,
|
||
"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": override_zone_geometries},
|
||
}
|
||
elif layout_preset == "vertical-2":
|
||
# 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.
|
||
ratios = []
|
||
for pos in positions:
|
||
geom = override_zone_geometries.get(pos)
|
||
ratios.append(float(geom["w"]) if geom else 0.0)
|
||
total = sum(ratios)
|
||
if total > 0:
|
||
cols = " ".join(f"{round(r / total * 100, 2)}fr" for r in ratios)
|
||
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": override_zone_geometries},
|
||
}
|
||
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.
|
||
return _override_to_grid_tracks(
|
||
preset, zones_data, override_zone_geometries, 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.
|
||
_SALVAGE_FAIL_BY_ACTION = {
|
||
"cross_zone_redistribute": "cross_zone_redistribute_insufficient",
|
||
"glue_compression": "glue_absorption_insufficient",
|
||
"font_step_compression": "font_step_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
|
||
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
|
||
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
|
||
|
||
|
||
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'"
|
||
)
|
||
env = Environment(
|
||
loader=FileSystemLoader(str(TEMPLATE_DIR)),
|
||
autoescape=select_autoescape(["html"]),
|
||
)
|
||
for zone in zones_data:
|
||
# Stage 4 Part 2 (Codex #10 Catch N) — empty zone produced by section
|
||
# assignment override has no partial template; render an empty string so
|
||
# the slide_base zones loop preserves grid identity without TemplateNotFound.
|
||
if zone.get("template_id") == "__empty__":
|
||
zone["partial_html"] = ""
|
||
continue
|
||
partial = env.get_template(f"families/{zone['template_id']}.html")
|
||
zone["partial_html"] = partial.render(slot_payload=zone["slot_payload"])
|
||
|
||
base = env.get_template("slide_base.html")
|
||
return 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,
|
||
)
|
||
|
||
|
||
# ─── 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 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 :
|
||
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]
|
||
covered = set()
|
||
for u in units:
|
||
covered.update(u.source_section_ids)
|
||
filtered_ids = sorted(set(aligned_ids) - covered)
|
||
full_coverage = len(filtered_ids) == 0
|
||
visual_passed = bool(overflow.get("passed", False))
|
||
|
||
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,
|
||
})
|
||
|
||
if 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),
|
||
"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
|
||
|
||
|
||
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.
|
||
"""
|
||
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-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,
|
||
}
|
||
|
||
|
||
# ─── Main entry ────────────────────────────────────────────────
|
||
|
||
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,
|
||
) -> Path:
|
||
"""MVP-1.5b entry — single slide + composition planner v0 + 8 preset vocabulary.
|
||
|
||
Pipeline :
|
||
parse_mdx → align_sections_to_v4_granularity → plan_composition →
|
||
mapper per unit → render slide_base + frame partial → Selenium check
|
||
|
||
User overrides (Step 7-A axis, 2026-05-08) :
|
||
override_layout : 자동 결정된 layout_preset 을 사용자 선택값으로 강제 (8 preset 중 하나).
|
||
override_frames : {unit_id: template_id} — 자동 결정된 frame template 을 사용자 선택값
|
||
으로 강제. unit_id = "+".join(source_section_ids) (e.g., "03-1"
|
||
또는 "03-1+03-2"). 매칭 unit 의 v4_candidates 에 있는 entry 면
|
||
그 entry 의 score / label 도 함께 갱신. 없으면 template_id 만 변경.
|
||
"""
|
||
mdx_path = Path(mdx_path)
|
||
if run_id is None:
|
||
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}")
|
||
|
||
# ─── Step 0: 사전 준비 (precondition snapshot) ───
|
||
_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),
|
||
},
|
||
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,
|
||
)
|
||
|
||
print(f" preset : {layout_preset} ({len(units)} units, composition v0 count-based)")
|
||
for u in units:
|
||
print(f" unit : {u.source_section_ids} merge={u.merge_type} → "
|
||
f"frame {u.frame_number} ({u.frame_template_id}) "
|
||
f"label={u.label} score={u.score:.3f}")
|
||
|
||
# ─── Step 6: Composition Planning ───
|
||
_write_step_artifact(
|
||
run_dir, 6, "composition_plan",
|
||
data={
|
||
"selected_units_count": len(units),
|
||
"layout_preset_decided": layout_preset,
|
||
"candidates_summary": comp_debug.get("candidates_summary"),
|
||
"candidates_total": comp_debug.get("candidates_total"),
|
||
"candidates_viable_auto": comp_debug.get("candidates_viable_auto"),
|
||
"selected_units": [
|
||
{
|
||
"source_section_ids": u.source_section_ids,
|
||
"merge_type": u.merge_type,
|
||
"frame_id": u.frame_id,
|
||
"frame_number": u.frame_number,
|
||
"frame_template_id": u.frame_template_id,
|
||
"label": u.label,
|
||
"v4_rank": u.v4_rank,
|
||
"selection_path": u.selection_path,
|
||
"fallback_reason": u.fallback_reason,
|
||
"score": u.score,
|
||
"phase_z_status": u.phase_z_status,
|
||
"rationale": u.rationale,
|
||
"notes": list(u.notes),
|
||
# Step 6-A axis (사용자 lock 2026-05-08) — V4 후보 list.
|
||
# 단일 frame_* / label / confidence 와 일관 (candidates[0] = rank-1 non-reject).
|
||
"v4_candidates": [
|
||
{
|
||
"template_id": c.template_id,
|
||
"frame_id": c.frame_id,
|
||
"frame_number": c.frame_number,
|
||
"confidence": c.confidence,
|
||
"label": c.label,
|
||
}
|
||
for c in u.v4_candidates
|
||
],
|
||
}
|
||
for u in units
|
||
],
|
||
},
|
||
step_status="done",
|
||
pipeline_path_connected=True,
|
||
inputs=["step02_normalized.json", "step05_v4_evidence.json"],
|
||
outputs=["step06_composition_plan.json"],
|
||
note=(
|
||
"composition v0 count-based — sections → candidates → score → greedy select. "
|
||
"Step 6-A (사용자 lock 2026-05-08): selected_units[i].v4_candidates 추가 "
|
||
"(non-reject max-6 후보 list, candidates[0] = 단일 frame_* 와 일관). "
|
||
"logic 무변 — runtime 결과 동일. Step 9 application_plan input."
|
||
),
|
||
)
|
||
|
||
# 5. Per-unit: synthesize MdxSection → mapper → assets → zone data
|
||
# mapper FitError 는 catch — 자동 파이프라인은 다른 zone 계속 진행. abort X.
|
||
positions = LAYOUT_PRESETS[layout_preset]["positions"]
|
||
zones_data = []
|
||
debug_zones = []
|
||
adapter_needed_units: list[dict] = []
|
||
|
||
# ── Step 7-A axis : frame override ──
|
||
# {unit_id: template_id} 형식. unit_id 매칭 시 unit.frame_template_id 강제 변경.
|
||
# v4_candidates 안에서 같은 template_id 를 가진 entry 를 찾으면 frame_id /
|
||
# frame_number / confidence / label 까지 그 entry 에서 가져와 갱신 — 그래야 step09
|
||
# artifact 의 메타가 일관됨. 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__":
|
||
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)),
|
||
})
|
||
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 ───
|
||
|
||
# mapper 시도 — 실패 (FitError) 시 zone 을 adapter_needed 로 표시하고 skip
|
||
try:
|
||
slot_payload = map_mdx_to_slots(synth_section, unit.frame_template_id)
|
||
except FitError as e:
|
||
adapter_record = {
|
||
"position": position,
|
||
"source_section_ids": unit.source_section_ids,
|
||
"merge_type": unit.merge_type,
|
||
"template_id": unit.frame_template_id,
|
||
"reason": "fit_error",
|
||
"fit_error": str(e),
|
||
}
|
||
adapter_needed_units.append(adapter_record)
|
||
print(f" adapter : zone--{position} {unit.source_section_ids} → "
|
||
f"{unit.frame_template_id} FitError → adapter_needed (skip render)")
|
||
continue
|
||
|
||
run_dir.mkdir(parents=True, exist_ok=True)
|
||
assets_dir = copy_assets(unit.frame_template_id, run_dir)
|
||
content_weight = compute_content_weight(synth_section)
|
||
truncated_count = slot_payload.get("_truncated_count") # builder 가 truncate 한 경우
|
||
|
||
# IMP-06 blocker-fix (Codex #13/#14/#15/#16/#17) — plan-aware additive
|
||
# fields. When no override CLI was used (plan_record is None), these
|
||
# default to None/False/[] so pre-IMP-06 readers see byte-equivalent
|
||
# data. Empty zone records appended below (post-loop) carry the same
|
||
# field shape from their plan entry directly.
|
||
plan_assignment_source = (
|
||
plan_record.get("assignment_source") if plan_record else None
|
||
)
|
||
plan_section_override = (
|
||
bool(plan_record.get("section_assignment_override"))
|
||
if plan_record else False
|
||
)
|
||
plan_replaced_auto = (
|
||
plan_record.get("replaced_auto_unit") if plan_record else None
|
||
)
|
||
plan_skipped_collided = list(
|
||
plan_record.get("skipped_collided_auto_units") or []
|
||
) if plan_record else []
|
||
plan_uncovered = list(
|
||
plan_record.get("uncovered_section_ids") or []
|
||
) if plan_record else []
|
||
plan_skipped_reason = (
|
||
plan_record.get("skipped_reason") if plan_record else None
|
||
)
|
||
|
||
# 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.
|
||
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)),
|
||
})
|
||
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
|
||
zones_data.append({
|
||
"position": pos,
|
||
"template_id": "__empty__",
|
||
"slot_payload": {},
|
||
"content_weight": {"score": 0},
|
||
"min_height_px": 0,
|
||
"assignment_source": "empty",
|
||
"skipped_reason": (
|
||
record.get("skipped_reason")
|
||
or "section_assignment_override_empty_or_unrenderable"
|
||
),
|
||
})
|
||
debug_zones.append({
|
||
"position": pos,
|
||
"source_section_ids": list(record.get("source_section_ids") or []),
|
||
"merge_type": "empty",
|
||
"title": "",
|
||
"v4_template_id": "__empty__",
|
||
"v4_label": None,
|
||
"v4_confidence": 0.0,
|
||
"selection_path": "section_assignment_empty",
|
||
"fallback_reason": None,
|
||
"fallback_used": False,
|
||
"phase_z_status": None,
|
||
"composition_score": 0.0,
|
||
"composition_rationale": {},
|
||
"composition_notes": [],
|
||
"mapper_type": "empty",
|
||
"contract_id": "__empty__",
|
||
"contract_frame_id": None,
|
||
"assignment_source": record.get("assignment_source"),
|
||
"skipped_reason": (
|
||
record.get("skipped_reason")
|
||
or "section_assignment_override_empty_or_unrenderable"
|
||
),
|
||
"section_assignment_override": record.get("section_assignment_override"),
|
||
"replaced_auto_unit": record.get("replaced_auto_unit"),
|
||
"skipped_collided_auto_units": list(record.get("skipped_collided_auto_units") or []),
|
||
"uncovered_section_ids": list(record.get("uncovered_section_ids") or []),
|
||
})
|
||
|
||
# ─── Step 3: Content Object 추출 (B1, trace-only) ───
|
||
# IMP-03 — slide-level rich ContentObject 추출 (default OFF canary).
|
||
# scope-lock 16 조건 (Gitea #3) :
|
||
# - 별 함수 (extract_rich_content_objects) — v0 extract_content_objects unchanged
|
||
# - slide-level — section_id=None, id=`{mdx_id}.{type}-N`, scope='slide'
|
||
# - root-level once (per-zone duplication X)
|
||
# - plan_placement() 는 v0 list 만 받음 (B4 회귀 X) — 본 rich 결과는 artifact only
|
||
# - transform_table dedup : arrow row 감지 시 skip
|
||
rich_flag = os.environ.get("PHASE_Z_STEP3_RICH_OBJECTS_ENABLED", "").strip().lower()
|
||
rich_enabled_flag = rich_flag in {"1", "true", "yes"}
|
||
_assets_total = (
|
||
len(stage0_normalized_assets.get("popups") or [])
|
||
+ len(stage0_normalized_assets.get("images") or [])
|
||
+ len(stage0_normalized_assets.get("tables") or [])
|
||
)
|
||
rich_disabled_reason: Optional[str] = None
|
||
if not rich_enabled_flag:
|
||
rich_disabled_reason = "FLAG_OFF"
|
||
elif _assets_total == 0:
|
||
rich_disabled_reason = "NO_NORMALIZED_ASSETS"
|
||
|
||
rich_objects: list = []
|
||
rich_skips: list = []
|
||
if rich_disabled_reason is None:
|
||
mdx_num_match = re.match(r"(\d+)", mdx_path.stem)
|
||
rich_mdx_id = mdx_num_match.group(1).zfill(2) if mdx_num_match else "00"
|
||
rich_objects, rich_skips = extract_rich_content_objects(
|
||
stage0_normalized_assets, mdx_id=rich_mdx_id,
|
||
)
|
||
|
||
# Count/list invariant check (IMP-02 ↔ IMP-03 chain) — soft warning, no fail.
|
||
invariant_warnings: list[dict] = []
|
||
_adapter_counts = (stage0_adapter_diagnostics or {}).get("adapter_counts") or {}
|
||
if _adapter_counts:
|
||
for key in ("popups", "images", "tables"):
|
||
expected = _adapter_counts.get(key)
|
||
actual = len(stage0_normalized_assets.get(key) or [])
|
||
if expected is not None and expected != actual:
|
||
invariant_warnings.append({
|
||
"field": key,
|
||
"adapter_counts": expected,
|
||
"stage0_normalized_assets_len": actual,
|
||
})
|
||
|
||
_write_step_artifact(
|
||
run_dir, 3, "content_objects",
|
||
data={
|
||
"per_zone": [
|
||
{
|
||
"position": dz["position"],
|
||
"section_ids": dz["source_section_ids"],
|
||
"internal_regions": (dz.get("placement_trace") or {}).get("internal_regions") or [],
|
||
}
|
||
for dz in debug_zones
|
||
],
|
||
# IMP-03 — slide-level rich trace (additive, trace-only).
|
||
"rich_content_objects": [asdict(o) for o in rich_objects],
|
||
"rich_content_objects_enabled": rich_disabled_reason is None,
|
||
"rich_content_objects_scope": "slide",
|
||
"rich_content_objects_source": "stage0_normalized_assets",
|
||
"rich_content_objects_disabled_reason": rich_disabled_reason,
|
||
"rich_content_objects_skips": rich_skips,
|
||
"rich_content_objects_invariant_warnings": invariant_warnings,
|
||
},
|
||
step_status="trace-only",
|
||
pipeline_path_connected=False,
|
||
inputs=["step02_normalized.json"],
|
||
outputs=["step03_content_objects.json"],
|
||
note=(
|
||
"현재는 trace 로 기록되지만 render payload 를 직접 만들지는 않음. "
|
||
"mapper.py 가 별도로 MDX 직접 파싱. "
|
||
"IMP-03 rich_content_objects = slide-level popup/image/table trace "
|
||
"(PHASE_Z_STEP3_RICH_OBJECTS_ENABLED canary, default OFF)."
|
||
),
|
||
)
|
||
|
||
# ─── Step 4: Section Internal Composition (B2, trace-only) ───
|
||
_write_step_artifact(
|
||
run_dir, 4, "internal_composition",
|
||
data={
|
||
"per_zone": [
|
||
{
|
||
"position": dz["position"],
|
||
"section_ids": dz["source_section_ids"],
|
||
"selected_template_id": (dz.get("placement_trace") or {}).get("selected_template_id"),
|
||
"frame_selection_matches_mapper": (dz.get("placement_trace") or {}).get("frame_selection_matches_mapper"),
|
||
"frame_selection_match_note": (dz.get("placement_trace") or {}).get("frame_selection_match_note"),
|
||
}
|
||
for dz in debug_zones
|
||
],
|
||
},
|
||
step_status="trace-only",
|
||
pipeline_path_connected=False,
|
||
inputs=["step03_content_objects.json"],
|
||
outputs=["step04_internal_composition.json"],
|
||
note="현재는 trace 로 기록되지만 render payload 를 직접 만들지는 않음. composition_planner_debug 가 main 결정 (Step 6).",
|
||
)
|
||
|
||
# ─── Step 9: Frame Selection (per zone) ───
|
||
_write_step_artifact(
|
||
run_dir, 9, "frame_selection",
|
||
data={
|
||
"per_zone": [
|
||
{
|
||
"position": dz["position"],
|
||
"v4_rank1_frame_number": dz.get("v4_rank1_frame_number"),
|
||
"v4_selected_rank": dz.get("v4_selected_rank"),
|
||
"v4_template_id": dz.get("v4_template_id"),
|
||
"v4_confidence": dz.get("v4_confidence"),
|
||
"v4_label": dz.get("v4_label"),
|
||
"selection_path": dz.get("selection_path"),
|
||
"fallback_reason": dz.get("fallback_reason"),
|
||
"phase_z_status": dz.get("phase_z_status"),
|
||
"selected_template_id": dz.get("contract_id"),
|
||
"mapper_type": dz.get("mapper_type"),
|
||
"composition_score": dz.get("composition_score"),
|
||
"composition_rationale": dz.get("composition_rationale"),
|
||
}
|
||
for dz in debug_zones
|
||
],
|
||
},
|
||
step_status="partial",
|
||
pipeline_path_connected=True,
|
||
inputs=["step05_v4_evidence.json", "step06_composition_plan.json"],
|
||
outputs=["step09_frame_selection.json", "step09_frame_matching_candidates.html"],
|
||
note="V4 evidence 와 B4 통합 미완 — 별 axis. 현재 = composition planner 의 V4 rank-1 채택.",
|
||
)
|
||
# Step 9 HTML — V4 top candidates per zone (rank 1~4)
|
||
# IMP-08 N-R6 diagnostic exemption : this report path is post-decision
|
||
# reporting only. Runtime selection goes through _resolve_v4_section_key
|
||
# (4 sites). Direct dict lookup here is intentional — debug_zones carries
|
||
# dict-shape entries without v4_alias_keys plumbing, and a miss here only
|
||
# yields a "V4 entry missing" report line (runtime impact zero).
|
||
try:
|
||
with open(V4_RESULT_PATH, encoding="utf-8") as _vf:
|
||
_v4_full = yaml.safe_load(_vf)
|
||
_v4_sections = (_v4_full or {}).get("mdx_sections", {}) or {}
|
||
except Exception:
|
||
_v4_sections = {}
|
||
_candidates_html = ""
|
||
# path from steps/ 에서 figma_to_html_agent/blocks/{frame_id}/index.html
|
||
# steps → phase_z2 → run_id → runs → data → design_agent (5 levels up)
|
||
_frame_iframe_base = "../../../../../figma_to_html_agent/blocks"
|
||
for dz in debug_zones:
|
||
_section_ids = dz.get("source_section_ids", [])
|
||
_candidates_html += f"<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).",
|
||
)
|
||
|
||
# 6. Build layout CSS — horizontal-2 = dynamic heights (regression preserve), 그 외 = fr default.
|
||
# Step D-ext : override_zone_geometries 가 들어오면 layout_css 강제.
|
||
layout_css = build_layout_css(
|
||
layout_preset, zones_data, override_zone_geometries=override_zone_geometries
|
||
)
|
||
# IMP-09 PR 1 — unified per-zone geometry aggregation across all
|
||
# layouts. Spanning zones in 2-D layouts (T / 2x2 from PR 2 onward)
|
||
# are handled by _compute_per_zone_geometry; in PR 1 the helper
|
||
# operates on row/col-static or row-dynamic / col-dynamic outputs.
|
||
per_zone_geo = _compute_per_zone_geometry(layout_css, debug_zones, GRID_GAP)
|
||
for dz, geo in zip(debug_zones, per_zone_geo):
|
||
dz["height_px"] = geo["zone_height_px"]
|
||
dz["ratio"] = geo["zone_height_ratio"]
|
||
dz["width_px"] = geo["zone_width_px"]
|
||
dz["width_ratio"] = geo["zone_width_ratio"]
|
||
if layout_css["dynamic_rows"] and layout_css.get("dynamic_cols"):
|
||
print(
|
||
f" zones : 2-D heights {layout_css['heights_px']} px, "
|
||
f"widths {layout_css['widths_px']} px, "
|
||
f"ratios {layout_css['ratios']}, "
|
||
f"width_ratios {layout_css['width_ratios']}"
|
||
)
|
||
elif layout_css["dynamic_rows"]:
|
||
print(f" zones : heights {layout_css['heights_px']} px, ratios {layout_css['ratios']}")
|
||
elif layout_css.get("dynamic_cols"):
|
||
print(f" zones : widths {layout_css['widths_px']} px, width_ratios {layout_css['width_ratios']}")
|
||
else:
|
||
print(f" zones : fr default ({layout_css['cols']} / {layout_css['rows']})")
|
||
|
||
# ─── Step 7: Slide-Level Layout (선택된 layout + 후보 list — Step 7-conn) ───
|
||
# Step 7-conn axis (사용자 lock 2026-05-08) — select_layout_candidates(unit_count)
|
||
# 결과를 artifact 에 기록 (passive). default 결정 자체는 무변 (layout_preset 그대로).
|
||
layout_candidates_list = select_layout_candidates(len(units))
|
||
_write_step_artifact(
|
||
run_dir, 7, "layout",
|
||
data={
|
||
"layout_preset": layout_preset,
|
||
"layout_css": layout_css,
|
||
"zones_count": len(zones_data),
|
||
"unit_count": len(units),
|
||
"layout_candidates": layout_candidates_list,
|
||
# Step 7-A axis : user override trace
|
||
"layout_override_applied": layout_override_applied,
|
||
"auto_layout_preset": auto_layout_preset,
|
||
},
|
||
step_status="partial",
|
||
pipeline_path_connected=True,
|
||
inputs=["step06_composition_plan.json"],
|
||
outputs=["step07_layout.json", "step07_selected_layout.html"],
|
||
note=(
|
||
"count-based v0 — 들여쓰기 / 정렬 미세 layout 미구현 (Step 7 ⚠ partial). "
|
||
"Step 7-conn (사용자 lock 2026-05-08): unit_count → layout_candidates list "
|
||
"(select_layout_candidates) artifact 기록. default 결정 자체는 무변 — "
|
||
"candidates[0] 가 default (default_selection: true), 나머지는 alternative. "
|
||
"Step 9 application_plan input."
|
||
),
|
||
)
|
||
# Step 7 HTML — slide-base 위 실제 zone 박스 위치 시각화 (constants 와 1:1 일치)
|
||
_slide_w, _slide_h, _body_h = SLIDE_W, SLIDE_H, SLIDE_BODY_HEIGHT
|
||
_zone_palette = ["#dbeafe", "#fef3c7", "#dcfce7", "#fce7f3", "#ede9fe"]
|
||
_zone_borders = ["#3b82f6", "#f59e0b", "#16a34a", "#db2777", "#7c3aed"]
|
||
_grid_template = layout_css.get("rows") or "1fr"
|
||
_zone_visuals_7 = ""
|
||
for i, dz in enumerate(debug_zones):
|
||
_color = _zone_palette[i % len(_zone_palette)]
|
||
_border = _zone_borders[i % len(_zone_borders)]
|
||
_zone_visuals_7 += (
|
||
f'<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)
|
||
|
||
# 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)
|
||
|
||
# ─── Step 17: Implemented Action (retry) ───
|
||
_write_step_artifact(
|
||
run_dir, 17, "retry_trace",
|
||
data=retry_trace,
|
||
step_status=(
|
||
"done" if retry_trace.get("retry_passed") or retry_trace.get("salvage_passed")
|
||
else "failed" if retry_trace.get("retry_attempted") and not retry_trace.get("retry_passed")
|
||
else "skipped"
|
||
),
|
||
pipeline_path_connected=True,
|
||
inputs=["step16_router_decision.json"],
|
||
outputs=["step17_retry_trace.json"],
|
||
note=(
|
||
"A3 — zone_ratio_retry + IMP-12 u8/u9 salvage cascade "
|
||
"(cross_zone_redistribute → glue_compression → font_step_compression). "
|
||
"Terminal actions (layout_adjust / frame_reselect / details_popup_escalation) still MISSING."
|
||
),
|
||
)
|
||
|
||
# ─── Step 18: Failure Classification (A4-1) ───
|
||
_write_step_artifact(
|
||
run_dir, 18, "failure_classification",
|
||
data=retry_trace.get("failure_classification") or {},
|
||
step_status="done" if retry_trace.get("failure_classification") else "skipped",
|
||
pipeline_path_connected=True,
|
||
inputs=["step17_retry_trace.json"],
|
||
outputs=["step18_failure_classification.json"],
|
||
note="A4-1 — retry 실패 시 failure_type 분류.",
|
||
)
|
||
|
||
# ─── Step 19: Next Action Proposal (A4-2) ───
|
||
_write_step_artifact(
|
||
run_dir, 19, "next_action",
|
||
data=retry_trace.get("next_action_proposal") or {},
|
||
step_status="partial" if retry_trace.get("next_action_proposal") else "skipped",
|
||
pipeline_path_connected=False,
|
||
inputs=["step18_failure_classification.json"],
|
||
outputs=["step19_next_action.json"],
|
||
note="A4-2 — failure_type → next_proposed_action 1-D mapping. 실제 action 자체는 미구현 (impl_status=MISSING).",
|
||
)
|
||
|
||
# 12. Slide status — 자동 파이프라인 결과 보고 (review/UI 개념 X)
|
||
slide_status = compute_slide_status(
|
||
sections, units, comp_debug, overflow,
|
||
adapter_needed_units=adapter_needed_units,
|
||
debug_zones=debug_zones,
|
||
)
|
||
|
||
# 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", "?")
|
||
_ov_class = "pass" if "PASS" in _overall else "fail" if "FAIL" in _overall or "REGRESSION" in _overall else "partial"
|
||
_vfs = slide_status.get("visual_fail_reasons") or []
|
||
_vfs_html = (
|
||
"<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 도 아님
|
||
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-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."
|
||
),
|
||
)
|
||
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
|
||
|
||
run_phase_z2_mvp1(
|
||
args.mdx_path,
|
||
args.run_id,
|
||
override_layout=args.override_layout,
|
||
override_frames=overrides_frames or None,
|
||
override_zone_geometries=overrides_geoms or None,
|
||
override_section_assignments=overrides_section_assignments or None,
|
||
)
|