#!/usr/bin/env python3 # -*- coding: utf-8 -*- """接力链体检 —— 一条命令出「某条接力链跑了哪些棒 / 每棒成本 / 有没有断链」。 用法: python chain_report.py # 默认看全部会话 python chain_report.py "覆盖网络" # 只看标题含该关键词的会话 python chain_report.py "覆盖网络" --json # 追加输出机器可读 JSON 数据源: /workbuddy.db → sessions / automations / automation_runs /projects/*/.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())