Files
C.E.L_Slide_test2/scripts/smoke_frame_render.py
kyeongmin 834ed3946d test(IMP-04): add F14 render artifact check and fix min-height note
3rd commit in F14 series (calibration point clean pass). Closes the two
Codex round 26 (#15435) blockers:
1. min_height_px self-contradiction
2. F14 actual rendered visual artifact absent

Per Codex round 28 (#15447) agreement (M1 + --render-to extension) and
Claude round 27 (#15438) fix path :

Changes :

1. templates/phase_z2/catalog/frame_contracts.yaml — min_height_px 320 → 350.
   Comment now self-consistent : 70 (badge raster) + 210 (bullet body) +
   36 (photo strip) + 30 (padding) = 346 sum + 4 safety buffer = 350.
   F14 is now F29-class (345) per raster-promoted content density.

2. scripts/smoke_frame_render.py — add `--render-to DIR` dev mode (R3
   acceptance gate). Behavior :
   - StrictUndefined smoke render (unchanged)
   - reuse production `copy_assets(template_id, run_dir)` so the runtime
     asset delivery path is exercised (no logic duplication)
   - wrap partial with minimal viewer HTML (Phase Z token vars + slide-
     sized wrap, browser-openable)
   - fail-fast if rendered HTML references a missing local asset (per
     Codex round 28 §4 recommendation)
   - save artifact to {DIR}/index.html with {DIR}/assets/{template_id}/*
   - production render path (phase_z2_pipeline.render_slide) unchanged
   - small regex fix : asset extraction now captures both `src="..."`
     and `url("...")` references

F14 verification (3rd commit) :
- python -m py_compile scripts/smoke_frame_render.py : PASS
- python scripts/smoke_frame_render.py --self-check : PASS 4/4 (7446 chars
  for persona unchanged from a1c06b7)
- python scripts/smoke_frame_render.py three_persona_benefits --render-to
  data/runs/imp04_f14_visual : PASS, 10 asset refs all resolved, 14
  raster files copied via production copy_assets() to
  data/runs/imp04_f14_visual/assets/three_persona_benefits/
- R3 artifact ready for browser visual inspection at
  data/runs/imp04_f14_visual/index.html (Phase Z slide-sized wrapper +
  promoted persona partial + 10 referenced assets all on disk)

F14 clean pass status :
- min_height_px self-consistency : fixed (M1 = 350)
- Actual rendered artifact : produced and assets resolved
- Visual fidelity inspection : ready for browser/eye review
- Earlier MDX02 chain attempt (commit a1c06b7 body) : superseded;
  MDX02 is not the F14 validation baseline (Claude round 26 / Codex
  round 26 agreement). MDX03 is the matched baseline; F14 visual
  inspection now uses the harness artifact path instead.

scope-lock guardrails honored : 32-frame target, no V4 logic change,
no Phase R' regression, no mapper or composition planner change, no
production render path change. The new harness mode is dev verification
only, isolated from runtime selection.

Refs Gitea #4 (IMP-04 Track A — F14 3rd commit, clean pass gate)
2026-05-13 11:07:37 +09:00

420 lines
16 KiB
Python

"""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},
],
},
],
}
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,
}
# ─── 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>F14 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())