virtual-insanity
← 리포트 목록

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. 잔존 리스크

  1. 이 Codex 실행 환경에서 Telegram API DNS 조회가 실패해 실제 도달과 message_id는 확보하지 못했다. 단, wrapper가 send_sector(ops)까지 호출한 증거는 sector_trace로 확인했다.
  2. 이 Codex 실행 환경에서 launchctl bootstrap gui/501 ...가 KPI뿐 아니라 이전 작업의 다른 plist에서도 error 5로 실패한다. plist 파일 변경은 완료됐고, 실제 load는 GUI 세션 터미널에서 /Users/ron/.hermes/scripts/reload_kpi_launchagents.sh 실행이 필요하다.
  3. 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