init: company-haness 설계
This commit is contained in:
@@ -0,0 +1,572 @@
|
||||
#!/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"", ""]
|
||||
# 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"", ""]
|
||||
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"", ""]
|
||||
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()
|
||||
Reference in New Issue
Block a user