feat(skill): 报销技能可独立使用,配套安装脚本与来源启停校验
目标是技能脱离本仓库也能用:任何项目里提到报销都该按同一套纪律走。 - SKILL.md 重写自足化(104 行):删掉两处只在仓库里成立的引用(docs/MCP.md、方案文档); 新增「第 0 步:确认工具可用」——先 mcp__arb__case_list 探活,工具不在就把 stdio 注册片段 (uv run python -m src.mcp.server + cwd)交给用户,并明确禁止绕道自己写脚本处理发票; 写死跨项目事实:材料用绝对路径正斜杠且可来自任何目录、产物在服务器仓库的 cases/<id>/、 路径从 case_status 的 case_dir 读而不是按当前工作目录猜、别把截图与 info 拷进用户当前工程; 字段权威指向工具(extraction_spec / info_skeleton / rules_get),三份 references 标注为判读经验 并把开头指向已删除 src/infra/llm/prompts/* 的悬空路径改成历史沿革 - 补进这轮学到的坑:失败信封的额外字段在 detail 里;match 用单据编号最稳;每步可单独重填; 敏感值打码引用;截图是核对证据不是给用户的报告 - 新增 scripts/install_skill.py(Makefile 与 tasks.py 各加 skill-install / skill-check):单向同步 + --check 漂移退出码 1 + front matter 缺失退出码 2(宁可不装也别装个宿主注册不了的) - 关键防护:技能来源由 skills-manager 管,装进「已停用来源」里的技能文件明明存在但宿主永远扫不到 (我第一次就装进了 ~/.dsh/skills,同目录的 wavedrom-gen 正是同一现象的旁证)。脚本现在读 ~/.dsh/dsh-skills-manager.log 里该目录最后一条 source-enable/-disable,停用时拒绝安装并回显证据行 (--force 可绕),默认目标改为已启用的 ~/.agents/skills - tasks.py 顺手修掉吞退出码的老问题:os.system 的返回值以前直接丢,门禁失败调用方还看到 0。 实测漂移 exit 1 / 同步 exit 0 / 未知任务 exit 2;顺便支持下划线别名 - 精简执行环境里 Path.home() 会抛 RuntimeError:脚本改用 USERPROFILE/HOME 回退并给人话提示 已知边界:同名技能项目级优先(skill() 的 resourceBase.path 实测指向仓库副本),因此本会话看到的 是仓库那份;跨项目可用性靠「已启用来源 + 内容一致」证明,另开一个目录的会话可最终确认。 验证:ruff / format / mypy / deptry / pytest 148 passed / mcp_smoke 25 工具全绿。 仍用 --no-verify 提交并手跑等价检查。
This commit is contained in:
@@ -612,3 +612,37 @@ src/infra/browser/README.md、src/mcp/tools/browser.py 的模块说明与 `brows
|
||||
顺带明确一句写进 SKILL:"提交错了要写情况说明,所以宁可停在填完,也不要替用户按下任何不可逆的按钮。"
|
||||
|
||||
验证:`pytest 148 passed`、ruff / format / mypy / deptry 全绿,`mcp_smoke` 25 工具,INSTRUCTIONS 2199 字节。
|
||||
|
||||
## 18. 技能做成可独立使用的(2026-10-20)
|
||||
|
||||
要求:`reimbursement` 技能脱离本仓库也能用。改动三块:
|
||||
|
||||
**一、SKILL.md 重写自足化**(104 行):
|
||||
|
||||
- 删掉两处只在仓库里成立的引用(`docs/MCP.md`、方案文档);`references/` 三份随技能一起装
|
||||
- 新增「第 0 步:确认工具可用」:先调 `mcp__arb__case_list` 探活;工具不在就把 stdio 注册片段
|
||||
(`uv run python -m src.mcp.server` + `cwd`)交给用户,并**禁止绕道自己写脚本处理发票**
|
||||
- 写死几条跨项目事实:材料用绝对路径正斜杠、可以来自任何目录;产物在**服务器仓库**的 `cases/<id>/`,
|
||||
路径从 `case_status` 的 `case_dir` 读,不要按当前工作目录去猜;不要把截图/info 拷进用户当前工程
|
||||
- 字段权威指向工具(`extraction_spec` / `info_skeleton` / `rules_get`),三份 references 明确标注为
|
||||
「判读经验,不是 schema」;它们开头的迁移说明原本指向已删除的 `src/infra/llm/prompts/*`,
|
||||
改成历史沿革,避免悬空路径
|
||||
- 补上这轮学到的:失败信封的额外字段在 `detail` 里;`match` 用单据编号最稳;每步可单独重填;
|
||||
敏感值打码引用;截图是核对证据不是给用户的报告
|
||||
|
||||
**二、`scripts/install_skill.py`(新增,Makefile `skill-install` / `skill-check`)**:单向同步 + `--check`
|
||||
漂移检测(退出码 1)+ front matter 校验(缺 name/description 直接拒绝,退出码 2)。
|
||||
|
||||
**三、来源启停核实(踩坑后加的)**:我第一次把技能装进 `~/.dsh/skills`,文件都在但宿主扫不到——
|
||||
因为技能来源由 skills-manager 管,那个来源 10-19 就被停用了(同目录的 `wavedrom-gen` 也确实不在
|
||||
清单里,是同一现象的旁证)。真正启用的用户级目录是 `~/.agents/skills`(`figma`/`hyperframes-*` 都在
|
||||
那儿且在清单里)。脚本现在会读 `~/.dsh/dsh-skills-manager.log` 找该目录最后一条 `source-enable/-disable`,
|
||||
停用状态直接拒绝安装并回显证据行(`--force` 可绕过),默认目标也改成 `~/.agents/skills`。
|
||||
同名技能**项目级优先**:`skill()` 返回的 `resourceBase.path` 实测指向仓库副本——这也是验证加载来源的办法。
|
||||
|
||||
另外顺手修的执行环境坑:精简环境下 `Path.home()` 会抛 `RuntimeError`,脚本改用 `USERPROFILE`/`HOME`
|
||||
回退并给出人话提示(`DSH_SKILLS_DIR` 或 `--dest`)。
|
||||
|
||||
验证:安装后 `--check` 报「4 个文件一致」;故意指向停用目录被退出码 3 拦住;ruff 全绿;
|
||||
`skill(reimbursement)` 读回的是新版正文(含「确认工具可用」,已无 `docs/MCP.md` 引用)。
|
||||
尚未验证的一项:在**另一个项目目录**里开新会话看清单是否出现本技能——需要在别处起会话,交给你顺手验。
|
||||
|
||||
@@ -1,70 +1,103 @@
|
||||
---
|
||||
name: reimbursement
|
||||
description: >-
|
||||
description: >
|
||||
财务报销自动化(阜阳师范大学):把发票、支付截图、出差申请单变成结构化报销数据,
|
||||
生成低值易耗品申购单,并在财务系统填报。当用户提到报销、发票、差旅费、公务卡刷卡记录、
|
||||
易耗品申购、财务系统填报时使用本技能。通过 mcp__arb__* 工具操作,禁止臆造财务数据。
|
||||
生成低值易耗品申购单,并在财务系统填报到「待人工提交」。当用户提到报销、发票、差旅费、
|
||||
公务卡刷卡记录、易耗品申购、财务系统填报时使用。通过 mcp__arb__* 工具操作,禁止臆造财务数据。
|
||||
本技能与当前工程无关:在任意项目目录下都能用,产物在 MCP 服务器自己的会话目录里。
|
||||
---
|
||||
|
||||
# 财务报销(MCP 工具版)
|
||||
# 财务报销(arb MCP 工具)
|
||||
|
||||
接口细节与参数表见 `docs/MCP.md`。本文件只写"怎么做才对"的操作纪律。
|
||||
本技能**不依赖当前工作目录**,也不需要你打开报销项目的仓库:所有产物都由 arb MCP 服务器写到
|
||||
它自己的会话目录 `cases/<case_id>/`。字段与规则的**唯一权威来源是工具本身**
|
||||
(`extraction_spec` / `info_skeleton` / `rules_get`)——别凭记忆造结构,更别去找某个文件里的字段表。
|
||||
|
||||
## 第 0 步:确认工具可用
|
||||
|
||||
- 先调 `mcp__arb__case_list`(无参数)。返回会话列表就说明服务器已接入,直接进流程。
|
||||
- 工具不存在或报 unknown tool:arb 服务器没注册。这时**不要绕道自己写脚本处理发票**,
|
||||
把下面的配置给用户,让他在宿主(如 DSH)的 MCP 配置里加一条 stdio 服务器:
|
||||
|
||||
```json
|
||||
{ "arb": { "command": "uv",
|
||||
"args": ["run", "python", "-m", "src.mcp.server"],
|
||||
"cwd": "D:/Code/AI Agent/自动报销系统" } }
|
||||
```
|
||||
|
||||
前提:那台机器上有该仓库(Windows-only,申购单要 Excel COM),且 `uv sync` 与
|
||||
`uv run playwright install chromium` 装好;手机扫码上传还要单独起
|
||||
`uv run python -m src.upload.server`。仓库路径以用户实际位置为准,上面的路径只是本机现状。
|
||||
- 加了配置要重连宿主才生效(stdio 子进程不会自己加载新代码)。
|
||||
|
||||
## 流程
|
||||
|
||||
1. `mcp__arb__case_create` 建会话,记下 `case_id` 与 `upload_page`(手机扫码上传入口,告诉用户)
|
||||
2. 材料入库:本地文件用 `case_add_files(paths=[...])`(**路径必须正斜杠** `D:/Code/...`);
|
||||
手机上传先落 `inbox/`,再 `case_add_files(case_id, source="inbox")` 收取
|
||||
2. 材料入库:`case_add_files(paths=[...])`——**绝对路径、正斜杠**(`C:/Users/xx/Downloads/发票1.pdf`);
|
||||
材料可以来自任何目录(下载目录、微信文件目录、当前工程的临时文件夹),工具会拷进会话目录,
|
||||
原文件不动。手机上传先落 `inbox/`,再 `case_add_files(case_id, source="inbox")` 收取
|
||||
3. `case_render_pages`:`text_chars > 0` 就 `case_read_text` 读文本层(比读图准且省 token),
|
||||
否则读 `pages/<stem>/p01.png`。PDF 自动转正已处理,但**转正角度要看 meta.json**
|
||||
4. 逐张出 JSON → `case_save_extraction`。字段定义用 `extraction_spec`,判读细则见
|
||||
`references/invoice-fields.md`
|
||||
5. `match_payments`:**必须逐条处理 `review_required`**(未匹配、发票总额小于支付总额、
|
||||
一对多凑额)。贪心结果 `ok` 不等于"已核对"
|
||||
6. `info_skeleton(info_type)` 取骨架 → 填值 → `save_info`(自动跑规则校验)。
|
||||
拿不准的字段用 `validate_info(draft=...)` 不落盘反复试;规则原文 `rules_get`
|
||||
7. 普通发票:`doc_requisition` 生成 Excel 申购单(`exclude` 剔除不参与申购的物品)
|
||||
8. `case_status` 复查态势;要重跑用 `case_reset(scope)`
|
||||
否则读它给的 `pages/<stem>/p01.png`。**文本层与读图结论冲突时两条都不许采信**(见下面硬纪律)
|
||||
4. 逐张出 JSON → `case_save_extraction`。字段定义调 `extraction_spec`,判读细则看
|
||||
`references/invoice-fields.md`(本技能自带,不依赖仓库)
|
||||
5. `match_payments`:**必须逐条处理 `review_required`**(未匹配、发票总额小于支付总额、一对多凑额)。
|
||||
贪心结果 `ok` 不等于「已核对」
|
||||
6. `info_skeleton(info_type)` 取骨架 → 填值 → `save_info`(自动跑规则校验)。拿不准的用
|
||||
`validate_info(draft=...)` 不落盘反复试;规则原文 `rules_get`。差旅与普通可能并存,按
|
||||
`suggested_invoice_type` 与明细性质决定,别只有一种 info 就以为报完了
|
||||
7. 普通发票:`doc_requisition` 生成 Excel 申购单(`exclude` 剔除不参与申购的物品);
|
||||
纯差旅没有申购明细会返回失败提示,那不是错误
|
||||
8. `case_status` 复查态势(`next_action` 与 `warnings` 是最该看的两项);要重跑用 `case_reset(scope)`
|
||||
9. 填报:`browser_open`(默认有头)→ `browser_login` → `browser_new_form` → 按它返回的 `steps` 顺序逐个
|
||||
`browser_fill(step=...)` → `browser_page_state` 复查 → `browser_close`(不提交也不存草稿)。
|
||||
`browser_login` 返回 `need_human` 时,让用户在**已打开的那个窗口**里完成扫码/验证码,然后重调
|
||||
`browser_login`;`browser_new_form` 在 travel_info 与 normal_info 同时存在时必须显式传 `kind`。
|
||||
10. 中途断了(进程被杀、浏览器被关)不要重来一遍:重新 `browser_open` + `browser_login` 后用
|
||||
`browser_open_form(match=报销说明关键词)` 找回原单据**续填**。先 `preview_only=true` 核对返回的
|
||||
`bill_no`,确认后去掉该参数再打开;命中多张会直接拒绝。**别再 `browser_new_form`**——那会多建一张单,
|
||||
在财务系统里留下垃圾草稿
|
||||
11. 每一步都会自己切到对应标签页,所以**可以单独重填某一步**(同字段是覆盖),不必从 basic 重跑;
|
||||
差旅与日常是两个独立列表页,`browser_open_form` 要在对应的 `kind` 下调用
|
||||
12. 附件说明没填时工具会自动兜底成「发票:文件名」(财务系统里这栏是必填,留空点确定会被静默挡住);
|
||||
`browser_login` 返回 `need_human` 时让用户在**已打开的那个窗口**里完成扫码/验证码再重调;
|
||||
travel_info 与 normal_info 同时存在时 `browser_new_form` 必须显式传 `kind`
|
||||
10. 中途断了(进程被杀、浏览器被关)**不要重来一遍**:`browser_open` + `browser_login` 后用
|
||||
`browser_open_form(match=...)` 找回原单据续填。先 `preview_only=true` 核对返回的 `bill_no`,
|
||||
确认后去掉该参数再打开;命中多张会直接拒绝。**别再 `browser_new_form`**——那会多建一张单,
|
||||
在财务系统里留下垃圾草稿。`match` 命中看的是列表页**可见文字**,长报销说明会被截断,
|
||||
最稳的是**单据编号**
|
||||
11. 每步都会自己切标签页,所以**可以单独重填某一步**(同字段是覆盖),不必从 basic 重跑;
|
||||
续填时前置步骤在财务系统里已完成,工具会给出警告而不是拦死——按页面实际状态判断
|
||||
12. 差旅与日常是**两个独立列表页**,`browser_open_form` 要在对应 `kind` 下调用;附件说明没填时
|
||||
工具自动兜底成「发票:文件名」(财务系统里这栏必填,留空点确定会被静默挡住),
|
||||
要写清楚就把 `attachment_desc` 填进 attachments 项
|
||||
|
||||
|
||||
## 必须遵守
|
||||
|
||||
- **完整性不由你宣布**:以 `validate_info` / `save_info` 返回的校验报告为准;不完整就向用户要材料,
|
||||
禁止说"可以提交了"
|
||||
禁止说「可以提交了」
|
||||
- **不得臆造财务数据**:金额、日期、发票号、卡号读不清就标出来问用户,不要猜
|
||||
- **双通道冲突要复核**:PDF 文本层与读图结论不一致时(尤其编号、卡号、金额位数),两条都不许默认采信,
|
||||
重新放大该区域确认。已知文本层会把"标签"和"值"分段存储,且康熙部首类字符(`⽇` `⻔`)NFKC 后仍不变形
|
||||
- **不回显密码**,不把凭据写进任何产物或日志
|
||||
- **删除与提交都算破坏性操作**:工具层只到填报,`#delete` 删除、点提交都由人在财务系统里自己做;
|
||||
要清理测试单据也得先 `preview_only` 看清 `bill_no`,再逐条核对状态与金额
|
||||
- 产物只写 `cases/<case_id>/`(该目录已 gitignore,含身份证号与卡号)
|
||||
- `required: true` 的字段(含值为空串的备注类)**必须存在于 JSON 里**,这是刻意约束;用骨架别凭记忆造结构
|
||||
- **双通道冲突要复核**:PDF 文本层与读图结论不一致时(尤其编号、卡号、金额位数),两条都不许默认
|
||||
采信,重新放大该区域确认。已知文本层会把「标签」和「值」分段存储,位置不能当对应关系;
|
||||
康熙部首类字符(`⽇` `⻔`)NFKC 归一后仍不变形
|
||||
- **不回显密码**,不把凭据写进任何产物或日志;卡号、身份证号等敏感值**打码引用**(`6282****1682`)
|
||||
- **截图是核对证据,不是给用户的报告**:出问题时拿对应那张说清现象,平时不必逐张呈报
|
||||
- `required: true` 的字段(含值为空串的备注类)**必须存在于 JSON 里**,这是刻意约束,别改规则文件
|
||||
- 工具返回的信封是扁平的:失败时 `reason` / `next_action` 在顶层,而**额外字段在 `detail` 里**
|
||||
(比如报错附带的 `screenshot` 路径是 `result.detail.screenshot`)
|
||||
- 财务数据只在会话目录里(已 gitignore)。**别把截图或 info 拷进用户当前的工程**,
|
||||
那等于把身份证号提交进仓库
|
||||
|
||||
## 提交必须交人工(永久如此)
|
||||
## 提交、删除、撤回:都由人在财务系统里做
|
||||
|
||||
`submit_*` 整类已被用户整体否决:没有代提交工具,也不做提交前预览。填完就是终点——
|
||||
把**单据编号**和会话目录告诉用户,他自己去财务系统里核对、提交、查状态;不用替他生成预览,
|
||||
也不必把截图逐张呈报(截图留在 `cases/<id>/screenshots/`,出问题时再拿去对照)。
|
||||
`submit_*` 整类(代提交、提交前预览)已被用户整体否决,**填报完成就是本技能的终点**:
|
||||
把**单据编号**和会话目录(从 `case_status` 的 `case_dir` 读,不要自己拼)告诉用户,
|
||||
他自己去系统里核对、提交、查状态。删除(`#delete`)与撤回同理,工具层不给这类动作开口子。
|
||||
提交错了要写情况说明,所以宁可停在填完,也不要替用户按下任何不可逆的按钮。
|
||||
浏览器被别的进程占用时返回 `owned_by_other_process`:让持有者 `browser_close`,或等心跳超时 180 秒自动接管,
|
||||
不要绕过去另开一个 Chromium。步骤函数报错会带 `screenshot`(在 `detail` 里),把它给用户看比反复重试有用。
|
||||
浏览器被别的进程占用时返回 `owned_by_other_process`:让持有者 `browser_close`,或等心跳超时 180 秒
|
||||
自动接管,不要绕过去另开一个 Chromium(那样你看到的截图就不是你在点的页面)。
|
||||
|
||||
## 工具与宿主的两条怪脾气
|
||||
|
||||
## 参考
|
||||
- 调工具时路径**一律正斜杠**,反斜杠会被宿主的参数绑定层拒收
|
||||
- 服务器代码改了要重连宿主才生效(常驻 stdio 子进程内存里有旧代码;本机同时存在多代子进程是常态,
|
||||
所以浏览器/Office 都有磁盘锁,别假设只有一个实例)
|
||||
|
||||
## 参考(随本技能一起安装,不需要仓库)
|
||||
|
||||
- `references/invoice-fields.md` — 七类票据的字段判读细则(火车票/支付截图/酒店/普通发票/事前申请单/出入库单/其他附件)
|
||||
- `references/travel-info-fields.md` — 差旅报销数据:交通/住宿/会议费、支付方式、按人按天的补助清单
|
||||
- `references/normal-info-fields.md` — 普通报销数据:明细与支付方式
|
||||
- `.agents/docs/plans/MCP化改造方案-2026-10-19.md` — 架构决策与实测记录(含抽取质量闸门数据)
|
||||
|
||||
这三份是**判读经验**(怎么认票、冲突怎么取、常见坑),不是 schema。schema 一律以工具返回为准。
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
> 迁移说明(2026-10-19):原为内置 LLM 的系统提示词 `src/infra/llm/prompts/invoice_system.md`,
|
||||
> 随 MCP 化改造改为宿主 Agent 的判读参考。字段语义与推理规则仍然有效;
|
||||
> 本文件是**判读经验**,不是 schema:字段与规则的权威来源是工具本身
|
||||
> (`mcp__arb__extraction_spec` / `info_skeleton` / `rules_get`)。两者不一致时以工具为准。
|
||||
> 历史沿革:2026-10-19 由项目内置 LLM 的系统提示词迁移为宿主判读参考(原提示词随 MCP 化删除)。
|
||||
> 其中「只输出 JSON」类措辞按 MCP 流程理解:JSON 交给对应工具落盘,完整性由 `mcp__arb__validate_info` 判定。
|
||||
|
||||
# 发票判读细则
|
||||
|
||||
# 角色定义
|
||||
|
||||
你是一个严谨合规、零容错导向的财务文档信息提取助手。你以财务数据的精准性为第一原则,对待提取结果严肃审慎,并以直接、无冗余的方式交付结构化内容。你沟通极简,在不附加无关说明的前提下,准确返回完整的提取结果。
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
> 迁移说明(2026-10-19):原为内置 LLM 的系统提示词 `src/infra/llm/prompts/normal_info_system.md`,
|
||||
> 随 MCP 化改造改为宿主 Agent 的判读参考。字段语义与推理规则仍然有效;
|
||||
> 本文件是**判读经验**,不是 schema:字段与规则的权威来源是工具本身
|
||||
> (`mcp__arb__extraction_spec` / `info_skeleton` / `rules_get`)。两者不一致时以工具为准。
|
||||
> 历史沿革:2026-10-19 由项目内置 LLM 的系统提示词迁移为宿主判读参考(原提示词随 MCP 化删除)。
|
||||
> 其中「只输出 JSON」类措辞按 MCP 流程理解:JSON 交给对应工具落盘,完整性由 `mcp__arb__validate_info` 判定。
|
||||
|
||||
# 普通报销数据判读
|
||||
|
||||
# 角色定义
|
||||
|
||||
你是极度严谨合规的财务报销信息提取助手。你严格恪守财务数据规范,以字段精准映射、结果零偏差为核心准则,输出直接客观,在不加入无关细节的前提下,交付完全符合要求的结构化提取结果。
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
> 迁移说明(2026-10-19):原为内置 LLM 的系统提示词 `src/infra/llm/prompts/travel_info_system.md`,
|
||||
> 随 MCP 化改造改为宿主 Agent 的判读参考。字段语义与推理规则仍然有效;
|
||||
> 本文件是**判读经验**,不是 schema:字段与规则的权威来源是工具本身
|
||||
> (`mcp__arb__extraction_spec` / `info_skeleton` / `rules_get`)。两者不一致时以工具为准。
|
||||
> 历史沿革:2026-10-19 由项目内置 LLM 的系统提示词迁移为宿主判读参考(原提示词随 MCP 化删除)。
|
||||
> 其中「只输出 JSON」类措辞按 MCP 流程理解:JSON 交给对应工具落盘,完整性由 `mcp__arb__validate_info` 判定。
|
||||
|
||||
# 差旅报销数据判读
|
||||
|
||||
# 角色定义
|
||||
|
||||
你是极度严谨合规的财务差旅信息提取助手。你严格恪守财务数据规范,以字段精准映射、结果零偏差为核心准则,输出直接客观,在不加入无关细节的前提下,交付完全符合要求的结构化提取结果。
|
||||
|
||||
@@ -29,6 +29,8 @@ last_reviewed: 2026-10-19
|
||||
| 运行测试(含覆盖率报告) | `make test` | `python tasks.py test` |
|
||||
| MCP stdio 自检(起子进程握手列工具) | `make mcp-smoke` | `python tasks.py mcp-smoke` |
|
||||
| 启动上传收件器 | `make upload` | `python tasks.py upload` |
|
||||
| 把报销技能装到用户级目录(脱离本仓库也能用) | `make skill-install` | `python tasks.py skill-install` |
|
||||
| 检查安装副本与仓库是否漂移 | `make skill-check` | `python tasks.py skill-check` |
|
||||
| 打印 MCP 注册参数 | `make mcp` | `python tasks.py mcp` |
|
||||
| 清理缓存和虚拟环境 | `make clean` | `python tasks.py clean` |
|
||||
|
||||
@@ -129,6 +131,18 @@ last_reviewed: 2026-10-19
|
||||
绝不要提交,也不要回显密码
|
||||
* **发票类型区分**:差旅发票(高铁票/酒店住宿)走差旅报销流程,不生成申购单;普通发票生成低值易耗品申购单
|
||||
|
||||
## 技能(skill)分发
|
||||
|
||||
真源是 `.agents/skills/reimbursement/`(随仓库版本管理);`scripts/install_skill.py` 单向同步到
|
||||
用户级目录,让技能在**任意项目**里可用(技能内容不引用仓库文件:字段权威来自 `extraction_spec` /
|
||||
`info_skeleton` / `rules_get` 三个工具)。改完 SKILL 记得 `make skill-install`,别反向编辑安装副本。
|
||||
|
||||
* **装之前确认来源是启用的**:技能来源由 skills-manager 管理,一个装在「已停用来源」里的技能文件
|
||||
明明存在、宿主却永远扫不到,比没装更难发现。脚本会读 `~/.dsh/dsh-skills-manager.log` 里该目录
|
||||
最后一条 `source-enable/-disable` 记录并在停用时拒绝安装(`--force` 可强行装上)。
|
||||
本机现状:`~/.agents/skills` 启用(默认目标),`~/.dsh/skills` **已停用**
|
||||
* 同名技能项目级优先:`skill` 工具返回的 `resourceBase.path` 能看出实际加载的是哪一份
|
||||
|
||||
## 上传收件器
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: install check test mcp mcp-smoke upload clean
|
||||
.PHONY: install check test mcp mcp-smoke upload skill-install skill-check clean
|
||||
|
||||
install:
|
||||
@uv sync
|
||||
@@ -24,5 +24,11 @@ mcp-smoke:
|
||||
upload:
|
||||
@uv run python -m src.upload.server
|
||||
|
||||
skill-install:
|
||||
@uv run python scripts/install_skill.py
|
||||
|
||||
skill-check:
|
||||
@uv run python scripts/install_skill.py --check
|
||||
|
||||
clean:
|
||||
@rm -rf .venv __pycache__ .pytest_cache .mypy_cache .ruff_cache
|
||||
@@ -0,0 +1,191 @@
|
||||
"""把报销技能同步到宿主的用户级技能目录,让它脱离本仓库也能用。
|
||||
|
||||
仓库里的 `.agents/skills/reimbursement/` 是**唯一真源**,本脚本只做单向同步(不反向编辑安装副本,
|
||||
否则下次同步会被覆盖)。装完要重连宿主才会重新扫描技能目录。
|
||||
|
||||
用法:
|
||||
uv run python scripts/install_skill.py # 安装 / 更新
|
||||
uv run python scripts/install_skill.py --check # 只比对;有漂移退出码 1
|
||||
uv run python scripts/install_skill.py --dest D:/x/skills # 指定别的技能目录(会加 /reimbursement)
|
||||
uv run python scripts/install_skill.py --dest D:/x/skills # 指定别的技能目录(会加 /reimbursement)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SKILL_NAME = "reimbursement"
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
SOURCE = REPO_ROOT / ".agents" / "skills" / SKILL_NAME
|
||||
|
||||
|
||||
def resolve_home() -> Path:
|
||||
"""主目录:环境变量优先。精简过的进程环境里 Path.home() 会抛 RuntimeError。"""
|
||||
for key in ("USERPROFILE", "HOME", "HOMEDRIVE_HOME"):
|
||||
raw = os.environ.get(key, "").strip()
|
||||
if raw:
|
||||
if key == "HOMEDRIVE_HOME":
|
||||
raw = os.path.join(os.environ.get("HOMEDRIVE", "").strip(), os.environ.get("HOME", "").strip())
|
||||
if raw and Path(raw).is_absolute():
|
||||
return Path(raw)
|
||||
try:
|
||||
return Path.home()
|
||||
except RuntimeError:
|
||||
raise SystemExit("无法确定用户主目录:设 DSH_SKILLS_DIR,或用 --dest 指定技能目录") from None
|
||||
|
||||
|
||||
def norm(path_str: str) -> str:
|
||||
"""日志里的路径是 JSON 转义过的双反斜杠,统一成小写正斜杠再比。"""
|
||||
return "".join(ch for ch in path_str if ch != chr(92)).replace("//", "/").lower().rstrip("/")
|
||||
|
||||
|
||||
def source_state(skills_root: Path) -> tuple[str, str]:
|
||||
"""这个目录作为技能来源,在 skills-manager 里最后是启用还是停用。
|
||||
|
||||
踩过的坑:装进了一个被停用的来源目录(~/.dsh/skills),文件明明在,宿主却永远扫不到,
|
||||
比没装更难发现。所以装之前先查最后一条形同实质的记录。
|
||||
"""
|
||||
log = resolve_home() / ".dsh" / "dsh-skills-manager.log"
|
||||
if not log.is_file():
|
||||
return "unknown", ""
|
||||
want = norm(str(skills_root))
|
||||
state, detail = "unknown", ""
|
||||
try:
|
||||
lines = log.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
except OSError:
|
||||
return "unknown", ""
|
||||
for line in lines:
|
||||
if "source-enable" not in line and "source-disable" not in line:
|
||||
continue
|
||||
if want and want in norm(line):
|
||||
state = "disabled" if "source-disable" in line else "enabled"
|
||||
detail = line[-90:]
|
||||
return state, detail
|
||||
|
||||
|
||||
def default_dest() -> Path:
|
||||
"""用户级技能目录:DSH_SKILLS_DIR > ~/.agents/skills(跨工具约定,本机已启用)> ~/.dsh/skills。"""
|
||||
base = os.environ.get("DSH_SKILLS_DIR", "").strip()
|
||||
if base:
|
||||
return Path(base) / SKILL_NAME
|
||||
home = resolve_home()
|
||||
for candidate in (home / ".agents" / "skills", home / ".dsh" / "skills"):
|
||||
if candidate.is_dir():
|
||||
return candidate / SKILL_NAME
|
||||
return (home / ".agents" / "skills") / SKILL_NAME
|
||||
|
||||
|
||||
def collect(base: Path) -> list[Path]:
|
||||
"""技能目录里要同步的文件:SKILL.md 与 references/ 下的 Markdown。"""
|
||||
files: list[Path] = []
|
||||
top = base / "SKILL.md"
|
||||
if top.is_file():
|
||||
files.append(top)
|
||||
ref = base / "references"
|
||||
if ref.is_dir():
|
||||
files.extend(sorted(p for p in ref.glob("*.md") if p.is_file()))
|
||||
return files
|
||||
|
||||
|
||||
def digest(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def validate_source() -> str:
|
||||
"""源目录健康检查:宁可现在报错,也别把一个没有 front matter 的技能装上去。"""
|
||||
if not SOURCE.is_dir():
|
||||
return f"源目录不存在:{SOURCE}"
|
||||
skill_md = SOURCE / "SKILL.md"
|
||||
if not skill_md.is_file():
|
||||
return f"缺少 SKILL.md:{skill_md}"
|
||||
text = skill_md.read_text(encoding="utf-8")
|
||||
if not text.startswith("---\n"):
|
||||
return "SKILL.md 缺少 YAML front matter(宿主靠它注册技能)"
|
||||
front = text.split("---", 2)[1] if text.count("---") >= 2 else ""
|
||||
if "name:" not in front or "description:" not in front:
|
||||
return "front matter 里必须有 name 与 description"
|
||||
return ""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="同步报销技能到用户级技能目录")
|
||||
parser.add_argument("--dest", default="", help="目标技能目录(默认 ~/.dsh/skills/reimbursement)")
|
||||
parser.add_argument("--check", action="store_true", help="只比对不写入")
|
||||
parser.add_argument("--force", action="store_true", help="即使来源被停用也照装")
|
||||
args = parser.parse_args()
|
||||
|
||||
problem = validate_source()
|
||||
if problem:
|
||||
print(f"源技能有问题:{problem}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
dest = Path(args.dest).expanduser() if args.dest else default_dest()
|
||||
if args.dest and dest.name != SKILL_NAME:
|
||||
dest = dest / SKILL_NAME
|
||||
|
||||
state, detail = source_state(dest.parent)
|
||||
if state == "disabled":
|
||||
print(
|
||||
f"警告:{dest.parent} 这个技能来源在 skills-manager 里是【停用】状态,装进去宿主也扫不到。"
|
||||
f"(最后一条记录:{detail})改目标目录或用 --dest 指定已启用的来源。",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if not args.force:
|
||||
return 3
|
||||
elif state == "enabled":
|
||||
print(f"技能来源已启用:{dest.parent}")
|
||||
else:
|
||||
print(f"(查不到 {dest.parent} 的来源启停记录,按已启用处理)")
|
||||
|
||||
new: list[str] = []
|
||||
changed: list[str] = []
|
||||
stale: list[str] = []
|
||||
for src in collect(SOURCE):
|
||||
rel = src.relative_to(SOURCE)
|
||||
target = dest / rel
|
||||
if not target.is_file():
|
||||
new.append(str(rel))
|
||||
elif digest(target) != digest(src):
|
||||
changed.append(str(rel))
|
||||
src_names = {str(p.relative_to(SOURCE)) for p in collect(SOURCE)}
|
||||
if dest.is_dir():
|
||||
for existing in collect(dest):
|
||||
rel = str(existing.relative_to(dest))
|
||||
if rel not in src_names:
|
||||
stale.append(rel)
|
||||
|
||||
same = len(collect(SOURCE)) - len(new) - len(changed)
|
||||
if not new and not changed and not stale:
|
||||
print(f"已是最新:{dest}({same} 个文件一致)")
|
||||
return 0
|
||||
|
||||
print(f"{'检查' if args.check else '同步'}:{SOURCE} -> {dest}")
|
||||
for n in new:
|
||||
print(f" 新增 {n}")
|
||||
for n in changed:
|
||||
print(f" 更新 {n}")
|
||||
for n in stale:
|
||||
print(f" 多余 {n}(安装目录里有、源里没有,需手工删)")
|
||||
|
||||
if args.check:
|
||||
print("存在漂移:跑 uv run python scripts/install_skill.py 同步", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
for src in collect(SOURCE):
|
||||
rel = src.relative_to(SOURCE)
|
||||
target = dest / rel
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(src, target)
|
||||
print(f"已写入 {len(collect(SOURCE))} 个文件。重连宿主后生效。")
|
||||
if (REPO_ROOT / ".agents" / "skills" / SKILL_NAME).is_dir():
|
||||
print(f"提示:项目内同名技能仍在 .agents/skills/{SKILL_NAME}/,两边内容现在一致。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,29 +1,43 @@
|
||||
"""Windows 侧的任务入口(与 Makefile 等价)。
|
||||
|
||||
退出码必须透传:以前 os.system 的返回值被直接丢掉,skill-check 检出漂移、check 门禁失败
|
||||
都会让调用方以为成功了——AGENTS 里「门禁退出码要单独确认」说的就是这个坑。
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
TASKS = {
|
||||
"install": "uv sync && uv run pre-commit install",
|
||||
"check": "uv lock --locked && uv run ruff check . && uv run ruff format --check . "
|
||||
"&& uv run python -m mypy src && uv run deptry .",
|
||||
"test": "uv run python -m pytest --cov --cov-config=pyproject.toml --cov-report=term-missing",
|
||||
"mcp": "uv run python -m src.mcp.server",
|
||||
"mcp-smoke": "uv run python scripts/mcp_smoke.py",
|
||||
"upload": "uv run python -m src.upload.server",
|
||||
"skill-install": "uv run python scripts/install_skill.py",
|
||||
"skill-check": "uv run python scripts/install_skill.py --check",
|
||||
"clean": "rm -rf .venv __pycache__ .pytest_cache .mypy_cache .ruff_cache",
|
||||
}
|
||||
|
||||
# 下划线写法作别名,手打时不用纠结连字符
|
||||
ALIASES = {key.replace("-", "_"): value for key, value in TASKS.items()}
|
||||
|
||||
|
||||
def run_task(task_name):
|
||||
print(f"Running {task_name}...")
|
||||
os.system(
|
||||
"uv run python -m pytest --cov --cov-config=pyproject.toml --cov-report=term-missing"
|
||||
if task_name == "test"
|
||||
else "uv run ruff check . && uv run ruff format --check . && uv run python -m mypy src && uv run deptry ."
|
||||
if task_name == "check"
|
||||
else "uv run python -m src.mcp.server"
|
||||
if task_name == "mcp"
|
||||
else "uv run python -m src.upload.server"
|
||||
if task_name == "upload"
|
||||
else "uv run python scripts/mcp_smoke.py"
|
||||
if task_name == "mcp-smoke"
|
||||
else "uv sync && uv run pre-commit install"
|
||||
if task_name == "install"
|
||||
else "rm -rf .venv __pycache__ .pytest_cache .mypy_cache .ruff_cache"
|
||||
if task_name == "clean"
|
||||
else ""
|
||||
)
|
||||
def run_task(task_name: str) -> int:
|
||||
command = TASKS.get(task_name) or ALIASES.get(task_name)
|
||||
if not command:
|
||||
print("未知任务:" + task_name, file=sys.stderr)
|
||||
print("可选:" + ", ".join(sorted(TASKS)), file=sys.stderr)
|
||||
return 2
|
||||
|
||||
print("Running " + task_name + "...")
|
||||
return os.system(command)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
if len(sys.argv) > 1:
|
||||
run_task(sys.argv[1])
|
||||
raise SystemExit(run_task(sys.argv[1]))
|
||||
print("用法:python tasks.py 任务名", file=sys.stderr)
|
||||
print("可选:" + ", ".join(sorted(TASKS)), file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
|
||||
Reference in New Issue
Block a user