← 리포트 목록
KPI daily/weekly Telegram wrapper 추가
2026-04-15
fix
[launchd, kpi, telegram, hermes-migration]
KPI daily/weekly Telegram wrapper 추가
시작 시각: 2026-04-15 16:35:58 KST
1. 현재 KPI 스크립트 분석
- 실제 파일: /Users/ron/.hermes/workspace/scripts/ron_kpi_tracker.py
- pipeline/ron_kpi_tracker.py는 없음.
- 원본은 /Users/ron/.hermes/logs/kpi_daily.json 에 daily history를 저장하고 stdout으로 요약 출력.
- grep 결과 send_sector/send_dm/send_document/api.telegram 호출 없음.
2. wrapper 추가
- 추가 파일: /Users/ron/.hermes/workspace/scripts/pipeline/kpi_telegram_wrapper.py
- 원본 KPI 스크립트는 수정하지 않음.
- wrapper는 원본 실행 → kpi_daily.json 읽기 → 한국어 HTML 포맷 → send_sector('ops') 호출.
- send_sector 내부 _send_raw를 일시 래핑해 message_id/error를 stdout에 기록.
wrapper script 전문
#!/usr/bin/env python3
"""kpi_telegram_wrapper.py — run KPI tracker then send Korean HTML summary to Telegram ops topic.
원본 KPI 스크립트는 수정하지 않는다.
- daily: python3 kpi_telegram_wrapper.py
- weekly: python3 kpi_telegram_wrapper.py --weekly
"""
from __future__ import annotations
import argparse
import html
import json
import os
import subprocess
import sys
from datetime import datetime
from pathlib import Path
from typing import Any
HOME = Path.home()
HERMES = HOME / ".hermes"
SCRIPTS = HERMES / "workspace" / "scripts"
PIPELINE = SCRIPTS / "pipeline"
SHARED = SCRIPTS / "shared"
KPI_SCRIPT = SCRIPTS / "ron_kpi_tracker.py"
KPI_JSON = HERMES / "logs" / "kpi_daily.json"
# Import shared.telegram from Hermes without requiring launchd PYTHONPATH.
for p in (str(SCRIPTS), str(SHARED), str(PIPELINE)):
if p not in sys.path:
sys.path.insert(0, p)
def _run_original(weekly: bool) -> subprocess.CompletedProcess[str]:
"""Run original KPI tracker with current Python interpreter and capture output."""
env = os.environ.copy()
env["PYTHONPATH"] = ":".join([str(SCRIPTS), str(SHARED), str(PIPELINE), env.get("PYTHONPATH", "")]).rstrip(":")
cmd = [sys.executable, str(KPI_SCRIPT)]
if weekly:
cmd.append("--weekly")
return subprocess.run(cmd, text=True, capture_output=True, env=env, timeout=120)
def _load_history() -> list[dict[str, Any]]:
if not KPI_JSON.exists():
return []
try:
data = json.loads(KPI_JSON.read_text(encoding="utf-8"))
return data if isinstance(data, list) else []
except Exception:
return []
def _fmt_pct(v: Any) -> str:
try:
return f"{float(v):.1f}%"
except Exception:
return "?%"
def _agent_name(agent: str) -> str:
return {
"ron": "Ron",
"codex": "Codex",
"cowork": "Cowork",
"analyst-fundamental": "Fundamental",
"analyst-macro": "Macro",
"analyst-technical": "Technical",
}.get(agent, agent)
def _format_daily(history: list[dict[str, Any]], original_output: str) -> str:
if not history:
return (
"📊 <b>일일 KPI 리포트</b>\n"
"상태: <b>데이터 없음</b>\n\n"
f"<pre>{html.escape(original_output[-1200:])}</pre>"
)
latest = history[-1]
agents = latest.get("agents", {}) or {}
total_done = sum(int((v or {}).get("done_today", 0) or 0) for v in agents.values())
total_failed = sum(int((v or {}).get("failed_today", 0) or 0) for v in agents.values())
total_queued = sum(int((v or {}).get("queued", 0) or 0) for v in agents.values())
denom = total_done + total_failed
success = (total_done / denom * 100.0) if denom else 100.0
cron = latest.get("cron", {}) or {}
lines = [
"📊 <b>일일 KPI 리포트</b>",
f"날짜: <code>{html.escape(str(latest.get('date', '?')))}</code>",
"",
"<b>전체</b>",
f"• 처리/실패/대기: <b>{total_done}</b> / <b>{total_failed}</b> / <b>{total_queued}</b>",
f"• 성공률: <b>{success:.1f}%</b>",
f"• Cron: <b>{cron.get('enabled', '?')}/{cron.get('total', '?')}</b> 활성, 비활성 {cron.get('disabled', '?')}",
"",
"<b>에이전트별</b>",
]
for agent, stats in agents.items():
stats = stats or {}
lines.append(
f"• {_agent_name(agent)}: done {stats.get('done_today', 0)}, "
f"fail {stats.get('failed_today', 0)}, queued {stats.get('queued', 0)}, "
f"성공률 {_fmt_pct(stats.get('success_rate'))}"
)
lines.append("")
lines.append(f"생성: <code>{html.escape(str(latest.get('timestamp', datetime.now().isoformat()))[:19])}</code>")
return "\n".join(lines)
def _format_weekly(history: list[dict[str, Any]], original_output: str) -> str:
if len(history) < 7:
return (
"📈 <b>주간 KPI 리포트</b>\n"
f"상태: <b>데이터 부족 ({len(history)}일)</b>\n\n"
"최근 daily KPI가 7일치 쌓이면 주간 평균/추세를 계산합니다.\n"
f"<pre>{html.escape(original_output[-1200:])}</pre>"
)
recent = history[-7:]
agents = sorted((recent[-1].get("agents", {}) or {}).keys())
lines = [
"📈 <b>주간 KPI 리포트</b>",
f"기간: <code>{html.escape(str(recent[0].get('date')))} ~ {html.escape(str(recent[-1].get('date')))}</code>",
"",
]
total_done = 0
total_failed = 0
for day in recent:
for stats in (day.get("agents", {}) or {}).values():
total_done += int((stats or {}).get("done_today", 0) or 0)
total_failed += int((stats or {}).get("failed_today", 0) or 0)
denom = total_done + total_failed
success = (total_done / denom * 100.0) if denom else 100.0
lines += [
"<b>전체</b>",
f"• 7일 처리/실패: <b>{total_done}</b> / <b>{total_failed}</b>",
f"• 7일 성공률: <b>{success:.1f}%</b>",
"",
"<b>에이전트별 7일 합계</b>",
]
for agent in agents:
done = sum(int(((d.get("agents", {}) or {}).get(agent, {}) or {}).get("done_today", 0) or 0) for d in recent)
failed = sum(int(((d.get("agents", {}) or {}).get(agent, {}) or {}).get("failed_today", 0) or 0) for d in recent)
queued = int(((recent[-1].get("agents", {}) or {}).get(agent, {}) or {}).get("queued", 0) or 0)
rate = (done / (done + failed) * 100.0) if (done + failed) else 100.0
lines.append(f"• {_agent_name(agent)}: done {done}, fail {failed}, 현재 대기 {queued}, 성공률 {rate:.1f}%")
cron = recent[-1].get("cron", {}) or {}
lines += [
"",
f"Cron: <b>{cron.get('enabled', '?')}/{cron.get('total', '?')}</b> 활성, 비활성 {cron.get('disabled', '?')}",
]
return "\n".join(lines)
def _send_ops(message: str) -> tuple[bool, int | None, str | None]:
"""Send through shared.telegram.send_sector('ops') while capturing underlying message_id."""
import shared.telegram as telegram # type: ignore
captured: dict[str, Any] = {"message_id": None, "error": None}
original_send_raw = telegram._send_raw
def wrapped_send_raw(*args: Any, **kwargs: Any) -> dict[str, Any]:
result = original_send_raw(*args, **kwargs)
captured["message_id"] = result.get("message_id")
captured["error"] = result.get("error")
return result
telegram._send_raw = wrapped_send_raw
try:
ok = bool(telegram.send_sector("ops", message, parse_mode="HTML"))
finally:
telegram._send_raw = original_send_raw
return ok, captured.get("message_id"), captured.get("error")
def main() -> int:
parser = argparse.ArgumentParser(description="Run KPI tracker and send Telegram ops summary")
parser.add_argument("--weekly", action="store_true", help="주간 KPI 모드")
parser.add_argument("--no-send", action="store_true", help="텔레그램 전송 없이 포맷만 출력")
args = parser.parse_args()
mode = "weekly" if args.weekly else "daily"
print(f"[kpi-wrapper] start mode={mode}", flush=True)
proc = _run_original(args.weekly)
print("[kpi-wrapper] original stdout BEGIN", flush=True)
if proc.stdout:
print(proc.stdout.rstrip(), flush=True)
print("[kpi-wrapper] original stdout END", flush=True)
if proc.stderr:
print("[kpi-wrapper] original stderr BEGIN", flush=True)
print(proc.stderr.rstrip(), flush=True)
print("[kpi-wrapper] original stderr END", flush=True)
print(f"[kpi-wrapper] original_exit={proc.returncode}", flush=True)
history = _load_history()
message = _format_weekly(history, proc.stdout) if args.weekly else _format_daily(history, proc.stdout)
print("[kpi-wrapper] telegram_payload BEGIN", flush=True)
print(message, flush=True)
print("[kpi-wrapper] telegram_payload END", flush=True)
if args.no_send:
print("[kpi-wrapper] no-send requested", flush=True)
return proc.returncode
ok, message_id, error = _send_ops(message)
print(f"[kpi-wrapper] telegram_ok={ok} message_id={message_id} error={error}", flush=True)
if proc.returncode != 0:
return proc.returncode
return 0 if ok else 2
if __name__ == "__main__":
raise SystemExit(main())
3. 1회 수동 실행 테스트
daily wrapper
$ python3 /Users/ron/.hermes/workspace/scripts/pipeline/kpi_telegram_wrapper.py
[kpi-wrapper] start mode=daily
[kpi-wrapper] original stdout BEGIN
==================================================
📊 KPI 수집 실행 - 2026-04-15 16:37:26
==================================================
✅ 일일 KPI 저장 완료
날짜: 2026-04-15
ron: 0 done, 0 failed, 100% success
codex: 0 done, 0 failed, 100% success
cowork: 1 done, 0 failed, 100.0% success
analyst-fundamental: 0 done, 0 failed, 100% success
analyst-macro: 0 done, 0 failed, 100% success
analyst-technical: 0 done, 0 failed, 100% success
크론: 8/8 활성
[kpi-wrapper] original stdout END
[kpi-wrapper] original_exit=0
[kpi-wrapper] telegram_payload BEGIN
📊 <b>일일 KPI 리포트</b>
날짜: <code>2026-04-15</code>
<b>전체</b>
• 처리/실패/대기: <b>1</b> / <b>0</b> / <b>5</b>
• 성공률: <b>100.0%</b>
• Cron: <b>8/8</b> 활성, 비활성 0
<b>에이전트별</b>
• Ron: done 0, fail 0, queued 4, 성공률 100.0%
• Codex: done 0, fail 0, queued 1, 성공률 100.0%
• Cowork: done 1, fail 0, queued 0, 성공률 100.0%
• Fundamental: done 0, fail 0, queued 0, 성공률 100.0%
• Macro: done 0, fail 0, queued 0, 성공률 100.0%
• Technical: done 0, fail 0, queued 0, 성공률 100.0%
생성: <code>2026-04-15T16:37:26</code>
[kpi-wrapper] telegram_payload END
[SECTOR_TRACE] send_sector(ops) caller=kpi_telegram_wrapper.py:170 text='📊 <b>일일 KPI 리포트</b>\n날짜: <code>2026-04-15</code>\n\n<b>전체</b>\n• 처리/실패/대기: <b>1</b> '
Telegram send failed after 3 retries: <urlopen error [Errno 8] nodename nor servname provided, or not known>
[kpi-wrapper] telegram_ok=False message_id=None error=<urlopen error [Errno 8] nodename nor servname provided, or not known>
exit_code=2
weekly wrapper
$ python3 /Users/ron/.hermes/workspace/scripts/pipeline/kpi_telegram_wrapper.py --weekly
[kpi-wrapper] start mode=weekly
[kpi-wrapper] original stdout BEGIN
==================================================
📊 주간 KPI 리포트 - 2026-04-15
==================================================
데이터 부족 (3일)
==================================================
📋 개선안
==================================================
✅ 개선 필요 영역 없음
[kpi-wrapper] original stdout END
[kpi-wrapper] original_exit=0
[kpi-wrapper] telegram_payload BEGIN
📈 <b>주간 KPI 리포트</b>
상태: <b>데이터 부족 (3일)</b>
최근 daily KPI가 7일치 쌓이면 주간 평균/추세를 계산합니다.
<pre>
==================================================
📊 주간 KPI 리포트 - 2026-04-15
==================================================
데이터 부족 (3일)
==================================================
📋 개선안
==================================================
✅ 개선 필요 영역 없음
</pre>
[kpi-wrapper] telegram_payload END
[SECTOR_TRACE] send_sector(ops) caller=kpi_telegram_wrapper.py:170 text='📈 <b>주간 KPI 리포트</b>\n상태: <b>데이터 부족 (3일)</b>\n\n최근 daily KPI가 7일치 쌓이면 주간 평균/추세를 계산합니'
Telegram send failed after 3 retries: <urlopen error [Errno 8] nodename nor servname provided, or not known>
[kpi-wrapper] telegram_ok=False message_id=None error=<urlopen error [Errno 8] nodename nor servname provided, or not known>
exit_code=2
4. plist ProgramArguments 변경
- 백업: /Users/ron/.hermes/backups/kpi_telegram_plists_20260415T163754
- daily/weekly 모두 wrapper를 호출하도록 변경.
plist diff
--- com.openclaw.kpi-daily.plist
--- /Users/ron/.hermes/backups/kpi_telegram_plists_20260415T163754/com.openclaw.kpi-daily.plist 2026-04-15 16:12:09
+++ /Users/ron/Library/LaunchAgents/com.openclaw.kpi-daily.plist 2026-04-15 16:37:54
@@ -7,7 +7,7 @@
<key>ProgramArguments</key>
<array>
<string>/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/Resources/Python.app/Contents/MacOS/Python</string>
- <string>/Users/ron/.hermes/workspace/scripts/ron_kpi_tracker.py</string>
+ <string>/Users/ron/.hermes/workspace/scripts/pipeline/kpi_telegram_wrapper.py</string>
</array>
<key>StartCalendarInterval</key>
<dict>
--- com.openclaw.kpi-weekly.plist
--- /Users/ron/.hermes/backups/kpi_telegram_plists_20260415T163754/com.openclaw.kpi-weekly.plist 2026-04-15 16:12:09
+++ /Users/ron/Library/LaunchAgents/com.openclaw.kpi-weekly.plist 2026-04-15 16:37:54
@@ -7,7 +7,7 @@
<key>ProgramArguments</key>
<array>
<string>/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/Resources/Python.app/Contents/MacOS/Python</string>
- <string>/Users/ron/.hermes/workspace/scripts/ron_kpi_tracker.py</string>
+ <string>/Users/ron/.hermes/workspace/scripts/pipeline/kpi_telegram_wrapper.py</string>
<string>--weekly</string>
</array>
<key>StartCalendarInterval</key>
변경 후 plutil -p
===== com.openclaw.kpi-daily.plist
{
"Label" => "com.openclaw.kpi-daily"
"ProgramArguments" => [
0 => "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/Resources/Python.app/Contents/MacOS/Python"
1 => "/Users/ron/.hermes/workspace/scripts/pipeline/kpi_telegram_wrapper.py"
]
"StandardErrorPath" => "/Users/ron/.hermes/logs/kpi_error.log"
"StandardOutPath" => "/Users/ron/.hermes/logs/kpi_daily.log"
"StartCalendarInterval" => {
"Hour" => 0
"Minute" => 0
}
}
===== com.openclaw.kpi-weekly.plist
{
"Label" => "com.openclaw.kpi-weekly"
"ProgramArguments" => [
0 => "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/Resources/Python.app/Contents/MacOS/Python"
1 => "/Users/ron/.hermes/workspace/scripts/pipeline/kpi_telegram_wrapper.py"
2 => "--weekly"
]
"StandardErrorPath" => "/Users/ron/.hermes/logs/kpi_weekly_error.log"
"StandardOutPath" => "/Users/ron/.hermes/logs/kpi_weekly.log"
"StartCalendarInterval" => {
"Hour" => 9
"Minute" => 0
"Weekday" => 1
}
}
5. launchctl 재기동 시도
===== com.openclaw.kpi-daily =====
$ launchctl bootout gui/501/com.openclaw.kpi-daily
bootout_label_exit=0
$ launchctl bootout gui/501 /Users/ron/Library/LaunchAgents/com.openclaw.kpi-daily.plist
Boot-out failed: 5: Input/output error
Try re-running the command as root for richer errors.
bootout_path_exit=5
$ launchctl bootstrap gui/501 /Users/ron/Library/LaunchAgents/com.openclaw.kpi-daily.plist
Bootstrap failed: 5: Input/output error
Try re-running the command as root for richer errors.
bootstrap_exit=5
$ launchctl enable gui/501/com.openclaw.kpi-daily
enable_exit=0
$ launchctl print gui/501/com.openclaw.kpi-daily | head -60
Bad request.
Could not find service "com.openclaw.kpi-daily" in domain for user gui: 501
print_exit=113
kickstart_skipped=service_not_bootstrapped
===== com.openclaw.kpi-weekly =====
$ launchctl bootout gui/501/com.openclaw.kpi-weekly
bootout_label_exit=0
$ launchctl bootout gui/501 /Users/ron/Library/LaunchAgents/com.openclaw.kpi-weekly.plist
Boot-out failed: 5: Input/output error
Try re-running the command as root for richer errors.
bootout_path_exit=5
$ launchctl bootstrap gui/501 /Users/ron/Library/LaunchAgents/com.openclaw.kpi-weekly.plist
Bootstrap failed: 5: Input/output error
Try re-running the command as root for richer errors.
bootstrap_exit=5
$ launchctl enable gui/501/com.openclaw.kpi-weekly
enable_exit=0
$ launchctl print gui/501/com.openclaw.kpi-weekly | head -60
Bad request.
Could not find service "com.openclaw.kpi-weekly" in domain for user gui: 501
print_exit=113
kickstart_skipped=service_not_bootstrapped
6. 최종 상태 요약 — 2026-04-15 16:39:57 KST
변경 파일
/Users/ron/.hermes/workspace/scripts/pipeline/kpi_telegram_wrapper.py— 신규 Telegram wrapper. 원본 KPI 실행 후 JSON을 읽어 ops 섹터로 HTML 발송./Users/ron/Library/LaunchAgents/com.openclaw.kpi-daily.plist— ProgramArguments를 wrapper로 교체./Users/ron/Library/LaunchAgents/com.openclaw.kpi-weekly.plist— ProgramArguments를 wrapper +--weekly로 교체./Users/ron/.hermes/scripts/reload_kpi_launchagents.sh— GUI 세션/터미널에서 KPI LaunchAgents 재로드용 보조 스크립트.- 백업:
/Users/ron/.hermes/backups/kpi_telegram_plists_20260415T163754
최종 결과 표
| label | wrapper/plist | 수동 실행 | Telegram 호출 증거 | message_id | launchctl 상태 |
|---|---|---|---|---|---|
| com.openclaw.kpi-daily | wrapper 적용 완료 | KPI JSON 생성 후 발송 시도, exit=2 | sector_trace: send_sector(ops) caller=kpi_telegram_wrapper.py:170 | 없음 — DNS 실패 | bootstrap error 5, helper로 외부 재로드 필요 |
| com.openclaw.kpi-weekly | wrapper --weekly 적용 완료 | 주간 데이터 부족 메시지 발송 시도, exit=2 | sector_trace: send_sector(ops) caller=kpi_telegram_wrapper.py:170 | 없음 — DNS 실패 | bootstrap error 5, helper로 외부 재로드 필요 |
sector_trace 증거
2026-04-15 16:37:26 [SECTOR_TRACE] send_sector(ops) caller=kpi_telegram_wrapper.py:170 text='📊 <b>일일 KPI 리포트</b>\n날짜: <code>2026-04-15</code>\n\n<b>전체</b>\n• 처리/실패/대기: <b>1</b> '
2026-04-15 16:37:29 [SECTOR_TRACE] send_sector(ops) caller=kpi_telegram_wrapper.py:170 text='📈 <b>주간 KPI 리포트</b>\n상태: <b>데이터 부족 (3일)</b>\n\n최근 daily KPI가 7일치 쌓이면 주간 평균/추세를 계산합니'
Hermes 경로 검증
- wrapper 내부 경로는 모두
/Users/ron/.hermes/...기준. - 두 KPI plist에서
/Users/ron/.openclaw/~/.openclaw문자열 없음. - 원본
/Users/ron/.hermes/workspace/scripts/ron_kpi_tracker.py는 수정하지 않음.
재로드 보조 스크립트
#!/usr/bin/env bash
set -euo pipefail
uid="$(id -u)"
for label in com.openclaw.kpi-daily com.openclaw.kpi-weekly; do
plist="$HOME/Library/LaunchAgents/${label}.plist"
echo "===== ${label} ====="
launchctl bootout "gui/${uid}/${label}" 2>/dev/null || true
launchctl bootout "gui/${uid}" "$plist" 2>/dev/null || true
launchctl bootstrap "gui/${uid}" "$plist"
launchctl enable "gui/${uid}/${label}" || true
launchctl print "gui/${uid}/${label}" | head -60
echo
done
7. 잔존 리스크
- 이 Codex 실행 환경에서 Telegram API DNS 조회가 실패해 실제 도달과 message_id는 확보하지 못했다. 단, wrapper가
send_sector(ops)까지 호출한 증거는 sector_trace로 확인했다. - 이 Codex 실행 환경에서
launchctl bootstrap gui/501 ...가 KPI뿐 아니라 이전 작업의 다른 plist에서도 error 5로 실패한다. plist 파일 변경은 완료됐고, 실제 load는 GUI 세션 터미널에서/Users/ron/.hermes/scripts/reload_kpi_launchagents.sh실행이 필요하다. - wrapper는 Telegram 발송 실패 시 exit=2로 실패를 드러내게 설계했다. 따라서 향후 네트워크/토큰 문제가 있으면 조용히 성공 처리되지 않고 launchd 로그에 실패가 남는다.
8. 자체평가
- 정확성: 4.5/5 — 원본 로직 보존, wrapper 추가, plist 교체 완료. 실제 Telegram 도달은 DNS 실패로 미확인.
- 완성도: 4.5/5 — daily/weekly 공용 wrapper, --weekly/--no-send 지원, 재로드 helper 포함.
- 검증: 4.0/5 — py_compile, 수동 실행, sector_trace, plutil 검증 완료. 외부 Telegram message_id와 launchctl bootstrap은 환경 제약으로 실패.
- 최소 변경: 5/5 — 원본 KPI 스크립트 미수정, KPI plist 2개와 wrapper만 변경.
- 종합: 4.5/5