"""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 << 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}/` 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//* 복사 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""" Phase Z render artifact — {template_id}
{html_or_err}
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.
""" 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())