Files
dsh_shenxian/dsh-server-docs/08-skills/dsh-auto-handoff-chain/scripts/chain_report.py
T
admin e6207aa691
build / build-and-scan (push) Waiting to run
chore(仓库对齐): 文档库结构治理 + IM/插件线落地
文档库:目录改为编号制(01-规范/02-架构设计/03-数据库/04-调整方案/
05-交接单/06-ops/07-scripts/08-skills/09-archive),顶层散文件归入 01-规范/;
INDEX.md 与 docs-manifest.json 重刷(档案 146 篇);旧目录名引用全量对齐。

IM 线:src/im/**(SDK / hub / store / presence / ws / gateway-token)、
src/web/routes/im.ts、src/db/plugin-data/**、src/supervisor/plugin-assembly.ts
及对应 test/**。

插件线:poc/{im-agent-bridge,im-connection-gateway,im-conversation-tabs,
business-plugins-im,carbon-mcp-probe}、src/web/routes/{sessions,overlay-device}.ts、
src/net/relay/{device-grant,instance-credential}.ts。

仓库卫生:清出 40 个历史误入库 / 已改名文件(34 个交接单归档 + 6 个旧结构,
本地均有副本);dsh-server-docs/.gitignore 补 tmp/;交接单不入库(政策)。
2026-09-24 07:25:16 +08:00

234 lines
7.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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())