#!/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 [--title T] [--type TYPE] [--members a.yaml b.yaml ...] [--out out.md] render_report.py --index # reports/INDEX.md 재생성 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()