Files
company-haness/.claude/hooks/render_report.py
T

347 lines
14 KiB
Python

#!/usr/bin/env python3
"""Render human-readable MD from agent .report.yaml (YAML = SoT, MD = 대표용).
에이전트는 .report.yaml만 쓴다(hook이 검증). 이 렌더러가 그 YAML을 대표(사용자)가
읽기 좋은 MD로 결정적으로 변환한다 — 손으로 쓰지 않으므로 drift가 없다.
fan-out family의 경우, 멤버별 .report.yaml을 --members로 넘기면 "역할별 핵심 결론" 표로
집계한다. YAML은 에이전트끼리 보는 원천, MD는 대표가 보는 뷰다.
Usage:
render_report.py <report.yaml> [--title T] [--type TYPE] [--members a.yaml b.yaml ...] [--out out.md]
render_report.py --index # reports/INDEX.md 재생성
<TYPE> in: decision | work | completion | review | blocked | design | spec
(배지 emoji·label은 report-templates.yaml human-md-rendering.render-badges에서 로드 — #13)
"""
import glob
import os
import sys
from datetime import datetime, timezone
import yaml
ROOT = os.environ.get("CLAUDE_PROJECT_DIR") or os.path.dirname(
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
)
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import _workspace as W # noqa: E402
WORK = W.work_root()
REPORTS_DIR = W.reports_dir()
# finding #13: 배지(type→emoji·label)는 SSOT(report-templates.yaml human-md-rendering.render-badges)에서
# 로드한다 — 예전엔 여기에 하드코딩되어 YAML을 고쳐도 렌더가 안 바뀌었다(dead SSOT).
# YAML 부재/파싱 실패/필드 누락 시 이 내장 기본값으로 폴백(렌더가 하드페일하지 않도록).
_TYPE_BADGE_FALLBACK = {
"decision": ("🟢", "결정"), "work": ("📝", "작업"), "completion": ("✅", "완료"),
"review": ("🔍", "리뷰"), "blocked": ("🚨", "블로커"), "design": ("📐", "설계"),
"spec": ("📋", "명세"),
}
_TEMPLATES_YAML = os.path.join(ROOT, "org-os", "06-agent-work", "report-templates.yaml")
def _load_type_badges():
"""report-templates.yaml 의 render-badges 를 소비. 실패해도 폴백으로 계속 렌더."""
badges = dict(_TYPE_BADGE_FALLBACK)
try:
with open(_TEMPLATES_YAML, encoding="utf-8") as f:
doc = yaml.safe_load(f) or {}
rb = (((doc.get("report-templates") or {}).get("human-md-rendering") or {})
.get("render-badges") or {})
for k, v in rb.items():
if isinstance(v, (list, tuple)) and len(v) >= 2:
badges[str(k)] = (str(v[0]), str(v[1]))
except (OSError, yaml.YAMLError):
pass
return badges
TYPE_BADGE = _load_type_badges()
def load(path):
with open(path) as f:
return yaml.safe_load(f) or {}
def rh_of(doc):
return doc.get("report-header", {}) if isinstance(doc, dict) else {}
def max_grade(rh):
grades = [str(e.get("grade", "")) for e in (rh.get("evidence") or []) if isinstance(e, dict)]
grades = [g for g in grades if g.startswith("E")]
return max(grades) if grades else None
def fmt_confidence(rh):
c = rh.get("confidence") or {}
val = c.get("value", "?") if isinstance(c, dict) else str(c)
g = max_grade(rh)
return f"{val} ({g} 근거)" if g else str(val)
def fmt_decision(rh):
dn = rh.get("decision-needed") or {}
if isinstance(dn, dict) and dn.get("needed"):
return f"✅ 예 · 승인자 `{dn.get('approver', '?')}`"
return "— 아니오"
def role_identity(doc, path=None):
"""멤버 보고서에서 역할명/관점/핵심결론을 최대한 뽑아낸다(스키마 유연)."""
stem = os.path.basename(path).replace(".report.yaml", "") if path else "(역할)"
role = doc.get("role-name") or doc.get("role-id") or doc.get("role") \
or doc.get("role-agent") or doc.get("completed-by") or stem
persp = doc.get("role-perspective") or doc.get("perspective") or doc.get("lens") or ""
rh = rh_of(doc)
bl = rh.get("bottom-line") or doc.get("work-summary") or ""
conf = (rh.get("confidence") or {}).get("value", "") if isinstance(rh.get("confidence"), dict) else ""
return str(role), str(persp), str(bl).strip(), str(conf)
def esc(s):
return str(s).replace("|", "\\|").replace("\n", " ").strip()
# 리포트를 자기완결적으로 만들기 위한 본문 섹션 렌더 (findings/설계/다음액션 등을 그대로 embed)
META_KEYS = {
"role-id", "role-name", "lens", "perspective", "workflow-id", "task-id",
"decision-id", "completion-id", "report-header", "title",
"synthesized-by", "synthesised-by", "linked-reports",
"decision-question", "recommendation", "consensus", "conflicts", "dissent",
}
SECTION_TITLES = {
"findings": "🔎 핵심 발견", "research-design": "🧪 리서치 설계",
"metrics-to-instrument": "📐 계측할 지표", "analysis-plan": "📊 분석 계획",
"next-actions": "➡️ 다음 액션", "ideas": "💡 아이디어",
"monetization-angle": "💰 수익화 관점", "recommended-instrumentation": "📐 계측 권고",
"assumptions": "🧩 가정", "handoff": "🤝 핸드오프",
"output-artifacts": "📦 산출물", "verification-performed": "✅ 검증",
"work-summary": "📝 작업 요약", "remaining-risks": "⚠️ 남은 리스크",
}
def humanize(key):
return SECTION_TITLES.get(key, "· " + key.replace("-", " "))
def render_value(v):
out = []
if isinstance(v, list):
for item in v:
if isinstance(item, dict):
out.append("- " + " · ".join(f"{k}: {vv}" for k, vv in item.items()))
else:
out.append(f"- {str(item).strip()}")
elif isinstance(v, dict):
for k, vv in v.items():
out.append(f"- **{k}**: {vv}")
else:
out.append(str(v).strip())
return out
def render_body(doc, heading="##", skip=()):
"""report-header/meta를 뺀 자유 본문 필드(findings 등)를 섹션으로 렌더."""
skip = set(skip) | META_KEYS
out = []
for k, v in doc.items():
if k in skip or v in (None, [], "", {}):
continue
out.append(f"{heading} {humanize(k)}")
out += render_value(v)
out.append("")
return out
def render(report_path, title=None, rtype=None, members=None):
doc = load(report_path)
rh = rh_of(doc)
emoji, label = TYPE_BADGE.get(rtype or "", ("📄", (rtype or "보고서")))
title = title or doc.get("title") or os.path.basename(report_path).replace(".report.yaml", "")
wid = doc.get("workflow-id") or doc.get("task-id") or doc.get("decision-id") or doc.get("completion-id") or "-"
ts = datetime.now().strftime("%Y-%m-%d %H:%M")
ts = doc.get("created-at") or ts
L = [f"# {emoji} [{label}] {title}", ""]
L.append(f"> **결론** — {rh.get('bottom-line', '(결론 미기재)')}")
L.append(f"> **결정 필요** — {fmt_decision(rh)}")
L.append(f"> **확신도** — {fmt_confidence(rh)}")
L.append("")
L.append(f"`repo: {os.path.basename(ROOT)}` · `{ts}` · `{wid}`")
L.append("")
# optional 자유 필드 — 있으면 렌더, 없으면 생략(날조 금지)
if doc.get("decision-question"):
L += ["## 🎯 결정해야 할 질문", str(doc["decision-question"]).strip(), ""]
if doc.get("recommendation"):
L += ["## ✅ 권고안", str(doc["recommendation"]).strip(), ""]
# 역할별 핵심 결론 (fan-out 집계) — 요약 표 + 관점 원문 embed(찾아다닐 필요 없게)
if members:
mdocs = [(m, load(m)) for m in members]
L += ["## 👥 역할별 핵심 결론 (요약)", "", "| 역할 | 관점 | 핵심 결론 | 확신도 |", "|---|---|---|---|"]
for m, md in mdocs:
role, persp, bl, conf = role_identity(md, m)
L.append(f"| {esc(role)} | {esc(persp)} | {esc(bl)} | {esc(conf)} |")
L += ["", "## 📋 역할별 상세 (관점 원문 그대로)", ""]
for m, md in mdocs:
role, persp, bl, conf = role_identity(md, m)
L.append(f"### {role}" + (f" — 확신도 {conf}" if conf else ""))
if persp:
L.append(f"*관점:* {persp}")
if bl:
L += ["", f"> **결론:** {bl}"]
L.append("")
L += render_body(md, heading="####")
mev = rh_of(md).get("evidence") or []
if mev:
srcs = ", ".join(
f"`{(e.get('source-uri') or e.get('command'))}` ({e.get('grade', '-')})"
for e in mev if isinstance(e, dict))
L += [f"*근거:* {srcs}", ""]
else:
# 단일 보고서: 자체 본문 필드(findings 등)를 직접 embed
L += render_body(doc, heading="##")
# 합의 / 충돌 (dissent 보존)
consensus = doc.get("consensus") or []
conflicts = doc.get("conflicts") or doc.get("dissent") or []
if consensus or conflicts:
L.append("## ⚖️ 합의 / 충돌")
if consensus:
L += ["", "**합의**"] + [f"- {c}" for c in consensus]
if conflicts:
L += ["", "**충돌(보존)**"] + [f"- {c}" for c in conflicts]
L.append("")
# 리스크
risks = rh.get("risks") or []
if risks:
L += ["## ⚠️ 리스크"] + [f"- {r}" for r in risks] + [""]
# 근거
ev = rh.get("evidence") or []
if ev:
L += ["## 📎 근거", "", "| # | 출처 | 등급 |", "|---|---|---|"]
for i, e in enumerate(ev, 1):
if isinstance(e, dict):
src = e.get("source-uri") or e.get("command") or "-"
L.append(f"| {i} | {esc(src)} | {e.get('grade', '-')} |")
L.append("")
# 원본 파일 링크(에이전트용 YAML — 위 본문에 이미 상세가 embed됨, 이건 추적용)
L += ["## 📂 원본 파일 (에이전트용 YAML)", "", f"- 종합/원천: `{os.path.relpath(report_path, ROOT)}`"]
for m in (members or []):
L.append(f"- 역할 보고서: `{os.path.relpath(m, ROOT)}`")
L.append("")
return "\n".join(L).rstrip() + "\n"
def write_md(report_path, md, out=None):
out = out or report_path.replace(".report.yaml", ".md")
if not out.endswith(".md"):
out = os.path.splitext(out)[0] + ".md"
with open(out, "w") as f:
f.write(md)
return out
def _created_at(doc, path):
ca = doc.get("created-at") if isinstance(doc, dict) else None
if ca:
return str(ca)
return datetime.fromtimestamp(os.path.getmtime(path), timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def build_index():
"""워크플로별 append-only 뷰. 보고서는 불변이라 매 실행이 새 버전으로 쌓인다."""
os.makedirs(REPORTS_DIR, exist_ok=True)
groups = {}
for p in sorted(glob.glob(os.path.join(WORK, "**", "*.report.yaml"), recursive=True)):
try:
doc = load(p)
except Exception:
continue
rh = rh_of(doc)
parent = os.path.basename(os.path.dirname(p))
wid = (doc.get("workflow-id") if isinstance(doc, dict) else None) \
or (parent if parent != "completion-records" else "(레거시-flat)")
bl = esc(rh.get("bottom-line", "-"))[:80]
dn = "✅" if (isinstance(rh.get("decision-needed"), dict) and rh["decision-needed"].get("needed")) else "—"
md = p.replace(".report.yaml", ".md")
link = f"`{os.path.relpath(md, ROOT)}`" if os.path.exists(md) else "(미렌더)"
groups.setdefault(str(wid), []).append((_created_at(doc, p), os.path.relpath(p, ROOT), bl, dn, link))
ts = datetime.now().strftime("%Y-%m-%d %H:%M")
total = sum(len(v) for v in groups.values())
body = ["# 📇 보고서 목차 (대표용)", "",
f"생성: {ts} · 총 {total}건 · 워크플로 {len(groups)}개",
"> 보고서는 **불변**이다 — 매 실행은 새 버전 파일로 쌓인다(덮어쓰기 없음). 아래는 워크플로별 append-only 뷰(최신순).", ""]
for wid in sorted(groups):
rows = sorted(groups[wid], key=lambda r: r[0], reverse=True)
body += [f"## {wid}", "", "| created-at | 보고서(YAML) | 결론 | 결정필요 | MD |", "|---|---|---|---|---|"]
body += [f"| {ca} | `{p}` | {bl} | {dn} | {link} |" for ca, p, bl, dn, link in rows]
body.append("")
idx = os.path.join(REPORTS_DIR, "INDEX.md")
with open(idx, "w") as f:
f.write("\n".join(body))
return idx, total
def main():
args = sys.argv[1:]
if not args:
sys.stderr.write(__doc__)
sys.exit(1)
if args[0] == "--index":
idx, n = build_index()
print(f"[render_report] INDEX -> {idx} ({n} reports)")
return
report = None
title = rtype = out = None
members = []
i = 0
while i < len(args):
a = args[i]
if a == "--title":
title = args[i + 1]; i += 2
elif a == "--type":
rtype = args[i + 1]; i += 2
elif a == "--out":
out = args[i + 1]; i += 2
elif a == "--members":
i += 1
while i < len(args) and not args[i].startswith("--"):
members.append(args[i]); i += 1
else:
report = a; i += 1
if not report:
sys.stderr.write("error: report.yaml 경로가 필요합니다\n")
sys.exit(1)
# 렌더 게이트: 검증 실패 보고서는 대표용 MD로 렌더하지 않는다(불량 산출 확산 차단).
# evidence-ledger(C6)까지 대조하도록 report_path를 넘긴다.
# 탈출구(도구 연쇄용): RENDER_REPORT_SKIP_VALIDATE=1 이면 경고만 하고 진행.
try:
import validate_report as _vr # noqa: E402
_verrs = _vr.validate(load(report), report_path=report)
except Exception as _e: # 검증기 자체 오류는 렌더를 막지 않는다
sys.stderr.write(f"[render_report] validate 건너뜀(검증기 오류): {_e}\n")
_verrs = []
if _verrs:
_skip = os.environ.get("RENDER_REPORT_SKIP_VALIDATE", "").lower() in ("1", "true", "yes")
_hdr = "[render_report] " + ("경고(SKIP_VALIDATE) — " if _skip else "REFUSE 렌더 — ") \
+ f"{os.path.relpath(report, ROOT)} 검증 실패:\n" \
+ "\n".join(f" - {e}" for e in _verrs) + "\n"
sys.stderr.write(_hdr)
if not _skip:
sys.exit(2)
md = render(report, title=title, rtype=rtype, members=members)
path = write_md(report, md, out)
print(f"[render_report] {os.path.relpath(report, ROOT)} -> {os.path.relpath(path, ROOT)}")
if __name__ == "__main__":
main()