Skill 健康检查 skill-doctor:自动跑 13 个办公 skill 的核心子命令(OCR、PDF 转换、excel 处理、Word 排版、合同生成、表单填写等),记录耗时/扣费/错误,输出 JSON+Markdown 报告。每天跑一次提前发现 skill 故障/平台回归/余额不足。
---
name: skill-doctor
description: skill-doctor 自动健康检查:自动跑 13 个办公 skill 的核心子命令(OCR、PDF 转换、excel 处理、Word 排版、合同生成、表单填写等),记录耗时/扣费/错误,输出 JSON + Markdown 报告。cron 每天跑一次提前发现 skill 故障/平台回归/余额不足,避免用户先发现问题。
---
本 skill 的真实能力依赖平台后端付费工具。在网页 / chat 端点下,这些工具可能不可用:
此时**请勿轻信**,请改用 **agent / CLI 路径**运行本 skill 以获取真实结果。任何路径下都**严禁谎称「已调用 / 已搜索完成」而实际未执行**。
> 费用预估必须基于脚本 `estimate` 子命令的真实返回,**禁止编造金额**。
定期检查所有 office skill 是否还活着,预防"用户先发现故障"。
**场景 1:平台升级后批量验证** —— 平台升级后不知道哪几个 skill 受影响,跑 `skill-doctor` 一次测全部13 个 skill,2 分钟出报告,受影响的标红。
**场景 2:余额预警** —— 跑一次 `skill-doctor`,自动记录跑前后余额差,超阈值时 `failures` 数组带 "余额不足" 警告。
**场景 3:CI/CD 集成** —— 公司 CI 流水线里加一行 `python skill-doctor/scripts/doctor.py --quiet`,失败时让 CI 红屏,构建不通过。
| 角色 | 典型用途 |
|------|----------|
| skill 开发者 | 上线后第一时间验证新 skill 健康 |
| 运维 / SRE | 每天 cron 跑,提前发现故障 |
| 产品 / 项目经理 | 看 Markdown 报告了解 skill 状态 |
| 客户支持 | 客户反馈 skill 异常时快速定位 |
# 跑全部 13 个 skill
python scripts/doctor.py
输出 JSON(精简):
{
"ok": true,
"total_duration_sec": 65.4,
"total_cost_fen": 12,
"balance_yuan": "3.06",
"results": [
{"skill": "office-toolkit", "label": "OCR 图像", "result": {"ok": true, "duration_sec": 3.2, "cost_fen": 2}},
{"skill": "pdf-decrypt", "label": "PDF 信息", "result": {"ok": true, "duration_sec": 0.8, "cost_fen": 0}}
]
}
# 只测某个 skill
python scripts/doctor.py --skill office-toolkit
# 输出 Markdown 报告
python scripts/doctor.py --report doctor-2026-09-17.md
# 只看失败项(CI 友好:失败 exit code 1)
python scripts/doctor.py --quiet
标准库 + subprocess(无需 pip install)。鉴权需 `AIMS_API_KEY`(用于:
python scripts/doctor.py
输出 JSON(精简):
{
"ok": true,
"total_duration_sec": 65.4,
"total_cost_fen": 12,
"balance_yuan": "3.06",
"results": [
{"skill": "office-toolkit", "label": "OCR 图像", "result": {"ok": true, "duration_sec": 3.2, "cost_fen": 2}},
{"skill": "pdf-decrypt", "label": "PDF 信息", "result": {"ok": true, "duration_sec": 0.8, "cost_fen": 0}},
...
]
}
python scripts/doctor.py --skill office-toolkit
python scripts/doctor.py --skill office-toolkit --skill ocr-scan
python scripts/doctor.py --report doctor-2026-09-16.md
生成的报告:
# skill-doctor 健康检查报告
- 总耗时数: 65.4s
- 总扣费: 12 分 (0.12 元)
- 检查后余额: 3.06 元
| skill | 子测试 | 状态 | 耗时 | 扣费 | 备注 |
|-------|--------|------|------|------|------|
| office-toolkit | OCR 图像 | ✅ | 3.2s | 2分 | - |
| pdf-to-ppt | PDF 转 PPT | ❌ | 45s | 0分 | 超时 |
...
python scripts/doctor.py --quiet
只输出 `failures` 数组(0 个失败就 exit 0)。
每个 skill 跑一个"轻量核心子命令",覆盖核心能力:
| skill | probe 命令 | 说明 |
|-------|-----------|------|
| office-toolkit | `ocr.py image` | OCR 图像(测视觉模型 + no_rag)|
| office-toolkit (PDF) | `pdf_ops.py extract` | PDF 文本提取(无 LLM) |
| ocr-scan | `ocr.py image` | 同上,独立 skill |
| pdf-decrypt | `pdf_ops.py info` | PDF 元信息(无 LLM)|
| pdf-to-ppt | `pdf2ppt.py` | 文本转 PPT(确定性)|
| excel-analyze | `xlsx_analyze.py summary` | 列统计(无 LLM)|
| contract-generator | `contract.py schema` | 字段扫描(无 LLM)|
| pdf-form-filler | `pdf_form.py fields` | 表单字段查看(无 LLM)|
| format-converter | `convert_ops.py xlsx2csv` | Excel 转 CSV(无 LLM)|
| pdf-batch-protect | `batch_pdf.py --help` | 帮助输出 |
| docx-styling | `docx_style.py --help` | 帮助输出 |
10 个里有 6 个是**纯本地**(不调 LLM,扣费 0),4 个会调 OCR。
# 编辑 crontab
crontab -e
# 加这一行(每天早上 8 点跑,输出到 logs/skill-doctor.log)
0 8 * * * cd /path/to/office-skills && AIMS_API_KEY=aims_sk_xxx python skill-doctor/scripts/doctor.py --report logs/doctor-$(date +\%F).md >> logs/skill-doctor.log 2>&1
或 Windows 任务计划程序(按相同 cron 时间配)。
加 `--notify` 参数(自己改脚本实现)可以接:
最简单的:在脚本最后 `if not report["ok"]: os.system("echo ... | mail -s ...")`。
| 失败模式 | 大概率原因 | 解决 |
|---------|-----------|------|
| OCR 超时(> 30s)| 平台 ASR/OCR 服务挂了 | 等平台恢复 / 换模型 |
| OCR 报 `upstream 404` | 模型下线/改名 | `aims.list_models` 看新名 |
| 余额变 -10 元 | 平台扣费规则变了 | 暂停自检,联系运营 |
| PDF 测试文件不存在 | `C:/tmp/test_ocr.png` 等被清掉 | 重新生成(见下) |
| 提示"缺少依赖 pypdf" | .packages 被清 | 重跑 `pip install --target .packages` |
第一次跑前需要生成测试文件:
# 在 office-skills 目录
python -c "
from PIL import Image, ImageDraw, ImageFont
import openpyxl, wave, struct, math, io
from docx import Document
import fitz
# 测试图片
img = Image.new('RGB', (720, 320), 'white')
d = ImageDraw.Draw(img)
d.text((20, 20), 'TEST 12345\nAIMS OCR test\n金额 680.50', fill='black')
img.save('C:/tmp/test_ocr.png')
# 测试 PDF(嵌入上面的图)
doc = fitz.open()
p = doc.new_page(width=720, height=320)
p.insert_image(fitz.Rect(0, 0, 720, 320), filename='C:/tmp/test_ocr.png')
doc.save('C:/tmp/scan_test.pdf')
# 测试 docx
d = Document()
d.add_paragraph('合同模板:甲方{{}}、乙方{{}}、金额{{}}')
d.save('C:/tmp/contract_template.docx')
# 测试 xlsx
wb = openpyxl.Workbook(); ws = wb.active
ws.append(['区域','数量','金额'])
ws.append(['华东',10,5000])
wb.save('C:/tmp/sales.xlsx')
"
<!-- ===== 以下为内嵌脚本代码(agent 安装时按需落盘为 scripts/<name> 并 chmod +x) ===== -->
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""skill-doctor:自动监测所有 office skill 是否还活着。
每次跑:
1. 调每个 skill 的"轻量核心子命令"(OCR 用 image 跑一张测试图)
2. 记录:结果 / 用时 / 扣费 / 错误
3. 输出:JSON 报告 + Markdown 人话总结
用法:
python doctor.py # 测全部 6 个 skill
python doctor.py --skill office-toolkit # 只测 1 个
python doctor.py --quiet # 只输出失败项
python doctor.py --report report.md # 输出 Markdown 报告
"""
import argparse
import json
import os
import subprocess
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent.parent
# 测试样本
TEST_IMAGE = "C:/tmp/test_ocr.png"
TEST_PDF = "C:/tmp/text_test.pdf" # 有文本层的 PDF,给 pdf-to-ppt 用
TEST_DOCX = "C:/tmp/contract_template.docx"
TEST_FORM_PDF = "C:/tmp/sample_form.pdf"
TEST_XLSX = "C:/tmp/sales.xlsx"
TEST_URL = "https://samplelib.com/lib/preview/mp3/sample-3s.mp3"
# 每个 skill 的最小"冒烟测试"
# (skill_dir, 子命令列表, 描述)
PROBES = [
{
"skill": "office-toolkit",
"label": "OCR 图像(qwen-vl-ocr-latest)",
"cmd": ["python", "office-toolkit/scripts/ocr.py", "image", "--input", TEST_IMAGE],
"min_duration_sec": 1,
"max_duration_sec": 30,
},
{
"skill": "office-toolkit-pdf",
"label": "PDF 文本提取",
"cmd": ["python", "office-toolkit/scripts/pdf_ops.py", "extract", "--input", TEST_PDF],
"min_duration_sec": 1,
"max_duration_sec": 15,
},
{
"skill": "ocr-scan",
"label": "OCR 图像(独立 skill)",
"cmd": ["python", "ocr-scan/scripts/ocr.py", "image", "--input", TEST_IMAGE],
"min_duration_sec": 1,
"max_duration_sec": 30,
},
{
"skill": "pdf-decrypt",
"label": "PDF 信息查看",
"cmd": ["python", "pdf-decrypt/scripts/pdf_ops.py", "info", "--input", TEST_PDF],
"min_duration_sec": 1,
"max_duration_sec": 10,
},
{
"skill": "pdf-to-ppt",
"label": "PDF 转 PPT(确定性模式)",
"cmd": ["python", "pdf-to-ppt/scripts/pdf2ppt.py", "--input", TEST_PDF,
"--output", "C:/tmp/doctor_ppt.pptx"],
"min_duration_sec": 2,
"max_duration_sec": 30,
},
{
"skill": "excel-analyze",
"label": "Excel 列统计",
"cmd": ["python", "excel-analyze/scripts/xlsx_analyze.py", "summary",
"--input", TEST_XLSX, "--columns", "数量", "金额"],
"min_duration_sec": 1,
"max_duration_sec": 10,
},
{
"skill": "contract-generator",
"label": "合同 schema 扫描",
"cmd": ["python", "contract-generator/scripts/contract.py", "schema",
"--template", TEST_DOCX],
"min_duration_sec": 1,
"max_duration_sec": 10,
},
{
"skill": "pdf-form-filler",
"label": "PDF 表单字段查看",
"cmd": ["python", "pdf-form-filler/scripts/pdf_form.py", "fields",
"--input", TEST_FORM_PDF],
"min_duration_sec": 1,
"max_duration_sec": 10,
},
{
"skill": "format-converter",
"label": "Excel 转 CSV",
"cmd": ["python", "format-converter/scripts/convert_ops.py", "xlsx2csv",
"--input", TEST_XLSX, "--output", "C:/tmp/doctor_out.csv"],
"min_duration_sec": 1,
"max_duration_sec": 10,
},
{
"skill": "pdf-batch-protect",
"label": "PDF 批量子命令帮助",
"cmd": ["python", "pdf-batch-protect/scripts/batch_pdf.py", "--help"],
"min_duration_sec": 1,
"max_duration_sec": 10,
},
{
"skill": "docx-styling",
"label": "docx-styling 帮助",
"cmd": ["python", "docx-styling/scripts/docx_style.py", "--help"],
"min_duration_sec": 1,
"max_duration_sec": 10,
},
{
"skill": "lead-miner",
"label": "lead-miner 帮助(轻量;不调用 API)",
"cmd": ["python", "lead-miner/scripts/lead_miner.py", "--help"],
"min_duration_sec": 1,
"max_duration_sec": 10,
},
{
"skill": "lead-miner-stdin",
"label": "lead-miner 价格计算(calc_cost 单元)",
"cmd": ["python", "-c", "import sys, json; sys.path.insert(0, 'lead-miner/scripts'); "
"from lead_miner import calc_cost; r = calc_cost('serpapi', 10, 3); "
"assert r['total_yuan'] > 0; print(json.dumps(r, ensure_ascii=False))"],
"min_duration_sec": 1,
"max_duration_sec": 5,
},
]
def get_balance(api_key):
"""调 aims.get_balance 拿当前余额(重试 3 次,吞 SSL 间歇错)。"""
import urllib.request, time
body = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {"name": "aims.get_balance", "arguments": {}}}).encode()
headers = {"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"}
last = None
for _ in range(3):
try:
req = urllib.request.Request("https://aimsgateway.cn/mcp/", data=body, method="POST",
headers=headers)
with urllib.request.urlopen(req, timeout=30) as r:
resp = json.loads(r.read())
txt = resp["result"]["content"][0]["text"]
return json.loads(txt)
except Exception as e:
last = e
time.sleep(2)
raise RuntimeError(f"get_balance 失败: {last}")
def run_probe(probe, api_key):
"""跑一个 probe,返回 {ok, duration_sec, cost_fen, output, error}。"""
if not api_key:
return {"ok": False, "error": "缺少 AIMS_API_KEY"}
# 跑前余额(只对会调 LLM 的 skill 有意义)
b0 = None
try:
b0 = get_balance(api_key)
except Exception as e:
return {"ok": False, "error": f"get_balance 失败(平台不可达?): {str(e)[:100]}"}
# Windows 上 PYTHONPATH 用 `;` 分隔,跨平台用 os.pathsep
import os as _os
env = os.environ.copy()
env["AIMS_API_KEY"] = api_key
env["PYTHONPATH"] = _os.pathsep.join([str(ROOT / "_lib"), str(ROOT / ".packages")])
start = time.time()
try:
proc = subprocess.run(probe["cmd"], cwd=str(ROOT), env=env,
capture_output=True, text=True, timeout=60)
duration = time.time() - start
ok = proc.returncode == 0
out = proc.stdout[:200]
err = proc.stderr[:300] if proc.stderr else ""
except subprocess.TimeoutExpired:
duration = time.time() - start
return {"ok": False, "duration_sec": round(duration, 2), "error": "timeout 60s"}
except Exception as e:
duration = time.time() - start
return {"ok": False, "duration_sec": round(duration, 2), "error": str(e)[:200]}
# 跑后余额 + 扣费
cost_fen = 0
b1_yuan = None
try:
b1 = get_balance(api_key)
b1_yuan = b1["balance_yuan"]
cost_fen = b1["balance_fen"] - b0["balance_fen"]
if cost_fen < 0:
cost_fen = 0 # 别的脚本扣的不算
except Exception:
pass
# 检查时长是否异常
duration_warning = ""
if duration > probe["max_duration_sec"]:
duration_warning = f"⚠️ 超时(> {probe['max_duration_sec']}s)"
elif duration < probe["min_duration_sec"] and ok:
duration_warning = f"⚠️ 太快(< {probe['min_duration_sec']}s)"
return {
"ok": ok,
"duration_sec": round(duration, 2),
"cost_fen": cost_fen,
"duration_warning": duration_warning,
"balance_yuan": b1_yuan,
"output_excerpt": out,
"error_excerpt": err,
}
def filter_probes(target):
"""按 --skill 过滤。"""
if not target:
return PROBES
return [p for p in PROBES if p["skill"] in (target if isinstance(target, list) else [target])]
def make_report(results, total_cost_fen, total_duration_sec, balance_after):
"""生成 Markdown 报告。"""
lines = ["# skill-doctor 健康检查报告", ""]
lines.append(f"- 总耗时数: {total_duration_sec:.1f}s")
lines.append(f"- 总扣费: {total_cost_fen} 分 ({total_cost_fen/100:.2f} 元)")
lines.append(f"- 检查后余额: {balance_after} 元")
lines.append("")
lines.append("## 详细结果")
lines.append("")
lines.append("| skill | 子测试 | 状态 | 耗时 | 扣费 | 备注 |")
lines.append("|-------|--------|------|------|------|------|")
for r in results:
ok_mark = "✅" if r["result"]["ok"] else "❌"
cost = r["result"]["cost_fen"]
dur = r["result"]["duration_sec"]
warn = r["result"].get("duration_warning", "")
err = r["result"].get("error_excerpt", "")[:60] if not r["result"]["ok"] else ""
lines.append(f"| {r['skill']} | {r['label']} | {ok_mark} | {dur}s | {cost}分 | {warn or err or '-'} |")
lines.append("")
fail = [r for r in results if not r["result"]["ok"]]
if fail:
lines.append("## ⚠️ 失败明细")
lines.append("")
for r in fail:
lines.append(f"### {r['skill']} - {r['label']}")
lines.append(f"```\n{r['result'].get('error_excerpt', r['result'].get('error', ''))[:500]}\n```")
lines.append("")
else:
lines.append("## ✅ 全部通过")
lines.append("")
lines.append("所有 skill 健康,无需处理。")
return "\n".join(lines)
def main():
ap = argparse.ArgumentParser(description="skill-doctor 健康检查")
ap.add_argument("--skill", help="只测某个 skill(可多次)", action="append")
ap.add_argument("--quiet", action="store_true", help="只输出失败项")
ap.add_argument("--report", help="输出 Markdown 报告到文件")
args = ap.parse_args()
api_key = os.environ.get("AIMS_API_KEY")
if not api_key:
print("⚠️ 未设置 AIMS_API_KEY,将只跑非 LLM 的 probe(OCR/AI 测试会失败)",
file=sys.stderr)
probes = filter_probes(args.skill)
print(f"将测试 {len(probes)} 个 probe")
results = []
total_start = time.time()
for probe in probes:
sys.stdout.write(f" ▶ {probe['skill']:20} - {probe['label']} ... ")
sys.stdout.flush()
r = run_probe(probe, api_key)
ok = r.get("ok")
sys.stdout.write(f"{'OK' if ok else 'FAIL'} ({r.get('duration_sec', 0)}s)\n")
results.append({
"skill": probe["skill"],
"label": probe["label"],
"cmd": probe["cmd"],
"result": r,
})
total_duration = time.time() - total_start
total_cost = sum(r["result"].get("cost_fen", 0) for r in results)
balance_after = None
if api_key:
try:
balance_after = get_balance(api_key)["balance_yuan"]
except Exception:
pass
report = {
"ok": all(r["result"]["ok"] for r in results),
"total_duration_sec": round(total_duration, 2),
"total_cost_fen": total_cost,
"balance_yuan": balance_after,
"results": results,
}
if args.quiet:
# 只输出失败的
fails = [r for r in results if not r["result"]["ok"]]
print(json.dumps({"ok": report["ok"], "failures": fails}, ensure_ascii=False, indent=2))
else:
print(json.dumps(report, ensure_ascii=False, indent=2))
if args.report:
md = make_report(results, total_cost, total_duration, balance_after)
Path(args.report).write_text(md, encoding="utf-8")
print(f"\n📝 Markdown 报告已写到 {args.report}")
sys.exit(0 if report["ok"] else 1)
if __name__ == "__main__":
main()