Skill 健康检查

Model: qwen-plus | Free
运维监控健康检查自动化CIskill 监控回归检测余额监控故障告警

Skill 健康检查 skill-doctor:自动跑 13 个办公 skill 的核心子命令(OCR、PDF 转换、excel 处理、Word 排版、合同生成、表单填写等),记录耗时/扣费/错误,输出 JSON+Markdown 报告。每天跑一次提前发现 skill 故障/平台回归/余额不足。

Skill Documentation

---

name: skill-doctor

description: skill-doctor 自动健康检查:自动跑 13 个办公 skill 的核心子命令(OCR、PDF 转换、excel 处理、Word 排版、合同生成、表单填写等),记录耗时/扣费/错误,输出 JSON + Markdown 报告。cron 每天跑一次提前发现 skill 故障/平台回归/余额不足,避免用户先发现问题。

---

⚠️ chat 模式使用须知(重要)

本 skill 的真实能力依赖平台后端付费工具。在网页 / chat 端点下,这些工具可能不可用:

此时**请勿轻信**,请改用 **agent / CLI 路径**运行本 skill 以获取真实结果。任何路径下都**严禁谎称「已调用 / 已搜索完成」而实际未执行**。

> 费用预估必须基于脚本 `estimate` 子命令的真实返回,**禁止编造金额**。

skill-doctor(自动健康检查)

定期检查所有 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

为什么选 skill-doctor

触发条件

为什么用这个

触发条件

依赖安装

标准库 + subprocess(无需 pip install)。鉴权需 `AIMS_API_KEY`(用于:

使用流程

跑全部 10 个 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
python scripts/doctor.py --skill office-toolkit --skill ocr-scan

输出 Markdown 报告

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。

怎么用 cron 每天跑

# 编辑 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) ===== -->

文件:scripts/doctor.py

#!/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()