Files
C.E.L_Slide_test2/scripts/smoke_frame_render.py
kyeongmin 73a98b8ad1 IMP-04 F17 schema correction — paired_rows_4x2 + pill alternation + source-faithful theme
source = 8 atomic issues (4 paired rows × 2 cells per texts.md), 이전 strict-4
가 source 의 절반 누락. round 55~73 review-loop 의 calibration frame.

- contract : source_shape=top_bullets / layout_variant=paired_rows_4x2_alternating_pills
  / strict 8 (no pad/truncate) / role_order row_{1..4}_{left,right} / visual_hints
  pill_positions + row_gap_after / builder paired_rows_4x2_slots
- builder : new _build_paired_rows_4x2_slots — 2-axis (row × side) deterministic
  index mapping, strict 8 raises before render, quadrant_item parser 재사용
- partial : 4-row × 2-cell flex, pill alternation (row 1/3 top, row 2/4 bottom
  via column-reverse), row 2-3 visual gap, source-faithful color (rgb(204,82,0)
  →rgb(136,55,0) title + #60A451 row border + rgba(250,237,203,0.15) bg + #0c271e
  body + 2px dashed #60A451 cell 분할선), pill = CSS approximation (asset crop
  variant single-pass 비용 高 → fallback per Codex round 62/68 scope cap, pill
  shape + alternation + green/cream/brown theme 보존), no row headers (source
  부재, inference 금지)
- fixture : flat 8 top-bullet (texts.md 8 issues 그대로)
- smoke + R3 : PASS (11/11 self-check, 5535 chars partial, 8 units rendered,
  pill alternation 정합, row 2-3 gap, no invented row headers)

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

548 lines
23 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""IMP-04 per-frame Jinja smoke harness (StrictUndefined).
scope-lock 16 조건 (Gitea #4) §11 / §13 :
- smoke = isolated Jinja partial render with StrictUndefined
- production render path (`phase_z2_pipeline.render_slide`) 미변경 — 본 harness 만 strict
- builder output keys ↔ partial Jinja variables 정합 확인이 본 harness 의 목적
- asset path / undefined variable / template syntax 실패 시 즉시 error
Usage :
# sanity check existing frames (mock payloads bundled below)
python scripts/smoke_frame_render.py --self-check
# check a specific template with mock payload from stdin
python scripts/smoke_frame_render.py three_parallel_requirements < mock.json
# check all template_ids in `templates/phase_z2/families/` against bundled mocks
python scripts/smoke_frame_render.py --self-check
Exit codes :
0 = all renders succeeded (StrictUndefined passed)
1 = at least one render failed
2 = invalid input (template_id 미존재 등)
본 harness 는 IMP-04 의 per-frame 6-step gate Step 5 의 자동 실행 단위.
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Optional
from jinja2 import (
Environment,
FileSystemLoader,
StrictUndefined,
TemplateError,
UndefinedError,
select_autoescape,
)
PROJECT_ROOT = Path(__file__).parent.parent
TEMPLATE_DIR = PROJECT_ROOT / "templates" / "phase_z2"
FAMILIES_DIR = TEMPLATE_DIR / "families"
# ─── Mock payloads (existing 3 frames — sanity baseline) ────────
# mock = real builder output shape (refer mapper.py builders).
# StrictUndefined requires every referenced attribute to exist; optional fields
# must be present with empty/None values so `{% if x %}` evaluates falsy.
_MOCK_THREE_PARALLEL = {
"title": "1. DX 시행을 위한 필수 요건",
"pillars": [
{
"label": "기술 (Technology)",
"label_main": "기술",
"label_paren": "Technology",
"color_class": "tech",
"sections": [
{
"heading": "디지털 도구",
"text_lines": [
{"text": "BIM 활용", "indent": 0},
{"text": "클라우드 협업", "indent": 0},
],
},
],
},
{
"label": "사람 (People)",
"label_main": "사람",
"label_paren": "People",
"color_class": "people",
"sections": [
{
"heading": "역량 강화",
"text_lines": [
{"text": "전문가 양성", "indent": 0},
],
},
],
},
{
"label": "자연 (Nature)",
"label_main": "자연",
"label_paren": "Nature",
"color_class": "nature",
"sections": [
{
"heading": "환경 적응",
"text_lines": [
{"text": "지역적 제약 고려", "indent": 0},
],
},
],
},
],
}
_MOCK_PROCESS_PRODUCT = {
"title": "2. Process의 혁신과 Product의 변화",
"banner_left": "Process",
"banner_right": "Product",
"process": {
"sections": [
{"title": "과정 1", "transforms": [], "text_lines": [{"text": "AS-IS 정리", "indent": 0}]},
{"title": "과정 2", "transforms": [], "text_lines": [{"text": "TO-BE 전환", "indent": 0}]},
{"title": "과정 3", "transforms": [], "text_lines": [{"text": "검증 단계", "indent": 0}]},
],
},
"product": {
"sections": [
{"title": "결과 1", "transforms": [], "text_lines": [{"text": "BIM 모델", "indent": 0}], "footnote": "", "sub_title": ""},
{"title": "결과 2", "transforms": [], "text_lines": [{"text": "통합 협업", "indent": 0}], "footnote": "", "sub_title": ""},
{"title": "결과 3", "transforms": [], "text_lines": [{"text": "실시간 검증", "indent": 0}], "footnote": "", "sub_title": ""},
],
},
}
_MOCK_QUADRANT = {
"title": "BIM 도입 4 문제",
"center_quote": "",
"quadrant_1_label": "기술 부족",
"quadrant_1_headline": "",
"quadrant_1_body": [{"text": "디지털 도구 미숙", "indent": 0}],
"quadrant_2_label": "인력 부족",
"quadrant_2_headline": "",
"quadrant_2_body": [{"text": "전문가 부재", "indent": 0}],
"quadrant_3_label": "프로세스",
"quadrant_3_headline": "",
"quadrant_3_body": [{"text": "업무 흐름 단절", "indent": 0}],
"quadrant_4_label": "표준화",
"quadrant_4_headline": "",
"quadrant_4_body": [{"text": "가이드 부재", "indent": 0}],
}
# IMP-04 frame 1 — three_persona_benefits (frame 14, frame_id=1171281191).
# Builder = items_with_role + quadrant_item parser → persona dict = {label, body, color_class}.
_MOCK_THREE_PERSONA_BENEFITS = {
"title": "주체별 기대효과",
"personas": [
{
"label": "발주자",
"color_class": "client",
"body": [
{"text": "민원, 재 작업 등의 예방 및 최소화", "indent": 0},
{"text": "직관화로 품질 향상 및 안정성 제고", "indent": 0},
{"text": "수행공정의 쉬운이해로 관리 편의성 증진", "indent": 0},
{"text": "실무자와 발주자간의 소통 오류 최소화", "indent": 0},
],
},
{
"label": "시공자",
"color_class": "constructor",
"body": [
{"text": "시공 오류예방 및 공사 Risk 최소화", "indent": 0},
{"text": "시각화로 안전성 제고 및 품질 향상", "indent": 0},
{"text": "건설 관계자들 간의 의사소통 강화", "indent": 0},
],
},
{
"label": "설계자",
"color_class": "designer",
"body": [
{"text": "직관적 시각화로 원활한 소통", "indent": 0},
{"text": "3D 모델 활용으로 오류 최소화", "indent": 0},
{"text": "발주자와의 상호 신뢰 증진", "indent": 0},
],
},
],
}
# Track A frame 2 — construction_goals_three_circle_intersection (frame 12).
# Builder = cycle_intersect_3 + quadrant_item parser (label only).
# slot_payload : title, circle_1_label, circle_2_label, circle_3_label, intersection.
_MOCK_CONSTRUCTION_GOALS = {
"title": "건설산업의 목표 (BIM 의 목적)",
"circle_1_label": "안전과 품질",
"circle_2_label": "생산성 향상",
"circle_3_label": "소통과 신뢰",
"intersection": "3요소가 조화를 이룰 때 BIM 의 궁극적 목표 달성",
}
# Track A frame 3 — construction_bim_three_usage (frame 11).
# Builder = quadrant_flat_slots reuse (pad_to=3, category_N_label/body keys).
_MOCK_CONSTRUCTION_BIM_USAGE = {
"title": "시공단계 BIM 모델·정보 활용 구분",
"category_1_label": "모델기반",
"category_1_body": [
{"text": "최종 목적물의 3D 형상정보 활용", "indent": 0},
],
"category_2_label": "객체기반",
"category_2_body": [
{"text": "Model 개별 객체의 건설정보 활용", "indent": 0},
],
"category_3_label": "위치기반",
"category_3_body": [
{"text": "공사 중 위치정보 활용", "indent": 0},
],
}
# Track A frame 4 — bim_dx_comparison_table (frame 18).
# NEW builder = compare_table_2col + NEW parser = compare_row_2col_item.
# slot_payload : title, col_a_label, col_b_label, rows=[{label, col_a, col_b}].
_MOCK_BIM_DX_COMPARISON = {
"title": "BIM 과 DX 의 이해",
"col_a_label": "BIM",
"col_b_label": "DX",
"rows": [
{"label": "범위", "col_a": "Only 3D", "col_b": "BIM &lt;&lt; DX (ENG. + Mgmt 포함)"},
{"label": "S/W", "col_a": "상용 S/W (Revit 등)", "col_b": "상용 + 전용 40~80개"},
{"label": "프로세스", "col_a": "기존 2D 설계방식 유지", "col_b": "근본적 문제의식 통한 개선"},
{"label": "성과물", "col_a": "3D 모델 중심", "col_b": "공학 정보 + 콘텐츠 연계"},
{"label": "활용", "col_a": "분야별 단절", "col_b": "전 생애주기 활용 시스템"},
{"label": "수행개념", "col_a": "수동적 / 집단적", "col_b": "적극·구체적 실현 방안"},
],
}
# Track A frame 5 — dx_sw_necessity_three_perspectives (frame 20).
# Builder reuse = quadrant_flat_slots (F11 pattern) — pad_to=3, perspective_N keys.
_MOCK_DX_SW_NECESSITY = {
"title": "디지털 전환(DX)은 S/W가 필수다",
"perspective_1_label": "BIM 전면설계",
"perspective_1_body": [
{"text": "건설산업 생산성 향상", "indent": 0},
{"text": "고부가가치 산업 전환", "indent": 0},
],
"perspective_2_label": "디지털 전환 S/W",
"perspective_2_body": [
{"text": "노동집약형 업무 탈피", "indent": 0},
{"text": "S/W 고도화 + 투자 필요", "indent": 0},
],
"perspective_3_label": "고부가가치 산업전환",
"perspective_3_body": [
{"text": "기본기술 이해 발전 필요", "indent": 0},
{"text": "DX 통한 Process 혁신", "indent": 0},
],
}
# Track A frame 6 — info_management_what_how_when (frame 8).
# V4-zero catalog-completeness activation (Codex round 47 guardrail).
# Builder reuse = quadrant_flat_slots (F11/F20 pattern) — section_N keys.
_MOCK_INFO_MGMT = {
"title": "효율적인 정보의 관리와 활용 (What/How/When)",
"section_1_label": "무슨 정보 (What)",
"section_1_body": [
{"text": "수량 / 단가 / 공사일정 등 계획 정보", "indent": 0},
{"text": "일일 작업 / 자원 등 공사 실행 정보", "indent": 0},
],
"section_2_label": "어떻게 연계 (How)",
"section_2_body": [
{"text": "3D 형상 산출속성 연계", "indent": 0},
{"text": "시방규정 + S/W 통합", "indent": 0},
],
"section_3_label": "언제 사용 (When)",
"section_3_body": [
{"text": "착수 전 공정/시공계획 수립", "indent": 0},
{"text": "공사 후 실적 관리 + 문서 작성", "indent": 0},
],
}
SELF_CHECK_FIXTURES: dict[str, dict] = {
"three_parallel_requirements": _MOCK_THREE_PARALLEL,
"process_product_two_way": _MOCK_PROCESS_PRODUCT,
"bim_issues_quadrant_four": _MOCK_QUADRANT,
"three_persona_benefits": _MOCK_THREE_PERSONA_BENEFITS,
"construction_goals_three_circle_intersection": _MOCK_CONSTRUCTION_GOALS,
"construction_bim_three_usage": _MOCK_CONSTRUCTION_BIM_USAGE,
"bim_dx_comparison_table": _MOCK_BIM_DX_COMPARISON,
"dx_sw_necessity_three_perspectives": _MOCK_DX_SW_NECESSITY,
"info_management_what_how_when": _MOCK_INFO_MGMT,
"sw_reality_three_emphasis": {
"title": "현존 상용 S/W 의 현실",
"emphasis_1_label": "토목 전문성 부족",
"emphasis_1_body": [{"text": "건축용 S/W 일부 수정 적용", "indent": 0}],
"emphasis_2_label": "비효율성",
"emphasis_2_body": [{"text": "범용 개발 + 전문가용 한계", "indent": 0}],
"emphasis_3_label": "실무 적용 불가",
"emphasis_3_body": [{"text": "특수성 반영 어려움", "indent": 0}],
},
"bim_current_problems_paired": {
# F17 schema-correction — 8 atomic issues per source texts.md (round 55~73 lock).
# paired_rows_4x2_alternating_pills : 4 rows × 2 cells, row 1/3 pill top, row 2/4 pill bottom.
"title": "현황 및 문제점",
# row 1 : BIM 의미 인식 오류 (개념 부재 + 잘못된 접근방식)
"row_1_left_label": "개념 부재",
"row_1_left_body": [{"text": "BIM을 CAD 확장판으로 오인, 3D 도구 정도로만 인식", "indent": 0}],
"row_1_right_label": "잘못된 접근방식",
"row_1_right_body": [{"text": "단순 업무효율 도구로만 인식, 교육으로 해결될 것으로 판단", "indent": 0}],
# row 2 : 기술 방향 의존 (방향성 상실 + 전제조건 오류)
"row_2_left_label": "방향성 상실",
"row_2_left_body": [{"text": "대형 S/W 회사 제시 내용 추종, 자체 목표설정 기능 상실", "indent": 0}],
"row_2_right_label": "전제조건 오류",
"row_2_right_body": [{"text": "건축·토목 동일 전제로 건축 방식을 토목에 그대로 적용", "indent": 0}],
# row 3 : 실행 주체 혼란 (수행주체 혼란 + 수행방식 무지)
"row_3_left_label": "수행주체 혼란",
"row_3_left_body": [{"text": "학자·발주처 주도, 실행주체 기업·기술자는 기존 방식 고수", "indent": 0}],
"row_3_right_label": "수행방식 무지",
"row_3_right_body": [{"text": "2D 결과 전제, 3D 수행 경험 부재, 비용·시간 증가·품질 미흡", "indent": 0}],
# row 4 : 외부 의존성 (외산S/W 기술예속 + H/W 미비)
"row_4_left_label": "외산S/W 기술예속",
"row_4_left_body": [{"text": "외산 범용 S/W 만으로 BIM 가능 인식, 기술예속 가속", "indent": 0}],
"row_4_right_label": "H/W 미비",
"row_4_right_body": [{"text": "탁상용 PC·Monitor 수준, 고품질 모델 표출 한계", "indent": 0}],
},
}
# ─── Core ───────────────────────────────────────────────────────
def _make_env() -> Environment:
"""StrictUndefined Jinja env — production render path 와 동등 loader,
단 undefined behavior 만 strict.
"""
return Environment(
loader=FileSystemLoader(str(TEMPLATE_DIR)),
autoescape=select_autoescape(["html"]),
undefined=StrictUndefined,
)
def smoke_render(template_id: str, slot_payload: dict) -> tuple[bool, str]:
"""Isolated StrictUndefined Jinja render.
Returns (ok, html_or_error_text).
"""
env = _make_env()
try:
partial = env.get_template(f"families/{template_id}.html")
except Exception as exc: # noqa: BLE001 — surface any loader error
return False, f"TemplateLoad: {exc!r}"
try:
html = partial.render(slot_payload=slot_payload)
except UndefinedError as exc:
return False, f"UndefinedError (missing variable): {exc}"
except TemplateError as exc:
return False, f"TemplateError: {exc}"
except Exception as exc: # noqa: BLE001
return False, f"RenderError: {exc!r}"
return True, html
def list_existing_partials() -> list[str]:
"""Return template_id list (filename stems without .html) under families/."""
return sorted(p.stem for p in FAMILIES_DIR.glob("*.html"))
# ─── Render-to artifact (R3 acceptance gate) ────────────────────
def _extract_asset_refs(html: str, template_id: str) -> list[str]:
"""Return relative paths `assets/{template_id}/<filename>` referenced by HTML.
Matches both `src="assets/..."` (img tag) and `url("assets/...")` (CSS).
"""
import re
pattern = rf'(?:src=|url\()["\']?(assets/{re.escape(template_id)}/[^"\'\)\s]+)'
return sorted(set(re.findall(pattern, html)))
def render_to_dir(template_id: str, slot_payload: dict, out_dir: Path) -> tuple[bool, str]:
"""R3 acceptance gate — render partial + copy assets + save artifact.
Mechanism (Codex round 28 spec) :
1. smoke render (StrictUndefined Jinja) → HTML
2. reuse production `copy_assets(template_id, run_dir)` — assets/<template_id>/* 복사
3. save HTML to `{out_dir}/index.html`
4. fail if HTML references a missing local asset (post copy)
5. production render path 미변경
Returns (ok, summary_or_error).
"""
ok, html_or_err = smoke_render(template_id, slot_payload)
if not ok:
return False, f"render failed: {html_or_err}"
out_dir.mkdir(parents=True, exist_ok=True)
# Reuse production copy_assets (no logic dup)
import sys
sys.path.insert(0, str(PROJECT_ROOT))
from src.phase_z2_pipeline import copy_assets
assets_dst = copy_assets(template_id, out_dir)
assets_info = f"assets dir={assets_dst.relative_to(out_dir) if assets_dst else '(none)'}"
# Verify all referenced assets exist (Codex round 28 — fail-fast missing assets)
refs = _extract_asset_refs(html_or_err, template_id)
missing = [r for r in refs if not (out_dir / r).exists()]
if missing:
return False, (
f"missing assets (fail-fast per Codex round 28) : {len(missing)} of "
f"{len(refs)} references not resolved : {missing[:3]}{'...' if len(missing) > 3 else ''}"
)
# Wrap partial with minimal HTML viewer (browser-openable)
viewer = f"""<!DOCTYPE html>
<html lang="ko"><head>
<meta charset="UTF-8">
<title>Phase Z render artifact — {template_id}</title>
<style>
body {{ margin: 0; padding: 20px; background: #e8ecf0;
font-family: 'Noto Sans KR', sans-serif; word-break: keep-all; }}
.viewer-wrap {{ width: 1180px; max-width: 100%; margin: 0 auto;
background: #fff; box-shadow: 0 4px 20px rgba(0,0,0,.15);
padding: 40px; min-height: 350px; box-sizing: border-box; }}
.viewer-note {{ max-width: 1180px; margin: 10px auto 0; padding: 8px 12px;
background: #fffbe8; border-left: 4px solid #f5b400;
font-size: 12px; color: #5a4500; }}
/* Phase Z token CSS — minimal viewer override (production uses real tokens) */
:root {{
--font-zone-title: 28px; --lh-zone-title: 1.3;
--font-sub-title: 18px; --lh-sub-title: 1.4;
--font-caption: 11px;
--font-body: 11px; --lh-body: 1.4;
}}
</style>
</head><body>
<div class="viewer-wrap">
{html_or_err}
</div>
<div class="viewer-note">
R3 acceptance gate artifact — smoke harness `--render-to`. template_id = {template_id}.
Open this file in a browser to visually inspect rendered output + promoted assets.
</div>
</body></html>
"""
out_html = out_dir / "index.html"
out_html.write_text(viewer, encoding="utf-8")
return True, (
f"rendered → {out_html} ({len(html_or_err)} chars partial, "
f"{len(refs)} asset refs all resolved). {assets_info}"
)
# ─── CLI ────────────────────────────────────────────────────────
def _cmd_self_check() -> int:
"""Run smoke render against every bundled fixture and report.
Exit 0 if all PASS, 1 otherwise. Frames with no fixture are SKIPPED
(reported separately so the per-frame gate is explicit).
"""
existing = list_existing_partials()
print(f"== smoke harness self-check ({len(existing)} partial(s) found) ==")
fail_count = 0
skip_count = 0
for tpl in existing:
if tpl not in SELF_CHECK_FIXTURES:
print(f" SKIP {tpl} (no bundled fixture)")
skip_count += 1
continue
ok, msg = smoke_render(tpl, SELF_CHECK_FIXTURES[tpl])
if ok:
print(f" PASS {tpl} ({len(msg)} chars)")
else:
print(f" FAIL {tpl}{msg}")
fail_count += 1
# Also surface fixtures with no corresponding partial (catches typos)
for fixture in SELF_CHECK_FIXTURES:
if fixture not in existing:
print(f" ORPHAN {fixture} (fixture but no partial)")
fail_count += 1
print(f"-- summary: PASS={len(existing) - fail_count - skip_count} "
f"FAIL={fail_count} SKIP={skip_count} --")
return 0 if fail_count == 0 else 1
def _cmd_one(template_id: str, payload_path: Optional[Path]) -> int:
if payload_path is not None:
slot_payload = json.loads(payload_path.read_text(encoding="utf-8"))
elif not sys.stdin.isatty():
slot_payload = json.load(sys.stdin)
elif template_id in SELF_CHECK_FIXTURES:
slot_payload = SELF_CHECK_FIXTURES[template_id]
print(f"[info] using bundled fixture for {template_id}", file=sys.stderr)
else:
print(f"error: no payload provided for '{template_id}' "
f"(pipe JSON via stdin or use --payload)", file=sys.stderr)
return 2
ok, msg = smoke_render(template_id, slot_payload)
if ok:
print(f"PASS {template_id} ({len(msg)} chars rendered)", file=sys.stderr)
sys.stdout.write(msg)
return 0
print(f"FAIL {template_id}{msg}", file=sys.stderr)
return 1
def main(argv: Optional[list[str]] = None) -> int:
parser = argparse.ArgumentParser(
description="IMP-04 per-frame Jinja smoke harness (StrictUndefined).",
)
parser.add_argument("template_id", nargs="?",
help="frame template_id (omit when --self-check).")
parser.add_argument("--self-check", action="store_true",
help="render every bundled fixture and report.")
parser.add_argument("--payload", type=Path, default=None,
help="JSON file with slot_payload (else stdin or fixture).")
parser.add_argument("--render-to", type=Path, default=None, metavar="DIR",
help=(
"R3 acceptance gate — render + copy_assets + save artifact "
"to DIR/index.html. fail-fast on missing local assets."
))
args = parser.parse_args(argv)
if args.self_check:
return _cmd_self_check()
if args.template_id is None:
parser.print_usage(sys.stderr)
return 2
# --render-to mode (R3 acceptance gate)
if args.render_to is not None:
# Determine payload (fixture preferred for render-to)
if args.payload is not None:
payload = json.loads(args.payload.read_text(encoding="utf-8"))
elif args.template_id in SELF_CHECK_FIXTURES:
payload = SELF_CHECK_FIXTURES[args.template_id]
print(f"[info] using bundled fixture for {args.template_id}", file=sys.stderr)
elif not sys.stdin.isatty():
payload = json.load(sys.stdin)
else:
print(f"error: no payload for '{args.template_id}' "
f"(--payload or stdin or bundled fixture required)", file=sys.stderr)
return 2
ok, msg = render_to_dir(args.template_id, payload, args.render_to)
if ok:
print(f"PASS {args.template_id}{msg}")
return 0
print(f"FAIL {args.template_id}{msg}", file=sys.stderr)
return 1
return _cmd_one(args.template_id, args.payload)
if __name__ == "__main__":
sys.exit(main())