233 lines
7.9 KiB
Python
233 lines
7.9 KiB
Python
#!/usr/bin/env python3
|
||||
|
|
# -*- coding: utf-8 -*-
|
|||
|
|
"""接力链体检 —— 一条命令出「某条接力链跑了哪些棒 / 每棒成本 / 有没有断链」。
|
|||
|
|
|
|||
|
|
用法:
|
|||
|
|
python chain_report.py # 默认看全部会话
|
|||
|
|
python chain_report.py "覆盖网络" # 只看标题含该关键词的会话
|
|||
|
|
python chain_report.py "覆盖网络" --json # 追加输出机器可读 JSON
|
|||
|
|
|
|||
|
|
数据源:
|
|||
|
|
<HOME>/workbuddy.db → sessions / automations / automation_runs
|
|||
|
|
<HOME>/projects/*/<sid>.jsonl → 逐次 rawUsage(含 credit)+ function_call 计数
|
|||
|
|
|
|||
|
|
坑(踩过的,别再踩):
|
|||
|
|
* rawUsage 不在顶层,必须【递归】收集;顶层 d.get("rawUsage") 抓不到。
|
|||
|
|
* 文本元素类型是 input_text / output_text,不是 "text"。
|
|||
|
|
* 工作区目录名会随工作区搬迁改变(e-... / d-...),两个目录都要找。
|
|||
|
|
* 本机没有 sqlite3 CLI ⇒ 必须用 python 的 sqlite3,以 mode=ro 只读打开。
|
|||
|
|
* 读文件必须显式 UTF-8(newline=""),否则 CP936 静默乱码。
|
|||
|
|
"""
|
|||
|
|
import sys, os, io, json, glob, sqlite3, datetime, argparse
|
|||
|
|
|
|||
|
|
sys.stdout.reconfigure(encoding="utf-8")
|
|||
|
|
|
|||
|
|
HOME = os.environ.get("WORKBUDDY_CONFIG_DIR") or os.path.expanduser("~/.workbuddy")
|
|||
|
|
DB = os.path.join(HOME, "workbuddy.db")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def ts(v):
|
|||
|
|
if not v:
|
|||
|
|
return "-"
|
|||
|
|
try:
|
|||
|
|
return datetime.datetime.fromtimestamp(int(v) / 1000).strftime("%m-%d %H:%M")
|
|||
|
|
except Exception:
|
|||
|
|
return str(v)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def dur(a, b):
|
|||
|
|
if not a or not b:
|
|||
|
|
return "-"
|
|||
|
|
try:
|
|||
|
|
return "%dmin" % round((int(b) - int(a)) / 60000.0)
|
|||
|
|
except Exception:
|
|||
|
|
return "-"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def walk_usage(o, hits):
|
|||
|
|
"""递归收集 rawUsage 节点(顶层抓不到)。"""
|
|||
|
|
if isinstance(o, dict):
|
|||
|
|
for k, v in o.items():
|
|||
|
|
if k == "rawUsage" and isinstance(v, dict):
|
|||
|
|
hits.append(v)
|
|||
|
|
else:
|
|||
|
|
walk_usage(v, hits)
|
|||
|
|
elif isinstance(o, list):
|
|||
|
|
for x in o:
|
|||
|
|
walk_usage(x, hits)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def get_text(d):
|
|||
|
|
"""文本元素类型是 input_text / output_text。"""
|
|||
|
|
c = d.get("content")
|
|||
|
|
if isinstance(c, str):
|
|||
|
|
return c
|
|||
|
|
out = []
|
|||
|
|
if isinstance(c, list):
|
|||
|
|
for x in c:
|
|||
|
|
if isinstance(x, dict) and x.get("type") in ("input_text", "output_text", "text"):
|
|||
|
|
out.append(x.get("text", ""))
|
|||
|
|
return "\n".join(out)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def find_jsonl(sid, projdirs):
|
|||
|
|
for d in projdirs:
|
|||
|
|
p = os.path.join(d, sid + ".jsonl")
|
|||
|
|
if os.path.exists(p):
|
|||
|
|
return p
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def scan_session(path):
|
|||
|
|
ncall = nuser = nasst = 0
|
|||
|
|
credit = 0.0
|
|||
|
|
tools, skills_missing = {}, 0
|
|||
|
|
with io.open(path, "r", encoding="utf-8", errors="replace", newline="") as f:
|
|||
|
|
for line in f:
|
|||
|
|
line = line.strip()
|
|||
|
|
if not line:
|
|||
|
|
continue
|
|||
|
|
try:
|
|||
|
|
d = json.loads(line)
|
|||
|
|
except Exception:
|
|||
|
|
continue
|
|||
|
|
t = d.get("type")
|
|||
|
|
if t == "function_call":
|
|||
|
|
ncall += 1
|
|||
|
|
nm = d.get("name") or "?"
|
|||
|
|
tools[nm] = tools.get(nm, 0) + 1
|
|||
|
|
elif t == "message":
|
|||
|
|
if d.get("role") == "user":
|
|||
|
|
nuser += 1
|
|||
|
|
elif d.get("role") == "assistant":
|
|||
|
|
nasst += 1
|
|||
|
|
hits = []
|
|||
|
|
walk_usage(d, hits)
|
|||
|
|
for x in hits:
|
|||
|
|
try:
|
|||
|
|
credit += float(x.get("credit") or 0)
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
return dict(calls=ncall, credit=credit, user=nuser, asst=nasst,
|
|||
|
|
tools=tools, skill=tools.get("Skill", 0))
|
|||
|
|
|
|||
|
|
|
|||
|
|
def main():
|
|||
|
|
ap = argparse.ArgumentParser()
|
|||
|
|
ap.add_argument("keyword", nargs="?", default="")
|
|||
|
|
ap.add_argument("--json", action="store_true")
|
|||
|
|
args = ap.parse_args()
|
|||
|
|
|
|||
|
|
if not os.path.exists(DB):
|
|||
|
|
print("找不到 workbuddy.db: %s" % DB)
|
|||
|
|
return 1
|
|||
|
|
|
|||
|
|
con = sqlite3.connect("file:%s?mode=ro" % DB.replace("?", "%3f"), uri=True)
|
|||
|
|
cur = con.cursor()
|
|||
|
|
|
|||
|
|
kw = args.keyword
|
|||
|
|
sql = "SELECT id,title,created_at,last_activity_at,is_background_automation FROM sessions"
|
|||
|
|
sess = []
|
|||
|
|
for r in cur.execute(sql):
|
|||
|
|
if kw and kw not in (r[1] or ""):
|
|||
|
|
continue
|
|||
|
|
sess.append(r)
|
|||
|
|
sess.sort(key=lambda r: r[2] or 0)
|
|||
|
|
|
|||
|
|
projdirs = glob.glob(os.path.join(HOME, "projects", "*"))
|
|||
|
|
|
|||
|
|
print("=" * 96)
|
|||
|
|
print("接力链体检 · 关键词 = %s · 会话 %d 个 · %s" % (kw or "(全部)", len(sess),
|
|||
|
|
datetime.datetime.now().strftime("%Y-%m-%d %H:%M")))
|
|||
|
|
print("=" * 96)
|
|||
|
|
print("%-10s %-12s %-7s %-5s %-8s %-8s %-7s %-6s %s" % (
|
|||
|
|
"sid", "创建", "时长", "auto", "工具调用", "积分", "积分/次", "Skill", "标题"))
|
|||
|
|
|
|||
|
|
rows = []
|
|||
|
|
T = dict(calls=0, credit=0.0, user=0)
|
|||
|
|
for r in sess:
|
|||
|
|
sid, title = r[0], r[1]
|
|||
|
|
p = find_jsonl(sid, projdirs)
|
|||
|
|
if not p:
|
|||
|
|
print("%-10s %-12s %-7s %-5s %-8s %-8s %-7s %-6s %s" % (
|
|||
|
|
sid[:8], ts(r[2]), dur(r[2], r[3]), r[4], "无转录", "-", "-", "-", title))
|
|||
|
|
continue
|
|||
|
|
s = scan_session(p)
|
|||
|
|
per = (s["credit"] / s["calls"]) if s["calls"] else 0
|
|||
|
|
T["calls"] += s["calls"]
|
|||
|
|
T["credit"] += s["credit"]
|
|||
|
|
T["user"] += s["user"]
|
|||
|
|
print("%-10s %-12s %-7s %-5s %-8d %-8.2f %-7.3f %-6d %s" % (
|
|||
|
|
sid[:8], ts(r[2]), dur(r[2], r[3]), r[4], s["calls"], s["credit"], per,
|
|||
|
|
s["skill"], title))
|
|||
|
|
top = sorted(s["tools"].items(), key=lambda kv: -kv[1])[:5]
|
|||
|
|
if top:
|
|||
|
|
print(" top: " + ", ".join("%s×%d" % (a, b) for a, b in top))
|
|||
|
|
rows.append(dict(sid=sid, title=title, created_at=r[2], **{
|
|||
|
|
k: s[k] for k in ("calls", "credit", "user", "asst", "skill")}))
|
|||
|
|
|
|||
|
|
print("-" * 96)
|
|||
|
|
print("合计: 工具调用 %d | 积分 %.2f | 用户轮 %d" % (T["calls"], T["credit"], T["user"]))
|
|||
|
|
|
|||
|
|
# ---- 自动化运行链(断链检测) ----
|
|||
|
|
print()
|
|||
|
|
print("=" * 96)
|
|||
|
|
print("自动化运行链(每行 = 一次触发 = 一个新会话)")
|
|||
|
|
print("=" * 96)
|
|||
|
|
anames = {r[0]: r[1] for r in cur.execute("SELECT id,name FROM automations")}
|
|||
|
|
recs = []
|
|||
|
|
for r in cur.execute("SELECT automation_id, runs_json FROM automation_runs"):
|
|||
|
|
try:
|
|||
|
|
runs = json.loads(r[1] or "[]")
|
|||
|
|
except Exception:
|
|||
|
|
runs = []
|
|||
|
|
for x in runs:
|
|||
|
|
recs.append((x.get("startedAt") or 0, anames.get(r[0], r[0]), x))
|
|||
|
|
recs.sort()
|
|||
|
|
prev = None
|
|||
|
|
gaps = []
|
|||
|
|
for st, name, x in recs:
|
|||
|
|
if kw and kw not in (name or ""):
|
|||
|
|
continue
|
|||
|
|
if prev:
|
|||
|
|
gap = (int(st) - int(prev)) / 60000.0
|
|||
|
|
if gap > 90:
|
|||
|
|
gaps.append((ts(prev), ts(st), gap))
|
|||
|
|
prev = st
|
|||
|
|
print("%-19s ok=%-5s %-6s %-14s %s" % (
|
|||
|
|
ts(st), x.get("success"),
|
|||
|
|
dur(st, x.get("finishedAt")), str(x.get("conversationId"))[:12], (name or "")[:40]))
|
|||
|
|
if gaps:
|
|||
|
|
print()
|
|||
|
|
print("⚠️ 疑似断链(相邻两棒间隔 > 90 分钟):")
|
|||
|
|
for a, b, g in gaps:
|
|||
|
|
print(" %s → %s (间隔 %.0f 分钟)" % (a, b, g))
|
|||
|
|
|
|||
|
|
# ---- 完成态核查 ----
|
|||
|
|
print()
|
|||
|
|
print("=" * 96)
|
|||
|
|
print("过期但仍 ACTIVE 的一次性 automation(once 型跑完不自动转完成态 ⇒ 有补跑窗口)")
|
|||
|
|
print("=" * 96)
|
|||
|
|
n = 0
|
|||
|
|
for r in cur.execute("SELECT id,name,status,scheduled_at,deleted_at FROM automations ORDER BY created_at DESC"):
|
|||
|
|
if r[2] != "ACTIVE" or r[4] or not r[3]:
|
|||
|
|
continue
|
|||
|
|
try:
|
|||
|
|
when = datetime.datetime.fromisoformat(r[3])
|
|||
|
|
except Exception:
|
|||
|
|
continue
|
|||
|
|
if when < datetime.datetime.now():
|
|||
|
|
n += 1
|
|||
|
|
print(" %s | %-40s | 触发时刻 %s | deleted_at=%s" % (r[0][:8], (r[1] or "")[:40], r[3], r[4]))
|
|||
|
|
if not n:
|
|||
|
|
print(" (无)")
|
|||
|
|
|
|||
|
|
if args.json:
|
|||
|
|
print()
|
|||
|
|
print(json.dumps(rows, ensure_ascii=False, indent=2))
|
|||
|
|
con.close()
|
|||
|
|
return 0
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
sys.exit(main())
|