Files

573 lines
25 KiB
Python

#!/usr/bin/env python3
"""Consulting deliverable renderer — storyline(.report.yaml) → 문서 + PPT.
한 소스(EM synthesis 보고서의 storyline/narrative)에서 두 산출물을 drift 없이 생성:
① 문서: <name>-report.md (장문 — BLUF·SCQA·권고·근거·이견, exhibit embed)
② 덱 : <name>-deck.md (Marp) + <name>-deck.html (self-contained, 오프라인 발표)
└ --marp면 marp-cli로 .pptx/.pdf/.html export(chrome 필요). 실패해도 HTML 덱은 보장.
exhibit 2계열: ①정량·개념 = consult_exhibits.py 손제작 SVG 아키타입 7종.
②소프트웨어 구조·흐름·의존성 = {type: d2}(1급, d2 CLI 실물 렌더) 우선, {type: mermaid}는 폴백.
방법론(액션타이틀·one-message-per-slide·Pyramid)을 렌더러가 구조로 강제한다(픽셀이 아니라 논리).
Usage:
python3 render_consult.py <report.yaml> [--outdir DIR] [--name NAME] [--marp]
"""
import argparse
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
import yaml
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import consult_exhibits as CE # noqa: E402
def slugify(s, fallback="deck"):
s = re.sub(r"[^\w가-힣\- ]", "", str(s or "")).strip().lower()
s = re.sub(r"[\s_]+", "-", s)
return s[:60] or fallback
def _mkdir(p):
os.makedirs(p, exist_ok=True)
return p
# ------------------------------------------------------------ mermaid (실제 다이어그램 산출)
def _mermaid_fallback_svg(code):
"""mmdc 미가용 시: 코드를 monospace로 보여주는 폴백 SVG(파이프라인 유지, 오프라인 안전).
**열화(degraded)로 표시** — 실물 렌더가 아님을 배너+기계마커로 명시한다(성공 위장 금지)."""
return CE.degraded_svg("mermaid", code=code, title="Mermaid 다이어그램")
def render_mermaid(code, workdir):
"""Mermaid 코드를 mmdc(+system chrome)로 SVG 렌더. 실패/미가용 시 코드 폴백 SVG.
diagram-as-code 실물 산출 — 플로우·시퀀스·C4풍·의존성 그래프 등 7 아키타입이 못 그리는 그림."""
if not str(code).strip():
return None
_mkdir(workdir)
if os.environ.get("RENDER_CONSULT_NO_MMDC"):
return _mermaid_fallback_svg(code)
h = hashlib.md5(code.encode("utf-8")).hexdigest()[:8] # 결정적 임시명(재현 안전)
mmd = os.path.join(workdir, f"_mmd-{h}.mmd")
outp = os.path.join(workdir, f"_mmd-{h}.svg")
cfg = os.path.join(workdir, "_pptr.json")
with open(mmd, "w") as f:
f.write(str(code))
if not os.path.exists(cfg):
chrome = "/usr/bin/google-chrome"
exe = chrome if os.path.exists(chrome) else ""
with open(cfg, "w") as f:
f.write('{"executablePath":"%s","args":["--no-sandbox","--disable-gpu"]}' % exe)
try:
r = subprocess.run(
["npx", "--yes", "-p", "@mermaid-js/mermaid-cli", "mmdc",
"-i", mmd, "-o", outp, "-p", cfg, "-b", "transparent"],
capture_output=True, text=True, timeout=200)
if r.returncode == 0 and os.path.exists(outp):
svg = open(outp).read()
for p in (mmd, outp):
try:
os.remove(p)
except OSError:
pass
return svg
sys.stderr.write(f"[mermaid] failed rc={r.returncode}: {r.stderr[-200:]}\n")
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
sys.stderr.write(f"[mermaid] skipped: {e}\n")
return _mermaid_fallback_svg(code)
# ------------------------------------------------------------ D2 (1급 diagram-as-code — Mermaid보다 실무급)
def _d2_bin():
"""d2 실행파일 위치: PATH → ~/.local/bin/d2(무루트 설치) 순."""
b = shutil.which("d2")
if b:
return b
cand = os.path.expanduser("~/.local/bin/d2")
return cand if os.path.exists(cand) else None
def _d2_fallback_svg(code):
"""d2 미가용 시: 코드를 monospace로 보여주는 폴백 SVG(파이프라인 유지, 오프라인 안전).
**열화(degraded)로 표시** — 실물 렌더가 아님을 배너+기계마커로 명시한다(성공 위장 금지)."""
return CE.degraded_svg("d2", code=code, title="D2 다이어그램")
def render_d2(ex, workdir):
"""D2 코드를 d2 CLI로 SVG 렌더. 소프트웨어 아키텍처·의존성·중첩 컨테이너 —
레이아웃엔진(dagre/elk)·테마·컨테이너로 Mermaid보다 실무급. 실패/미가용 시 코드 폴백.
exhibit 스키마: {type: d2, code, layout?: dagre|elk, theme?: int, sketch?: bool, pad?: int}."""
code = ex.get("code", "") if isinstance(ex, dict) else str(ex)
if not str(code).strip():
return None
_mkdir(workdir)
if os.environ.get("RENDER_CONSULT_NO_D2"):
return _d2_fallback_svg(code)
d2 = _d2_bin()
if not d2:
return _d2_fallback_svg(code)
h = hashlib.md5(str(code).encode("utf-8")).hexdigest()[:8] # 결정적 임시명(재현 안전)
src = os.path.join(workdir, f"_d2-{h}.d2")
outp = os.path.join(workdir, f"_d2-{h}.svg")
with open(src, "w") as f:
f.write(str(code))
o = ex if isinstance(ex, dict) else {}
cmd = [d2, "--pad", str(o.get("pad", 16)), "--theme", str(o.get("theme", 0))]
if o.get("layout"):
cmd += ["--layout", str(o["layout"])]
if o.get("sketch"):
cmd += ["--sketch"]
cmd += [src, outp]
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
if r.returncode == 0 and os.path.exists(outp):
svg = open(outp).read()
for p in (src, outp):
try:
os.remove(p)
except OSError:
pass
return svg
sys.stderr.write(f"[d2] failed rc={r.returncode}: {r.stderr[-200:]}\n")
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
sys.stderr.write(f"[d2] skipped: {e}\n")
return _d2_fallback_svg(code)
# ------------------------------------------------------------ exhibit handling
def collect_exhibits(slides, narrative, imgdir):
"""slide/narrative의 exhibit을 SVG로 렌더해 파일로 쓰고 (map, degraded[]) 반환.
엔진 우선순위: type=d2(실무급 diagram-as-code, 1급) → type=mermaid(폴백) → consult_exhibits 7 아키타입(정량·개념).
degraded[] = 실물 렌더 실패로 폴백 SVG(코드 텍스트)로 대체된 exhibit 목록 — main()이 이를
성공으로 위장하지 않고 stderr·stdout·render.json으로 보고한다."""
_mkdir(imgdir)
out = {}
degraded = []
idx = 0
for kind, items in (("s", slides), ("n", narrative)):
for i, it in enumerate(items):
ex = it.get("exhibit") if isinstance(it, dict) else None
if not ex:
continue
t = ex.get("type") if isinstance(ex, dict) else None
if t == "d2":
svg = render_d2(ex, imgdir)
elif t == "mermaid":
svg = render_mermaid(ex.get("code", ""), imgdir)
else:
svg = CE.render_exhibit(ex)
if not svg:
continue
idx += 1
slug = f"ex-{kind}{i+1:02d}"
fp = os.path.join(imgdir, slug + ".svg")
with open(fp, "w") as f:
f.write(svg)
is_degraded = CE.DEGRADED_MARKER in svg
out[id(it)] = {"file": os.path.join("img", slug + ".svg"), "inline": svg,
"slug": slug, "degraded": is_degraded}
if is_degraded:
degraded.append({"slug": slug, "type": t or "archetype",
"file": os.path.join("img", slug + ".svg")})
return out, degraded
# ------------------------------------------------------------ document (.md)
def render_document(doc, storyline, exmap):
hdr = doc.get("report-header", {}) or {}
st = storyline or {}
title = st.get("title") or doc.get("title") or "컨설팅 보고서"
meta = []
if st.get("client"):
meta.append(f"대상: **{st['client']}**")
if st.get("date") or doc.get("created-at"):
meta.append(f"일자: {st.get('date') or doc.get('created-at')}")
if doc.get("synthesized-by"):
meta.append(f"작성: {doc['synthesized-by']}")
L = [f"# {title}", "", " · ".join(meta) if meta else "", ""]
# BLUF callout
bl = (hdr.get("bottom-line") or "").strip()
if bl:
L += ["> **BLUF (핵심 결론)**", ">", "> " + bl.replace("\n", "\n> "), ""]
dn = hdr.get("decision-needed") or {}
conf = hdr.get("confidence") or {}
info = []
if dn.get("needed"):
info.append(f"**결정 필요** · 승인자 `{dn.get('approver','?')}`")
if conf.get("value"):
info.append(f"신뢰도 **{conf['value']}**")
if info:
L += [" · ".join(info), ""]
# SCQA
scqa = st.get("scqa") or {}
if scqa:
L += ["## 배경 (SCQA)", ""]
for k, ko in (("situation", "상황"), ("complication", "문제"), ("question", "질문"), ("answer", "답(지배 메시지)")):
if scqa.get(k):
L.append(f"- **{ko}**: {scqa[k]}")
L.append("")
# storyline as story (horizontal logic) — action titles
slides = st.get("slides") or []
if slides:
L += ["## 핵심 논리 (액션타이틀만 읽어도 이야기가 된다)", ""]
for i, s in enumerate(slides, 1):
L.append(f"{i}. {s.get('action-title','')}")
L.append("")
# detailed sections: narrative first, else slides
sections = doc.get("narrative") or []
if sections:
L += ["## 상세", ""]
for sec in sections:
L.append(f"### {sec.get('heading','')}")
L.append("")
for para in (sec.get("body") or []):
L += [para, ""]
ex = exmap.get(id(sec))
if ex:
L += [f"![{sec.get('heading','exhibit')}]({ex['file']})", ""]
# slide detail (exhibit + body) — always show exhibits/bodies from slides
if slides:
L += ["## 근거 도해 · 슬라이드별", ""]
for i, s in enumerate(slides, 1):
L.append(f"### {i}. {s.get('action-title','')}")
L.append("")
ex = exmap.get(id(s))
if ex:
L += [f"![slide {i}]({ex['file']})", ""]
for b in (s.get("body") or []):
L.append(f"- {b}")
if s.get("evidence"):
L.append(f"- _근거: {', '.join(str(e) for e in s['evidence'])}_")
L.append("")
# recommendation / go-no-go
if doc.get("recommendation"):
L += ["## 권고", "", str(doc["recommendation"]).strip(), ""]
if doc.get("go-no-go"):
L += [f"**Go/No-Go**: {doc['go-no-go']}", ""]
# conflicts / dissent (preserve)
conflicts = doc.get("conflicts") or doc.get("dissent") or []
if conflicts:
L += ["## 보존된 이견 (dissent)", ""]
for c in conflicts:
L.append(f"- {c}")
L.append("")
# risks
risks = hdr.get("risks") or []
if risks:
L += ["## 리스크", ""]
for r in risks:
L.append(f"- {r}")
L.append("")
# evidence table
ev = hdr.get("evidence") or []
if ev:
L += ["## 근거 (evidence)", "", "| # | source-uri | grade |", "|---|---|---|"]
for i, e in enumerate(ev, 1):
if isinstance(e, dict):
L.append(f"| {i} | `{e.get('source-uri','')}` | {e.get('grade','')} |")
L.append("")
linked = doc.get("linked-reports") or []
if linked:
L += ["## 분과 원본 보고서 (linked)", ""]
for lp in linked:
L.append(f"- `{lp}`")
L.append("")
return "\n".join(x for x in L if x is not None) + "\n"
# ------------------------------------------------------------ Marp deck (.md)
MARP_STYLE = """<style>
:root { --navy:#1f3a5f; --accent:#e07b39; --ink:#1b2430; --mute:#5b6472; }
section { font-family:'Segoe UI',Helvetica,Arial,sans-serif; color:var(--ink); padding:46px 58px; font-size:22px; }
section h1 { color:var(--navy); font-size:40px; line-height:1.2; }
section h2 { color:var(--navy); font-size:26px; line-height:1.3; border-bottom:2px solid var(--navy); padding-bottom:10px; margin:0 0 18px 0; font-weight:700; }
section.lead { justify-content:center; text-align:left; }
section.lead h1 { border:none; }
section .sub { color:var(--mute); font-size:20px; }
section img { display:block; margin:6px auto; max-height:74%; }
section ul { margin-top:8px; } section li { margin:5px 0; line-height:1.35; }
section .tag { color:var(--accent); font-weight:700; letter-spacing:.5px; font-size:15px; }
section footer { color:var(--mute); font-size:13px; }
strong { color:var(--navy); }
</style>"""
def render_deck_md(doc, storyline, exmap, theme_footer=""):
st = storyline or {}
hdr = doc.get("report-header", {}) or {}
title = st.get("title") or doc.get("title") or "컨설팅 보고서"
L = ["---", "marp: true", "paginate: true", "size: 16:9", f'footer: "{theme_footer}"', "---", "", MARP_STYLE, ""]
# title slide
L += ["<!-- _class: lead -->", "<!-- _paginate: false -->",
f'<span class="tag">CONSULTING DELIVERABLE</span>', "", f"# {title}", ""]
subs = []
if st.get("client"):
subs.append(f"대상: **{st['client']}**")
if st.get("date"):
subs.append(str(st["date"]))
if doc.get("synthesized-by"):
subs.append(str(doc["synthesized-by"]))
if subs:
L.append(f'<span class="sub">{" · ".join(subs)}</span>')
L += ["", "---", ""]
# BLUF slide
bl = (hdr.get("bottom-line") or "").strip()
if bl:
L += ["<!-- _class: lead -->", '<span class="tag">BOTTOM LINE UP FRONT</span>', "", f"## 결론", "", bl, ""]
scqa = st.get("scqa") or {}
if scqa.get("answer"):
L += ["", f"**지배 메시지 —** {scqa['answer']}"]
L += ["", "---", ""]
# content slides
for i, s in enumerate(st.get("slides") or [], 1):
L.append(f"## {s.get('action-title','')}")
L.append("")
ex = exmap.get(id(s))
if ex:
L += [f"![w:860]({ex['file']})", ""]
for b in (s.get("body") or []):
L.append(f"- {b}")
if s.get("body"):
L.append("")
L += ["---", ""]
# closing / recommendation
rec = doc.get("recommendation")
dn = hdr.get("decision-needed") or {}
L += ["<!-- _class: lead -->", '<span class="tag">RECOMMENDATION</span>', "", "## 권고 및 결정 요청", ""]
if rec:
L += [str(rec).strip(), ""]
if doc.get("go-no-go"):
L += [f"**Go/No-Go —** {doc['go-no-go']}", ""]
if dn.get("needed"):
L += [f'<span class="sub">결정 필요 · 승인자 <strong>{dn.get("approver","?")}</strong></span>', ""]
return "\n".join(L) + "\n"
# ------------------------------------------------------------ HTML deck (offline)
HTML_TMPL = """<!doctype html><html lang="ko"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{title}</title>
<style>
:root{{--navy:#1f3a5f;--accent:#e07b39;--ink:#1b2430;--mute:#5b6472}}
*{{box-sizing:border-box}}
html,body{{margin:0;height:100%;background:#0e1622;font-family:'Segoe UI',Helvetica,Arial,sans-serif;color:var(--ink)}}
#deck{{height:100vh;display:flex;align-items:center;justify-content:center}}
.slide{{display:none;width:min(1120px,94vw);aspect-ratio:16/9;background:#fff;border-radius:8px;
box-shadow:0 12px 40px rgba(0,0,0,.5);padding:44px 56px;overflow:hidden;position:relative;flex-direction:column}}
.slide.active{{display:flex}}
.slide.lead{{justify-content:center}}
h1{{color:var(--navy);font-size:38px;margin:.1em 0}}
h2{{color:var(--navy);font-size:26px;border-bottom:2px solid var(--navy);padding-bottom:9px;margin:0 0 14px}}
.tag{{color:var(--accent);font-weight:700;letter-spacing:.6px;font-size:14px}}
.sub{{color:var(--mute);font-size:19px}}
.body{{overflow:auto}}
.slide img,.slide svg{{display:block;margin:4px auto;max-width:100%;max-height:66vh;height:auto}}
ul{{margin:8px 0 0}} li{{margin:5px 0;line-height:1.35;font-size:20px}}
strong{{color:var(--navy)}}
#nav{{position:fixed;bottom:14px;right:20px;color:#9fb0c2;font-size:14px;user-select:none}}
#bar{{position:fixed;top:0;left:0;height:4px;background:var(--accent);transition:width .2s}}
.credit{{position:absolute;bottom:16px;left:56px;color:var(--mute);font-size:12px}}
</style></head><body>
<div id="bar"></div><div id="deck">{slides}</div>
<div id="nav"><span id="cur">1</span> / {n} ←/→</div>
<script>
var S=[].slice.call(document.querySelectorAll('.slide')),i=0;
function show(n){{i=Math.max(0,Math.min(S.length-1,n));S.forEach((s,k)=>s.classList.toggle('active',k===i));
document.getElementById('cur').textContent=i+1;document.getElementById('bar').style.width=((i+1)/S.length*100)+'%';}}
document.addEventListener('keydown',e=>{{if(e.key==='ArrowRight'||e.key===' ')show(i+1);
if(e.key==='ArrowLeft')show(i-1);if(e.key==='Home')show(0);if(e.key==='End')show(S.length-1);}});
document.getElementById('deck').addEventListener('click',e=>{{var r=innerWidth/2;show(e.clientX<r?i-1:i+1);}});
window.addEventListener('hashchange',()=>show((parseInt(location.hash.slice(1))||1)-1));
show((parseInt(location.hash.slice(1))||1)-1);
</script></body></html>"""
def render_deck_html(doc, storyline, exmap):
st = storyline or {}
hdr = doc.get("report-header", {}) or {}
title = st.get("title") or doc.get("title") or "컨설팅 보고서"
slides_html = []
def sec(inner, cls=""):
slides_html.append(f'<section class="slide {cls}">{inner}<div class="credit">Org OS · Consulting (LENS-ADVISORY)</div></section>')
subs = " · ".join([str(x) for x in [st.get("client") and f"대상: {st['client']}", st.get("date"),
doc.get("synthesized-by")] if x])
sec(f'<span class="tag">CONSULTING DELIVERABLE</span><h1>{CE._esc(title)}</h1><div class="sub">{CE._esc(subs)}</div>', "lead")
bl = (hdr.get("bottom-line") or "").strip()
if bl:
ans = (st.get("scqa") or {}).get("answer")
extra = f'<p><strong>지배 메시지 —</strong> {CE._esc(ans)}</p>' if ans else ""
sec(f'<span class="tag">BOTTOM LINE UP FRONT</span><h2>결론</h2><div class="body"><p style="font-size:22px">{CE._esc(bl)}</p>{extra}</div>', "lead")
for i, s in enumerate(st.get("slides") or [], 1):
inner = [f'<h2>{CE._esc(s.get("action-title",""))}</h2><div class="body">']
ex = exmap.get(id(s))
if ex:
inner.append(ex["inline"])
if s.get("body"):
inner.append("<ul>" + "".join(f"<li>{CE._esc(b)}</li>" for b in s["body"]) + "</ul>")
inner.append("</div>")
sec("".join(inner))
rec = doc.get("recommendation")
dn = hdr.get("decision-needed") or {}
inner = ['<span class="tag">RECOMMENDATION</span><h2>권고 및 결정 요청</h2><div class="body">']
if rec:
inner.append(f'<p style="font-size:21px">{CE._esc(str(rec).strip())}</p>')
if doc.get("go-no-go"):
inner.append(f'<p><strong>Go/No-Go —</strong> {CE._esc(doc["go-no-go"])}</p>')
if dn.get("needed"):
inner.append(f'<div class="sub">결정 필요 · 승인자 <strong>{CE._esc(dn.get("approver","?"))}</strong></div>')
inner.append("</div>")
sec("".join(inner), "lead")
return HTML_TMPL.format(title=CE._esc(title), slides="".join(slides_html), n=len(slides_html))
# ------------------------------------------------------------ marp export
def try_marp(deck_md_path, outdir, stem):
"""best-effort: marp-cli로 pptx/pdf. 성공한 산출물 경로 리스트 반환.
절대경로로 정규화한다 — cwd=덱 디렉터리라 상대 outdir가 이중 적용되면 marp가 파일을 못 찾는다."""
produced = []
env = dict(os.environ)
chrome = "/usr/bin/google-chrome"
if os.path.exists(chrome):
env.setdefault("CHROME_PATH", chrome)
deck_abs = os.path.abspath(deck_md_path)
workdir = os.path.dirname(deck_abs) # 이미지 상대경로(img/…)는 덱 위치 기준으로 해석
out_abs = os.path.abspath(outdir)
base = ["npx", "--yes", "@marp-team/marp-cli@latest", os.path.basename(deck_abs), "--allow-local-files"]
# --html은 제외: render_deck_html이 만든 self-contained <stem>-deck.html(오프라인 보장)을 덮어쓰지 않게.
targets = [("--pptx", stem + ".pptx"), ("--pdf", stem + ".pdf")]
for flag, outname in targets:
outpath = os.path.join(out_abs, outname)
try:
r = subprocess.run(base + [flag, "-o", outpath], env=env, cwd=workdir,
capture_output=True, text=True, timeout=240)
if r.returncode == 0 and os.path.exists(outpath):
produced.append(outpath)
else:
sys.stderr.write(f"[marp] {flag} failed rc={r.returncode}: {r.stderr[-300:]}\n")
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
sys.stderr.write(f"[marp] {flag} skipped: {e}\n")
return produced
# ------------------------------------------------------------ main
def main():
ap = argparse.ArgumentParser()
ap.add_argument("report")
ap.add_argument("--outdir", default=None)
ap.add_argument("--name", default=None)
ap.add_argument("--marp", action="store_true", help="marp-cli로 pptx/pdf export 시도(네트워크·chrome 필요)")
ap.add_argument("--strict", action="store_true",
help="degraded(폴백 SVG 대체)면 비영점 종료 — 발표/게시 게이트용(#16). "
"기본은 exit 0 유지(마커·meta·stderr로 degraded 표시).")
args = ap.parse_args()
with open(args.report) as f:
doc = yaml.safe_load(f)
if not isinstance(doc, dict):
sys.exit("report YAML 파싱 실패 또는 매핑 아님")
storyline = doc.get("storyline") or {}
outdir = args.outdir or os.path.join(os.path.dirname(os.path.abspath(args.report)), "deliverables")
_mkdir(outdir)
stem = args.name or slugify(storyline.get("title") or doc.get("title") or
os.path.splitext(os.path.basename(args.report))[0])
slides = storyline.get("slides") or []
narrative = doc.get("narrative") or []
exmap, degraded = collect_exhibits(slides, narrative, os.path.join(outdir, "img"))
doc_md = render_document(doc, storyline, exmap)
deck_md = render_deck_md(doc, storyline, exmap, theme_footer="Org OS · Consulting (LENS-ADVISORY)")
deck_html = render_deck_html(doc, storyline, exmap)
paths = {
"report": os.path.join(outdir, stem + "-report.md"),
"deck-md": os.path.join(outdir, stem + "-deck.md"),
"deck-html": os.path.join(outdir, stem + "-deck.html"),
}
with open(paths["report"], "w") as f:
f.write(doc_md)
with open(paths["deck-md"], "w") as f:
f.write(deck_md)
with open(paths["deck-html"], "w") as f:
f.write(deck_html)
produced = []
if args.marp:
produced = try_marp(paths["deck-md"], outdir, stem + "-deck")
# 렌더 상태(degraded 여부)를 기계 감지 가능한 메타로 기록 — 열화를 성공으로 위장하지 않는다.
status = "degraded" if degraded else "ok"
render_meta = {
"status": status,
"degraded": bool(degraded),
"exhibits": len(exmap),
"degraded_exhibits": degraded,
"outputs": paths,
"marp-exports": produced,
}
meta_path = os.path.join(outdir, stem + "-render.json")
with open(meta_path, "w") as f:
json.dump(render_meta, f, ensure_ascii=False, indent=2)
if degraded:
# stderr 경고(사람+CI) — 실물 렌더 실패로 폴백 SVG 대체됨을 명시.
sys.stderr.write(
"[render_consult] DEGRADED: %d exhibit(s) fell back to code-text SVG "
"(d2/mmdc/exhibit 렌더 미가용): %s\n"
% (len(degraded), ", ".join("%s(%s)" % (d["slug"], d["type"]) for d in degraded))
)
print("OK render_consult:")
print(f" 문서(document): {paths['report']}")
print(f" 덱(Marp source): {paths['deck-md']}")
print(f" 덱(offline HTML): {paths['deck-html']} ← 브라우저에서 바로 발표")
print(f" exhibits: {len(exmap)} SVG in {os.path.join(outdir,'img')}")
print(f" render-meta: {meta_path}")
for p in produced:
print(f" 덱(marp export): {p}")
if args.marp and not produced:
print(" (marp export 실패/미가용 — HTML 덱으로 발표하세요)")
# stdout 기계 감지 마커(마지막 줄) — degraded면 발표 전 재렌더 필요.
if degraded:
print("RENDER_STATUS: DEGRADED (%d exhibit fallback — NOT publication-grade)" % len(degraded))
else:
print("RENDER_STATUS: OK")
# #16: --strict면 degraded를 하드 게이트(비영점). 기본은 exit 0 유지(파이프라인 계약 보존).
if degraded and args.strict:
sys.stderr.write(
"[render_consult] --strict: degraded 산출은 발표/게시 등급이 아니다 — "
"d2/mmdc 설치 후 재렌더하라(비영점 종료).\n")
sys.exit(2)
if __name__ == "__main__":
main()