#!/usr/bin/env python3 """Consulting deliverable renderer — storyline(.report.yaml) → 문서 + PPT. 한 소스(EM synthesis 보고서의 storyline/narrative)에서 두 산출물을 drift 없이 생성: ① 문서: -report.md (장문 — BLUF·SCQA·권고·근거·이견, exhibit embed) ② 덱 : -deck.md (Marp) + -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 [--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 = """""" 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 += ["", "", f'CONSULTING DELIVERABLE', "", 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'{" · ".join(subs)}') L += ["", "---", ""] # BLUF slide bl = (hdr.get("bottom-line") or "").strip() if bl: L += ["", 'BOTTOM LINE UP FRONT', "", 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 += ["", 'RECOMMENDATION', "", "## 권고 및 결정 요청", ""] 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'결정 필요 · 승인자 {dn.get("approver","?")}', ""] return "\n".join(L) + "\n" # ------------------------------------------------------------ HTML deck (offline) HTML_TMPL = """ {title}
{slides}
""" 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'
{inner}
Org OS · Consulting (LENS-ADVISORY)
') 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'CONSULTING DELIVERABLE

{CE._esc(title)}

{CE._esc(subs)}
', "lead") bl = (hdr.get("bottom-line") or "").strip() if bl: ans = (st.get("scqa") or {}).get("answer") extra = f'

지배 메시지 — {CE._esc(ans)}

' if ans else "" sec(f'BOTTOM LINE UP FRONT

결론

{CE._esc(bl)}

{extra}
', "lead") for i, s in enumerate(st.get("slides") or [], 1): inner = [f'

{CE._esc(s.get("action-title",""))}

'] ex = exmap.get(id(s)) if ex: inner.append(ex["inline"]) if s.get("body"): inner.append("
    " + "".join(f"
  • {CE._esc(b)}
  • " for b in s["body"]) + "
") inner.append("
") sec("".join(inner)) rec = doc.get("recommendation") dn = hdr.get("decision-needed") or {} inner = ['RECOMMENDATION

권고 및 결정 요청

'] if rec: inner.append(f'

{CE._esc(str(rec).strip())}

') if doc.get("go-no-go"): inner.append(f'

Go/No-Go — {CE._esc(doc["go-no-go"])}

') if dn.get("needed"): inner.append(f'
결정 필요 · 승인자 {CE._esc(dn.get("approver","?"))}
') inner.append("
") 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 -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()