Some checks failed
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 22s
u1 KNOWN_AXES tuple gains slide_css entry in src/user_overrides_io.py
(snake_case parity with image_overrides); round-trip test extends
to 6 axes.
u2 src/mdx_normalizer.py surfaces nested slide_overrides.css from the
MDX frontmatter into the normalize_mdx_content return dict; absent
key -> {}, non-string css drops. 4 unit cases in tests/test_mdx_normalizer.py
(present / absent / non-string / title-only).
u3 src/slide_css_injector.py NEW (88 lines) mirrors the
inject_image_overrides_style contract from src/image_id_stamper.py:
marker pair <!--IMP45-SLIDE-CSS:OPEN--> / <!--IMP45-SLIDE-CSS:CLOSE-->,
idempotent re-injection, </head> > <body> > document-start three-tier
fallback, empty/None -> unchanged. 8 fixtures in
tests/test_slide_css_injector.py mirror test_image_id_stamper.py.
u4 run_phase_z2_mvp1 accepts override_slide_css: Optional[str] = None;
None -> frontmatter slide_overrides.css fallback. Step 13 calls
inject_slide_css after image override injection and before the
final.html disk write, so CLI/CI/regression renders observe the same
backend artifact.
u5 argparse adds mutually-exclusive --override-slide-css TEXT (inline
CSS, <style> wrapper optional) and --slide-css-file PATH (UTF-8 read,
fail-closed sys.exit(2) on missing path / decode error / both flags
present). Resolved string is forwarded as override_slide_css kwarg.
6 cases in tests/test_phase_z2_cli_overrides.py (inline / file / both
/ missing / non-utf8 / neither).
u6 samples/mdx_batch/04.mdx frontmatter gains slide_overrides.css
block (verbatim of the former MDX04_DEFAULT_OVERRIDE_CSS constant,
no sample/frame gate). Subprocess smoke in
tests/test_phase_z2_slide_css_smoke.py verifies the marker pair and
CSS substring land in final.html.
u7 Front/client removes the sample/frame-gated frontend-only injection:
Home.tsx drops the MDX04_DEFAULT_OVERRIDE_CSS constant and the
sample==="04"+frame==="process_product_two_way" branch (-28 lines);
SlideCanvas.tsx drops the iframe contentDocument.head injection of
that prop (-14 lines). Live preview now reads backend final.html only.
u8 tests/regression/fixtures/89a_pre_baseline_sha.json 04.mdx entry
resyncs to the live SHA ddb6bf2f... / 28042 bytes (overwrites the
earlier 5-byte-drift d02c76fd... / 28047). Other entries untouched.
Note: 01.mdx baseline drift (ad6f16a3... / 29089 -> live f26a7fac...
/ 29084) predates this branch and is split to a follow-up issue per
the closed-issue fresh validation rule.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
8343 lines
392 KiB
Python
8343 lines
392 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 hashlib
|
||
import json
|
||
import os
|
||
import re
|
||
import shutil
|
||
import sys
|
||
import time
|
||
from dataclasses import asdict, dataclass, field
|
||
from pathlib import Path
|
||
from typing import Any, Optional
|
||
|
||
import yaml
|
||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||
|
||
from phase_z2_composition import (
|
||
LAYOUT_PRESETS,
|
||
CompositionUnit,
|
||
compose_zone_popup_payload,
|
||
derive_parent_id,
|
||
plan_composition,
|
||
resplit_all_reject_merges,
|
||
select_display_strategy_candidates,
|
||
select_layout_candidates,
|
||
select_region_layout_candidates,
|
||
)
|
||
from phase_z2_mapper import (
|
||
FitError,
|
||
compute_capacity_fit,
|
||
get_contract,
|
||
load_frame_contracts,
|
||
load_v4_fallback_policy,
|
||
map_with_contract,
|
||
)
|
||
from phase_z2_classifier import classify_visual_runtime_check
|
||
from phase_z2_router import plan_details_popup_escalation, route_fit_classification
|
||
from phase_z2_retry import (
|
||
DEFAULT_SAFETY_MARGIN_PX,
|
||
apply_cross_zone_redistribute_css,
|
||
apply_font_step_compression_css,
|
||
apply_frame_internal_fit_candidate_css,
|
||
apply_glue_compression_css,
|
||
apply_image_fit_css,
|
||
apply_layout_adjust_layout_css,
|
||
apply_retry_to_layout_css,
|
||
plan_cross_zone_redistribute,
|
||
plan_font_step_compression,
|
||
plan_frame_internal_fit_candidate,
|
||
plan_glue_compression,
|
||
plan_image_fit,
|
||
plan_layout_adjust,
|
||
plan_zone_ratio_retry,
|
||
)
|
||
from phase_z2_failure_router import (
|
||
enrich_retry_trace_with_failure_classification,
|
||
route_retry_failure,
|
||
)
|
||
|
||
# trace-only runtime 연결 v0 — B1 → B4 chain.
|
||
# final.html / mapper / render path 미영향. debug_zones[i].placement_trace 만 기록.
|
||
from phase_z2_content_extractor import extract_content_objects, extract_rich_content_objects
|
||
from phase_z2_placement_planner import plan_placement
|
||
|
||
# IMP-47B u4 — Step 12 AI repair wiring. gather() short-circuits at the
|
||
# router when settings.ai_fallback_enabled is False (default), so import
|
||
# at module load is safe for the AI=0 normal path (PZ-1). Activation gate
|
||
# stays in src/config.py + src/phase_z2_ai_fallback/router.py.
|
||
from src.phase_z2_ai_fallback.step12 import gather_step12_ai_repair_proposals
|
||
|
||
# IMP-35 (#64) u5 — Step 17 deterministic POPUP gate executor. Runs after
|
||
# the salvage cascade exhausts at cascade-terminal action
|
||
# ``details_popup_escalation`` (router u3 / failure_router u2) and BEFORE
|
||
# the AI_REPAIR cascade stage. Stamps ``popup_escalation_plan`` and the
|
||
# idempotent ``has_popup`` marker onto retry_trace per unit. No AI call.
|
||
from src.phase_z2_ai_fallback.step17 import run_step17_popup_gate
|
||
|
||
# IMP-43 (#72) u3 — Step 6 reuse snapshot sidecar (JSON-only). Schema +
|
||
# serializers + validator live in u2 (``src.phase_z2_reuse_snapshot``);
|
||
# this module's call site at the Step 6 boundary writes the sidecar
|
||
# alongside ``steps/step06_composition_plan.json`` so that future
|
||
# ``--reuse-from`` runs (u4) can resume at Step 7 without re-deriving
|
||
# Step 0/1/2/5/6 state. ``--reuse-from`` is u4 scope; here we only
|
||
# WRITE the snapshot — restore wiring lands in u4.
|
||
from src.phase_z2_reuse_snapshot import build_snapshot, SNAPSHOT_FILENAME
|
||
|
||
|
||
# ─── Constants ──────────────────────────────────────────────────
|
||
|
||
PROJECT_ROOT = Path(__file__).parent.parent
|
||
TEMPLATE_DIR = PROJECT_ROOT / "templates" / "phase_z2"
|
||
ASSETS_SOURCE_BASE = PROJECT_ROOT / "figma_to_html_agent" / "blocks"
|
||
V4_RESULT_PATH = PROJECT_ROOT / "tests" / "matching" / "v4_full32_result.yaml"
|
||
RUNS_DIR = PROJECT_ROOT / "data" / "runs"
|
||
|
||
# IMP-39 (#68) u1 — single-source ranking sort policy yaml.
|
||
# Loader + apply_ranking_sort helper below `to_phase_z_status`.
|
||
RANKING_SORT_POLICY_PATH = (
|
||
PROJECT_ROOT / "templates" / "phase_z2" / "catalog" / "ranking_sort_policy.yaml"
|
||
)
|
||
|
||
# V4 label → Phase Z status (§ 7.4 매트릭스)
|
||
V4_LABEL_TO_PHASE_Z_STATUS = {
|
||
"use_as_is": "matched_zone",
|
||
"light_edit": "adapt_matched_zone",
|
||
"restructure": "extract_matched_zone",
|
||
"reject": "fallback_candidate",
|
||
}
|
||
MVP1_ALLOWED_STATUSES = {"matched_zone", "adapt_matched_zone"}
|
||
|
||
# Step 9 v0 (사용자 lock 2026-05-08) — V4 label → application_mode 변환.
|
||
# tuple = (application_mode, auto_applicable, delegated_to).
|
||
# status.md §2 Q3 / Q7 lock 따라.
|
||
APPLICATION_MODE_BY_V4_LABEL = {
|
||
"use_as_is": ("direct_insert", True, "step10_contract_check"),
|
||
"light_edit": ("same_frame_with_adjustment", True, "step10_contract_check"),
|
||
"restructure": ("layout_or_region_change", False, "human_review"),
|
||
"reject": ("exclude", False, None),
|
||
}
|
||
# adapt_matched_zone (V4 light_edit) = frame 구조 동일, 텍스트만 minor edit 필요.
|
||
# minor edit 정책 (mapper 의무) :
|
||
# 1. MDX item 수 < frame slot 수 → 빈 slot 그대로 (Jinja2 {% if %} 로 스킵)
|
||
# 2. MDX item 수 > frame slot 수 → 추가 item 누락 (truncate) — debug.json 에 기록
|
||
# 3. 텍스트 길이 mismatch → 그대로 통과 (overflow 는 zone-fit + Selenium check 가 처리)
|
||
# 4. slot ↔ MDX item 의미 매핑 → 순서 기반 (간단). V4 anchor_match 정교화는 future
|
||
# AI 호출 X — MVP-1.5b 의 "MDX 1:1 결정론적 매핑" 룰 그대로.
|
||
|
||
# Slide canvas / body geometry — front 기준 정상화 (2026-05-07)
|
||
# 참조: D:/ad-hoc/kei/design_agent_front/slide-base.html
|
||
# slide-base.html CSS 와 1:1 일치해야 함 (불일치 시 layout 계산 어긋남)
|
||
SLIDE_W = 1280
|
||
SLIDE_H = 720
|
||
SLIDE_BODY_LEFT = 50
|
||
SLIDE_BODY_TOP = 76 # 사용자 직설 (divider-body 16px 여백) 2026-05-07
|
||
SLIDE_BODY_WIDTH = 1180 # calc(100% - 100px)
|
||
SLIDE_BODY_HEIGHT = 585 # 사용자 직설 (body-footer 10px 여백) 2026-05-07
|
||
SLIDE_FOOTER_HEIGHT = 41 # was 32, front 기준
|
||
GRID_GAP = 14 # zone 간격 (사용자 직설 2026-05-07)
|
||
|
||
# zone min-height fallback — contract 에 visual_hints.min_height_px 없을 때 사용.
|
||
# token-based font (var(--font-body) 11px 등) 기준 최소 가독 높이.
|
||
DEFAULT_ZONE_MIN_HEIGHT_PX = 100
|
||
|
||
# Step 14 image_aspect_mismatch tolerance — |natural_ratio - rendered_ratio| > TOL ⇒ fail.
|
||
# Local anchor : IMP-15 실행-1 (Gitea issue #45) — image axis acceptance criteria.
|
||
# Spec doc row (PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC) update deferred to IMP-15 실행-4.
|
||
IMAGE_ASPECT_DELTA_TOL = 0.05
|
||
|
||
# Step 14 table_self_overflow tolerance — 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")
|
||
|
||
|
||
# ─── IMP-39 (#68) u1 — single-source ranking sort policy ──────────
|
||
#
|
||
# Single source of (label_priority, tie-break) ordering shared by:
|
||
# - backend `lookup_v4_match_with_fallback` selector loop (wired in u2)
|
||
# - Step 9 `_build_application_plan_unit` payload (wired in u3)
|
||
# - frontend `designAgentApi.ts` candidate builder (wired in u4)
|
||
#
|
||
# u1 scope = additive only (yaml + loader + helper). No selector wiring,
|
||
# no behavior change. Default-fallback matches yaml so missing-file boot
|
||
# keeps deterministic ordering identical to the file-loaded policy.
|
||
|
||
_RANKING_SORT_POLICY_DEFAULT: dict = {
|
||
"policy_type": "deterministic_label_priority_then_confidence",
|
||
"label_priority": {
|
||
"use_as_is": 0,
|
||
"light_edit": 1,
|
||
"restructure": 2,
|
||
"reject": 3,
|
||
},
|
||
"unknown_label_priority": 99,
|
||
"tie_break_axes": ["confidence_desc", "v4_rank_asc"],
|
||
}
|
||
|
||
_RANKING_SORT_POLICY_CACHE: Optional[dict] = None
|
||
|
||
|
||
def load_ranking_sort_policy() -> dict:
|
||
"""IMP-39 u1 — ranking sort policy loader (separate yaml, additive).
|
||
|
||
Returns dict with keys: policy_type, label_priority (dict),
|
||
unknown_label_priority (int), tie_break_axes (list[str]).
|
||
|
||
Graceful fallback: yaml 파일 없을 시 _RANKING_SORT_POLICY_DEFAULT
|
||
(위 dict) 그대로 — backward-compat boot-safe.
|
||
|
||
Cache: module-level, mirrors `load_v4_fallback_policy` pattern.
|
||
"""
|
||
global _RANKING_SORT_POLICY_CACHE
|
||
if _RANKING_SORT_POLICY_CACHE is None:
|
||
if RANKING_SORT_POLICY_PATH.exists():
|
||
loaded = (
|
||
yaml.safe_load(RANKING_SORT_POLICY_PATH.read_text(encoding="utf-8"))
|
||
or {}
|
||
)
|
||
# merge with default so partial yaml falls through cleanly
|
||
merged = dict(_RANKING_SORT_POLICY_DEFAULT)
|
||
for k, v in loaded.items():
|
||
merged[k] = v
|
||
_RANKING_SORT_POLICY_CACHE = merged
|
||
else:
|
||
_RANKING_SORT_POLICY_CACHE = dict(_RANKING_SORT_POLICY_DEFAULT)
|
||
return _RANKING_SORT_POLICY_CACHE
|
||
|
||
|
||
def apply_ranking_sort(
|
||
records: list,
|
||
*,
|
||
policy: Optional[dict] = None,
|
||
label_key: str = "label",
|
||
confidence_key: str = "confidence",
|
||
v4_rank_key: str = "v4_rank",
|
||
) -> list:
|
||
"""IMP-39 u1 — stable sort by (label_priority asc, confidence desc, v4_rank asc).
|
||
|
||
Shared ordering primitive — backend selector / Step 9 payload / frontend
|
||
mirror invariant. Sample-agnostic; no hardcoded sample IDs.
|
||
|
||
Args:
|
||
records: list of dicts (selector loop, trace candidates) OR V4Match
|
||
objects. Field access falls through getitem → getattr.
|
||
policy: optional explicit policy dict; defaults to `load_ranking_sort_policy()`.
|
||
label_key / confidence_key / v4_rank_key: per-record field names.
|
||
|
||
Returns:
|
||
NEW list — input is not mutated. Records lacking a key get the
|
||
unknown-label priority / confidence=0.0 / v4_rank=inf so they sink
|
||
to the bottom in a deterministic way.
|
||
"""
|
||
pol = policy if policy is not None else load_ranking_sort_policy()
|
||
priority_map: dict = pol.get("label_priority", {}) or {}
|
||
unknown_priority: int = int(pol.get("unknown_label_priority", 99))
|
||
|
||
def _get(rec, key):
|
||
if isinstance(rec, dict):
|
||
return rec.get(key)
|
||
return getattr(rec, key, None)
|
||
|
||
def _key(rec):
|
||
label = _get(rec, label_key)
|
||
conf = _get(rec, confidence_key)
|
||
v4_rank = _get(rec, v4_rank_key)
|
||
label_pri = priority_map.get(label, unknown_priority)
|
||
conf_val = float(conf) if conf is not None else 0.0
|
||
# confidence desc → negate for asc sort key
|
||
rank_val = int(v4_rank) if v4_rank is not None else 10**9
|
||
return (label_pri, -conf_val, rank_val)
|
||
|
||
return sorted(records, key=_key)
|
||
|
||
|
||
def _b4_mapper_source_enabled() -> bool:
|
||
"""IMP-89 89-a u1 — PHASE_Z_B4_MAPPER_SOURCE env flag reader (default OFF).
|
||
|
||
Switches slot_payload source-of-truth from mapper-only (legacy) to B4
|
||
PlacementPlan.selected_template_id. Distinct from PHASE_Z_B4_GATEKEEPER
|
||
(mismatch render-skip semantics). u2 wires this into the slot_payload
|
||
construction site; u3 adds BLOCKED exits for no-cover and FitError under
|
||
flag ON. Truthy values: '1', 'true', 'yes' (case-insensitive, trimmed).
|
||
"""
|
||
return os.environ.get("PHASE_Z_B4_MAPPER_SOURCE", "").strip().lower() in {
|
||
"1",
|
||
"true",
|
||
"yes",
|
||
}
|
||
|
||
|
||
def _select_mapper_template_id(
|
||
placement_plan, unit_frame_template_id: str
|
||
) -> Optional[str]:
|
||
"""IMP-89 89-a u2 — slot_payload source-of-truth selector.
|
||
|
||
Returns the template_id that drives slot_payload construction at the
|
||
single switch site in the runtime loop:
|
||
flag ON → placement_plan.selected_template_id (B4 PlacementPlan,
|
||
Layer A render-active path)
|
||
flag OFF → unit_frame_template_id (legacy mapper-only / V4 rank-1;
|
||
byte-equivalent default; final.html SHA parity guarded
|
||
by u4)
|
||
|
||
Under flag ON the returned value may be None when B4 found no covering
|
||
frame. u3 adds the BLOCKED exit for None and for FitError on the
|
||
B4-selected template — NO silent fallback (IMP-87 honesty gate pattern).
|
||
"""
|
||
if _b4_mapper_source_enabled():
|
||
return placement_plan.selected_template_id
|
||
return unit_frame_template_id
|
||
|
||
|
||
def _b4_mapper_source_blocked_exit(
|
||
reason: str, *, position: str, context: dict
|
||
) -> "NoReturn": # type: ignore[name-defined]
|
||
"""IMP-89 89-a u3 — BLOCKED exit (sys.exit(1)) when PHASE_Z_B4_MAPPER_SOURCE
|
||
is ON and the Layer A render path cannot resolve a covering frame.
|
||
|
||
Stage 1 Q2 lock: when the user explicitly opts into the B4-driven render
|
||
path, a content-bearing zone MUST NOT silently degrade to adapter_needed
|
||
or to the legacy V4 rank-1 mapper input. Mirrors IMP-87 u3 honesty-gate
|
||
pattern (`_is_blocked_overall` → `sys.exit(1)`): the BLOCKED signal
|
||
preempts the silent adapter_needed fallback so the operator sees the
|
||
Layer A failure immediately on stderr instead of inheriting a
|
||
pseudo-rendered partial.
|
||
|
||
Reasons (locked enum):
|
||
b4_no_cover — PlacementPlan.selected_template_id is None
|
||
(B4 found no covering frame on the unit)
|
||
b4_selected_fit_error — map_mdx_to_slots raised FitError against the
|
||
B4-selected template (frame contract reject)
|
||
|
||
Always raises SystemExit(1) via sys.exit. The `NoReturn` annotation makes
|
||
the call-site control flow explicit for type-checkers; behavior is the
|
||
same as IMP-87 u3's sys.exit(1) at L6387.
|
||
"""
|
||
print(
|
||
f"\n[Phase Z-2 IMP-89 89-a u3] BLOCKED @ {reason} (zone--{position})",
|
||
file=sys.stderr,
|
||
)
|
||
print(
|
||
" policy : PHASE_Z_B4_MAPPER_SOURCE=ON requires B4-driven render "
|
||
"(NO silent fallback — IMP-87 honesty gate pattern)",
|
||
file=sys.stderr,
|
||
)
|
||
for key, value in context.items():
|
||
print(f" {key:9}: {value}", file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
|
||
# ─── MDX parsing ────────────────────────────────────────────────
|
||
|
||
def parse_mdx(mdx_path: Path) -> tuple[str, list[MdxSection], Optional[str]]:
|
||
"""basic MDX parser — ## level sections only. V4 무관 (matching artifact 모름).
|
||
|
||
section.raw_content 에 ### sub-section 그대로 포함. V4 granularity 와 align 은
|
||
align_sections_to_v4_granularity() 가 처리.
|
||
"""
|
||
text = mdx_path.read_text(encoding="utf-8")
|
||
|
||
fm_match = re.match(r"^---\n(.*?)\n---\n", text, re.DOTALL)
|
||
slide_title = ""
|
||
if fm_match:
|
||
fm = yaml.safe_load(fm_match.group(1))
|
||
slide_title = fm.get("title", "")
|
||
text = text[fm_match.end():]
|
||
|
||
footer_match = re.search(r":::note\[[^\]]*\]\n(.*?)\n:::", text, re.DOTALL)
|
||
footer_text = None
|
||
if footer_match:
|
||
body = footer_match.group(1)
|
||
bullet_match = re.search(r"\*\s*\*\*([^*]+)\*\*", body)
|
||
footer_text = (bullet_match.group(1).strip() if bullet_match else body.strip())
|
||
text = text[:footer_match.start()] + text[footer_match.end():]
|
||
|
||
sections = []
|
||
section_pattern = re.compile(r"^##\s+(\d+)\.\s+(.+?)$", re.MULTILINE)
|
||
matches = list(section_pattern.finditer(text))
|
||
|
||
mdx_num_match = re.match(r"(\d+)", mdx_path.stem)
|
||
mdx_id = mdx_num_match.group(1).zfill(2) if mdx_num_match else "00"
|
||
|
||
for i, m in enumerate(matches):
|
||
section_num = int(m.group(1))
|
||
title_text = m.group(2).strip()
|
||
start = m.end()
|
||
end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
|
||
raw_content = text[start:end].strip()
|
||
sections.append(MdxSection(
|
||
section_id=f"{mdx_id}-{section_num}",
|
||
section_num=section_num,
|
||
title=f"{section_num}. {title_text}",
|
||
raw_content=raw_content,
|
||
))
|
||
|
||
return slide_title, sections, footer_text
|
||
|
||
|
||
# IMP-02 (Phase Z Step 2) — Stage 0 normalize chained adapter.
|
||
# scope-lock 7 조건 (Gitea #2):
|
||
# 1. inline helper near parse_mdx()
|
||
# 2. PHASE_Z_STAGE0_ADAPTER_ENABLED env flag, default OFF (canary, matches PHASE_Z_B4_*)
|
||
# 3. env=1 sample verification required (in review loop)
|
||
# 4. fallback_reason: str | None flat — 5 hard cases
|
||
# 5. verify normalize_mdx_content(raw_mdx)["sections"] is list
|
||
# 6. preserve Step 2 existing fields; stage0_adapter_diagnostics additive only
|
||
# 7. out of scope: V4 / align / composition / AI/Kei / frame selection / status semantics
|
||
_STAGE0_FALLBACK_REASONS = {
|
||
"ADAPTER_EXCEPTION",
|
||
"NO_USABLE_SECTIONS",
|
||
"MISSING_INVALID_IDS",
|
||
"DUPLICATE_IDS",
|
||
"NON_POSITIVE_SECTION_NUM",
|
||
}
|
||
|
||
|
||
def _stage0_chained_adapter(
|
||
mdx_path: Path,
|
||
legacy_slide_title: str,
|
||
legacy_sections: list[MdxSection],
|
||
legacy_footer: Optional[str],
|
||
) -> tuple[str, list[MdxSection], Optional[str], dict, dict]:
|
||
"""IMP-02 — chained adapter for Stage 0 normalize → Phase Z Step 2 input.
|
||
|
||
Chain: mdx_normalizer.normalize_mdx_content + section_parser.extract_major_sections
|
||
+ section_parser.extract_conclusion_text → reconstructed MdxSection list.
|
||
|
||
Default OFF (canary, env=`1/true/yes` to enable). When OFF, returns legacy parse_mdx
|
||
output with diagnostics indicating disabled. When ON, runs adapter chain; on any
|
||
hard contract failure or exception, falls back to legacy and records fallback_reason.
|
||
|
||
Returns (slide_title, sections, footer, diagnostics, normalized_assets).
|
||
normalized_assets = {"popups": [...], "images": [...], "tables": [...]}
|
||
— IMP-03 Step 3 handoff. env=OFF or hard fallback 시 빈 list.
|
||
"""
|
||
diagnostics: dict = {
|
||
"enabled": False,
|
||
"used": False,
|
||
"fallback_reason": None,
|
||
"id_reconstruction_log": [],
|
||
"adapter_counts": None,
|
||
"legacy_counts": {"sections": len(legacy_sections)},
|
||
}
|
||
# IMP-03 — Step 3 handoff. env=OFF / fallback 시 모든 list 가 비어 있음.
|
||
normalized_assets: dict = {"popups": [], "images": [], "tables": []}
|
||
|
||
raw_flag = os.environ.get("PHASE_Z_STAGE0_ADAPTER_ENABLED", "").strip().lower()
|
||
enabled = raw_flag in {"1", "true", "yes"}
|
||
diagnostics["enabled"] = enabled
|
||
if not enabled:
|
||
return legacy_slide_title, legacy_sections, legacy_footer, diagnostics, normalized_assets
|
||
|
||
try:
|
||
# Defer imports — legacy path must not depend on these modules.
|
||
from mdx_normalizer import normalize_mdx_content
|
||
from section_parser import extract_conclusion_text, extract_major_sections
|
||
|
||
raw_mdx = mdx_path.read_text(encoding="utf-8")
|
||
normalized = normalize_mdx_content(raw_mdx)
|
||
if not isinstance(normalized, dict) or not isinstance(normalized.get("sections"), list):
|
||
diagnostics["fallback_reason"] = "MISSING_INVALID_IDS"
|
||
return legacy_slide_title, legacy_sections, legacy_footer, diagnostics, normalized_assets
|
||
|
||
majors = extract_major_sections(normalized["sections"])
|
||
if not majors:
|
||
diagnostics["fallback_reason"] = "NO_USABLE_SECTIONS"
|
||
return legacy_slide_title, legacy_sections, legacy_footer, diagnostics, normalized_assets
|
||
|
||
adapter_title = (normalized.get("title") or "").strip() or legacy_slide_title
|
||
conclusion = extract_conclusion_text(raw_mdx)
|
||
adapter_footer = conclusion if conclusion else None
|
||
|
||
mdx_num_match = re.match(r"(\d+)", mdx_path.stem)
|
||
mdx_id = mdx_num_match.group(1).zfill(2) if mdx_num_match else "00"
|
||
|
||
# Pre-scan raw MDX `## N. Title` headings → {title: section_num} map.
|
||
# Required to make scope-lock §5 "raw heading reuse first" functionally
|
||
# reachable, since extract_major_sections strips the leading `N.` from
|
||
# its level=2 group titles (Codex implementation review #6 catch).
|
||
raw_heading_map: dict[str, int] = {}
|
||
for h in re.finditer(r"^##\s+(\d+)\.\s+(.+?)$", raw_mdx, re.MULTILINE):
|
||
raw_heading_map[h.group(2).strip()] = int(h.group(1))
|
||
|
||
adapter_sections: list[MdxSection] = []
|
||
used_nums: set[int] = set()
|
||
for idx, m in enumerate(majors, start=1):
|
||
mtitle = (m.get("title") or "").strip()
|
||
content = (m.get("content") or "").strip()
|
||
|
||
if mtitle in raw_heading_map:
|
||
section_num = raw_heading_map[mtitle]
|
||
clean_title = mtitle
|
||
reuse_source = "raw_heading"
|
||
else:
|
||
inline_match = re.match(r"^(\d+)\.\s*(.+)$", mtitle)
|
||
if inline_match:
|
||
section_num = int(inline_match.group(1))
|
||
clean_title = inline_match.group(2).strip()
|
||
reuse_source = "raw_heading_inline"
|
||
else:
|
||
section_num = idx
|
||
clean_title = mtitle
|
||
reuse_source = "order_fallback"
|
||
|
||
if section_num <= 0:
|
||
diagnostics["fallback_reason"] = "NON_POSITIVE_SECTION_NUM"
|
||
return legacy_slide_title, legacy_sections, legacy_footer, diagnostics, normalized_assets
|
||
if section_num in used_nums:
|
||
diagnostics["fallback_reason"] = "DUPLICATE_IDS"
|
||
return legacy_slide_title, legacy_sections, legacy_footer, diagnostics, normalized_assets
|
||
used_nums.add(section_num)
|
||
|
||
diagnostics["id_reconstruction_log"].append({
|
||
"input_title": mtitle,
|
||
"section_num": section_num,
|
||
"reuse_source": reuse_source,
|
||
})
|
||
|
||
adapter_sections.append(MdxSection(
|
||
section_id=f"{mdx_id}-{section_num}",
|
||
section_num=section_num,
|
||
title=f"{section_num}. {clean_title}",
|
||
raw_content=content,
|
||
))
|
||
|
||
diagnostics["adapter_counts"] = {
|
||
"sections": len(adapter_sections),
|
||
"majors": len(majors),
|
||
"normalized_sections": len(normalized["sections"]),
|
||
"popups": len(normalized.get("popups", []) or []),
|
||
"images": len(normalized.get("images", []) or []),
|
||
"tables": len(normalized.get("tables", []) or []),
|
||
}
|
||
diagnostics["diff_vs_legacy"] = {
|
||
"title_match": adapter_title == legacy_slide_title,
|
||
"count_match": len(adapter_sections) == len(legacy_sections),
|
||
"footer_match": adapter_footer == legacy_footer,
|
||
}
|
||
diagnostics["used"] = True
|
||
# IMP-03 — populate Step 3 handoff (success path only).
|
||
# All fallback paths leave normalized_assets as empty lists (defined at fn top).
|
||
normalized_assets = {
|
||
"popups": normalized.get("popups", []) or [],
|
||
"images": normalized.get("images", []) or [],
|
||
"tables": normalized.get("tables", []) or [],
|
||
}
|
||
return adapter_title, adapter_sections, adapter_footer, diagnostics, normalized_assets
|
||
|
||
except Exception as exc: # noqa: BLE001 — adapter must never break legacy path
|
||
diagnostics["fallback_reason"] = "ADAPTER_EXCEPTION"
|
||
diagnostics["exception"] = repr(exc)
|
||
return legacy_slide_title, legacy_sections, legacy_footer, diagnostics, normalized_assets
|
||
|
||
|
||
# ─── V4 lookup ──────────────────────────────────────────────────
|
||
|
||
def load_v4_result() -> dict:
|
||
return yaml.safe_load(V4_RESULT_PATH.read_text(encoding="utf-8"))
|
||
|
||
|
||
def align_sections_to_v4_granularity(
|
||
sections: list[MdxSection],
|
||
v4: dict,
|
||
*,
|
||
override_target_section_ids: Optional[list[str]] = None,
|
||
) -> list[MdxSection]:
|
||
"""Align MDX sections to canonical sub-section granularity.
|
||
|
||
Default behaviour (V4-driven granularity, backward compatible) :
|
||
- V4 has section_id exact key -> keep section unchanged (parent
|
||
granularity rendering, parent-level V4 evidence applies).
|
||
- V4 missing + H3 sub-sections -> drill into sub-sections, emit
|
||
canonical ids ``${section_id}-sub-${ordinal}`` with optional
|
||
decimal alias for legacy V4 keys (e.g. ``04-2.1``).
|
||
- V4 missing + no H3 -> pass through (downstream V4 lookup
|
||
will naturally abort with no_v4_section).
|
||
|
||
IMP-08 B-3 / Stage 5 R2 blocker-fix — ``override_target_section_ids``
|
||
is the list of section ids that drag/drop override CLI flags target.
|
||
When any override target matches ``${section_id}-sub-N`` for a section
|
||
whose parent is otherwise V4-aligned, that section is force-drilled so
|
||
sub-section ids become addressable. This keeps the default rendering
|
||
path on V4 granularity while making drag/drop deterministic regardless
|
||
of whether V4 carries a parent exact key.
|
||
|
||
Each drilled sub-section carries :
|
||
- heading_number : decimal "2.1" / integer "1" / None (bare H3 title).
|
||
- v4_alias_keys : legacy V4 keys to try when the canonical ordinal
|
||
id misses. Populated only when ``heading_number`` matches the
|
||
decimal pattern ``\\d+\\.\\d+`` (N-R5 guard) — integer-only or
|
||
bare H3 produces no alias to avoid sibling-parent V4 collisions.
|
||
|
||
Design boundary :
|
||
- parser (``parse_mdx``) = MDX-only knowledge (V4-agnostic).
|
||
- aligner (this function) = canonical sub-id schema, MDX-driven on
|
||
force_drill, V4-driven otherwise.
|
||
- resolver (``_resolve_v4_section_key``) = exact > alias > None,
|
||
never auto-promotes to parent/sibling (axis 7 hybrid lock).
|
||
"""
|
||
v4_keys = set(v4.get("mdx_sections", {}).keys())
|
||
|
||
# Build the set of parent ids whose sub-ids are explicitly targeted by
|
||
# an override. These sections must be drilled even if V4 also carries
|
||
# the parent key exactly. Parents derived from canonical "X-sub-N" ids
|
||
# only — non-sub ids (top-level overrides) do not trigger drilling.
|
||
force_drill_parents: set[str] = set()
|
||
if override_target_section_ids:
|
||
for sid in override_target_section_ids:
|
||
parent = derive_parent_id(sid)
|
||
if parent and sid != parent:
|
||
force_drill_parents.add(parent)
|
||
|
||
aligned: list[MdxSection] = []
|
||
|
||
# Capture optional heading-number prefix (decimal "2.1" or integer "1")
|
||
# plus the heading title. None group = bare "### Title".
|
||
sub_pattern = re.compile(
|
||
r"^###\s+(?:(\d+(?:\.\d+)?)\s+)?(.+?)$", re.MULTILINE
|
||
)
|
||
decimal_re = re.compile(r"\d+\.\d+")
|
||
|
||
for section in sections:
|
||
force_drill = section.section_id in force_drill_parents
|
||
if section.section_id in v4_keys and not force_drill:
|
||
# V4 carries this section exactly and no override targets a
|
||
# sub-id under it: keep parent granularity (backward compat).
|
||
aligned.append(section)
|
||
continue
|
||
|
||
sub_matches = list(sub_pattern.finditer(section.raw_content))
|
||
if not sub_matches:
|
||
# No H3 sub-sections: cannot drill. Pass section through;
|
||
# downstream V4 lookup aborts with no_v4_section when needed.
|
||
aligned.append(section)
|
||
continue
|
||
|
||
mdx_id = section.section_id.split("-")[0] # e.g., "04"
|
||
for ordinal, m in enumerate(sub_matches, start=1):
|
||
heading_number = m.group(1) # decimal "2.1" / integer "1" / None
|
||
sub_title = m.group(2).strip()
|
||
start = m.end()
|
||
end = (
|
||
sub_matches[ordinal].start()
|
||
if ordinal < len(sub_matches)
|
||
else len(section.raw_content)
|
||
)
|
||
raw = section.raw_content[start:end].strip()
|
||
|
||
# N-R5 : alias only for decimal heading numbers. integer-only
|
||
# H3 (`### 1`) or undecorated H3 produce no alias to avoid
|
||
# sibling-parent V4 collisions (e.g., 05.mdx integer H3s).
|
||
alias_keys: list[str] = []
|
||
if heading_number and decimal_re.fullmatch(heading_number):
|
||
alias_keys.append(f"{mdx_id}-{heading_number}")
|
||
|
||
title = (
|
||
f"{heading_number} {sub_title}" if heading_number else sub_title
|
||
)
|
||
aligned.append(MdxSection(
|
||
section_id=f"{section.section_id}-sub-{ordinal}",
|
||
section_num=section.section_num,
|
||
title=title,
|
||
raw_content=raw,
|
||
heading_number=heading_number,
|
||
v4_alias_keys=alias_keys,
|
||
))
|
||
|
||
return aligned
|
||
|
||
|
||
def _v4_match_from_judgment(section_id: str, judgment: dict, rank: Optional[int] = None) -> V4Match:
|
||
resolved_rank = rank if rank is not None else judgment.get("v4_full_rank")
|
||
return V4Match(
|
||
section_id=section_id,
|
||
frame_id=str(judgment["frame_id"]),
|
||
frame_number=int(judgment["frame_number"]),
|
||
template_id=judgment["template_id"],
|
||
confidence=float(judgment["confidence"]),
|
||
label=judgment["label"],
|
||
v4_rank=int(resolved_rank) if resolved_rank is not None else None,
|
||
)
|
||
|
||
|
||
def _resolve_v4_section_key(
|
||
v4: dict,
|
||
section_id: str,
|
||
*,
|
||
alias_keys: Optional[list] = None,
|
||
) -> Optional[str]:
|
||
"""Resolve a V4 ``mdx_sections`` key for *section_id*.
|
||
|
||
Resolution order :
|
||
1. exact match (canonical ordinal id wins)
|
||
2. alias_keys in given order (e.g. legacy decimal ``04-2.1`` for ``04-2-sub-1``)
|
||
3. None on miss.
|
||
|
||
Never promotes to parent or sibling — that would reinterpret V4 evidence
|
||
(axis 7 hybrid lock, RULE 0). U1 callers pass alias_keys=None so the
|
||
function is byte-identical to the previous exact-match lookup; U2 populates
|
||
aliases from MDX heading_number metadata.
|
||
"""
|
||
keys = v4.get("mdx_sections", {})
|
||
if section_id in keys:
|
||
return section_id
|
||
if alias_keys:
|
||
for a in alias_keys:
|
||
if a and a in keys:
|
||
return a
|
||
return None
|
||
|
||
|
||
def lookup_v4_match(
|
||
v4: dict, section_id: str, *, alias_keys: Optional[list] = None
|
||
) -> Optional[V4Match]:
|
||
resolved = _resolve_v4_section_key(v4, section_id, alias_keys=alias_keys)
|
||
sec = v4.get("mdx_sections", {}).get(resolved) if resolved else None
|
||
if not sec:
|
||
return None
|
||
judgments = sec.get("judgments_full32", [])
|
||
if not judgments:
|
||
return None
|
||
top = judgments[0]
|
||
return _v4_match_from_judgment(section_id, top, rank=1)
|
||
|
||
|
||
# IMP-05 L2/L5 route hint — V4 label → execution route guidance for future consumers
|
||
# (frontend zone-level override / AI-assisted adaptation). Codex #2 conceptual model :
|
||
# use_as_is → Phase Z direct render
|
||
# light_edit → deterministic minor adjustment
|
||
# restructure → AI-assisted frame-aware adaptation (deferred to IMP-17 — carve-out, AI fallback only, normal path 밖)
|
||
# reject → AI re-construction over the rank-1 reject frame (IMP-47B u1, 2026-05-21);
|
||
# policy correction supersedes the legacy "design reference only" disposition.
|
||
# Frame visual / contract stays untouched; AI only re-maps MDX content into
|
||
# declared slots. Activation still gated by ai_fallback_enabled (default OFF).
|
||
_IMP05_ROUTE_HINTS: dict[str, str] = {
|
||
"use_as_is": "direct_render",
|
||
"light_edit": "deterministic_minor_adjustment",
|
||
"restructure": "ai_adaptation_required",
|
||
"reject": "ai_adaptation_required",
|
||
}
|
||
|
||
|
||
def _imp05_route_hint(label: Optional[str]) -> Optional[str]:
|
||
"""Map V4 label to execution route hint. Returns None for unknown labels."""
|
||
if label is None:
|
||
return None
|
||
return _IMP05_ROUTE_HINTS.get(label)
|
||
|
||
|
||
def _load_frame_partial_html(template_id: str) -> str:
|
||
"""IMP-47B u4 — Read templates/phase_z2/families/{template_id}.html.
|
||
|
||
Missing partial (e.g., ``__empty__`` shell from IMP-30) returns an
|
||
empty string so gather_step12_ai_repair_proposals can still build a
|
||
record with skip_reason without raising on file IO.
|
||
"""
|
||
partial_path = TEMPLATE_DIR / "families" / f"{template_id}.html"
|
||
if not partial_path.is_file():
|
||
return ""
|
||
return partial_path.read_text(encoding="utf-8")
|
||
|
||
|
||
def _run_step12_ai_repair(units) -> list[dict]:
|
||
"""IMP-47B u4 — Wire gather_step12_ai_repair_proposals into Step 12.
|
||
|
||
Routes provisional units whose IMP-05 hint maps to
|
||
``ai_adaptation_required`` (``restructure`` + ``reject`` per u1)
|
||
through ``src.phase_z2_ai_fallback.router``. Normal-path units
|
||
(``use_as_is`` / ``light_edit`` / non-provisional) record a
|
||
skip_reason without invoking the router; flag-off runs short-circuit
|
||
at the router (``settings.ai_fallback_enabled=False`` default).
|
||
Returns the per-unit record list — u5 consumes records for
|
||
PARTIAL_OVERRIDES apply and u6 writes the audit artifact.
|
||
"""
|
||
return gather_step12_ai_repair_proposals(
|
||
units,
|
||
route_for_label=_imp05_route_hint,
|
||
get_contract_fn=get_contract,
|
||
frame_visual_loader=_load_frame_partial_html,
|
||
)
|
||
|
||
|
||
_REJECT_SUPPORTED_PROPOSAL_KINDS: frozenset[str] = frozenset({"partial_overrides"})
|
||
|
||
|
||
def _apply_ai_repair_proposals_to_zones(
|
||
ai_repair_records: list[dict],
|
||
unit_positions: list[str],
|
||
zones_data: list[dict],
|
||
) -> None:
|
||
"""IMP-47B u5 — Apply PARTIAL_OVERRIDES into zones_data.slot_payload.
|
||
|
||
Mutates each record's ``apply_status`` in place and merges
|
||
``proposal.payload.slots`` into the matching zone. Out-of-scope
|
||
kinds (``builder_options_patch``, ``slot_mapping_proposal``)
|
||
loud-fail with ``unsupported_kind_for_reject_route:<kind>`` — zones
|
||
untouched (human_review surfacing → u8). IMP-33 u5 validator
|
||
guarantees declared-slot completeness, so ``dict.update`` is the
|
||
structural merge (``feedback_ai_isolation_contract``).
|
||
"""
|
||
zone_by_position = {z["position"]: z for z in zones_data}
|
||
for record in ai_repair_records:
|
||
proposal = record.get("proposal")
|
||
if proposal is None:
|
||
record["apply_status"] = "no_proposal"
|
||
continue
|
||
kind = proposal.get("proposal_kind")
|
||
if kind not in _REJECT_SUPPORTED_PROPOSAL_KINDS:
|
||
record["apply_status"] = f"unsupported_kind_for_reject_route:{kind}"
|
||
print(
|
||
f" [ai-repair-apply] unit {record['unit_index']} "
|
||
f"proposal_kind='{kind}' out-of-scope for reject route — "
|
||
"skipping apply; human_review required.",
|
||
file=sys.stderr,
|
||
)
|
||
continue
|
||
unit_index = record["unit_index"]
|
||
position = (
|
||
unit_positions[unit_index]
|
||
if 0 <= unit_index < len(unit_positions) else None
|
||
)
|
||
zone = zone_by_position.get(position) if position is not None else None
|
||
if zone is None:
|
||
record["apply_status"] = "no_zone_match"
|
||
continue
|
||
slots = (proposal.get("payload") or {}).get("slots") or {}
|
||
zone["slot_payload"].update(slots)
|
||
record["apply_status"] = "applied:partial_overrides"
|
||
|
||
|
||
def _check_post_ai_coverage_invariant(
|
||
units,
|
||
ai_repair_records: list[dict],
|
||
) -> dict:
|
||
"""IMP-47B u7 — Verify AI repair preserved every source_section_id.
|
||
|
||
Compares the union of unit-level ``source_section_ids`` (pre-AI) to
|
||
the union present on ``ai_repair_records`` post-apply. Per the AI
|
||
isolation contract + dropped 절대 룰
|
||
(``feedback_ai_isolation_contract``), AI repair never removes a
|
||
unit's section coverage. Any divergence indicates a regression that
|
||
u8 surfaces through ``slide_status.ai_repair_status``. The check is
|
||
structural (set membership); the per-record ``source_section_ids``
|
||
list is a copy populated by ``gather_step12_ai_repair_proposals``
|
||
(``step12.py:124``) so apply mutations cannot silently drop it.
|
||
"""
|
||
pre_ai_ids: set[str] = set()
|
||
for unit in units:
|
||
pre_ai_ids.update(getattr(unit, "source_section_ids", []) or [])
|
||
post_ai_ids: set[str] = set()
|
||
for record in ai_repair_records:
|
||
post_ai_ids.update(record.get("source_section_ids") or [])
|
||
dropped = sorted(pre_ai_ids - post_ai_ids)
|
||
return {
|
||
"pre_ai_section_ids": sorted(pre_ai_ids),
|
||
"post_ai_section_ids": sorted(post_ai_ids),
|
||
"dropped_section_ids": dropped,
|
||
"status": "ok" if not dropped else "violated",
|
||
}
|
||
|
||
|
||
def _persist_ai_repair_proposals_to_cache(
|
||
ai_repair_records: list[dict],
|
||
*,
|
||
visual_check_passed: bool,
|
||
user_approved: bool,
|
||
auto_cache: bool,
|
||
) -> None:
|
||
"""IMP-47B u13 — Persist applied AI repair proposals through IMP-46 gates.
|
||
|
||
Mutates each record in place with a ``cache_save_status`` axis.
|
||
Only records whose ``apply_status`` starts with ``"applied:"`` and
|
||
that still carry the original ``cache_key`` + ``fingerprints`` + a
|
||
serialized ``proposal`` dict are eligible — everything else marked
|
||
``not_applied``. Eligible records go through
|
||
``cache.save_proposal`` with the IMP-46 dual-gate truth table; the
|
||
helper catches :class:`AiFallbackCacheGateError` so a gate block is
|
||
surfaced (``gate_blocked:<reason>``) without raising into the
|
||
pipeline runtime (the cache is a hint, never a hard dependency —
|
||
cache.py contract). ``visual_check_passed`` is never bypassable;
|
||
``auto_cache=True`` bypasses ONLY the ``user_approved`` gate per
|
||
IMP-46 u5. Pure save layer: no AI call, no MDX touch.
|
||
"""
|
||
from src.phase_z2_ai_fallback.cache import (
|
||
AiFallbackCacheGateError,
|
||
save_proposal,
|
||
)
|
||
from src.phase_z2_ai_fallback.schema import AiFallbackProposal
|
||
for record in ai_repair_records:
|
||
apply_status = record.get("apply_status") or ""
|
||
proposal_dict = record.get("proposal")
|
||
cache_key = record.get("cache_key")
|
||
fingerprints = record.get("fingerprints")
|
||
if (
|
||
not apply_status.startswith("applied:")
|
||
or not isinstance(proposal_dict, dict)
|
||
or not cache_key
|
||
or not isinstance(fingerprints, dict)
|
||
):
|
||
record["cache_save_status"] = "not_applied"
|
||
continue
|
||
try:
|
||
proposal_obj = AiFallbackProposal.model_validate(proposal_dict)
|
||
except Exception as exc: # noqa: BLE001 — invalid payload → skip, never raise
|
||
record["cache_save_status"] = f"invalid_proposal:{type(exc).__name__}"
|
||
continue
|
||
try:
|
||
save_proposal(
|
||
cache_key,
|
||
proposal_obj,
|
||
visual_check_passed=visual_check_passed,
|
||
user_approved=user_approved,
|
||
auto_cache=auto_cache,
|
||
fingerprints=fingerprints,
|
||
)
|
||
except AiFallbackCacheGateError as gate_exc:
|
||
record["cache_save_status"] = f"gate_blocked:{gate_exc}"
|
||
continue
|
||
record["cache_save_status"] = "saved"
|
||
|
||
|
||
def _summarize_ai_repair_status(
|
||
ai_repair_records: list[dict],
|
||
coverage_invariant: dict,
|
||
) -> dict:
|
||
"""IMP-47B u8 — Classify Step 12 AI repair outcomes for slide_status surfacing.
|
||
|
||
Reads u4 gather ``error`` + u5 ``apply_status`` + u7 coverage_invariant
|
||
to derive a single ``ai_repair_status`` axis attached to
|
||
``slide_status``. Failure-axis priority (highest → lowest):
|
||
``error`` > ``coverage_violated`` > ``unsupported_kind`` > ``applied`` > ``ok``.
|
||
``human_review_required`` flips True on the three failure axes so the
|
||
frontend (u11) can surface a notification per the IMP-47B policy
|
||
("AI 호출 실패 / proposal validation 실패 / coverage 미달 → frontend notification").
|
||
Pure: no IO, no AI call.
|
||
|
||
IMP-92 u3 — propagate ``api_error_kind`` (quota / billing / auth /
|
||
other) stamped by Step 12 (u2 ``classify_operational_error``) through
|
||
``ai_repair_status`` so the frontend operational formatter can route
|
||
only operational kinds (quota / billing / auth) to user-visible
|
||
alerts. ``api_error_kinds`` aggregates counts by kind at the summary
|
||
level; ``error_records[i]["api_error_kind"]`` retains the per-record
|
||
kind for unit-level surfacing.
|
||
"""
|
||
counts = {
|
||
"total": len(ai_repair_records),
|
||
"applied": 0,
|
||
"no_proposal": 0,
|
||
"no_zone_match": 0,
|
||
"unsupported_kind": 0,
|
||
"error": 0,
|
||
}
|
||
api_error_kinds = {"quota": 0, "billing": 0, "auth": 0, "other": 0}
|
||
unsupported_records: list[dict] = []
|
||
error_records: list[dict] = []
|
||
for record in ai_repair_records:
|
||
if record.get("error"):
|
||
counts["error"] += 1
|
||
kind = record.get("api_error_kind")
|
||
if kind in api_error_kinds:
|
||
api_error_kinds[kind] += 1
|
||
error_records.append({
|
||
"unit_index": record.get("unit_index"),
|
||
"source_section_ids": list(record.get("source_section_ids") or []),
|
||
"error": record.get("error"),
|
||
"api_error_kind": kind,
|
||
})
|
||
continue
|
||
apply_status = record.get("apply_status") or ""
|
||
if apply_status.startswith("applied:"):
|
||
counts["applied"] += 1
|
||
elif apply_status.startswith("unsupported_kind_for_reject_route:"):
|
||
counts["unsupported_kind"] += 1
|
||
unsupported_records.append({
|
||
"unit_index": record.get("unit_index"),
|
||
"source_section_ids": list(record.get("source_section_ids") or []),
|
||
"apply_status": apply_status,
|
||
})
|
||
elif apply_status == "no_zone_match":
|
||
counts["no_zone_match"] += 1
|
||
else:
|
||
counts["no_proposal"] += 1
|
||
coverage_status = (coverage_invariant or {}).get("status", "ok")
|
||
dropped = list((coverage_invariant or {}).get("dropped_section_ids") or [])
|
||
if counts["error"]:
|
||
status = "error"
|
||
elif coverage_status != "ok":
|
||
status = "coverage_violated"
|
||
elif counts["unsupported_kind"]:
|
||
status = "unsupported_kind"
|
||
elif counts["applied"]:
|
||
status = "applied"
|
||
else:
|
||
status = "ok"
|
||
return {
|
||
"status": status,
|
||
"counts": counts,
|
||
"api_error_kinds": api_error_kinds,
|
||
"unsupported_kind_records": unsupported_records,
|
||
"error_records": error_records,
|
||
"coverage_status": coverage_status,
|
||
"dropped_section_ids": dropped,
|
||
"human_review_required": status in {"error", "coverage_violated", "unsupported_kind"},
|
||
}
|
||
|
||
|
||
def lookup_v4_match_with_fallback(
|
||
v4: dict,
|
||
section_id: str,
|
||
*,
|
||
raw_content: Optional[str] = None,
|
||
max_rank: Optional[int] = None,
|
||
alias_keys: Optional[list] = None,
|
||
allow_provisional: bool = False,
|
||
) -> tuple[Optional[V4Match], dict]:
|
||
"""Select V4 rank-1, or promote rank-2..N when rank-1 is not auto-renderable.
|
||
|
||
This is an IMP-05 selector only. It uses existing V4 labels, frame-contract
|
||
presence, and the Phase Z capacity precheck; it does not call calculate_fit.
|
||
|
||
IMP-30 u1 — when ``allow_provisional=True`` and the rank-1..effective_max_rank
|
||
chain is exhausted (no candidate passes MVP1 filter + contract + capacity),
|
||
the selector synthesizes a *provisional* V4Match from the rank-1 judgment so
|
||
the first-render invariant can be satisfied downstream. The synthesized
|
||
match carries ``provisional=True``, ``selection_path="provisional_rank_1"``,
|
||
and ``fallback_reason`` mirrors the existing chain-exhaust reason. The
|
||
candidate trace shape is unchanged (synthetic injection only updates the
|
||
top-level ``selection_path`` + ``selected_*`` mirrors). When the rank-1
|
||
judgment itself is missing (``empty_v4_judgments`` / ``no_v4_section``),
|
||
no provisional is synthesized — the caller handles those cases with a
|
||
placeholder zone or empty-shell.
|
||
|
||
Default ``allow_provisional=False`` keeps the IMP-05 behavior byte-identical.
|
||
|
||
IMP-38 — dynamic effective max_rank via ``load_v4_fallback_policy()``
|
||
(4 round 합의 / Codex #1~#3 + Claude #1~#4 LOCK at #67 comment 23195):
|
||
- ``max_rank=None`` (default) → policy applied:
|
||
usable_count = candidates in rank 1..default_max_rank passing 3-tier
|
||
predicate (status in MVP1 + catalog registered + optional capacity).
|
||
usable_count >= usable_threshold → effective_max_rank = default_max_rank.
|
||
Otherwise → effective_max_rank = min(extended_max_rank,
|
||
len(judgments_full32)) = effective_extended_ceiling (Codex #2 정정).
|
||
- ``max_rank`` explicitly passed → caller_override: that value is used
|
||
as-is (backward compat for tests / explicit IMP-05/IMP-30 paths).
|
||
|
||
Trace gains 8 IMP-38 fields: ``requested_max_rank``, ``default_max_rank``,
|
||
``configured_extended_max_rank``, ``judgments_count``,
|
||
``effective_extended_ceiling``, ``effective_max_rank``, ``usable_count``,
|
||
``policy_applied``. ``max_rank`` legacy field kept as alias for backward
|
||
compat (= effective_max_rank).
|
||
"""
|
||
resolved = _resolve_v4_section_key(v4, section_id, alias_keys=alias_keys)
|
||
sec = v4.get("mdx_sections", {}).get(resolved) if resolved else None
|
||
all_judgments = (sec.get("judgments_full32") if sec else None) or []
|
||
judgments_count = len(all_judgments)
|
||
|
||
# IMP-38 — load policy (graceful: yaml 없을 시 default_max_rank=3, extended=3)
|
||
_policy = load_v4_fallback_policy()
|
||
default_max_rank = int(_policy.get("default_max_rank", 3))
|
||
configured_extended_max_rank = int(_policy.get("extended_max_rank", default_max_rank))
|
||
usable_threshold = int(_policy.get("usable_threshold", 1))
|
||
# Codex #2 정정: min(configured, len(judgments_full32)) — yaml ceiling 무력화 방지
|
||
effective_extended_ceiling = min(configured_extended_max_rank, judgments_count) if judgments_count else default_max_rank
|
||
|
||
usable_count: Optional[int] = None # set only when policy path active
|
||
if max_rank is not None:
|
||
# caller override (backward compat — explicit IMP-05/IMP-30 paths, tests)
|
||
effective_max_rank = int(max_rank)
|
||
policy_applied = "caller_override"
|
||
elif judgments_count == 0:
|
||
# no judgments — slicing 빈 list 라 어차피 영향 X
|
||
effective_max_rank = default_max_rank
|
||
policy_applied = "no_judgments"
|
||
else:
|
||
# IMP-38 policy path — 3-tier predicate usable_count on default window
|
||
usable_count = 0
|
||
default_window = all_judgments[:default_max_rank]
|
||
for _j in default_window:
|
||
_m = _v4_match_from_judgment(section_id, _j, rank=0)
|
||
if to_phase_z_status(_m) not in MVP1_ALLOWED_STATUSES:
|
||
continue
|
||
if get_contract(_m.template_id) is None:
|
||
continue
|
||
if raw_content is not None:
|
||
_cap = compute_capacity_fit(_m.template_id, raw_content)
|
||
if _cap and _cap.get("fit_status") not in {
|
||
"ok", "no_contract", "unknown_source_shape",
|
||
}:
|
||
continue
|
||
usable_count += 1
|
||
|
||
if usable_count >= usable_threshold:
|
||
effective_max_rank = default_max_rank
|
||
policy_applied = "default_max_rank"
|
||
else:
|
||
effective_max_rank = effective_extended_ceiling
|
||
policy_applied = "extended_max_rank"
|
||
|
||
trace = {
|
||
"section_id": section_id,
|
||
# IMP-38 — 8 trace fields (4 round LOCK)
|
||
"requested_max_rank": max_rank,
|
||
"default_max_rank": default_max_rank,
|
||
"configured_extended_max_rank": configured_extended_max_rank,
|
||
"judgments_count": judgments_count,
|
||
"effective_extended_ceiling": effective_extended_ceiling,
|
||
"effective_max_rank": effective_max_rank,
|
||
"usable_count": usable_count,
|
||
"policy_applied": policy_applied,
|
||
# legacy alias for backward compat (= effective_max_rank)
|
||
"max_rank": effective_max_rank,
|
||
"selection_path": "no_v4_candidate",
|
||
"selected_rank": None,
|
||
"selected_template_id": None,
|
||
"selected_frame_id": None,
|
||
"selected_label": None,
|
||
"fallback_used": False,
|
||
"fallback_reason": None,
|
||
"candidates": [],
|
||
}
|
||
if not sec:
|
||
trace["fallback_reason"] = "no_v4_section"
|
||
return None, trace
|
||
|
||
judgments = all_judgments[:effective_max_rank]
|
||
if not judgments:
|
||
trace["fallback_reason"] = "empty_v4_judgments"
|
||
return None, trace
|
||
|
||
# IMP-39 (#68) u2 — apply single-source ranking sort policy to the selected
|
||
# window AFTER IMP-38 raw-window calc (default_window / usable_count above
|
||
# remain RAW all_judgments-based — no silent interaction with fallback
|
||
# expansion). Selection order now follows
|
||
# (label_priority asc, confidence desc, v4_rank asc)
|
||
# so backend selected rank-1 matches frontend frame_candidates[0]
|
||
# (designAgentApi.ts:578-597 LABEL_PRIORITY + confidence-desc mirror).
|
||
# `v4_rank_key="v4_full_rank"` reads the RAW V4 confidence-rank from each
|
||
# judgment dict for tie-break (yaml: tie_break_axes=[confidence_desc,
|
||
# v4_rank_asc]). Input list is NOT mutated (apply_ranking_sort returns a
|
||
# new list). Trace fields (sorted_candidate_evidence / ranking_sort_policy)
|
||
# are forwarded through Step 9 payload in u3.
|
||
ranking_sort_policy = load_ranking_sort_policy()
|
||
judgments = apply_ranking_sort(
|
||
judgments,
|
||
policy=ranking_sort_policy,
|
||
label_key="label",
|
||
confidence_key="confidence",
|
||
v4_rank_key="v4_full_rank",
|
||
)
|
||
trace["ranking_sort_policy_applied"] = ranking_sort_policy.get(
|
||
"policy_type", "deterministic_label_priority_then_confidence"
|
||
)
|
||
|
||
first_skip_reason: Optional[str] = None
|
||
# IMP-05 L4 dedup (Codex #14 ordering — Claude #16 placement precision) :
|
||
# first occurrence claims template_id for the chain regardless of decision
|
||
# (selected/non-direct/rejected/missing-contract/capacity-skipped). Defensive
|
||
# against V4 anomaly where same template_id appears at multiple ranks with
|
||
# different labels — first label/reason is preserved, later duplicates skip.
|
||
seen_template_ids: set[str] = set()
|
||
for i, judgment in enumerate(judgments, start=1):
|
||
match = _v4_match_from_judgment(section_id, judgment, rank=i)
|
||
status = to_phase_z_status(match)
|
||
# IMP-05 L2 (Codex #10 E4) — informative candidate_evidence schema.
|
||
# `v4_label` naming matches Codex schema (Claude #13 §1 lock).
|
||
# `filtered_for_direct_execution` + `route_hint` = L5 restructure/reject trace 보존
|
||
# 단일 source (frontend/AI future consumer guidance).
|
||
is_direct_eligible = status in MVP1_ALLOWED_STATUSES
|
||
candidate_trace = {
|
||
"rank": i,
|
||
"template_id": match.template_id,
|
||
"frame_id": match.frame_id,
|
||
"frame_number": match.frame_number,
|
||
"confidence": match.confidence,
|
||
"label": match.label, # existing — kept for backward compat
|
||
"v4_label": match.label, # IMP-05 L2 alias (Codex schema)
|
||
"phase_z_status": status,
|
||
"catalog_registered": get_contract(match.template_id) is not None,
|
||
"filtered_for_direct_execution": not is_direct_eligible, # IMP-05 L2/L5
|
||
"route_hint": _imp05_route_hint(match.label), # IMP-05 L2/L5
|
||
"decision": "skipped",
|
||
"reason": None,
|
||
}
|
||
|
||
# IMP-05 L4 dedup — duplicate check BEFORE rank evaluation.
|
||
# First occurrence reserves template_id even if non-direct/rejected/skipped.
|
||
# Later rank with same template_id is skipped as duplicate, audit fields preserved.
|
||
if match.template_id in seen_template_ids:
|
||
candidate_trace["reason"] = "duplicate_template_id"
|
||
trace["candidates"].append(candidate_trace)
|
||
continue
|
||
seen_template_ids.add(match.template_id)
|
||
|
||
if status not in MVP1_ALLOWED_STATUSES:
|
||
candidate_trace["reason"] = f"phase_z_status_not_allowed:{status}"
|
||
elif get_contract(match.template_id) is None:
|
||
candidate_trace["reason"] = "skipped_no_contract"
|
||
else:
|
||
capacity_fit = None
|
||
if raw_content is not None:
|
||
capacity_fit = compute_capacity_fit(match.template_id, raw_content)
|
||
candidate_trace["capacity_fit"] = capacity_fit
|
||
if capacity_fit and capacity_fit.get("fit_status") not in {
|
||
"ok", "no_contract", "unknown_source_shape",
|
||
}:
|
||
candidate_trace["reason"] = f"capacity_mismatch:{capacity_fit.get('fit_status')}"
|
||
else:
|
||
fallback_used = i > 1
|
||
fallback_reason = first_skip_reason if fallback_used else None
|
||
match.selection_path = f"rank_{i}" if not fallback_used else f"rank_{i}_fallback"
|
||
match.fallback_reason = fallback_reason
|
||
candidate_trace["decision"] = "selected"
|
||
candidate_trace["reason"] = "primary_selected" if i == 1 else "fallback_selected"
|
||
trace["candidates"].append(candidate_trace)
|
||
trace.update({
|
||
"selection_path": match.selection_path,
|
||
"selected_rank": i,
|
||
"selected_template_id": match.template_id,
|
||
"selected_frame_id": match.frame_id,
|
||
"selected_label": match.label,
|
||
"fallback_used": fallback_used,
|
||
"fallback_reason": fallback_reason,
|
||
})
|
||
return match, trace
|
||
|
||
if i == 1:
|
||
first_skip_reason = candidate_trace["reason"]
|
||
trace["candidates"].append(candidate_trace)
|
||
|
||
trace["selection_path"] = "chain_exhausted"
|
||
trace["fallback_reason"] = first_skip_reason or f"no_auto_renderable_rank_1_to_{effective_max_rank}"
|
||
|
||
# IMP-30 u1 — opt-in provisional first-render synthesis. When the caller
|
||
# signals allow_provisional, promote rank-1 judgment as a provisional
|
||
# match so downstream composition can satisfy the first-render invariant.
|
||
# Top-level mirrors (selection_path / selected_*) are updated; candidate
|
||
# trace entries are left intact (their skip reasons remain accurate).
|
||
# Default-off keeps IMP-05 behavior byte-identical.
|
||
if allow_provisional:
|
||
rank_1_judgment = judgments[0]
|
||
provisional_match = _v4_match_from_judgment(
|
||
section_id, rank_1_judgment, rank=1
|
||
)
|
||
provisional_match.selection_path = "provisional_rank_1"
|
||
provisional_match.fallback_reason = trace["fallback_reason"]
|
||
provisional_match.provisional = True
|
||
trace.update({
|
||
"selection_path": "provisional_rank_1",
|
||
"selected_rank": 1,
|
||
"selected_template_id": provisional_match.template_id,
|
||
"selected_frame_id": provisional_match.frame_id,
|
||
"selected_label": provisional_match.label,
|
||
"fallback_used": True,
|
||
"provisional": True,
|
||
})
|
||
return provisional_match, trace
|
||
|
||
return None, trace
|
||
|
||
|
||
def lookup_v4_all_judgments(
|
||
v4: dict, section_id: str, *, alias_keys: Optional[list] = None
|
||
) -> list[V4Match]:
|
||
"""V4 raw 32 entry 그대로 반환 — reject 포함, max_n filter 없음.
|
||
|
||
Step 7-A axis 보강 (사용자 lock 2026-05-08) — 사용자 UI 가 모든 frame 의
|
||
png 를 보여줄 수 있도록 reject 까지 trace. lookup_v4_candidates 는 변경 없음
|
||
(backward compat — non-reject + max_n 만 반환).
|
||
|
||
Returns :
|
||
list[V4Match] — 0~32 길이. raw judgments_full32 순서 (= V4 score desc) 보존.
|
||
"""
|
||
resolved = _resolve_v4_section_key(v4, section_id, alias_keys=alias_keys)
|
||
sec = v4.get("mdx_sections", {}).get(resolved) if resolved else None
|
||
if not sec:
|
||
return []
|
||
judgments = sec.get("judgments_full32", [])
|
||
out: list[V4Match] = []
|
||
for j in judgments:
|
||
out.append(_v4_match_from_judgment(section_id, j))
|
||
return out
|
||
|
||
|
||
def _is_visual_pending(template_id: str) -> bool:
|
||
"""IMP-#85 u4 — return True iff catalog marks contract as ``visual_pending``.
|
||
|
||
Data-driven from ``frame_contracts.yaml`` (no hard-coded frame allow-list).
|
||
Used by ``lookup_v4_candidates`` to exclude VP frames from the live
|
||
candidate set; ``lookup_v4_all_judgments`` raw telemetry stays untouched
|
||
(Step 7-A axis preserves full 32-frame evidence for the frontend).
|
||
"""
|
||
contract = get_contract(template_id)
|
||
if not isinstance(contract, dict):
|
||
return False
|
||
return contract.get("visual_pending") is True
|
||
|
||
|
||
def lookup_v4_candidates(
|
||
v4: dict,
|
||
section_id: str,
|
||
max_n: int = 6,
|
||
*,
|
||
alias_keys: Optional[list] = None,
|
||
) -> list[V4Match]:
|
||
"""V4 non-reject 후보 list 반환 (Step 5 보완 axis — 사용자 lock 2026-05-08).
|
||
|
||
Rule (catalog 와 1:1) :
|
||
v4_candidates = [
|
||
c for c in judgments_full32
|
||
if c["label"] != "reject"
|
||
and not visual_pending(c.template_id) # IMP-#85 u4
|
||
][:max_n]
|
||
|
||
Returns:
|
||
list[V4Match] — 0~max_n 길이.
|
||
0 길이 = "no_non_reject_v4_candidate" 신호 (Step 9 fallback path 입력).
|
||
raw 32 entry 는 tests/matching/v4_full32_result.yaml 에 영속 보존.
|
||
|
||
Backward compat:
|
||
lookup_v4_match() (rank-1) 는 그대로. Step 6 의 plan_composition()
|
||
호출처 무변. 본 함수는 Step 5 artifact + Step 9 application_plan input
|
||
위한 새 entry point.
|
||
|
||
IMP-#85 u4 — visual_pending frames are excluded from the live candidate
|
||
set (catalog scaffolding without registered builder would crash the
|
||
mapper). lookup_v4_all_judgments raw telemetry is intentionally NOT
|
||
gated here.
|
||
"""
|
||
resolved = _resolve_v4_section_key(v4, section_id, alias_keys=alias_keys)
|
||
sec = v4.get("mdx_sections", {}).get(resolved) if resolved else None
|
||
if not sec:
|
||
return []
|
||
judgments = sec.get("judgments_full32", [])
|
||
candidates: list[V4Match] = []
|
||
for j in judgments:
|
||
if j.get("label") == "reject":
|
||
continue
|
||
tid = j.get("template_id")
|
||
if tid and _is_visual_pending(tid):
|
||
continue
|
||
candidates.append(_v4_match_from_judgment(section_id, j))
|
||
if len(candidates) >= max_n:
|
||
break
|
||
return candidates
|
||
|
||
|
||
def _apply_frame_override_to_unit(unit, new_tid: str, v4: dict) -> str:
|
||
"""IMP-47B u3 — apply a frame override to *unit* in place.
|
||
|
||
Returns a meta_source string for the override book-keeping. Three
|
||
probe layers, in order:
|
||
|
||
1. ``unit.v4_candidates`` (non-reject, max_n bounded). Copies
|
||
frame_id / frame_number / confidence / label from the matching
|
||
candidate so Step 9 metadata stays consistent. Returns
|
||
``"v4_candidates"``.
|
||
2. Full 32 V4 judgments (reject inclusive). When the override
|
||
target matches a reject judgment for the unit's primary section,
|
||
the unit is promoted to ``provisional=True`` with ``label="reject"``
|
||
so Step 12 (IMP-47B u4) admits the AI repair path. Returns
|
||
``"v4_reject_judgment_provisional"``.
|
||
3. Raw fall-through. Updates only ``frame_template_id``; returns
|
||
``"raw_template_id_only"``.
|
||
|
||
Frame visual / contract stay untouched per the AI isolation contract
|
||
(frame auto-swap forbidden — AI re-places content into the existing
|
||
frame only). The caller validates catalog contract presence before
|
||
invoking this helper.
|
||
"""
|
||
for cand in (unit.v4_candidates or []):
|
||
if getattr(cand, "template_id", None) == new_tid:
|
||
unit.frame_template_id = cand.template_id
|
||
unit.frame_id = cand.frame_id
|
||
unit.frame_number = cand.frame_number
|
||
unit.confidence = cand.confidence
|
||
unit.label = cand.label
|
||
return "v4_candidates"
|
||
primary_sid = (
|
||
unit.source_section_ids[0] if unit.source_section_ids else None
|
||
)
|
||
if primary_sid:
|
||
for j in lookup_v4_all_judgments(v4, primary_sid):
|
||
if j.template_id == new_tid and j.label == "reject":
|
||
unit.frame_template_id = j.template_id
|
||
unit.frame_id = j.frame_id
|
||
unit.frame_number = j.frame_number
|
||
unit.confidence = j.confidence
|
||
unit.label = "reject"
|
||
unit.provisional = True
|
||
return "v4_reject_judgment_provisional"
|
||
unit.frame_template_id = new_tid
|
||
return "raw_template_id_only"
|
||
|
||
|
||
# ─── Content weight + zone layout 계산 ─────────────────────────
|
||
# layout preset 선택은 phase_z2_composition.select_layout_preset (composition v0) 가 담당.
|
||
# 본 모듈의 select_layout_preset 은 이전 단순 count-based 구현이었고 dead code 로 제거 (2026-04-29).
|
||
|
||
def compute_content_weight(section: MdxSection) -> dict:
|
||
"""Section 의 콘텐츠 부피 측정 — text/bullet/table/subsection 합성 score."""
|
||
text = section.raw_content
|
||
lines = text.splitlines()
|
||
|
||
text_len = len(text)
|
||
top_bullets = sum(1 for l in lines if re.match(r"^[\*\-]\s", l))
|
||
nested_bullets = sum(1 for l in lines if re.match(r"^\s+[\*\-]\s", l))
|
||
has_table = bool(re.search(r"\|[^\n]+\|\n[ \t]*\|[\s\-:|]+\|", text))
|
||
subsections = len(re.findall(r"^###\s", text, re.MULTILINE))
|
||
|
||
c = CONTENT_WEIGHT_COEFFS
|
||
score = (
|
||
text_len / c["text_per_chars"]
|
||
+ top_bullets * c["top_bullet"]
|
||
+ nested_bullets * c["nested_bullet"]
|
||
+ (c["table_bonus"] if has_table else 0)
|
||
+ subsections * c["subsection"]
|
||
)
|
||
return {
|
||
"score": round(score, 3),
|
||
"text_length": text_len,
|
||
"top_bullets": top_bullets,
|
||
"nested_bullets": nested_bullets,
|
||
"has_table": has_table,
|
||
"subsection_count": subsections,
|
||
}
|
||
|
||
|
||
def compute_zone_layout(zones_data: list[dict],
|
||
total_height: int = SLIDE_BODY_HEIGHT,
|
||
gap: int = GRID_GAP) -> dict:
|
||
"""zone height 계산 — frame_min_height_px 우선 + 남은 공간 content_weight 비율 분배.
|
||
|
||
Returns dict with per-zone heights + reasoning trace.
|
||
"""
|
||
n = len(zones_data)
|
||
if n == 0:
|
||
return {"heights_px": [], "ratios": [], "zones": []}
|
||
|
||
available = total_height - gap * (n - 1)
|
||
|
||
# Step 1: 각 zone 의 min_height 할당 — pipeline 가 zones_data 에 frame contract 의
|
||
# visual_hints.min_height_px 를 미리 주입했음. 없으면 DEFAULT_ZONE_MIN_HEIGHT_PX.
|
||
min_heights = [
|
||
z.get("min_height_px", DEFAULT_ZONE_MIN_HEIGHT_PX)
|
||
for z in zones_data
|
||
]
|
||
total_min = sum(min_heights)
|
||
min_scaled = False
|
||
if total_min > available:
|
||
scale = available / total_min
|
||
min_heights = [int(m * scale) for m in min_heights]
|
||
total_min = sum(min_heights)
|
||
min_scaled = True
|
||
|
||
remaining = available - total_min
|
||
|
||
# Step 2: 남은 공간을 content_weight 비율로 분배
|
||
weights = [z["content_weight"]["score"] for z in zones_data]
|
||
total_w = sum(weights) if sum(weights) > 0 else n
|
||
extras = [int(round(remaining * (w / total_w))) for w in weights]
|
||
|
||
# Step 3: rounding 보정 (마지막 zone 잔여 흡수)
|
||
heights = [m + e for m, e in zip(min_heights, extras)]
|
||
diff = available - sum(heights)
|
||
if diff != 0 and heights:
|
||
heights[-1] += diff
|
||
|
||
ratios = [round(h / total_height, 3) for h in heights]
|
||
|
||
return {
|
||
"computation": "min_height_first + content_weight_distribution",
|
||
"slide_body_height": total_height,
|
||
"gap": gap,
|
||
"available_after_gap": available,
|
||
"min_heights_px": min_heights,
|
||
"min_scaled": min_scaled,
|
||
"total_min_height": total_min,
|
||
"remaining_after_min": remaining,
|
||
"content_weights": [{"position": z["position"],
|
||
"template_id": z["template_id"],
|
||
"score": w}
|
||
for z, w in zip(zones_data, weights)],
|
||
"weight_shares": [round(w / total_w, 3) for w in weights],
|
||
"extras_px": extras,
|
||
"heights_px": heights,
|
||
"ratios": ratios,
|
||
}
|
||
|
||
|
||
# ─── IMP-09 PR 1 helpers (8-preset layout vocabulary) ────────────────
|
||
# Catalog css_areas / css_cols / css_rows parsing + per-zone aggregation
|
||
# + col-axis solver. Symmetric counterparts to compute_zone_layout (row-axis).
|
||
|
||
|
||
def _parse_css_areas(css_areas: str) -> tuple[list[list[str]], list[str]]:
|
||
"""Parse CSS grid-template-areas string into (row x col) cell grid.
|
||
|
||
Input : '"top top" "bottom-left bottom-right"'
|
||
Output : (
|
||
[["top", "top"], ["bottom-left", "bottom-right"]],
|
||
["top", "bottom-left", "bottom-right"],
|
||
)
|
||
|
||
Raises ValueError on empty input, missing quotes, empty row, or
|
||
non-rectangular layout (rows with mismatched column counts).
|
||
"""
|
||
rows: list[list[str]] = []
|
||
seen: list[str] = []
|
||
quoted = re.findall(r'"([^"]+)"', css_areas)
|
||
if not quoted:
|
||
raise ValueError(
|
||
f"_parse_css_areas: no quoted row strings found in {css_areas!r}"
|
||
)
|
||
for row_str in quoted:
|
||
tokens = row_str.split()
|
||
if not tokens:
|
||
raise ValueError(
|
||
f"_parse_css_areas: empty row in {css_areas!r}"
|
||
)
|
||
rows.append(tokens)
|
||
for token in tokens:
|
||
if token not in seen:
|
||
seen.append(token)
|
||
col_counts = {len(r) for r in rows}
|
||
if len(col_counts) > 1:
|
||
raise ValueError(
|
||
f"_parse_css_areas: non-rectangular grid, row column counts = "
|
||
f"{col_counts} in {css_areas!r}"
|
||
)
|
||
return rows, seen
|
||
|
||
|
||
def _parse_fr_string(spec: str, total: int) -> list[int]:
|
||
"""Parse '1fr' / '1fr 1fr' / 'Nfr Mfr' into integer px lengths.
|
||
|
||
Catalog presets (templates/phase_z2/layouts/layouts.yaml) only use
|
||
1fr-only specs; mixed px/fr is out of scope. Raises ValueError on
|
||
non-fr tokens or zero total.
|
||
"""
|
||
fractions: list[float] = []
|
||
for token in spec.split():
|
||
m = re.fullmatch(r"(\d+(?:\.\d+)?)fr", token)
|
||
if not m:
|
||
raise ValueError(
|
||
f"_parse_fr_string: non-fr token {token!r} in {spec!r}"
|
||
)
|
||
fractions.append(float(m.group(1)))
|
||
if not fractions:
|
||
raise ValueError(f"_parse_fr_string: empty spec {spec!r}")
|
||
total_fr = sum(fractions)
|
||
if total_fr <= 0:
|
||
raise ValueError(f"_parse_fr_string: total fr = 0 in {spec!r}")
|
||
sizes = [int(round(total * (f / total_fr))) for f in fractions]
|
||
sizes[-1] += total - sum(sizes)
|
||
return sizes
|
||
|
||
|
||
def compute_zone_layout_cols(zones_data: list[dict],
|
||
total_width: int = SLIDE_BODY_WIDTH,
|
||
gap: int = GRID_GAP) -> dict:
|
||
"""Per-zone column width allocation — weight-only distribution.
|
||
|
||
Symmetric counterpart of compute_zone_layout for the column axis.
|
||
No min_width_px contract exists in frame_contracts.yaml (verified
|
||
empty as of IMP-09), so column allocation is purely content_weight
|
||
score based.
|
||
"""
|
||
n = len(zones_data)
|
||
if n == 0:
|
||
return {"widths_px": [], "width_ratios": [], "zones": []}
|
||
|
||
available = total_width - gap * (n - 1)
|
||
weights = [z["content_weight"]["score"] for z in zones_data]
|
||
total_w = sum(weights)
|
||
|
||
if total_w <= 0:
|
||
# Zero-weight guard (override-empty zone where score=0).
|
||
widths_px = [available // n] * n
|
||
widths_px[-1] += available - sum(widths_px)
|
||
weight_shares = [round(1.0 / n, 3)] * n
|
||
else:
|
||
widths_px = [
|
||
int(round(available * (w / total_w))) for w in weights
|
||
]
|
||
diff = available - sum(widths_px)
|
||
if diff != 0:
|
||
widths_px[-1] += diff
|
||
weight_shares = [round(w / total_w, 3) for w in weights]
|
||
|
||
width_ratios = [round(w / total_width, 3) for w in widths_px]
|
||
|
||
return {
|
||
"computation": "content_weight_distribution_cols",
|
||
"slide_body_width": total_width,
|
||
"gap": gap,
|
||
"available_after_gap": available,
|
||
"content_weights": [
|
||
{"position": z["position"],
|
||
"template_id": z["template_id"],
|
||
"score": w}
|
||
for z, w in zip(zones_data, weights)
|
||
],
|
||
"weight_shares": weight_shares,
|
||
"widths_px": widths_px,
|
||
"width_ratios": width_ratios,
|
||
}
|
||
|
||
|
||
def _aggregate_zone_signals_per_track(
|
||
preset: dict,
|
||
zones_data: list[dict],
|
||
) -> tuple[list[dict], list[dict]]:
|
||
"""Build per-row + per-col virtual zones for 2-D dynamic dispatch.
|
||
|
||
Each virtual zone aggregates content_weight.score (max) and
|
||
min_height_px (max) across single-span zones on that track
|
||
(occupied_rows == {r} for rows, occupied_cols == {c} for cols).
|
||
Falls back to all-span zones (touching every track on the axis)
|
||
when a track has no single-span zone.
|
||
"""
|
||
rows_grid, _ = _parse_css_areas(preset["css_areas"])
|
||
R = len(rows_grid)
|
||
C = len(rows_grid[0])
|
||
|
||
occupancy: list[tuple[dict, set[int], set[int]]] = []
|
||
for z in zones_data:
|
||
pos = z["position"]
|
||
occ_rows = {r for r, row in enumerate(rows_grid) if pos in row}
|
||
occ_cols = {
|
||
c for row in rows_grid for c, tok in enumerate(row) if tok == pos
|
||
}
|
||
occupancy.append((z, occ_rows, occ_cols))
|
||
|
||
def _track_virtual(idx: int, axis: str) -> dict:
|
||
if axis == "row":
|
||
single = [z for z, rr, _cc in occupancy if rr == {idx}]
|
||
allspan = [z for z, rr, _cc in occupancy if rr == set(range(R))]
|
||
else:
|
||
single = [z for z, _rr, cc in occupancy if cc == {idx}]
|
||
allspan = [z for z, _rr, cc in occupancy if cc == set(range(C))]
|
||
candidates = single or allspan
|
||
return {
|
||
"position": f"_virtual_{axis}_{idx}",
|
||
"template_id": f"_virtual_{axis}_{idx}",
|
||
"content_weight": {
|
||
"score": max(c["content_weight"]["score"] for c in candidates)
|
||
},
|
||
"min_height_px": max(
|
||
c.get("min_height_px", DEFAULT_ZONE_MIN_HEIGHT_PX)
|
||
for c in candidates
|
||
),
|
||
}
|
||
|
||
row_virtuals = [_track_virtual(r, "row") for r in range(R)]
|
||
col_virtuals = [_track_virtual(c, "col") for c in range(C)]
|
||
return row_virtuals, col_virtuals
|
||
|
||
|
||
def _compute_per_zone_geometry(
|
||
layout_css: dict,
|
||
debug_zones: list[dict],
|
||
gap: int = GRID_GAP,
|
||
) -> list[dict]:
|
||
"""Aggregate grid-track sizes into per-zone dimensions for ALL layouts.
|
||
|
||
Parses layout_css["areas"] (catalog css_areas) into an R x C cell
|
||
grid, then for each zone in debug_zones sums the heights_px of its
|
||
occupied rows and widths_px of its occupied columns, including the
|
||
inter-track gap absorbed by a spanning zone.
|
||
|
||
Length contract: layout_css["heights_px"] MUST have length R, and
|
||
layout_css["widths_px"] MUST have length C. Mismatch raises
|
||
ValueError because that indicates a broken build path, not a
|
||
runtime input issue.
|
||
"""
|
||
rows_grid, _ = _parse_css_areas(layout_css["areas"])
|
||
R = len(rows_grid)
|
||
C = len(rows_grid[0])
|
||
heights_px = layout_css.get("heights_px") or []
|
||
widths_px = layout_css.get("widths_px") or []
|
||
|
||
if len(heights_px) != R:
|
||
raise ValueError(
|
||
f"_compute_per_zone_geometry: heights_px length "
|
||
f"{len(heights_px)} != grid rows R={R} "
|
||
f"(css_areas={layout_css.get('areas')!r})"
|
||
)
|
||
if len(widths_px) != C:
|
||
raise ValueError(
|
||
f"_compute_per_zone_geometry: widths_px length "
|
||
f"{len(widths_px)} != grid cols C={C} "
|
||
f"(css_areas={layout_css.get('areas')!r})"
|
||
)
|
||
|
||
per_zone: list[dict] = []
|
||
for dz in debug_zones:
|
||
pos = dz["position"]
|
||
occupied_rows = sorted(
|
||
{r for r, row in enumerate(rows_grid) if pos in row}
|
||
)
|
||
occupied_cols = sorted(
|
||
{c for r, row in enumerate(rows_grid)
|
||
for c, tok in enumerate(row) if tok == pos}
|
||
)
|
||
if not occupied_rows or not occupied_cols:
|
||
raise ValueError(
|
||
f"_compute_per_zone_geometry: zone position {pos!r} "
|
||
f"not present in css_areas {rows_grid}"
|
||
)
|
||
zh = (
|
||
sum(heights_px[r] for r in occupied_rows)
|
||
+ gap * (len(occupied_rows) - 1)
|
||
)
|
||
zw = (
|
||
sum(widths_px[c] for c in occupied_cols)
|
||
+ gap * (len(occupied_cols) - 1)
|
||
)
|
||
per_zone.append({
|
||
"position": pos,
|
||
"zone_height_px": zh,
|
||
"zone_width_px": zw,
|
||
"zone_height_ratio": round(zh / SLIDE_BODY_HEIGHT, 3),
|
||
"zone_width_ratio": round(zw / SLIDE_BODY_WIDTH, 3),
|
||
})
|
||
return per_zone
|
||
|
||
|
||
def _build_fr_default(preset: dict) -> dict:
|
||
"""fr-default sink — populate widths_px / heights_px from catalog fr ratios.
|
||
|
||
Replaces the legacy empty-array sink so that downstream consumers
|
||
(Step 7/8 trace, _compute_per_zone_geometry) always receive
|
||
length-locked arrays matching the catalog grid dimensions.
|
||
"""
|
||
rows_grid, _ = _parse_css_areas(preset["css_areas"])
|
||
R = len(rows_grid)
|
||
C = len(rows_grid[0])
|
||
|
||
avail_h = SLIDE_BODY_HEIGHT - GRID_GAP * (R - 1)
|
||
avail_w = SLIDE_BODY_WIDTH - GRID_GAP * (C - 1)
|
||
|
||
heights_px = _parse_fr_string(preset["css_rows"], avail_h)
|
||
widths_px = _parse_fr_string(preset["css_cols"], avail_w)
|
||
return {
|
||
"areas": preset["css_areas"],
|
||
"cols": preset["css_cols"],
|
||
"rows": preset["css_rows"],
|
||
"heights_px": heights_px,
|
||
"widths_px": widths_px,
|
||
"ratios": [round(h / SLIDE_BODY_HEIGHT, 3) for h in heights_px],
|
||
"width_ratios": [round(w / SLIDE_BODY_WIDTH, 3) for w in widths_px],
|
||
"computation": "fr_default_from_preset",
|
||
"dynamic_rows": False,
|
||
"dynamic_cols": False,
|
||
"raw_zone_layout": None,
|
||
}
|
||
|
||
|
||
def _build_rows_dynamic(preset: dict, zones_data: list[dict],
|
||
gap: int = GRID_GAP) -> dict:
|
||
"""horizontal-2 path — dynamic row heights, static fr column widths.
|
||
|
||
Preserves the legacy compute_zone_layout output (heights_px / ratios /
|
||
computation / raw_zone_layout) byte-for-byte; only adds the new
|
||
col-axis keys (widths_px from css_cols fr, width_ratios, dynamic_cols=False).
|
||
"""
|
||
rows_grid, _ = _parse_css_areas(preset["css_areas"])
|
||
C = len(rows_grid[0])
|
||
avail_w = SLIDE_BODY_WIDTH - gap * (C - 1)
|
||
widths_px = _parse_fr_string(preset["css_cols"], avail_w)
|
||
|
||
zl = compute_zone_layout(zones_data, gap=gap)
|
||
rows_str = " ".join(f"{h}px" for h in zl["heights_px"])
|
||
return {
|
||
"areas": preset["css_areas"],
|
||
"cols": preset["css_cols"],
|
||
"rows": rows_str,
|
||
"heights_px": zl["heights_px"],
|
||
"widths_px": widths_px,
|
||
"ratios": zl["ratios"],
|
||
"width_ratios": [round(w / SLIDE_BODY_WIDTH, 3) for w in widths_px],
|
||
"computation": zl["computation"],
|
||
"dynamic_rows": True,
|
||
"dynamic_cols": False,
|
||
"raw_zone_layout": zl,
|
||
}
|
||
|
||
|
||
def _build_grid_dynamic_2d(preset: dict, zones_data: list[dict],
|
||
gap: int = GRID_GAP) -> dict:
|
||
"""2-D dynamic path — dynamic row heights + dynamic column widths.
|
||
|
||
IMP-09 PR 2 (B-4) handler for the five preset families whose topology
|
||
is neither pure 'rows' nor pure 'cols':
|
||
- T (top-1-bottom-2)
|
||
- inverted-T (top-2-bottom-1)
|
||
- side-T-left (left-1-right-2)
|
||
- side-T-right (left-2-right-1)
|
||
- 2x2 (grid-2x2)
|
||
|
||
Strategy:
|
||
1) _aggregate_zone_signals_per_track builds R per-row + C per-col
|
||
virtual zones (max content_weight.score + max min_height_px of
|
||
single-span zones, falling back to all-span zones).
|
||
2) Row virtuals → compute_zone_layout → heights_px (R).
|
||
3) Col virtuals → compute_zone_layout_cols → widths_px (C).
|
||
4) Assemble layout_css dict with computation='2d_dynamic_aggregated'
|
||
and dynamic_rows=True, dynamic_cols=True.
|
||
|
||
raw_zone_layout carries both solver outputs + the virtual zone lists
|
||
so step08 trace can explain the per-track aggregation.
|
||
"""
|
||
row_virtuals, col_virtuals = _aggregate_zone_signals_per_track(
|
||
preset, zones_data
|
||
)
|
||
zl_row = compute_zone_layout(row_virtuals, gap=gap)
|
||
zl_col = compute_zone_layout_cols(col_virtuals, gap=gap)
|
||
|
||
heights_px = zl_row["heights_px"]
|
||
widths_px = zl_col["widths_px"]
|
||
rows_str = " ".join(f"{h}px" for h in heights_px)
|
||
cols_str = " ".join(f"{w}px" for w in widths_px)
|
||
|
||
return {
|
||
"areas": preset["css_areas"],
|
||
"cols": cols_str,
|
||
"rows": rows_str,
|
||
"heights_px": heights_px,
|
||
"widths_px": widths_px,
|
||
"ratios": [round(h / SLIDE_BODY_HEIGHT, 3) for h in heights_px],
|
||
"width_ratios": [round(w / SLIDE_BODY_WIDTH, 3) for w in widths_px],
|
||
"computation": "2d_dynamic_aggregated",
|
||
"dynamic_rows": True,
|
||
"dynamic_cols": True,
|
||
"raw_zone_layout": {
|
||
"row_layout": zl_row,
|
||
"col_layout": zl_col,
|
||
"row_virtuals": row_virtuals,
|
||
"col_virtuals": col_virtuals,
|
||
},
|
||
}
|
||
|
||
|
||
def _build_cols_dynamic(preset: dict, zones_data: list[dict],
|
||
gap: int = GRID_GAP) -> dict:
|
||
"""vertical-2 path — dynamic column widths, static fr row heights.
|
||
|
||
Mirror of _build_rows_dynamic. Returns a pixel grid-template-columns
|
||
string. PR 2 promotes vertical-2 override to dynamic_rows=True; in
|
||
PR 1 dynamic_rows stays False (legacy).
|
||
"""
|
||
rows_grid, _ = _parse_css_areas(preset["css_areas"])
|
||
R = len(rows_grid)
|
||
avail_h = SLIDE_BODY_HEIGHT - gap * (R - 1)
|
||
heights_px = _parse_fr_string(preset["css_rows"], avail_h)
|
||
|
||
zl = compute_zone_layout_cols(zones_data, gap=gap)
|
||
cols_str = " ".join(f"{w}px" for w in zl["widths_px"])
|
||
return {
|
||
"areas": preset["css_areas"],
|
||
"cols": cols_str,
|
||
"rows": preset["css_rows"],
|
||
"heights_px": heights_px,
|
||
"widths_px": zl["widths_px"],
|
||
"ratios": [round(h / SLIDE_BODY_HEIGHT, 3) for h in heights_px],
|
||
"width_ratios": zl["width_ratios"],
|
||
"computation": zl["computation"],
|
||
"dynamic_rows": False,
|
||
"dynamic_cols": True,
|
||
"raw_zone_layout": zl,
|
||
}
|
||
|
||
|
||
def _override_to_grid_tracks(
|
||
preset: dict,
|
||
zones_data: list[dict],
|
||
override_zone_geometries: dict[str, dict],
|
||
gap: int = GRID_GAP,
|
||
) -> dict:
|
||
"""2-D override path — derive heights_px (R) + widths_px (C) from
|
||
user-supplied zone_id -> {x, y, w, h} (0~1 within slide-body).
|
||
|
||
IMP-09 PR 2 (B-4) override handler for the five preset families
|
||
whose topology is neither pure 'rows' nor pure 'cols':
|
||
- T (top-1-bottom-2)
|
||
- inverted-T (top-2-bottom-1)
|
||
- side-T-left (left-1-right-2)
|
||
- side-T-right (left-2-right-1)
|
||
- 2x2 (grid-2x2)
|
||
|
||
Strategy:
|
||
1) Parse css_areas into R x C grid.
|
||
2) For each row r: aggregate h via max over single-row zones
|
||
(occupied_rows == {r}); fallback to all-span zones; else 0.0.
|
||
3) For each col c: same with w.
|
||
4) Normalize per-axis (divide by total) and multiply by avail_*,
|
||
absorbing rounding diff into the last element.
|
||
5) If total_h or total_w == 0 (degenerate / empty override),
|
||
fall back to _build_grid_dynamic_2d default path.
|
||
"""
|
||
rows_grid, _ = _parse_css_areas(preset["css_areas"])
|
||
R = len(rows_grid)
|
||
C = len(rows_grid[0])
|
||
|
||
# Hot-fix (2026-05-22): partial override 버그 fix — override 없는 track 은
|
||
# default 비율로 fallback. 이전엔 0 반환 → normalize 후 다른 track 이 모든 공간 흡수.
|
||
_default_result = _build_grid_dynamic_2d(preset, zones_data, gap=gap)
|
||
_default_widths = _default_result.get("widths_px", []) or []
|
||
_default_heights = _default_result.get("heights_px", []) or []
|
||
_sum_w = sum(_default_widths) if _default_widths else 1.0
|
||
_sum_h = sum(_default_heights) if _default_heights else 1.0
|
||
|
||
occupancy: list[tuple[dict, set[int], set[int]]] = []
|
||
for z in zones_data:
|
||
pos = z["position"]
|
||
occ_rows = {r for r, row in enumerate(rows_grid) if pos in row}
|
||
occ_cols = {
|
||
c for row in rows_grid for c, tok in enumerate(row) if tok == pos
|
||
}
|
||
occupancy.append((z, occ_rows, occ_cols))
|
||
|
||
def _track_value(idx: int, axis: str) -> float:
|
||
if axis == "row":
|
||
single = [z for z, rr, _cc in occupancy if rr == {idx}]
|
||
allspan = [z for z, rr, _cc in occupancy if rr == set(range(R))]
|
||
key = "h"
|
||
_fallback = (_default_heights[idx] / _sum_h) if idx < len(_default_heights) and _sum_h else (1.0 / R)
|
||
else:
|
||
single = [z for z, _rr, cc in occupancy if cc == {idx}]
|
||
allspan = [z for z, _rr, cc in occupancy if cc == set(range(C))]
|
||
key = "w"
|
||
_fallback = (_default_widths[idx] / _sum_w) if idx < len(_default_widths) and _sum_w else (1.0 / C)
|
||
candidates = single or allspan
|
||
vals = [
|
||
float(override_zone_geometries[z["position"]][key])
|
||
for z in candidates
|
||
if z["position"] in override_zone_geometries
|
||
]
|
||
return max(vals) if vals else _fallback
|
||
|
||
row_values = [_track_value(r, "row") for r in range(R)]
|
||
col_values = [_track_value(c, "col") for c in range(C)]
|
||
|
||
total_h = sum(row_values)
|
||
total_w = sum(col_values)
|
||
if total_h == 0 or total_w == 0:
|
||
return _build_grid_dynamic_2d(preset, zones_data, gap=gap)
|
||
|
||
row_ratios = [v / total_h for v in row_values]
|
||
col_ratios = [v / total_w for v in col_values]
|
||
|
||
avail_h = SLIDE_BODY_HEIGHT - gap * (R - 1)
|
||
avail_w = SLIDE_BODY_WIDTH - gap * (C - 1)
|
||
heights_px = [int(round(r * avail_h)) for r in row_ratios]
|
||
widths_px = [int(round(r * avail_w)) for r in col_ratios]
|
||
diff_h = avail_h - sum(heights_px)
|
||
if diff_h != 0 and heights_px:
|
||
heights_px[-1] += diff_h
|
||
diff_w = avail_w - sum(widths_px)
|
||
if diff_w != 0 and widths_px:
|
||
widths_px[-1] += diff_w
|
||
|
||
rows_str = " ".join(f"{h}px" for h in heights_px)
|
||
cols_str = " ".join(f"{w}px" for w in widths_px)
|
||
return {
|
||
"areas": preset["css_areas"],
|
||
"cols": cols_str,
|
||
"rows": rows_str,
|
||
"heights_px": heights_px,
|
||
"widths_px": widths_px,
|
||
"ratios": [round(rr, 3) for rr in row_ratios],
|
||
"width_ratios": [round(rr, 3) for rr in col_ratios],
|
||
"computation": "user_override_geometry",
|
||
"dynamic_rows": True,
|
||
"dynamic_cols": True,
|
||
"raw_zone_layout": {
|
||
"override_applied": True,
|
||
"source": override_zone_geometries,
|
||
},
|
||
}
|
||
|
||
|
||
# Layout preset → zone position 순서 = LAYOUT_PRESETS[preset]["positions"] 직접 사용.
|
||
# 이전 ZONE_POSITIONS_BY_PRESET (type-b 등 legacy 명) 는 dead code 로 제거 (2026-04-29).
|
||
|
||
|
||
def build_layout_css(layout_preset: str, zones_data: list[dict],
|
||
gap: int = GRID_GAP,
|
||
override_zone_geometries: Optional[dict[str, dict]] = None) -> dict:
|
||
"""Composition v0 layout preset → CSS grid 문자열.
|
||
|
||
IMP-09 PR 1 contract — every layout_css return path carries
|
||
matching-length heights_px (= grid rows R) and widths_px (= grid cols C),
|
||
plus ratios / width_ratios / dynamic_rows / dynamic_cols. The
|
||
horizontal-2 grid CSS strings (areas/cols/rows) remain byte-identical
|
||
to the legacy path.
|
||
|
||
Dynamic dispatch:
|
||
- topology="rows" -> _build_rows_dynamic (horizontal-2: row heights)
|
||
- topology="cols" -> _build_cols_dynamic (vertical-2: col widths)
|
||
- other topologies (single / T / inverted-T / side-T / 2x2) fall
|
||
through to _build_fr_default in PR 1; PR 2 enables the 2-D
|
||
dispatcher.
|
||
|
||
Step D-ext (사용자 lock 2026-05-08) — override_zone_geometries (zone_id ->
|
||
{x,y,w,h} slide-body 내부 0~1) 가 들어오면 그 비율로 layout_css 강제.
|
||
PR 1 lock: horizontal-2 / vertical-2 만 처리 (legacy inline preserve).
|
||
다른 preset 은 warn-and-fallthrough (PR 2 가 unified _override_to_grid_tracks
|
||
로 promote).
|
||
"""
|
||
preset = LAYOUT_PRESETS[layout_preset]
|
||
positions = preset["positions"]
|
||
topology = preset.get("topology")
|
||
|
||
# ── Step D-ext : user override 처리 ──
|
||
if override_zone_geometries:
|
||
if layout_preset == "horizontal-2":
|
||
# IMP-44 u1 — unknown-key guard: drop foreign-preset keys
|
||
# (예: vertical-2 keys {left,right} sent to horizontal-2), emit
|
||
# structured warning, keep matching keys. All-unknown → fall
|
||
# through to default dynamic dispatch (no false override_applied).
|
||
unknown_keys = sorted(
|
||
k for k in override_zone_geometries if k not in positions
|
||
)
|
||
if unknown_keys:
|
||
print(
|
||
f" [override-warning] layout_preset={layout_preset} "
|
||
f"expected_positions={list(positions)} unknown_keys={unknown_keys} "
|
||
f"(dropped foreign-preset keys; default split for non-overridden).",
|
||
file=sys.stderr,
|
||
)
|
||
filtered_overrides = {
|
||
k: v for k, v in override_zone_geometries.items() if k in positions
|
||
}
|
||
if filtered_overrides:
|
||
# heights_px override — zone 의 h 비율로 SLIDE_BODY_HEIGHT 분배.
|
||
# Hot-fix (2026-05-22): partial override = 나머지 공간을 비-override zone 들에
|
||
# 균등 분배 (drag boundary intent). 이전엔 0.0 fallback → 100/0 깨짐.
|
||
overridden_h = sum(
|
||
float(filtered_overrides[p]["h"])
|
||
for p in positions if p in filtered_overrides
|
||
)
|
||
non_overridden = [p for p in positions if p not in filtered_overrides]
|
||
per_non = max(0.0, 1.0 - overridden_h) / max(len(non_overridden), 1)
|
||
ratios = []
|
||
for pos in positions:
|
||
geom = filtered_overrides.get(pos)
|
||
ratios.append(float(geom["h"]) if geom else per_non)
|
||
total = sum(ratios)
|
||
if total > 0:
|
||
heights_px = [int(round(r / total * SLIDE_BODY_HEIGHT)) for r in ratios]
|
||
rows = " ".join(f"{h}px" for h in heights_px)
|
||
return {
|
||
"areas": preset["css_areas"],
|
||
"cols": preset["css_cols"],
|
||
"rows": rows,
|
||
"heights_px": heights_px,
|
||
"widths_px": [SLIDE_BODY_WIDTH],
|
||
"ratios": [round(r / total, 3) for r in ratios],
|
||
"width_ratios": [1.0],
|
||
"computation": "user_override_geometry",
|
||
"dynamic_rows": True,
|
||
"dynamic_cols": False,
|
||
"raw_zone_layout": {"override_applied": True, "source": filtered_overrides},
|
||
}
|
||
elif layout_preset == "vertical-2":
|
||
# IMP-44 u1 — unknown-key guard: drop foreign-preset keys
|
||
# (예: horizontal-2 keys {top,bottom} sent to vertical-2), emit
|
||
# structured warning, keep matching keys. All-unknown → fall
|
||
# through to default dynamic dispatch (no false override_applied).
|
||
unknown_keys = sorted(
|
||
k for k in override_zone_geometries if k not in positions
|
||
)
|
||
if unknown_keys:
|
||
print(
|
||
f" [override-warning] layout_preset={layout_preset} "
|
||
f"expected_positions={list(positions)} unknown_keys={unknown_keys} "
|
||
f"(dropped foreign-preset keys; default split for non-overridden).",
|
||
file=sys.stderr,
|
||
)
|
||
filtered_overrides = {
|
||
k: v for k, v in override_zone_geometries.items() if k in positions
|
||
}
|
||
if filtered_overrides:
|
||
# cols override — zone 의 w 비율로 fr 분배 (legacy: fr-string cols).
|
||
# PR 1 keeps fr-string cols for legacy preserve; widths_px is
|
||
# populated in pixels for _compute_per_zone_geometry length contract.
|
||
# Hot-fix (2026-05-22): partial override = 나머지 공간을 비-override zone 들에
|
||
# 균등 분배 (drag boundary intent). 이전엔 0.0 fallback → 100/0 깨짐.
|
||
overridden_w = sum(
|
||
float(filtered_overrides[p]["w"])
|
||
for p in positions if p in filtered_overrides
|
||
)
|
||
non_overridden = [p for p in positions if p not in filtered_overrides]
|
||
per_non = max(0.0, 1.0 - overridden_w) / max(len(non_overridden), 1)
|
||
ratios = []
|
||
for pos in positions:
|
||
geom = filtered_overrides.get(pos)
|
||
ratios.append(float(geom["w"]) if geom else per_non)
|
||
total = sum(ratios)
|
||
if total > 0:
|
||
cols = " ".join(f"{round(r / total * 100, 2)}fr" for r in ratios)
|
||
normalized = [r / total for r in ratios]
|
||
widths_px = [
|
||
int(round(rr * (SLIDE_BODY_WIDTH - gap * (len(ratios) - 1))))
|
||
for rr in normalized
|
||
]
|
||
diff = (SLIDE_BODY_WIDTH - gap * (len(ratios) - 1)) - sum(widths_px)
|
||
if diff != 0 and widths_px:
|
||
widths_px[-1] += diff
|
||
return {
|
||
"areas": preset["css_areas"],
|
||
"cols": cols,
|
||
"rows": preset["css_rows"],
|
||
"heights_px": [SLIDE_BODY_HEIGHT],
|
||
"widths_px": widths_px,
|
||
"ratios": [1.0],
|
||
"width_ratios": [round(rr, 3) for rr in normalized],
|
||
"computation": "user_override_geometry",
|
||
"dynamic_rows": False,
|
||
"dynamic_cols": True,
|
||
"raw_zone_layout": {"override_applied": True, "source": filtered_overrides},
|
||
}
|
||
elif topology in ("T", "inverted-T", "side-T-left", "side-T-right", "2x2"):
|
||
# IMP-09 PR 2 — 2-D override path (T / inverted-T / side-T / 2x2).
|
||
# Degenerate inputs (total_h == 0 or total_w == 0) fall back to
|
||
# _build_grid_dynamic_2d inside the helper.
|
||
#
|
||
# IMP-44 u2 — unknown-key guard mirrors u1 (1-D): drop foreign-
|
||
# preset keys (예: vertical-2 keys {left,right} sent to T-preset),
|
||
# emit structured warning, keep matching keys. All-unknown → fall
|
||
# through to _build_grid_dynamic_2d default (no false override_applied).
|
||
unknown_keys = sorted(
|
||
k for k in override_zone_geometries if k not in positions
|
||
)
|
||
if unknown_keys:
|
||
print(
|
||
f" [override-warning] layout_preset={layout_preset} "
|
||
f"expected_positions={list(positions)} unknown_keys={unknown_keys} "
|
||
f"(dropped foreign-preset keys; default split for non-overridden).",
|
||
file=sys.stderr,
|
||
)
|
||
filtered_overrides = {
|
||
k: v for k, v in override_zone_geometries.items() if k in positions
|
||
}
|
||
if filtered_overrides:
|
||
return _override_to_grid_tracks(
|
||
preset, zones_data, filtered_overrides, gap=gap
|
||
)
|
||
return _build_grid_dynamic_2d(preset, zones_data, gap=gap)
|
||
else:
|
||
# warn-and-fallthrough preserved for remaining presets (single).
|
||
# PR 3 territory.
|
||
print(
|
||
f" [override-warning] zone-geometry override 는 layout '{layout_preset}' 미지원 "
|
||
f"(현재 horizontal-2 / vertical-2 / 2-D presets 만). default layout_css 사용.",
|
||
file=sys.stderr,
|
||
)
|
||
|
||
# ── Dynamic branch — topology dispatch ──
|
||
if topology == "rows":
|
||
return _build_rows_dynamic(preset, zones_data, gap)
|
||
if topology == "cols":
|
||
return _build_cols_dynamic(preset, zones_data, gap)
|
||
if topology in ("T", "inverted-T", "side-T-left", "side-T-right", "2x2"):
|
||
return _build_grid_dynamic_2d(preset, zones_data, gap)
|
||
# PR 3 will dispatch single here.
|
||
return _build_fr_default(preset)
|
||
|
||
|
||
# ─── Abort ──────────────────────────────────────────────────────
|
||
|
||
def abort_with_error(run_dir: Path, section: MdxSection,
|
||
match: Optional[V4Match], stage: str, reason: str):
|
||
error_data = {
|
||
"section": {"id": section.section_id, "title": section.title},
|
||
"frame": {
|
||
"id": match.frame_id if match else None,
|
||
"number": match.frame_number if match else None,
|
||
"template_id": match.template_id if match else None,
|
||
},
|
||
"v4_label": match.label if match else None,
|
||
"phase_z_status": to_phase_z_status(match) if match else None,
|
||
"confidence": match.confidence if match else None,
|
||
"stage": stage,
|
||
"reason": reason,
|
||
}
|
||
run_dir.mkdir(parents=True, exist_ok=True)
|
||
err_path = run_dir / "error.json"
|
||
err_path.write_text(json.dumps(error_data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
print(f"\n[Phase Z-2 MVP-1.5b] ABORT @ {stage}", file=sys.stderr)
|
||
print(f" section : {section.section_id} — {section.title}", file=sys.stderr)
|
||
if match:
|
||
print(f" frame : {match.frame_id} ({match.template_id})", file=sys.stderr)
|
||
print(f" status : V4 label '{match.label}' → Phase Z '{to_phase_z_status(match)}'", file=sys.stderr)
|
||
print(f" reason : {reason}", file=sys.stderr)
|
||
print(f" error : {err_path}", file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
|
||
# ─── IMP-06 Step 6 zone-section assignment override (backend/CLI/composition only) ──
|
||
|
||
|
||
def _build_position_assignment_plan(
|
||
units: list,
|
||
positions: list[str],
|
||
override_section_assignments: Optional[dict[str, list[str]]],
|
||
sections_by_id: dict[str, "MdxSection"],
|
||
override_frames: Optional[dict[str, str]] = None,
|
||
v4: Optional[dict] = None,
|
||
) -> tuple[list[dict], dict]:
|
||
"""IMP-06 (#6 / Codex #6,#7 15-axis lock) — section-to-position assignment plan.
|
||
|
||
Pure helper invoked AFTER ``plan_composition()`` returns ``units`` and
|
||
AFTER ``override_layout`` has been applied so ``positions`` is the final
|
||
layout-preset position vocabulary.
|
||
|
||
Locked behavior :
|
||
- explicit override wins per position
|
||
- no section id appears in more than one final rendered position
|
||
- overlapping auto units are skipped WHOLE (no split, no cascade, no replan)
|
||
- template_id resolution ladder :
|
||
(1) ``override_frames`` exact ``unit_id`` (catalog validation downstream)
|
||
(2) exact existing auto unit reuse (same ``source_section_ids``)
|
||
(3) single-section override -> ``lookup_v4_match_with_fallback`` with
|
||
``raw_content=section.raw_content`` (direct executable only)
|
||
(4) multi-section ad-hoc override (no exact auto + no override-frame)
|
||
-> skipped_reason='ad_hoc_merged_no_template'
|
||
- additive trace : ``previous_source_section_ids`` (position-level history),
|
||
``skipped_collided_auto_units`` (collision-level), ``uncovered_section_ids``
|
||
(post-override coverage gap), ``v4_selector_trace`` (selector failure embed),
|
||
``skipped_reason`` for failed assignments
|
||
|
||
Returns ``(plan, summary)`` where :
|
||
- ``plan`` : list[dict] keyed by position with the per-position record
|
||
- ``summary`` : dict with applied/skipped counts + uncovered ids
|
||
|
||
NOTE : the helper does NOT mutate ``units`` and does NOT raise on validation
|
||
failures; caller is responsible for fail-fast validation of unknown zone ids
|
||
or unknown section ids before calling.
|
||
"""
|
||
overrides = override_section_assignments or {}
|
||
override_frames = override_frames or {}
|
||
|
||
# Section ids reserved by any explicit override.
|
||
overridden_section_ids: set[str] = set()
|
||
for _zid, sids in overrides.items():
|
||
overridden_section_ids.update(sids)
|
||
|
||
# Build position -> auto unit baseline. Auto plan uses sequential mapping of
|
||
# ``units`` over ``positions`` (the same order Step 6 of the pipeline uses).
|
||
auto_by_position: dict[str, object] = {}
|
||
for i, pos in enumerate(positions):
|
||
auto_by_position[pos] = units[i] if i < len(units) else None
|
||
|
||
# Reverse lookup : section_id -> auto unit (for collision detection).
|
||
auto_unit_by_section: dict[str, object] = {}
|
||
for u in units:
|
||
for sid in u.source_section_ids:
|
||
auto_unit_by_section[sid] = u
|
||
|
||
# Track auto units that get whole-skipped because of collision.
|
||
skipped_collided_unit_ids: set[str] = set()
|
||
|
||
plan: list[dict] = []
|
||
|
||
def _unit_id(sids: list[str]) -> str:
|
||
return "+".join(sids)
|
||
|
||
def _resolve_template_for_override(zone_id: str, sids: list[str]) -> tuple[
|
||
Optional[str], Optional[str], Optional[dict]
|
||
]:
|
||
"""template_id resolution ladder. Returns (template_id, skipped_reason, v4_selector_trace)."""
|
||
unit_id = _unit_id(sids)
|
||
# (1) explicit --override-frame for exact unit_id
|
||
if unit_id in override_frames:
|
||
return override_frames[unit_id], None, None
|
||
# (2) exact existing auto unit reuse
|
||
for u in units:
|
||
if list(u.source_section_ids) == list(sids):
|
||
return getattr(u, "frame_template_id", None) or getattr(u, "template_id", None), None, None
|
||
# (3) single-section selector
|
||
if len(sids) == 1:
|
||
sid = sids[0]
|
||
section = sections_by_id.get(sid)
|
||
if v4 is None or section is None:
|
||
return None, "no_v4_section", None
|
||
raw_content = getattr(section, "raw_content", None)
|
||
# IMP-08 B-3 : forward sub-section V4 aliases (decimal heading_number)
|
||
# when canonical ordinal id misses; safe for top-level sids (empty list).
|
||
alias_keys = list(getattr(section, "v4_alias_keys", []) or [])
|
||
match, trace = lookup_v4_match_with_fallback(
|
||
v4, sid, raw_content=raw_content, alias_keys=alias_keys
|
||
)
|
||
if match is None:
|
||
return None, "no_direct_render_template", trace
|
||
return match.template_id, None, trace
|
||
# (4) ad-hoc multi-section override without exact auto + without override-frame
|
||
return None, "ad_hoc_merged_no_template", None
|
||
|
||
# Iterate positions deterministically. Explicit overrides win.
|
||
for pos in positions:
|
||
if pos in overrides:
|
||
sids = overrides[pos]
|
||
auto_unit = auto_by_position.get(pos)
|
||
previous_source_section_ids = (
|
||
list(auto_unit.source_section_ids) if auto_unit is not None else []
|
||
)
|
||
# Sections that the previous auto unit at this position contained but
|
||
# the explicit override did not take = uncovered post-override.
|
||
sids_set = set(sids)
|
||
uncovered_from_previous = [
|
||
sid for sid in previous_source_section_ids if sid not in sids_set
|
||
]
|
||
template_id, skipped_reason, selector_trace = _resolve_template_for_override(pos, sids)
|
||
# IMP-06 Stage 4 (Codex #9 R1 + Claude #9 Catch L + Codex #10) — replaced_auto_unit
|
||
# populated only when the same position previously had an auto unit and that
|
||
# auto unit was different from the requested override. Documents "this auto
|
||
# unit was removed from this position to apply the override" as a distinct
|
||
# audit fact (vs skipped_collided_auto_units which is cross-position skip).
|
||
replaced_auto_unit = None
|
||
if auto_unit is not None and list(auto_unit.source_section_ids) != list(sids):
|
||
replaced_auto_unit = {
|
||
"unit_id": _unit_id(list(auto_unit.source_section_ids)),
|
||
"source_section_ids": list(auto_unit.source_section_ids),
|
||
"reason": "same_position_override_replacement",
|
||
}
|
||
plan.append({
|
||
"position": pos,
|
||
"assignment_source": "cli_override",
|
||
"unit_id": _unit_id(sids),
|
||
"source_section_ids": list(sids),
|
||
"template_id": template_id,
|
||
"previous_source_section_ids": previous_source_section_ids,
|
||
"section_assignment_override": {
|
||
"override_applied": True,
|
||
"override_source": "cli",
|
||
"zone_id": pos,
|
||
"requested_section_ids": list(sids),
|
||
},
|
||
"replaced_auto_unit": replaced_auto_unit,
|
||
"skipped_collided_auto_units": [],
|
||
"uncovered_section_ids": uncovered_from_previous,
|
||
"skipped_reason": skipped_reason,
|
||
"v4_selector_trace": selector_trace,
|
||
})
|
||
else:
|
||
# Auto-retain unless overlap with overridden sections.
|
||
auto_unit = auto_by_position.get(pos)
|
||
if auto_unit is None:
|
||
plan.append({
|
||
"position": pos,
|
||
"assignment_source": "empty",
|
||
"unit_id": None,
|
||
"source_section_ids": [],
|
||
"template_id": None,
|
||
"previous_source_section_ids": [],
|
||
"section_assignment_override": None,
|
||
"replaced_auto_unit": None,
|
||
"skipped_collided_auto_units": [],
|
||
"uncovered_section_ids": [],
|
||
"skipped_reason": "no_auto_unit_available",
|
||
"v4_selector_trace": None,
|
||
})
|
||
continue
|
||
overlap = [sid for sid in auto_unit.source_section_ids if sid in overridden_section_ids]
|
||
if overlap:
|
||
# Whole-skip the auto unit. Sections in the auto unit that were NOT taken
|
||
# by an override become uncovered.
|
||
unit_id_str = _unit_id(list(auto_unit.source_section_ids))
|
||
skipped_collided_unit_ids.add(unit_id_str)
|
||
uncovered = [
|
||
sid for sid in auto_unit.source_section_ids
|
||
if sid not in overridden_section_ids
|
||
]
|
||
plan.append({
|
||
"position": pos,
|
||
"assignment_source": "empty",
|
||
"unit_id": None,
|
||
"source_section_ids": [],
|
||
"template_id": None,
|
||
"previous_source_section_ids": list(auto_unit.source_section_ids),
|
||
"section_assignment_override": None,
|
||
"replaced_auto_unit": None,
|
||
"skipped_collided_auto_units": [{
|
||
"unit_id": unit_id_str,
|
||
"source_section_ids": list(auto_unit.source_section_ids),
|
||
"reason": "override_collision",
|
||
}],
|
||
"uncovered_section_ids": uncovered,
|
||
"skipped_reason": "override_collision",
|
||
"v4_selector_trace": None,
|
||
})
|
||
else:
|
||
plan.append({
|
||
"position": pos,
|
||
"assignment_source": "auto",
|
||
"unit_id": _unit_id(list(auto_unit.source_section_ids)),
|
||
"source_section_ids": list(auto_unit.source_section_ids),
|
||
"template_id": (
|
||
getattr(auto_unit, "frame_template_id", None)
|
||
or getattr(auto_unit, "template_id", None)
|
||
),
|
||
"previous_source_section_ids": list(auto_unit.source_section_ids),
|
||
"section_assignment_override": None,
|
||
"replaced_auto_unit": None,
|
||
"skipped_collided_auto_units": [],
|
||
"uncovered_section_ids": [],
|
||
"skipped_reason": None,
|
||
"v4_selector_trace": None,
|
||
})
|
||
|
||
# Summary aggregates.
|
||
applied = [p for p in plan if p["assignment_source"] == "cli_override"]
|
||
skipped_assignments = [p for p in plan if p["skipped_reason"] is not None]
|
||
all_uncovered: list[str] = []
|
||
for p in plan:
|
||
all_uncovered.extend(p.get("uncovered_section_ids", []))
|
||
summary = {
|
||
"section_assignment_overrides_applied": [
|
||
{"position": p["position"], "source_section_ids": p["source_section_ids"]}
|
||
for p in applied
|
||
],
|
||
"section_assignment_overrides_skipped": [
|
||
{"position": p["position"], "reason": p["skipped_reason"]}
|
||
for p in skipped_assignments
|
||
],
|
||
"applied_count": len(applied),
|
||
"skipped_count": len(skipped_assignments),
|
||
"uncovered_section_ids": all_uncovered,
|
||
"skipped_collided_auto_unit_ids": sorted(skipped_collided_unit_ids),
|
||
}
|
||
return plan, summary
|
||
|
||
|
||
# ─── Slot mapping (catalog-only dispatch) ──────────────────────
|
||
|
||
def _known_contract_ids() -> list[str]:
|
||
from phase_z2_mapper import load_frame_contracts
|
||
return list(load_frame_contracts().keys())
|
||
|
||
|
||
def map_mdx_to_slots(section: MdxSection, template_id: str) -> dict:
|
||
"""template_id → slot_payload via catalog contract only.
|
||
|
||
F13/F29/F16 등 모든 frame 의 slot 구조 / cardinality / role / payload builder 는
|
||
`templates/phase_z2/catalog/frame_contracts.yaml` 에 선언. legacy hand-coded
|
||
mapper / MAPPER_BY_TEMPLATE / COLOR_CLASS_BY_KEYWORD / 관련 helper 는
|
||
F13/F29/F16 transition (2026-04-29) 후 모두 제거.
|
||
|
||
template_id 가 catalog 에 없으면 ValueError — fallback 없음.
|
||
새 frame 추가 = catalog yaml 에 entry 추가 + (필요시) 새 builder/parser 등록.
|
||
"""
|
||
contract = get_contract(template_id)
|
||
if contract is None:
|
||
raise ValueError(
|
||
f"No frame_contracts entry for template_id='{template_id}'. "
|
||
f"Add an entry in templates/phase_z2/catalog/frame_contracts.yaml. "
|
||
f"Known contracts: {sorted(_known_contract_ids())}."
|
||
)
|
||
return map_with_contract(section, contract)
|
||
|
||
|
||
# ─── Asset copy ─────────────────────────────────────────────────
|
||
|
||
def copy_assets(template_id: str, run_dir: Path) -> Optional[Path]:
|
||
"""Frame asset (Figma) 폴더 복사 — frame_id 는 catalog contract 에서 도출.
|
||
|
||
contract 에 `frame_id` 없으면 (asset 없는 frame) None 반환.
|
||
이전엔 pipeline.py 에 TEMPLATE_TO_FRAME_ID Python dict 가 있었지만 catalog 로 이전 (2026-04-29).
|
||
"""
|
||
contract = get_contract(template_id)
|
||
frame_id = (contract or {}).get("frame_id")
|
||
if not frame_id:
|
||
return None
|
||
src = ASSETS_SOURCE_BASE / str(frame_id) / "assets"
|
||
if not src.exists():
|
||
return None
|
||
dst = run_dir / "assets" / template_id
|
||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||
if dst.exists():
|
||
shutil.rmtree(dst)
|
||
shutil.copytree(src, dst)
|
||
return dst
|
||
|
||
|
||
# ─── Render (single slide + Type B) ────────────────────────────
|
||
|
||
def _read_token_css() -> str:
|
||
token_dir = PROJECT_ROOT / "templates" / "styles" / "tokens"
|
||
files = ["typography.css", "spacing.css", "colors.css"]
|
||
parts = []
|
||
for f in files:
|
||
path = token_dir / f
|
||
if path.exists():
|
||
parts.append(f"/* === {f} === */\n{path.read_text(encoding='utf-8')}")
|
||
return "\n\n".join(parts)
|
||
|
||
|
||
def _attempt_zone_ratio_retry(
|
||
*,
|
||
run_dir: Path,
|
||
out_path: Path,
|
||
slide_title: str,
|
||
slide_footer: Optional[str],
|
||
zones_data: list[dict],
|
||
debug_zones: list[dict],
|
||
layout_preset: str,
|
||
layout_css: dict,
|
||
overflow: dict,
|
||
fit_classification: dict,
|
||
router_decision: dict,
|
||
gap_px: int,
|
||
) -> dict:
|
||
"""A3 zone_ratio_retry orchestration.
|
||
|
||
locked rules :
|
||
- retry budget = 1
|
||
- slide-base / spacing / gap 고정
|
||
- target zone height 만 증가, sibling donor 에서 같은 양 차감
|
||
- donor 룰 strict (visual ok / capacity ok / slack > 0 / min_height 보존)
|
||
- (b) revert : redistribution fail 또는 rerender 후 visual fail 시 original final.html 그대로
|
||
|
||
Returns:
|
||
retry_trace dict (always returned, even when no retry attempted) with :
|
||
retry_attempted : bool
|
||
retry_action : 'zone_ratio_retry' or None
|
||
plan : phase_z2_retry.plan_zone_ratio_retry() 결과 (있을 때만)
|
||
rerender_attempted : bool
|
||
retry_passed : bool
|
||
retry_failure_reason : str or None
|
||
retried_candidate_path : str or None (rerender 한 경우 진단 artifact 경로)
|
||
post_retry_overflow : dict (retry_passed=True 일 때만)
|
||
post_retry_debug_zones : list (retry_passed=True 일 때만 — height_px 갱신본)
|
||
post_retry_layout_css : dict (retry_passed=True 일 때만)
|
||
"""
|
||
base_trace = {
|
||
"retry_attempted": False,
|
||
"retry_action": None,
|
||
"plan": None,
|
||
"rerender_attempted": False,
|
||
"retry_passed": False,
|
||
"retry_failure_reason": None,
|
||
"retried_candidate_path": None,
|
||
"safety_margin_px": DEFAULT_SAFETY_MARGIN_PX,
|
||
"policy": (
|
||
"A3 locked rules : retry budget=1, slide-base/spacing/gap fixed, "
|
||
"donor strict (sibling/visual ok/capacity ok/slack>0/min_height 보존), "
|
||
"(b) revert on redistribution fail or rerender visual fail."
|
||
),
|
||
}
|
||
|
||
# 1. retry attempt 자체가 적절한지 판단
|
||
if not router_decision.get("router_active"):
|
||
base_trace["retry_skipped_reason"] = "router_active=False (visual check passed — no overflow)"
|
||
return base_trace
|
||
|
||
proposed = router_decision.get("proposed_actions_summary") or []
|
||
if "zone_ratio_retry" not in proposed:
|
||
base_trace["retry_skipped_reason"] = (
|
||
f"zone_ratio_retry not in proposed_actions {proposed} (다른 action category)"
|
||
)
|
||
return base_trace
|
||
|
||
# IMP-09 PR 1 retry gate — row-axis retry is only valid for layouts whose
|
||
# row geometry is dynamic. 2-D / dynamic_cols layouts and fr_default sinks
|
||
# would either misapply row-only redistribution or produce a no-op trace.
|
||
if layout_css.get("dynamic_cols", False):
|
||
base_trace["retry_skipped_reason"] = (
|
||
"layout has dynamic_cols (2-D topology) — "
|
||
"row-axis retry not applicable to 2-D layouts (IMP-09 lock)"
|
||
)
|
||
return base_trace
|
||
if not layout_css.get("dynamic_rows", False):
|
||
base_trace["retry_skipped_reason"] = (
|
||
"layout is fr_default_from_preset (no dynamic geometry) — retry no-op"
|
||
)
|
||
return base_trace
|
||
|
||
# 2. plan
|
||
base_trace["retry_attempted"] = True
|
||
base_trace["retry_action"] = "zone_ratio_retry"
|
||
plan = plan_zone_ratio_retry(
|
||
debug_zones=debug_zones,
|
||
overflow=overflow,
|
||
fit_classification=fit_classification,
|
||
router_decision=router_decision,
|
||
safety_margin_px=DEFAULT_SAFETY_MARGIN_PX,
|
||
)
|
||
base_trace["plan"] = plan
|
||
|
||
if plan is None:
|
||
base_trace["retry_failure_reason"] = "plan_zone_ratio_retry returned None — no target classification matched zone_ratio_retry"
|
||
return base_trace
|
||
|
||
if not plan.get("feasible"):
|
||
# redistribution check 실패 → rerender 안 함, original final.html 그대로
|
||
base_trace["retry_failure_reason"] = plan.get("failure_reason")
|
||
print(
|
||
f" retry : zone_ratio_retry redistribution INFEASIBLE — "
|
||
f"target {plan['target_zone_position']} needs {plan['target_added_px']}px, "
|
||
f"{plan.get('failure_reason')}"
|
||
)
|
||
return base_trace
|
||
|
||
# 3. feasible — apply plan to layout_css, rerender to candidate path (NOT final.html yet)
|
||
new_layout_css = apply_retry_to_layout_css(
|
||
layout_css, plan, zones_data,
|
||
total_height=SLIDE_BODY_HEIGHT, gap_px=gap_px,
|
||
)
|
||
candidate_path = run_dir / "retried_candidate.html"
|
||
candidate_html = render_slide(
|
||
slide_title, slide_footer, zones_data, layout_preset, new_layout_css, gap_px=gap_px,
|
||
)
|
||
candidate_path.write_text(candidate_html, encoding="utf-8")
|
||
base_trace["rerender_attempted"] = True
|
||
base_trace["retried_candidate_path"] = str(candidate_path.relative_to(PROJECT_ROOT))
|
||
|
||
print(
|
||
f" retry : zone_ratio_retry attempted — target {plan['target_zone_position']} "
|
||
f"+{plan['target_added_px']}px (donor {plan['donor_zone_position']} -{plan['donor_reduced_px']}px) "
|
||
f"→ rerender to retried_candidate.html → visual check"
|
||
)
|
||
|
||
# 4. 후 visual check on candidate
|
||
candidate_overflow = run_overflow_check(candidate_path)
|
||
|
||
if candidate_overflow.get("passed", False):
|
||
# 성공 — final.html 을 candidate 로 promote
|
||
out_path.write_text(candidate_html, encoding="utf-8")
|
||
# debug_zones height_px / ratio 갱신 (post-retry 상태)
|
||
new_heights = new_layout_css["heights_px"]
|
||
new_ratios = new_layout_css["ratios"]
|
||
post_retry_debug_zones = []
|
||
for i, dz in enumerate(debug_zones):
|
||
new_dz = dict(dz)
|
||
new_dz["height_px"] = new_heights[i] if i < len(new_heights) else dz.get("height_px")
|
||
new_dz["ratio"] = new_ratios[i] if i < len(new_ratios) else dz.get("ratio")
|
||
new_dz["zone_height_post_retry"] = True
|
||
post_retry_debug_zones.append(new_dz)
|
||
|
||
base_trace["retry_passed"] = True
|
||
base_trace["post_retry_overflow"] = candidate_overflow
|
||
base_trace["post_retry_debug_zones"] = post_retry_debug_zones
|
||
base_trace["post_retry_layout_css"] = new_layout_css
|
||
print(f" retry : PASSED — final.html promoted to retried version")
|
||
return base_trace
|
||
|
||
# 5. rerender 후에도 visual fail → (b) revert : final.html 은 original 그대로 (이미 written)
|
||
base_trace["retry_passed"] = False
|
||
base_trace["retry_failure_reason"] = (
|
||
f"rerender visual_check failed: {candidate_overflow.get('fail_reasons')}. "
|
||
f"reverting to original final.html (retried_candidate.html stays as diagnostic only)."
|
||
)
|
||
base_trace["candidate_overflow_summary"] = {
|
||
"passed": False,
|
||
"fail_reasons": candidate_overflow.get("fail_reasons", []),
|
||
}
|
||
print(f" retry : FAILED — candidate visual_check 도 실패. revert to original. ({candidate_path.name} 은 diagnostic 으로 보존)")
|
||
return base_trace
|
||
|
||
|
||
# IMP-12 u8 — Step 17 salvage cascade orchestrator (deterministic, no normal-path AI).
|
||
# Plan/apply pairs: phase_z2_retry (u4/u5/u6). Routing: failure_router.route_retry_failure (u3).
|
||
# Pipeline wiring (cascade_inputs assembly + retry_trace merge) lands in u9.
|
||
# IMP-88 u6 — extended with layout_adjust + frame_internal_fit_candidate dispatch.
|
||
# Mirror of failure_router.SALVAGE_FAILURE_TYPE_BY_ACTION (single source-of-truth lives
|
||
# there; this local map gates which actions the salvage loop can execute and feeds the
|
||
# loop-cap range(len(...)) so cascade depth scales with implemented executors).
|
||
_SALVAGE_FAIL_BY_ACTION = {
|
||
"cross_zone_redistribute": "cross_zone_redistribute_insufficient",
|
||
"glue_compression": "glue_absorption_insufficient",
|
||
"font_step_compression": "font_step_insufficient",
|
||
"layout_adjust": "layout_adjust_insufficient",
|
||
"frame_internal_fit_candidate": "frame_internal_fit_candidate_insufficient",
|
||
}
|
||
|
||
|
||
def _attempt_salvage_chain(
|
||
*, run_dir: Path, out_path: Path, slide_title: str, slide_footer: Optional[str],
|
||
zones_data: list[dict], layout_preset: str, layout_css: dict,
|
||
cascade_inputs: dict, initial_failure_type: str, gap_px: int,
|
||
) -> dict:
|
||
"""IMP-12 u8 — deterministic Step 17 salvage cascade (cross_zone → glue → font_step).
|
||
Per stage: plan → apply CSS → rerender → run_overflow_check. PASS promotes final.html;
|
||
cascade-exit (layout_adjust/frame_reselect/none) or all-fail preserves (b) revert.
|
||
Honors IMP-09 dynamic_cols / fr_default gate.
|
||
"""
|
||
trace = {"salvage_attempted": False, "salvage_passed": False, "salvage_steps": []}
|
||
if layout_css.get("dynamic_cols", False) or not layout_css.get("dynamic_rows", False):
|
||
trace["salvage_skipped_reason"] = "IMP-09 gate — dynamic_cols/no dynamic_rows; cascade no-op"
|
||
return trace
|
||
trace["salvage_attempted"] = True
|
||
failure_type = initial_failure_type
|
||
ci = cascade_inputs
|
||
for _ in range(len(_SALVAGE_FAIL_BY_ACTION)):
|
||
routing = route_retry_failure(failure_type) or {}
|
||
next_action = routing.get("next_proposed_action")
|
||
if next_action not in _SALVAGE_FAIL_BY_ACTION:
|
||
trace["salvage_terminal_action"] = next_action
|
||
trace["salvage_terminal_rationale"] = routing.get("rationale")
|
||
return trace
|
||
# IMP-88 u6 — layout_adjust takes a distinct render path (fresh
|
||
# render_slide with the new preset + remapped zones_data + new
|
||
# layout_css), so it is dispatched BEFORE the shared CSS-overlay
|
||
# planner cluster below. No common margin / slide-body shrink
|
||
# ([[feedback_phase_z_spacing_direction]]) — topology swap only.
|
||
if next_action == "layout_adjust":
|
||
plan = plan_layout_adjust(
|
||
current_layout_preset=layout_preset, zones_data=zones_data)
|
||
new_layout_css = (
|
||
apply_layout_adjust_layout_css(plan, gap_px=gap_px)
|
||
if (plan and plan.get("feasible")) else None
|
||
)
|
||
candidate_path = run_dir / f"salvage_{next_action}_candidate.html"
|
||
candidate_html, candidate_overflow, passed = None, None, False
|
||
if new_layout_css:
|
||
candidate_html = render_slide(
|
||
slide_title, slide_footer,
|
||
plan["new_zones_data"], plan["new_layout_preset"],
|
||
new_layout_css, gap_px=gap_px,
|
||
)
|
||
candidate_path.write_text(candidate_html, encoding="utf-8")
|
||
candidate_overflow = run_overflow_check(candidate_path)
|
||
passed = bool(candidate_overflow.get("passed", False))
|
||
step = {
|
||
"action": next_action, "plan": plan, "passed": passed,
|
||
"new_layout_preset": (
|
||
plan.get("new_layout_preset") if isinstance(plan, dict) else None
|
||
),
|
||
"candidate_path": (
|
||
str(candidate_path.relative_to(PROJECT_ROOT))
|
||
if new_layout_css else None
|
||
),
|
||
}
|
||
if passed:
|
||
out_path.write_text(candidate_html, encoding="utf-8")
|
||
step["post_salvage_overflow"] = candidate_overflow
|
||
trace["salvage_steps"].append(step)
|
||
trace["salvage_passed"] = True
|
||
return trace
|
||
step["failure_reason"] = (
|
||
(plan.get("failure_reason") if isinstance(plan, dict) else None)
|
||
or (candidate_overflow.get("fail_reasons") if candidate_overflow else None)
|
||
or "infeasible or render emitted no candidate")
|
||
trace["salvage_steps"].append(step)
|
||
failure_type = _SALVAGE_FAIL_BY_ACTION[next_action]
|
||
continue
|
||
if next_action == "cross_zone_redistribute":
|
||
if ci.get("fit_analysis") is None:
|
||
plan = {"action": "cross_zone_redistribute", "feasible": False,
|
||
"failure_reason": "cascade_inputs.fit_analysis missing — cross_zone_redistribute requires fit_analysis."}
|
||
else:
|
||
plan = plan_cross_zone_redistribute(
|
||
fit_analysis=ci["fit_analysis"], containers=ci.get("containers") or {},
|
||
min_margin_px=ci.get("min_margin_px"))
|
||
apply_fn = apply_cross_zone_redistribute_css
|
||
elif next_action == "glue_compression":
|
||
plan = plan_glue_compression(
|
||
excess_px=float(ci.get("excess_px") or 0.0),
|
||
block_count=int(ci.get("block_count") or 0),
|
||
zone_position=str(ci.get("zone_position") or ""))
|
||
apply_fn = apply_glue_compression_css
|
||
elif next_action == "frame_internal_fit_candidate":
|
||
# IMP-88 u6 — per-zone frame-scoped envelope variant. Resolves
|
||
# frame_template_id from zones_data via cascade_inputs.zone_position
|
||
# so the planner stays within the frame contract envelope
|
||
# (no shared margin shrink per [[feedback_phase_z_spacing_direction]]).
|
||
_target_pos = str(ci.get("zone_position") or "")
|
||
_target_zone = next(
|
||
(z for z in zones_data if z.get("position") == _target_pos), {},
|
||
) or {}
|
||
_frame_tid = str(_target_zone.get("template_id") or "")
|
||
plan = plan_frame_internal_fit_candidate(
|
||
frame_template_id=_frame_tid,
|
||
overflow_zone={"excess_y": float(ci.get("excess_px") or 0.0)},
|
||
)
|
||
apply_fn = apply_frame_internal_fit_candidate_css
|
||
else:
|
||
plan = plan_font_step_compression(
|
||
current_font_px=float(ci.get("current_font_px") or 0.0),
|
||
excess_after_glue_px=float(ci.get("excess_after_glue_px") or ci.get("excess_px") or 0.0),
|
||
available_lines=int(ci.get("available_lines") or 0),
|
||
chars_per_line=int(ci.get("chars_per_line") or 0),
|
||
zone_position=str(ci.get("zone_position") or ""))
|
||
apply_fn = apply_font_step_compression_css
|
||
css_override = apply_fn(plan) if (plan and plan.get("feasible")) else ""
|
||
candidate_path = run_dir / f"salvage_{next_action}_candidate.html"
|
||
candidate_html, candidate_overflow, passed = None, None, False
|
||
if css_override:
|
||
base = render_slide(slide_title, slide_footer, zones_data, layout_preset, layout_css, gap_px=gap_px)
|
||
style = f"<style>\n{css_override}\n</style>"
|
||
candidate_html = base.replace("</head>", f"{style}\n</head>", 1) if "</head>" in base else style + base
|
||
candidate_path.write_text(candidate_html, encoding="utf-8")
|
||
candidate_overflow = run_overflow_check(candidate_path)
|
||
passed = bool(candidate_overflow.get("passed", False))
|
||
step = {"action": next_action, "plan": plan, "passed": passed,
|
||
"css_override": css_override or None,
|
||
"candidate_path": str(candidate_path.relative_to(PROJECT_ROOT)) if css_override else None}
|
||
if passed:
|
||
out_path.write_text(candidate_html, encoding="utf-8")
|
||
step["post_salvage_overflow"] = candidate_overflow
|
||
trace["salvage_steps"].append(step)
|
||
trace["salvage_passed"] = True
|
||
return trace
|
||
step["failure_reason"] = (
|
||
(plan.get("failure_reason") if isinstance(plan, dict) else None)
|
||
or (candidate_overflow.get("fail_reasons") if candidate_overflow else None)
|
||
or "infeasible or no CSS emitted")
|
||
trace["salvage_steps"].append(step)
|
||
failure_type = _SALVAGE_FAIL_BY_ACTION[next_action]
|
||
return trace
|
||
|
||
|
||
# IMP-88 (#88) u7 — Step 17 image_fit single-pass entry executor.
|
||
# image_fit is NOT a salvage cascade stage (deliberately OUT of
|
||
# _SALVAGE_FAIL_BY_ACTION per u6 guard). Instead it is a Step 17 ENTRY
|
||
# single-pass: aggregate per-image plan/apply via plan_image_fit (u4) +
|
||
# apply_image_fit_css (u4), emit one CSS overlay, re-render once,
|
||
# run_overflow_check. PASS promotes final.html; FAIL records `image_fit`
|
||
# step with failure_reason so failure_router (u2)
|
||
# SALVAGE_FAILURE_TYPE_BY_ACTION classifies it as `image_fit_insufficient`
|
||
# and the cascade entry block routes onto layout_adjust.
|
||
# Honors [[feedback_phase_z_spacing_direction]] — frame-scoped img CSS only,
|
||
# no common margin / slide-body / zone gap shrink. AI isolation contract
|
||
# (PZ-1) — deterministic data-surface, no AI call.
|
||
def _attempt_step17_image_fit_single_pass(
|
||
*, run_dir: Path, out_path: Path, slide_title: str, slide_footer: Optional[str],
|
||
zones_data: list[dict], layout_preset: str, layout_css: dict,
|
||
image_events: list[dict], gap_px: int,
|
||
delta_tol: float = IMAGE_ASPECT_DELTA_TOL,
|
||
) -> dict:
|
||
"""IMP-88 u7 — Step 17 image_fit single-pass executor.
|
||
|
||
Returns a result dict with shape:
|
||
- triggered : bool — True iff any feasible image_fit plan emitted CSS.
|
||
- passed : bool — True iff post-rerender overflow check passed.
|
||
- step : dict|None — salvage_steps[] entry shape (action="image_fit",
|
||
image_fit_event_plans, candidate_path, post_salvage_overflow
|
||
on pass / failure_reason on fail).
|
||
- candidate_html : str|None — rendered HTML when triggered (None otherwise).
|
||
- candidate_overflow : dict|None — run_overflow_check result when triggered.
|
||
- event_plans : list[dict] — every plan_image_fit result (feasible + no-op),
|
||
surfaced for telemetry even when none emit CSS.
|
||
Side effect on PASS only: writes candidate_html to out_path.
|
||
"""
|
||
event_plans = []
|
||
css_chunks = []
|
||
for ev in (image_events or []):
|
||
plan = plan_image_fit(image_event=ev, delta_tol=delta_tol)
|
||
event_plans.append(plan)
|
||
if plan.get("feasible"):
|
||
css = apply_image_fit_css(plan)
|
||
if css:
|
||
css_chunks.append(css)
|
||
if not css_chunks:
|
||
return {
|
||
"triggered": False, "passed": False, "step": None,
|
||
"candidate_html": None, "candidate_overflow": None,
|
||
"event_plans": event_plans,
|
||
}
|
||
candidate_path = run_dir / "salvage_image_fit_candidate.html"
|
||
base = render_slide(
|
||
slide_title, slide_footer, zones_data, layout_preset, layout_css,
|
||
gap_px=gap_px,
|
||
)
|
||
style = "<style>\n" + "\n".join(css_chunks) + "\n</style>"
|
||
candidate_html = (
|
||
base.replace("</head>", f"{style}\n</head>", 1)
|
||
if "</head>" in base else style + base
|
||
)
|
||
candidate_path.write_text(candidate_html, encoding="utf-8")
|
||
candidate_overflow = run_overflow_check(candidate_path)
|
||
passed = bool(candidate_overflow.get("passed", False))
|
||
step = {
|
||
"action": "image_fit", "passed": passed,
|
||
"image_fit_event_plans": event_plans,
|
||
"candidate_path": (
|
||
str(candidate_path.relative_to(PROJECT_ROOT))
|
||
if candidate_path.is_absolute() else str(candidate_path)
|
||
),
|
||
}
|
||
if passed:
|
||
step["post_salvage_overflow"] = candidate_overflow
|
||
out_path.write_text(candidate_html, encoding="utf-8")
|
||
else:
|
||
step["failure_reason"] = (
|
||
candidate_overflow.get("fail_reasons")
|
||
or "image_fit single-pass: overflow persists"
|
||
)
|
||
return {
|
||
"triggered": True, "passed": passed, "step": step,
|
||
"candidate_html": candidate_html, "candidate_overflow": candidate_overflow,
|
||
"event_plans": event_plans,
|
||
}
|
||
|
||
|
||
def _remeasure_after_frame_reselect(
|
||
*, candidate_path: Path, plan: Optional[dict] = None,
|
||
) -> dict:
|
||
"""IMP-35 (#64) u1 — post-frame remeasure helper for the cascade terminal.
|
||
|
||
Contract (q4 / Stage 2): frame_reselect_insufficient is detected by an
|
||
*explicit overflow re-measure* after a V4 top-k alternate frame swap —
|
||
NOT a failure-flag carryover. This helper runs run_overflow_check on the
|
||
re-rendered candidate HTML and shapes the salvage_steps entry that
|
||
classify_retry_failure / SALVAGE_FAILURE_TYPE_BY_ACTION read.
|
||
|
||
Future frame_reselect orchestrator (post-IMP-35) writes the candidate
|
||
HTML and calls this helper to append the entry to retry_trace.salvage_steps.
|
||
On passed=True the orchestrator promotes the candidate to final.html; on
|
||
passed=False the classifier emits frame_reselect_insufficient → u2 routes
|
||
onto details_popup_escalation (Step 17 POPUP gate / u5).
|
||
"""
|
||
candidate_overflow = run_overflow_check(candidate_path)
|
||
passed = bool(candidate_overflow.get("passed", False))
|
||
return {
|
||
"action": "frame_reselect",
|
||
"plan": plan,
|
||
"passed": passed,
|
||
"candidate_path": (
|
||
str(candidate_path.relative_to(PROJECT_ROOT))
|
||
if candidate_path.is_absolute() else str(candidate_path)
|
||
),
|
||
"post_salvage_overflow": candidate_overflow,
|
||
"failure_reason": (
|
||
None if passed
|
||
else (candidate_overflow.get("fail_reasons") or "post-frame remeasure: overflow persists")
|
||
),
|
||
}
|
||
|
||
|
||
# ─── IMP-42 u3 (#71) — unconditional Step 12 / Step 13 DIAG log helper ──
|
||
|
||
|
||
def _emit_diag_zones_shape(stage_label: str, zones_data: list[dict], **extra_fields) -> None:
|
||
"""IMP-42 u3 (#71) — emit shape-only zone metadata to stdout.
|
||
|
||
Used at the Step 12 slot_payload emit site and the Step 13 render_slide
|
||
entry site to make the silent 3-hop handoff visible in the terminal.
|
||
Shape-only — never logs raw slot_payload values — so the diag is
|
||
sample-agnostic (RULE 0) and never leaks user content.
|
||
|
||
No env gate: silence is the bug this IMP fights, so the log fires
|
||
unconditionally on every slide loop.
|
||
"""
|
||
payload = {
|
||
"zones_count": len(zones_data),
|
||
"zones": [
|
||
{
|
||
"i": i,
|
||
"position": z.get("position"),
|
||
"template_id": z.get("template_id"),
|
||
"slot_keys": (
|
||
sorted(z["slot_payload"].keys())
|
||
if isinstance(z.get("slot_payload"), dict) else None
|
||
),
|
||
}
|
||
for i, z in enumerate(zones_data)
|
||
],
|
||
}
|
||
payload.update(extra_fields)
|
||
print(
|
||
f"[DIAG] phase_z2 {stage_label} "
|
||
+ json.dumps(payload, ensure_ascii=False, sort_keys=True),
|
||
flush=True,
|
||
)
|
||
|
||
|
||
# ─── IMP-42 u2 (#71) — post-render HTML invalid path char detector ──
|
||
|
||
_INVALID_PATH_ATTR_RE = re.compile(
|
||
r"""(src|href)\s*=\s*["']([^"']*)["']|url\(\s*['"]?([^)'"\s]+)['"]?\s*\)""",
|
||
re.IGNORECASE,
|
||
)
|
||
_INVALID_PATH_CHARS = ("\\", "&", "'")
|
||
|
||
|
||
def _scan_rendered_html_for_invalid_path_chars(html: str, context: str) -> None:
|
||
"""IMP-42 u2 (#71) — fail loud when rendered HTML asset references
|
||
contain invalid path characters in src / href / url(...) values.
|
||
|
||
Catches three silent fail vectors at the rendered HTML boundary that
|
||
surface downstream as 404 / asset-load failures far from upstream cause:
|
||
- Windows backslash from str(Path) (e.g. ``assets\\img.png``).
|
||
- Autoescape entity ``&`` (raw ``&`` in raw path source string).
|
||
- Autoescape entity ``'`` (raw ``'`` in raw path source string).
|
||
|
||
Raises ValueError on first hit, citing context, attr type, value snippet.
|
||
Scope-locked to rendered HTML asset attrs only; never inspects CSS grid
|
||
metadata or static template strings.
|
||
"""
|
||
for match in _INVALID_PATH_ATTR_RE.finditer(html):
|
||
if match.group(3) is not None:
|
||
attr_label = "url(...)"
|
||
value = match.group(3)
|
||
else:
|
||
attr_label = match.group(1).lower()
|
||
value = match.group(2)
|
||
for bad in _INVALID_PATH_CHARS:
|
||
if bad in value:
|
||
snippet = value if len(value) <= 120 else value[:117] + "..."
|
||
raise ValueError(
|
||
f"render_slide: {context} — invalid path char {bad!r} in "
|
||
f"{attr_label} value (value={snippet}). "
|
||
"Likely upstream: Windows backslash from str(Path) or "
|
||
"autoescape of '&' / \"'\" in raw path string."
|
||
)
|
||
|
||
|
||
def render_slide(slide_title: str, slide_footer: Optional[str],
|
||
zones_data: list[dict], layout_preset: str,
|
||
layout_css: dict, gap_px: int = GRID_GAP,
|
||
*, embedded_mode: str = "auto") -> str:
|
||
"""Single slide HTML — slide_base.html + 8-preset layout vocabulary.
|
||
|
||
layout_css = build_layout_css() 결과 — areas/cols/rows 문자열 + 동적 heights flag.
|
||
Template 은 layout_css.{areas,cols,rows} 를 grid CSS 에 직접 주입.
|
||
|
||
embedded_mode (IMP-14): "auto" | "embedded" | "standalone". Controls
|
||
slide_base.html body CSS contract. Default "auto" preserves backward-compat
|
||
with run_overflow_check standalone path and lets iframe consumers signal via
|
||
?embedded=1 or window.self!==window.top.
|
||
"""
|
||
if embedded_mode not in ("auto", "embedded", "standalone"):
|
||
raise ValueError(
|
||
f"render_slide: invalid embedded_mode={embedded_mode!r}; "
|
||
"expected one of 'auto', 'embedded', 'standalone'"
|
||
)
|
||
# IMP-42 u3 (#71) — unconditional Step 13 entry DIAG log.
|
||
_emit_diag_zones_shape(
|
||
"Step 13 render_slide entry",
|
||
zones_data,
|
||
layout_preset=layout_preset,
|
||
embedded_mode=embedded_mode,
|
||
)
|
||
env = Environment(
|
||
loader=FileSystemLoader(str(TEMPLATE_DIR)),
|
||
autoescape=select_autoescape(["html"]),
|
||
)
|
||
for zone_index, zone in enumerate(zones_data):
|
||
# Stage 4 Part 2 (Codex #10 Catch N) — empty zone produced by section
|
||
# assignment override has no partial template; render an empty string so
|
||
# the slide_base zones loop preserves grid identity without TemplateNotFound.
|
||
if zone.get("template_id") == "__empty__":
|
||
zone["partial_html"] = ""
|
||
continue
|
||
# IMP-42 u1 (#71) — fail-loud precondition for Step 13 partial render.
|
||
# Catches the silent fail vector where Step 12 emits a zone dict missing
|
||
# `template_id` / `slot_payload`. Error message cites zone_index +
|
||
# missing key so the diag is actionable (vs Jinja TemplateNotFound /
|
||
# KeyError surfacing far from the upstream emit site).
|
||
template_id = zone.get("template_id")
|
||
if not isinstance(template_id, str) or not template_id:
|
||
raise TypeError(
|
||
f"render_slide: zones_data[{zone_index}] precondition failed — "
|
||
f"`template_id` must be a non-empty str, got {type(template_id).__name__}={template_id!r}"
|
||
)
|
||
if "slot_payload" not in zone:
|
||
raise TypeError(
|
||
f"render_slide: zones_data[{zone_index}] precondition failed — "
|
||
f"`slot_payload` key missing (template_id={template_id!r})"
|
||
)
|
||
slot_payload = zone["slot_payload"]
|
||
if not isinstance(slot_payload, dict):
|
||
raise TypeError(
|
||
f"render_slide: zones_data[{zone_index}] precondition failed — "
|
||
f"`slot_payload` must be a dict, got {type(slot_payload).__name__} "
|
||
f"(template_id={template_id!r})"
|
||
)
|
||
partial = env.get_template(f"families/{template_id}.html")
|
||
rendered_partial = partial.render(slot_payload=slot_payload)
|
||
# IMP-42 u2 (#71) — fail loud on invalid path chars in rendered HTML
|
||
# asset refs (src / href / url(...)). Catches Windows backslash and
|
||
# autoescape entity vectors before they reach the browser as 404.
|
||
_scan_rendered_html_for_invalid_path_chars(
|
||
rendered_partial,
|
||
f"zones_data[{zone_index}] template_id={template_id!r}",
|
||
)
|
||
zone["partial_html"] = rendered_partial
|
||
|
||
base = env.get_template("slide_base.html")
|
||
rendered_base = base.render(
|
||
slide_title=slide_title,
|
||
slide_footer=slide_footer,
|
||
zones=zones_data,
|
||
layout_preset=layout_preset,
|
||
layout_css=layout_css,
|
||
gap_px=gap_px,
|
||
token_css=_read_token_css(),
|
||
embedded_mode=embedded_mode,
|
||
)
|
||
# IMP-42 u2 (#71) — also scan the assembled slide_base output to cover
|
||
# asset refs introduced by the slide-base shell itself (title / footer /
|
||
# popup slots) outside the per-zone partial scope.
|
||
_scan_rendered_html_for_invalid_path_chars(rendered_base, "slide_base")
|
||
return rendered_base
|
||
|
||
|
||
# ─── Selenium check (single slide + per-zone) ──────────────────
|
||
|
||
def run_overflow_check(html_path: Path) -> dict:
|
||
"""Single slide + per-zone overflow + clipping check."""
|
||
from selenium import webdriver
|
||
from selenium.webdriver.chrome.options import Options
|
||
from selenium.webdriver.chrome.service import Service
|
||
|
||
options = Options()
|
||
options.add_argument("--headless=new")
|
||
options.add_argument("--no-sandbox")
|
||
options.add_argument("--disable-dev-shm-usage")
|
||
options.add_argument("--window-size=1400,900")
|
||
|
||
chromedriver_candidates = [
|
||
PROJECT_ROOT / "chromedriver",
|
||
PROJECT_ROOT / "chromedriver.exe",
|
||
]
|
||
driver = None
|
||
last_err = None
|
||
for path in chromedriver_candidates:
|
||
if path.is_file():
|
||
try:
|
||
driver = webdriver.Chrome(service=Service(str(path)), options=options)
|
||
break
|
||
except Exception as e:
|
||
last_err = e
|
||
if driver is None:
|
||
try:
|
||
driver = webdriver.Chrome(options=options)
|
||
except Exception as e:
|
||
return {"passed": False, "error": f"selenium init failed: {last_err or e}"}
|
||
|
||
try:
|
||
driver.get(html_path.resolve().as_uri())
|
||
driver.set_window_size(1400, 900)
|
||
driver.implicitly_wait(1)
|
||
result = driver.execute_script(r"""
|
||
const measure = (el) => ({
|
||
clientWidth: el.clientWidth,
|
||
clientHeight: el.clientHeight,
|
||
scrollWidth: el.scrollWidth,
|
||
scrollHeight: el.scrollHeight,
|
||
excess_x: Math.max(0, el.scrollWidth - el.clientWidth),
|
||
excess_y: Math.max(0, el.scrollHeight - el.clientHeight),
|
||
overflowed: (el.scrollWidth > el.clientWidth + 5) ||
|
||
(el.scrollHeight > el.clientHeight + 5),
|
||
});
|
||
const slide = document.querySelector('.slide');
|
||
if (!slide) return { error: '.slide not found' };
|
||
|
||
const slideM = measure(slide);
|
||
slideM.size_correct = slide.clientWidth === 1280 && slide.clientHeight === 720;
|
||
// A-6 (IMP-01 #1) — slide-relative bbox base
|
||
const slideRect = slide.getBoundingClientRect();
|
||
|
||
const body = document.querySelector('.slide-body');
|
||
const bodyM = body ? measure(body) : null;
|
||
|
||
const zones = [];
|
||
const zone_geometries_px = [];
|
||
|
||
// IMP-15 실행-2 (issue #46) — element-identity dedup map for table_events.
|
||
// Map<Element, integer> keyed by DOM node reference (NOT class string) so that
|
||
// two wrappers sharing identical className resolve to distinct map entries.
|
||
// Populated alongside the existing per-zone clipped_inner scan below.
|
||
const clippedWrapperMap = new Map();
|
||
let clippedIdxCounter = 0;
|
||
|
||
slide.querySelectorAll('.zone').forEach((z) => {
|
||
const pos = z.getAttribute('data-zone-position') || 'unknown';
|
||
const tid = z.getAttribute('data-template-id') || '?';
|
||
const m = measure(z);
|
||
m.position = pos;
|
||
m.template_id = tid;
|
||
|
||
// A-6 (IMP-01 #1) — zone bbox in slide-relative px (additive trace, no layout side effect)
|
||
const zoneRect = z.getBoundingClientRect();
|
||
zone_geometries_px.push({
|
||
position: pos,
|
||
template_id: tid,
|
||
x: Math.round(zoneRect.left - slideRect.left),
|
||
y: Math.round(zoneRect.top - slideRect.top),
|
||
w: Math.round(zoneRect.width),
|
||
h: Math.round(zoneRect.height),
|
||
});
|
||
|
||
// 내부 clipping 검사 — frame-family root/cell 단위.
|
||
// tolerance / threshold 그대로. inner_content_signals 만 추가 보강 (detection 데이터 늘림).
|
||
const clipped = [];
|
||
z.querySelectorAll('[class*="f13b"], [class*="f29b"], [class*="f16b"]').forEach((el) => {
|
||
const dx = el.scrollWidth - el.clientWidth;
|
||
const dy = el.scrollHeight - el.clientHeight;
|
||
if (dx > 5 || dy > 5) {
|
||
// inner content signals — clipped cell 안에 *어떤 종류의 콘텐츠가 들어있는지* 보고.
|
||
// classifier 가 frame_internal_cell 만 봐서는 부족하니 inner 까지 본다.
|
||
const inner_signals = [];
|
||
if (el.querySelector('.transform-block, .transform-row, .transform-rows')) {
|
||
inner_signals.push('structural_unit');
|
||
}
|
||
if (el.querySelector('table')) {
|
||
inner_signals.push('tabular');
|
||
}
|
||
if (el.querySelector('.text-line')) {
|
||
inner_signals.push('text_flow');
|
||
}
|
||
clipped.push({
|
||
class_name: el.className,
|
||
inner_content_signals: inner_signals,
|
||
excess_x: Math.max(0, dx),
|
||
excess_y: Math.max(0, dy),
|
||
clientWidth: el.clientWidth,
|
||
clientHeight: el.clientHeight,
|
||
scrollWidth: el.scrollWidth,
|
||
scrollHeight: el.scrollHeight,
|
||
});
|
||
// IMP-15 실행-2 (issue #46) — element-identity registration.
|
||
// Key by DOM node `el`, NOT className: two wrappers with identical
|
||
// class string still hash to distinct Map entries.
|
||
if (!clippedWrapperMap.has(el)) {
|
||
clippedWrapperMap.set(el, clippedIdxCounter);
|
||
clippedIdxCounter++;
|
||
}
|
||
}
|
||
});
|
||
m.clipped_inner = clipped;
|
||
zones.push(m);
|
||
});
|
||
|
||
// B5 v0 — frame_slot_metrics (per-cell measurement of [data-frame-slot-id])
|
||
// 현재 F29 partial 만 marker 보유 (process_column / product_column × 3 cells = 6 entries 기대).
|
||
// 다른 frame (F13 / F16) 은 marker 미적용 → entry 0 — 정상.
|
||
const frame_slot_metrics = [];
|
||
slide.querySelectorAll('[data-frame-slot-id]').forEach((cell) => {
|
||
const slotId = cell.getAttribute('data-frame-slot-id');
|
||
const m2 = measure(cell);
|
||
const parentZone = cell.closest('.zone');
|
||
const zonePos = parentZone
|
||
? (parentZone.getAttribute('data-zone-position') || 'unknown')
|
||
: 'unknown';
|
||
const zoneTid = parentZone
|
||
? (parentZone.getAttribute('data-template-id') || '?')
|
||
: '?';
|
||
frame_slot_metrics.push({
|
||
zone_position: zonePos,
|
||
zone_template_id: zoneTid,
|
||
frame_slot_id: slotId,
|
||
class_name: cell.className,
|
||
clientWidth: m2.clientWidth,
|
||
clientHeight: m2.clientHeight,
|
||
scrollWidth: m2.scrollWidth,
|
||
scrollHeight: m2.scrollHeight,
|
||
excess_x: m2.excess_x,
|
||
excess_y: m2.excess_y,
|
||
overflowed: m2.overflowed,
|
||
});
|
||
});
|
||
|
||
// IMP-15 실행-1 (issue #45) — image_events[] for image_aspect_mismatch detection.
|
||
// 하나의 entry per <img> under .slide. natural vs rendered aspect 비교.
|
||
// zone_position : closest('.zone') data-zone-position. 없으면 literal "unknown".
|
||
const image_events = [];
|
||
slide.querySelectorAll('img').forEach((img) => {
|
||
const parentZone = img.closest('.zone');
|
||
const zonePos = parentZone
|
||
? (parentZone.getAttribute('data-zone-position') || 'unknown')
|
||
: 'unknown';
|
||
const zoneTid = parentZone
|
||
? (parentZone.getAttribute('data-template-id') || '?')
|
||
: '?';
|
||
const imgRect = img.getBoundingClientRect();
|
||
const rendered_w = imgRect.width;
|
||
const rendered_h = imgRect.height;
|
||
const natural_w = img.naturalWidth;
|
||
const natural_h = img.naturalHeight;
|
||
const natural_ratio = (natural_w > 0 && natural_h > 0)
|
||
? (natural_w / natural_h)
|
||
: null;
|
||
const rendered_ratio = (rendered_w > 0 && rendered_h > 0)
|
||
? (rendered_w / rendered_h)
|
||
: null;
|
||
const delta = (natural_ratio !== null && rendered_ratio !== null)
|
||
? (rendered_ratio - natural_ratio)
|
||
: null;
|
||
image_events.push({
|
||
src: img.getAttribute('src') || '',
|
||
zone_position: zonePos,
|
||
zone_template_id: zoneTid,
|
||
natural_w: natural_w,
|
||
natural_h: natural_h,
|
||
rendered_w: Math.round(rendered_w),
|
||
rendered_h: Math.round(rendered_h),
|
||
natural_ratio: natural_ratio,
|
||
rendered_ratio: rendered_ratio,
|
||
delta: delta,
|
||
bbox: {
|
||
x: Math.round(imgRect.left - slideRect.left),
|
||
y: Math.round(imgRect.top - slideRect.top),
|
||
w: Math.round(rendered_w),
|
||
h: Math.round(rendered_h),
|
||
},
|
||
});
|
||
});
|
||
|
||
// IMP-15 실행-2 (issue #46) — table_events[] for table_self_overflow detection.
|
||
// One entry per <table> under .slide. wrapper_clipped_index is the integer index
|
||
// (from clippedWrapperMap) of the nearest ancestor that is itself in the clipped
|
||
// wrapper set, or null. Element-identity walk (NOT className) so that two same-class
|
||
// wrappers (W1 clipped, W2 not) resolve independently for any contained <table>.
|
||
const table_events = [];
|
||
slide.querySelectorAll('table').forEach((tbl) => {
|
||
const parentZone = tbl.closest('.zone');
|
||
const zonePos = parentZone
|
||
? (parentZone.getAttribute('data-zone-position') || 'unknown')
|
||
: 'unknown';
|
||
const zoneTid = parentZone
|
||
? (parentZone.getAttribute('data-template-id') || '?')
|
||
: '?';
|
||
let wrapper_clipped_index = null;
|
||
let node = tbl.parentElement;
|
||
while (node && node !== slide) {
|
||
if (clippedWrapperMap.has(node)) {
|
||
wrapper_clipped_index = clippedWrapperMap.get(node);
|
||
break;
|
||
}
|
||
node = node.parentElement;
|
||
}
|
||
const tblRect = tbl.getBoundingClientRect();
|
||
const dx = tbl.scrollWidth - tbl.clientWidth;
|
||
const dy = tbl.scrollHeight - tbl.clientHeight;
|
||
table_events.push({
|
||
zone_position: zonePos,
|
||
zone_template_id: zoneTid,
|
||
clientWidth: tbl.clientWidth,
|
||
clientHeight: tbl.clientHeight,
|
||
scrollWidth: tbl.scrollWidth,
|
||
scrollHeight: tbl.scrollHeight,
|
||
excess_x: Math.max(0, dx),
|
||
excess_y: Math.max(0, dy),
|
||
wrapper_clipped_index: wrapper_clipped_index,
|
||
bbox: {
|
||
x: Math.round(tblRect.left - slideRect.left),
|
||
y: Math.round(tblRect.top - slideRect.top),
|
||
w: Math.round(tblRect.width),
|
||
h: Math.round(tblRect.height),
|
||
},
|
||
});
|
||
});
|
||
|
||
return { slide: slideM, slide_body: bodyM, zones, frame_slot_metrics, zone_geometries_px, image_events, table_events };
|
||
""")
|
||
|
||
screenshot_path = html_path.parent / "preview.png"
|
||
try:
|
||
driver.save_screenshot(str(screenshot_path))
|
||
result["screenshot"] = str(screenshot_path.relative_to(PROJECT_ROOT))
|
||
except Exception as e:
|
||
result["screenshot_error"] = str(e)
|
||
finally:
|
||
driver.quit()
|
||
|
||
if "error" in result:
|
||
return {"passed": False, **result}
|
||
|
||
fail_reasons = []
|
||
if not result["slide"]["size_correct"]:
|
||
fail_reasons.append(
|
||
f"slide size != 1280x720 (got {result['slide']['clientWidth']}x{result['slide']['clientHeight']})"
|
||
)
|
||
if result["slide"]["overflowed"]:
|
||
fail_reasons.append(
|
||
f"slide overflowed by {result['slide']['excess_y']}px (vert) / {result['slide']['excess_x']}px (horiz)"
|
||
)
|
||
if result.get("slide_body") and result["slide_body"]["overflowed"]:
|
||
fail_reasons.append(
|
||
f"slide-body overflowed by {result['slide_body']['excess_y']}px (vert)"
|
||
)
|
||
for z in result["zones"]:
|
||
if z["overflowed"]:
|
||
fail_reasons.append(
|
||
f"zone--{z['position']} ({z['template_id']}) overflowed by {z['excess_y']}px (vert) / {z['excess_x']}px (horiz)"
|
||
)
|
||
for c in z.get("clipped_inner", []):
|
||
fail_reasons.append(
|
||
f"zone--{z['position']}: inner clipped .{c['class_name']} — "
|
||
f"excess {c['excess_y']}px vert / {c['excess_x']}px horiz "
|
||
f"(content {c['scrollHeight']} vs container {c['clientHeight']})"
|
||
)
|
||
|
||
# IMP-15 실행-1 (issue #45) — image_aspect_mismatch aggregation.
|
||
# |natural_ratio - rendered_ratio| > IMAGE_ASPECT_DELTA_TOL ⇒ fail_reason append.
|
||
# Entries with null ratio (image not loaded / natural dims = 0) are skipped (no false positive).
|
||
for ev in result.get("image_events", []):
|
||
delta = ev.get("delta")
|
||
if delta is None:
|
||
continue
|
||
if abs(delta) > IMAGE_ASPECT_DELTA_TOL:
|
||
n_ratio = ev.get("natural_ratio")
|
||
r_ratio = ev.get("rendered_ratio")
|
||
src = ev.get("src", "")
|
||
pos = ev.get("zone_position", "unknown")
|
||
tid = ev.get("zone_template_id", "?")
|
||
fail_reasons.append(
|
||
f"image aspect mismatch in zone--{pos}: "
|
||
f"natural={n_ratio:.3f} rendered={r_ratio:.3f} delta={delta:+.3f} "
|
||
f"(template={tid}, tol={IMAGE_ASPECT_DELTA_TOL}, src={src})"
|
||
)
|
||
|
||
# IMP-15 실행-2 (issue #46) — table_self_overflow aggregation.
|
||
# Emit fail_reason only when (excess_x>TOL OR excess_y>TOL) AND wrapper_clipped_index is None.
|
||
# The clipped-wrapper case is already accounted for by the clipped_inner fail_reason above;
|
||
# element-identity dedup (clippedWrapperMap keyed by DOM node ref, NOT className) prevents
|
||
# double-counting and—critically—prevents two same-class wrappers from masking each other.
|
||
for ev in result.get("table_events", []):
|
||
if ev.get("wrapper_clipped_index") is not None:
|
||
continue
|
||
excess_x = ev.get("excess_x", 0) or 0
|
||
excess_y = ev.get("excess_y", 0) or 0
|
||
if excess_x > TABLE_SCROLL_TOL_PX or excess_y > TABLE_SCROLL_TOL_PX:
|
||
pos = ev.get("zone_position", "unknown")
|
||
tid = ev.get("zone_template_id", "?")
|
||
fail_reasons.append(
|
||
f"table self-overflow in zone--{pos}: "
|
||
f"excess {excess_y}px vert / {excess_x}px horiz "
|
||
f"(content {ev.get('scrollWidth')}x{ev.get('scrollHeight')} vs "
|
||
f"container {ev.get('clientWidth')}x{ev.get('clientHeight')}, "
|
||
f"template={tid}, tol={TABLE_SCROLL_TOL_PX})"
|
||
)
|
||
|
||
result["passed"] = len(fail_reasons) == 0
|
||
result["fail_reasons"] = fail_reasons
|
||
return result
|
||
|
||
|
||
def write_overflow_error(run_dir: Path, overflow: dict) -> Path:
|
||
error_data = {
|
||
"stage": "visual_runtime_check",
|
||
"reason": "Visual runtime contract 위반 — slide / slide-body / zone overflow / clipping.",
|
||
"fail_reasons": overflow.get("fail_reasons", []),
|
||
"details": overflow,
|
||
}
|
||
err_path = run_dir / "error.json"
|
||
err_path.write_text(json.dumps(error_data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
return err_path
|
||
|
||
|
||
# ─── Debug.json (single slide + zones[]) ───────────────────────
|
||
|
||
|
||
def _is_empty_shell_unit(u: CompositionUnit) -> bool:
|
||
"""IMP-87 u1 — true when a CompositionUnit is the IMP-30 u4 empty-shell
|
||
placeholder (frame_template_id="__empty__" / label="empty_shell" /
|
||
merge_type="empty_shell").
|
||
|
||
Used by compute_slide_status to redefine `full_mdx_coverage` over
|
||
rendered content units only: an empty-shell unit attaches the aligned
|
||
section_ids to a ``__empty__`` frame for layout purposes, but the slide
|
||
surface carries no MDX content for those sections. Counting it as
|
||
coverage would violate feedback_artifact_status_naming (overall /
|
||
coverage must reflect actual content state, not pipeline completion).
|
||
|
||
The three markers are checked as independent OR-branches so a
|
||
CompositionUnit synthesised by any of the IMP-30 u4 entry points
|
||
(phase A / phase B / e2e) is classified consistently.
|
||
"""
|
||
if getattr(u, "frame_template_id", None) == "__empty__":
|
||
return True
|
||
if getattr(u, "label", None) == "empty_shell":
|
||
return True
|
||
if getattr(u, "merge_type", None) == "empty_shell":
|
||
return True
|
||
return False
|
||
|
||
|
||
def _final_status_html_class(overall: str) -> str:
|
||
"""IMP-87 u3 — map ``overall`` enum string → CSS class for the step20
|
||
final_status.html dashboard.
|
||
|
||
EMPTY_SHELL_NO_CONTENT MUST resolve to ``"fail"`` (red) so the Case B
|
||
honesty defect (Stage 1 mdx05) surfaces in the same colour band as visual
|
||
failures and regressions, not the legacy ``"partial"`` amber band. The
|
||
explicit check runs BEFORE the legacy substring-based mapping because
|
||
the literal ``"EMPTY_SHELL_NO_CONTENT"`` contains neither ``"PASS"`` nor
|
||
``"FAIL"`` / ``"REGRESSION"`` and would otherwise default to
|
||
``"partial"`` (Stage 2 axis A5 lock).
|
||
|
||
All other enums preserve pre-IMP-87 substring semantics so the legacy
|
||
PASS / RENDERED_WITH_VISUAL_REGRESSION / PARTIAL_COVERAGE / etc. paths
|
||
keep the colour they had before u3.
|
||
"""
|
||
if overall == "EMPTY_SHELL_NO_CONTENT":
|
||
return "fail"
|
||
if "PASS" in overall:
|
||
return "pass"
|
||
if "FAIL" in overall or "REGRESSION" in overall:
|
||
return "fail"
|
||
return "partial"
|
||
|
||
|
||
def _is_blocked_overall(overall: str) -> bool:
|
||
"""IMP-87 u3 — true iff ``overall`` warrants a BLOCKED CLI exit
|
||
(returncode 1) independent of the visual_check / full_mdx_coverage axes.
|
||
|
||
Currently the single blocked enum is EMPTY_SHELL_NO_CONTENT (Stage 1
|
||
mdx05 Case B). The CLI consults this helper BEFORE the legacy
|
||
visual_fail / partial_coverage branches so a content-empty placeholder
|
||
slide that happens to pass Selenium overflow checks (no content → no
|
||
overflow) cannot silently return a content-empty artifact without an
|
||
exit signal (Stage 2 axis A4 lock).
|
||
"""
|
||
return overall == "EMPTY_SHELL_NO_CONTENT"
|
||
|
||
|
||
def compute_slide_status(sections: list[MdxSection],
|
||
units: list[CompositionUnit],
|
||
comp_debug: dict,
|
||
overflow: dict,
|
||
adapter_needed_units: Optional[list[dict]] = None,
|
||
debug_zones: Optional[list[dict]] = None) -> dict:
|
||
"""Slide 산출물의 정확한 상태 계산 — 자동 파이프라인 결과 보고.
|
||
|
||
축 :
|
||
- rendered : final.html 이 디스크에 쓰였는가
|
||
- visual_check_passed : Selenium per-zone overflow / clipping 통과 여부
|
||
- full_mdx_coverage : aligned 된 모든 section_id 가 어떤 selected unit 에 의해 covered
|
||
- adapter_needed_count : mapper FitError 로 자동 렌더 못 한 unit 수 (별 review 개념 X — 자동 실패 보고)
|
||
- content_truncated_count : builder 가 truncate 한 zone 수 (informational)
|
||
- provisional_first_render_count : IMP-30 first-render invariant 로 합성된 unit 수
|
||
(u1 V4Match synthesis / u3 last-resort fill /
|
||
u4 empty-shell — needs user/AI adaptation 신호)
|
||
|
||
overall enum :
|
||
EMPTY_SHELL_NO_CONTENT — IMP-87 u2 : every selected unit is an
|
||
IMP-30 u4 empty-shell placeholder
|
||
(no content-rendered units). Takes
|
||
precedence over the 4-way ladder below
|
||
because such a slide can technically
|
||
pass Selenium overflow checks but
|
||
carries no MDX content.
|
||
PASS — visual OK + full coverage + adapter_needed=0
|
||
RENDERED_WITH_VISUAL_REGRESSION — full coverage 이지만 visual fail
|
||
PARTIAL_COVERAGE — 일부 section 필터됨, 렌더된 부분만 visual OK
|
||
PARTIAL_COVERAGE_WITH_VISUAL_REGRESSION — 둘 다
|
||
(adapter_needed > 0 시 status note 추가, overall 은 위 enum 사용)
|
||
(IMP-30 u6 : provisional_first_render_count 도 qualifier 일 뿐, overall enum 변경 X.
|
||
Stage 1 Q3 + Codex #10 D4 lock.)
|
||
"""
|
||
aligned_ids = [s.section_id for s in sections]
|
||
# IMP-87 u1 — split coverage into legacy display vs honesty axis.
|
||
# ``covered`` (legacy, preserved for downstream display / IMP-05 / IMP-06
|
||
# readers) still tracks every section attached to any selected unit.
|
||
# ``content_covered`` is the new honesty axis: only non-empty-shell units
|
||
# count as "rendered with content". Sections attached solely to an
|
||
# empty-shell placeholder (IMP-30 u4 frame_template_id="__empty__")
|
||
# are routed into ``filtered_section_ids`` so an EMPTY-SHELL-only slide
|
||
# cannot inherit full_mdx_coverage=True (Case B honesty defect lock,
|
||
# Stage 1 anchor c53722ad).
|
||
covered: set = set()
|
||
content_covered: set = set()
|
||
for u in units:
|
||
covered.update(u.source_section_ids)
|
||
if not _is_empty_shell_unit(u):
|
||
content_covered.update(u.source_section_ids)
|
||
filtered_ids = sorted(set(aligned_ids) - content_covered)
|
||
full_coverage = len(filtered_ids) == 0
|
||
visual_passed = bool(overflow.get("passed", False))
|
||
|
||
# IMP-87 u2 — Additive empty/content accounting (used by the overall enum
|
||
# precedence block below and surfaced on the return dict for downstream
|
||
# introspection). ``content_rendered_section_ids`` mirrors the new honesty
|
||
# axis (content_covered) at the surface level so consumers can ask "how
|
||
# much real MDX content actually rendered" without re-running the helper.
|
||
# ``empty_shell_section_ids`` exposes the placeholder counterpart for the
|
||
# same reason. Both pairs are purely additive — no existing field is
|
||
# removed or repurposed (legacy ``covered_section_ids`` display semantics
|
||
# locked by u1).
|
||
empty_shell_units_list = [u for u in units if _is_empty_shell_unit(u)]
|
||
content_units_list = [u for u in units if not _is_empty_shell_unit(u)]
|
||
empty_shell_section_ids = sorted({
|
||
sid
|
||
for u in empty_shell_units_list
|
||
for sid in (u.source_section_ids or [])
|
||
})
|
||
content_rendered_section_ids = sorted(content_covered)
|
||
|
||
adapter_needed_units = list(adapter_needed_units or [])
|
||
content_truncated = []
|
||
fallback_selections = []
|
||
for z in (debug_zones or []):
|
||
if z.get("fallback_used"):
|
||
fallback_selections.append({
|
||
"position": z["position"],
|
||
"source_section_ids": z["source_section_ids"],
|
||
"template_id": z["v4_template_id"],
|
||
"selected_v4_rank": z.get("v4_selected_rank"),
|
||
"selection_path": z.get("selection_path"),
|
||
"fallback_reason": z.get("fallback_reason"),
|
||
})
|
||
tc = z.get("content_truncated_count")
|
||
if tc:
|
||
content_truncated.append({
|
||
"position": z["position"],
|
||
"source_section_ids": z["source_section_ids"],
|
||
"template_id": z["v4_template_id"],
|
||
"truncated_count": tc,
|
||
})
|
||
|
||
# 필터된 section 의 사유 (auto pipeline 결정 트레이스 — review 개념 X)
|
||
filtered_section_reasons = []
|
||
for c in comp_debug.get("candidates_summary", []):
|
||
if c.get("selection_state") == "selected":
|
||
continue
|
||
cand_ids = c.get("source_section_ids", [])
|
||
if any(sid in filtered_ids for sid in cand_ids):
|
||
filtered_section_reasons.append({
|
||
"section_ids": cand_ids,
|
||
"merge_type": c.get("merge_type"),
|
||
"template_id": c.get("template_id"),
|
||
"v4_label": c.get("label"),
|
||
"phase_z_status": c.get("phase_z_status"),
|
||
"score": c.get("score"),
|
||
"selection_state": c.get("selection_state"), # filtered_status / filtered_weak / filtered_lost
|
||
"filter_reasons": c.get("filter_reasons", []),
|
||
})
|
||
|
||
# IMP-06 blocker-fix (Codex #10 Catch O schema + Codex #16 coverage invariant) —
|
||
# surface override-uncovered sections as additive list entries in
|
||
# `filtered_section_reasons` and ensure `filtered_section_ids` includes them
|
||
# so coverage does not silently miss sections that were dropped by an explicit
|
||
# zone-section override.
|
||
v4_fb_summary = comp_debug.get("v4_fallback_summary", {}) or {}
|
||
section_assignment_summary = comp_debug.get("section_assignment_summary") or {}
|
||
section_assignment_uncovered_ids: list[str] = list(
|
||
section_assignment_summary.get("uncovered_section_ids") or []
|
||
)
|
||
if section_assignment_uncovered_ids:
|
||
# Codex #16 invariant : final filtered_section_ids must contain the
|
||
# override-uncovered ids even if they were originally "covered" by the
|
||
# pre-override auto plan. full_coverage must be re-evaluated too so
|
||
# Step 20 `overall` enum reflects the post-override reality.
|
||
for sid in section_assignment_uncovered_ids:
|
||
if sid not in filtered_ids:
|
||
filtered_ids.append(sid)
|
||
filtered_ids = sorted(set(filtered_ids))
|
||
full_coverage = len(filtered_ids) == 0
|
||
# Append a separate list entry per override-uncovered section so existing
|
||
# readers of filtered_section_reasons (list-shaped) keep working.
|
||
plan_by_position = {
|
||
(p.get("position") or ""): p
|
||
for p in (comp_debug.get("section_assignment_plan") or [])
|
||
}
|
||
for sid in section_assignment_uncovered_ids:
|
||
# Find the position whose plan entry recorded this uncovered id.
|
||
source_position = None
|
||
for pos, entry in plan_by_position.items():
|
||
if sid in (entry.get("uncovered_section_ids") or []):
|
||
source_position = pos
|
||
break
|
||
filtered_section_reasons.append({
|
||
"section_ids": [sid],
|
||
"merge_type": None,
|
||
"template_id": None,
|
||
"v4_label": None,
|
||
"phase_z_status": None,
|
||
"score": None,
|
||
"selection_state": "section_assignment_override_uncovered",
|
||
"filter_reasons": ["section_assignment_override_uncovered"],
|
||
"source": "section_assignment_override",
|
||
"position": source_position,
|
||
})
|
||
|
||
# IMP-87 u2 — EMPTY_SHELL_NO_CONTENT precedence over the legacy 4-way
|
||
# ladder (Stage 2 axis A3). If the slide has aligned MDX sections but
|
||
# every selected unit is an IMP-30 u4 empty-shell placeholder (zero
|
||
# content units selected, at least one empty-shell unit selected), the
|
||
# visible artifact carries no real content — ``overall`` MUST report
|
||
# EMPTY_SHELL_NO_CONTENT so the u3 CLI exit / final_status.html styling
|
||
# can branch on it. The check runs before the visual/coverage ladder
|
||
# because a content-empty placeholder slide can technically pass Selenium
|
||
# overflow checks (no content → no overflow); without this precedence the
|
||
# ladder would mislabel it PASS (Stage 1 mdx05 Case B honesty defect).
|
||
is_empty_shell_only = (
|
||
len(aligned_ids) > 0
|
||
and len(empty_shell_units_list) > 0
|
||
and len(content_units_list) == 0
|
||
)
|
||
if is_empty_shell_only:
|
||
overall = "EMPTY_SHELL_NO_CONTENT"
|
||
elif full_coverage and visual_passed:
|
||
overall = "PASS"
|
||
elif full_coverage and not visual_passed:
|
||
overall = "RENDERED_WITH_VISUAL_REGRESSION"
|
||
elif not full_coverage and visual_passed:
|
||
overall = "PARTIAL_COVERAGE"
|
||
else:
|
||
overall = "PARTIAL_COVERAGE_WITH_VISUAL_REGRESSION"
|
||
|
||
# IMP-05 L3 (Codex #10 D4 / #17 idea F / Claude #21 idea J) — Step 20 qualifier fields.
|
||
# Additive only — top-level overall enum unchanged. Defensive defaults so non-fallback
|
||
# paths (empty v4_fallback_summary) do not crash and report 0 / [] cleanly.
|
||
_v4_fb_summary = comp_debug.get("v4_fallback_summary", {}) or {}
|
||
_fallback_selection_count = _v4_fb_summary.get("fallback_selection_count", 0)
|
||
_selection_paths = _v4_fb_summary.get("selection_paths", [])
|
||
|
||
# IMP-30 u6 — Step 20 additive qualifier fields for the first-render invariant.
|
||
# provisional_first_render_count = number of selected units whose .provisional
|
||
# flag is True (set by u1 V4Match synthesis → u2 CompositionUnit propagation,
|
||
# u3 last-resort fill, or u4 empty-shell synthesis). The list mirrors the shape
|
||
# of fallback_selections / adapter_needed_units for symmetry. Top-level overall
|
||
# enum stays unchanged per IMP-05 Codex #10 D4 + Stage 1 Q3 decision: this
|
||
# signal is a qualifier, not a new failure class. Defensive getattr keeps the
|
||
# function safe when units come from legacy code paths predating u2.
|
||
provisional_first_render_units: list[dict] = []
|
||
for u in units:
|
||
if not getattr(u, "provisional", False):
|
||
continue
|
||
provisional_first_render_units.append({
|
||
"source_section_ids": list(getattr(u, "source_section_ids", []) or []),
|
||
"phase_z_status": getattr(u, "phase_z_status", None),
|
||
"frame_template_id": getattr(u, "frame_template_id", None),
|
||
"frame_id": getattr(u, "frame_id", None),
|
||
"label": getattr(u, "label", None),
|
||
"selection_path": getattr(u, "selection_path", None),
|
||
"fallback_reason": getattr(u, "fallback_reason", None),
|
||
"v4_rank": getattr(u, "v4_rank", None),
|
||
})
|
||
|
||
return {
|
||
"rendered": True,
|
||
"visual_check_passed": visual_passed,
|
||
"full_mdx_coverage": full_coverage,
|
||
"aligned_section_ids": aligned_ids,
|
||
"covered_section_ids": sorted(covered),
|
||
# IMP-87 u2 — additive empty/content accounting (overall enum precedence above).
|
||
"content_rendered_section_ids": content_rendered_section_ids,
|
||
"content_rendered_unit_count": len(content_units_list),
|
||
"empty_shell_section_ids": empty_shell_section_ids,
|
||
"empty_shell_unit_count": len(empty_shell_units_list),
|
||
"filtered_section_ids": filtered_ids,
|
||
"filtered_section_reasons": filtered_section_reasons,
|
||
"selection_path": "fallback_used" if fallback_selections else "rank_1",
|
||
"fallback_used": bool(fallback_selections),
|
||
"fallback_selections": fallback_selections,
|
||
# IMP-05 L3 qualifier fields — grouped near existing fallback fields for readability.
|
||
"fallback_selection_count": _fallback_selection_count,
|
||
"selection_paths": _selection_paths,
|
||
"visual_fail_reasons": list(overflow.get("fail_reasons") or []),
|
||
"adapter_needed_count": len(adapter_needed_units),
|
||
"adapter_needed_units": adapter_needed_units,
|
||
"content_truncated_count": len(content_truncated),
|
||
"content_truncated_units": content_truncated,
|
||
# IMP-30 u6 — additive provisional qualifiers (overall enum unchanged).
|
||
"provisional_first_render_count": len(provisional_first_render_units),
|
||
"provisional_first_render_units": provisional_first_render_units,
|
||
"overall": overall,
|
||
"note": (
|
||
"자동 파이프라인 결과 보고. review/UI 개념 X. final.html 파일명 != PASS 의미. "
|
||
"overall == PASS 는 visual OK + full coverage + adapter_needed=0 일 때만. "
|
||
"adapter_needed_count > 0 = mapper 가 contract 와 안 맞아 자동 렌더 못 한 zone 존재. "
|
||
"content_truncated_count > 0 = builder 가 truncate 한 zone 존재 (rendered 됐지만 일부 콘텐츠 손실). "
|
||
"provisional_first_render_count > 0 = IMP-30 first-render invariant 가 작동한 unit 존재 "
|
||
"(empty_shell / chain_exhausted_provisional / 등 — needs user/AI adaptation)."
|
||
),
|
||
}
|
||
|
||
|
||
# ─── Per-step artifact write (locked schema) ────────────────────
|
||
# 모든 step JSON 공통 필드: step_num, step_name, step_status,
|
||
# pipeline_path_connected, input, output, note, data
|
||
# 사용자 lock — 한 슬라이드 결과물 고치지 말고 시스템 layer 박기 (오답노트 #2)
|
||
|
||
|
||
def _write_step_artifact(
|
||
run_dir: Path,
|
||
step_num: int,
|
||
name: str,
|
||
data,
|
||
*,
|
||
step_status: str = "done",
|
||
pipeline_path_connected: bool = True,
|
||
inputs: Optional[list[str]] = None,
|
||
outputs: Optional[list[str]] = None,
|
||
note: Optional[str] = None,
|
||
) -> Path:
|
||
"""Write per-step JSON artifact to {run_dir}/steps/step{NN}_{name}.json.
|
||
|
||
Locked schema (사용자 직설):
|
||
step_num, step_name, step_status, pipeline_path_connected,
|
||
input, output, note, data.
|
||
Status values: 'done' / 'partial' / 'trace-only' / 'future' / 'failed'.
|
||
"""
|
||
steps_dir = run_dir / "steps"
|
||
steps_dir.mkdir(exist_ok=True)
|
||
fname = f"step{step_num:02d}_{name}.json"
|
||
fpath = steps_dir / fname
|
||
payload = {
|
||
"step_num": step_num,
|
||
"step_name": name,
|
||
"step_status": step_status,
|
||
"pipeline_path_connected": pipeline_path_connected,
|
||
"input": inputs or [],
|
||
"output": outputs or [fname],
|
||
"note": note,
|
||
"data": data,
|
||
}
|
||
fpath.write_text(
|
||
json.dumps(payload, ensure_ascii=False, indent=2, default=str),
|
||
encoding="utf-8",
|
||
)
|
||
return fpath
|
||
|
||
|
||
# IMP-43 (#72) u3 — Step 6 reuse snapshot sidecar writer.
|
||
#
|
||
# Scope (u3 only — Stage 2 unit split):
|
||
# * Writes ``run_dir/_reuse_snapshot.json`` *after* the Step 6 artifact.
|
||
# * JSON-only (per Stage 2 guardrail — pickle forbidden); schema +
|
||
# ``build_snapshot`` live in u2 (``src.phase_z2_reuse_snapshot``).
|
||
# * Write failure WARNS and CONTINUES — the snapshot is an OPTIONAL
|
||
# sidecar; absence means future ``--reuse-from`` (u4) will fail
|
||
# closed when it cannot find / load the file. The main pipeline
|
||
# run must not abort on snapshot write failure.
|
||
# * Returns the run_dir-relative path (``"_reuse_snapshot.json"``) on
|
||
# success, ``None`` on failure. The caller stamps the returned value
|
||
# (or the constant when known ahead of time) into the Step 6 artifact.
|
||
def _write_reuse_snapshot(
|
||
run_dir: Path,
|
||
*,
|
||
mdx_source_text: str,
|
||
slide_title: Optional[str],
|
||
slide_footer: Optional[str],
|
||
sections: list,
|
||
stage0_adapter_diagnostics: Optional[dict],
|
||
stage0_normalized_assets: Optional[dict],
|
||
v4_evidence: list,
|
||
layout_preset_pre_override: Optional[str],
|
||
units: list,
|
||
comp_debug: Optional[dict],
|
||
v4_fallback_traces: Optional[dict],
|
||
ai_preflight: Optional[dict],
|
||
) -> Optional[str]:
|
||
try:
|
||
mdx_sha256 = hashlib.sha256(mdx_source_text.encode("utf-8")).hexdigest()
|
||
snapshot = build_snapshot(
|
||
mdx_sha256=mdx_sha256,
|
||
slide_title=slide_title,
|
||
slide_footer=slide_footer,
|
||
sections=sections,
|
||
stage0_adapter_diagnostics=stage0_adapter_diagnostics,
|
||
stage0_normalized_assets=stage0_normalized_assets,
|
||
v4_evidence=v4_evidence,
|
||
layout_preset_pre_override=layout_preset_pre_override,
|
||
units=units,
|
||
comp_debug=comp_debug,
|
||
v4_fallback_traces=v4_fallback_traces,
|
||
ai_preflight=ai_preflight,
|
||
)
|
||
fpath = run_dir / SNAPSHOT_FILENAME
|
||
fpath.write_text(
|
||
json.dumps(snapshot, ensure_ascii=False, indent=2),
|
||
encoding="utf-8",
|
||
)
|
||
return SNAPSHOT_FILENAME
|
||
except Exception as exc:
|
||
print(
|
||
f" [reuse-snapshot] WARN — failed to write {SNAPSHOT_FILENAME} "
|
||
f"(reason={type(exc).__name__}: {exc}); --reuse-from will not be "
|
||
f"available from this run.",
|
||
file=sys.stderr,
|
||
)
|
||
return None
|
||
|
||
|
||
# IMP-43 (#72) u4 — --reuse-from copy + restore + entry helpers.
|
||
#
|
||
# Scope (u4 only — Stage 2 unit split):
|
||
# * Pure path resolution / file copy / snapshot load+validate /
|
||
# section + unit rehydration / marker writing.
|
||
# * NO edits to ``run_phase_z2_mvp1`` body — the kwarg threading and
|
||
# the entry-point branch that invokes these helpers land in u5.
|
||
# * NO sys.exit(2) translation — helpers RAISE
|
||
# (FileNotFoundError / SnapshotValidationError / OSError); u4b adds
|
||
# the stderr + exit-code-2 wrapper, the prev_run_dir == new_run_dir
|
||
# accidental-write guard, and the mdx_sha256 mismatch surface
|
||
# fingerprint.
|
||
#
|
||
# Restore contract (Stage 2 boundary): Step 0/1/2/5/6 artifacts +
|
||
# ``_reuse_snapshot.json``. Step numbers 3 / 4 are deliberately absent
|
||
# — the pipeline DOES write ``step03_content_objects.json`` and
|
||
# ``step04_internal_composition.json`` AFTER the Step 6 artifact and
|
||
# BEFORE the Step 7 artifact (see ``_write_step_artifact`` call sites
|
||
# for ``run_dir, 3`` and ``run_dir, 4`` above the ``run_dir, 7`` call
|
||
# in this file), but both are emitted with
|
||
# ``step_status="trace-only"`` and ``pipeline_path_connected=False``:
|
||
# they are diagnostic projections derived from the Step 6
|
||
# ``debug_zones`` snapshot, not deterministic inputs that Step 7+
|
||
# consume. Restoring them is unnecessary because downstream code
|
||
# reads ``debug_zones`` directly (rehydrated from the snapshot), and
|
||
# copying trace-only files would muddle the boundary audit. Stage 2
|
||
# boundary lock = pipeline-path-connected pre-Step 7 artifacts only.
|
||
|
||
_REUSE_STEP_ARTIFACTS: tuple[str, ...] = (
|
||
"step00_preconditions.json",
|
||
"step01_mdx_upload.json",
|
||
"step01_mdx_source.md",
|
||
"step02_normalized.json",
|
||
"step05_v4_evidence.json",
|
||
"step06_composition_plan.json",
|
||
)
|
||
|
||
REUSE_MARKER_FILENAME = "_reuse_marker.json"
|
||
|
||
|
||
def _resolve_reuse_from_prev_run_dir(reuse_from: str) -> Path:
|
||
"""Resolve ``--reuse-from PREV_RUN_ID`` to its ``phase_z2`` run_dir.
|
||
|
||
Pure path computation — does NOT check existence. u4b adds the
|
||
fail-closed prev-run-missing translation around this helper.
|
||
"""
|
||
return RUNS_DIR / reuse_from / "phase_z2"
|
||
|
||
|
||
def _copy_reuse_artifacts_from_prev_run(
|
||
prev_run_dir: Path, new_run_dir: Path
|
||
) -> dict[str, str]:
|
||
"""Copy Step 0/1/2/5/6 artifacts + ``_reuse_snapshot.json`` into new_run_dir.
|
||
|
||
Returns ``{artifact_name: new_run_dir-relative_path}`` for all
|
||
copied files. Raises ``FileNotFoundError`` when any required
|
||
artifact is missing in ``prev_run_dir`` (u4b translates to exit 2).
|
||
"""
|
||
new_steps = new_run_dir / "steps"
|
||
new_steps.mkdir(parents=True, exist_ok=True)
|
||
copied: dict[str, str] = {}
|
||
for fname in _REUSE_STEP_ARTIFACTS:
|
||
src = prev_run_dir / "steps" / fname
|
||
if not src.exists():
|
||
raise FileNotFoundError(
|
||
f"reuse artifact missing in prev_run_dir: steps/{fname} "
|
||
f"(expected at {src})"
|
||
)
|
||
shutil.copyfile(src, new_steps / fname)
|
||
copied[fname] = f"steps/{fname}"
|
||
snap_src = prev_run_dir / SNAPSHOT_FILENAME
|
||
if not snap_src.exists():
|
||
raise FileNotFoundError(
|
||
f"reuse snapshot missing in prev_run_dir: {SNAPSHOT_FILENAME} "
|
||
f"(expected at {snap_src})"
|
||
)
|
||
shutil.copyfile(snap_src, new_run_dir / SNAPSHOT_FILENAME)
|
||
copied[SNAPSHOT_FILENAME] = SNAPSHOT_FILENAME
|
||
return copied
|
||
|
||
|
||
def _load_and_validate_reuse_snapshot(
|
||
new_run_dir: Path, *, mdx_source_text: str
|
||
) -> dict:
|
||
"""Load + validate the reuse snapshot already copied into ``new_run_dir``.
|
||
|
||
Computes the expected ``mdx_sha256`` from ``mdx_source_text`` UTF-8
|
||
bytes — same derivation as ``_write_reuse_snapshot`` so the
|
||
integrity check is symmetric. Delegates structural validation to
|
||
u2's ``validate_snapshot``; that raises
|
||
``SnapshotValidationError`` (subclass of ``ValueError``) on
|
||
schema_version mismatch, mdx_sha256 mismatch, missing required
|
||
keys, or malformed wrappers — u4b catches and translates.
|
||
"""
|
||
from src.phase_z2_reuse_snapshot import validate_snapshot
|
||
|
||
snap_path = new_run_dir / SNAPSHOT_FILENAME
|
||
snapshot = json.loads(snap_path.read_text(encoding="utf-8"))
|
||
expected_sha = hashlib.sha256(mdx_source_text.encode("utf-8")).hexdigest()
|
||
validate_snapshot(snapshot, expected_mdx_sha256=expected_sha)
|
||
return snapshot
|
||
|
||
|
||
@dataclass
|
||
class _RehydratedV4Candidate:
|
||
"""V4Match-shape duck type restored from snapshot ``v4_candidates``.
|
||
|
||
Exposes the 6-attribute contract that the reuse path's downstream
|
||
consumers read off ``unit.v4_candidates`` entries:
|
||
* template_id / frame_id / frame_number / confidence / label —
|
||
read by ``_apply_frame_override_to_unit`` (frame swap).
|
||
* v4_rank — read by ``_build_application_plan_unit`` (Step 9
|
||
payload, ``data.application_plan.zones[i].v4_candidates[j]``).
|
||
Default ``None`` keeps the dataclass safe to construct from
|
||
legacy snapshots that pre-date the u4 fix where the snapshot
|
||
serializer did not persist per-candidate rank.
|
||
Kept local — circular-dep-free; the production ``V4Match`` dataclass
|
||
additionally carries section_id / selection_path / fallback_reason /
|
||
provisional that the reuse boundary deliberately does not require.
|
||
"""
|
||
template_id: str
|
||
frame_id: str
|
||
frame_number: int
|
||
confidence: float
|
||
label: str
|
||
v4_rank: Optional[int] = None
|
||
|
||
|
||
def _rehydrate_mdx_sections_from_snapshot(snapshot: dict) -> list:
|
||
"""Rebuild ``list[MdxSection]`` from snapshot ``sections`` wrapper.
|
||
|
||
Mirrors the ``serialize_section`` field list (u2 source of truth).
|
||
Returns a Python list of ``MdxSection`` dataclass instances so the
|
||
Step 7+ pipeline code that does ``[s.section_id for s in sections]``
|
||
keeps byte-for-byte behavior.
|
||
"""
|
||
entries = snapshot["sections"]["value"]
|
||
return [
|
||
MdxSection(
|
||
section_id=e["section_id"],
|
||
section_num=e["section_num"],
|
||
title=e["title"],
|
||
raw_content=e["raw_content"],
|
||
heading_number=e.get("heading_number"),
|
||
v4_alias_keys=list(e.get("v4_alias_keys") or []),
|
||
sub_sections=list(e.get("sub_sections") or []),
|
||
)
|
||
for e in entries
|
||
]
|
||
|
||
|
||
def _rehydrate_composition_units_from_snapshot(snapshot: dict) -> list:
|
||
"""Rebuild ``list[CompositionUnit]`` from snapshot ``units`` wrapper.
|
||
|
||
``v4_candidates`` entries are restored as ``_RehydratedV4Candidate``
|
||
instances so attribute access (``cand.template_id`` etc.) works
|
||
end-to-end through ``_apply_frame_override_to_unit`` without
|
||
serializing the production ``V4Match`` dataclass shape.
|
||
|
||
Uses the ``src.phase_z2_composition`` import path to match
|
||
line 4976 / 5125's local re-imports — the module is loaded under
|
||
both ``phase_z2_composition`` and ``src.phase_z2_composition`` due
|
||
to historical sys.path duality, so a top-level CompositionUnit
|
||
reference would create a class-identity mismatch against tests and
|
||
downstream code that imports via the ``src.`` path.
|
||
"""
|
||
from src.phase_z2_composition import CompositionUnit as _CompositionUnit
|
||
|
||
entries = snapshot["units"]["value"]
|
||
units: list = []
|
||
for e in entries:
|
||
cands = [
|
||
_RehydratedV4Candidate(
|
||
template_id=c["template_id"],
|
||
frame_id=c["frame_id"],
|
||
frame_number=int(c["frame_number"]),
|
||
confidence=float(c["confidence"]),
|
||
label=c["label"],
|
||
v4_rank=(
|
||
int(c["v4_rank"])
|
||
if c.get("v4_rank") is not None
|
||
else None
|
||
),
|
||
)
|
||
for c in (e.get("v4_candidates") or [])
|
||
]
|
||
units.append(_CompositionUnit(
|
||
source_section_ids=list(e["source_section_ids"]),
|
||
merge_type=e["merge_type"],
|
||
frame_template_id=e["frame_template_id"],
|
||
frame_id=e["frame_id"],
|
||
frame_number=int(e["frame_number"]),
|
||
confidence=float(e["confidence"]),
|
||
label=e["label"],
|
||
phase_z_status=e["phase_z_status"],
|
||
raw_content=e["raw_content"],
|
||
title=e["title"],
|
||
v4_rank=e.get("v4_rank"),
|
||
selection_path=e.get("selection_path") or "rank_1",
|
||
fallback_reason=e.get("fallback_reason"),
|
||
score=float(e.get("score") or 0.0),
|
||
rationale=dict(e.get("rationale") or {}),
|
||
auto_selectable=bool(e.get("auto_selectable", True)),
|
||
filter_reasons=list(e.get("filter_reasons") or []),
|
||
notes=list(e.get("notes") or []),
|
||
v4_candidates=cands,
|
||
provisional=bool(e.get("provisional", False)),
|
||
))
|
||
return units
|
||
|
||
|
||
REUSE_MARKER_SCHEMA_VERSION = 1
|
||
|
||
|
||
def _write_reuse_marker(
|
||
new_run_dir: Path,
|
||
*,
|
||
prev_run_id: str,
|
||
copied_artifacts: dict[str, str],
|
||
) -> Path:
|
||
"""Write ``_reuse_marker.json`` to ``new_run_dir`` for audit trail.
|
||
|
||
Records prev_run_id, copied artifact map, the locked Step 0/1/2/5/6
|
||
boundary, and ``resume_at_step=7``. Informational sidecar — absence
|
||
does not break the reused run; presence lets operators trace which
|
||
prev_run_id the reuse path was sourced from. u5 invokes this after
|
||
a successful copy + restore.
|
||
"""
|
||
marker = {
|
||
"schema_version": REUSE_MARKER_SCHEMA_VERSION,
|
||
"reuse_from_prev_run_id": prev_run_id,
|
||
"snapshot_filename": SNAPSHOT_FILENAME,
|
||
"copied_artifacts": dict(copied_artifacts),
|
||
"boundary_steps": list(_REUSE_STEP_ARTIFACTS),
|
||
"resume_at_step": 7,
|
||
"note": (
|
||
"IMP-43 (#72) u4 — this run was sourced from prev_run_id via "
|
||
"--reuse-from. Steps 0/1/2/5/6 artifacts copied; Step 7+ "
|
||
"re-executed in this run_dir."
|
||
),
|
||
}
|
||
fpath = new_run_dir / REUSE_MARKER_FILENAME
|
||
fpath.write_text(
|
||
json.dumps(marker, ensure_ascii=False, indent=2),
|
||
encoding="utf-8",
|
||
)
|
||
return fpath
|
||
|
||
|
||
# IMP-43 (#72) u4b — fail-closed wrapper around the u4 helpers.
|
||
#
|
||
# Scope (u4b only — Stage 2 unit split):
|
||
# * Translate the u4 raises (FileNotFoundError, SnapshotValidationError,
|
||
# json.JSONDecodeError, OSError) into the CLI fail-closed contract:
|
||
# stderr message + ``sys.exit(2)``.
|
||
# * Add the prev_run_dir == new_run_dir accidental-write guard BEFORE
|
||
# any copy attempt — otherwise ``_copy_reuse_artifacts_from_prev_run``
|
||
# would overwrite prev_run_dir's own step files with itself and
|
||
# mutate the "read-only" reuse source.
|
||
# * Add the missing-prev-run-dir surface so the user gets a clean
|
||
# "run id not found" message instead of the raw FileNotFoundError
|
||
# stack from inside _copy_reuse_artifacts_from_prev_run.
|
||
# * Surface the mdx_sha256 mismatch as its OWN axis (distinct from
|
||
# generic snapshot validation failures) so the operator can tell
|
||
# "wrong --mdx-path for this prev_run_id" apart from "snapshot file
|
||
# is broken".
|
||
#
|
||
# Out of scope: signature threading into ``run_phase_z2_mvp1`` (u5),
|
||
# the actual call site dispatch into Step 7+ (u5).
|
||
#
|
||
# Diagnostic format (factual-verification guardrail):
|
||
# [error] --reuse-from fail-closed: <axis>
|
||
# value: <repr>
|
||
# path: <fs path / resource locator>
|
||
# upstream: <where the value originated>
|
||
# reason: <type>: <message> (only when exc != None)
|
||
#
|
||
# axis vocabulary (closed enum — tests pin this set):
|
||
# * prev_run_dir_missing
|
||
# * prev_run_dir_equals_new_run_dir
|
||
# * reuse_artifact_missing
|
||
# * reuse_copy_os_error # OSError != FileNotFoundError during copy
|
||
# # (PermissionError, IsADirectoryError,
|
||
# # OSError(errno.EXDEV), full-disk, etc.)
|
||
# * snapshot_missing_after_copy
|
||
# * snapshot_corrupt_json
|
||
# * snapshot_read_os_error # OSError != FileNotFoundError during
|
||
# # snapshot read (permission denied,
|
||
# # path-became-dir, lower-level IO)
|
||
# * mdx_sha256_mismatch
|
||
# * snapshot_validation_failed
|
||
|
||
|
||
REUSE_FAIL_CLOSED_AXES: frozenset[str] = frozenset({
|
||
"prev_run_dir_missing",
|
||
"prev_run_dir_equals_new_run_dir",
|
||
"reuse_artifact_missing",
|
||
"reuse_copy_os_error",
|
||
"snapshot_missing_after_copy",
|
||
"snapshot_corrupt_json",
|
||
"snapshot_read_os_error",
|
||
"mdx_sha256_mismatch",
|
||
"snapshot_validation_failed",
|
||
})
|
||
|
||
|
||
def _abort_reuse_from(
|
||
*,
|
||
axis: str,
|
||
value: Any,
|
||
path: str,
|
||
upstream: str,
|
||
exc: Optional[BaseException] = None,
|
||
) -> "NoReturn":
|
||
"""Print provenance-tagged stderr message and ``sys.exit(2)``.
|
||
|
||
All four `value+path+upstream+axis` fields are mandatory so the
|
||
operator can pinpoint the failed precondition without grepping the
|
||
pipeline source. ``exc`` (when supplied) adds the underlying type
|
||
+ message — useful for ``json.JSONDecodeError`` line/col info or
|
||
OSError errno.
|
||
"""
|
||
if axis not in REUSE_FAIL_CLOSED_AXES:
|
||
raise AssertionError(
|
||
f"_abort_reuse_from: unknown axis {axis!r} "
|
||
f"(expected one of {sorted(REUSE_FAIL_CLOSED_AXES)})"
|
||
)
|
||
lines = [
|
||
f"[error] --reuse-from fail-closed: {axis}",
|
||
f" value: {value!r}",
|
||
f" path: {path}",
|
||
f" upstream: {upstream}",
|
||
]
|
||
if exc is not None:
|
||
lines.append(f" reason: {type(exc).__name__}: {exc}")
|
||
print("\n".join(lines), file=sys.stderr)
|
||
sys.exit(2)
|
||
|
||
|
||
def _paths_equivalent(a: Path, b: Path) -> bool:
|
||
"""Return True when ``a`` and ``b`` resolve to the same filesystem
|
||
location, falling back to lexical equality when ``a`` doesn't
|
||
exist yet (Path.resolve(strict=False) still normalizes case + sep
|
||
on Windows + collapses ``..`` segments).
|
||
"""
|
||
try:
|
||
return a.resolve(strict=False) == b.resolve(strict=False)
|
||
except (OSError, RuntimeError):
|
||
return a == b
|
||
|
||
|
||
def execute_reuse_from_or_fail_closed(
|
||
*,
|
||
reuse_from: str,
|
||
new_run_dir: Path,
|
||
mdx_source_text: str,
|
||
) -> tuple[Path, dict[str, str], dict]:
|
||
"""Orchestrate u4 helpers under the u4b fail-closed contract.
|
||
|
||
Returns ``(prev_run_dir, copied_artifacts, snapshot)`` on success.
|
||
Calls ``sys.exit(2)`` on any of the seven fail-closed axes; does
|
||
NOT return in that case.
|
||
|
||
The caller (u5, into ``run_phase_z2_mvp1``) does NOT need to wrap
|
||
this in its own try/except — every reachable failure inside this
|
||
function terminates the process directly.
|
||
"""
|
||
from src.phase_z2_reuse_snapshot import SnapshotValidationError
|
||
|
||
prev_run_dir = _resolve_reuse_from_prev_run_dir(reuse_from)
|
||
|
||
# Guard 1: prev_run_dir must exist.
|
||
if not prev_run_dir.exists():
|
||
_abort_reuse_from(
|
||
axis="prev_run_dir_missing",
|
||
value=reuse_from,
|
||
path=str(prev_run_dir),
|
||
upstream="--reuse-from CLI argument",
|
||
)
|
||
|
||
# Guard 2: prev_run_dir must NOT be the same as new_run_dir.
|
||
# Without this, the copy step would overwrite prev_run_dir's own
|
||
# files with themselves and break the RO guarantee on the reuse
|
||
# source. The check resolves both sides so a relative-vs-absolute
|
||
# or symlinked collision still trips it.
|
||
if _paths_equivalent(prev_run_dir, new_run_dir):
|
||
_abort_reuse_from(
|
||
axis="prev_run_dir_equals_new_run_dir",
|
||
value=reuse_from,
|
||
path=str(prev_run_dir),
|
||
upstream=(
|
||
"_resolve_reuse_from_prev_run_dir(reuse_from) == new_run_dir "
|
||
"(would overwrite prev_run_dir during copy)"
|
||
),
|
||
)
|
||
|
||
# Copy step 0/1/2/5/6 + snapshot from prev_run_dir → new_run_dir.
|
||
# FileNotFoundError MUST be caught before the bare OSError handler —
|
||
# it is a subclass of OSError and the missing-artifact case has its
|
||
# own dedicated axis.
|
||
try:
|
||
copied = _copy_reuse_artifacts_from_prev_run(prev_run_dir, new_run_dir)
|
||
except FileNotFoundError as exc:
|
||
_abort_reuse_from(
|
||
axis="reuse_artifact_missing",
|
||
value=str(exc),
|
||
path=str(prev_run_dir),
|
||
upstream=(
|
||
"Step 0/1/2/5/6 deterministic artifacts + "
|
||
f"{SNAPSHOT_FILENAME} under prev_run_dir/steps/"
|
||
),
|
||
exc=exc,
|
||
)
|
||
except OSError as exc:
|
||
# PermissionError, IsADirectoryError, OSError(errno.EXDEV) when
|
||
# crossing filesystems with shutil.copyfile, disk-full, etc.
|
||
# Without this branch the raw traceback would escape the wrapper
|
||
# and contradict the docstring contract ("every reachable
|
||
# failure inside this function terminates the process directly").
|
||
_abort_reuse_from(
|
||
axis="reuse_copy_os_error",
|
||
value=str(exc),
|
||
path=str(prev_run_dir),
|
||
upstream=(
|
||
"_copy_reuse_artifacts_from_prev_run "
|
||
"(OSError != FileNotFoundError; shutil.copyfile or "
|
||
"Path.mkdir surface)"
|
||
),
|
||
exc=exc,
|
||
)
|
||
|
||
# Load + validate snapshot. Exception fan-out below mirrors the
|
||
# u4 helper raise surface; each fail-closed axis is reported
|
||
# separately so operators can tell the cases apart.
|
||
# FileNotFoundError MUST be caught before the bare OSError handler.
|
||
try:
|
||
snapshot = _load_and_validate_reuse_snapshot(
|
||
new_run_dir, mdx_source_text=mdx_source_text,
|
||
)
|
||
except FileNotFoundError as exc:
|
||
# Should not happen — copy step would have failed first — but
|
||
# left explicit to make the contract symmetric.
|
||
_abort_reuse_from(
|
||
axis="snapshot_missing_after_copy",
|
||
value=str(exc),
|
||
path=str(new_run_dir / SNAPSHOT_FILENAME),
|
||
upstream="_copy_reuse_artifacts_from_prev_run side effect",
|
||
exc=exc,
|
||
)
|
||
except json.JSONDecodeError as exc:
|
||
_abort_reuse_from(
|
||
axis="snapshot_corrupt_json",
|
||
value=str(exc),
|
||
path=str(new_run_dir / SNAPSHOT_FILENAME),
|
||
upstream=f"json.loads({SNAPSHOT_FILENAME})",
|
||
exc=exc,
|
||
)
|
||
except OSError as exc:
|
||
# Permission denied on the copied snapshot, snap_path turned out
|
||
# to be a directory, lower-level IO error. JSONDecodeError is
|
||
# ValueError (independent of OSError) so order with that branch
|
||
# does not matter; this branch only needs to follow FNF.
|
||
_abort_reuse_from(
|
||
axis="snapshot_read_os_error",
|
||
value=str(exc),
|
||
path=str(new_run_dir / SNAPSHOT_FILENAME),
|
||
upstream=(
|
||
"_load_and_validate_reuse_snapshot "
|
||
"(OSError != FileNotFoundError; Path.read_text surface)"
|
||
),
|
||
exc=exc,
|
||
)
|
||
except SnapshotValidationError as exc:
|
||
msg = str(exc)
|
||
if "mdx_sha256 mismatch" in msg:
|
||
_abort_reuse_from(
|
||
axis="mdx_sha256_mismatch",
|
||
value=msg,
|
||
path=str(new_run_dir / SNAPSHOT_FILENAME),
|
||
upstream=(
|
||
"sha256(mdx_source_text) vs "
|
||
f"{SNAPSHOT_FILENAME}#/mdx_sha256"
|
||
),
|
||
exc=exc,
|
||
)
|
||
else:
|
||
_abort_reuse_from(
|
||
axis="snapshot_validation_failed",
|
||
value=msg,
|
||
path=str(new_run_dir / SNAPSHOT_FILENAME),
|
||
upstream="src.phase_z2_reuse_snapshot.validate_snapshot",
|
||
exc=exc,
|
||
)
|
||
|
||
return prev_run_dir, copied, snapshot
|
||
|
||
|
||
def _write_step_html(
|
||
run_dir: Path,
|
||
step_num: int,
|
||
name: str,
|
||
title: str,
|
||
body_html: str,
|
||
*,
|
||
step_status: str = "done",
|
||
inputs: Optional[list[str]] = None,
|
||
outputs: Optional[list[str]] = None,
|
||
) -> Path:
|
||
"""Write per-step HTML artifact with locked header (input/output/status).
|
||
|
||
HTML 산출물 = 사용자가 시각으로 판단해야 하는 step (7/8/9/13/20).
|
||
"""
|
||
steps_dir = run_dir / "steps"
|
||
steps_dir.mkdir(exist_ok=True)
|
||
fname = f"step{step_num:02d}_{name}.html"
|
||
fpath = steps_dir / fname
|
||
inputs_lines = "\n".join(f" - {i}" for i in (inputs or []))
|
||
outputs_lines = "\n".join(f" - {o}" for o in (outputs or [fname]))
|
||
status_class = (
|
||
"pass" if step_status == "done"
|
||
else "fail" if step_status == "failed"
|
||
else "partial"
|
||
)
|
||
inputs_li = "".join(f"<li><code>{i}</code></li>" for i in (inputs or []))
|
||
outputs_li = "".join(f"<li><code>{o}</code></li>" for o in (outputs or [fname]))
|
||
full_html = f"""<!--
|
||
Step {step_num:02d}: {title}
|
||
Status: {step_status}
|
||
Input:
|
||
{inputs_lines}
|
||
Output:
|
||
{outputs_lines}
|
||
-->
|
||
<!DOCTYPE html>
|
||
<html lang="ko"><head>
|
||
<meta charset="UTF-8">
|
||
<title>Step {step_num:02d} — {title}</title>
|
||
<style>
|
||
body {{ font-family: 'Pretendard', 'Noto Sans KR', sans-serif; padding: 24px; max-width: 1280px; margin: 0 auto; color: #1e293b; }}
|
||
h1 {{ border-bottom: 2px solid #333; padding-bottom: 8px; margin-top: 0; }}
|
||
h2 {{ margin-top: 32px; }}
|
||
.meta {{ background: #f8fafc; padding: 16px; border-left: 4px solid #2563eb; margin-bottom: 24px; }}
|
||
.meta ul {{ margin: 4px 0; padding-left: 20px; }}
|
||
.meta li {{ margin-bottom: 2px; }}
|
||
.pass {{ color: #16a34a; font-weight: 700; }}
|
||
.fail {{ color: #dc2626; font-weight: 700; }}
|
||
.partial {{ color: #ca8a04; font-weight: 700; }}
|
||
table {{ border-collapse: collapse; width: 100%; margin-top: 12px; }}
|
||
th, td {{ border: 1px solid #cbd5e1; padding: 8px 12px; text-align: left; vertical-align: top; }}
|
||
th {{ background: #f1f5f9; }}
|
||
code {{ background: #f1f5f9; padding: 2px 6px; border-radius: 3px; font-size: 0.9em; }}
|
||
.grid-preview {{ border: 2px solid #cbd5e1; padding: 8px; margin-top: 12px; }}
|
||
.zone-box {{ background: #e2e8f0; border: 1px solid #94a3b8; padding: 12px; margin: 4px 0; border-radius: 4px; }}
|
||
.candidate-card {{ border: 1px solid #cbd5e1; padding: 12px; margin: 8px 0; border-radius: 4px; }}
|
||
.candidate-rank1 {{ border-left: 4px solid #16a34a; background: #f0fdf4; }}
|
||
</style>
|
||
</head><body>
|
||
<h1>Step {step_num:02d} — {title}</h1>
|
||
<div class="meta">
|
||
<strong>Status:</strong> <span class="{status_class}">{step_status}</span><br>
|
||
<strong>Input:</strong>
|
||
<ul>{inputs_li}</ul>
|
||
<strong>Output:</strong>
|
||
<ul>{outputs_li}</ul>
|
||
</div>
|
||
{body_html}
|
||
</body>
|
||
</html>"""
|
||
fpath.write_text(full_html, encoding="utf-8")
|
||
return fpath
|
||
|
||
|
||
def write_debug_json(run_dir: Path, layout_preset: str,
|
||
debug_zones: list[dict],
|
||
layout_css: dict,
|
||
visual_runtime_check: Optional[dict] = None,
|
||
composition_debug: Optional[dict] = None,
|
||
slide_status: Optional[dict] = None,
|
||
fit_classification: Optional[dict] = None,
|
||
router_decision: Optional[dict] = None,
|
||
retry_trace: Optional[dict] = None) -> Path:
|
||
debug = {
|
||
"v4_source": str(V4_RESULT_PATH.relative_to(PROJECT_ROOT)),
|
||
"v4_label_to_phase_z_status": V4_LABEL_TO_PHASE_Z_STATUS,
|
||
"mvp1_allowed_statuses": sorted(MVP1_ALLOWED_STATUSES),
|
||
"mode": "composition_v0_layout_8preset",
|
||
"mode_note": (
|
||
"MVP-1.5b w/ composition planner v0 — sections → candidates (separate / "
|
||
"parent_merged) → score → greedy select → 8-preset layout vocabulary "
|
||
"(single / horizontal-2 / vertical-2 / top-1-bottom-2 / top-2-bottom-1 / "
|
||
"left-1-right-2 / left-2-right-1 / grid-2x2). v0 layout = count-based; "
|
||
"v1 axes (cardinality_fit / hierarchy_coherence / density_score) 추후."
|
||
),
|
||
"layout_preset": layout_preset,
|
||
"layout_css": layout_css,
|
||
"slide_status": slide_status,
|
||
"fit_classification": fit_classification,
|
||
"router_decision": router_decision,
|
||
"retry_trace": retry_trace,
|
||
"composition_planner_debug": composition_debug,
|
||
"zones": debug_zones,
|
||
"visual_runtime_check": visual_runtime_check,
|
||
# A-6 (IMP-01 #1) — additive top-level zone bbox trace (slide-relative px)
|
||
"zone_geometries_px": (visual_runtime_check or {}).get("zone_geometries_px", []),
|
||
# IMP-15 실행-4 (issue #48) — additive top-level Step 14 event streams
|
||
"image_events": (visual_runtime_check or {}).get("image_events", []),
|
||
"table_events": (visual_runtime_check or {}).get("table_events", []),
|
||
}
|
||
debug_path = run_dir / "debug.json"
|
||
debug_path.write_text(json.dumps(debug, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
return debug_path
|
||
|
||
|
||
# ─── Step 9 application-plan helpers (IMP-32 u1) ───────────────
|
||
|
||
def _application_candidates_for_unit(unit) -> list[dict]:
|
||
"""Step 9 (IMP-32 u1) — application candidate dicts from unit.v4_candidates.
|
||
|
||
Pure extraction of inline block at src/phase_z2_pipeline.py:4487-4501.
|
||
Behavior preserved: key set/order, APPLICATION_MODE_BY_V4_LABEL lookup,
|
||
required_changes placeholder = [] (v0 = trace-only).
|
||
"""
|
||
app_candidates = []
|
||
for c in unit.v4_candidates:
|
||
mode, auto_app, delegated = APPLICATION_MODE_BY_V4_LABEL.get(
|
||
c.label, ("exclude", False, None)
|
||
)
|
||
app_candidates.append({
|
||
"template_id": c.template_id,
|
||
"frame_id": c.frame_id,
|
||
"v4_label": c.label,
|
||
"application_mode": mode,
|
||
"auto_applicable": auto_app,
|
||
"required_changes": [], # v0 = trace-only
|
||
"delegated_to": delegated,
|
||
})
|
||
return app_candidates
|
||
|
||
|
||
def _v4_all_judgments_for_unit(v4_all_for_unit) -> list[dict]:
|
||
"""Step 9 (IMP-32 u2) — V4 all-judgment dicts (reject 포함) for a unit.
|
||
|
||
Pure extraction of inline block at src/phase_z2_pipeline.py:4529-4545
|
||
(post-u1 line numbers). IMP-11 D-2 markers preserved in this helper:
|
||
single `_contract = get_contract(c.template_id)` bind, `catalog_registered`
|
||
boolean, and `min_height_px` chain `(_contract or {}).get("visual_hints", {}).get("min_height_px")`.
|
||
Key set/order unchanged: template_id, frame_id, frame_number, v4_rank,
|
||
confidence, label, catalog_registered, min_height_px.
|
||
"""
|
||
# IMP-11 D-2 (u1) — per-candidate min_height_px source = catalog
|
||
# frame_contracts[template_id].visual_hints.min_height_px (logical 1280×720 px).
|
||
# None when contract unregistered (frontend tolerates undefined).
|
||
# Single get_contract lookup binds both catalog_registered and min_height_px.
|
||
v4_all_judgments_list = []
|
||
for c in v4_all_for_unit:
|
||
_contract = get_contract(c.template_id)
|
||
v4_all_judgments_list.append({
|
||
"template_id": c.template_id,
|
||
"frame_id": c.frame_id,
|
||
"frame_number": c.frame_number,
|
||
"v4_rank": c.v4_rank,
|
||
"confidence": c.confidence,
|
||
"label": c.label,
|
||
"catalog_registered": _contract is not None,
|
||
"min_height_px": (_contract or {}).get("visual_hints", {}).get("min_height_px"),
|
||
})
|
||
return v4_all_judgments_list
|
||
|
||
|
||
def _build_application_plan_unit(
|
||
unit,
|
||
zone_plan,
|
||
selection_trace,
|
||
plan_record,
|
||
v4_all_for_unit,
|
||
layout_preset,
|
||
layout_candidates_list,
|
||
) -> dict:
|
||
"""Step 9 (IMP-32 u3) — per-unit application_plan dict assembly.
|
||
|
||
Pure extraction of the inline `application_plan_units.append({...})` block
|
||
currently at src/phase_z2_pipeline.py:4577-4623 (post-u1/u2 line numbers).
|
||
Byte-identical output (key set + key order + value identity) when called
|
||
with the same per-unit inputs:
|
||
|
||
- unit : Step 6 unit (source_section_ids, v4_candidates,
|
||
v4_rank, selection_path, fallback_reason,
|
||
frame_template_id).
|
||
- zone_plan : Step 8 per-unit zone_plan dict (region_layout_
|
||
candidates, display_strategy_candidates).
|
||
- selection_trace : v4_fallback_traces[unit.source_section_ids[0]]
|
||
(candidates list for candidate_evidence /
|
||
fallback_chain compat alias).
|
||
- plan_record : plan_record_by_unit_id[id(unit)] or None
|
||
(IMP-06 plan-aware additive fields).
|
||
- v4_all_for_unit : lookup_v4_all_judgments(...) result (Step 7-A
|
||
axis trace — reject 포함 모든 V4 후보).
|
||
- layout_preset : Step 7 preset name (e.g., "Type A").
|
||
- layout_candidates_list : Step 7 candidate list.
|
||
|
||
Per-index/per-id lookups (zone_region_plans[i], v4_fallback_traces.get(...),
|
||
plan_record_by_unit_id.get(id(unit)), section_alias_by_id, lookup_v4_all_
|
||
judgments(...)) stay at the call-site (u4).
|
||
|
||
Invariants preserved:
|
||
- candidate_evidence = selection_trace.get("candidates", []) — primary field.
|
||
- fallback_chain = same list — compat alias for pre-IMP-05 readers.
|
||
- v4_candidates list comprehension fields + order unchanged.
|
||
- IMP-06 additive plan fields (position / assignment_source / section_
|
||
assignment_override / replaced_auto_unit / skipped_collided_auto_units /
|
||
skipped_reason) — None / False / [] when no override CLI used.
|
||
|
||
IMP-39 u3 (issue #68) additive fields :
|
||
- ``ranking_sort_policy`` : full policy dict from
|
||
``load_ranking_sort_policy()`` (cached). Forwards the single-source
|
||
ordering contract (label_priority map + tie_break_axes) to the Step 9
|
||
payload so the frontend (``designAgentApi.ts``) can mirror the backend
|
||
sort without re-implementing the policy locally. u4 wires consumption.
|
||
- ``sorted_candidate_evidence`` : explicit alias of the policy-sorted
|
||
``selection_trace["candidates"]`` list. Identical contents to
|
||
``candidate_evidence`` (u2 sorted the underlying ``judgments`` window
|
||
before the selector loop appended ``trace["candidates"]``), but the
|
||
explicit name documents the post-u2 contract for the frontend.
|
||
"""
|
||
unit_id = "+".join(unit.source_section_ids)
|
||
|
||
has_v4 = bool(unit.v4_candidates)
|
||
candidate_status = "ok" if has_v4 else "no_non_reject_v4_candidate"
|
||
application_status = "ok" if has_v4 else "no_v4_candidate"
|
||
current_default = unit.frame_template_id if has_v4 else None
|
||
|
||
# IMP-39 u3 (issue #68) — forward the single-source ranking policy to the
|
||
# Step 9 per-unit payload. ``load_ranking_sort_policy()`` is module-cached
|
||
# (``_RANKING_SORT_POLICY_CACHE``), so the per-unit call is O(1) after
|
||
# first invocation. The full policy dict (not just ``policy_type``) is
|
||
# forwarded so the frontend can mirror label_priority + tie_break_axes
|
||
# without re-declaring the contract locally.
|
||
ranking_sort_policy = load_ranking_sort_policy()
|
||
|
||
# IMP-06 blocker-fix (Codex #13 Blocker 3 / #16) — plan-aware additive
|
||
# fields. additive = pre-IMP-06 readers (no override CLI used) see
|
||
# position=None / assignment_source=None / section_assignment_override
|
||
# =False / replaced_auto_unit=None / skipped_collided_auto_units=[] /
|
||
# skipped_reason=None — i.e. byte-identical absent overrides.
|
||
plan_position = plan_record.get("position") if plan_record else None
|
||
plan_assignment_source = plan_record.get("assignment_source") if plan_record else None
|
||
plan_section_override = bool(plan_record.get("section_assignment_override")) if plan_record else False
|
||
plan_replaced_auto = plan_record.get("replaced_auto_unit") if plan_record else None
|
||
plan_skipped_collided = list(plan_record.get("skipped_collided_auto_units") or []) if plan_record else []
|
||
plan_skipped_reason = plan_record.get("skipped_reason") if plan_record else None
|
||
|
||
app_candidates = _application_candidates_for_unit(unit)
|
||
v4_all_judgments_list = _v4_all_judgments_for_unit(v4_all_for_unit)
|
||
|
||
return {
|
||
"unit_id": unit_id,
|
||
"layout_preset": layout_preset,
|
||
"layout_candidates": layout_candidates_list,
|
||
"region_layout_candidates": zone_plan.get("region_layout_candidates", []),
|
||
"display_strategy_candidates": zone_plan.get("display_strategy_candidates", []),
|
||
"candidate_status": candidate_status,
|
||
"application_status": application_status,
|
||
"current_default_candidate": current_default,
|
||
"selected_v4_rank": unit.v4_rank,
|
||
"selection_path": unit.selection_path,
|
||
"fallback_used": bool(unit.selection_path and "fallback" in unit.selection_path),
|
||
"fallback_reason": unit.fallback_reason,
|
||
# IMP-05 L2 (Codex #10 D4 / #16 idea A) — Step 9 per-unit candidate evidence.
|
||
# candidate_evidence is the primary field for future frontend / AI consumers.
|
||
# fallback_chain is kept as a compat alias for any pre-IMP-05 reader.
|
||
"candidate_evidence": selection_trace.get("candidates", []),
|
||
"fallback_chain": selection_trace.get("candidates", []), # compat alias; prefer candidate_evidence
|
||
"v4_candidates": [
|
||
{
|
||
"template_id": c.template_id,
|
||
"frame_id": c.frame_id,
|
||
"frame_number": c.frame_number,
|
||
"v4_rank": c.v4_rank,
|
||
"confidence": c.confidence,
|
||
"label": c.label,
|
||
}
|
||
for c in unit.v4_candidates
|
||
],
|
||
# Step 7-A axis 보강 (사용자 lock 2026-05-08) — frontend UI 가 reject
|
||
# 포함 모든 V4 후보를 시각 차별 (회색) 로 보여줄 수 있도록 trace.
|
||
# length = 0~32. label 별 count : v4_candidates 는 non-reject only,
|
||
# v4_all_judgments 는 reject 포함.
|
||
# catalog_registered = frame_contracts.yaml 에 contract 있는지 여부.
|
||
# false 면 사용자가 override 시도해도 Step 7-A 가 skip (render path 미연결).
|
||
# IMP-11 D-2 (u1) : per-candidate min_height_px added (None when unregistered).
|
||
"v4_all_judgments": v4_all_judgments_list,
|
||
"application_candidates": app_candidates,
|
||
# IMP-06 blocker-fix (Codex #13 Blocker 3 / #16) — plan-aware
|
||
# additive fields. None / False / [] when no override CLI used.
|
||
"position": plan_position,
|
||
"assignment_source": plan_assignment_source,
|
||
"section_assignment_override": plan_section_override,
|
||
"replaced_auto_unit": plan_replaced_auto,
|
||
"skipped_collided_auto_units": plan_skipped_collided,
|
||
"skipped_reason": plan_skipped_reason,
|
||
# IMP-39 u3 (issue #68) — single-source ranking policy forwarded to
|
||
# frontend so backend selector "rank 1" and frontend
|
||
# ``frame_candidates[0]`` share one ordering contract. Additive only;
|
||
# pre-u3 readers ignore both keys.
|
||
"ranking_sort_policy": ranking_sort_policy,
|
||
"sorted_candidate_evidence": selection_trace.get("candidates", []),
|
||
}
|
||
|
||
|
||
# ─── Main entry ────────────────────────────────────────────────
|
||
|
||
|
||
class Step0PreflightError(RuntimeError):
|
||
"""IMP-92 u4 — Step 0 AI preflight fail-fast surface.
|
||
|
||
Raised at boot when ``settings.ai_fallback_enabled`` is True and the
|
||
Anthropic API ping reveals a persistent setup problem (invalid API
|
||
key, invalid model ID, billing / permission denied). Transient errors
|
||
(429 / 5xx) do NOT fail boot — they are recorded as ``"transient"``
|
||
in the Step 0 artifact and the pipeline proceeds; the in-pipeline
|
||
retry layer + u2 operational classifier handle them downstream.
|
||
"""
|
||
|
||
|
||
def _run_step0_ai_preflight() -> dict:
|
||
"""IMP-92 u4 — Boot-time AI fallback preflight ping (gated).
|
||
|
||
When ``settings.ai_fallback_enabled`` is False (default), returns
|
||
``{"status": "skipped", "reason": "ai_fallback_disabled", ...}``
|
||
without instantiating ``anthropic.Anthropic`` — preserves the PZ-1
|
||
AI=0 normal path and the ``feedback_demo_env_toggle_policy``
|
||
default-OFF contract (no API call on normal runs).
|
||
|
||
When enabled, issues a single 1-token Anthropic ``messages.create``
|
||
to validate the configured ``(ai_fallback_model, anthropic_api_key)``
|
||
pair. Persistent setup errors raise ``Step0PreflightError`` so the
|
||
pipeline fails fast at boot rather than at first AI repair attempt.
|
||
Transient errors are recorded as ``"transient"`` and the pipeline
|
||
continues.
|
||
|
||
Setup errors (fail-fast):
|
||
* ``anthropic.AuthenticationError`` (401) — invalid API key
|
||
* ``anthropic.PermissionDeniedError`` (403) — billing / permission
|
||
* ``anthropic.NotFoundError`` (404) — invalid model ID
|
||
* generic ``anthropic.APIStatusError`` (402) — billing / payment
|
||
required (Anthropic surfaces 402 without a typed subclass;
|
||
dispatched here by HTTP status code, mirroring u2
|
||
``classify_operational_error``).
|
||
|
||
Transient (record + continue):
|
||
* ``anthropic.RateLimitError`` (429)
|
||
* ``anthropic.InternalServerError`` (5xx)
|
||
* generic ``anthropic.APIStatusError`` with HTTP 429 / 5xx
|
||
"""
|
||
import anthropic
|
||
|
||
from src.config import settings as _settings
|
||
|
||
if not _settings.ai_fallback_enabled:
|
||
return {
|
||
"status": "skipped",
|
||
"reason": "ai_fallback_disabled",
|
||
"model": _settings.ai_fallback_model,
|
||
}
|
||
try:
|
||
client = anthropic.Anthropic(
|
||
api_key=_settings.anthropic_api_key,
|
||
timeout=_settings.ai_fallback_timeout_s,
|
||
)
|
||
client.messages.create(
|
||
model=_settings.ai_fallback_model,
|
||
max_tokens=1,
|
||
messages=[{"role": "user", "content": "ping"}],
|
||
)
|
||
except (
|
||
anthropic.AuthenticationError,
|
||
anthropic.PermissionDeniedError,
|
||
anthropic.NotFoundError,
|
||
) as exc:
|
||
raise Step0PreflightError(
|
||
f"Anthropic API preflight failed for model "
|
||
f"{_settings.ai_fallback_model!r}: "
|
||
f"{type(exc).__name__}: {exc}. "
|
||
"Check ANTHROPIC_API_KEY / ai_fallback_model in .env."
|
||
) from exc
|
||
except (anthropic.RateLimitError, anthropic.InternalServerError) as exc:
|
||
return {
|
||
"status": "transient",
|
||
"model": _settings.ai_fallback_model,
|
||
"transient_error": f"{type(exc).__name__}: {exc}",
|
||
}
|
||
except anthropic.APIStatusError as exc:
|
||
# IMP-92 u4 — fall back to HTTP status code dispatch when the SDK
|
||
# surfaces a setup error as the generic ``APIStatusError`` instead
|
||
# of a typed subclass. Mirrors u2 ``classify_operational_error``
|
||
# so HTTP 402 (Payment Required / billing) becomes a fail-fast
|
||
# Step0PreflightError, matching the issue body's explicit
|
||
# operational contract.
|
||
status_code = getattr(exc, "status_code", None)
|
||
if status_code is None:
|
||
status_code = getattr(getattr(exc, "response", None), "status_code", None)
|
||
if status_code == 429 or (status_code is not None and 500 <= status_code < 600):
|
||
return {
|
||
"status": "transient",
|
||
"model": _settings.ai_fallback_model,
|
||
"transient_error": f"{type(exc).__name__}: {exc}",
|
||
}
|
||
raise Step0PreflightError(
|
||
f"Anthropic API preflight failed for model "
|
||
f"{_settings.ai_fallback_model!r}: "
|
||
f"HTTP {status_code} {type(exc).__name__}: {exc}. "
|
||
"Check ANTHROPIC_API_KEY / ai_fallback_model in .env."
|
||
) from exc
|
||
return {
|
||
"status": "passed",
|
||
"model": _settings.ai_fallback_model,
|
||
}
|
||
|
||
|
||
def _resolve_slide_css_from_frontmatter(mdx_source_text: str) -> Optional[str]:
|
||
"""IMP-45 (#74) u4 — minimal frontmatter probe for ``slide_overrides.css``.
|
||
|
||
Targeted re-parse of the same YAML frontmatter block that
|
||
:func:`parse_mdx` reads at line 415-418 — extracts only the
|
||
nested ``slide_overrides.css`` string when present. Kept inline
|
||
rather than routed through :func:`src.mdx_normalizer.normalize_mdx_content`
|
||
so Step 13 render does not depend on the Stage 0 normalize adapter
|
||
(project lock 2026-05-08).
|
||
|
||
Mirrors the validation rules in
|
||
:func:`src.mdx_normalizer._extract_slide_overrides` (u2) :
|
||
|
||
- No frontmatter / unparseable YAML / non-mapping → ``None``.
|
||
- Missing ``slide_overrides`` mapping → ``None``.
|
||
- ``slide_overrides.css`` non-string or empty → ``None``.
|
||
- ``slide_overrides.css`` non-empty ``str`` → that string.
|
||
"""
|
||
fm_match = re.match(r"^---\n(.*?)\n---\n", mdx_source_text, re.DOTALL)
|
||
if fm_match is None:
|
||
return None
|
||
try:
|
||
fm = yaml.safe_load(fm_match.group(1))
|
||
except yaml.YAMLError:
|
||
return None
|
||
if not isinstance(fm, dict):
|
||
return None
|
||
overrides = fm.get("slide_overrides")
|
||
if not isinstance(overrides, dict):
|
||
return None
|
||
css = overrides.get("css")
|
||
if isinstance(css, str) and css:
|
||
return css
|
||
return None
|
||
|
||
|
||
def run_phase_z2_mvp1(
|
||
mdx_path: Path,
|
||
run_id: Optional[str] = None,
|
||
*,
|
||
override_layout: Optional[str] = None,
|
||
override_frames: Optional[dict[str, str]] = None,
|
||
override_zone_geometries: Optional[dict[str, dict]] = None,
|
||
override_section_assignments: Optional[dict[str, list[str]]] = None,
|
||
override_image_overrides: Optional[dict[str, dict]] = None,
|
||
override_slide_css: Optional[str] = None,
|
||
reuse_from: Optional[str] = None,
|
||
) -> Path:
|
||
"""MVP-1.5b entry — single slide + composition planner v0 + 8 preset vocabulary.
|
||
|
||
Pipeline :
|
||
parse_mdx → align_sections_to_v4_granularity → plan_composition →
|
||
mapper per unit → render slide_base + frame partial → Selenium check
|
||
|
||
User overrides (Step 7-A axis, 2026-05-08) :
|
||
override_layout : 자동 결정된 layout_preset 을 사용자 선택값으로 강제 (8 preset 중 하나).
|
||
override_frames : {unit_id: template_id} — 자동 결정된 frame template 을 사용자 선택값
|
||
으로 강제. unit_id = "+".join(source_section_ids) (e.g., "03-1"
|
||
또는 "03-1+03-2"). 매칭 unit 의 v4_candidates 에 있는 entry 면
|
||
그 entry 의 score / label 도 함께 갱신. 없으면 template_id 만 변경.
|
||
override_image_overrides : {image_id: {x, y, w, h}} — IMP-51 (#79) u5 axis.
|
||
image_id = stable id stamped on user-content `<img>` tags by
|
||
``src/image_id_stamper.py`` (u4). x/y/w/h are percent-of-slide
|
||
coordinates (0–100, slide-absolute). Forward-compat kwarg: the
|
||
render-time CSS injection that consumes this mapping lands in
|
||
u7; until u7 wires the consumer, accepting the kwarg keeps the
|
||
backend contract (KNOWN_AXES u1 + Vite allowlist u2 + typed
|
||
client u3 + stamper u4) end-to-end addressable from CLI without
|
||
diverging the function signature.
|
||
override_slide_css : Optional slide-level CSS string — IMP-45 (#74) u4 axis.
|
||
Marker-wrapped <style> block injected into ``final.html``
|
||
at Step 13 via :func:`src.slide_css_injector.inject_slide_css`
|
||
(mirrors the image_id_stamper injection contract). When
|
||
``None``, the MDX frontmatter ``slide_overrides.css`` value
|
||
is used as fallback via
|
||
:func:`_resolve_slide_css_from_frontmatter`. Kwarg always
|
||
wins over frontmatter when provided. The CLI surface
|
||
(``--override-slide-css`` inline + ``--slide-css-file``
|
||
PATH) lands in u5.
|
||
|
||
Incremental rerun (IMP-43 #72, u5) :
|
||
reuse_from : Optional PREV_RUN_ID. When set, Steps 0/1/2/5/6 artifacts
|
||
are copied from ``RUNS_DIR / PREV_RUN_ID / phase_z2``
|
||
and the in-memory state (sections, units, layout_preset,
|
||
comp_debug, v4_fallback_traces, slide_title/footer,
|
||
stage0_*, v4_evidence, ai_preflight) is rehydrated
|
||
from ``_reuse_snapshot.json`` via the u4 helpers,
|
||
wrapped by u4b's fail-closed contract. Step 7+ then
|
||
re-executes against ``override_frames`` in this new
|
||
run_dir. ``None`` preserves the legacy single-pass
|
||
behaviour (Steps 0-6 derive state from scratch).
|
||
The post-merge u1 guard at the CLI surface rejects
|
||
any layout / zone_geometry / zone_section / image
|
||
override under ``--reuse-from`` so only frame
|
||
overrides reach this kwarg's reuse branch.
|
||
"""
|
||
mdx_path = Path(mdx_path)
|
||
if run_id is None:
|
||
run_id = time.strftime("%Y%m%d_%H%M%S") + "_phase_z2"
|
||
run_dir = RUNS_DIR / run_id / "phase_z2"
|
||
run_dir.mkdir(parents=True, exist_ok=True) # step artifacts 위해 미리 생성
|
||
|
||
print(f"[Phase Z-2 MVP-1.5b] start — mdx={mdx_path.name}, run_id={run_id}")
|
||
|
||
# IMP-43 (#72) u5 — Steps 0/1/2/5/6 entry-point branch.
|
||
# ``reuse_from is None`` = normal pipeline (Steps 0-6 derive state).
|
||
# ``reuse_from is not None`` = restore Steps 0/1/2/5/6 state from
|
||
# prev_run snapshot via the u4 helpers wrapped by u4b's fail-closed
|
||
# contract, then fall through to the shared Step 7+ block below.
|
||
# The post-merge u1 guard has already rejected any layout /
|
||
# zone_geometry / zone_section / image override on the reuse path,
|
||
# so only --override-frame (handled at the Step 7-A axis below the
|
||
# branch) survives into this code path.
|
||
if reuse_from is None:
|
||
# ─── Step 0: 사전 준비 (precondition snapshot) ───
|
||
# IMP-92 u4 — boot-time AI fallback preflight (gated on
|
||
# settings.ai_fallback_enabled; default OFF = skipped, no API call).
|
||
# Persistent setup errors raise Step0PreflightError before Step 1.
|
||
ai_preflight = _run_step0_ai_preflight()
|
||
_write_step_artifact(
|
||
run_dir, 0, "preconditions",
|
||
data={
|
||
"v4_source": str(V4_RESULT_PATH.relative_to(PROJECT_ROOT)),
|
||
"templates_dir": str(TEMPLATE_DIR.relative_to(PROJECT_ROOT)),
|
||
"assets_source_base": str(ASSETS_SOURCE_BASE.relative_to(PROJECT_ROOT)),
|
||
"frame_contracts_loaded": len(load_frame_contracts()),
|
||
"frame_contracts_template_ids": sorted(load_frame_contracts().keys()),
|
||
"v4_label_to_phase_z_status": V4_LABEL_TO_PHASE_Z_STATUS,
|
||
"mvp1_allowed_statuses": sorted(MVP1_ALLOWED_STATUSES),
|
||
"ai_preflight": ai_preflight,
|
||
},
|
||
step_status="partial",
|
||
pipeline_path_connected=True,
|
||
inputs=[
|
||
"templates/phase_z2/catalog/frame_contracts.yaml",
|
||
"tests/matching/v4_full32_result.yaml",
|
||
"templates/phase_z2/families/*.html",
|
||
"figma_to_html_agent/blocks/",
|
||
],
|
||
outputs=["step00_preconditions.json"],
|
||
note=(
|
||
"frame_contracts.yaml 에 등록된 frame 만 mapping 가능. "
|
||
"V4 결과의 32 frame 중 다수 (F11/F14/F18 등) 미등록 — Step 0 ⚠ partial."
|
||
),
|
||
)
|
||
|
||
# ─── Step 1: MDX 업로드 ───
|
||
mdx_source_text = mdx_path.read_text(encoding="utf-8")
|
||
(run_dir / "steps").mkdir(exist_ok=True)
|
||
(run_dir / "steps" / "step01_mdx_source.md").write_text(mdx_source_text, encoding="utf-8")
|
||
_write_step_artifact(
|
||
run_dir, 1, "mdx_upload",
|
||
data={
|
||
"mdx_path": str(mdx_path),
|
||
"run_id": run_id,
|
||
"run_dir": str(run_dir),
|
||
"mdx_source_size_bytes": len(mdx_source_text.encode("utf-8")),
|
||
"mdx_source_lines": mdx_source_text.count("\n") + 1,
|
||
},
|
||
step_status="done",
|
||
pipeline_path_connected=True,
|
||
inputs=[str(mdx_path)],
|
||
outputs=["step01_mdx_upload.json", "step01_mdx_source.md"],
|
||
note="MDX 원본 그대로 step01_mdx_source.md 에 복사.",
|
||
)
|
||
|
||
# 1. Parse MDX (V4 무관)
|
||
legacy_slide_title, legacy_sections, legacy_footer = parse_mdx(mdx_path)
|
||
# IMP-02 — Stage 0 chained adapter dispatch (default OFF canary).
|
||
# When env PHASE_Z_STAGE0_ADAPTER_ENABLED=1/true/yes the adapter chain
|
||
# (mdx_normalizer + section_parser) replaces legacy parse_mdx output;
|
||
# on any contract failure or exception, falls back to legacy with
|
||
# fallback_reason recorded in stage0_adapter_diagnostics.
|
||
# IMP-03 — 5-tuple return adds stage0_normalized_assets (Step 3 handoff).
|
||
(
|
||
slide_title,
|
||
sections,
|
||
slide_footer,
|
||
stage0_adapter_diagnostics,
|
||
stage0_normalized_assets,
|
||
) = _stage0_chained_adapter(
|
||
mdx_path, legacy_slide_title, legacy_sections, legacy_footer,
|
||
)
|
||
_adapter_tag = (
|
||
"adapter-used" if stage0_adapter_diagnostics["used"]
|
||
else f"legacy({stage0_adapter_diagnostics['fallback_reason'] or 'disabled'})"
|
||
)
|
||
print(f" parsed : title='{slide_title}', sections={len(sections)} "
|
||
f"({[s.section_id for s in sections]}), footer={'yes' if slide_footer else 'no'}, "
|
||
f"stage0={_adapter_tag}")
|
||
|
||
# ─── Step 2: MDX 정규화 ───
|
||
# orphans / details 필드는 schema lock — 빈 배열이라도 박혀야
|
||
# "검사 안 함" vs "없음" 구분 가능 (사용자 직설 2026-05-07).
|
||
# 실제 orphan / details 감지 로직은 별 axis (Step 2 보강).
|
||
_write_step_artifact(
|
||
run_dir, 2, "normalized",
|
||
data={
|
||
"slide_title": slide_title,
|
||
"slide_footer": slide_footer,
|
||
"sections_count": len(sections),
|
||
"sections": [
|
||
{
|
||
"section_id": s.section_id,
|
||
"section_num": s.section_num,
|
||
"title": s.title,
|
||
"raw_content_length": len(s.raw_content),
|
||
"raw_content": s.raw_content,
|
||
}
|
||
for s in sections
|
||
],
|
||
"orphans": [], # schema lock — 중목차에 안 속한 텍스트 (감지 미구현)
|
||
"details": [], # schema lock — <details> 팝업 콘텐츠 (감지 미구현)
|
||
# IMP-02 — additive only. enabled/used/fallback_reason + id reconstruction
|
||
# trace + count diff. Out of scope: V4 / align / composition.
|
||
"stage0_adapter_diagnostics": stage0_adapter_diagnostics,
|
||
# IMP-03 — Step 3 handoff (slide-level rich asset list).
|
||
# env=OFF / fallback 시 모든 list 가 비어 있음. consumer = Step 3
|
||
# rich extractor (PHASE_Z_STEP3_RICH_OBJECTS_ENABLED canary).
|
||
"stage0_normalized_assets": stage0_normalized_assets,
|
||
},
|
||
step_status="partial",
|
||
pipeline_path_connected=True,
|
||
inputs=["step01_mdx_source.md"],
|
||
outputs=["step02_normalized.json"],
|
||
note=(
|
||
"parse_mdx 결과: title / sections / footer 분리 + raw_content 보존. "
|
||
"heading tree 미생성, orphan / details 감지 미완 (Step 2 ⚠ partial — 별 axis). "
|
||
"orphans / details 필드는 schema lock — 빈 배열이라도 'detection 미수행' marker. "
|
||
"stage0_adapter_diagnostics = IMP-02 chained adapter trace (default OFF canary). "
|
||
"stage0_normalized_assets = IMP-03 Step 3 slide-level handoff (popups/images/tables list)."
|
||
),
|
||
)
|
||
|
||
# 2. Load V4
|
||
v4 = load_v4_result()
|
||
|
||
# 3. Align sections to V4 granularity (### drill if needed).
|
||
# IMP-08 B-3 / Stage 5 R2 : forward override target ids so sub-id
|
||
# drag/drop targets force-drill their parent section even when V4
|
||
# carries the parent exact key (deterministic drag/drop addressing).
|
||
_override_target_sids: list[str] = []
|
||
if override_section_assignments:
|
||
for _sids in override_section_assignments.values():
|
||
for _sid in _sids:
|
||
if isinstance(_sid, str) and _sid:
|
||
_override_target_sids.append(_sid)
|
||
sections = align_sections_to_v4_granularity(
|
||
sections,
|
||
v4,
|
||
override_target_section_ids=_override_target_sids or None,
|
||
)
|
||
print(f" aligned : sections={len(sections)} ({[s.section_id for s in sections]})")
|
||
|
||
# ─── Step 5: V4 매칭 evidence (non-reject max-6 후보 list — 사용자 lock 2026-05-08) ───
|
||
v4_evidence_list = []
|
||
for s in sections:
|
||
candidates = lookup_v4_candidates(v4, s.section_id)
|
||
v4_evidence_list.append({
|
||
"section_id": s.section_id,
|
||
"v4_candidates": [
|
||
{
|
||
"template_id": c.template_id,
|
||
"frame_id": c.frame_id,
|
||
"frame_number": c.frame_number,
|
||
"confidence": c.confidence,
|
||
"label": c.label,
|
||
}
|
||
for c in candidates
|
||
],
|
||
"candidate_status": "ok" if candidates else "no_non_reject_v4_candidate",
|
||
})
|
||
_write_step_artifact(
|
||
run_dir, 5, "v4_evidence",
|
||
data={
|
||
"v4_source": str(V4_RESULT_PATH.relative_to(PROJECT_ROOT)),
|
||
"aligned_section_ids": [s.section_id for s in sections],
|
||
"evidence_per_section": v4_evidence_list,
|
||
},
|
||
step_status="done",
|
||
pipeline_path_connected=True,
|
||
inputs=["step02_normalized.json", "tests/matching/v4_full32_result.yaml"],
|
||
outputs=["step05_v4_evidence.json"],
|
||
note=(
|
||
"V4 non-reject max-6 후보 list (Step 9 application_plan input). "
|
||
"raw 32 entry 는 tests/matching/v4_full32_result.yaml 에 영속. "
|
||
"candidate_status='ok' = 후보 1개 이상 / 'no_non_reject_v4_candidate' = "
|
||
"0개 (Step 9 fallback path 입력). "
|
||
"Step 6 plan_composition() 은 lookup_v4_match() (rank-1) 그대로 사용 — "
|
||
"backward compat (Step 6-A axis 까지)."
|
||
),
|
||
)
|
||
|
||
# 4. Composition planner v0 — replaces per-section + select_layout_preset.
|
||
# candidate (separate / parent_merged) → score → greedy non-overlapping select →
|
||
# layout preset (count-based v0).
|
||
section_content_by_id = {s.section_id: s.raw_content for s in sections}
|
||
# IMP-08 B-3 : sub-section ordinal id -> legacy V4 key aliases (e.g. "04-2.1").
|
||
# Empty list for canonical (top-level) sections — U1 baseline path is exact-only.
|
||
section_alias_by_id: dict[str, list] = {
|
||
s.section_id: list(getattr(s, "v4_alias_keys", []) or []) for s in sections
|
||
}
|
||
v4_fallback_traces: dict[str, dict] = {}
|
||
|
||
def lookup_fn(sid: str) -> Optional[V4Match]:
|
||
match, trace = lookup_v4_match_with_fallback(
|
||
v4,
|
||
sid,
|
||
raw_content=section_content_by_id.get(sid),
|
||
alias_keys=section_alias_by_id.get(sid),
|
||
)
|
||
v4_fallback_traces[sid] = trace
|
||
return match
|
||
|
||
# Step 6-A axis (사용자 lock 2026-05-08) — V4 raw dict 흡수 fn.
|
||
# composition module 은 V4 yaml shape 모름. 본 fn 만 통해 후보 list 받음.
|
||
def candidates_lookup_fn(sid: str) -> list[V4Match]:
|
||
return lookup_v4_candidates(v4, sid, alias_keys=section_alias_by_id.get(sid))
|
||
|
||
units, layout_preset, comp_debug = plan_composition(
|
||
sections, lookup_fn, V4_LABEL_TO_PHASE_Z_STATUS, MVP1_ALLOWED_STATUSES,
|
||
capacity_fit_fn=compute_capacity_fit,
|
||
v4_candidates_lookup_fn=candidates_lookup_fn,
|
||
)
|
||
comp_debug["v4_fallback_selections"] = list(v4_fallback_traces.values())
|
||
# IMP-05 L3 (Codex #10 D4) — Step 20 qualifier fields (additive only, no top-level enum change).
|
||
# `fallback_selection_count` = number of sections where rank-2/3 was promoted.
|
||
# `selection_paths` = per-section selection_path summary (rank_1 / rank_N_fallback / chain_exhausted).
|
||
# Top-level slide status enum (PASS / PARTIAL_COVERAGE / ...) remains stable.
|
||
_imp05_selection_paths = [
|
||
{
|
||
"section_id": sid,
|
||
"selection_path": t.get("selection_path"),
|
||
"selected_rank": t.get("selected_rank"),
|
||
"selected_template_id": t.get("selected_template_id"),
|
||
"fallback_trigger": t.get("fallback_reason") if t.get("fallback_used") else None,
|
||
}
|
||
for sid, t in v4_fallback_traces.items()
|
||
]
|
||
comp_debug["v4_fallback_summary"] = {
|
||
"fallback_used_count": sum(1 for t in v4_fallback_traces.values() if t.get("fallback_used")),
|
||
"fallback_selection_count": sum(1 for t in v4_fallback_traces.values() if t.get("fallback_used")),
|
||
"chain_exhausted_count": sum(
|
||
1 for t in v4_fallback_traces.values()
|
||
if t.get("selection_path") == "chain_exhausted"
|
||
),
|
||
"selection_paths": _imp05_selection_paths,
|
||
"policy": (
|
||
"IMP-05: rank-1 is kept when usable; rank-2/3 may be promoted only when "
|
||
"the earlier rank is not auto-renderable, has no catalog contract, or fails "
|
||
"capacity precheck. calculate_fit is not used."
|
||
),
|
||
}
|
||
|
||
# IMP-47B u12 — mixed direct+reject first-render admission.
|
||
# When initial plan_composition produces a viable layout but at least one
|
||
# section remains uncovered (typically chain_exhausted / reject), re-run
|
||
# with allow_provisional in the lookup + allow_provisional_fill=True so
|
||
# reject sections gain a provisional rank-1 V4Match and a last-resort
|
||
# provisional candidate fill. This admits the mixed direct+reject case
|
||
# to the AI repair path (IMP-47B u4/u5) on first render. Skipped under
|
||
# --override-section-assignments to preserve the operator's plan and
|
||
# mirror the IMP-30 u4 retry's section_assignment_plan gate. All-direct
|
||
# slides have no uncovered sections so this is a no-op. The all-reject
|
||
# case is still handled by the IMP-30 u4 retry block below (initial
|
||
# plan_composition returns units=[]).
|
||
if units and layout_preset is not None and not override_section_assignments:
|
||
_u12_covered_ids: set[str] = set()
|
||
for _u in units:
|
||
_u12_covered_ids.update(_u.source_section_ids)
|
||
_u12_uncovered_ids = [
|
||
s.section_id for s in sections if s.section_id not in _u12_covered_ids
|
||
]
|
||
if _u12_uncovered_ids:
|
||
def _lookup_fn_mixed_admission(sid: str) -> Optional[V4Match]:
|
||
match, trace = lookup_v4_match_with_fallback(
|
||
v4,
|
||
sid,
|
||
raw_content=section_content_by_id.get(sid),
|
||
alias_keys=section_alias_by_id.get(sid),
|
||
allow_provisional=True,
|
||
)
|
||
v4_fallback_traces[sid] = trace
|
||
return match
|
||
|
||
units_mixed, layout_preset_mixed, _comp_debug_mixed = plan_composition(
|
||
sections,
|
||
_lookup_fn_mixed_admission,
|
||
V4_LABEL_TO_PHASE_Z_STATUS,
|
||
MVP1_ALLOWED_STATUSES,
|
||
capacity_fit_fn=compute_capacity_fit,
|
||
v4_candidates_lookup_fn=candidates_lookup_fn,
|
||
allow_provisional_fill=True,
|
||
)
|
||
if units_mixed and layout_preset_mixed is not None:
|
||
units = units_mixed
|
||
layout_preset = layout_preset_mixed
|
||
comp_debug["v4_fallback_selections"] = list(v4_fallback_traces.values())
|
||
comp_debug["imp47b_u12_mixed_admission"] = {
|
||
"applied": True,
|
||
"uncovered_before": _u12_uncovered_ids,
|
||
"result_unit_count": len(units_mixed),
|
||
"result_layout_preset": layout_preset_mixed,
|
||
}
|
||
|
||
# ── Step 7-A axis : layout override ──
|
||
# 사용자가 LayoutPanel 에서 다른 preset 을 선택했을 때 자동 결정값을 강제 변경.
|
||
# 길이 mismatch (positions count vs unit count) 는 zone loop 의 fallback (zone_{i})
|
||
# 으로 처리됨. 알 수 없는 preset 이면 ValueError.
|
||
auto_layout_preset = layout_preset
|
||
layout_override_applied = False
|
||
if override_layout is not None and override_layout != layout_preset:
|
||
if override_layout not in LAYOUT_PRESETS:
|
||
raise ValueError(
|
||
f"--override-layout '{override_layout}' is not a known preset. "
|
||
f"Available: {sorted(LAYOUT_PRESETS.keys())}"
|
||
)
|
||
print(
|
||
f" [override] layout_preset: {layout_preset} → {override_layout}",
|
||
file=sys.stderr,
|
||
)
|
||
layout_preset = override_layout
|
||
layout_override_applied = True
|
||
|
||
# IMP-06 (#6 / Codex #6/#7/#10/#11/#12 lock) — zone-section assignment override.
|
||
# Applied AFTER final layout_preset resolution. ZONE_ID = layout positions.
|
||
# The helper validates unknown zone ids / unknown section ids and builds a
|
||
# `position_assignment_plan`. Immediately below, Stage 4 Part 2 rebuilds the
|
||
# `units` list aligned with that plan: cli_override entries synthesize a
|
||
# CompositionUnit, auto entries reuse the original planner unit, and empty/
|
||
# collision-skipped entries become None placeholders. The downstream
|
||
# zones_data / debug_zones loop then handles None entries by emitting an
|
||
# explicit empty zone record (template_id="__empty__") so the slide grid
|
||
# preserves position identity without distorting layout allocation.
|
||
section_assignment_plan: Optional[list[dict]] = None
|
||
section_assignment_summary: Optional[dict] = None
|
||
if override_section_assignments and layout_preset is not None:
|
||
positions = list(LAYOUT_PRESETS[layout_preset]["positions"])
|
||
# Validate ZONE_IDs against active layout positions (fail-fast).
|
||
unknown_zones = [z for z in override_section_assignments if z not in positions]
|
||
if unknown_zones:
|
||
raise ValueError(
|
||
f"--override-section-assignment unknown ZONE_ID(s) {unknown_zones} for "
|
||
f"layout '{layout_preset}'. Available positions: {positions}"
|
||
)
|
||
# Validate section_ids against aligned sections (fail-fast).
|
||
aligned_section_ids = {s.section_id for s in sections}
|
||
sections_by_id = {s.section_id: s for s in sections}
|
||
unknown_sections: list[str] = []
|
||
for zid, sids in override_section_assignments.items():
|
||
for sid in sids:
|
||
if sid not in aligned_section_ids:
|
||
unknown_sections.append(sid)
|
||
if unknown_sections:
|
||
raise ValueError(
|
||
f"--override-section-assignment unknown section_id(s) {unknown_sections}. "
|
||
f"Aligned sections: {sorted(aligned_section_ids)}"
|
||
)
|
||
section_assignment_plan, section_assignment_summary = _build_position_assignment_plan(
|
||
units=units,
|
||
positions=positions,
|
||
override_section_assignments=override_section_assignments,
|
||
sections_by_id=sections_by_id,
|
||
override_frames=override_frames,
|
||
v4=v4,
|
||
)
|
||
comp_debug["section_assignment_plan"] = section_assignment_plan
|
||
comp_debug["section_assignment_summary"] = section_assignment_summary
|
||
print(
|
||
f" [override] section_assignment applied: "
|
||
f"{section_assignment_summary['applied_count']} position(s), "
|
||
f"{section_assignment_summary['skipped_count']} skipped, "
|
||
f"uncovered_sections={section_assignment_summary['uncovered_section_ids']}",
|
||
file=sys.stderr,
|
||
)
|
||
|
||
# Stage 4 blocker-fix (Codex #13/#14/#15/#16/#17) — rebuild units as a pure
|
||
# `list[CompositionUnit]` (renderable only, no None). Position-aware truth
|
||
# lives in `render_records` (built after frame_overrides apply) per Codex
|
||
# internal contract: units = canonical renderable list, render_records =
|
||
# canonical per-position view including empty/skipped entries.
|
||
from src.phase_z2_composition import CompositionUnit
|
||
plan_units: list = []
|
||
# Maintain ordered alignment with section_assignment_plan for the
|
||
# render_records build step below: plan_unit_by_position[pos] = unit | None.
|
||
plan_unit_by_position: dict[str, object] = {}
|
||
for entry in section_assignment_plan:
|
||
assignment_source = entry["assignment_source"]
|
||
pos = entry["position"]
|
||
if assignment_source == "cli_override" and entry["template_id"] is not None:
|
||
sids = entry["source_section_ids"]
|
||
raw_content_parts = []
|
||
title_parts = []
|
||
for sid in sids:
|
||
sect = sections_by_id.get(sid)
|
||
if sect is None:
|
||
continue
|
||
raw_content_parts.append(sect.raw_content or "")
|
||
if sect.title:
|
||
title_parts.append(sect.title)
|
||
contract = get_contract(entry["template_id"])
|
||
contract_frame_id = (contract or {}).get("frame_id") or ""
|
||
override_unit = CompositionUnit(
|
||
source_section_ids=list(sids),
|
||
merge_type="cli_override",
|
||
frame_template_id=entry["template_id"],
|
||
frame_id=str(contract_frame_id),
|
||
frame_number=0,
|
||
confidence=0.0,
|
||
label="use_as_is",
|
||
phase_z_status="matched_zone",
|
||
raw_content="\n\n".join(raw_content_parts),
|
||
title=" / ".join(title_parts) if title_parts else "+".join(sids),
|
||
v4_rank=None,
|
||
selection_path="cli_override",
|
||
fallback_reason=None,
|
||
score=0.0,
|
||
rationale={
|
||
"section_assignment_override": entry["section_assignment_override"],
|
||
"replaced_auto_unit": entry["replaced_auto_unit"],
|
||
},
|
||
)
|
||
plan_units.append(override_unit)
|
||
plan_unit_by_position[pos] = override_unit
|
||
elif assignment_source == "auto":
|
||
# Find original auto unit by source_section_ids.
|
||
matched = None
|
||
for u in units:
|
||
if list(u.source_section_ids) == entry["source_section_ids"]:
|
||
matched = u
|
||
break
|
||
if matched is not None:
|
||
plan_units.append(matched)
|
||
plan_unit_by_position[pos] = matched
|
||
else:
|
||
# Unexpected — auto plan entry without a matching original unit.
|
||
plan_unit_by_position[pos] = None
|
||
else:
|
||
# empty / collision-skipped — NO None in units list, but the position
|
||
# is preserved in plan_unit_by_position so render_records can emit
|
||
# an empty zone record below (after frame_overrides apply).
|
||
plan_unit_by_position[pos] = None
|
||
units = plan_units
|
||
|
||
if not units or layout_preset is None:
|
||
# IMP-30 u4 — first-render invariant. The pre-u4 path here was
|
||
# `sys.exit(1)` after writing error.json. That violated the invariant
|
||
# ("final.html + Step 20 slide_status MUST be written for every input
|
||
# where Step 0~5 succeed") whenever V4 evidence for any section was
|
||
# restructure/reject (chain_exhausted) or missing (no_v4_section /
|
||
# empty_v4_judgments).
|
||
#
|
||
# Recovery has two phases:
|
||
# Phase A — provisional retry (u1 + u3 opt-in). Re-run plan_composition
|
||
# with allow_provisional=True (in lookup_fn) and allow_provisional_fill
|
||
# =True. Synthesizes rank-1 provisional V4Match on chain_exhausted
|
||
# (u1) and last-resort-fills uncovered sections with provisional
|
||
# candidates (u3). Skipped when the CLI override path was used —
|
||
# re-running plan_composition there would discard the override.
|
||
# Phase B — terminal empty-shell. If retry still yields zero units
|
||
# (true "no rank-1 V4 anywhere" case, or override path with no
|
||
# resolvable assignments), synthesize a single placeholder
|
||
# CompositionUnit with frame_template_id="__empty__", layout_preset
|
||
# ="single". The per-unit loop's __empty__ guard emits a placeholder
|
||
# zones_data / debug_zones record; final.html renders the slide
|
||
# base shell (title + footer + empty zone) so the first-render
|
||
# invariant holds. Provisional flag = True surfaces the "needs
|
||
# adaptation" signal (u5 zone class + u6 status qualifier).
|
||
provisional_recovered = False
|
||
if section_assignment_plan is None:
|
||
def _lookup_fn_provisional(sid: str) -> Optional[V4Match]:
|
||
match, trace = lookup_v4_match_with_fallback(
|
||
v4,
|
||
sid,
|
||
raw_content=section_content_by_id.get(sid),
|
||
alias_keys=section_alias_by_id.get(sid),
|
||
allow_provisional=True,
|
||
)
|
||
v4_fallback_traces[sid] = trace
|
||
return match
|
||
|
||
units_retry, layout_preset_retry, comp_debug_retry = plan_composition(
|
||
sections,
|
||
_lookup_fn_provisional,
|
||
V4_LABEL_TO_PHASE_Z_STATUS,
|
||
MVP1_ALLOWED_STATUSES,
|
||
capacity_fit_fn=compute_capacity_fit,
|
||
v4_candidates_lookup_fn=candidates_lookup_fn,
|
||
allow_provisional_fill=True,
|
||
)
|
||
comp_debug["imp30_u4_provisional_retry"] = {
|
||
"applied": True,
|
||
"result_unit_count": len(units_retry),
|
||
"result_layout_preset": layout_preset_retry,
|
||
"candidates_summary": comp_debug_retry.get("candidates_summary"),
|
||
}
|
||
if units_retry and layout_preset_retry is not None:
|
||
units = units_retry
|
||
layout_preset = layout_preset_retry
|
||
provisional_recovered = True
|
||
# v4_fallback_traces was overwritten by _lookup_fn_provisional;
|
||
# refresh the IMP-05 selection_paths telemetry so Step 20 reflects
|
||
# the actual selection (provisional_rank_1) rather than the stale
|
||
# chain_exhausted state from the first attempt.
|
||
_imp05_selection_paths_retry = [
|
||
{
|
||
"section_id": sid,
|
||
"selection_path": t.get("selection_path"),
|
||
"selected_rank": t.get("selected_rank"),
|
||
"selected_template_id": t.get("selected_template_id"),
|
||
"fallback_trigger": (
|
||
t.get("fallback_reason") if t.get("fallback_used") else None
|
||
),
|
||
}
|
||
for sid, t in v4_fallback_traces.items()
|
||
]
|
||
comp_debug["v4_fallback_selections"] = list(v4_fallback_traces.values())
|
||
if "v4_fallback_summary" in comp_debug:
|
||
comp_debug["v4_fallback_summary"]["selection_paths"] = (
|
||
_imp05_selection_paths_retry
|
||
)
|
||
print(
|
||
f" [IMP-30 u4] provisional retry recovered {len(units)} unit(s) "
|
||
f"— first-render invariant preserved.",
|
||
file=sys.stderr,
|
||
)
|
||
|
||
if not provisional_recovered:
|
||
# Phase B — terminal empty-shell. No rank-1 V4 evidence for any
|
||
# section, or override path produced no renderable assignments.
|
||
from src.phase_z2_composition import CompositionUnit as _CompositionUnit
|
||
run_dir.mkdir(parents=True, exist_ok=True)
|
||
empty_shell_unit = _CompositionUnit(
|
||
source_section_ids=[s.section_id for s in sections],
|
||
merge_type="empty_shell",
|
||
frame_template_id="__empty__",
|
||
frame_id="__empty__",
|
||
frame_number=0,
|
||
confidence=0.0,
|
||
label="empty_shell",
|
||
phase_z_status="empty_shell",
|
||
raw_content="\n\n".join((s.raw_content or "") for s in sections),
|
||
title=" / ".join((s.title or "") for s in sections),
|
||
v4_rank=None,
|
||
selection_path="empty_shell",
|
||
fallback_reason="no_v4_rank_1_for_any_section",
|
||
score=0.0,
|
||
rationale={
|
||
"imp30_u4": "terminal_first_render_empty_shell",
|
||
"reason": (
|
||
"no_rank_1_V4_evidence_in_any_section"
|
||
if section_assignment_plan is None
|
||
else "section_assignment_override_yielded_no_renderable_units"
|
||
),
|
||
"aligned_section_ids": [s.section_id for s in sections],
|
||
},
|
||
provisional=True,
|
||
)
|
||
units = [empty_shell_unit]
|
||
layout_preset = "single"
|
||
comp_debug["imp30_u4_empty_shell"] = {
|
||
"applied": True,
|
||
"reason": (
|
||
"no_rank_1_V4_for_any_section"
|
||
if section_assignment_plan is None
|
||
else "section_assignment_override_yielded_no_renderable_units"
|
||
),
|
||
"aligned_section_ids": [s.section_id for s in sections],
|
||
}
|
||
print(
|
||
f"\n[Phase Z-2 IMP-30 u4] EMPTY-SHELL @ composition_planner",
|
||
file=sys.stderr,
|
||
)
|
||
print(
|
||
f" reason : "
|
||
f"{'no rank-1 V4 evidence for any section' if section_assignment_plan is None else 'override produced no renderable units'}",
|
||
file=sys.stderr,
|
||
)
|
||
print(
|
||
f" shell : 1 placeholder unit, preset='single' "
|
||
f"(sections={[s.section_id for s in sections]})",
|
||
file=sys.stderr,
|
||
)
|
||
|
||
# IMP-48 (#77) — re-split merged-reject units into per-section singles.
|
||
# One-shot, deterministic (AI=0) post-pass. Fires AFTER all Step 6 settling
|
||
# chains (initial plan_composition / u12 mixed admission / u4 provisional
|
||
# retry / empty-shell) and AFTER section_assignment_plan is known, but
|
||
# BEFORE the Step 6 artifact write below — so the artifact reflects the
|
||
# post-resplit unit list. SKIPS when --override-section-assignments is
|
||
# active (IMP-06 / #6 is the ground truth). Helper guardrails (coverage
|
||
# equality / beneficial split / layout cap ≤ 4) keep mdx03 byte-identical
|
||
# (no-op on use_as_is / light_edit slides). u5 re-derives layout_preset
|
||
# below using the audit payload.
|
||
units, _imp48_audit = resplit_all_reject_merges(
|
||
units,
|
||
sections,
|
||
lookup_fn,
|
||
V4_LABEL_TO_PHASE_Z_STATUS,
|
||
MVP1_ALLOWED_STATUSES,
|
||
capacity_fit_fn=compute_capacity_fit,
|
||
v4_candidates_lookup_fn=candidates_lookup_fn,
|
||
section_assignment_override=section_assignment_plan is not None,
|
||
)
|
||
comp_debug["imp48_resplit"] = _imp48_audit
|
||
# u5 — re-derive layout_preset from helper audit (post-split count via
|
||
# select_layout_preset(out_units)). Helper guarantees post_split_unit_count
|
||
# ≤ 4 (layout cap abort), so the derived preset is always renderable by
|
||
# LAYOUT_PRESETS. Respect --override-layout when present (user's explicit
|
||
# choice wins over auto-redrive; mirrors the override gate above at L3697).
|
||
if _imp48_audit.get("applied"):
|
||
_imp48_post_preset = _imp48_audit.get("post_split_layout_preset")
|
||
if _imp48_post_preset and not layout_override_applied:
|
||
if _imp48_post_preset != layout_preset:
|
||
print(
|
||
f" [IMP-48] layout_preset re-derived: {layout_preset} → "
|
||
f"{_imp48_post_preset} (post-split unit count="
|
||
f"{_imp48_audit.get('post_split_unit_count')})",
|
||
file=sys.stderr,
|
||
)
|
||
layout_preset = _imp48_post_preset
|
||
print(
|
||
f" [IMP-48] re-split applied — "
|
||
f"split={len(_imp48_audit.get('split_units', []))} "
|
||
f"skipped={len(_imp48_audit.get('skipped_units', []))} "
|
||
f"post_count={_imp48_audit.get('post_split_unit_count')} "
|
||
f"post_preset={_imp48_audit.get('post_split_layout_preset')!r}",
|
||
file=sys.stderr,
|
||
)
|
||
|
||
print(f" preset : {layout_preset} ({len(units)} units, composition v0 count-based)")
|
||
for u in units:
|
||
print(f" unit : {u.source_section_ids} merge={u.merge_type} → "
|
||
f"frame {u.frame_number} ({u.frame_template_id}) "
|
||
f"label={u.label} score={u.score:.3f}")
|
||
|
||
# ─── Step 6: Composition Planning ───
|
||
_write_step_artifact(
|
||
run_dir, 6, "composition_plan",
|
||
data={
|
||
"selected_units_count": len(units),
|
||
"layout_preset_decided": layout_preset,
|
||
"candidates_summary": comp_debug.get("candidates_summary"),
|
||
"candidates_total": comp_debug.get("candidates_total"),
|
||
"candidates_viable_auto": comp_debug.get("candidates_viable_auto"),
|
||
"selected_units": [
|
||
{
|
||
"source_section_ids": u.source_section_ids,
|
||
"merge_type": u.merge_type,
|
||
"frame_id": u.frame_id,
|
||
"frame_number": u.frame_number,
|
||
"frame_template_id": u.frame_template_id,
|
||
"label": u.label,
|
||
"v4_rank": u.v4_rank,
|
||
"selection_path": u.selection_path,
|
||
"fallback_reason": u.fallback_reason,
|
||
"score": u.score,
|
||
"phase_z_status": u.phase_z_status,
|
||
"rationale": u.rationale,
|
||
"notes": list(u.notes),
|
||
# Step 6-A axis (사용자 lock 2026-05-08) — V4 후보 list.
|
||
# 단일 frame_* / label / confidence 와 일관 (candidates[0] = rank-1 non-reject).
|
||
"v4_candidates": [
|
||
{
|
||
"template_id": c.template_id,
|
||
"frame_id": c.frame_id,
|
||
"frame_number": c.frame_number,
|
||
"confidence": c.confidence,
|
||
"label": c.label,
|
||
}
|
||
for c in u.v4_candidates
|
||
],
|
||
}
|
||
for u in units
|
||
],
|
||
# IMP-48 (#77) — re-split audit. Additive field. AI=0 deterministic
|
||
# one-shot post-pass on Step 6 settling result. applied=True means
|
||
# ≥1 parent_merged / parent_merged_inferred reject unit was split
|
||
# into per-section singles; selected_units already reflects the
|
||
# post-split list. Skipped reasons (incomplete_rebuild /
|
||
# no_beneficial_split / layout_cap_exceeded) keep the merged unit
|
||
# for IMP-47B (#76) AI handoff. section_assignment_override skip
|
||
# honors IMP-06 (#6) zoneSections ground truth.
|
||
"imp48_resplit": _imp48_audit,
|
||
# IMP-43 (#72) u3 — additive informational field recording the
|
||
# run_dir-relative location of the ``--reuse-from`` sidecar
|
||
# (written immediately after this artifact). Path is stamped
|
||
# unconditionally so that a future ``--reuse-from`` consumer
|
||
# (u4) can locate the expected sidecar even when its write
|
||
# failed (u4 then fail-closes on missing/invalid sidecar via
|
||
# u2's ``validate_snapshot``).
|
||
"reuse_snapshot_path": SNAPSHOT_FILENAME,
|
||
},
|
||
step_status="done",
|
||
pipeline_path_connected=True,
|
||
inputs=["step02_normalized.json", "step05_v4_evidence.json"],
|
||
outputs=["step06_composition_plan.json", SNAPSHOT_FILENAME],
|
||
note=(
|
||
"composition v0 count-based — sections → candidates → score → greedy select. "
|
||
"Step 6-A (사용자 lock 2026-05-08): selected_units[i].v4_candidates 추가 "
|
||
"(non-reject max-6 후보 list, candidates[0] = 단일 frame_* 와 일관). "
|
||
"IMP-48 (#77, 2026-05-22): merged-reject 자동 분리 post-pass — "
|
||
"parent_merged / parent_merged_inferred + label=reject + ≥2 sections "
|
||
"→ per-section singles (each own rank-1 V4 evidence + raw_content 보존). "
|
||
"guardrails: coverage equality / beneficial split (≥1 non-reject) / "
|
||
"layout cap (≤4 units). imp48_resplit audit additive. "
|
||
"logic 무변 — runtime 결과 동일. Step 9 application_plan input. "
|
||
"IMP-43 (#72) u3: _reuse_snapshot.json sidecar written next to "
|
||
"this artifact (run_dir level) for future --reuse-from (u4) "
|
||
"consumption. Optional sidecar — write failure warns + continues."
|
||
),
|
||
)
|
||
|
||
# IMP-43 (#72) u3 — write Step 6 reuse snapshot sidecar AFTER the
|
||
# step06 artifact. The sidecar captures the in-memory state that
|
||
# downstream steps need but that the canonical step02 / step05 /
|
||
# step06 artifacts do not preserve in a deserialize-ready form (e.g.
|
||
# ``CompositionUnit`` instances, raw ``comp_debug``, untruncated
|
||
# ``v4_fallback_traces``, pre-override ``layout_preset``). Helper
|
||
# warns + returns ``None`` on failure — does NOT abort the run.
|
||
# Restore wiring (``--reuse-from``) lands in u4.
|
||
_write_reuse_snapshot(
|
||
run_dir,
|
||
mdx_source_text=mdx_source_text,
|
||
slide_title=slide_title,
|
||
slide_footer=slide_footer,
|
||
sections=sections,
|
||
stage0_adapter_diagnostics=stage0_adapter_diagnostics,
|
||
stage0_normalized_assets=stage0_normalized_assets,
|
||
v4_evidence=v4_evidence_list,
|
||
layout_preset_pre_override=layout_preset,
|
||
units=units,
|
||
comp_debug=comp_debug,
|
||
v4_fallback_traces=v4_fallback_traces,
|
||
ai_preflight=ai_preflight,
|
||
)
|
||
else:
|
||
# IMP-43 (#72) u5 — reuse path: restore Steps 0/1/2/5/6 state
|
||
# from prev_run snapshot. u4b's execute_reuse_from_or_fail_closed
|
||
# handles all nine fail-closed axes (prev_run_dir_missing,
|
||
# snapshot_corrupt_json, mdx_sha256_mismatch, etc.) — on success
|
||
# it returns ``(prev_run_dir, copied_artifacts, snapshot)``;
|
||
# any reachable failure terminates the process before this branch
|
||
# binds a local.
|
||
#
|
||
# State variable shape matches the locals produced by Steps 0-6
|
||
# above so the Step 7+ block reads them transparently:
|
||
# ai_preflight : Step 0 preflight dict
|
||
# slide_title / slide_footer : parse_mdx output
|
||
# sections : list[MdxSection], post-align
|
||
# stage0_adapter_diagnostics : Stage 0 adapter trace dict
|
||
# stage0_normalized_assets : Step 3 handoff dict (popups/...)
|
||
# v4_evidence_list : list[dict] (Step 5 artifact)
|
||
# layout_preset : Step 6 post-IMP-48 preset
|
||
# units : list[CompositionUnit]
|
||
# comp_debug : Step 6 debug dict
|
||
# v4_fallback_traces : dict[sid -> trace dict]
|
||
#
|
||
# NOT serialized (deterministic from external sources or restored
|
||
# sections — recomputed here):
|
||
# v4 : load_v4_result() — V4_RESULT_PATH on disk
|
||
# section_alias_by_id : derived from restored sections
|
||
#
|
||
# u1 guard ensures override_layout is None on the reuse path, so
|
||
# layout_override_applied / auto_layout_preset reflect the
|
||
# restored Step 6 preset for the Step 7 artifact.
|
||
mdx_source_text = mdx_path.read_text(encoding="utf-8")
|
||
(run_dir / "steps").mkdir(exist_ok=True)
|
||
_prev_run_dir, _copied_artifacts, _snapshot = execute_reuse_from_or_fail_closed(
|
||
reuse_from=reuse_from,
|
||
new_run_dir=run_dir,
|
||
mdx_source_text=mdx_source_text,
|
||
)
|
||
ai_preflight = _snapshot["ai_preflight"]["value"]
|
||
slide_title = _snapshot["slide_title"]["value"]
|
||
slide_footer = _snapshot["slide_footer"]["value"]
|
||
sections = _rehydrate_mdx_sections_from_snapshot(_snapshot)
|
||
stage0_adapter_diagnostics = _snapshot["stage0_adapter_diagnostics"]["value"]
|
||
stage0_normalized_assets = _snapshot["stage0_normalized_assets"]["value"]
|
||
v4_evidence_list = _snapshot["v4_evidence"]["value"]
|
||
layout_preset = _snapshot["layout_preset_pre_override"]["value"]
|
||
units = _rehydrate_composition_units_from_snapshot(_snapshot)
|
||
comp_debug = _snapshot["comp_debug"]["value"]
|
||
v4_fallback_traces = _snapshot["v4_fallback_traces"]["value"]
|
||
v4 = load_v4_result()
|
||
section_alias_by_id = {
|
||
s.section_id: list(getattr(s, "v4_alias_keys", []) or [])
|
||
for s in sections
|
||
}
|
||
auto_layout_preset = layout_preset
|
||
layout_override_applied = False
|
||
# IMP-43 (#72) u4 fix — shared Step 7+ block reads
|
||
# ``section_assignment_plan`` unconditionally at the render_records
|
||
# gate below, and ``section_assignment_summary`` is mirrored into
|
||
# comp_debug via the normal-path override branch. Both stay at
|
||
# their "no override applied" defaults on the reuse path because
|
||
# u1's fail-closed guard already rejected --override-section-
|
||
# assignment when --reuse-from is set. Without these explicit
|
||
# defaults the reuse branch falls through to ``if
|
||
# section_assignment_plan is not None:`` (line ~5754) with an
|
||
# unbound local and the run aborts with UnboundLocalError before
|
||
# Step 7 can begin (see Codex #14 rewind report).
|
||
section_assignment_plan: Optional[list[dict]] = None
|
||
section_assignment_summary: Optional[dict] = None
|
||
_write_reuse_marker(
|
||
run_dir,
|
||
prev_run_id=reuse_from,
|
||
copied_artifacts=_copied_artifacts,
|
||
)
|
||
print(
|
||
f" reuse : sections={len(sections)} "
|
||
f"({[s.section_id for s in sections]}), "
|
||
f"units={len(units)}, layout={layout_preset}, "
|
||
f"prev_run_id={reuse_from}"
|
||
)
|
||
|
||
# 5. Per-unit: synthesize MdxSection → mapper → assets → zone data
|
||
# mapper FitError 는 catch — 자동 파이프라인은 다른 zone 계속 진행. abort X.
|
||
positions = LAYOUT_PRESETS[layout_preset]["positions"]
|
||
zones_data = []
|
||
debug_zones = []
|
||
adapter_needed_units: list[dict] = []
|
||
|
||
# ── Step 7-A axis : frame override ──
|
||
# {unit_id: template_id} 형식. unit_id 매칭 시 unit.frame_template_id 강제 변경.
|
||
# v4_candidates 안에서 같은 template_id 를 가진 entry 를 찾으면 frame_id /
|
||
# frame_number / confidence / label 까지 그 entry 에서 가져와 갱신 — 그래야 step09
|
||
# artifact 의 메타가 일관됨. IMP-47B u3 (2026-05-21) : v4_candidates miss 시
|
||
# 전 32 judgments 까지 probe — reject 라벨 frame 을 사용자가 선택한 경우
|
||
# unit 을 provisional=True 로 승격해 Step 12 AI 재구성 게이트를 통과시킴
|
||
# (frame 유지, 자동 frame swap 금지 — [[feedback_ai_isolation_contract]]).
|
||
# frame contract 가 catalog 에 등록 안 된 template_id 면 skip + warning —
|
||
# crash 방지 (V4 score 는 매겨지지만 catalog partial 은 없는 후보 존재).
|
||
frame_overrides_applied: list[dict] = []
|
||
frame_overrides_skipped: list[dict] = []
|
||
if override_frames:
|
||
for unit in units:
|
||
unit_id = "+".join(unit.source_section_ids)
|
||
if unit_id not in override_frames:
|
||
continue
|
||
new_tid = override_frames[unit_id]
|
||
old_tid = unit.frame_template_id
|
||
if new_tid == old_tid:
|
||
continue
|
||
# catalog contract 존재 확인 — 없으면 override 거부.
|
||
new_contract = get_contract(new_tid)
|
||
if new_contract is None:
|
||
frame_overrides_skipped.append({
|
||
"unit_id": unit_id,
|
||
"from": old_tid,
|
||
"to": new_tid,
|
||
"reason": "no_frame_contract_in_catalog",
|
||
})
|
||
print(
|
||
f" [override-skip] unit {unit_id}: '{new_tid}' has no entry in "
|
||
f"frame_contracts catalog — keeping {old_tid}",
|
||
file=sys.stderr,
|
||
)
|
||
continue
|
||
meta_source = _apply_frame_override_to_unit(unit, new_tid, v4)
|
||
frame_overrides_applied.append({
|
||
"unit_id": unit_id,
|
||
"from": old_tid,
|
||
"to": new_tid,
|
||
"meta_source": meta_source,
|
||
})
|
||
print(
|
||
f" [override] unit {unit_id} frame: {old_tid} → {new_tid} "
|
||
f"({meta_source})",
|
||
file=sys.stderr,
|
||
)
|
||
|
||
# IMP-06 blocker-fix (Codex #13/#14/#15/#16/#17) — build render_records AFTER
|
||
# frame_overrides so each record points to the post-override CompositionUnit
|
||
# object (Codex #15 Catch R). render_records is the canonical per-position
|
||
# plan-derived view: cli_override / auto / empty / collision-skipped all carry
|
||
# a record. `unit` is the same instance reference as in `units` when
|
||
# renderable, otherwise None. This drives debug_zones/Step 9/Step 20 traces
|
||
# without forcing None into the renderable `units` list.
|
||
render_records: list[dict] = []
|
||
if section_assignment_plan is not None:
|
||
for entry in section_assignment_plan:
|
||
pos = entry["position"]
|
||
unit_ref = plan_unit_by_position.get(pos)
|
||
render_records.append({
|
||
"position": pos,
|
||
"assignment_source": entry["assignment_source"],
|
||
"unit": unit_ref,
|
||
"source_section_ids": list(entry.get("source_section_ids") or []),
|
||
"section_assignment_override": entry.get("section_assignment_override"),
|
||
"replaced_auto_unit": entry.get("replaced_auto_unit"),
|
||
"skipped_collided_auto_units": list(entry.get("skipped_collided_auto_units") or []),
|
||
"uncovered_section_ids": list(entry.get("uncovered_section_ids") or []),
|
||
"skipped_reason": entry.get("skipped_reason"),
|
||
})
|
||
|
||
# IMP-06 blocker-fix (Codex #13/#14/#15/#16/#17) — index render_records by
|
||
# unit object identity so the renderable zones loop below can stamp each
|
||
# zones_data / debug_zones entry with plan-aware fields (assignment_source,
|
||
# section_assignment_override, replaced_auto_unit, skipped_collided_auto_units,
|
||
# uncovered_section_ids, skipped_reason). Empty positions handled later by
|
||
# the post-loop "empty zone records" block — those records carry the same
|
||
# plan-aware fields directly from their plan entry.
|
||
render_record_by_unit_id: dict[int, dict] = {}
|
||
if render_records:
|
||
for rec in render_records:
|
||
u = rec.get("unit")
|
||
if u is not None:
|
||
render_record_by_unit_id[id(u)] = rec
|
||
|
||
for i, unit in enumerate(units):
|
||
position = positions[i] if i < len(positions) else f"zone_{i}"
|
||
plan_record = render_record_by_unit_id.get(id(unit))
|
||
# When render_records exists (override path) prefer its position to
|
||
# guard against future drifts. positions[i] is the legacy auto path
|
||
# and is byte-identical to plan_record.position in the normal case.
|
||
if plan_record is not None and plan_record.get("position"):
|
||
position = plan_record["position"]
|
||
|
||
# IMP-30 u4 — empty-shell synthesized unit. frame_template_id="__empty__"
|
||
# has no catalog contract by design; bypass mapper/contract path and emit
|
||
# a placeholder zone record so render_slide() short-circuits to empty
|
||
# partial_html (existing `__empty__` branch at render_slide:2106). The
|
||
# slide_base still renders title + footer + empty grid cell so the
|
||
# first-render invariant holds; u5 will surface the provisional flag as
|
||
# a zone class + needs-adaptation badge.
|
||
if unit.frame_template_id == "__empty__":
|
||
# IMP-35 u7 — popup payload wiring. Empty-shell units never go
|
||
# through the Step 17 POPUP gate (no raw content to escalate),
|
||
# so compose_zone_popup_payload returns the no-popup branch
|
||
# (has_popup=False, popup_html=None, preview_text=None).
|
||
_popup_payload = compose_zone_popup_payload(unit, 0)
|
||
zones_data.append({
|
||
"position": position,
|
||
"template_id": "__empty__",
|
||
"slot_payload": {},
|
||
"content_weight": {"score": 0},
|
||
"min_height_px": 0,
|
||
"assignment_source": "imp30_u4_empty_shell",
|
||
"section_assignment_override": False,
|
||
"provisional": bool(getattr(unit, "provisional", False)),
|
||
**_popup_payload,
|
||
})
|
||
debug_zones.append({
|
||
"position": position,
|
||
"source_section_ids": list(unit.source_section_ids),
|
||
"merge_type": unit.merge_type,
|
||
"title": unit.title,
|
||
"v4_rank1_frame_id": unit.frame_id,
|
||
"v4_rank1_frame_number": unit.frame_number,
|
||
"v4_template_id": "__empty__",
|
||
"v4_label": unit.label,
|
||
"v4_confidence": float(unit.confidence or 0.0),
|
||
"v4_selected_rank": unit.v4_rank,
|
||
"selection_path": unit.selection_path,
|
||
"fallback_reason": unit.fallback_reason,
|
||
"fallback_used": False,
|
||
"phase_z_status": unit.phase_z_status,
|
||
"composition_score": float(unit.score or 0.0),
|
||
"composition_rationale": dict(unit.rationale or {}),
|
||
"composition_notes": list(unit.notes),
|
||
"mapper_type": "empty_shell",
|
||
"contract_id": "__empty__",
|
||
"contract_frame_id": None,
|
||
"builder": None,
|
||
"min_height_px": 0,
|
||
"slot_payload_keys": [],
|
||
"content_truncated_count": None,
|
||
"assets_dir": None,
|
||
"content_weight": {"score": 0},
|
||
"placement_trace": None,
|
||
"assignment_source": "imp30_u4_empty_shell",
|
||
"section_assignment_override": False,
|
||
"replaced_auto_unit": None,
|
||
"skipped_collided_auto_units": [],
|
||
"uncovered_section_ids": [],
|
||
"skipped_reason": "imp30_u4_empty_shell_no_v4_evidence",
|
||
"provisional": bool(getattr(unit, "provisional", False)),
|
||
})
|
||
continue
|
||
|
||
synth_section = MdxSection(
|
||
section_id="+".join(unit.source_section_ids),
|
||
section_num=0,
|
||
title=unit.title,
|
||
raw_content=unit.raw_content,
|
||
)
|
||
contract = get_contract(unit.frame_template_id)
|
||
builder_name = contract["payload"].get("builder")
|
||
visual_hints = contract.get("visual_hints") or {}
|
||
min_height_px = visual_hints.get("min_height_px", DEFAULT_ZONE_MIN_HEIGHT_PX)
|
||
contract_frame_id = contract.get("frame_id")
|
||
|
||
# ─── trace-only runtime 연결 v0 — B1 → B4 chain (final.html 영향 X) ───
|
||
# B1~B4 의 dormant chain 을 *real MDX runtime data* 로 처음 호출.
|
||
# 결과 (PlacementPlan) = debug_zones[i].placement_trace 로 *기록만*.
|
||
# render path / mapper output / final.html 모두 미변경 — B5 baseline SHA 유지.
|
||
# B4 frame selection = catalog declaration order (V4 evidence 미사용 — 별 axis).
|
||
# Option 1 (PHASE_Z_B4_SOURCE_SHAPE_ENABLED, default OFF) : pilot = F13 top_bullets only.
|
||
b4_source_shape_enabled = (
|
||
os.environ.get("PHASE_Z_B4_SOURCE_SHAPE_ENABLED", "").strip().lower()
|
||
in {"1", "true", "yes"}
|
||
)
|
||
b1_source_shape = (
|
||
contract.get("source_shape")
|
||
if b4_source_shape_enabled and contract.get("source_shape") == "top_bullets"
|
||
else None
|
||
)
|
||
content_objects = extract_content_objects(synth_section, source_shape=b1_source_shape)
|
||
placement_plan = plan_placement(
|
||
content_objects=content_objects,
|
||
frame_contracts=list(load_frame_contracts().values()),
|
||
section_id=synth_section.section_id,
|
||
)
|
||
mapper_frame_template_id = unit.frame_template_id
|
||
matches_mapper = (
|
||
placement_plan.selected_template_id == mapper_frame_template_id
|
||
)
|
||
match_note: Optional[str] = None
|
||
if not matches_mapper:
|
||
if placement_plan.selected_template_id is None:
|
||
match_note = "no_frame_covers_content_types"
|
||
else:
|
||
match_note = (
|
||
f"B4 selected '{placement_plan.selected_template_id}' but "
|
||
f"mapper uses '{mapper_frame_template_id}' (composition V4 rank-1)"
|
||
)
|
||
placement_trace = {
|
||
**asdict(placement_plan),
|
||
"mapper_frame_template_id": mapper_frame_template_id,
|
||
"frame_selection_matches_mapper": matches_mapper,
|
||
"frame_selection_match_note": match_note,
|
||
}
|
||
# ─── end trace-only runtime 연결 v0 ───
|
||
|
||
# ─── B4 gatekeeper (Q-V4B4 / PHASE_Z_B4_GATEKEEPER, default OFF) ───
|
||
if (
|
||
os.environ.get("PHASE_Z_B4_GATEKEEPER", "").strip().lower()
|
||
in {"1", "true", "yes"}
|
||
and not matches_mapper
|
||
):
|
||
adapter_record = {
|
||
"position": position,
|
||
"source_section_ids": unit.source_section_ids,
|
||
"merge_type": unit.merge_type,
|
||
"template_id": unit.frame_template_id,
|
||
"reason": "v4_b4_mismatch",
|
||
"mismatch_detail": {
|
||
"v4_template_id": mapper_frame_template_id,
|
||
"b4_selected_template_id": placement_plan.selected_template_id,
|
||
"match_note": match_note,
|
||
},
|
||
}
|
||
adapter_needed_units.append(adapter_record)
|
||
print(f" adapter : zone--{position} {unit.source_section_ids} → "
|
||
f"{unit.frame_template_id} v4_b4_mismatch → adapter_needed (skip render)")
|
||
continue
|
||
# ─── end B4 gatekeeper ───
|
||
|
||
# ─── IMP-89 89-a u2 — slot_payload source-of-truth switch ───
|
||
# PHASE_Z_B4_MAPPER_SOURCE (u1 flag, default OFF):
|
||
# ON → mapper input = B4 PlacementPlan.selected_template_id
|
||
# (Layer A render-active; B4 drives slot_payload)
|
||
# OFF → mapper input = unit.frame_template_id (legacy mapper-only /
|
||
# V4 rank-1; byte-equivalent default — final.html SHA parity
|
||
# guarded by u4)
|
||
# u3 layers BLOCKED exits for (selected_template_id is None OR
|
||
# FitError on B4-selected template) under flag ON — NO silent
|
||
# fallback (IMP-87 honesty gate pattern). Under flag OFF semantics
|
||
# preserved verbatim.
|
||
mapper_template_id = _select_mapper_template_id(
|
||
placement_plan, unit.frame_template_id
|
||
)
|
||
|
||
# IMP-89 89-a u3 — BLOCKED exit on B4 no-cover under flag ON.
|
||
# When PHASE_Z_B4_MAPPER_SOURCE=ON and PlacementPlan produced no
|
||
# covering frame, refuse to fall back to the legacy V4 rank-1 mapper
|
||
# input. NO silent fallback (Stage 1 Q2 lock; IMP-87 honesty gate
|
||
# pattern). Under flag OFF this branch is never entered, so the
|
||
# default render path remains byte-identical.
|
||
if _b4_mapper_source_enabled() and mapper_template_id is None:
|
||
_b4_mapper_source_blocked_exit(
|
||
"b4_no_cover",
|
||
position=position,
|
||
context={
|
||
"unit": (
|
||
f"source_section_ids={list(unit.source_section_ids)} "
|
||
f"merge_type={unit.merge_type}"
|
||
),
|
||
"v4_rank1": unit.frame_template_id,
|
||
"b4_pick": placement_plan.selected_template_id,
|
||
},
|
||
)
|
||
|
||
# mapper 시도 — 실패 (FitError) 시 zone 을 adapter_needed 로 표시하고 skip
|
||
try:
|
||
slot_payload = map_mdx_to_slots(synth_section, mapper_template_id)
|
||
except FitError as e:
|
||
_fit_error_str = str(e)
|
||
# IMP-89 89-a u3 — BLOCKED exit on B4-selected FitError under flag
|
||
# ON. When PHASE_Z_B4_MAPPER_SOURCE=ON the mapper rejecting the
|
||
# B4-selected template is a Layer A honesty failure — adapter_needed
|
||
# would mask it (Stage 1 Q2 lock). Under flag OFF the legacy
|
||
# adapter_needed silent-fallback path executes unchanged.
|
||
if _b4_mapper_source_enabled():
|
||
_b4_mapper_source_blocked_exit(
|
||
"b4_selected_fit_error",
|
||
position=position,
|
||
context={
|
||
"template": f"{mapper_template_id} (B4 selected)",
|
||
"unit": (
|
||
f"source_section_ids={list(unit.source_section_ids)}"
|
||
),
|
||
"v4_rank1": unit.frame_template_id,
|
||
"fit_error": _fit_error_str,
|
||
},
|
||
)
|
||
_unit_provisional = bool(getattr(unit, "provisional", False))
|
||
adapter_record = {
|
||
"position": position,
|
||
"source_section_ids": unit.source_section_ids,
|
||
"merge_type": unit.merge_type,
|
||
"template_id": unit.frame_template_id,
|
||
"reason": "fit_error",
|
||
"fit_error": _fit_error_str,
|
||
}
|
||
adapter_needed_units.append(adapter_record)
|
||
# IMP-86 u1 — placeholder zones_data + debug_zone keep the failed
|
||
# unit's preset position so downstream build_layout_css /
|
||
# _compute_per_zone_geometry observe len(zones_data) == active
|
||
# layout preset's css_areas rows (R). Without this both arrays
|
||
# drift relative to R, raising
|
||
# ValueError("heights_px length N != grid rows R=M") at
|
||
# _compute_per_zone_geometry. Mirrors the IMP-30 empty-shell
|
||
# pattern: zones_data uses template_id="__empty__" so render_slide
|
||
# short-circuits to empty partial_html; debug_zone keeps the
|
||
# original V4 evidence and adapter_needed_units stays the
|
||
# authoritative adapter signal.
|
||
# IMP-86 u5 — per-record telemetry. adapter_needed=True +
|
||
# mapper_fit_error=<str(FitError)> + provisional mirror the
|
||
# adapter signal directly on each placeholder record so
|
||
# debug.json / final.html consumers can identify the adapter
|
||
# contract surface from the zones array alone, without joining
|
||
# against slide_status.adapter_needed_units. adapter_needed_units
|
||
# itself is unchanged (still the authoritative per-slide list).
|
||
_placeholder_popup = compose_zone_popup_payload(unit, 0)
|
||
zones_data.append({
|
||
"position": position,
|
||
"template_id": "__empty__",
|
||
"slot_payload": {},
|
||
"content_weight": {"score": 0},
|
||
"min_height_px": min_height_px,
|
||
"assignment_source": "imp86_u1_adapter_needed",
|
||
"section_assignment_override": False,
|
||
"provisional": _unit_provisional,
|
||
"adapter_needed": True,
|
||
"mapper_fit_error": _fit_error_str,
|
||
**_placeholder_popup,
|
||
})
|
||
debug_zones.append({
|
||
"position": position,
|
||
"source_section_ids": list(unit.source_section_ids),
|
||
"merge_type": unit.merge_type,
|
||
"title": unit.title,
|
||
"v4_rank1_frame_id": unit.frame_id,
|
||
"v4_rank1_frame_number": unit.frame_number,
|
||
"v4_template_id": unit.frame_template_id,
|
||
"v4_label": unit.label,
|
||
"v4_confidence": float(unit.confidence or 0.0),
|
||
"v4_selected_rank": unit.v4_rank,
|
||
"selection_path": unit.selection_path,
|
||
"fallback_reason": unit.fallback_reason,
|
||
"fallback_used": bool(unit.selection_path and "fallback" in unit.selection_path),
|
||
"phase_z_status": unit.phase_z_status,
|
||
"composition_score": float(unit.score or 0.0),
|
||
"composition_rationale": dict(unit.rationale or {}),
|
||
"composition_notes": list(unit.notes),
|
||
"mapper_type": "adapter_needed",
|
||
"contract_id": unit.frame_template_id,
|
||
"contract_frame_id": contract_frame_id,
|
||
"builder": builder_name,
|
||
"min_height_px": min_height_px,
|
||
"slot_payload_keys": [],
|
||
"content_truncated_count": None,
|
||
"assets_dir": None,
|
||
"content_weight": {"score": 0},
|
||
"placement_trace": placement_trace,
|
||
"assignment_source": "imp86_u1_adapter_needed",
|
||
"section_assignment_override": False,
|
||
"replaced_auto_unit": None,
|
||
"skipped_collided_auto_units": [],
|
||
"uncovered_section_ids": [],
|
||
"skipped_reason": "imp86_u1_adapter_needed_mapper_fit_error",
|
||
"provisional": _unit_provisional,
|
||
"adapter_needed": True,
|
||
"mapper_fit_error": _fit_error_str,
|
||
})
|
||
print(f" adapter : zone--{position} {unit.source_section_ids} → "
|
||
f"{unit.frame_template_id} FitError → adapter_needed (skip render)")
|
||
continue
|
||
|
||
run_dir.mkdir(parents=True, exist_ok=True)
|
||
assets_dir = copy_assets(unit.frame_template_id, run_dir)
|
||
content_weight = compute_content_weight(synth_section)
|
||
truncated_count = slot_payload.get("_truncated_count") # builder 가 truncate 한 경우
|
||
|
||
# IMP-06 blocker-fix (Codex #13/#14/#15/#16/#17) — plan-aware additive
|
||
# fields. When no override CLI was used (plan_record is None), these
|
||
# default to None/False/[] so pre-IMP-06 readers see byte-equivalent
|
||
# data. Empty zone records appended below (post-loop) carry the same
|
||
# field shape from their plan entry directly.
|
||
plan_assignment_source = (
|
||
plan_record.get("assignment_source") if plan_record else None
|
||
)
|
||
plan_section_override = (
|
||
bool(plan_record.get("section_assignment_override"))
|
||
if plan_record else False
|
||
)
|
||
plan_replaced_auto = (
|
||
plan_record.get("replaced_auto_unit") if plan_record else None
|
||
)
|
||
plan_skipped_collided = list(
|
||
plan_record.get("skipped_collided_auto_units") or []
|
||
) if plan_record else []
|
||
plan_uncovered = list(
|
||
plan_record.get("uncovered_section_ids") or []
|
||
) if plan_record else []
|
||
plan_skipped_reason = (
|
||
plan_record.get("skipped_reason") if plan_record else None
|
||
)
|
||
|
||
# IMP-30 u5 — `provisional` flag flows as data through V4Match (u1) →
|
||
# CompositionUnit (u2) → zones_data here. slide_base.html reads
|
||
# zone.provisional to apply the `zone--provisional` class + inline
|
||
# needs-adaptation badge. Default False keeps non-provisional zones
|
||
# byte-identical to pre-u5; only u1-synthesized rank-1 fills or u4
|
||
# empty-shell synthesize provisional=True units.
|
||
#
|
||
# IMP-35 u7 — popup payload wiring. `compose_zone_popup_payload(unit,
|
||
# min_height_px)` reads u6 binding (yaml strategy + popup_body_source)
|
||
# AND derives a px-budgeted preview from min_height_px. Surfaces three
|
||
# uniform render-context fields per zone (has_popup / popup_html /
|
||
# preview_text) plus the full u6 binding under `popup_binding` for
|
||
# u8 / u9 downstream consumers. Non-popup units (has_popup=False)
|
||
# return the no-popup branch — byte-identical zone shape pre-u7.
|
||
_popup_payload = compose_zone_popup_payload(unit, min_height_px)
|
||
zones_data.append({
|
||
"position": position,
|
||
"template_id": unit.frame_template_id,
|
||
"slot_payload": slot_payload,
|
||
"content_weight": content_weight,
|
||
"min_height_px": min_height_px,
|
||
"assignment_source": plan_assignment_source,
|
||
"section_assignment_override": plan_section_override,
|
||
"provisional": bool(getattr(unit, "provisional", False)),
|
||
**_popup_payload,
|
||
})
|
||
debug_zones.append({
|
||
"position": position,
|
||
"source_section_ids": unit.source_section_ids,
|
||
"merge_type": unit.merge_type,
|
||
"title": unit.title,
|
||
"v4_rank1_frame_id": unit.frame_id,
|
||
"v4_rank1_frame_number": unit.frame_number,
|
||
"v4_template_id": unit.frame_template_id,
|
||
"v4_label": unit.label,
|
||
"v4_confidence": unit.confidence,
|
||
"v4_selected_rank": unit.v4_rank,
|
||
"selection_path": unit.selection_path,
|
||
"fallback_reason": unit.fallback_reason,
|
||
"fallback_used": bool(unit.selection_path and "fallback" in unit.selection_path),
|
||
"phase_z_status": unit.phase_z_status,
|
||
"composition_score": unit.score,
|
||
"composition_rationale": unit.rationale,
|
||
"composition_notes": list(unit.notes), # parent_merged_inferred 정보 신호
|
||
"mapper_type": "contract",
|
||
"contract_id": unit.frame_template_id,
|
||
"contract_frame_id": contract_frame_id,
|
||
"builder": builder_name,
|
||
"min_height_px": min_height_px,
|
||
"slot_payload_keys": sorted(slot_payload.keys()),
|
||
"content_truncated_count": truncated_count, # None / N (builder 가 N 개 자름)
|
||
"assets_dir": str(assets_dir.relative_to(run_dir)) if assets_dir else None,
|
||
"content_weight": content_weight,
|
||
# trace-only runtime 연결 v0 — B1 → B2 → B4 chain 결과 (render 미영향).
|
||
"placement_trace": placement_trace,
|
||
# IMP-06 blocker-fix — plan-aware additive fields.
|
||
"assignment_source": plan_assignment_source,
|
||
"section_assignment_override": plan_section_override,
|
||
"replaced_auto_unit": plan_replaced_auto,
|
||
"skipped_collided_auto_units": plan_skipped_collided,
|
||
"uncovered_section_ids": plan_uncovered,
|
||
"skipped_reason": plan_skipped_reason,
|
||
# IMP-30 u5 — provisional signal mirror for debug.json consumers.
|
||
"provisional": bool(getattr(unit, "provisional", False)),
|
||
})
|
||
|
||
# IMP-06 blocker-fix (Codex #10 Catch N) — append explicit empty zone records
|
||
# for positions whose section-assignment plan produced no renderable unit
|
||
# (cli_override with no resolvable template, ad-hoc multi-section,
|
||
# override_collision whole-skip with no replacement, etc.). Empty records
|
||
# preserve zone identity in zones_data/debug_zones (template_id="__empty__",
|
||
# content_weight=0, min_height_px=0) so layout/grid stay structurally
|
||
# consistent without distorting allocation, and the partial-render loop
|
||
# short-circuits "__empty__" to an empty string (no TemplateNotFound).
|
||
if render_records:
|
||
renderable_positions = {z["position"] for z in zones_data}
|
||
for record in render_records:
|
||
pos = record["position"]
|
||
if pos in renderable_positions:
|
||
continue
|
||
# IMP-35 u7 — popup payload wiring for unrenderable empty
|
||
# plan record. No CompositionUnit exists for this branch
|
||
# (section-assignment plan produced no unit), so we stamp the
|
||
# no-popup defaults directly. Keeps the zone shape uniform
|
||
# across all three append paths so slide_base.html (u8) does
|
||
# not have to branch on the presence of popup fields.
|
||
zones_data.append({
|
||
"position": pos,
|
||
"template_id": "__empty__",
|
||
"slot_payload": {},
|
||
"content_weight": {"score": 0},
|
||
"min_height_px": 0,
|
||
"assignment_source": "empty",
|
||
"skipped_reason": (
|
||
record.get("skipped_reason")
|
||
or "section_assignment_override_empty_or_unrenderable"
|
||
),
|
||
"has_popup": False,
|
||
"popup_html": None,
|
||
"preview_text": None,
|
||
"popup_binding": None,
|
||
})
|
||
debug_zones.append({
|
||
"position": pos,
|
||
"source_section_ids": list(record.get("source_section_ids") or []),
|
||
"merge_type": "empty",
|
||
"title": "",
|
||
"v4_template_id": "__empty__",
|
||
"v4_label": None,
|
||
"v4_confidence": 0.0,
|
||
"selection_path": "section_assignment_empty",
|
||
"fallback_reason": None,
|
||
"fallback_used": False,
|
||
"phase_z_status": None,
|
||
"composition_score": 0.0,
|
||
"composition_rationale": {},
|
||
"composition_notes": [],
|
||
"mapper_type": "empty",
|
||
"contract_id": "__empty__",
|
||
"contract_frame_id": None,
|
||
"assignment_source": record.get("assignment_source"),
|
||
"skipped_reason": (
|
||
record.get("skipped_reason")
|
||
or "section_assignment_override_empty_or_unrenderable"
|
||
),
|
||
"section_assignment_override": record.get("section_assignment_override"),
|
||
"replaced_auto_unit": record.get("replaced_auto_unit"),
|
||
"skipped_collided_auto_units": list(record.get("skipped_collided_auto_units") or []),
|
||
"uncovered_section_ids": list(record.get("uncovered_section_ids") or []),
|
||
})
|
||
|
||
# ─── Step 3: Content Object 추출 (B1, trace-only) ───
|
||
# IMP-03 — slide-level rich ContentObject 추출 (default OFF canary).
|
||
# scope-lock 16 조건 (Gitea #3) :
|
||
# - 별 함수 (extract_rich_content_objects) — v0 extract_content_objects unchanged
|
||
# - slide-level — section_id=None, id=`{mdx_id}.{type}-N`, scope='slide'
|
||
# - root-level once (per-zone duplication X)
|
||
# - plan_placement() 는 v0 list 만 받음 (B4 회귀 X) — 본 rich 결과는 artifact only
|
||
# - transform_table dedup : arrow row 감지 시 skip
|
||
rich_flag = os.environ.get("PHASE_Z_STEP3_RICH_OBJECTS_ENABLED", "").strip().lower()
|
||
rich_enabled_flag = rich_flag in {"1", "true", "yes"}
|
||
_assets_total = (
|
||
len(stage0_normalized_assets.get("popups") or [])
|
||
+ len(stage0_normalized_assets.get("images") or [])
|
||
+ len(stage0_normalized_assets.get("tables") or [])
|
||
)
|
||
rich_disabled_reason: Optional[str] = None
|
||
if not rich_enabled_flag:
|
||
rich_disabled_reason = "FLAG_OFF"
|
||
elif _assets_total == 0:
|
||
rich_disabled_reason = "NO_NORMALIZED_ASSETS"
|
||
|
||
rich_objects: list = []
|
||
rich_skips: list = []
|
||
if rich_disabled_reason is None:
|
||
mdx_num_match = re.match(r"(\d+)", mdx_path.stem)
|
||
rich_mdx_id = mdx_num_match.group(1).zfill(2) if mdx_num_match else "00"
|
||
rich_objects, rich_skips = extract_rich_content_objects(
|
||
stage0_normalized_assets, mdx_id=rich_mdx_id,
|
||
)
|
||
|
||
# Count/list invariant check (IMP-02 ↔ IMP-03 chain) — soft warning, no fail.
|
||
invariant_warnings: list[dict] = []
|
||
_adapter_counts = (stage0_adapter_diagnostics or {}).get("adapter_counts") or {}
|
||
if _adapter_counts:
|
||
for key in ("popups", "images", "tables"):
|
||
expected = _adapter_counts.get(key)
|
||
actual = len(stage0_normalized_assets.get(key) or [])
|
||
if expected is not None and expected != actual:
|
||
invariant_warnings.append({
|
||
"field": key,
|
||
"adapter_counts": expected,
|
||
"stage0_normalized_assets_len": actual,
|
||
})
|
||
|
||
_write_step_artifact(
|
||
run_dir, 3, "content_objects",
|
||
data={
|
||
"per_zone": [
|
||
{
|
||
"position": dz["position"],
|
||
"section_ids": dz["source_section_ids"],
|
||
"internal_regions": (dz.get("placement_trace") or {}).get("internal_regions") or [],
|
||
}
|
||
for dz in debug_zones
|
||
],
|
||
# IMP-03 — slide-level rich trace (additive, trace-only).
|
||
"rich_content_objects": [asdict(o) for o in rich_objects],
|
||
"rich_content_objects_enabled": rich_disabled_reason is None,
|
||
"rich_content_objects_scope": "slide",
|
||
"rich_content_objects_source": "stage0_normalized_assets",
|
||
"rich_content_objects_disabled_reason": rich_disabled_reason,
|
||
"rich_content_objects_skips": rich_skips,
|
||
"rich_content_objects_invariant_warnings": invariant_warnings,
|
||
},
|
||
step_status="trace-only",
|
||
pipeline_path_connected=False,
|
||
inputs=["step02_normalized.json"],
|
||
outputs=["step03_content_objects.json"],
|
||
note=(
|
||
"현재는 trace 로 기록되지만 render payload 를 직접 만들지는 않음. "
|
||
"mapper.py 가 별도로 MDX 직접 파싱. "
|
||
"IMP-03 rich_content_objects = slide-level popup/image/table trace "
|
||
"(PHASE_Z_STEP3_RICH_OBJECTS_ENABLED canary, default OFF)."
|
||
),
|
||
)
|
||
|
||
# ─── Step 4: Section Internal Composition (B2, trace-only) ───
|
||
_write_step_artifact(
|
||
run_dir, 4, "internal_composition",
|
||
data={
|
||
"per_zone": [
|
||
{
|
||
"position": dz["position"],
|
||
"section_ids": dz["source_section_ids"],
|
||
"selected_template_id": (dz.get("placement_trace") or {}).get("selected_template_id"),
|
||
"frame_selection_matches_mapper": (dz.get("placement_trace") or {}).get("frame_selection_matches_mapper"),
|
||
"frame_selection_match_note": (dz.get("placement_trace") or {}).get("frame_selection_match_note"),
|
||
}
|
||
for dz in debug_zones
|
||
],
|
||
},
|
||
step_status="trace-only",
|
||
pipeline_path_connected=False,
|
||
inputs=["step03_content_objects.json"],
|
||
outputs=["step04_internal_composition.json"],
|
||
note="현재는 trace 로 기록되지만 render payload 를 직접 만들지는 않음. composition_planner_debug 가 main 결정 (Step 6).",
|
||
)
|
||
|
||
# ─── Step 9: Frame Selection (per zone) ───
|
||
_write_step_artifact(
|
||
run_dir, 9, "frame_selection",
|
||
data={
|
||
"per_zone": [
|
||
{
|
||
"position": dz["position"],
|
||
"v4_rank1_frame_number": dz.get("v4_rank1_frame_number"),
|
||
"v4_selected_rank": dz.get("v4_selected_rank"),
|
||
"v4_template_id": dz.get("v4_template_id"),
|
||
"v4_confidence": dz.get("v4_confidence"),
|
||
"v4_label": dz.get("v4_label"),
|
||
"selection_path": dz.get("selection_path"),
|
||
"fallback_reason": dz.get("fallback_reason"),
|
||
"phase_z_status": dz.get("phase_z_status"),
|
||
"selected_template_id": dz.get("contract_id"),
|
||
"mapper_type": dz.get("mapper_type"),
|
||
"composition_score": dz.get("composition_score"),
|
||
"composition_rationale": dz.get("composition_rationale"),
|
||
}
|
||
for dz in debug_zones
|
||
],
|
||
},
|
||
step_status="partial",
|
||
pipeline_path_connected=True,
|
||
inputs=["step05_v4_evidence.json", "step06_composition_plan.json"],
|
||
outputs=["step09_frame_selection.json", "step09_frame_matching_candidates.html"],
|
||
note="V4 evidence 와 B4 통합 미완 — 별 axis. 현재 = composition planner 의 V4 rank-1 채택.",
|
||
)
|
||
# Step 9 HTML — V4 top candidates per zone (rank 1~4)
|
||
# IMP-08 N-R6 diagnostic exemption : this report path is post-decision
|
||
# reporting only. Runtime selection goes through _resolve_v4_section_key
|
||
# (4 sites). Direct dict lookup here is intentional — debug_zones carries
|
||
# dict-shape entries without v4_alias_keys plumbing, and a miss here only
|
||
# yields a "V4 entry missing" report line (runtime impact zero).
|
||
try:
|
||
with open(V4_RESULT_PATH, encoding="utf-8") as _vf:
|
||
_v4_full = yaml.safe_load(_vf)
|
||
_v4_sections = (_v4_full or {}).get("mdx_sections", {}) or {}
|
||
except Exception:
|
||
_v4_sections = {}
|
||
_candidates_html = ""
|
||
# path from steps/ 에서 figma_to_html_agent/blocks/{frame_id}/index.html
|
||
# steps → phase_z2 → run_id → runs → data → design_agent (5 levels up)
|
||
_frame_iframe_base = "../../../../../figma_to_html_agent/blocks"
|
||
for dz in debug_zones:
|
||
_section_ids = dz.get("source_section_ids", [])
|
||
_candidates_html += f"<h2>zone--{dz['position']} (sections: {', '.join(_section_ids)})</h2>"
|
||
for _sid in _section_ids:
|
||
_sec = _v4_sections.get(_sid)
|
||
if not _sec:
|
||
_candidates_html += f"<p>section <code>{_sid}</code>: V4 entry 없음</p>"
|
||
continue
|
||
_candidates_html += (
|
||
f"<h3>section <code>{_sid}</code>: {_sec.get('mdx_title', '?')}</h3>"
|
||
f"<p>answer_frame_number: <strong>{_sec.get('answer_frame_number', '-')}</strong>"
|
||
f" | is_holdout: {_sec.get('is_holdout', '-')}</p>"
|
||
f'<div style="display:grid;grid-template-columns:repeat(2,1fr);gap:16px;margin-bottom:24px;">'
|
||
)
|
||
for _j in (_sec.get("judgments_full32") or [])[:4]:
|
||
_rank = _j.get("v4_full_rank")
|
||
_frame_id = _j.get("frame_id")
|
||
_frame_index = ASSETS_SOURCE_BASE / str(_frame_id) / "index.html"
|
||
_has_preview = _frame_index.exists()
|
||
_css = "candidate-card candidate-rank1" if _rank == 1 else "candidate-card"
|
||
_preview_html = (
|
||
f'<div style="margin-top:8px;width:320px;height:180px;overflow:hidden;'
|
||
f'border:1px solid #cbd5e1;background:#fff;">'
|
||
f'<iframe src="{_frame_iframe_base}/{_frame_id}/index.html" '
|
||
f'style="width:1280px;height:720px;border:none;'
|
||
f'transform:scale(0.25);transform-origin:0 0;"></iframe>'
|
||
f'</div>'
|
||
if _has_preview else
|
||
f'<div style="margin-top:8px;width:320px;height:180px;'
|
||
f'border:1px dashed #cbd5e1;background:#f8fafc;display:flex;'
|
||
f'align-items:center;justify-content:center;color:#94a3b8;font-size:13px;">'
|
||
f'preview missing<br>'
|
||
f'(figma_to_html_agent/blocks/{_frame_id}/index.html 없음)'
|
||
f'</div>'
|
||
)
|
||
_candidates_html += (
|
||
f'<div class="{_css}">'
|
||
f'<strong>rank {_rank}: frame {_j.get("frame_number")} '
|
||
f'(<code>{_j.get("template_id")}</code>)</strong><br>'
|
||
f'confidence: {_j.get("confidence", 0):.4f} | '
|
||
f'label: <strong>{_j.get("label", "-")}</strong> | '
|
||
f'base: {_j.get("base", 0):.4f} | penalty: {_j.get("penalty", 0)}'
|
||
f'{_preview_html}'
|
||
f"</div>"
|
||
)
|
||
_candidates_html += "</div>"
|
||
_write_step_html(
|
||
run_dir, 9, "frame_matching_candidates",
|
||
title="V4 Frame Matching Candidates (top 4 per section)",
|
||
body_html=(
|
||
f"<p>각 zone 의 source section 마다 V4 rank 1~4 후보 표시. "
|
||
f"녹색 강조 = rank-1 (현재 선택된 frame).</p>{_candidates_html}"
|
||
),
|
||
step_status="done",
|
||
inputs=["step05_v4_evidence.json", "tests/matching/v4_full32_result.yaml"],
|
||
outputs=["step09_frame_selection.json", "step09_frame_matching_candidates.html"],
|
||
)
|
||
|
||
# ─── Step 10: Frame Contract 확인 ───
|
||
contracts_per_zone = []
|
||
for dz in debug_zones:
|
||
c = get_contract(dz.get("contract_id"))
|
||
contracts_per_zone.append({
|
||
"position": dz["position"],
|
||
"contract_id": dz.get("contract_id"),
|
||
"frame_id": (c or {}).get("frame_id"),
|
||
"family": (c or {}).get("family"),
|
||
"source_shape": (c or {}).get("source_shape"),
|
||
"cardinality": (c or {}).get("cardinality"),
|
||
"visual_hints": (c or {}).get("visual_hints"),
|
||
"accepted_content_types": (c or {}).get("accepted_content_types"),
|
||
"sub_zones": (c or {}).get("sub_zones"),
|
||
"payload_builder": ((c or {}).get("payload") or {}).get("builder"),
|
||
"payload_builder_options": ((c or {}).get("payload") or {}).get("builder_options"),
|
||
})
|
||
_write_step_artifact(
|
||
run_dir, 10, "frame_contract",
|
||
data={"per_zone": contracts_per_zone},
|
||
step_status="partial",
|
||
pipeline_path_connected=True,
|
||
inputs=["step09_frame_selection.json", "templates/phase_z2/catalog/frame_contracts.yaml"],
|
||
outputs=["step10_frame_contract.json"],
|
||
note="full contract dict (sub_zones / cardinality / visual_hints 포함). density envelope 미선언 (별 axis).",
|
||
)
|
||
|
||
# ─── Step 11: Slot Mapping (B4 placement, trace-only) ───
|
||
_write_step_artifact(
|
||
run_dir, 11, "slot_mapping",
|
||
data={
|
||
"per_zone": [
|
||
{
|
||
"position": dz["position"],
|
||
"selected_template_id": (dz.get("placement_trace") or {}).get("selected_template_id"),
|
||
"slot_assignments": (dz.get("placement_trace") or {}).get("slot_assignments") or [],
|
||
"rejection": (dz.get("placement_trace") or {}).get("rejection") or [],
|
||
"overflow_buffer": (dz.get("placement_trace") or {}).get("overflow_buffer") or [],
|
||
}
|
||
for dz in debug_zones
|
||
],
|
||
},
|
||
step_status="trace-only",
|
||
pipeline_path_connected=False,
|
||
inputs=["step04_internal_composition.json", "step10_frame_contract.json"],
|
||
outputs=["step11_slot_mapping.json"],
|
||
note="B4 PlacementPlan slot_assignments — render path 미연결. 실제 render slot 매핑은 mapper.py 의 builder.",
|
||
)
|
||
|
||
# ─── Step 12 IMP-47B u4 — AI repair proposal gather ───
|
||
# Wire gather_step12_ai_repair_proposals so reject / restructure
|
||
# provisional units reach the AI fallback router. Normal-path units
|
||
# (use_as_is / light_edit / non-provisional) skip via the catch-all
|
||
# route gate; flag-off runs short-circuit at the router. Stored locally
|
||
# for u5 (PARTIAL_OVERRIDES apply) + u6 (step12_ai_repair.json audit).
|
||
ai_repair_records = _run_step12_ai_repair(units)
|
||
|
||
# ─── Step 12 IMP-47B u5 — Apply PARTIAL_OVERRIDES proposals ───
|
||
# Mirror the per-unit position derivation from the render loop above
|
||
# (L3789-3796); apply merges slots into zone slot_payload, loud-fails
|
||
# unsupported kinds via apply_status marker.
|
||
unit_positions: list[str] = []
|
||
for _i, _unit in enumerate(units):
|
||
_pos = positions[_i] if _i < len(positions) else f"zone_{_i}"
|
||
_plan_record = render_record_by_unit_id.get(id(_unit))
|
||
if _plan_record is not None and _plan_record.get("position"):
|
||
_pos = _plan_record["position"]
|
||
unit_positions.append(_pos)
|
||
_apply_ai_repair_proposals_to_zones(ai_repair_records, unit_positions, zones_data)
|
||
|
||
# ─── Step 12 IMP-47B u7 — Post-AI source_section_ids coverage invariant ───
|
||
# Structural defense: AI repair must not silently drop a unit's
|
||
# source_section_ids. dropped 절대 룰 — text_block / table / image /
|
||
# details deletion forbidden. Result feeds u6 audit (below) and
|
||
# u8 slide_status.ai_repair_status surfacing.
|
||
ai_repair_coverage_invariant = _check_post_ai_coverage_invariant(
|
||
units, ai_repair_records,
|
||
)
|
||
|
||
# ─── Step 12 IMP-47B u6 — AI repair audit artifact ───
|
||
# Persist per-unit gather/apply outcomes (route_hint, skip_reason,
|
||
# apply_status, ai_called, proposal kind, cache_key, fingerprints)
|
||
# so reviewers can audit which units reached the AI fallback router
|
||
# and what happened. Flag-off default → every record has
|
||
# ai_called=False + apply_status='no_proposal'; flag-on +
|
||
# provisional reject/restructure → router_short_circuit (cache miss
|
||
# without client) or applied:partial_overrides (cache hit / live AI).
|
||
# u7 coverage_invariant rides alongside per_unit for reviewers.
|
||
_write_step_artifact(
|
||
run_dir, 12, "ai_repair",
|
||
data={
|
||
"per_unit": ai_repair_records,
|
||
"coverage_invariant": ai_repair_coverage_invariant,
|
||
},
|
||
step_status="done",
|
||
pipeline_path_connected=True,
|
||
inputs=["step10_frame_contract.json", "step02_normalized.json"],
|
||
outputs=["step12_ai_repair.json"],
|
||
note="IMP-47B u6 — Step 12 AI repair gather + apply records per unit (route, skip_reason, apply_status, proposal). u7 coverage_invariant = pre/post AI source_section_ids set comparison.",
|
||
)
|
||
|
||
# ─── Step 12: Slot Payload (actual values, mapper.py 결과) ───
|
||
_write_step_artifact(
|
||
run_dir, 12, "slot_payload",
|
||
data={
|
||
"per_zone": [
|
||
{
|
||
"position": zd["position"],
|
||
"template_id": zd["template_id"],
|
||
"builder": (get_contract(zd["template_id"]) or {}).get("payload", {}).get("builder"),
|
||
"slot_payload": zd["slot_payload"],
|
||
"content_weight": zd.get("content_weight"),
|
||
"min_height_px": zd.get("min_height_px"),
|
||
}
|
||
for zd in zones_data
|
||
],
|
||
},
|
||
step_status="done",
|
||
pipeline_path_connected=True,
|
||
inputs=["step10_frame_contract.json", "step02_normalized.json"],
|
||
outputs=["step12_slot_payload.json"],
|
||
note="map_with_contract 결과 — actual slot_payload 값 그대로 (key 만 X).",
|
||
)
|
||
# IMP-42 u3 (#71) — unconditional Step 12 slot_payload emit DIAG log.
|
||
_emit_diag_zones_shape("Step 12 slot_payload emit", zones_data)
|
||
|
||
# 6. Build layout CSS — horizontal-2 = dynamic heights (regression preserve), 그 외 = fr default.
|
||
# Step D-ext : override_zone_geometries 가 들어오면 layout_css 강제.
|
||
# IMP-86 u2 — pre-build layout invariant guard. zones_data / debug_zones
|
||
# MUST be cardinality- and position-aligned with the active layout
|
||
# preset's css_areas tokens before build_layout_css derives heights_px
|
||
# / widths_px and _compute_per_zone_geometry validates them against R/C.
|
||
# If a mapper FitError path (IMP-86 u1) — or any future zone-drop path —
|
||
# forgets to append a placeholder, the resulting shape drift would
|
||
# surface as a confusing `heights_px length N != grid rows R=M`
|
||
# ValueError deep inside the geometry helper. This guard fails fast at
|
||
# the pipeline boundary with preset / expected positions / actual
|
||
# positions / count diagnostics so the root cause is obvious from the
|
||
# log line (factual_verification: value + path + upstream).
|
||
_active_preset = LAYOUT_PRESETS[layout_preset]
|
||
_expected_positions = _parse_css_areas(_active_preset["css_areas"])[1]
|
||
_actual_positions = [zd["position"] for zd in zones_data]
|
||
_debug_positions = [dz["position"] for dz in debug_zones]
|
||
if (
|
||
len(zones_data) != len(_expected_positions)
|
||
or len(debug_zones) != len(_expected_positions)
|
||
or sorted(_actual_positions) != sorted(_expected_positions)
|
||
or sorted(_debug_positions) != sorted(_expected_positions)
|
||
):
|
||
raise ValueError(
|
||
"phase_z2_pipeline pre-build layout invariant violation: "
|
||
f"layout_preset={layout_preset!r} "
|
||
f"css_areas={_active_preset['css_areas']!r} "
|
||
f"expected_positions={_expected_positions!r} "
|
||
f"zones_data_positions={_actual_positions!r} "
|
||
f"debug_zones_positions={_debug_positions!r} "
|
||
f"zones_count={len(zones_data)} "
|
||
f"debug_count={len(debug_zones)} "
|
||
f"expected_count={len(_expected_positions)}"
|
||
)
|
||
layout_css = build_layout_css(
|
||
layout_preset, zones_data, override_zone_geometries=override_zone_geometries
|
||
)
|
||
# IMP-09 PR 1 — unified per-zone geometry aggregation across all
|
||
# layouts. Spanning zones in 2-D layouts (T / 2x2 from PR 2 onward)
|
||
# are handled by _compute_per_zone_geometry; in PR 1 the helper
|
||
# operates on row/col-static or row-dynamic / col-dynamic outputs.
|
||
per_zone_geo = _compute_per_zone_geometry(layout_css, debug_zones, GRID_GAP)
|
||
for dz, geo in zip(debug_zones, per_zone_geo):
|
||
dz["height_px"] = geo["zone_height_px"]
|
||
dz["ratio"] = geo["zone_height_ratio"]
|
||
dz["width_px"] = geo["zone_width_px"]
|
||
dz["width_ratio"] = geo["zone_width_ratio"]
|
||
if layout_css["dynamic_rows"] and layout_css.get("dynamic_cols"):
|
||
print(
|
||
f" zones : 2-D heights {layout_css['heights_px']} px, "
|
||
f"widths {layout_css['widths_px']} px, "
|
||
f"ratios {layout_css['ratios']}, "
|
||
f"width_ratios {layout_css['width_ratios']}"
|
||
)
|
||
elif layout_css["dynamic_rows"]:
|
||
print(f" zones : heights {layout_css['heights_px']} px, ratios {layout_css['ratios']}")
|
||
elif layout_css.get("dynamic_cols"):
|
||
print(f" zones : widths {layout_css['widths_px']} px, width_ratios {layout_css['width_ratios']}")
|
||
else:
|
||
print(f" zones : fr default ({layout_css['cols']} / {layout_css['rows']})")
|
||
|
||
# ─── Step 7: Slide-Level Layout (선택된 layout + 후보 list — Step 7-conn) ───
|
||
# Step 7-conn axis (사용자 lock 2026-05-08) — select_layout_candidates(unit_count)
|
||
# 결과를 artifact 에 기록 (passive). default 결정 자체는 무변 (layout_preset 그대로).
|
||
layout_candidates_list = select_layout_candidates(len(units))
|
||
_write_step_artifact(
|
||
run_dir, 7, "layout",
|
||
data={
|
||
"layout_preset": layout_preset,
|
||
"layout_css": layout_css,
|
||
"zones_count": len(zones_data),
|
||
"unit_count": len(units),
|
||
"layout_candidates": layout_candidates_list,
|
||
# Step 7-A axis : user override trace
|
||
"layout_override_applied": layout_override_applied,
|
||
"auto_layout_preset": auto_layout_preset,
|
||
},
|
||
step_status="partial",
|
||
pipeline_path_connected=True,
|
||
inputs=["step06_composition_plan.json"],
|
||
outputs=["step07_layout.json", "step07_selected_layout.html"],
|
||
note=(
|
||
"count-based v0 — 들여쓰기 / 정렬 미세 layout 미구현 (Step 7 ⚠ partial). "
|
||
"Step 7-conn (사용자 lock 2026-05-08): unit_count → layout_candidates list "
|
||
"(select_layout_candidates) artifact 기록. default 결정 자체는 무변 — "
|
||
"candidates[0] 가 default (default_selection: true), 나머지는 alternative. "
|
||
"Step 9 application_plan input."
|
||
),
|
||
)
|
||
# Step 7 HTML — slide-base 위 실제 zone 박스 위치 시각화 (constants 와 1:1 일치)
|
||
_slide_w, _slide_h, _body_h = SLIDE_W, SLIDE_H, SLIDE_BODY_HEIGHT
|
||
_zone_palette = ["#dbeafe", "#fef3c7", "#dcfce7", "#fce7f3", "#ede9fe"]
|
||
_zone_borders = ["#3b82f6", "#f59e0b", "#16a34a", "#db2777", "#7c3aed"]
|
||
_grid_template = layout_css.get("rows") or "1fr"
|
||
_zone_visuals_7 = ""
|
||
for i, dz in enumerate(debug_zones):
|
||
_color = _zone_palette[i % len(_zone_palette)]
|
||
_border = _zone_borders[i % len(_zone_borders)]
|
||
_zone_visuals_7 += (
|
||
f'<div style="background:{_color};border:3px solid {_border};'
|
||
f'display:flex;flex-direction:column;justify-content:center;align-items:center;'
|
||
f'padding:16px;text-align:center;">'
|
||
f'<div style="font-size:18px;font-weight:700;margin-bottom:6px;">zone--{dz["position"]}</div>'
|
||
f'<div style="font-size:14px;"><code>{dz.get("v4_template_id") or "-"}</code></div>'
|
||
f'<div style="font-size:13px;color:#475569;margin-top:4px;">'
|
||
f'height: {dz.get("height_px") or "?"}px / ratio: {dz.get("ratio") or "?"}'
|
||
f'</div></div>'
|
||
)
|
||
_slide_visual_7 = (
|
||
f'<div style="width:{_slide_w}px;border:2px solid #1e293b;background:#fff;margin:16px 0;'
|
||
f'box-shadow:0 4px 16px rgba(0,0,0,0.1);">'
|
||
f'<div style="padding:14px 40px;border-bottom:1px solid #cbd5e1;background:#f8fafc;">'
|
||
f'<div style="font-size:15px;color:#64748b;">[slide-title placeholder]</div></div>'
|
||
f'<div style="padding:0 40px;height:{_body_h}px;display:grid;'
|
||
f'grid-template-rows:{_grid_template};grid-template-columns:{layout_css.get("cols") or "1fr"};'
|
||
f'gap:{GRID_GAP}px;box-sizing:border-box;padding-top:12px;padding-bottom:12px;">'
|
||
f'{_zone_visuals_7}</div>'
|
||
f'<div style="padding:10px 40px;border-top:1px solid #cbd5e1;background:#f8fafc;text-align:right;">'
|
||
f'<span style="font-size:13px;color:#64748b;">[slide-footer]</span></div>'
|
||
f'</div>'
|
||
)
|
||
# Step 7-conn — layout 후보 list 시각 (default = candidates[0]).
|
||
_candidates_rows = ""
|
||
for i, lc in enumerate(layout_candidates_list):
|
||
_is_default = (i == 0)
|
||
_is_selected = (lc == layout_preset)
|
||
_badge = (
|
||
'<span style="background:#dcfce7;color:#166534;padding:2px 8px;border-radius:3px;'
|
||
'font-size:11px;font-weight:600;margin-left:6px;">selected</span>'
|
||
if _is_selected else ''
|
||
)
|
||
_default_mark = (
|
||
'<span style="background:#e0e7ff;color:#3730a3;padding:2px 8px;border-radius:3px;'
|
||
'font-size:11px;font-weight:600;margin-left:6px;">default</span>'
|
||
if _is_default else
|
||
'<span style="background:#f1f5f9;color:#64748b;padding:2px 8px;border-radius:3px;'
|
||
'font-size:11px;font-weight:600;margin-left:6px;">alternative</span>'
|
||
)
|
||
_candidates_rows += (
|
||
f'<li style="padding:6px 0;"><code>{lc}</code>{_default_mark}{_badge}</li>'
|
||
)
|
||
_candidates_block = (
|
||
f'<h2>Layout Candidates (unit_count={len(units)})</h2>'
|
||
f'<p>source: <code>templates/phase_z2/layouts/layouts.yaml</code> + '
|
||
f'<code>select_layout_candidates(unit_count)</code> '
|
||
f'(Step 7-B). default = first entry. <strong>현재 single-decision logic 은 '
|
||
f'default 만 사용</strong> — Step 9 application_plan 진입 시 후보 평가.</p>'
|
||
f'<ul>{_candidates_rows}</ul>'
|
||
)
|
||
_write_step_html(
|
||
run_dir, 7, "selected_layout",
|
||
title=f"Selected Layout — {layout_preset}",
|
||
body_html=(
|
||
f"<h2>Layout Preset: <code>{layout_preset}</code></h2>"
|
||
f"<p>slide canvas {_slide_w}×{_slide_h}px / slide-body {SLIDE_BODY_WIDTH}×{_body_h}px (left {SLIDE_BODY_LEFT} / top {SLIDE_BODY_TOP}). "
|
||
f"안 zone 박스 = 실제 비율 그대로.</p>"
|
||
f"{_slide_visual_7}"
|
||
f"{_candidates_block}"
|
||
f"<h2>Layout CSS (raw)</h2>"
|
||
f"<pre><code>{json.dumps(layout_css, ensure_ascii=False, indent=2)}</code></pre>"
|
||
),
|
||
step_status="partial",
|
||
inputs=["step06_composition_plan.json"],
|
||
outputs=["step07_layout.json", "step07_selected_layout.html"],
|
||
)
|
||
|
||
# ─── Step 8: Zone + Region Ratio (계획값 only — render 전) ───
|
||
# Step 8-conn axis (사용자 lock 2026-05-08) — region/display 후보 list per zone (passive 기록).
|
||
# placeholder signals (Step 3/4 부재 종속) :
|
||
# region_count = 1 (text-only assumption)
|
||
# content_type = "text_block"
|
||
# flow_type / role_pattern / has_visual_element / details_presence /
|
||
# large_table / long_text / fits_in_region = None or False
|
||
# Step 3/4 (Content Object 추출 + Internal Composition Planning) 활성화 시
|
||
# 이 placeholder 가 실제 content_object 신호로 교체됨 (별 axis).
|
||
_step8_placeholder_signals = {
|
||
"region_count": 1,
|
||
"content_type": "text_block",
|
||
"flow_type": None,
|
||
"role_pattern": None,
|
||
"ratio_asymmetric": False,
|
||
"has_visual_element": False,
|
||
"details_presence": False,
|
||
"large_table": False,
|
||
"long_text": False,
|
||
"fits_in_region": None,
|
||
"_note": (
|
||
"Step 3/4 부재 종속 placeholder — 모든 zone 을 text-only / region_count=1 "
|
||
"로 가정. 실제 content_object 신호 활성화는 별 axis."
|
||
),
|
||
}
|
||
zone_region_plans = []
|
||
for dz in debug_zones:
|
||
contract = get_contract(dz.get("contract_id")) or {}
|
||
sub_zones = contract.get("sub_zones") or []
|
||
cardinality = contract.get("cardinality") or {}
|
||
visual_hints = contract.get("visual_hints") or {}
|
||
# Step 8-conn — region / display strategy 후보 (placeholder signals).
|
||
_region_cands = select_region_layout_candidates(
|
||
region_count=_step8_placeholder_signals["region_count"],
|
||
flow_type=_step8_placeholder_signals["flow_type"],
|
||
role_pattern=_step8_placeholder_signals["role_pattern"],
|
||
ratio_asymmetric=_step8_placeholder_signals["ratio_asymmetric"],
|
||
has_visual_element=_step8_placeholder_signals["has_visual_element"],
|
||
details_presence=_step8_placeholder_signals["details_presence"],
|
||
large_table=_step8_placeholder_signals["large_table"],
|
||
long_text=_step8_placeholder_signals["long_text"],
|
||
)
|
||
_display_cands = select_display_strategy_candidates(
|
||
content_type=_step8_placeholder_signals["content_type"],
|
||
long_text=_step8_placeholder_signals["long_text"],
|
||
large_table=_step8_placeholder_signals["large_table"],
|
||
fits_in_region=_step8_placeholder_signals["fits_in_region"],
|
||
)
|
||
zone_region_plans.append({
|
||
"position": dz["position"],
|
||
"zone_height_px_planned": dz.get("height_px"),
|
||
"zone_ratio_planned": dz.get("ratio"),
|
||
"zone_width_px_planned": dz.get("width_px"),
|
||
"zone_col_ratio_planned": dz.get("width_ratio"),
|
||
"min_height_px": visual_hints.get("min_height_px"),
|
||
"frame_cardinality_strict": cardinality.get("strict"),
|
||
"sub_zones_planned": [
|
||
{
|
||
"id": sz.get("id"),
|
||
"role": sz.get("role"),
|
||
"accepts": sz.get("accepts"),
|
||
"cardinality_strict": (sz.get("cardinality") or {}).get("strict"),
|
||
"partial_target_path": sz.get("partial_target_path"),
|
||
}
|
||
for sz in sub_zones
|
||
],
|
||
"child_distribution_note": (
|
||
"현재 child zone (sub_zones 안 sections) 균등 분배 — "
|
||
"Step 8 region-level ratio ⚠ partial."
|
||
),
|
||
# Step 8-conn — region/display 후보 (placeholder signals)
|
||
"region_layout_candidates": _region_cands,
|
||
"display_strategy_candidates": _display_cands,
|
||
})
|
||
_write_step_artifact(
|
||
run_dir, 8, "zone_region_ratios",
|
||
data={
|
||
"zone_heights_px_planned": layout_css.get("heights_px"),
|
||
"zone_widths_px_planned": layout_css.get("widths_px"),
|
||
"zone_ratios_planned": layout_css.get("ratios"),
|
||
"zone_col_ratios_planned": layout_css.get("width_ratios"),
|
||
"per_zone_plan": zone_region_plans,
|
||
# Step 8-conn placeholder signals (사람이 한 곳에서 caveat 확인)
|
||
"step8_conn_placeholder_signals": _step8_placeholder_signals,
|
||
},
|
||
step_status="partial",
|
||
pipeline_path_connected=True,
|
||
inputs=["step07_layout.json", "step10_frame_contract.json"],
|
||
outputs=["step08_zone_region_ratios.json", "step08_zone_content_placement.html"],
|
||
note=(
|
||
"계획값 only. 실측값은 Step 14 (visual_check). "
|
||
"zone-level 만 dynamic, region-level (sub_zone 안 sections) 은 균등 분배 (1/1/1). "
|
||
"F29 처럼 비균등 콘텐츠 (transform_table + plain) 는 첫 cell 부족 가능 — Step 8 ⚠ partial. "
|
||
"Step 8-conn (사용자 lock 2026-05-08): per_zone_plan[i] 에 region_layout_candidates + "
|
||
"display_strategy_candidates 추가 (passive 기록). placeholder signals = text-only / "
|
||
"region_count=1 (Step 3/4 부재 종속). 실제 content_object 신호 활성화는 별 axis. "
|
||
"Step 9 application_plan input."
|
||
),
|
||
)
|
||
# Step 8 HTML — zone + sub_zone + sections 시각 박스 (균등 분배 보임)
|
||
# Step 8-conn — region/display 후보 list 도 zone 별 박스 아래 표시.
|
||
_zone_visuals_8 = ""
|
||
for i, plan in enumerate(zone_region_plans):
|
||
_color = _zone_palette[i % len(_zone_palette)]
|
||
_border = _zone_borders[i % len(_zone_borders)]
|
||
_zone_h = plan['zone_height_px_planned'] or 300
|
||
_sub_zones_html = ""
|
||
for sz in plan['sub_zones_planned']:
|
||
_card = sz['cardinality_strict'] or 1
|
||
_accepts = ', '.join(sz['accepts'] or [])
|
||
# sub_zone 안에 section 박스 cardinality 만큼 균등 분배
|
||
_section_boxes = "".join(
|
||
f'<div style="flex:1;border:1px dashed #94a3b8;background:rgba(255,255,255,0.7);'
|
||
f'padding:6px;display:flex;align-items:center;justify-content:center;'
|
||
f'font-size:12px;color:#475569;">section {k+1} (균등 1/{_card})</div>'
|
||
for k in range(_card)
|
||
)
|
||
_sub_zones_html += (
|
||
f'<div style="flex:1;background:rgba(59,130,246,0.08);border:2px solid #3b82f6;'
|
||
f'padding:8px;display:flex;flex-direction:column;gap:4px;">'
|
||
f'<div style="font-weight:700;font-size:13px;padding-bottom:6px;border-bottom:1px solid #3b82f6;">'
|
||
f'<code>{sz["id"]}</code><br>'
|
||
f'<span style="font-size:11px;font-weight:400;color:#475569;">'
|
||
f'cardinality.strict={_card} | accepts: {_accepts}'
|
||
f'</span>'
|
||
f'</div>'
|
||
f'{_section_boxes}'
|
||
f'</div>'
|
||
)
|
||
# Step 8-conn — region / display strategy 후보 시각.
|
||
_region_pills = " ".join(
|
||
f'<span style="background:#dbeafe;color:#1e40af;padding:2px 10px;border-radius:12px;'
|
||
f'font-size:11px;font-weight:600;margin-right:4px;">'
|
||
f'{rc}{" ★" if k == 0 else ""}</span>'
|
||
for k, rc in enumerate(plan.get("region_layout_candidates") or [])
|
||
)
|
||
_display_pills = " ".join(
|
||
f'<span style="background:#dcfce7;color:#166534;padding:2px 10px;border-radius:12px;'
|
||
f'font-size:11px;font-weight:600;margin-right:4px;">'
|
||
f'{ds}{" ★" if k == 0 else ""}</span>'
|
||
for k, ds in enumerate(plan.get("display_strategy_candidates") or [])
|
||
)
|
||
_zone_visuals_8 += (
|
||
f'<h3>zone--{plan["position"]} ({_zone_h}px planned, '
|
||
f'min_height {plan["min_height_px"]}px, '
|
||
f'frame_cardinality_strict {plan["frame_cardinality_strict"]})</h3>'
|
||
f'<div style="height:{min(_zone_h, 360)}px;background:{_color};border:3px solid {_border};'
|
||
f'display:flex;gap:8px;padding:12px;box-sizing:border-box;margin-bottom:8px;">'
|
||
f'{_sub_zones_html}'
|
||
f'</div>'
|
||
f'<p style="margin-top:0;color:#64748b;font-size:13px;"><em>{plan["child_distribution_note"]}</em></p>'
|
||
f'<div style="background:#f8fafc;border-left:3px solid #94a3b8;padding:8px 12px;margin-bottom:16px;font-size:13px;">'
|
||
f'<div style="margin-bottom:4px;"><strong>region_layout_candidates</strong> (★ default): {_region_pills}</div>'
|
||
f'<div><strong>display_strategy_candidates</strong> (★ default): {_display_pills}</div>'
|
||
f'</div>'
|
||
)
|
||
_write_step_html(
|
||
run_dir, 8, "zone_content_placement",
|
||
title="Zone + Child Zone Placement Plan (pre-render)",
|
||
body_html=(
|
||
f"<p><strong>중요:</strong> 이 페이지는 <em>계획값</em> only. 실제 측정값 (clientHeight / scrollHeight / overflow) 은 "
|
||
f"<code>step14_visual_check.json</code> 에서 봄.</p>"
|
||
f"<p>각 zone 안에 sub_zones 가 columns 로 나열, 각 sub_zone 안 sections 가 cardinality.strict 만큼 균등 row 분배. "
|
||
f"F29 process_column 의 첫 section 이 transform_table (AS-IS/TO-BE 표) 인데 균등 분배로 capacity 부족 → 10px overflow 직접 원인.</p>"
|
||
f'<div style="background:#fef3c7;border-left:3px solid #f59e0b;padding:8px 12px;margin-bottom:16px;font-size:13px;">'
|
||
f'<strong>Step 8-conn placeholder caveat (사용자 lock 2026-05-08):</strong> '
|
||
f'region_layout_candidates / display_strategy_candidates 는 현재 '
|
||
f'<code>region_count=1 / content_type="text_block"</code> placeholder 신호로만 산출. '
|
||
f'Step 3/4 (Content Object 추출 + Internal Composition Planning) 활성화 시 '
|
||
f'실제 content_object 신호로 교체 (별 axis).'
|
||
f'</div>'
|
||
f"{_zone_visuals_8}"
|
||
),
|
||
step_status="partial",
|
||
inputs=["step07_layout.json", "step10_frame_contract.json"],
|
||
outputs=["step08_zone_region_ratios.json", "step08_zone_content_placement.html"],
|
||
)
|
||
|
||
# ─── Step 9 v0: Passive Application Plan (사용자 lock 2026-05-08) ───
|
||
# status.md §2 schema 따라 per-unit application_plan 생성.
|
||
# v0 = passive — Step 6 default 그대로 사용. runtime 결과 무변.
|
||
# invariant 5 가지 (status.md §4) 만족 :
|
||
# 1. len(application_candidates) == len(v4_candidates) per unit
|
||
# 2. application_candidates[i].template_id == v4_candidates[i].template_id (zip 일관)
|
||
# 3. current_default_candidate == v4_candidates[0].template_id (application_status="ok")
|
||
# 또는 null (application_status="no_v4_candidate")
|
||
# 4. len(units) == Step 6 의 plan_composition 결과 (무변)
|
||
# 5. application_status == "ok" iff len(v4_candidates) > 0 iff candidate_status == "ok"
|
||
|
||
# IMP-06 blocker-fix (Codex #13 Blocker 3 / #16) — pre-build per-unit
|
||
# plan-aware lookup so Step 9 application_plan can carry position +
|
||
# assignment_source + override-flag fields. Render-record is the canonical
|
||
# plan-derived per-position view (built post-frame_overrides); match by
|
||
# object identity since render_records[i]["unit"] holds the same instance
|
||
# ref as the entry in `units`.
|
||
plan_record_by_unit_id = {}
|
||
if "render_records" in locals() and render_records:
|
||
for rec in render_records:
|
||
u = rec.get("unit")
|
||
if u is None:
|
||
continue
|
||
plan_record_by_unit_id[id(u)] = rec
|
||
|
||
application_plan_units = []
|
||
for i, unit in enumerate(units):
|
||
# zone_region_plans 는 unit i 와 1:1 (Step 6 unit → Step 8 zone_plan).
|
||
zone_plan = zone_region_plans[i] if i < len(zone_region_plans) else {}
|
||
selection_trace = v4_fallback_traces.get(unit.source_section_ids[0], {})
|
||
plan_record = plan_record_by_unit_id.get(id(unit))
|
||
|
||
# Step 7-A axis 보강 — reject 포함 모든 V4 judgments (frontend UI 가
|
||
# 모든 frame 의 png 를 카드로 보여주기 위함).
|
||
# unit_id = source_section_ids join. parent_merged 는 첫 section 의
|
||
# judgments 사용 (parent V4 entry 가 그 section 에 있으므로).
|
||
# IMP-08 B-3 : forward sub-section V4 aliases (decimal heading_number)
|
||
# when canonical ordinal id misses; U1 default = empty list (no change).
|
||
_first_sid = unit.source_section_ids[0]
|
||
v4_all_for_unit = lookup_v4_all_judgments(
|
||
v4, _first_sid, alias_keys=section_alias_by_id.get(_first_sid)
|
||
)
|
||
|
||
# IMP-32 u4 — per-unit application_plan dict assembly extracted into
|
||
# _build_application_plan_unit(...). Per-index/per-id lookups
|
||
# (zone_region_plans[i], v4_fallback_traces.get(...),
|
||
# plan_record_by_unit_id.get(id(unit)), section_alias_by_id,
|
||
# lookup_v4_all_judgments(...)) stay at the call-site.
|
||
application_plan_units.append(
|
||
_build_application_plan_unit(
|
||
unit,
|
||
zone_plan,
|
||
selection_trace,
|
||
plan_record,
|
||
v4_all_for_unit,
|
||
layout_preset,
|
||
layout_candidates_list,
|
||
)
|
||
)
|
||
|
||
units_with_no_v4 = [
|
||
u["unit_id"] for u in application_plan_units
|
||
if u["application_status"] == "no_v4_candidate"
|
||
]
|
||
|
||
_write_step_artifact(
|
||
run_dir, 9, "application_plan",
|
||
data={
|
||
"units": application_plan_units,
|
||
"candidate_status_summary": {
|
||
"units_with_no_v4_candidate": units_with_no_v4,
|
||
"units_with_fallback": [
|
||
u["unit_id"] for u in application_plan_units if u.get("fallback_used")
|
||
],
|
||
},
|
||
"fallback_policy": comp_debug.get("v4_fallback_summary"),
|
||
# Step 7-A axis : user override trace
|
||
"frame_overrides_applied": frame_overrides_applied,
|
||
"frame_overrides_skipped": frame_overrides_skipped,
|
||
# IMP-06 blocker-fix (Codex #13 Blocker 3 / #16) — surface the
|
||
# section-assignment plan + summary at Step 9 top level so
|
||
# frontend / downstream readers do not have to dive into
|
||
# comp_debug to see override impact.
|
||
"section_assignment_plan": comp_debug.get("section_assignment_plan"),
|
||
"section_assignment_summary": comp_debug.get("section_assignment_summary"),
|
||
"v0_lock_note": (
|
||
"Step 9 v0 passive (사용자 lock 2026-05-08). "
|
||
"Step 6 default 그대로 사용 — runtime 결과 byte-동일. "
|
||
"auto decision / scoring 은 Step 9 v1 (별 axis)."
|
||
),
|
||
},
|
||
step_status="partial",
|
||
pipeline_path_connected=True,
|
||
inputs=[
|
||
"step05_v4_evidence.json",
|
||
"step06_composition_plan.json",
|
||
"step07_layout.json",
|
||
"step08_zone_region_ratios.json",
|
||
],
|
||
outputs=["step09_application_plan.json", "step09_application_plan.html"],
|
||
note=(
|
||
"Step 9 v0 passive application_plan trace (사용자 lock 2026-05-08). "
|
||
"V4 후보 + layout / region / display 후보 + V4 label → application_mode "
|
||
"변환을 side-by-side 로 기록. v0 invariant 5 가지 (status.md §4) 만족. "
|
||
"Step 6 의 default 결정 그대로 (current_default_candidate). "
|
||
"auto decision 은 Step 9 v1 (별 axis). region/display 후보는 Step 8-conn "
|
||
"의 placeholder signal 종속 (Step 3/4 부재). "
|
||
"Step 7-A axis (2026-05-08): frame_overrides_applied 가 사용자 LayoutPanel/"
|
||
"FramePanel 선택값을 강제 적용한 trace."
|
||
),
|
||
)
|
||
|
||
# Step 9 v0 HTML — per-unit table + application_mode pill.
|
||
_mode_color = {
|
||
"direct_insert": ("#dcfce7", "#166534"), # green
|
||
"same_frame_with_adjustment": ("#dbeafe", "#1e40af"), # blue
|
||
"layout_or_region_change": ("#fef3c7", "#92400e"), # yellow (manual)
|
||
"exclude": ("#fee2e2", "#991b1b"), # red
|
||
}
|
||
_unit_blocks_9 = ""
|
||
for u in application_plan_units:
|
||
_status_badge = (
|
||
f'<span style="background:#dcfce7;color:#166534;padding:2px 8px;border-radius:3px;'
|
||
f'font-size:11px;font-weight:600;margin-left:6px;">application_status: ok</span>'
|
||
if u["application_status"] == "ok" else
|
||
f'<span style="background:#fee2e2;color:#991b1b;padding:2px 8px;border-radius:3px;'
|
||
f'font-size:11px;font-weight:600;margin-left:6px;">application_status: no_v4_candidate</span>'
|
||
)
|
||
_default_html = (
|
||
f'<code>{u["current_default_candidate"]}</code>'
|
||
if u["current_default_candidate"] else '<em>null</em>'
|
||
)
|
||
_fallback_html = (
|
||
f' | <strong>selection_path:</strong> <code>{u.get("selection_path")}</code>'
|
||
f' | <strong>selected_v4_rank:</strong> {u.get("selected_v4_rank")}'
|
||
f' | <strong>fallback_reason:</strong> <code>{u.get("fallback_reason")}</code>'
|
||
if u.get("fallback_used") else
|
||
f' | <strong>selection_path:</strong> <code>{u.get("selection_path")}</code>'
|
||
)
|
||
_layout_pills = " ".join(
|
||
f'<span style="background:#e0e7ff;color:#3730a3;padding:1px 8px;border-radius:10px;'
|
||
f'font-size:11px;margin-right:3px;">{lc}{" ★" if k == 0 else ""}</span>'
|
||
for k, lc in enumerate(u["layout_candidates"])
|
||
)
|
||
_region_pills = " ".join(
|
||
f'<span style="background:#dbeafe;color:#1e40af;padding:1px 8px;border-radius:10px;'
|
||
f'font-size:11px;margin-right:3px;">{rc}{" ★" if k == 0 else ""}</span>'
|
||
for k, rc in enumerate(u["region_layout_candidates"])
|
||
)
|
||
_display_pills = " ".join(
|
||
f'<span style="background:#dcfce7;color:#166534;padding:1px 8px;border-radius:10px;'
|
||
f'font-size:11px;margin-right:3px;">{ds}{" ★" if k == 0 else ""}</span>'
|
||
for k, ds in enumerate(u["display_strategy_candidates"])
|
||
)
|
||
_app_rows = ""
|
||
for k, ac in enumerate(u["application_candidates"]):
|
||
_bg, _fg = _mode_color.get(ac["application_mode"], ("#f1f5f9", "#475569"))
|
||
_is_default = (ac["template_id"] == u["current_default_candidate"])
|
||
_default_mark = (
|
||
' <span style="background:#fef3c7;color:#92400e;padding:1px 6px;border-radius:3px;'
|
||
'font-size:10px;font-weight:600;">current_default</span>'
|
||
if _is_default else ''
|
||
)
|
||
_app_rows += (
|
||
f'<tr>'
|
||
f'<td style="padding:6px 10px;border-bottom:1px solid #e2e8f0;">'
|
||
f'<code>{ac["template_id"]}</code>{_default_mark}</td>'
|
||
f'<td style="padding:6px 10px;border-bottom:1px solid #e2e8f0;font-size:12px;">'
|
||
f'{ac["v4_label"]}</td>'
|
||
f'<td style="padding:6px 10px;border-bottom:1px solid #e2e8f0;">'
|
||
f'<span style="background:{_bg};color:{_fg};padding:2px 8px;border-radius:3px;'
|
||
f'font-size:11px;font-weight:600;">{ac["application_mode"]}</span></td>'
|
||
f'<td style="padding:6px 10px;border-bottom:1px solid #e2e8f0;font-size:12px;">'
|
||
f'{"✓" if ac["auto_applicable"] else "✗"}</td>'
|
||
f'<td style="padding:6px 10px;border-bottom:1px solid #e2e8f0;font-size:11px;color:#64748b;">'
|
||
f'{ac["delegated_to"] or "—"}</td>'
|
||
f'</tr>'
|
||
)
|
||
_unit_blocks_9 += (
|
||
f'<div style="background:#fff;border:1px solid #cbd5e1;border-radius:6px;padding:16px;margin-bottom:20px;">'
|
||
f'<h3 style="margin-top:0;">unit: <code>{u["unit_id"]}</code> {_status_badge}</h3>'
|
||
f'<p style="margin:4px 0;font-size:13px;color:#475569;">'
|
||
f'<strong>layout_preset (default):</strong> <code>{u["layout_preset"]}</code> | '
|
||
f'<strong>current_default_candidate:</strong> {_default_html}{_fallback_html}</p>'
|
||
f'<p style="margin:4px 0;font-size:13px;"><strong>layout_candidates (★ default):</strong> {_layout_pills}</p>'
|
||
f'<p style="margin:4px 0;font-size:13px;"><strong>region_layout_candidates (★ default, placeholder):</strong> {_region_pills}</p>'
|
||
f'<p style="margin:4px 0;font-size:13px;"><strong>display_strategy_candidates (★ default, placeholder):</strong> {_display_pills}</p>'
|
||
f'<table style="width:100%;border-collapse:collapse;margin-top:10px;font-size:13px;">'
|
||
f'<thead><tr style="background:#f8fafc;">'
|
||
f'<th style="padding:6px 10px;text-align:left;border-bottom:2px solid #cbd5e1;">template_id</th>'
|
||
f'<th style="padding:6px 10px;text-align:left;border-bottom:2px solid #cbd5e1;">v4_label</th>'
|
||
f'<th style="padding:6px 10px;text-align:left;border-bottom:2px solid #cbd5e1;">application_mode</th>'
|
||
f'<th style="padding:6px 10px;text-align:left;border-bottom:2px solid #cbd5e1;">auto_app.</th>'
|
||
f'<th style="padding:6px 10px;text-align:left;border-bottom:2px solid #cbd5e1;">delegated_to</th>'
|
||
f'</tr></thead>'
|
||
f'<tbody>{_app_rows}</tbody>'
|
||
f'</table>'
|
||
f'</div>'
|
||
)
|
||
|
||
_summary_block_9 = (
|
||
f'<div style="background:#fef3c7;border-left:3px solid #f59e0b;padding:8px 12px;margin-bottom:16px;font-size:13px;">'
|
||
f'<strong>Step 9 v0 lock (사용자 2026-05-08):</strong> '
|
||
f'passive trace only — Step 6 default 그대로 사용 (runtime 결과 byte-동일). '
|
||
f'auto decision / scoring 은 Step 9 v1 (별 axis). '
|
||
f'region / display 후보는 Step 8-conn placeholder signal (Step 3/4 부재) 종속.'
|
||
f'</div>'
|
||
f'<p><strong>units:</strong> {len(application_plan_units)} | '
|
||
f'<strong>units_with_no_v4_candidate:</strong> '
|
||
f'{len(units_with_no_v4)} ({", ".join(units_with_no_v4) if units_with_no_v4 else "none"})</p>'
|
||
)
|
||
|
||
_write_step_html(
|
||
run_dir, 9, "application_plan",
|
||
title="Step 9 v0 — Passive Application Plan",
|
||
body_html=(
|
||
f'{_summary_block_9}'
|
||
f'{_unit_blocks_9}'
|
||
),
|
||
step_status="partial",
|
||
inputs=[
|
||
"step05_v4_evidence.json",
|
||
"step06_composition_plan.json",
|
||
"step07_layout.json",
|
||
"step08_zone_region_ratios.json",
|
||
],
|
||
outputs=["step09_application_plan.json", "step09_application_plan.html"],
|
||
)
|
||
|
||
# 7. Render single slide
|
||
html = render_slide(slide_title, slide_footer, zones_data, layout_preset, layout_css)
|
||
|
||
# IMP-51 (#79) u4 + u7 — stamp user-content imgs with stable id /
|
||
# role attrs, then inject persisted `image_overrides` CSS so the
|
||
# next render re-applies the user-edited geometry.
|
||
#
|
||
# Forward-compat: `stage0_normalized_assets["images"]` is empty in
|
||
# every current Phase Z run (Q1 = A confirmed at Stage 1), so the
|
||
# stamper returns an empty `stamped_image_ids` list and the CSS
|
||
# builder short-circuits to "". The HTML is therefore byte-for-byte
|
||
# identical to the pre-IMP-51 output until Phase Z starts emitting
|
||
# user-content imgs (separate axis, out of scope for #79).
|
||
from src.image_id_stamper import (
|
||
build_image_overrides_style,
|
||
inject_image_overrides_style,
|
||
stamp_user_content_images,
|
||
)
|
||
_user_content_image_srcs = [
|
||
(entry.get("path") or entry.get("src") or "")
|
||
for entry in (stage0_normalized_assets.get("images") or [])
|
||
if isinstance(entry, dict)
|
||
]
|
||
html, _stamped_image_ids = stamp_user_content_images(
|
||
html, sources=_user_content_image_srcs,
|
||
)
|
||
if override_image_overrides:
|
||
_image_overrides_css = build_image_overrides_style(
|
||
override_image_overrides, _stamped_image_ids,
|
||
)
|
||
if _image_overrides_css:
|
||
html = inject_image_overrides_style(html, _image_overrides_css)
|
||
|
||
# IMP-45 (#74) u4 — slide-level CSS override injection.
|
||
# Kwarg wins; otherwise fall back to MDX frontmatter
|
||
# ``slide_overrides.css``. Called AFTER image override injection so
|
||
# editor-authored slide CSS wins ties at the same specificity in the
|
||
# cascade (slide_css_injector.py module docstring lock).
|
||
_effective_slide_css = override_slide_css
|
||
if _effective_slide_css is None:
|
||
_effective_slide_css = _resolve_slide_css_from_frontmatter(mdx_source_text)
|
||
if _effective_slide_css:
|
||
from src.slide_css_injector import inject_slide_css
|
||
html = inject_slide_css(html, _effective_slide_css)
|
||
|
||
# 8. Write final.html
|
||
out_path = run_dir / "final.html"
|
||
out_path.write_text(html, encoding="utf-8")
|
||
print(f" html : {out_path}")
|
||
|
||
# ─── Step 13: Render ───
|
||
_write_step_artifact(
|
||
run_dir, 13, "render",
|
||
data={
|
||
"final_html_path": str(out_path),
|
||
"final_html_size_bytes": out_path.stat().st_size,
|
||
"render_inputs": {
|
||
"slide_title": slide_title,
|
||
"slide_footer": slide_footer,
|
||
"zones_count": len(zones_data),
|
||
"layout_preset": layout_preset,
|
||
},
|
||
},
|
||
step_status="done",
|
||
pipeline_path_connected=True,
|
||
inputs=["step07_layout.json", "step12_slot_payload.json", "templates/phase_z2/slide_base.html", "templates/phase_z2/families/*.html"],
|
||
outputs=["step13_render.json", "step13_draft_render.html", "final.html"],
|
||
note="Jinja2 render 결과 = final.html. step13_draft_render.html 은 final.html 동일 복사 (검토용).",
|
||
)
|
||
# Step 13 HTML — final.html 그대로 복사 (검토용)
|
||
(run_dir / "steps" / "step13_draft_render.html").write_text(html, encoding="utf-8")
|
||
|
||
# 9. Selenium check
|
||
print(" visual : running per-zone overflow check ...")
|
||
overflow = run_overflow_check(out_path)
|
||
|
||
# ─── Step 14: Visual Runtime Check (실측값) ───
|
||
_write_step_artifact(
|
||
run_dir, 14, "visual_check",
|
||
data=overflow,
|
||
step_status="partial",
|
||
pipeline_path_connected=True,
|
||
inputs=["step13_render.json", "final.html"],
|
||
outputs=["step14_visual_check.json"],
|
||
note=(
|
||
"Selenium 실측 — clientHeight / scrollHeight / excess_y / frame_slot_metrics. "
|
||
"Step 8 의 계획값과 비교 시 어느 cell 이 overflow 했는지 박힘. "
|
||
"image_aspect_mismatch 검사 추가 (IMP-15 실행-1, issue #45) — image_events[] + fail_reasons. "
|
||
"table 검사 부재 (실행-2 잔류) — Step 14 ⚠ partial (table only)."
|
||
),
|
||
)
|
||
|
||
# ─── IMP-47B u13: Persist validated AI repair proposals to cache ───
|
||
# Saves each applied PARTIAL_OVERRIDES proposal AFTER Step 14 visual
|
||
# check + per IMP-46 dual-gate. ``visual_check_passed`` reads the
|
||
# Selenium overflow result; ``auto_cache`` sourced from Settings
|
||
# (CLI --auto-cache wires settings.ai_fallback_auto_cache at parse
|
||
# time, src/phase_z2_pipeline.py:5631-5633). ``user_approved`` stays
|
||
# False — the pipeline has no UX approval gate; the auto_cache
|
||
# opt-in is the documented bypass per IMP-46 u5. Gate violations
|
||
# surface as ``cache_save_status='gate_blocked:<reason>'`` on the
|
||
# record (cache is a hint, never a hard dependency).
|
||
from src.config import settings as _ai_cache_settings
|
||
_persist_ai_repair_proposals_to_cache(
|
||
ai_repair_records,
|
||
visual_check_passed=bool(overflow.get("passed")),
|
||
user_approved=False,
|
||
auto_cache=bool(_ai_cache_settings.ai_fallback_auto_cache),
|
||
)
|
||
|
||
# 10. fit_classifier v0 (A1) — Selenium 결과 → spec §3 category 분류 layer.
|
||
# *분류만*. action / router / rerender X. behavior 변경 0.
|
||
fit_classification = classify_visual_runtime_check(overflow, debug_zones)
|
||
|
||
# ─── Step 15: Fit Classification ───
|
||
_write_step_artifact(
|
||
run_dir, 15, "fit_classification",
|
||
data=fit_classification,
|
||
step_status="done",
|
||
pipeline_path_connected=True,
|
||
inputs=["step14_visual_check.json"],
|
||
outputs=["step15_fit_classification.json"],
|
||
note="A1 — visual_runtime_check 결과를 spec §3 category 로 분류.",
|
||
)
|
||
|
||
# 11. overflow_router v0 (A2) — category → proposed_action 매핑 layer.
|
||
# *매핑까지만*. 실행 / rerender / behavior 변경 X.
|
||
# classifications 각 entry 에 proposed_action 추가, router_decision summary 반환.
|
||
router_decision = route_fit_classification(fit_classification)
|
||
router_decision["v4_fallback_summary"] = comp_debug.get("v4_fallback_summary")
|
||
router_decision["v4_fallback_selections"] = comp_debug.get("v4_fallback_selections", [])
|
||
router_decision["frame_reselect_fallback_status"] = (
|
||
"pre_render_rank_2_3_fallback_implemented; "
|
||
"post_render visual-fail rerender remains routed through existing action trace"
|
||
)
|
||
|
||
# ─── Step 16: Overflow Router ───
|
||
_write_step_artifact(
|
||
run_dir, 16, "router_decision",
|
||
data=router_decision,
|
||
step_status="done",
|
||
pipeline_path_connected=True,
|
||
inputs=["step15_fit_classification.json"],
|
||
outputs=["step16_router_decision.json"],
|
||
note="A2 — category → proposed_action 매핑.",
|
||
)
|
||
|
||
# 11.5 zone_ratio_retry action (A3) — A3 locked rules (사용자 잠금) 그대로.
|
||
# retry budget = 1, slide-base 고정, donor 룰, (b) revert 정책.
|
||
retry_trace = _attempt_zone_ratio_retry(
|
||
run_dir=run_dir,
|
||
out_path=out_path,
|
||
slide_title=slide_title,
|
||
slide_footer=slide_footer,
|
||
zones_data=zones_data,
|
||
debug_zones=debug_zones,
|
||
layout_preset=layout_preset,
|
||
layout_css=layout_css,
|
||
overflow=overflow,
|
||
fit_classification=fit_classification,
|
||
router_decision=router_decision,
|
||
gap_px=GRID_GAP,
|
||
)
|
||
# retry 가 *성공* 했으면 overflow / fit_classification / router_decision / debug_zones 를
|
||
# post-retry 상태로 갱신 (slide_status 가 새 상태 반영하도록).
|
||
if retry_trace.get("retry_passed"):
|
||
overflow = retry_trace["post_retry_overflow"]
|
||
debug_zones = retry_trace["post_retry_debug_zones"]
|
||
layout_css = retry_trace["post_retry_layout_css"]
|
||
# post-retry classifier / router 재실행 — 새 overflow 가 통과면 router_active=False
|
||
fit_classification = classify_visual_runtime_check(overflow, debug_zones)
|
||
router_decision = route_fit_classification(fit_classification)
|
||
router_decision["v4_fallback_summary"] = comp_debug.get("v4_fallback_summary")
|
||
router_decision["v4_fallback_selections"] = comp_debug.get("v4_fallback_selections", [])
|
||
router_decision["frame_reselect_fallback_status"] = (
|
||
"pre_render_rank_2_3_fallback_implemented; "
|
||
"post_render visual-fail rerender remains routed through existing action trace"
|
||
)
|
||
|
||
# 11.6 retry_failure_classifier + next_action_router (A4 — 분류/매핑만, 실행 X)
|
||
# retry 실패 시 failure_type 분류 + next_proposed_action 기록 (escalation 후보).
|
||
enrich_retry_trace_with_failure_classification(retry_trace)
|
||
|
||
# 11.7 IMP-12 u9 — Step 17 deterministic salvage cascade.
|
||
# Triggered on donor_slack_insufficient / no_donor_candidates: cross_zone_redistribute
|
||
# → glue_compression → font_step_compression (terminal → layout_adjust/frame_reselect).
|
||
_ft = (retry_trace.get("failure_classification") or {}).get("failure_type")
|
||
if _ft in {"donor_slack_insufficient", "no_donor_candidates"}:
|
||
_plan = retry_trace.get("plan") or {}
|
||
_tpos = _plan.get("target_zone_position")
|
||
_tdz = next((dz for dz in debug_zones if dz.get("position") == _tpos), {}) or {}
|
||
_excess = float(_plan.get("target_excess_y") or 0.0)
|
||
# Synthesize FitAnalysis from debug_zones + per-zone overflow so cross_zone_redistribute
|
||
# can compute feasibility against real Phase Z geometry (Phase Q's calculate_fit is
|
||
# not invoked in Phase Z — see comp_debug v4_fallback_summary policy at 2839-2843).
|
||
# Each position becomes a "role"; all share conceptual zone "slide_body" so
|
||
# fit_verifier.redistribute trades shortfalls between them. shortfall_px is signed:
|
||
# scrollHeight - clientHeight > 0 = deficit, < 0 = surplus (matches redistribute()).
|
||
from src.fit_verifier import FitAnalysis, RoleFit
|
||
_zof = {z.get("position"): z for z in (overflow.get("zones") or [])}
|
||
_fa_roles, _fa_containers = {}, {}
|
||
for _dz in debug_zones:
|
||
_pos = _dz.get("position")
|
||
if not _pos:
|
||
continue
|
||
_alloc = float(_dz.get("height_px") or 0.0)
|
||
_zm = _zof.get(_pos) or {}
|
||
_ch = float(_zm.get("clientHeight") or _alloc)
|
||
_sh = float(_zm.get("scrollHeight") or _ch)
|
||
_fa_roles[_pos] = RoleFit(role=_pos, allocated_px=_alloc, shortfall_px=_sh - _ch)
|
||
_fa_containers[_pos] = {"zone": "slide_body", "height_px": int(_alloc)}
|
||
_salvage_trace = _attempt_salvage_chain(
|
||
run_dir=run_dir, out_path=out_path,
|
||
slide_title=slide_title, slide_footer=slide_footer,
|
||
zones_data=zones_data, layout_preset=layout_preset, layout_css=layout_css,
|
||
cascade_inputs={
|
||
"fit_analysis": FitAnalysis(roles=_fa_roles),
|
||
"containers": _fa_containers,
|
||
"min_margin_px": None,
|
||
"excess_px": _excess, "excess_after_glue_px": _excess,
|
||
"block_count": len((_tdz.get("placement_trace") or {}).get("internal_regions") or []) or 1,
|
||
"zone_position": _tpos or "",
|
||
"current_font_px": float(_tdz.get("font_size_px") or 0.0),
|
||
"available_lines": int(_tdz.get("available_lines") or 0),
|
||
"chars_per_line": int(_tdz.get("chars_per_line") or 0),
|
||
},
|
||
initial_failure_type=_ft, gap_px=GRID_GAP,
|
||
)
|
||
retry_trace.update(_salvage_trace)
|
||
if _salvage_trace.get("salvage_passed"):
|
||
overflow = (_salvage_trace["salvage_steps"][-1].get("post_salvage_overflow")) or overflow
|
||
fit_classification = classify_visual_runtime_check(overflow, debug_zones)
|
||
router_decision = route_fit_classification(fit_classification)
|
||
router_decision["v4_fallback_summary"] = comp_debug.get("v4_fallback_summary")
|
||
router_decision["v4_fallback_selections"] = comp_debug.get("v4_fallback_selections", [])
|
||
router_decision["frame_reselect_fallback_status"] = (
|
||
"pre_render_rank_2_3_fallback_implemented; "
|
||
"post_render visual-fail rerender remains routed through existing action trace"
|
||
)
|
||
# Refresh failure_classification / next_action_proposal so Step 18 / Step 19
|
||
# do not surface the pre-salvage donor_slack_insufficient / no_donor_candidates
|
||
# state. classify_retry_failure short-circuits on salvage_passed=True → both
|
||
# fields become None (no failure to classify, no escalation pending).
|
||
enrich_retry_trace_with_failure_classification(retry_trace)
|
||
|
||
# 11.7.1 IMP-88 (#88) u7 — Step 17 image_fit single-pass entry trigger.
|
||
# Activates when router proposes `image_fit` (image_aspect_mismatch
|
||
# classifications, ACTION_BY_CATEGORY row added in u1). Single-pass executor
|
||
# (`_attempt_step17_image_fit_single_pass`): per-image plan/apply via
|
||
# plan_image_fit + apply_image_fit_css → aggregated CSS overlay → single
|
||
# re-render → run_overflow_check. PASS promotes final.html and refreshes
|
||
# Step 17/18/19 state; FAIL records `image_fit` step with failure_reason
|
||
# so failure_router (u2) SALVAGE_FAILURE_TYPE_BY_ACTION classifies it as
|
||
# `image_fit_insufficient` and the §11.7.2 cascade entry block routes
|
||
# onto layout_adjust. image_fit stays OUT of _SALVAGE_FAIL_BY_ACTION
|
||
# (u6 guard) — Step 17 ENTRY single-pass, not a salvage cascade stage.
|
||
if (
|
||
not retry_trace.get("retry_passed")
|
||
and not retry_trace.get("salvage_passed")
|
||
and "image_fit" in set(router_decision.get("proposed_actions_summary") or [])
|
||
):
|
||
_img_result = _attempt_step17_image_fit_single_pass(
|
||
run_dir=run_dir, out_path=out_path,
|
||
slide_title=slide_title, slide_footer=slide_footer,
|
||
zones_data=zones_data, layout_preset=layout_preset,
|
||
layout_css=layout_css,
|
||
image_events=overflow.get("image_events") or [],
|
||
gap_px=GRID_GAP,
|
||
)
|
||
if _img_result["triggered"]:
|
||
retry_trace.setdefault("salvage_steps", []).append(_img_result["step"])
|
||
retry_trace["salvage_attempted"] = True
|
||
if _img_result["passed"]:
|
||
retry_trace["salvage_passed"] = True
|
||
overflow = _img_result["candidate_overflow"]
|
||
fit_classification = classify_visual_runtime_check(overflow, debug_zones)
|
||
router_decision = route_fit_classification(fit_classification)
|
||
router_decision["v4_fallback_summary"] = comp_debug.get("v4_fallback_summary")
|
||
router_decision["v4_fallback_selections"] = comp_debug.get("v4_fallback_selections", [])
|
||
router_decision["frame_reselect_fallback_status"] = (
|
||
"pre_render_rank_2_3_fallback_implemented; "
|
||
"post_render visual-fail rerender remains routed through existing action trace"
|
||
)
|
||
enrich_retry_trace_with_failure_classification(retry_trace)
|
||
|
||
# 11.7.2 IMP-88 (#88) u7 — direct entry triggers for layout_adjust /
|
||
# frame_internal_fit_candidate when the donor_slack pathway above did not
|
||
# engage but the router proposes one of these actions (e.g. moderate_overflow
|
||
# / layout_zone_mismatch → layout_adjust; frame_capacity_mismatch →
|
||
# frame_internal_fit_candidate per u1 ACTION_BY_CATEGORY). Re-uses
|
||
# _attempt_salvage_chain with a synthetic initial_failure_type that
|
||
# routes (via failure_router NEXT_ACTION_BY_FAILURE / u2) onto the proposed
|
||
# action, so the cascade tail (frame_reselect → details_popup_escalation)
|
||
# stays consistent with the donor_slack pathway. Also handles the
|
||
# image_fit single-pass FAIL → image_fit_insufficient escalation onto
|
||
# the cascade (u2 row routes image_fit_insufficient → layout_adjust).
|
||
if not retry_trace.get("retry_passed") and not retry_trace.get("salvage_passed"):
|
||
_u7_proposed = set(router_decision.get("proposed_actions_summary") or [])
|
||
_u7_last_step = (retry_trace.get("salvage_steps") or [])
|
||
_u7_image_fit_failed = bool(
|
||
_u7_last_step
|
||
and _u7_last_step[-1].get("action") == "image_fit"
|
||
and _u7_last_step[-1].get("passed") is False
|
||
)
|
||
_u7_initial = None
|
||
if _u7_image_fit_failed:
|
||
_u7_initial = "image_fit_insufficient"
|
||
elif "layout_adjust" in _u7_proposed:
|
||
_u7_initial = "font_step_insufficient"
|
||
elif "frame_internal_fit_candidate" in _u7_proposed:
|
||
_u7_initial = "layout_adjust_insufficient"
|
||
if _u7_initial:
|
||
_u7_cls_list = fit_classification.get("classifications") or []
|
||
_u7_entry_cls = next(
|
||
(c for c in _u7_cls_list
|
||
if c.get("proposed_action") in {
|
||
"layout_adjust", "frame_internal_fit_candidate", "image_fit",
|
||
}),
|
||
{},
|
||
)
|
||
_u7_tpos = _u7_entry_cls.get("zone_position") or ""
|
||
_u7_tdz = next(
|
||
(dz for dz in debug_zones if dz.get("position") == _u7_tpos), {},
|
||
) or {}
|
||
_u7_zof = {z.get("position"): z for z in (overflow.get("zones") or [])}
|
||
_u7_zm = _u7_zof.get(_u7_tpos) or {}
|
||
_u7_excess = max(
|
||
0.0,
|
||
float(_u7_zm.get("scrollHeight") or 0.0)
|
||
- float(_u7_zm.get("clientHeight") or 0.0),
|
||
)
|
||
from src.fit_verifier import FitAnalysis, RoleFit
|
||
_u7_fa_roles, _u7_fa_containers = {}, {}
|
||
for _u7_dz in debug_zones:
|
||
_u7_pos = _u7_dz.get("position")
|
||
if not _u7_pos:
|
||
continue
|
||
_u7_alloc = float(_u7_dz.get("height_px") or 0.0)
|
||
_u7_zmi = _u7_zof.get(_u7_pos) or {}
|
||
_u7_ch = float(_u7_zmi.get("clientHeight") or _u7_alloc)
|
||
_u7_sh = float(_u7_zmi.get("scrollHeight") or _u7_ch)
|
||
_u7_fa_roles[_u7_pos] = RoleFit(
|
||
role=_u7_pos, allocated_px=_u7_alloc,
|
||
shortfall_px=_u7_sh - _u7_ch,
|
||
)
|
||
_u7_fa_containers[_u7_pos] = {
|
||
"zone": "slide_body", "height_px": int(_u7_alloc),
|
||
}
|
||
_u7_salvage = _attempt_salvage_chain(
|
||
run_dir=run_dir, out_path=out_path,
|
||
slide_title=slide_title, slide_footer=slide_footer,
|
||
zones_data=zones_data, layout_preset=layout_preset, layout_css=layout_css,
|
||
cascade_inputs={
|
||
"fit_analysis": FitAnalysis(roles=_u7_fa_roles),
|
||
"containers": _u7_fa_containers,
|
||
"min_margin_px": None,
|
||
"excess_px": _u7_excess, "excess_after_glue_px": _u7_excess,
|
||
"block_count": len(
|
||
(_u7_tdz.get("placement_trace") or {}).get("internal_regions") or []
|
||
) or 1,
|
||
"zone_position": _u7_tpos,
|
||
"current_font_px": float(_u7_tdz.get("font_size_px") or 0.0),
|
||
"available_lines": int(_u7_tdz.get("available_lines") or 0),
|
||
"chars_per_line": int(_u7_tdz.get("chars_per_line") or 0),
|
||
},
|
||
initial_failure_type=_u7_initial, gap_px=GRID_GAP,
|
||
)
|
||
_u7_prior_steps = retry_trace.get("salvage_steps") or []
|
||
_u7_new_steps = _u7_salvage.get("salvage_steps") or []
|
||
retry_trace.update(_u7_salvage)
|
||
retry_trace["salvage_steps"] = _u7_prior_steps + _u7_new_steps
|
||
if _u7_salvage.get("salvage_passed"):
|
||
overflow = (_u7_new_steps[-1].get("post_salvage_overflow")) or overflow
|
||
fit_classification = classify_visual_runtime_check(overflow, debug_zones)
|
||
router_decision = route_fit_classification(fit_classification)
|
||
router_decision["v4_fallback_summary"] = comp_debug.get("v4_fallback_summary")
|
||
router_decision["v4_fallback_selections"] = comp_debug.get("v4_fallback_selections", [])
|
||
router_decision["frame_reselect_fallback_status"] = (
|
||
"pre_render_rank_2_3_fallback_implemented; "
|
||
"post_render visual-fail rerender remains routed through existing action trace"
|
||
)
|
||
enrich_retry_trace_with_failure_classification(retry_trace)
|
||
|
||
# 11.8 IMP-35 (#64) u5 — Step 17 deterministic POPUP gate executor.
|
||
# Runs after the salvage cascade exits at cascade-terminal action
|
||
# `details_popup_escalation` (router u3 IMPLEMENTED + failure_router u2
|
||
# cascade row). Stamps popup_escalation_plan + idempotent has_popup
|
||
# marker per unit onto retry_trace["popup_gate_records"]. Deterministic
|
||
# gate — no AI call (feedback_ai_isolation_contract); the u4
|
||
# api_gated split-decision hook is a separate cascade-stage record
|
||
# consumed only when a future IMP activates the Anthropic API.
|
||
# Consumer side (composition popup binding / render wiring) lands in
|
||
# u6 / u7. q1 (per-unit), q2 (idempotent via has_popup), q3
|
||
# (deterministic from fit_classification) — see Stage 2 plan.
|
||
# next_proposed_action is the single canonical signal: it is set by
|
||
# enrich_retry_trace_with_failure_classification via failure_router u2
|
||
# (NEXT_ACTION_BY_FAILURE), which routes frame_reselect_insufficient ->
|
||
# details_popup_escalation. This check is independent of whether the
|
||
# salvage chain block ran, so the popup gate fires for any retry path
|
||
# that lands on the cascade-terminal popup action.
|
||
_next_action = (
|
||
retry_trace.get("next_action_proposal") or {}
|
||
).get("next_proposed_action")
|
||
if _next_action == "details_popup_escalation":
|
||
_popup_cls_by_zone = {
|
||
c.get("zone_position"): c
|
||
for c in (fit_classification.get("classifications") or [])
|
||
if c.get("category") in {
|
||
"structural_major_overflow",
|
||
"tabular_overflow",
|
||
}
|
||
}
|
||
_zone_by_ssids = {
|
||
tuple(z.get("source_section_ids") or []): z.get("position")
|
||
for z in debug_zones
|
||
}
|
||
|
||
def _classification_for_unit(u):
|
||
ssids = tuple(getattr(u, "source_section_ids", []) or [])
|
||
zone_pos = _zone_by_ssids.get(ssids)
|
||
return _popup_cls_by_zone.get(zone_pos) if zone_pos else None
|
||
|
||
retry_trace["popup_gate_records"] = run_step17_popup_gate(
|
||
units,
|
||
classification_for_unit=_classification_for_unit,
|
||
route_for_label=_imp05_route_hint,
|
||
plan_for_classification=plan_details_popup_escalation,
|
||
)
|
||
retry_trace["popup_gate_executed"] = True
|
||
retry_trace["popup_gate_terminal_action"] = "details_popup_escalation"
|
||
|
||
# ─── Step 17: Implemented Action (retry) ───
|
||
_write_step_artifact(
|
||
run_dir, 17, "retry_trace",
|
||
data=retry_trace,
|
||
step_status=(
|
||
"done" if retry_trace.get("retry_passed") or retry_trace.get("salvage_passed")
|
||
else "failed" if retry_trace.get("retry_attempted") and not retry_trace.get("retry_passed")
|
||
else "skipped"
|
||
),
|
||
pipeline_path_connected=True,
|
||
inputs=["step16_router_decision.json"],
|
||
outputs=["step17_retry_trace.json"],
|
||
note=(
|
||
"A3 — zone_ratio_retry + IMP-12 u8/u9 salvage cascade "
|
||
"(cross_zone_redistribute → glue_compression → font_step_compression). "
|
||
"IMP-88 u6/u7 — salvage cascade extended with layout_adjust + "
|
||
"frame_internal_fit_candidate; image_fit single-pass entry triggered "
|
||
"from router (cascade tail frame_reselect remains PARTIAL pre-render only)."
|
||
),
|
||
)
|
||
|
||
# ─── Step 18: Failure Classification (A4-1) ───
|
||
_write_step_artifact(
|
||
run_dir, 18, "failure_classification",
|
||
data=retry_trace.get("failure_classification") or {},
|
||
step_status="done" if retry_trace.get("failure_classification") else "skipped",
|
||
pipeline_path_connected=True,
|
||
inputs=["step17_retry_trace.json"],
|
||
outputs=["step18_failure_classification.json"],
|
||
note="A4-1 — retry 실패 시 failure_type 분류.",
|
||
)
|
||
|
||
# ─── Step 19: Next Action Proposal (A4-2) ───
|
||
_write_step_artifact(
|
||
run_dir, 19, "next_action",
|
||
data=retry_trace.get("next_action_proposal") or {},
|
||
step_status="partial" if retry_trace.get("next_action_proposal") else "skipped",
|
||
pipeline_path_connected=False,
|
||
inputs=["step18_failure_classification.json"],
|
||
outputs=["step19_next_action.json"],
|
||
note="A4-2 — failure_type → next_proposed_action 1-D mapping. 실제 action 자체는 미구현 (impl_status=MISSING).",
|
||
)
|
||
|
||
# 12. Slide status — 자동 파이프라인 결과 보고 (review/UI 개념 X)
|
||
slide_status = compute_slide_status(
|
||
sections, units, comp_debug, overflow,
|
||
adapter_needed_units=adapter_needed_units,
|
||
debug_zones=debug_zones,
|
||
)
|
||
|
||
# IMP-47B u8 — Surface Step 12 AI repair outcomes through slide_status.
|
||
# Composes u4 gather errors + u5 apply_status + u7 coverage_invariant
|
||
# into a single ``ai_repair_status`` axis the frontend (u11) reads to
|
||
# render human_review notifications. Auto pipeline first
|
||
# ([[feedback_auto_pipeline_first]]) — no review_queue insertion;
|
||
# explicit status enum + human_review_required flag.
|
||
slide_status["ai_repair_status"] = _summarize_ai_repair_status(
|
||
ai_repair_records, ai_repair_coverage_invariant,
|
||
)
|
||
|
||
# ─── Step 20: Slide Status ───
|
||
_write_step_artifact(
|
||
run_dir, 20, "slide_status",
|
||
data=slide_status,
|
||
step_status="done",
|
||
pipeline_path_connected=True,
|
||
inputs=["step14_visual_check.json", "step17_retry_trace.json", "step19_next_action.json"],
|
||
outputs=["step20_slide_status.json", "step20_final_status.html"],
|
||
note="자동 파이프라인 최종 결과 보고. overall = PASS/RENDERED_WITH_VISUAL_REGRESSION/PARTIAL_COVERAGE 등.",
|
||
)
|
||
# Step 20 HTML — 최종 판정 시각 보고
|
||
_overall = slide_status.get("overall", "?")
|
||
# IMP-87 u3 — route the dashboard CSS class through _final_status_html_class
|
||
# so EMPTY_SHELL_NO_CONTENT renders as "fail" (red) instead of the legacy
|
||
# substring-default "partial" amber. Other enums keep their pre-IMP-87
|
||
# colour (Stage 2 axis A5 lock).
|
||
_ov_class = _final_status_html_class(_overall)
|
||
_vfs = slide_status.get("visual_fail_reasons") or []
|
||
_vfs_html = (
|
||
"<ul>" + "".join(f"<li>{v}</li>" for v in _vfs) + "</ul>"
|
||
if _vfs else "<p>없음</p>"
|
||
)
|
||
_aligned = slide_status.get("aligned_section_ids") or []
|
||
_covered = slide_status.get("covered_section_ids") or []
|
||
_filtered = slide_status.get("filtered_section_ids") or []
|
||
_ai_repair = slide_status.get("ai_repair_status") or {}
|
||
_ai_repair_label = (
|
||
f'{_ai_repair.get("status", "?")} '
|
||
f'(human_review_required={_ai_repair.get("human_review_required", False)})'
|
||
)
|
||
_write_step_html(
|
||
run_dir, 20, "final_status",
|
||
title="Final Slide Status",
|
||
body_html=(
|
||
f'<h2>Overall: <span class="{_ov_class}">{_overall}</span></h2>'
|
||
f'<table>'
|
||
f'<tr><th>rendered</th><td>{slide_status.get("rendered")}</td></tr>'
|
||
f'<tr><th>visual_check_passed</th><td>{slide_status.get("visual_check_passed")}</td></tr>'
|
||
f'<tr><th>full_mdx_coverage</th><td>{slide_status.get("full_mdx_coverage")}</td></tr>'
|
||
f'<tr><th>aligned_section_ids</th><td>{_aligned}</td></tr>'
|
||
f'<tr><th>covered_section_ids</th><td>{_covered}</td></tr>'
|
||
f'<tr><th>filtered_section_ids</th><td>{_filtered}</td></tr>'
|
||
f'<tr><th>adapter_needed_count</th><td>{slide_status.get("adapter_needed_count", 0)}</td></tr>'
|
||
f'<tr><th>content_truncated_count</th><td>{slide_status.get("content_truncated_count", 0)}</td></tr>'
|
||
f'<tr><th>ai_repair_status</th><td>{_ai_repair_label}</td></tr>'
|
||
f'</table>'
|
||
f'<h2>Visual Fail Reasons</h2>{_vfs_html}'
|
||
f'<h2>Note</h2><p>{slide_status.get("note", "")}</p>'
|
||
),
|
||
step_status="done",
|
||
inputs=["step14_visual_check.json", "step17_retry_trace.json"],
|
||
outputs=["step20_slide_status.json", "step20_final_status.html"],
|
||
)
|
||
|
||
# 13. Debug.json
|
||
debug_path = write_debug_json(
|
||
run_dir, layout_preset, debug_zones, layout_css, overflow,
|
||
composition_debug=comp_debug,
|
||
slide_status=slide_status,
|
||
fit_classification=fit_classification,
|
||
router_decision=router_decision,
|
||
retry_trace=retry_trace,
|
||
)
|
||
print(f" debug : {debug_path}")
|
||
|
||
# ─── Step 21: Debug Trace ───
|
||
_write_step_artifact(
|
||
run_dir, 21, "debug_index",
|
||
data={
|
||
"debug_json_path": str(debug_path),
|
||
"debug_json_size_bytes": debug_path.stat().st_size,
|
||
"placement_trace_recorded_zones": len(debug_zones),
|
||
"frame_slot_metrics_count": len(
|
||
(overflow or {}).get("frame_slot_metrics")
|
||
or (overflow or {}).get("details", {}).get("frame_slot_metrics", [])
|
||
),
|
||
},
|
||
step_status="partial",
|
||
pipeline_path_connected=True,
|
||
inputs=[],
|
||
outputs=["step21_debug_index.json", "debug.json"],
|
||
note="placement_trace per-zone + frame_slot_metrics F29 만 기록. region marker partial 미주입 — Step 21 ⚠ partial.",
|
||
)
|
||
|
||
# ─── Step 22: 사용자 확인 / Export (future, UI 영역) ───
|
||
_write_step_artifact(
|
||
run_dir, 22, "user_export",
|
||
data={"status": "future", "scope": "UI 영역 — 자동 파이프라인 범위 외"},
|
||
step_status="future",
|
||
pipeline_path_connected=False,
|
||
inputs=[],
|
||
outputs=["step22_user_export.json"],
|
||
note="UI / export 단계. 자동 파이프라인 범위 외 — 사용자 직접 확인 / 다운로드.",
|
||
)
|
||
|
||
# 13. Status report
|
||
overall = slide_status["overall"]
|
||
print(f" status : {overall}")
|
||
if slide_status["filtered_section_ids"]:
|
||
print(f" filtered_sections = {slide_status['filtered_section_ids']}")
|
||
if slide_status["adapter_needed_count"]:
|
||
print(f" adapter_needed_count = {slide_status['adapter_needed_count']}")
|
||
if slide_status["content_truncated_count"]:
|
||
print(f" content_truncated = "
|
||
f"{[(c['position'], c['truncated_count']) for c in slide_status['content_truncated_units']]}")
|
||
if not slide_status["visual_check_passed"]:
|
||
for r in (overflow.get("fail_reasons") or [])[:3]:
|
||
print(f" visual_fail = {r}")
|
||
# fit_classifier + router 결과 요약
|
||
if not fit_classification["visual_check_passed"]:
|
||
cats = fit_classification["categories_seen"]
|
||
print(f" fit_categories = {cats}")
|
||
if router_decision["router_active"]:
|
||
actions = router_decision["proposed_actions_summary"]
|
||
status = router_decision["implementation_status_summary"]
|
||
missing = router_decision["missing_actions_pending_impl"]
|
||
print(f" proposed_actions = {actions}")
|
||
print(f" impl_status_summary = {status}")
|
||
if missing:
|
||
print(f" missing_actions = {missing} (현재 미구현 → abort)")
|
||
# retry 결과 요약 (A3) + failure classification / next action proposal (A4)
|
||
if retry_trace.get("retry_attempted"):
|
||
print(f" retry_action = {retry_trace['retry_action']}")
|
||
print(f" retry_passed = {retry_trace['retry_passed']}")
|
||
if not retry_trace["retry_passed"]:
|
||
print(f" retry_failure = {retry_trace.get('retry_failure_reason')}")
|
||
fc = retry_trace.get("failure_classification") or {}
|
||
nap = retry_trace.get("next_action_proposal") or {}
|
||
if fc.get("failure_type"):
|
||
print(f" failure_type = {fc['failure_type']}")
|
||
if nap.get("next_proposed_action"):
|
||
print(f" next_proposed_action = {nap['next_proposed_action']} "
|
||
f"(impl_status={nap.get('next_action_implementation_status')})")
|
||
|
||
# 13. Exit 정책 — visual fail 은 abort, partial coverage 는 abort 안 하지만 PASS 도 아님
|
||
# IMP-87 u3 — BLOCKED exit on EMPTY_SHELL_NO_CONTENT precedes the legacy
|
||
# visual_fail / partial_coverage branches (Stage 2 axis A4). A slide whose
|
||
# every selected unit is an IMP-30 u4 empty-shell placeholder carries no
|
||
# MDX content; without this branch a Case B run could pass Selenium
|
||
# overflow checks (nothing to overflow) and silently return overall
|
||
# EMPTY_SHELL_NO_CONTENT without any CLI exit signal, violating
|
||
# feedback_artifact_status_naming.
|
||
if _is_blocked_overall(overall):
|
||
_aligned = slide_status.get("aligned_section_ids") or []
|
||
_empty_ids = slide_status.get("empty_shell_section_ids") or []
|
||
_empty_count = slide_status.get("empty_shell_unit_count", 0)
|
||
_content_count = slide_status.get("content_rendered_unit_count", 0)
|
||
print(
|
||
f"\n[Phase Z-2 IMP-87 u3] BLOCKED @ empty_shell_no_content ({overall})",
|
||
file=sys.stderr,
|
||
)
|
||
print(
|
||
" reason : every selected unit is IMP-30 u4 empty-shell placeholder "
|
||
"(no MDX content rendered)",
|
||
file=sys.stderr,
|
||
)
|
||
print(f" aligned : {_aligned}", file=sys.stderr)
|
||
print(f" shell : {_empty_ids}", file=sys.stderr)
|
||
print(
|
||
f" units : empty_shell_unit_count={_empty_count} "
|
||
f"content_rendered_unit_count={_content_count}",
|
||
file=sys.stderr,
|
||
)
|
||
sys.exit(1)
|
||
|
||
if not slide_status["visual_check_passed"]:
|
||
err_path = write_overflow_error(run_dir, overflow)
|
||
print(f"\n[Phase Z-2 MVP-1.5b] FAIL @ visual_runtime_check ({overall})", file=sys.stderr)
|
||
for reason in overflow.get("fail_reasons", [overflow.get("error", "unknown")]):
|
||
print(f" - {reason}", file=sys.stderr)
|
||
print(f" error : {err_path}", file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
if not slide_status["full_mdx_coverage"]:
|
||
print(
|
||
f"\n[Phase Z-2 MVP-1.5b] PARTIAL — visual check OK 지만 "
|
||
f"sections {slide_status['filtered_section_ids']} 가 composition planner 에서 "
|
||
f"필터됨 (allowed_statuses 미통과). final.html 은 viable units 만 렌더된 "
|
||
f"partial artifact. full MDX slide 아님."
|
||
)
|
||
return out_path
|
||
|
||
print(
|
||
f"\n[Phase Z-2 MVP-1.5b] {overall} — visual check OK + full MDX coverage. "
|
||
f"최종 사용자 브라우저 검증 후 ship 가능."
|
||
)
|
||
return out_path
|
||
|
||
|
||
if __name__ == "__main__":
|
||
import argparse
|
||
|
||
parser = argparse.ArgumentParser(
|
||
prog="python -m src.phase_z2_pipeline",
|
||
description="Phase Z-2 MVP-1.5b pipeline (MDX → 1 slide HTML).",
|
||
)
|
||
parser.add_argument("mdx_path", type=Path, help="MDX 파일 경로")
|
||
parser.add_argument(
|
||
"run_id", nargs="?", default=None,
|
||
help="run_id (출력 디렉토리 이름). 없으면 자동 생성 (basename + timestamp).",
|
||
)
|
||
parser.add_argument(
|
||
"--override-layout", dest="override_layout", default=None,
|
||
metavar="PRESET",
|
||
help=(
|
||
"layout_preset 강제 (8 preset 중 하나 — single, horizontal-2, vertical-2, "
|
||
"top-1-bottom-2, top-2-bottom-1, left-1-right-2, left-2-right-1, grid-2x2). "
|
||
"없으면 자동 결정 (count-based v0)."
|
||
),
|
||
)
|
||
parser.add_argument(
|
||
"--override-frame", dest="override_frames", action="append", default=[],
|
||
metavar="UNIT_ID=TEMPLATE_ID",
|
||
help=(
|
||
"unit_id 의 frame template 강제 변경. UNIT_ID 는 \"+\".join(source_section_ids) "
|
||
"(e.g., 03-1 또는 03-1+03-2). multiple 가능: --override-frame 03-1=foo "
|
||
"--override-frame 03-2=bar"
|
||
),
|
||
)
|
||
parser.add_argument(
|
||
"--override-zone-geometry", dest="override_zone_geometries", action="append", default=[],
|
||
metavar="ZONE_ID=X,Y,W,H",
|
||
help=(
|
||
"zone position (top/bottom/left/right/...) 의 slide-body 내부 비율 (0~1) "
|
||
"강제. horizontal-2 / vertical-2 만 지원. multiple 가능: "
|
||
"--override-zone-geometry top=0,0,1,0.3 --override-zone-geometry bottom=0,0.3,1,0.7"
|
||
),
|
||
)
|
||
# IMP-06 (#6) — zone-section assignment override (backend/CLI/composition only;
|
||
# frontend bridge = #38). ZONE_ID = active layout preset position names
|
||
# (single=primary, horizontal-2=top/bottom, grid-2x2=top-left/top-right/...).
|
||
parser.add_argument(
|
||
"--override-section-assignment",
|
||
dest="override_section_assignments",
|
||
action="append",
|
||
default=[],
|
||
metavar="ZONE_ID=section_id[,section_id]",
|
||
help=(
|
||
"zone position 의 section assignment 강제. ZONE_ID = active layout 의 "
|
||
"position name (e.g., top, bottom, left, right, top-left, ...). section_id = "
|
||
"MDX section identifier (e.g., 03-1). multiple sections per zone = comma-separated. "
|
||
"multiple flags: --override-section-assignment top=03-1 "
|
||
"--override-section-assignment bottom=03-2,03-3"
|
||
),
|
||
)
|
||
# IMP-51 (#79) u5 — image override CLI flag. IMAGE_ID = stable id stamped
|
||
# on user-content `<img>` tags by src/image_id_stamper.py (u4). X,Y,W,H =
|
||
# percent-of-slide coordinates (0–100, slide-absolute), consistent with
|
||
# the typed client `ImageOverride` shape (u3, userOverridesApi.ts) and
|
||
# the persisted `image_overrides` axis (u1, KNOWN_AXES). The render-time
|
||
# CSS injection consuming this mapping lands in u7; u5 is the CLI surface.
|
||
parser.add_argument(
|
||
"--override-image",
|
||
dest="override_image_overrides",
|
||
action="append",
|
||
default=[],
|
||
metavar="IMAGE_ID=X,Y,W,H",
|
||
help=(
|
||
"user-content image 의 slide-absolute geometry 강제. IMAGE_ID = "
|
||
"src/image_id_stamper.py 가 stamp 한 `data-image-id` value "
|
||
"(e.g., img-1a2b3c4d5e). X,Y,W,H = percent-of-slide (0–100, "
|
||
"slide-absolute) — typed client ImageOverride shape 와 일치. "
|
||
"multiple flags: --override-image img-abc=10,15,30,25 "
|
||
"--override-image img-def=50,15,40,40"
|
||
),
|
||
)
|
||
# IMP-45 (#74) u5 — slide-level CSS override CLI surface. Two mutually
|
||
# exclusive flags carrying the same axis: ``--override-slide-css TEXT``
|
||
# passes inline CSS verbatim; ``--slide-css-file PATH`` reads UTF-8
|
||
# CSS from disk. Both forward into the single ``override_slide_css``
|
||
# kwarg on :func:`run_phase_z2_mvp1` (u4), which already mirrors the
|
||
# marker-wrapped image_id_stamper injection contract at Step 13.
|
||
# Manual mutual-exclusion (not ``add_mutually_exclusive_group``) so the
|
||
# error stderr can name the axis pair explicitly with ``sys.exit(2)``,
|
||
# matching sibling override surfaces (--override-frame /
|
||
# --override-zone-geometry / --override-image).
|
||
parser.add_argument(
|
||
"--override-slide-css",
|
||
dest="override_slide_css",
|
||
default=None,
|
||
metavar="CSS_TEXT",
|
||
help=(
|
||
"slide-level CSS override (inline). value is passed verbatim "
|
||
"to the run_phase_z2_mvp1 override_slide_css kwarg; an "
|
||
"optional outer `<style>` wrapper is allowed because the "
|
||
"injector wraps the payload in its own marker-bracketed "
|
||
"`<style>` block regardless. mutually exclusive with "
|
||
"--slide-css-file."
|
||
),
|
||
)
|
||
parser.add_argument(
|
||
"--slide-css-file",
|
||
dest="slide_css_file",
|
||
default=None,
|
||
type=Path,
|
||
metavar="PATH",
|
||
help=(
|
||
"slide-level CSS override (file). UTF-8-encoded CSS payload "
|
||
"read from PATH and forwarded to the override_slide_css "
|
||
"kwarg. fail-closed: missing file / non-UTF-8 bytes exit 2 "
|
||
"with stderr message. mutually exclusive with "
|
||
"--override-slide-css."
|
||
),
|
||
)
|
||
# IMP-46 u5 — auto-cache opt-in. When set, ``cache.save_proposal``
|
||
# bypasses the ``user_approved`` gate only (``visual_check_passed``
|
||
# is never bypassable). Source of truth is
|
||
# ``settings.ai_fallback_auto_cache`` (src/config.py); this flag
|
||
# mutates the setting in-process so downstream callers read the
|
||
# same value through Settings rather than parsing args themselves.
|
||
parser.add_argument(
|
||
"--auto-cache",
|
||
dest="auto_cache",
|
||
action="store_true",
|
||
default=False,
|
||
help=(
|
||
"Allow cache.save_proposal to bypass the user_approved gate "
|
||
"(visual_check_passed remains mandatory). Sets "
|
||
"settings.ai_fallback_auto_cache=True for this run."
|
||
),
|
||
)
|
||
# IMP-43 (#72) u1 — incremental rerun reuse pointer. Reuse target
|
||
# = Step 0/1/2/5/6 deterministic artifacts from a prior run; Step 7
|
||
# onward re-executes against the new frame overrides. Only frame
|
||
# overrides preserve the reusable subset (Stage 2 boundary lock);
|
||
# layout/geometry/section/image overrides invalidate it and are
|
||
# rejected by the post-merge guard below. Signature threading +
|
||
# snapshot copy/restore land in u5 and u4 respectively; this unit
|
||
# only adds the CLI surface + fail-closed precondition guard.
|
||
parser.add_argument(
|
||
"--reuse-from",
|
||
dest="reuse_from",
|
||
default=None,
|
||
metavar="PREV_RUN_ID",
|
||
help=(
|
||
"Reuse Step 0/1/2/5/6 artifacts from a previous run id "
|
||
"(directory under data/runs/<PREV_RUN_ID>/phase_z2) and resume "
|
||
"execution at Step 7. Only --override-frame is preserved; "
|
||
"--override-layout / --override-zone-geometry / "
|
||
"--override-section-assignment / --override-image invalidate "
|
||
"the reusable boundary and will be rejected."
|
||
),
|
||
)
|
||
args = parser.parse_args()
|
||
|
||
if args.auto_cache:
|
||
from src.config import settings as _settings
|
||
_settings.ai_fallback_auto_cache = True
|
||
|
||
overrides_frames: dict[str, str] = {}
|
||
for ov in args.override_frames:
|
||
if "=" not in ov:
|
||
print(
|
||
f"[error] --override-frame must be UNIT_ID=TEMPLATE_ID, got: '{ov}'",
|
||
file=sys.stderr,
|
||
)
|
||
sys.exit(2)
|
||
k, v = ov.split("=", 1)
|
||
overrides_frames[k.strip()] = v.strip()
|
||
|
||
overrides_geoms: dict[str, dict] = {}
|
||
for ov in args.override_zone_geometries:
|
||
if "=" not in ov:
|
||
print(f"[error] --override-zone-geometry must be ZONE_ID=X,Y,W,H, got: '{ov}'", file=sys.stderr)
|
||
sys.exit(2)
|
||
zid, vals = ov.split("=", 1)
|
||
parts = vals.split(",")
|
||
if len(parts) != 4:
|
||
print(f"[error] --override-zone-geometry expects 4 floats X,Y,W,H, got: '{vals}'", file=sys.stderr)
|
||
sys.exit(2)
|
||
try:
|
||
x, y, w, h = (float(p) for p in parts)
|
||
except ValueError:
|
||
print(f"[error] --override-zone-geometry floats parse fail: '{vals}'", file=sys.stderr)
|
||
sys.exit(2)
|
||
overrides_geoms[zid.strip()] = {"x": x, "y": y, "w": w, "h": h}
|
||
|
||
# IMP-06 — parse --override-section-assignment into dict[str, list[str]].
|
||
# Hard errors per Codex #2/#3 lock : missing `=` / empty ZONE_ID / empty section
|
||
# list / duplicate ZONE_ID / duplicate section across zones (parse-time).
|
||
overrides_section_assignments: dict[str, list[str]] = {}
|
||
_seen_sections_across_zones: dict[str, str] = {} # section_id -> zone_id (first seen)
|
||
for ov in args.override_section_assignments:
|
||
if "=" not in ov:
|
||
print(
|
||
f"[error] --override-section-assignment must be ZONE_ID=section_id[,section_id], "
|
||
f"got: '{ov}'",
|
||
file=sys.stderr,
|
||
)
|
||
sys.exit(2)
|
||
zid, vals = ov.split("=", 1)
|
||
zid = zid.strip()
|
||
if not zid:
|
||
print(
|
||
f"[error] --override-section-assignment ZONE_ID must be non-empty, got: '{ov}'",
|
||
file=sys.stderr,
|
||
)
|
||
sys.exit(2)
|
||
section_ids = [s.strip() for s in vals.split(",") if s.strip()]
|
||
if not section_ids:
|
||
print(
|
||
f"[error] --override-section-assignment section list must be non-empty, "
|
||
f"got: '{ov}'",
|
||
file=sys.stderr,
|
||
)
|
||
sys.exit(2)
|
||
if zid in overrides_section_assignments:
|
||
print(
|
||
f"[error] --override-section-assignment duplicate ZONE_ID '{zid}' "
|
||
f"(first assignment kept). Provide each zone only once.",
|
||
file=sys.stderr,
|
||
)
|
||
sys.exit(2)
|
||
for sid in section_ids:
|
||
if sid in _seen_sections_across_zones:
|
||
print(
|
||
f"[error] --override-section-assignment section '{sid}' appears in "
|
||
f"multiple zones ('{_seen_sections_across_zones[sid]}' and '{zid}'). "
|
||
"A section may be assigned to at most one zone.",
|
||
file=sys.stderr,
|
||
)
|
||
sys.exit(2)
|
||
_seen_sections_across_zones[sid] = zid
|
||
overrides_section_assignments[zid] = section_ids
|
||
|
||
# IMP-51 (#79) u5 — parse --override-image into dict[str, dict[str, float]].
|
||
# Mirrors --override-zone-geometry parsing pattern: each flag is
|
||
# IMAGE_ID=X,Y,W,H with 4 floats; multiple flags accumulate. Hard errors
|
||
# on missing `=` / wrong float count / non-numeric values / empty IMAGE_ID
|
||
# / duplicate IMAGE_ID. The on-disk schema (u1 KNOWN_AXES) and typed
|
||
# client (u3 ImageOverride) both expect percent-of-slide values in
|
||
# 0–100; the CLI accepts floats without range clamping here so the
|
||
# error remains the user's mistake to read rather than a silent shift.
|
||
overrides_images: dict[str, dict[str, float]] = {}
|
||
for ov in args.override_image_overrides:
|
||
if "=" not in ov:
|
||
print(
|
||
f"[error] --override-image must be IMAGE_ID=X,Y,W,H, got: '{ov}'",
|
||
file=sys.stderr,
|
||
)
|
||
sys.exit(2)
|
||
iid, vals = ov.split("=", 1)
|
||
iid = iid.strip()
|
||
if not iid:
|
||
print(
|
||
f"[error] --override-image IMAGE_ID must be non-empty, got: '{ov}'",
|
||
file=sys.stderr,
|
||
)
|
||
sys.exit(2)
|
||
if iid in overrides_images:
|
||
print(
|
||
f"[error] --override-image duplicate IMAGE_ID '{iid}' "
|
||
f"(first assignment kept). Provide each image only once.",
|
||
file=sys.stderr,
|
||
)
|
||
sys.exit(2)
|
||
parts = vals.split(",")
|
||
if len(parts) != 4:
|
||
print(
|
||
f"[error] --override-image expects 4 floats X,Y,W,H, got: '{vals}'",
|
||
file=sys.stderr,
|
||
)
|
||
sys.exit(2)
|
||
try:
|
||
x, y, w, h = (float(p) for p in parts)
|
||
except ValueError:
|
||
print(
|
||
f"[error] --override-image floats parse fail: '{vals}'",
|
||
file=sys.stderr,
|
||
)
|
||
sys.exit(2)
|
||
overrides_images[iid] = {"x": x, "y": y, "w": w, "h": h}
|
||
|
||
# IMP-45 (#74) u5 — resolve slide-level CSS override CLI surface to a
|
||
# single string. Mutually exclusive: both flags set → exit 2. File
|
||
# path read is UTF-8 strict; missing path or non-UTF-8 bytes → exit
|
||
# 2. When neither flag is set, ``override_slide_css`` stays ``None``
|
||
# and Step 13 falls back to the MDX ``slide_overrides.css``
|
||
# frontmatter via ``_resolve_slide_css_from_frontmatter`` (u4).
|
||
if args.override_slide_css is not None and args.slide_css_file is not None:
|
||
print(
|
||
"[error] --override-slide-css and --slide-css-file are mutually "
|
||
"exclusive; pass only one.",
|
||
file=sys.stderr,
|
||
)
|
||
sys.exit(2)
|
||
_final_override_slide_css: Optional[str] = args.override_slide_css
|
||
if args.slide_css_file is not None:
|
||
try:
|
||
_final_override_slide_css = args.slide_css_file.read_text(
|
||
encoding="utf-8"
|
||
)
|
||
except FileNotFoundError:
|
||
print(
|
||
f"[error] --slide-css-file path does not exist: "
|
||
f"{args.slide_css_file}",
|
||
file=sys.stderr,
|
||
)
|
||
sys.exit(2)
|
||
except UnicodeDecodeError as _exc:
|
||
print(
|
||
f"[error] --slide-css-file must be UTF-8 encoded "
|
||
f"({args.slide_css_file}): {_exc}",
|
||
file=sys.stderr,
|
||
)
|
||
sys.exit(2)
|
||
|
||
# IMP-52 (#80) u2 — user_overrides.json persistence fallback.
|
||
# After argparse fully parses CLI flags, fill ONLY the axes the user
|
||
# did NOT pass on the command line. CLI payload always wins over the
|
||
# persisted file (Stage 2 lock: "CLI > file, 결손 축만 채움").
|
||
# MDX stem keys the persistence file; invalid stems / corrupt file
|
||
# degrade gracefully (warning to stderr + no override injected).
|
||
from src.user_overrides_io import (
|
||
InvalidOverrideKey,
|
||
load as _load_user_overrides,
|
||
validate_key as _validate_overrides_key,
|
||
)
|
||
|
||
_final_override_layout = args.override_layout
|
||
try:
|
||
_ov_key = _validate_overrides_key(Path(args.mdx_path).stem)
|
||
except InvalidOverrideKey as _exc:
|
||
print(
|
||
f"[user_overrides] warning: cannot derive persistence key from "
|
||
f"mdx_path {args.mdx_path!r}: {_exc}; skipping fallback.",
|
||
file=sys.stderr,
|
||
)
|
||
_ov_key = None
|
||
if _ov_key is not None:
|
||
_persisted = _load_user_overrides(_ov_key)
|
||
# layout — CLI None → fill from file (must be str).
|
||
if _final_override_layout is None:
|
||
_file_layout = _persisted.get("layout")
|
||
if isinstance(_file_layout, str) and _file_layout:
|
||
_final_override_layout = _file_layout
|
||
# frames — CLI empty → fill from file (must be dict[str, str]).
|
||
if not overrides_frames:
|
||
_file_frames = _persisted.get("frames")
|
||
if isinstance(_file_frames, dict):
|
||
overrides_frames = {
|
||
str(k): str(v)
|
||
for k, v in _file_frames.items()
|
||
if isinstance(k, str) and isinstance(v, str)
|
||
}
|
||
# zone_geometries — CLI empty → fill from file (dict[str, dict]).
|
||
if not overrides_geoms:
|
||
_file_geoms = _persisted.get("zone_geometries")
|
||
if isinstance(_file_geoms, dict):
|
||
_accepted: dict[str, dict] = {}
|
||
for _zid, _g in _file_geoms.items():
|
||
if (
|
||
isinstance(_zid, str)
|
||
and isinstance(_g, dict)
|
||
and all(k in _g for k in ("x", "y", "w", "h"))
|
||
):
|
||
try:
|
||
_accepted[_zid] = {
|
||
"x": float(_g["x"]),
|
||
"y": float(_g["y"]),
|
||
"w": float(_g["w"]),
|
||
"h": float(_g["h"]),
|
||
}
|
||
except (TypeError, ValueError):
|
||
continue
|
||
overrides_geoms = _accepted
|
||
# zone_sections — CLI empty → fill from file (dict[str, list[str]]).
|
||
if not overrides_section_assignments:
|
||
_file_sections = _persisted.get("zone_sections")
|
||
if isinstance(_file_sections, dict):
|
||
_accepted_sec: dict[str, list[str]] = {}
|
||
for _zid, _sec_list in _file_sections.items():
|
||
if isinstance(_zid, str) and isinstance(_sec_list, list):
|
||
_sids = [s for s in _sec_list if isinstance(s, str) and s]
|
||
if _sids:
|
||
_accepted_sec[_zid] = _sids
|
||
overrides_section_assignments = _accepted_sec
|
||
# image_overrides — CLI empty → fill from file (dict[str, dict]).
|
||
# IMP-51 (#79) u6 — mirrors zone_geometries validation: only accept
|
||
# mappings of {image_id: {x,y,w,h}} with float-coercible values.
|
||
if not overrides_images:
|
||
_file_images = _persisted.get("image_overrides")
|
||
if isinstance(_file_images, dict):
|
||
_accepted_img: dict[str, dict] = {}
|
||
for _iid, _g in _file_images.items():
|
||
if (
|
||
isinstance(_iid, str)
|
||
and _iid
|
||
and isinstance(_g, dict)
|
||
and all(k in _g for k in ("x", "y", "w", "h"))
|
||
):
|
||
try:
|
||
_accepted_img[_iid] = {
|
||
"x": float(_g["x"]),
|
||
"y": float(_g["y"]),
|
||
"w": float(_g["w"]),
|
||
"h": float(_g["h"]),
|
||
}
|
||
except (TypeError, ValueError):
|
||
continue
|
||
overrides_images = _accepted_img
|
||
|
||
# IMP-43 (#72) u1 — fail-closed reuse_from precondition guard.
|
||
# Placed AFTER the user_overrides.json merge so persisted overrides
|
||
# are evaluated against the same reuse boundary as CLI overrides
|
||
# (Stage 2 lock: "fail-closed guard after user_overrides.json merge
|
||
# and before dispatch"). Reuse target = Step 0/1/2/5/6 deterministic
|
||
# artifacts; only frame overrides preserve that subset. layout /
|
||
# zone_geometry / zone_section / image overrides each invalidate at
|
||
# least one of Step 0/1/2/5/6 and must reject. Frame-only is allowed
|
||
# (no rejected axes → falls through to dispatch). Error stderr names
|
||
# every rejected axis so the user can either drop the rejected axes
|
||
# or rerun without --reuse-from.
|
||
if args.reuse_from is not None:
|
||
_rejected_axes: list[str] = []
|
||
if _final_override_layout is not None:
|
||
_rejected_axes.append("layout")
|
||
if overrides_geoms:
|
||
_rejected_axes.append("zone_geometry")
|
||
if overrides_section_assignments:
|
||
_rejected_axes.append("zone_section")
|
||
if overrides_images:
|
||
_rejected_axes.append("image")
|
||
if _rejected_axes:
|
||
print(
|
||
f"[error] --reuse-from incompatible with override axes: "
|
||
f"{', '.join(_rejected_axes)}. Only --override-frame is "
|
||
f"preserved across Step 0/1/2/5/6 reuse; drop the rejected "
|
||
f"overrides or rerun without --reuse-from.",
|
||
file=sys.stderr,
|
||
)
|
||
sys.exit(2)
|
||
|
||
run_phase_z2_mvp1(
|
||
args.mdx_path,
|
||
args.run_id,
|
||
override_layout=_final_override_layout,
|
||
override_frames=overrides_frames or None,
|
||
override_zone_geometries=overrides_geoms or None,
|
||
override_section_assignments=overrides_section_assignments or None,
|
||
override_image_overrides=overrides_images or None,
|
||
override_slide_css=_final_override_slide_css,
|
||
reuse_from=args.reuse_from,
|
||
)
|