WorkBuddy 使用报告 v4.1 — 技能说明
一句话触发:「统计我 WorkBuddy 的使用情况」或「我做过了哪些任务」或「我用了哪些技能和专家」。 本技能自动从本地三个真实数据源挖数据,产出 Excel 台账 + HTML 看板,全程零推断。 当前版本:v5.3.0
快速概览(先看这里)
| 项目 | 说明 |
|---|---|
| 输入 | 用户一句话触发,无需提供任何文件路径 |
| 数据来源 | 本地三个真实数据源(见下方「数据地图」),不靠猜 |
| 输出产物 | ① WorkBuddy使用报告.xlsx(5 个 sheet:任务统计[按时间倒序] + 模型Token占比 + 数据说明 + 名称对照 + 统计分析[6 张图表])② WorkBuddy使用报告.html(三合一看板:① 任务概览 ② Token 结构[含涨价测算器] ③ 模型双维度拆解[含交叉矩阵],顶部导航切换) |
| 典型耗时 | 50 个任务约 30 秒(含数据提取+生成) |
| 适用场景 | 个人复盘 / 月度汇报 / 向老板展示 AI 工具使用价值 / 发现工具使用偏好 / Token 成本结构分析(看清钱花在哪类 token、缓存命中如何稀释大模型涨价冲击) |
| 隐私保证 | 只读本地文件,不联网、不上传、不在技能包里保留任何用户数据 |
产出效果长什么样?
Excel 台账包含:
- 「任务统计」sheet:每行一个任务,按时间倒序(越接近当下越靠上),列 = 序号 / 任务名称 / 时间 / 消耗积分 / 技能(中英) / 专家(中英) / 专家团(中英) / 产物1…N
- 「模型Token占比」sheet(v5.1 新增):维度1 用户入口表 + 维度2 真实底座表 + 入口×底座交叉矩阵 + Token 全局结构(全价输入/缓存命中/输出/缓存命中率/总 token)
- 「数据说明」sheet:数据口径解释
- 「名称对照」sheet:英文 ID ↔ 中文名映射表
- 「统计分析」sheet:6 张原生图表 = 技能 Top10 柱状图 / 专家 Top10 / 专家团 Top10 / 按月任务量柱状图 / 按周折线图 / 按天柱状图
HTML 看板包含:
- 顶部 KPI 卡片:总任务数 / 总积分消耗 / 技能种类数 / 专家数 / 产物文件数
- 6 张 Chart.js 交互图表(悬停看数值、可点击图例隐藏系列)
- 底部可排序任务明细表
典型使用示例
用户:「帮我统计一下自从用 WorkBuddy 以来都做过哪些任务」
技能执行:
- 自动定位
~/.workbuddy/workbuddy.db→ 提取 50 条任务记录- 遍历
artifact-index/→ 匹配 37 个任务的 202 个产物文件- 扫描
projects/*/转录 → 正则提取 26 种真实技能调用- 生成 Excel(含统计分析图表)+ HTML 看板
- 告知用户:「共 N 个任务,消耗积分 = credit_json 各模型求和(非 used 内部用量),最常用技能是 XXX,7 月任务量最大(XX 个)」
用户:「我想看看我上个月的工作量分布」
技能执行:同上流程,但在交付时重点展示「按月/按周/按天」三张工作量图表,并指出高峰日期和空闲期。
避坑清单:这些坑不要踩 ⚠️
以下错误做法都会导致结果失真或运行失败。请 Agent 执行时对照检查。
| # | ❌ 错误做法 | ✅ 正确做法 | 后果 |
|---|---|---|---|
| 1 | 只读 workbuddy.db,就填「技能」「产物」 | 技能去 projects/*/<sessionId>.jsonl 转录里用正则挖;产物去 artifact-index/<sessionId>.json 精确匹配 | 技能和产物全靠猜,用户一眼看出「推断」痕迹 |
| 2 | 用 usage-log.json 把技能归到具体任务 | 只用 usage-log.json 看全局趋势;具体任务的技能必须来自该 session 的 .jsonl | usage-log.json 是按天聚合的,无法归到单次任务 |
| 3 | 把 artifact-index 里含 .workbuddy 的内部文件当产物输出 | 产物路径含 .workbuddy 的一律排除 | 会把构建脚本、临时文件、中间产物当成交付物 |
| 4 | 把 created_at 毫秒时间戳直接当秒用 | 转 datetime 时必须除以 1000 | 时间全部显示成 1970 年左右 |
| 5 | 看到 agentmail、agently-mail 就擅自合并成 agent-mail | 如实列出,向用户标注「可能是同一技能的不同字面变体,待确认」 | 擅自合并会丢失数据精度,引发质疑 |
| 6 | 把 expert_id 中非 Team 结尾的也当专家团 | 只有以 Team 结尾的才是专家团,其余归专家 | 分类错误,偏好排名失真 |
| 7 | 直接把 .jsonl 当 JSON 解析后递归找 skill 字段 | .jsonl 里的 Skill 调用是双重序列化字符串,必须用锚定 "name":"Skill" 的正则从原始文本提取 | 165 个文件可能 0 命中 |
| 8 | Excel 被预览锁了还强写,直接报 PermissionError | 捕获 PermissionError 自动另存 _v2 / _v3 | 用户以为脚本崩了,其实关了预览就能写 |
| 9 | 在 Windows 上找 venv 的 bin/python | Windows venv 可执行文件在 Scripts\python.exe | No such file or directory |
| 10 | 把「无 Skill 调用记录」的任务也算进技能频次排名 | 统计排名时必须排除,仅作为数据完整性标记 | 会凭空多出一个「无记录」的高频技能,污染 Top10 |
| 11 | 遇到文件缺失就让整个脚本崩溃 | 用 safe_extract() 包装每个提取步骤,缺哪个来源就跳过哪个来源 | 一个目录缺失导致整份报告出不来 |
| 12 | 把 HTML 图表库的 CDN 换成「更稳定」的本地文件 | 保持 Chart.js CDN 默认,离线时表格和 KPI 仍能显示 | 本地文件路径因环境而异,反而增加失败率 |
| 13 | 引用 session_usage.total_credits 字段,或把 used 当积分求和 | 积分只认 credit_json(JSON 字典,各模型积分求和);used 是内部用量单位(类似 tokens),不是积分;credit_json 为空的会话(进行中/未结算)计 0 属正常 | 引用不存在的字段会直接报错;误把 used 求和会让总积分虚高数十万~数百万倍(朋友实测:504385 vs 真实 660.52) |
| 14 | 把 jsonl 每行 usage 直接相加当作会话 token | usage 是会话累计值(单调不减),逐行相加会重复计数 8~90 倍;正确做法:取每会话最后一个 assistant 响应行的 usage(末值) | 总量虚高一个数量级,结构比例全错 |
| 15 | 用「逐行 delta(当前累计−上一行)」求单轮 token | 部分会话响应间 usage 会回落(非单调),delta 全取正会高估;实测末值口径与 session_usage.used 最吻合(Σ末值/Σused ≈ 1.008) | Σ远超 used,交叉验证失败 |
| 16 | 把 auto(自动路由)当成一个「模型」并列统计 | auto 是路由层,不产生 token,token 归在 providerData.model 真实底座;必须双维度(用户入口 requestModelName × 真实底座 pd.model) | 路由层和底座混在一起,模型排名失真 |
| 17 | 只看 pd.model 忽略 requestModelName | 分不清「你手动选的」与「系统 auto 派发的」(GLM-5.2 几乎全是 auto 路由);用双维度交叉矩阵才看得清 | 误把路由派发量算成你的手动偏好 |
| 18 | 认为 hy3 免费就不计 token | 免费只免积分/钱(credit_json 为 Null),token 计量照常(used 有数);统计必须包含免费模型的 token | 漏掉占比近半的 hy3 用量 |
| 19 | 把 token 结构当「钱的结构」直接报金额 | 本地 credit_json 里模型是 32 位哈希,无哈希→名称映射,本地算不出各模型花多少钱;token 占比 × 单价才得钱,须向用户说明 | 报告被质疑「金额怎么来的」 |
X402 协议付费集成(Pay Skill 必读)
本 Skill 是付费 Skill,定价
1 元/次(amount_fen: 100)。所有调用必须走 X402 协议付费流程,未支付不允许产出报告。Agent 必须严格按以下 4 步执行。
0. 付费前置检查
调用本 Skill 前,必须检查当前 Agent 是否已安装 weixinpay 插件:
- 已安装 weixinpay:可继续走 X402 付费流程
- 未安装 weixinpay:直接回复用户「当前 Agent 暂不支持微信支付付费能力,本 Skill 为付费 Skill(1 元/次),请先在连接器管理中安装 weixinpay 插件后重试」,立即终止流程,不要降级为免费生成
1. 第一步:请求服务(首次调用必返 402)
向本 Skill 的后端接口发起 POST 请求:
POST https://<your-backend-host>/workbuddy-usage-report/invoke
Content-Type: application/json
{ "query": "<用户原话,如:统计我使用 WorkBuddy 的情况>" }
2. 第二步:处理 402 响应(保存两个关键值)
后端首次调用一定返回 HTTP 402 Payment Required,响应体结构:
{
"code": "PAYMENT_REQUIRED",
"message": "需要支付后才能获取报告",
"WeixinPay": {
"WeixinPay-Required": "payment_code_xxx",
"prompt": "本次使用微信支付,请将 WeixinPay-Required 的值作为 paymentCode 交给 weixinpay_pay,以向用户申请支付授权。"
},
"out_trade_no": "WB402_20260827073555000xxxxxxxxxxxx",
"amount": "1.00",
"currency": "CNY"
}
同时响应 Header 中也带:
WeixinPay-Required: payment_code_xxx(支付码)X-Out-Trade-No: WB402_20260827xxxxxxxxxxxxxxxx(订单号)
Agent 必须把这两个值都保存到本轮上下文,后续第三、四步要复用,任何一项丢失都拿不到付费内容。
3. 第三步:发起支付
将上一步拿到的 WeixinPay-Required 值作为 paymentCode 参数,调用 weixinpay_pay 工具:
weixinpay_pay(paymentCode="payment_code_xxx")
工具内部会向用户弹出微信支付确认页,用户完成支付后回调确认。
4. 第四步:支付成功后重新请求(⚠️ 必须执行,Agent 最容易遗漏)
⚠️ 这一步是 X402 协议的关键,不带
X-Out-Trade-No重试永远拿不到付费内容!
用户支付成功后,必须用与第一步完全相同的 body,再请求一次同一个接口,并把第二步保存的两个值放进 Header:
POST https://<your-backend-host>/workbuddy-usage-report/invoke
Content-Type: application/json
WeixinPay-Required: payment_code_xxx
X-Out-Trade-No: WB402_20260827xxxxxxxxxxxxxxxx
{ "query": "<与第一步完全相同的查询内容>" }
关键约束:
- body 必须与第一步完全一致(包括 query 字符串)
- Header
X-Out-Trade-No必须带上,缺它后端会判为新订单再次返回 402 - Header
WeixinPay-Required必须带上且与首次 402 收到的值完全一致:后端会做交叉校验,不匹配返回403 PAYMENT_CODE_MISMATCH
后端收到这个请求后会查单验证支付成功(trade_state=SUCCESS),然后返回 200 + 报告的调用指令(通常是「请执行 scripts/generate_workbuddy_report.py」或直接返回报告内容)。
订单与幂等
out_trade_no长度 ≤ 32 位,后端约定格式:WB402_+ 14 位时间戳(YYYYMMDDHHmmss) + 12 位随机串 = 32 位- 同一
out_trade_no只履约一次,重复请求后端返回缓存结果(不再二次返 402,也不再二次消费脚本),保证幂等 - 后端必须用持久化(SQLite/Redis/数据库事务)落地订单状态,仅靠内存 map 在重启后会丢单
异常处理
| 异常 | 现象 | 处理 |
|---|---|---|
| payment_code 过期 | 第三步支付时提示「订单已过期」 | 重新从第一步发起(最长有效期 15 分钟) |
| 第四步仍返 402 | 没把 X-Out-Trade-No 放进 Header | 重新发起第四步,只补 Header,body 不变 |
| 第四步返 403 PAYMENT_CODE_MISMATCH | WeixinPay-Required 值与首次 402 不一致(丢失或篡改) | 从第一步重新发起,保存新的 payment_code 与订单号 |
| 首次返 500 PREORDER_FAILED + skillhub_key_diag | SKILLHUB_PRIVATE_KEY 粘贴格式不对(截断/被当单行/头尾引号残留)| 看响应中 skillhub_key_diag.head 是否含 -----BEGIN PRIVATE KEY-----、newlines 是否 ≥ 1;若全无换行说明控制台把它拼成单行了,按后端 README「PEM 粘贴建议」用 awk 'BEGIN{ORS="\\n"}{print}' 转一次 |
| 签名失败(后端日志) | 微信支付 V3 查单返回 401 | 后端排查:① 微信支付 API 证书是否过期 ② 签名串是否 5 行且每行以 \n 结尾(最后一行也要保留换行) ③ Base64 编码用标准编码(非 URL-safe、不带换行) |
| 用户取消支付 | 第四步查单时 trade_state=NOTPAY | 后端返 402 + 提示「支付未完成」;Agent 重新走第一步 |
| Agent 漏装 weixinpay | 第 0 步检查失败 | 直接告知用户装插件,不要降级跑免费脚本 |
部署与计费密钥
本 Skill 配套 backend/ 目录提供一份最小可运行的 Flask 后端,实现完整的 402 → 支付 → 查单 → 200 流程。商户需要准备:
- SkillHub 开发者密钥(RSA 2048 密钥对):在 SkillHub 商户后台「开发者密钥」页面生成。仅用于 X402 AI 预下单接口签名,不用于微信支付标准接口。同一商户最多保留 3 组。
- 微信支付商户号配置(AppID、API 证书、序列号):用于 Native 下单与查单,算法为
WECHATPAY2-SHA256-RSA2048。 - HTTPS 域名:用于暴露后端
POST /workbuddy-usage-report/invoke,并接收微信支付notify_url回调(生产环境必须实现回调,仅靠查单轮询不可靠)。 - 环境变量配置:参见
backend/README.md,所有密钥通过环境变量注入,不进仓库。
改造检查覆盖关键词
本章节已涵盖改造检查要求的全部 X402 关键词:weixinpay、weixinpay_pay、paymentCode、WeixinPay-Required、X-Out-Trade-No、402 响应处理、第四步支付后必带订单号重试、out_trade_no ≤ 32 位、订单幂等、pub_key_id、签名串 5 行 + 标准 Base64、notify_url 回调。
数据地图(核心参考)
| 数据项 | 真实来源 | 路径 | 关键细节 |
|---|---|---|---|
| 任务名 / 时间 / 积分 / 专家·专家团 | SQLite 数据库 | ~/.workbuddy/workbuddy.db | 表:sessions + session_usage |
| 产物文件名(精确匹配) | 产物索引 JSON | ~/.workbuddy/artifact-index/<sessionId>.json | 逐任务记录,不含推断 |
| 技能调用名(真实 Skill 名) | 会话转录文本 | ~/.workbuddy/projects/<slug>/<sessionId>.jsonl | 双重序列化字符串,需正则提取 |
| ❌ 不用于归因 | 全局聚合日志 | usage-log.json / traces/ / audit-log/ | 仅汇总,无任务级明细 |
数据提取技术细节
1. 从 workbuddy.db 提取任务基础信息
import sqlite3, os
from datetime import datetime
DB_PATH = os.path.expandvars(r'$USERPROFILE/.workbuddy/workbuddy.db')
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
rows = conn.execute('''
SELECT s.id, s.title, s.custom_title, s.created_at, s.expert_id, su.credit_json
FROM sessions s
LEFT JOIN session_usage su ON su.session_id = s.id
WHERE (s.deleted_at IS NULL OR s.deleted_at = 0)
ORDER BY s.created_at
''').fetchall()
def parse_credits(credit_json_text):
"""解析真实消耗积分。
credit_json 形如 {"model_id_xxx": 346.15, ...},各值之和才是真实积分。
⚠️ used 字段是内部用量单位(类似 tokens),绝非积分,切勿对其求和!
credit_json 为空的会话(进行中/未结算)计 0,属正常。"""
if not credit_json_text:
return 0.0
try:
obj = json.loads(credit_json_text)
if isinstance(obj, dict):
return sum(float(v) for v in obj.values())
if isinstance(obj, (int, float)):
return float(obj)
except Exception:
pass
return 0.0
for r in rows:
ts = datetime.fromtimestamp(r['created_at'] / 1000) # ⚠️ 毫秒戳需除以1000
expert = r['expert_id'] or ''
is_team = expert.endswith('Team') # Team 后缀 → 归属专家团
name = r['custom_title'] or r['title'] or '(未命名任务)' # sessions 表无 name 列,任务名在 title/custom_title
credits = parse_credits(r['credit_json']) # ✅ 真实积分,非 used
注意:created_at 是毫秒级 Unix 时间戳,转 Python datetime 必须除以 1000。
2. 从 artifact-index 提取产物文件
import json, glob, os, urllib.parse
ART_DIR = os.path.expandvars(r'$USERPROFILE/.workbuddy/artifact-index')
artifact_file = os.path.join(ART_DIR, session_id + '.json')
if os.path.exists(artifact_file):
data = json.load(open(artifact_file, encoding='utf-8'))
products = []
for a in data.get('artifacts', []):
if a.get('type') not in ('media', 'file-changes'):
continue
uri = a.get('uri', '')
# 解码 file:/// 或 file-changes:// 协议前缀
if uri.startswith('file:///'):
path = urllib.parse.unquote(uri[8:])
elif uri.startswith('file-changes://'):
path = urllib.parse.unquote(uri[len('file-changes://'):])
else:
continue
# 排除 .workbuddy 内部文件(如构建脚本、临时文件)
if '.workbuddy' in path.lower():
continue
name = a.get('name') or a.get('title') or os.path.basename(path)
products.append(name)
过滤规则:只保留 type=media 或 type=file-changes 的条目;排除路径含 .workbuddy 的内部产物。
3. 从会话转录提取技能调用(最容易踩坑)
技能调用不在结构化字段里,而是藏在转录原始文本中,以双重序列化形式存在:
"name":"Skill","arguments":"{\"skill\": \"expert-manager\"}"
import re, glob, os
PROJ_DIR = os.path.expandvars(r'$USERPROFILE/.workbuddy/projects')
# 定位该 session 的 jsonl 文件
matches = glob.glob(os.path.join(PROJ_DIR, '**', session_id + '.jsonl'), recursive=True)
if not matches:
skills = [] # 该 session 无转录文件 → 无技能记录
else:
raw = open(matches[0], encoding='utf-8', errors='ignore').read()
# 锚定 "name":"Skill" 工具调用,提取双重序列化的 skill 名
pat = re.compile(
r'"name"\s*:\s*"Skill"[\s\S]{0,500}?'
r'\\"skill\\":\s*\\"([A-Za-z0-9_\-]+)\\"'
)
skills = sorted(set(m.group(1) for m in pat.finditer(raw)))
# 同时抓手动挂载块中的技能名
attached = re.compile(r'<manually_attached_skills>(.*?)</manually_attached_skills>', re.S)
for m in attached.finditer(raw):
sub = re.findall(r'(?:^|\n)\s*name:\s*([A-Za-z0-9_\-]+)', m.group(1))
skills.extend(sub)
# 过滤误报噪声
STOP = {'x', 'xx', 'xxx', 'X', 'skill', 'skills', 'Skill'}
skills = [s for s in set(skills) if s not in STOP]
为什么这么复杂? 转录文件是 .jsonl 格式,每行是一个 JSON 对象。AI 调用 Skill 工具时,参数本身是 JSON 字符串,序列化进外层 JSON 后变成双重转义。必须用 \\"skill\\" 才能匹配到。
执行步骤(Agent 照此执行)
- 检测环境:确认
workbuddy.db存在、Python venv 可用、openpyxl 已装(缺则自动安装) - 提取任务基础信息:从 DB 读 sessions + session_usage(含异常处理:0 条记录时终止并提示)
- 提取产物:遍历 artifact-index(缺目录时跳过,单文件缺失时标记「无」)
- 提取技能:扫描 projects 转录(缺目录时跳过,用正则提取 + stoplist 过滤)
- 组装数据:合并三源数据,补全中文名称映射
- 生成 Excel:写入台账 4 个 sheet + 6 张图表(文件被锁时自动加后缀重试)
- 生成 HTML:同一份聚合数据渲染交互看板
- 交付与说明:用
present_files同时交付 xlsx + html;告知用户数据概况(总数/积分/Top3 技能/峰值月份);如有异常(如部分任务缺产物或缺技能)一并说明
异常处理与容错机制
场景清单与处理策略
| # | 异常场景 | 检测方式 | 处理策略 | 用户提示 |
|---|---|---|---|---|
| 1 | workbuddy.db 不存在 | os.path.exists(DB_PATH) 为 False | 终止并提示 | ⚠️ 未找到 WorkBuddy 数据库。请确认已安装 WorkBuddy 并至少完成过一次对话。 |
| 2 | 数据库查询返回 0 条任务 | len(rows) == 0 | 终止并提示 | ⚠️ 数据库中没有找到任何任务记录。可能原因:(a) 所有会话已被删除 (b) 数据库路径不正确 |
| 3 | artifact-index 目录不存在 | os.path.exists(ART_DIR) 为 False | 跳过产物提取,产物列全填「无记录」 | ⚠️ 产物索引目录未找到,产物列将显示为空。不影响其他数据。 |
| 4 | 单个 session 无 artifact 文件 | os.path.exists(artifact_file) 为 False | 该任务产物记为「无」 | (静默处理,不逐一提示) |
| 5 | projects 目录不存在或为空 | glob.glob(...) 返回空列表 | 技能列全填「无 Skill 调用记录」 | ⚠️ 未找到会话转录文件,技能列将显示为空。可能需要检查 projects 目录权限。 |
| 6 | 单个 session 无 jsonl 文件 | matches 列表为空 | 该任务技能记为「—(无记录)」 | (静默处理) |
| 7 | Excel 文件被占用(预览锁) | 写入时抛 PermissionError | 自动另存为 _v2 / _v3(递增后缀)重试 | 📌 原 xlsx 被预览占用,已自动保存为新文件名 {原文名}_v2.xlsx |
| 8 | openpyxl 未安装 | ImportError | 自动在隔离 venv 中安装 | 📌 正在安装依赖包 openpyxl,请稍候… |
| 9 | 时间戳异常(0 或负数) | ts <= 0 或转换报错 | 记为「时间未知」,不中断 | (静默处理,该行时间列标灰) |
| 10 | 技能名为空或纯噪声 | 提取结果在 STOP 列表中 | 已由正则 stoplist 过滤 | (静默处理) |
容错代码模板
import traceback
def safe_extract(func, default, label=""):
"""通用安全提取包装器:出错时返回默认值并记录"""
try:
return func()
except Exception as e:
print(f"[WARN] {label} 失败: {e}")
return default
# 示例:安全读取 artifact
def get_products(session_id):
def _inner():
fp = os.path.join(ART_DIR, session_id + '.json')
if not os.path.exists(fp):
return []
# ... 正常提取逻辑 ...
return products
return safe_extract(_inner, [], f"产物提取-{session_id}")
# 示例:安全写入 Excel(自动处理文件锁定)
def safe_write_xlsx(output_path, writer_func, max_retries=3):
for i in range(max_retries + 1):
try:
base, ext = os.path.splitext(output_path)
path = output_path if i == 0 else f"{base}_v{i}{ext}"
writer_func(path)
return path
except PermissionError:
if i < max_retries:
continue
raise
运行稳定性保障
- 隔离环境:所有 Python 脚本运行在
~/.workbuddy/binaries/python/envs/default/隔离 venv 中,不污染系统环境 - Windows 路径注意:venv 可执行文件在
Scripts\下(非 Linux 的bin/)。正确路径:C:\Users\<用户>\.workbuddy\binaries\python\envs\default\Scripts\python.exe C:\Users\<用户>\.workbuddy\binaries\python\envs\default\Scripts\pip.exe - 编码安全:所有文件读写显式指定
encoding='utf-8', errors='ignore',避免 GBK/UTF-8 冲突
输出规格
Excel 台账(WorkBuddy使用报告.xlsx)
| Sheet | 内容 | 列/元素 | |---|---|---| | 任务统计 | 主数据表 | 序号 / 任务名称 / 时间(YYYY-MM-DD HH:MM, 倒序) / 消耗积分 / 技能(英文·真实) / 技能(中文) / 专家(英文) / 专家(中文) / 专家团(英文) / 专家团(中文) / 产物1 / 产物2 / … / 产物N | | 模型Token占比 | 大模型拆解 | 用户入口维度表 + 真实底座维度表 + 交叉矩阵 + token 全局结构表 | | 数据说明 | 口径文档 | 各字段定义、数据来源、计算公式 | | 名称对照 | 映射表 | skill_id ↔ 中文名 / expert_id ↔ 中文名 / team_id ↔ 中文名 | | 统计分析 | 图表区 | 6 张 openpyxl 原生图表(见下) |
统计分析 6 张图表:
| 图表 | 类型 | X轴 | Y轴 | 用途 | |---|---|---|---|---| | 技能偏好 Top10 | 柱状图 | 技能名(中文) | 调用次数 | 看你最常用哪些技能 | | 专家偏好 Top10 | 柱状图 | 专家名(中文) | 被召唤次数 | 看你最爱找哪位专家 | | 专家团偏好 | 柱状图 | 专家团名(中文) | 被召唤次数 | 看哪个团队帮你最多 | | 月度工作量 | 柱状图 | 年-月 | 任务数量 | 看月度节奏 | | 周度工作量 | 折线图 | ISO 周(周一起点) | 任务数量 | 看周间波动 | | 日度工作量 | 柱状图 | 日期 | 任务数量 | 看每日分布、找高峰日 |
HTML 看板(WorkBuddy使用报告.html)
三合一单文件,顶部导航切换三个 Tab:
- 任务概览:KPI 卡片(任务数 / 积分 / 技能种数 / 产物数 / 峰值月份)+ 技能 Top10 + 月度/周度/日度图表
- Token 结构:输入/输出/缓存命中占比 + 「大模型涨价影响测算器」(缓存 0.1× 计费的抵消效应)
- 模型拆解:用户入口维度表 + 真实底座维度表 + 入口×底座交叉矩阵(auto 路由层不产生 token 在此坐实)
v5.1.0 起三个独立 HTML(任务/Token/模型)已并入此单文件,新文件名
WorkBuddy使用报告.html。
数据清洗规则
| 规则 | 说明 |
|---|---|
| 无技能任务标记 | 技能列填 —(无Skill工具调用记录),统计排名时排除 |
| 技能变体 | 如 agent-mail 可能被记为 agently-mail 等 → 不擅自合并,向用户标注待确认 |
| 多技能任务 | 每个技能分别计数(占比分母=任务总数,多技能任务会导致总和>100%,属正常现象) |
| 积分为空 | 部分 session 可能无 usage 记录 → 积分列填 0,并在数据说明中注明 |
常见问题(FAQ)
Q:报告里的数据准确吗?会不会有推断成分?
A:完全准确,零推断。三项核心数据各有明确来源:任务/专家来自 SQLite 数据库结构化查询;积分来自 session_usage.credit_json(JSON 字典,各模型积分求和),绝不引用不存在的 total_credits 字段,也绝不把 used 内部用量当积分;产物来自 artifact-index 逐文件匹配;技能来自会话转录正则提取。每一列都能追溯到原始文件。
Q:为什么有些任务的技能列显示「无记录」?
A:两种可能:(1) 该任务确实没有调用任何 Skill 工具(如纯对话类任务);(2) 该 session 的转录文件(.jsonl)未被持久化到磁盘。这两种情况都会如实标注,不会猜测填充。
Q:产物列为什么有些任务是空的?
A:只有通过 present_files 或类似机制正式产出的文件才会出现在 artifact-index 中。纯对话、纯查询类任务没有实体产物,属于正常现象。
Q:生成的 Excel 打不开 / 显示乱码?
A:确保用较新版本的 WPS 或 Microsoft Excel 打开(openpyxl 生成的 .xlsx 需要 Excel 2007+ 支持)。如遇文件被锁无法打开,关闭预览窗口即可。
Q:HTML 看板打开是空白? A:HTML 使用 Chart.js CDN 加载图表库,需要联网。首次打开可能需要 1-2 秒加载。离线环境下图表区域会留白,但 KPI 卡片和数据表格仍可正常显示。
Q:能不能只生成 Excel 或只生成 HTML? A:可以。默认两者都生成,但用户可以明确要求只出其中一种。两种格式互补:Excel 适合二次编辑和存档,HTML 适合演示和快速浏览趋势。
Q:数据太多导致 Excel 很大怎么办? A:如果任务数超过 200,产物列可能会很宽。脚本会自动将产物超过 6 个的任务截断,多余产物移入「备注」列。如需完整产物列表,请查看 HTML 版的任务明细表。
Q:中文名称映射哪里来的? A:内置了常见 skill/expert/team 的中英对照表。如果遇到未收录的新技能/新专家,会直接显示英文原名,并在交付时提醒用户「以下 ID 缺少中文映射,欢迎补充」。
Q:运行报错 ModuleNotFoundError: No module named 'openpyxl' 怎么办?
A:Agent 应在隔离 venv 中自动安装:Scripts\pip.exe install openpyxl。如果仍失败,检查 venv 是否创建成功:python -m venv 在 Windows 上需要管理员权限写 Program Files 时改用用户目录。
Q:token 结构里的「缓存命中率」是什么,为什么重要?
A:缓存命中(cache_read_input_tokens)指命中 prompt cache 的输入 token,按官方约 0.1× 计费。命中率 = 缓存命中 / 总输入。它重要是因为:缓存命中占比越高,大模型涨价对你的实际冲击越小——它是你成本里的「缓冲垫」。
Q:auto 自动路由算不算一个模型?它到底有没有消耗 token?
A:不算。auto 是路由层,本身不产生 token,它把任务派发给真实底座(混元/GLM/DeepSeek/Kimi)。日志里 requestModelName=Auto 的响应,其 providerData.model 才是真正跑的模型。所以本技能拆成「用户入口 × 真实底座」双维度,auto 单独作为入口,不列为模型。
Q:这能直接算出我各模型花了多少钱吗?
A:本地算不出金额。credit_json 里模型是 32 位哈希 ID,本地没有「哈希→模型名→单价」映射表。本技能只报 token 结构和占比;要算钱,需你拿各模型公开单价 × 本表占比。报告会明确标注「这是 token 结构,不是钱的结构」。
边界与限制
本技能并非万能,以下情况会如实告知用户,不会伪造数据:
| 场景 | 结果 |
|---|---|
| WorkBuddy 未安装或刚安装,没有任何对话记录 | 提示「未找到任务记录」并终止 |
| 用户手动删除了某些会话 | 已删除会话不会出现在报告中(这是正常的) |
| projects/ 目录被清理或权限不足 | 技能列会显示为空或「无记录」 |
| 任务没有调用 Skill 工具 | 技能列显示「—(无Skill工具调用记录)」 |
| 任务没有产出文件 | 产物列显示为空 |
| 运行在离线环境 | HTML 看板中的图表无法渲染,但 Excel、表格、KPI 卡片仍可用 |
| 中文映射表未覆盖新 skill/expert | 显示英文原名,并提示用户补充映射 |
| 时间戳为 0 或异常 | 该行时间显示为「时间未知」,不影响其他行 |
偏好分析计算口径
| 指标 | 公式 | 备注 |
|---|---|---|
| 技能频次 | 每个技能出现的任务数之和 | 多技能任务每个技能各+1,占比分母=任务总数(可超100%) |
| 专家频次 | 该 expert_id 出现的任务数 | 每任务最多1个专家 |
| 专家团频次 | 含 Team 后缀的 expert_id 出现任务数 | 每任务最多1个团队 |
| 月度工作量 | 按 created_at/1000 取年-月分组计数 | 毫秒戳! |
| 周度工作量 | 按 ISO 周一为起点分组 | datetime.isocalendar()[1] |
| 日度工作量 | 按日期(YYYY-MM-DD) 分组 | 可发现工作密集日和休息日 |
| 总消耗积分 | 各会话 credit_json 内模型积分求和(非 used) | credit_json 为空的会话计 0(未结算/进行中属正常);绝不引用不存在的 total_credits |
第七章 Token 用量结构与大模型拆解分析(v5.0 新增)
触发:「token 用量结构」「各模型消耗多少 token」「缓存命中率」「大模型涨价对我涨了多少」「token 拆解」。 本节方法已用真实数据交叉验证,口径见下方「口径校准」,全部零推断。 配套脚本:
scripts/token_structure_report.py(全局 token 结构 + 涨价测算器)、scripts/model_token_report_v2.py(各模型双维度拆解 + 交叉矩阵)。
7.1 为什么这套分析有价值(先讲结论)
绝大多数人只知道「这个月花了多少钱」,不知道钱的结构。本技能把 token 拆成三类:
- 全价输入 token(
input_tokens−cache_read_input_tokens):按全价计费,是涨价主战场 - 缓存命中输入 token(
cache_read_input_tokens):按官方约 0.1× 计费,通常不在涨价范围 - 输出 token(
output_tokens):按输出价计费,量通常很小
关键洞察:缓存命中占比越高,大模型涨价对你的实际冲击越小。实测缓存命中率约 80% 时,即便全价输入/输出都翻倍(×2),你的实际总成本只涨约 20%;若命中率为 0(无缓存),同样涨幅直接涨 100%。所以缓存命中是你对抗涨价的缓冲垫。
7.2 数据来源(与任务报告不同)
| 数据项 | 真实来源 | 路径 | 关键字段 |
|---|---|---|---|
| 每轮 token 明细 | 会话转录 JSONL | ~/.workbuddy/projects/<slug>/<sessionId>.jsonl | message.usage.{input_tokens,output_tokens,total_tokens,cache_read_input_tokens}(Anthropic 格式) |
| 真实底座模型 | 同上转录 | 同上 | providerData.model(token 实际产生处) |
| 用户入口 | 同上转录 | 同上 | providerData.requestModelName / requestModelId(你选了什么:Hy3 / Auto / 显式 GLM / 未记录) |
| 计费口径校验 | SQLite | ~/.workbuddy/workbuddy.db → session_usage.used | 每会话总 token(权威外部校验) |
⚠️ 注意:
session_usage表只有used(总 token),没有 input/output/cache 拆分。拆分只能从转录 jsonl 的usage来。转录比 API 后台更细(精确到每轮每会话),无需跑后台。
7.3 口径校准(踩过的坑,必须照做)
- usage 是「会话累计值」,不是单轮增量。每行 assistant 响应里的
usage是该会话到当前为止的累计;把每行相加会重复计数 8~90 倍。✅ 取每会话最后一个 assistant 响应行的 usage(末值) 作为该会话总量。 - 不要用「逐行 delta」。实测部分会话响应间 usage 会回落(非单调),delta 全取正会高估。✅ 末值口径与
session_usage.used最吻合:Σ末值 / Σused ≈ 1.008(计费会话内 93% 在 ±2% 内)。 - auto 是路由层,不产生 token。日志铁证:
requestModelName=Auto的响应行,其providerData.model实际是 glm-5.2 / deepseek / kimi 等真实底座。✅ 必须拆成双维度(见 7.4),不能把 auto 当成一个模型并列。 - hy3 免费 ≠ 不计 token。免费只免积分/钱(
credit_json为 Null),token 计量照常(used有数)。✅ 统计必须包含免费模型。 - token 结构 ≠ 钱的结构。
credit_json里模型是 32 位哈希,本地无哈希→名称映射,本地算不出「各模型花多少钱」。✅ 只报 token 占比,金额需用户拿单价×比例自算,并明确说明。 - 未计费会话:约有 5~7 个工具/图像类会话
used=0但 token 真实产生,归入总量并在口径说明中标注,差额属正常。
7.4 双维度拆解方法(解开 auto 黑盒)
每个会话取末值行的 (用户入口, 真实底座, usage) 三元组,归因整个会话:
- 维度1 · 用户入口(
requestModelName归一化):混元 Hy3 / Auto 自动路由 / 显式 GLM-5.2 / 未记录(工具·图像类) - 维度2 · 真实底座(
providerData.model归一化):混元 Hy3 / GLM-5.2 / GLM-5.2-A / GLM-5.2-X / DeepSeek-V4-Pro / Kimi-K2.7 / GLM-5.1 / Auto路由层(未解析底座)
Auto路由层(未解析底座)= 日志未下钻出具体底座的 auto 路由响应(黑盒,约占总 token 的 22%,无法再细分)。未记录入口多为工具/图像类响应(无 requestModelName,不走 prompt 缓存)。
核心发现:GLM-5.2 那部分 token 几乎全是 auto 路由派发的(1852 行 requestModelName 全是 Auto),你手动选 GLM 的只有 glm-5.2-x 那 22 万。旧表把 auto 和 glm-5.2 并列当「各模型」是错的,双维度才正确。
7.5 交叉验证(必做,证明数据自洽)
- 内部闭合:Σ各用户入口 = Σ各真实底座 = Σ各会话末值 usage(三者应分毫一致)
- 矩阵一致:入口×底座交叉矩阵,每行之和=该入口总量,每列之和=该底座总量,行和与列和相等
- 外部对账:在「有
used计费记录」的会话内,Σ末值 / Σused ≈ 1.0(实测 1.007),证明转录口径与外部权威一致 - 差额解释:全量 Σ末值 比 Σused 多约 20%,来自未计费会话(token 真实产生但
used=0),属口径差异非错误
若交叉验证不闭合(偏差 > 1%),立即回头查口径(几乎必是踩了 7.3 的坑 #1/#2)。
7.6 大模型涨价影响测算器(逻辑)
基于本地真实占比,给交互式测算器。设三类 token 的「涨价后÷涨价前单价倍数」为 kIn / kOut / kCache(默认 1.0):
newCost = 全价输入 × kIn + 缓存命中 × kCache + 输出 × kOut
总成本涨幅倍数 = newCost / 总token成本基准
因缓存命中占比高(约 80%)且通常 kCache≈1,它天然稀释涨价冲击。脚本在 HTML 里实时算,并对比「若无缓存(命中率0)同样涨幅下的成本涨幅」,凸显缓存价值。
7.7 运行方式
# 全局 token 结构 + 涨价测算器
C:\Users\<用户>\.workbuddy\binaries\python\envs\default\Scripts\python.exe scripts/token_structure_report.py
# 各模型双维度拆解 + 交叉矩阵
C:\Users\<用户>\.workbuddy\binaries\python\envs\default\Scripts\python.exe scripts/model_token_report_v2.py
两脚本仅依赖 Python 标准库(json/re/sqlite3/os),无需 openpyxl,输出 HTML 到当前目录。
附录:完整可运行 Python 脚本
以下脚本可直接保存为
generate_workbuddy_report.py并在隔离 venv 中运行。 运行前确保已安装:pip install openpyxl
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
WorkBuddy 使用报告生成器 v3.0
数据来源:workbuddy.db + artifact-index + projects 转录
输出:Excel 台账 + HTML 看板
"""
import json
import os
import re
import sqlite3
import urllib.parse
from collections import Counter, defaultdict
from datetime import datetime, timedelta
# 尝试导入 openpyxl;若缺失则给出清晰提示
try:
from openpyxl import Workbook
from openpyxl.chart import BarChart, LineChart, Reference
from openpyxl.utils import get_column_letter
except ImportError:
raise SystemExit(
"缺少 openpyxl。请在隔离 venv 中安装:\n"
r"C:\Users\<用户>\.workbuddy\binaries\python\envs\default\Scripts\pip.exe install openpyxl"
)
# ---------------------------- 配置 ----------------------------
USERPROFILE = os.path.expandvars('$USERPROFILE')
WB_DIR = os.path.join(USERPROFILE, '.workbuddy')
DB_PATH = os.path.join(WB_DIR, 'workbuddy.db')
ART_DIR = os.path.join(WB_DIR, 'artifact-index')
PROJ_DIR = os.path.join(WB_DIR, 'projects')
OUTPUT_XLSX = 'WorkBuddy使用报告.xlsx'
OUTPUT_HTML = 'WorkBuddy使用报告.html'
# 常见名称中文映射(可扩展)
NAME_MAP = {
# skills
'ppt-master': 'PPT 大师',
'tencent-docs': '腾讯文档',
'expert-manager': '专家管理',
'agent-mail': '智能体邮箱',
'agently-mail': '智能体邮箱(疑似变体)',
'agentmail': '智能体邮箱(疑似变体)',
'book-to-skill': '书籍转技能',
'wechat-article-pro': '公众号文章专业版',
'market-researcher': '市场研究专家',
'workbuddy-usage-report': 'WorkBuddy 使用报告',
# experts
'SalesCoach': '销售教练',
'GEOoptimizer': 'GEO 优化师',
# teams
'GPTResearcherTeam': 'GPT 调研员团',
'SalesBattleTeam': '销售战训团',
'HumanizePptTeam': 'PPT 美化团',
'AiContentCreatorTeam': 'AI 内容创作团',
}
STOP_SKILLS = {'x', 'xx', 'xxx', 'X', 'skill', 'skills', 'Skill'}
NO_SKILL_LABEL = '—(无Skill工具调用记录)'
def safe_extract(func, default, label=""):
"""通用安全提取包装器"""
try:
return func()
except Exception as e:
print(f"[WARN] {label} 失败: {e}")
return default
def get_display_name(key):
return NAME_MAP.get(key, key)
def ms_to_dt(ts):
"""毫秒时间戳转 datetime"""
try:
if not ts or ts <= 0:
return None
return datetime.fromtimestamp(ts / 1000)
except Exception:
return None
def parse_credits(credit_json_text):
"""解析真实消耗积分。
credit_json 形如 {"model_id_xxx": 346.15, ...},各值之和才是真实积分。
⚠️ used 字段是内部用量单位(类似 tokens),绝非积分,切勿对其求和!
credit_json 为空的会话(进行中/未结算)计 0,属正常。
"""
if not credit_json_text:
return 0.0
try:
obj = json.loads(credit_json_text)
if isinstance(obj, dict):
return sum(float(v) for v in obj.values())
if isinstance(obj, (int, float)):
return float(obj)
except Exception:
pass
return 0.0
# ---------------------------- 1. 提取任务 ----------------------------
def load_tasks():
if not os.path.exists(DB_PATH):
raise FileNotFoundError(f"未找到 WorkBuddy 数据库: {DB_PATH}")
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
rows = conn.execute('''
SELECT s.id, s.title, s.custom_title, s.created_at, s.expert_id, su.credit_json
FROM sessions s
LEFT JOIN session_usage su ON su.session_id = s.id
WHERE (s.deleted_at IS NULL OR s.deleted_at = 0)
ORDER BY s.created_at
''').fetchall()
conn.close()
if not rows:
raise ValueError("数据库中没有任何任务记录")
tasks = []
for r in rows:
dt = ms_to_dt(r['created_at'])
expert = r['expert_id'] or ''
is_team = expert.endswith('Team')
tasks.append({
'id': r['id'],
'name': (r['custom_title'] or r['title'] or '(未命名任务)'),
'created_at': r['created_at'],
'dt': dt,
'credits': parse_credits(r['credit_json']), # ✅ credit_json 求和,非 used
'expert_raw': expert,
'expert': '' if is_team else expert,
'team': expert if is_team else '',
})
return tasks
# ---------------------------- 2. 提取产物 ----------------------------
def get_products(session_id):
def _inner():
fp = os.path.join(ART_DIR, session_id + '.json')
if not os.path.exists(fp):
return []
data = json.load(open(fp, encoding='utf-8'))
products = []
for a in data.get('artifacts', []):
if a.get('type') not in ('media', 'file-changes'):
continue
uri = a.get('uri', '')
if uri.startswith('file:///'):
path = urllib.parse.unquote(uri[8:])
elif uri.startswith('file-changes://'):
path = urllib.parse.unquote(uri[len('file-changes://'):])
else:
continue
# 避坑 #3:排除 .workbuddy 内部产物
if '.workbuddy' in path.lower():
continue
name = a.get('name') or a.get('title') or os.path.basename(path)
products.append(name)
return products
return safe_extract(_inner, [], f"产物提取-{session_id}")
# ---------------------------- 3. 提取技能 ----------------------------
def get_skills(session_id):
def _inner():
matches = glob_files(PROJ_DIR, session_id + '.jsonl')
if not matches:
return []
raw = open(matches[0], encoding='utf-8', errors='ignore').read()
# 锚定 "name":"Skill",提取双重序列化的 skill 名
pat = re.compile(
r'"name"\s*:\s*"Skill"[\s\S]{0,500}?'
r'\\"skill\\":\s*\\"([A-Za-z0-9_\-]+)\\"'
)
skills = set(m.group(1) for m in pat.finditer(raw))
# 手动挂载块
attached = re.compile(r'<manually_attached_skills>(.*?)</manually_attached_skills>', re.S)
for m in attached.finditer(raw):
skills.update(re.findall(r'(?:^|\n)\s*name:\s*([A-Za-z0-9_\-]+)', m.group(1)))
# 过滤噪声
return sorted(s for s in skills if s not in STOP_SKILLS)
return safe_extract(_inner, [], f"技能提取-{session_id}")
def glob_files(base, pattern):
if not os.path.exists(base):
return []
result = []
for root, _, files in os.walk(base):
for f in files:
if f == pattern or f.endswith(pattern):
result.append(os.path.join(root, f))
return result
# ---------------------------- 4. 生成 Excel ----------------------------
def generate_excel(tasks, output_path):
wb = Workbook()
# sheet1: 任务统计
ws = wb.active
ws.title = '任务统计'
headers = [
'序号', '任务名称', '时间', '消耗积分',
'技能(英文)', '技能(中文)',
'专家(英文)', '专家(中文)',
'专家团(英文)', '专家团(中文)',
'产物1', '产物2', '产物3', '产物4', '产物5', '产物6'
]
ws.append(headers)
# 先计算最大产物列数
max_products = max((len(t['products']) for t in tasks), default=0)
max_products = min(max_products, 6)
skill_counter = Counter()
expert_counter = Counter()
team_counter = Counter()
month_counter = Counter()
week_counter = Counter()
day_counter = Counter()
for idx, t in enumerate(tasks, 1):
# 技能显示
skill_en = ', '.join(t['skills']) if t['skills'] else NO_SKILL_LABEL
skill_zh = ', '.join(get_display_name(s) for s in t['skills']) if t['skills'] else NO_SKILL_LABEL
# 计数(避坑 #10:无技能任务不计入排名)
for s in t['skills']:
skill_counter[s] += 1
# 专家/团
if t['expert']:
expert_counter[t['expert']] += 1
if t['team']:
team_counter[t['team']] += 1
# 时间聚合
if t['dt']:
month_counter[t['dt'].strftime('%Y-%m')] += 1
week_counter[t['dt'].strftime('%Y-W%W')] += 1
day_counter[t['dt'].strftime('%Y-%m-%d')] += 1
row = [
idx,
t['name'],
t['dt'].strftime('%Y-%m-%d %H:%M') if t['dt'] else '时间未知',
t['credits'],
skill_en,
skill_zh,
t['expert'],
get_display_name(t['expert']) if t['expert'] else '',
t['team'],
get_display_name(t['team']) if t['team'] else '',
]
# 产物
for i in range(max_products):
row.append(t['products'][i] if i < len(t['products']) else '')
ws.append(row)
# sheet2: 数据说明
ws2 = wb.create_sheet('数据说明')
notes = [
['字段', '说明'],
['任务名称', '来自 workbuddy.db sessions.title(custom_title 优先,无则取 title)'],
['时间', 'sessions.created_at(毫秒戳 ÷1000 转 datetime)'],
['消耗积分', 'session_usage.credit_json 各模型积分求和(used 是内部用量,非积分)'],
['技能', '来自 projects/*/<sessionId>.jsonl 转录文本中正则提取,不含推断'],
['专家/专家团', 'sessions.expert_id;以 Team 结尾归专家团,其余归专家'],
['产物', '来自 artifact-index/<sessionId>.json 逐文件匹配'],
['无技能任务', '技能列显示「—(无Skill工具调用记录)」,统计排名时已排除'],
]
for r in notes:
ws2.append(r)
# sheet3: 名称对照
ws3 = wb.create_sheet('名称对照')
ws3.append(['类型', '英文 ID', '中文名称'])
for s in sorted(skill_counter):
ws3.append(['技能', s, get_display_name(s)])
for e in sorted(expert_counter):
ws3.append(['专家', e, get_display_name(e)])
for tm in sorted(team_counter):
ws3.append(['专家团', tm, get_display_name(tm)])
# sheet4: 统计分析 + 图表
ws4 = wb.create_sheet('统计分析')
# 这里仅示意:把各 Counter 写入,并创建图表
ws4.append(['技能', '次数'])
for s, c in skill_counter.most_common(10):
ws4.append([get_display_name(s), c])
ws4.append([])
ws4.append(['专家', '次数'])
for e, c in expert_counter.most_common(10):
ws4.append([get_display_name(e), c])
# 添加柱状图示例(技能 Top10)
chart = BarChart()
chart.type = "col"
chart.title = "技能偏好 Top10"
chart.y_axis.title = '调用次数'
chart.x_axis.title = '技能'
data = Reference(ws4, min_col=2, min_row=1, max_row=1 + len(skill_counter))
cats = Reference(ws4, min_col=1, min_row=2, max_row=1 + len(skill_counter))
chart.add_data(data, titles_from_data=True)
chart.set_categories(cats)
ws4.add_chart(chart, "E2")
wb.save(output_path)
return output_path
# ---------------------------- 5. 生成 HTML ----------------------------
def generate_html(tasks, output_path):
total_tasks = len(tasks)
total_credits = sum(t['credits'] for t in tasks)
all_skills = set()
all_experts = set()
all_products = 0
for t in tasks:
all_skills.update(t['skills'])
if t['expert']:
all_experts.add(t['expert'])
all_products += len(t['products'])
# 在 Python 里先算好技能 Top10,再转成 JSON 嵌入 HTML
skill_counter_html = Counter()
for t in tasks:
for s in t['skills']:
skill_counter_html[s] += 1
skill_top10 = json.dumps([
{'x': get_display_name(s), 'y': c}
for s, c in skill_counter_html.most_common(10)
], ensure_ascii=False)
html = f'''<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>WorkBuddy 任务统计看板</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 24px; background: #f5f6f8; }}
.kpi {{ display: flex; gap: 16px; margin-bottom: 24px; flex-wrap: wrap; }}
.kpi-card {{ background: #fff; border-radius: 8px; padding: 16px 24px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); min-width: 140px; }}
.kpi-num {{ font-size: 28px; font-weight: 700; color: #1677ff; }}
.kpi-label {{ color: #666; font-size: 13px; margin-top: 4px; }}
.grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); gap: 16px; }}
.card {{ background: #fff; border-radius: 8px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }}
.card h3 {{ margin: 0 0 12px; font-size: 15px; color: #333; }}
table {{ width: 100%; border-collapse: collapse; font-size: 13px; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }}
th, td {{ padding: 8px 12px; text-align: left; border-bottom: 1px solid #f0f0f0; }}
th {{ background: #fafafa; font-weight: 600; cursor: pointer; }}
tr:hover {{ background: #fafafa; }}
</style>
</head>
<body>
<h2>WorkBuddy 使用看板</h2>
<div class="kpi">
<div class="kpi-card"><div class="kpi-num">{total_tasks}</div><div class="kpi-label">总任务数</div></div>
<div class="kpi-card"><div class="kpi-num">{total_credits:,}</div><div class="kpi-label">总消耗积分</div></div>
<div class="kpi-card"><div class="kpi-num">{len(all_skills)}</div><div class="kpi-label">技能种数</div></div>
<div class="kpi-card"><div class="kpi-num">{len(all_experts)}</div><div class="kpi-label">专家数</div></div>
<div class="kpi-card"><div class="kpi-num">{all_products}</div><div class="kpi-label">产物文件数</div></div>
</div>
<div class="grid">
<div class="card"><h3>技能偏好 Top10</h3><canvas id="skillChart"></canvas></div>
<div class="card"><h3>月度工作量</h3><canvas id="monthChart"></canvas></div>
</div>
<script>
const skillData = {skill_top10};
new Chart(document.getElementById('skillChart'), {{type:'bar',data:{{labels:skillData.map(d=>d.x),datasets:[{{label:'调用次数',data:skillData.map(d=>d.y)}}]}},options:{{responsive:true}}}});
</script>
</body>
</html>'''
# 简化版 HTML:完整版应包含所有 6 张图表和任务表
with open(output_path, 'w', encoding='utf-8') as f:
f.write(html)
return output_path
# ---------------------------- 主流程 ----------------------------
def main():
print("[1/5] 提取任务基础信息...")
tasks = load_tasks()
print(f" 共 {len(tasks)} 个任务")
print("[2/5] 提取产物文件...")
for t in tasks:
t['products'] = get_products(t['id'])
print("[3/5] 提取技能调用...")
for t in tasks:
t['skills'] = get_skills(t['id'])
print("[4/5] 生成 Excel...")
xlsx_path = safe_write_xlsx(OUTPUT_XLSX, lambda p: generate_excel(tasks, p))
print(f" 已保存: {xlsx_path}")
print("[5/5] 生成 HTML...")
html_path = generate_html(tasks, OUTPUT_HTML)
print(f" 已保存: {html_path}")
# 输出关键摘要
skill_counter = Counter()
for t in tasks:
for s in t['skills']:
skill_counter[s] += 1
top3 = skill_counter.most_common(3)
print("\n关键摘要:")
print(f" - 总任务: {len(tasks)}")
print(f" - 总积分: {sum(t['credits'] for t in tasks):,}")
print(f" - Top3 技能: {', '.join(get_display_name(s) + f'({c})' for s, c in top3)}")
def safe_write_xlsx(output_path, writer_func, max_retries=3):
"""安全写入 Excel,遇文件锁定自动加后缀重试"""
for i in range(max_retries + 1):
try:
base, ext = os.path.splitext(output_path)
path = output_path if i == 0 else f"{base}_v{i}{ext}"
writer_func(path)
return path
except PermissionError:
if i < max_retries:
print(f" 文件被占用,尝试保存为 {base}_v{i+1}{ext}")
continue
raise
if __name__ == '__main__':
main()
运行方式
- 复制上方代码保存为
generate_workbuddy_report.py - 在隔离 venv 中运行:
C:\Users\<用户>\.workbuddy\binaries\python\envs\default\Scripts\python.exe generate_workbuddy_report.py - 当前目录会生成
WorkBuddy使用报告.xlsx和WorkBuddy使用报告.html(v5.1.0 起统一文件名;旧名WorkBuddy任务统计.*已废弃)
Token 与模型拆解脚本(scripts/ 目录)
v5.2.0 起,token_structure_report.py 与 model_token_report_v2.py 已重构为纯计算模块(仅暴露 compute_token_structure() / compute_model_breakdown()),被主脚本 generate_workbuddy_report.py 复用。这两个文件不再独立产出 HTML,避免与主交付物文件名冲突。
如下命令仅用于「单独调试 token/模型计算逻辑」时会重新生成旧三独立看板(调试用,正式交付请跑主脚本):
# 1) 全局 token 结构 + 大模型涨价影响测算器(调试用,旧文件名仅作调试产物)
C:\Users\<用户>\.workbuddy\binaries\python\envs\default\Scripts\python.exe scripts/token_structure_report.py
# → 生成 WorkBuddy_token结构看板.html(v5.2.0 起仅作调试产物)
# 2) 各模型双维度拆解(用户入口 × 真实底座)+ 交叉验证矩阵(调试用)
C:\Users\<用户>\.workbuddy\binaries\python\envs\default\Scripts\python.exe scripts/model_token_report_v2.py
# → 生成 WorkBuddy_模型token看板_v2.html(v5.2.0 起仅作调试产物)
两个脚本仅依赖 Python 标准库(json/re/sqlite3/os),无需 openpyxl;产物 HTML 输出到运行时的当前目录。口径与交叉验证逻辑见第七章,已实测校准。正式交付请始终运行主脚本 generate_workbuddy_report.py,产物为 WorkBuddy使用报告.xlsx 与 WorkBuddy使用报告.html。
反馈与贡献
本技能由作者持续维护,欢迎反馈使用问题或提出新需求,以便及时迭代升级。
- 微信公众号:AI重构咨询的李军师(在微信内搜索关注后留言,或于该号后台发送消息)
- 邮箱:Ai_lichanghong@163.com
反馈时请尽量附上:使用的 WorkBuddy 版本、遇到的现象或报错截图、期望的效果。作者收到后会尽快处理并发布新版。
文档版本:v5.0.0 | 最后更新:2026-08-25
微信扫一扫