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

WorkBuddy 使用报告

Generate a personal WorkBuddy usage report (task ledger + preference analysis + workload charts) for ANY user, by mining the three REAL local data sources. Use when the user asks to "统计我用 WorkBuddy 做了哪些任务" / "统计 WorkBuddy 使用情况" / "我用了哪些技能/专家/专家团" / "WorkBuddy 工作量分析" / "做一份 WorkBuddy 使用报告". Produces an Excel ledger (with analysis sheet + charts) AND an HTML dashboard. No inference — all data is real. Correctly reads credits from credit_json (NOT the nonexistent total_credits column, NOT the used internal-usage counter).

personAuthor: user_d8cafa77hubcommunity

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

技能执行

  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) |


数据地图(核心参考)

| 数据项 | 真实来源 | 路径 | 关键细节 | |---|---|---|---| | 任务名 / 时间 / 积分 / 专家·专家团 | 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 | | 数据说明 | 口径文档 | 各字段定义、数据来源、计算公式 | | 名称对照 | 映射表 | 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()

运行方式

  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

反馈与贡献

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

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

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

文档版本:v4.1.0 | 最后更新:2026-08-07