347 lines
14 KiB
Python
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()
|