WorkBuddy 使用报告 v4.1 — 技能说明
一句话触发:「统计我 WorkBuddy 的使用情况」或「我做过了哪些任务」或「我用了哪些技能和专家」。 本技能自动从本地三个真实数据源挖数据,产出 Excel 台账 + HTML 看板,全程零推断。 当前版本:v4.1.0
快速概览(先看这里)
| 项目 | 说明 |
|---|---|
| 输入 | 用户一句话触发,无需提供任何文件路径 |
| 数据来源 | 本地三个真实数据源(见下方「数据地图」),不靠猜 |
| 输出产物 | ① WorkBuddy任务统计.xlsx(含统计分析+6张图表)② WorkBuddy任务统计看板.html(交互式 KPI 卡片+图表) |
| 典型耗时 | 50 个任务约 30 秒(含数据提取+生成) |
| 适用场景 | 个人复盘 / 月度汇报 / 向老板展示 AI 工具使用价值 / 发现自己的工具使用偏好 |
| 隐私保证 | 只读本地文件,不联网、不上传、不在技能包里保留任何用户数据 |
产出效果长什么样?
Excel 台账包含:
- 「任务统计」sheet:每行一个任务,列 = 序号 / 任务名称 / 时间 / 消耗积分 / 技能(中英) / 专家(中英) / 专家团(中英) / 产物1…N
- 「数据说明」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) |
数据地图(核心参考)
| 数据项 | 真实来源 | 路径 | 关键细节 |
|---|---|---|---|
| 任务名 / 时间 / 积分 / 专家·专家团 | 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 | | 数据说明 | 口径文档 | 各字段定义、数据来源、计算公式 | | 名称对照 | 映射表 | skill_id ↔ 中文名 / expert_id ↔ 中文名 / team_id ↔ 中文名 | | 统计分析 | 图表区 | 6 张 openpyxl 原生图表(见下) |
统计分析 6 张图表:
| 图表 | 类型 | X轴 | Y轴 | 用途 | |---|---|---|---|---| | 技能偏好 Top10 | 柱状图 | 技能名(中文) | 调用次数 | 看你最常用哪些技能 | | 专家偏好 Top10 | 柱状图 | 专家名(中文) | 被召唤次数 | 看你最爱找哪位专家 | | 专家团偏好 | 柱状图 | 专家团名(中文) | 被召唤次数 | 看哪个团队帮你最多 | | 月度工作量 | 柱状图 | 年-月 | 任务数量 | 看月度节奏 | | 周度工作量 | 折线图 | ISO 周(周一起点) | 任务数量 | 看周间波动 | | 日度工作量 | 柱状图 | 日期 | 任务数量 | 看每日分布、找高峰日 |
HTML 看板(WorkBuddy任务统计看板.html)
- 引入 Chart.js CDN(无需本地安装)
- 顶部 5 个 KPI 卡片(总任务 / 总积分 / 技能种数 / 专家数 / 产物数)
- 6 张交互图表(鼠标悬停显示精确值)
- 底部可排序表格(点击表头排序)
数据清洗规则
| 规则 | 说明 |
|---|---|
| 无技能任务标记 | 技能列填 —(无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 时改用用户目录。
边界与限制
本技能并非万能,以下情况会如实告知用户,不会伪造数据:
| 场景 | 结果 |
|---|---|
| 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 |
附录:完整可运行 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
反馈与贡献
本技能由作者持续维护,欢迎反馈使用问题或提出新需求,以便及时迭代升级。
- 微信公众号:AI重构咨询的李军师(在微信内搜索关注后留言,或于该号后台发送消息)
- 邮箱:Ai_lichanghong@163.com
反馈时请尽量附上:使用的 WorkBuddy 版本、遇到的现象或报错截图、期望的效果。作者收到后会尽快处理并发布新版。
文档版本:v4.1.0 | 最后更新:2026-08-07
微信扫一扫