Files

334 lines
14 KiB
Python

#!/usr/bin/env python3
"""kpi_ledger.py — agent-operating KPI 수집기 (finding #19).
리뷰 지적: agent-operating-kpi.yaml 에 rework/hallucination/context-bloat/release-failure 등이
정의돼 있으나 **수집기가 없어 측정되지 않는다**. 이 도구가 그 구멍을 닫는다 —
① 기존 append-only 아티팩트(completion-records 시도수·acceptance-events 결정·token-ledger)에서
**파생 가능한 KPI를 실제로 계산**하고, ② 파생 불가한 것은 수동 이벤트로 적재하며,
③ 대시보드에서 각 KPI를 measured/derived · manual · **unmeasured(정직 표시)** 로 구분한다.
→ "측정 안 됨"을 "측정됨"처럼 위장하지 않는다.
Usage:
kpi_ledger.py derive [--workflow WF] # 아티팩트에서 파생 KPI 계산 → kpi-ledger.jsonl 적재
kpi_ledger.py log --metric M --value V [--workflow WF --role R --note "..."] # 수동 이벤트
kpi_ledger.py dashboard # reports/KPI.md 렌더(measured/manual/unmeasured)
"""
import glob
import json
import os
import sys
from datetime import datetime, timezone
import yaml
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.environ.get("CLAUDE_PROJECT_DIR") or os.path.dirname(os.path.dirname(HERE))
sys.path.insert(0, HERE)
import _workspace as W # noqa: E402
import acceptance_log as AL # noqa: E402
import state_engine as SE # noqa: E402
KPI_SPEC = os.path.join(ROOT, "org-os", "06-agent-work", "agent-operating-kpi.yaml")
def _ledger():
return os.path.join(W.state_dir(), "kpi-ledger.jsonl")
def _now():
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def _append(rec):
lp = _ledger()
os.makedirs(os.path.dirname(lp), exist_ok=True)
with open(lp, "a", encoding="utf-8") as f:
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
def _rows():
lp = _ledger()
if not os.path.exists(lp):
return []
out = []
for line in open(lp, encoding="utf-8"):
line = line.strip()
if line:
try:
out.append(json.loads(line))
except json.JSONDecodeError:
pass
return out
# ---------------------------------------------------------------- derive
def _report_files():
return glob.glob(os.path.join(W.records_dir(), "**", "*.report.yaml"), recursive=True)
def _role_of(path):
"""report yaml에서 role-id(없으면 파일명 stem 앞부분) 추출."""
try:
doc = yaml.safe_load(open(path, encoding="utf-8")) or {}
rid = doc.get("role-id") or doc.get("role-name")
wf = doc.get("workflow-id")
except Exception:
rid, wf = None, None
stem = os.path.basename(path).replace(".report.yaml", "")
role = rid or stem.rsplit("-", 1)[0]
wf = wf or os.path.basename(os.path.dirname(path))
return str(wf), str(role)
def derive(workflow=None):
"""기존 아티팩트에서 파생 KPI를 계산해 적재. (measured=derived source)"""
artifact_events = [event for event in SE.read_artifact_events()
if event.get("event-type") == "artifact-submitted"
and (not workflow or event.get("workflow-id") == workflow)]
wf_set = {event.get("workflow-id") for event in artifact_events if event.get("workflow-id")}
total_reports = len(artifact_events)
event_ids = [event.get("artifact-event-id") for event in artifact_events]
artifact_keys = [(event.get("workflow-id"), event.get("artifact-id"), event.get("artifact-sha256"))
for event in artifact_events]
extra_attempts = ((len(event_ids) - len(set(event_ids)))
+ (len(artifact_keys) - len(set(artifact_keys))))
# acceptance 이벤트에서 결정 분포.
events = [e for e in AL.read_events()
if not workflow or e.get("workflow-id") == workflow]
dec = {"accepted": 0, "changes-requested": 0, "blocked": 0}
human_wf = set()
for e in events:
d = (e.get("decision") or "").strip().lower()
if d in dec:
dec[d] += 1
reviewer = e.get("reviewer") if isinstance(e.get("reviewer"), dict) else {}
appr = str(reviewer.get("actor-id") or reviewer.get("role-id") or e.get("role-id") or "")
if "HUMAN" in appr.upper():
human_wf.add(e.get("workflow-id"))
total_dec = sum(dec.values())
release_events = []
state_dir = W.state_dir()
for path in glob.glob(os.path.join(state_dir, "*", "workflow-events.jsonl")):
for line in open(path, encoding="utf-8"):
try:
event = json.loads(line)
except Exception:
continue
if event.get("event-type") != "release-decision-recorded":
continue
if workflow and event.get("workflow-id") != workflow:
continue
release_events.append(event)
failed_release = sum(1 for event in release_events
if event.get("status") != "Approved" or event.get("unresolved-critical-risks"))
# Context bloat is token-weighted, not item-count weighted. Planned context comes from
# exact packages bound in the subagent registry; actual reads come from usage-events.
planned = {}
registry_path = os.path.join(state_dir, "subagent-registry.jsonl")
if os.path.exists(registry_path):
for line in open(registry_path, encoding="utf-8"):
try:
row = json.loads(line)
except Exception:
continue
if workflow and row.get("workflow_id") != workflow:
continue
package_ref = row.get("context_package")
if not package_ref:
continue
package_path = package_ref if os.path.isabs(package_ref) else os.path.join(ROOT, package_ref)
try:
package = yaml.safe_load(open(package_path, encoding="utf-8")) or {}
except Exception:
continue
for item in package.get("must-read", []) or []:
if not isinstance(item, dict) or not item.get("context-id"):
continue
estimate = item.get("estimated-tokens")
if estimate is None:
uri = item.get("uri")
path = uri if os.path.isabs(str(uri or "")) else os.path.join(ROOT, str(uri or ""))
try:
estimate = max(1, os.path.getsize(path) // 4)
except OSError:
estimate = 0
planned[(package_ref, str(item["context-id"]))] = max(0, int(estimate or 0))
reads = {}
usage_path = os.path.join(state_dir, "usage-events.jsonl")
if os.path.exists(usage_path):
for line in open(usage_path, encoding="utf-8"):
try:
event = json.loads(line)
except Exception:
continue
if event.get("event-type") != "ContextItemRead":
continue
if workflow and event.get("workflow-id") != workflow:
continue
key = (event.get("context-package"), str(event.get("context-id")))
reads[key] = reads.get(key, 0) + 1
planned_tokens = sum(planned.values())
unused_tokens = sum(value for key, value in planned.items() if not reads.get(key))
duplicate_tokens = sum(planned.get(key, 0) * max(0, count - 1) for key, count in reads.items())
context_bloat = (min(planned_tokens, unused_tokens + duplicate_tokens) / planned_tokens
if planned_tokens else None)
metrics = {
# rework-rate = changes-requested / canonical submitted outputs
"rework-rate": (dec["changes-requested"] / total_reports) if total_reports else None,
# duplicate-report-rate = duplicate canonical event/revision keys / submissions
"duplicate-report-rate": (extra_attempts / total_reports) if total_reports else None,
# human-intervention-rate = 인간 개입 워크플로 / 총 워크플로
"human-intervention-rate": (len(human_wf) / len(wf_set)) if wf_set else None,
"release-gate-failure-rate": (failed_release / len(release_events)) if release_events else None,
"context-bloat-rate": context_bloat,
}
stamp = _now()
logged = 0
for m, v in metrics.items():
if v is None:
continue
_append({"at": stamp, "metric": m, "value": round(v, 4), "source": "derived",
"workflow": workflow or "*",
"basis": {"reports": total_reports, "decisions": total_dec,
"extra_attempts": extra_attempts, "workflows": len(wf_set),
"release_checks": len(release_events),
"planned_context_tokens": planned_tokens,
"unused_context_tokens": unused_tokens,
"duplicate_context_tokens": duplicate_tokens}})
logged += 1
# 파생 불가하지만 유용한 원자료도 함께 기록(counts).
_append({"at": stamp, "metric": "_counts", "value": total_reports, "source": "derived",
"workflow": workflow or "*",
"basis": {"reports": total_reports, "decisions": dec, "workflows": len(wf_set),
"extra_attempts": extra_attempts}})
print(f"[kpi_ledger] derived {logged} metric(s) from {total_reports} reports · "
f"{total_dec} acceptance decisions · {len(wf_set)} workflow(s)")
# ---------------------------------------------------------------- dashboard
# 파생 가능(코드가 아티팩트에서 계산) vs 수동만(사람/외부 계측 필요) vs 미측정(수집기 없음).
DERIVED_METRICS = {
"rework-rate", "duplicate-report-rate", "human-intervention-rate",
"release-gate-failure-rate", "context-bloat-rate",
}
MANUAL_METRICS = { # log 이벤트로만 채워질 수 있는 것(사람 판정/외부 계측)
"hallucination-rate", "blocker-reopen-rate", "slo-risk-escape-rate",
"shift-left-detection-rate", "skipped-role-incident-rate", "learning-capture-rate",
}
def _spec_metrics():
try:
return (yaml.safe_load(open(KPI_SPEC, encoding="utf-8")) or {})["agent-operating-kpi"]["metrics"]
except Exception:
return {}
def dashboard():
rows = _rows()
latest = {}
manual_counts = {}
for r in rows:
m = r.get("metric")
if m == "_counts":
continue
if r.get("source") == "manual":
manual_counts[m] = manual_counts.get(m, 0) + 1
latest[m] = r # 마지막 기록이 최신
spec = _spec_metrics()
ts = datetime.now().strftime("%Y-%m-%d %H:%M")
L = ["# 📈 Agent KPI 대시보드 (finding #19)", "",
f"생성: {ts} · 원장: `state/kpi-ledger.jsonl`",
"> 각 KPI를 **측정(derived)** · **수동(manual)** · **미측정(no collector)** 로 정직히 구분한다. "
"미측정을 측정된 것처럼 위장하지 않는다.",
"", "| KPI | 목표 | 상태 | 최근 값 |", "|---|---|---|---|"]
measured = 0
for name, spec_v in spec.items():
target = spec_v.get("target", "-") if isinstance(spec_v, dict) else "-"
if name in latest:
measured += 1
val = latest[name].get("value")
src = latest[name].get("source")
status = "✅ derived" if src == "derived" else "✍️ manual"
vals = f"{val:.1%}" if isinstance(val, float) and val <= 1 else str(val)
elif name in DERIVED_METRICS:
status, vals = "⏳ derivable (run `derive`)", "-"
elif name in MANUAL_METRICS:
status, vals = "✍️ manual-only (log 이벤트 필요)", "-"
else:
status, vals = "⚪ 미측정 (수집기 없음 — 정직)", "-"
L.append(f"| {name} | {target} | {status} | {vals} |")
total = len(spec)
L += ["", f"측정중(derived/manual): **{measured}** · 파생가능 미실행: "
f"{len(DERIVED_METRICS - set(latest))} · 미측정: "
f"{total - measured - len(DERIVED_METRICS - set(latest))} / 총 {total} KPI"]
out = os.path.join(W.reports_dir(), "KPI.md")
os.makedirs(os.path.dirname(out), exist_ok=True)
with open(out, "w", encoding="utf-8") as f:
f.write("\n".join(L) + "\n")
print(f"[kpi_ledger] dashboard -> {os.path.relpath(out, ROOT)} "
f"({measured}/{total} KPI 측정중)")
# ---------------------------------------------------------------- CLI
def _parse(args):
opt = {}
i = 0
while i < len(args):
if args[i].startswith("--"):
k = args[i][2:]
v = args[i + 1] if i + 1 < len(args) and not args[i + 1].startswith("--") else True
opt[k] = v
i += 2
else:
i += 1
return opt
def main():
a = sys.argv[1:]
if not a:
sys.stderr.write(__doc__)
sys.exit(1)
cmd = a[0]
opt = _parse(a[1:])
try:
if cmd == "derive":
derive(opt.get("workflow") if isinstance(opt.get("workflow"), str) else None)
elif cmd == "log":
metric = opt.get("metric")
if metric not in _spec_metrics():
raise ValueError(f"미등록 metric: {metric!r}")
if not opt.get("window-start") or not opt.get("window-end"):
raise ValueError("manual KPI는 --window-start/--window-end 필수")
value = float(opt.get("value"))
if not (value == value and abs(value) != float("inf")):
raise ValueError("KPI value는 finite number여야 한다")
if metric.endswith("-rate") and not 0 <= value <= 1:
raise ValueError("rate KPI는 0..1 범위여야 한다")
_append({"at": _now(), "metric": metric,
"value": value, "unit": opt.get("unit") or ("ratio" if metric.endswith("-rate") else "count"),
"window-start": opt.get("window-start"), "window-end": opt.get("window-end"),
"formula-version": 2, "source": "manual",
"workflow": opt.get("workflow", "*"), "role": opt.get("role"),
"note": opt.get("note")})
print(f"[kpi_ledger] logged manual {metric}={value}")
elif cmd == "dashboard":
dashboard()
else:
sys.stderr.write(f"unknown command: {cmd}\n")
sys.exit(1)
except W.WorkspaceNotSetError as e:
sys.stderr.write(f"[kpi_ledger] 워크스페이스 미설정: {e}\n")
sys.exit(1)
except (TypeError, ValueError) as e:
sys.stderr.write(f"[kpi_ledger] 입력 거부: {e}\n")
sys.exit(2)
if __name__ == "__main__":
main()