Back to skills
extension
Category: Data & AnalyticsNo API key required

WorkBuddy 使用报告

把你在 WorkBuddy 上做的事盘成可下载的真实报告。 任务台账、技能与专家调用记录、token 消耗与模型拆解、缓存命中率、大模型涨价后实际多掏多少,一次算清。 数据全部来自你本机三份真实日志,不编不猜不凑数。输出一份 Excel 台账 + 一份 HTML 可视化看板,Excel 按时间倒序排好,直接发老板、做汇报都能用。 按次付费,1 元扫码走 X402 协议自动跑完,零订阅零月租。 适用:WorkBuddy 用户盘点 AI 干活、OPC 一人公司交账、大模型涨价后的成本测算、token 拆解与模型占比分析。

personAuthor: u_1ef778cbhubenterprise

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 以来都做过哪些任务」

技能执行

  1. 自动定位 ~/.workbuddy/workbuddy.db → 提取 50 条任务记录
  2. 遍历 artifact-index/ → 匹配 37 个任务的 202 个产物文件
  3. 扫描 projects/*/ 转录 → 正则提取 26 种真实技能调用
  4. 生成 Excel(含统计分析图表)+ HTML 看板
  5. 告知用户:「共 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 | 看到 agentmailagently-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 关键词:weixinpayweixinpay_paypaymentCodeWeixinPay-RequiredX-Out-Trade-No402 响应处理、第四步支付后必带订单号重试、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=mediatype=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 照此执行)

  1. 检测环境:确认 workbuddy.db 存在、Python venv 可用、openpyxl 已装(缺则自动安装)
  2. 提取任务基础信息:从 DB 读 sessions + session_usage(含异常处理:0 条记录时终止并提示)
  3. 提取产物:遍历 artifact-index(缺目录时跳过,单文件缺失时标记「无」)
  4. 提取技能:扫描 projects 转录(缺目录时跳过,用正则提取 + stoplist 过滤)
  5. 组装数据:合并三源数据,补全中文名称映射
  6. 生成 Excel:写入台账 4 个 sheet + 6 张图表(文件被锁时自动加后缀重试)
  7. 生成 HTML:同一份聚合数据渲染交互看板
  8. 交付与说明:用 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 拆成三类:

  • 全价输入 tokeninput_tokenscache_read_input_tokens):按全价计费,是涨价主战场
  • 缓存命中输入 tokencache_read_input_tokens):按官方约 0.1× 计费,通常不在涨价范围
  • 输出 tokenoutput_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.dbsession_usage.used | 每会话总 token(权威外部校验) |

⚠️ 注意:session_usage只有 used(总 token),没有 input/output/cache 拆分。拆分只能从转录 jsonl 的 usage 来。转录比 API 后台更细(精确到每轮每会话),无需跑后台。

7.3 口径校准(踩过的坑,必须照做)

  1. usage 是「会话累计值」,不是单轮增量。每行 assistant 响应里的 usage 是该会话到当前为止的累计;把每行相加会重复计数 8~90 倍。✅ 取每会话最后一个 assistant 响应行的 usage(末值) 作为该会话总量。
  2. 不要用「逐行 delta」。实测部分会话响应间 usage 会回落(非单调),delta 全取正会高估。✅ 末值口径与 session_usage.used 最吻合:Σ末值 / Σused ≈ 1.008(计费会话内 93% 在 ±2% 内)。
  3. auto 是路由层,不产生 token。日志铁证:requestModelName=Auto 的响应行,其 providerData.model 实际是 glm-5.2 / deepseek / kimi 等真实底座。✅ 必须拆成双维度(见 7.4),不能把 auto 当成一个模型并列。
  4. hy3 免费 ≠ 不计 token。免费只免积分/钱(credit_json 为 Null),token 计量照常(used 有数)。✅ 统计必须包含免费模型。
  5. token 结构 ≠ 钱的结构credit_json 里模型是 32 位哈希,本地无哈希→名称映射,本地算不出「各模型花多少钱」。✅ 只报 token 占比,金额需用户拿单价×比例自算,并明确说明。
  6. 未计费会话:约有 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()

运行方式

  1. 复制上方代码保存为 generate_workbuddy_report.py
  2. 在隔离 venv 中运行:
    C:\Users\<用户>\.workbuddy\binaries\python\envs\default\Scripts\python.exe generate_workbuddy_report.py
    
  3. 当前目录会生成 WorkBuddy使用报告.xlsxWorkBuddy使用报告.html(v5.1.0 起统一文件名;旧名 WorkBuddy任务统计.* 已废弃)

Token 与模型拆解脚本(scripts/ 目录)

v5.2.0 起,token_structure_report.pymodel_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使用报告.xlsxWorkBuddy使用报告.html


反馈与贡献

本技能由作者持续维护,欢迎反馈使用问题或提出新需求,以便及时迭代升级。

  • 微信公众号:AI重构咨询的李军师(在微信内搜索关注后留言,或于该号后台发送消息)
  • 邮箱:Ai_lichanghong@163.com

反馈时请尽量附上:使用的 WorkBuddy 版本、遇到的现象或报错截图、期望的效果。作者收到后会尽快处理并发布新版。

文档版本:v5.0.0 | 最后更新:2026-08-25