Files
C.E.L_Slide_test2/scripts/smoke_frame_render.py
kyeongmin 556b4486ae feat(catalog): activate three_persona_benefits frame (IMP-04 #4 / 1 of 7)
Reason : V4 use_as_is=1 (frame_number=14, frame_id=1171281191).
Pattern : cards-3col-persona — 발주자/시공자/설계자 3 주체 각 benefit.

- Append `three_persona_benefits` contract to frame_contracts.yaml after
  the existing F13/F29/F16 entries (Codex Catch 1/4: YAML order = trace
  selection surface)
- Reuse existing builder primitives: items_with_role + quadrant_item
  parser. No new entry in PAYLOAD_BUILDERS / ITEM_PARSERS.
  Output dict shape: payload.personas = [{label, body, color_class}, ...]
- Add families/three_persona_benefits.html partial:
  - Pure CSS (no Figma raster img tags) per memory rule
    `feedback_blocks_must_be_css.md`
  - PROMOTED colors per persona (#285b4a client / #445a2f constructor /
    #743002 designer) from Figma TEXT layers
  - NOT PROMOTED: col_bg_texture / overlay / 하단 사진 / 원형 뱃지 inner-outer
    image — all replaced by CSS approximation (pill badge + colored
    border + check-style text-line bullets)
  - Token-fixed typography (zone-title / sub-title / caption / body)
  - data-frame-id="1171281191" data-template-id attributes
- Add bundled smoke fixture for three_persona_benefits to
  scripts/smoke_frame_render.py
- visual_hints.min_height_px = 280 (initial estimate between F13=230 and
  F29=345 for 3-card text-heavy layout). Refine during batch full
  pipeline if needed.
- accepted_content_types = [text_block] only (rich types not routed yet
  per IMP-03 scope-lock).

Verification :
- isolated Jinja StrictUndefined smoke (scripts/smoke_frame_render.py
  --self-check) : PASS=4/4 (existing 3 + new persona, 3889 chars)
- regression run on MDX 03 (env OFF + rich OFF) : PASS — MDX 03 V4
  rank-1 still F13/F29 so the new entry does not affect existing flow

scope-lock 15 conditions all honored (no V4 / mapper / Phase R' / Step
6+ changes; per-frame 6-step gate complete; YAML order preserved).

Refs Gitea #4 (IMP-04 A-2 Catalog 확장)
2026-05-13 06:56:35 +09:00

304 lines
11 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"))
# ─── 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).")
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
return _cmd_one(args.template_id, args.payload)
if __name__ == "__main__":
sys.exit(main())