commit 7b24521b437d647592b24a9951d6f5efa1cc6757 Author: chenfuqiang Date: Sun Sep 13 16:04:01 2026 +0800 feat: 结题报告初稿助手首版 v1.0.1(draft-closing-report-default) diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2e39ea3 --- /dev/null +++ b/.env.example @@ -0,0 +1,54 @@ +# 运行模式:决定本技能用模拟数据还是真实浏览器/接口去执行 +OPENCLAW_TEST_TARGET=real_rpa # 可选:mock=本地模拟,simulator_rpa=仿真站点,real_api=真实接口,real_rpa=真实浏览器;模板默认真实浏览器;本地单测/CI 请用 mock + +# 目标网站地址:技能要访问的网站或服务地址 +TARGET_BASE_URL=https://sandbox.jc2009.com # 填写完整网址(含 https://);换环境时再改,一般保持默认即可 + +# 默认登录账号:仅填写非敏感的账号标识(如工号/登录名/手机号) +DEFAULT_LOGIN_ID= # 留空=按平台自动选用或登记;有值则按该标识筛选;不要填密码 + +# 默认账号编号:仅填账号管理里的纯数字主键(一般留空) +DEFAULT_ACCOUNT_ID= # 留空=自动;只填纯数字;手机号请填上方登录账号 + +# 是否显示浏览器窗口:网页自动化时是否弹出可见浏览器 +OPENCLAW_BROWSER_HEADLESS=0 # 0=显示窗口(必须,便于介入与排查);1=后台静默(勿作日常用法) + +# 浏览器兼容配置:仅在项目授权与平台规则允许时使用共享兼容能力 +OPENCLAW_PLAYWRIGHT_STEALTH=1 # 1=使用共享兼容配置,0=关闭;不得用于伪造身份或绕过平台限制 + +# 是否录制操作过程:用于留证、复现问题或向他人演示 +OPENCLAW_RECORD_VIDEO=0 # 0=不录屏(默认),1=录屏并生成带说明的视频文件;需要留证时再改为 1 + +# 失败时是否截图:任务出错时自动保存现场截图,便于排查 +OPENCLAW_ARTIFACTS_ON_FAILURE=1 # 1=失败时截图(推荐),0=不截图 + +# 步骤最短等待:两个正常业务动作之间随机停顿的下限 +STEP_DELAY_MIN=1.0 # 单位:秒;一般保持默认,页面不稳定时可略调大 + +# 步骤最长等待:两步操作之间随机停顿的上限 +STEP_DELAY_MAX=3.0 # 单位:秒;默认与最短等待组成 1~3 秒节奏;状态等待须另行判断 + +# 人工处理超时:遇到滑块、验证码、二次验证时,等待人工处理的最长时间 +HUMAN_WAIT_TIMEOUT=180 # 单位:秒;来不及处理时可调大,如 300 + +# 浏览器启动超时(可选):限制 launch_persistent_browser 最长时间;空=不限制(默认) +# OPENCLAW_BROWSER_LAUNCH_TIMEOUT_MS=60000 # 单位:毫秒;慢机器/限频时可开启并调大 + +# 以下为可选目录配置。一般不用改;取消注释并填写后,才覆盖默认子文件夹。 +# 填写相对路径时,相对于本技能的数据目录(不是当前工作目录)。 +# 用户可见只有 input(上行)与 output(下行);见 development/DATA_PATHS.md。 + +# 输入目录:用户/Agent 放入的待处理文件(清单、CSV、产品表等) +# SKILL_INPUT_DIR=input # 相对本技能数据目录;留空或保持注释则用默认 input + +# 输出目录:技能/宿主交给用户拿走的结果(视频、报表、导出文件等) +# SKILL_OUTPUT_DIR=output # 相对本技能数据目录;留空或保持注释则用默认 output + +# 上传目录:待发布到外部平台的 staging(可选,不上侧栏) +# SKILL_UPLOAD_DIR=uploads # 相对本技能数据目录;留空或保持注释则用默认 uploads + +# 缓存目录:临时缓存文件的位置(不上侧栏) +# SKILL_CACHE_DIR=cache # 相对本技能数据目录;留空或保持注释则用默认 cache + +# 临时目录:运行中临时文件的位置(不上侧栏) +# SKILL_TEMP_DIR=temp # 相对本技能数据目录;留空或保持注释则用默认 temp diff --git a/.github/workflows/release_skill.yaml b/.github/workflows/release_skill.yaml new file mode 100644 index 0000000..0b0c53c --- /dev/null +++ b/.github/workflows/release_skill.yaml @@ -0,0 +1,13 @@ +name: 技能自动化发布 +on: + push: + tags: ["v*"] + +jobs: + release: + uses: client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-release-skill.yaml@main + secrets: + PYARMOR_REG_B64: ${{ secrets.PYARMOR_REG_B64 }} + with: + artifact_platform: windows + pyarmor_platform: windows.x86_64 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..08d4ec6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +.env +*.env.local +.pytest_cache/ +.coverage +htmlcov/ +dist/ +build/ +*.egg-info/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4413450 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog + +本文件对应技能市场 **「更新日志」** Tab。 +发布 CI 会按当前 tag 版本提取匹配的 `##` 小节,写入该版本的 `changelog`。 + +## 1.0.1 + +- 首个版本:结题报告"一键初稿生成"上线 +- 结题项目/预期指标/成果清单/决算明细台账维护(表单 + JSON 批量导入) +- 指标对比自动生成"完成情况"章节(超额完成/持平/未达标/数据待补) +- 决算明细按科目汇总并转规范描述句(占比、合计、预算符合性说明) +- 叙事素材要点提取(逐条标注来源),叙事章节仅素材+占位,防编造 +- Word 版《结题报告(初稿)》生成(python-docx)+ HTML 预览;批量生成(async Action) +- 宿主入口:对话任务(生成/导入/查询)、数据管理(表单/按钮/HTML 报告) diff --git a/DEMO.md b/DEMO.md new file mode 100644 index 0000000..50fb892 --- /dev/null +++ b/DEMO.md @@ -0,0 +1,16 @@ +--- +demo_video_url: "" +--- + +演示视频录制后请把地址填入上方 `demo_video_url`。以下是无视频时的图文演示脚本。 + +## 演示一:一键初稿(对话) + +1. 对话任务:"帮我生成 NSFC2026003 的结题报告初稿。" +2. 助手生成 Word + HTML 预览,并解读:论文指标 超额完成(4/3)、专利 持平(1/1)、决算合计 8.6 万元(占比表)。 +3. 打开 HTML 预览:数据章节已成文,叙事章节有素材要点与黄底 [待补充] 占位。 + +## 演示二:批量准备结题季(数据管理) + +1. "结题项目"表批量导入本院待结题项目(含预期指标)。 +2. 对话:"把所有待结题项目都生成初稿"——任务中心逐项进度,完成后逐份取 Word。 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..bb90574 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 深圳匠厂科技有限公司 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..dfcb25b --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +--- +description: "结题季的一键初稿:自动对比预期指标与实际成果、把决算明细写成规范描述,组装可直接修改的 Word 版《结题报告(初稿)》。" +--- + +结题报告初稿助手把"写结题报告"从两周压缩到三天:数据章节(完成情况、经费决算、成果清单)由台账自动生成、数字零转录差错;叙事章节提供素材要点与占位指引,由项目负责人本人终审。初稿严禁编造数据——每个数字都可溯源到台账。 + +--- + +## 这个技能能帮你做什么 + +- 维护结题项目、预期成果指标、实际成果清单与经费决算明细(表单录入或 JSON 批量导入) +- 自动对比预期与实际:按类型标记 超额完成 / 持平 / 未达标,生成"完成情况"章节 +- 把决算流水按科目汇总并转成规范描述句(含占比与合计,金额不改写) +- 从代表性成果与年度报告摘句提取叙事素材要点(逐条标注来源) +- 一键组装 Word 版《结题报告(初稿)》(含目录骨架与六章节)+ HTML 预览 +- 批量为全部待结题项目生成初稿,记录入库可查 + +## 适合什么场景 + +- 纵向项目临近结题,负责人要起草结题报告 +- 科研秘书协助本院多位老师批量准备结题材料 +- 决算数字多、怕转录出错,想要"数字只录一次"的生成方式 + +## 使用前需要准备什么 + +- 项目台账:项目编号、名称、负责人、执行期、预期成果指标(如 论文 3 篇、专利 1 项) +- 实际成果清单:论文/专利/人才培养等成果的名称、期刊/专利号、日期 +- 经费决算明细:按科目的支出金额(可从财务导出后整理) +- 需要生成 Word 文件:技能依赖 python-docx(安装技能时自动就绪;异常时 HTML 预览仍可用) + +## 怎么使用 + +支持 **数据管理**(录台账、点生成、重建预览)与 **对话任务**("帮我生成 X 项目的结题初稿")两种方式;批量生成走对话发起。逐步操作请查看技能市场「使用教程」Tab。 + +## 执行后你会得到什么 + +- 一份 Word 版《结题报告(初稿)》(output 目录可取):完成情况与决算章节已自动成文,叙事章节有素材要点与占位指引 +- 一份 HTML 预览(数据管理侧栏可直接看) +- 初稿记录入库:可查历史、可统计 + +## 注意事项 + +- 初稿**不是终稿**:叙事章节必须由项目负责人本人撰写;所有数字请对照原始证明材料核对 +- 报告为通用结题骨架,提交前请按本单位/资助机构模板调整格式 +- 缺成果或缺决算时对应章节生成占位提示,不会编造内容 +- 初稿涉及项目经费信息,请注意不要外传 + +## 常见问题 + +### 叙事章节为什么是空的? + +技能只负责"素材要点 + 占位",叙事正文需要你结合素材撰写(或让 AI 按素材起草、你逐句核对),这是防止编造研究成果的机制设计。 + +### Word 文件在哪里? + +生成后保存在技能数据目录的 output 下;在对话里让助手报路径,或在数据管理初稿记录中查看文件路径。预览版可随时在侧栏打开。 + +### 指标和实际成果对不上怎么办? + +完成情况章节会明确标记"未达标"及其差距——这正是结题审查要说明的部分;请补充说明或核对台账。 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..657e660 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,20 @@ +# 安全漏洞披露 + +本仓库是匠厂技能开发模板。请把**未公开的安全漏洞**发给深圳匠厂科技有限公司,不要在公开 Issue、PR 或讨论区里贴完整复现步骤、PoC 或利用细节。 + +## 怎么报告 + +请通过匠厂官网联系渠道提交:[https://www.jc2009.com/](https://www.jc2009.com/) + +建议在联系时注明: + +- 标题或开头带 `[Security]` +- 影响的仓库 / 版本(例如 `skill-template`、tag `v1.0.x`) +- 问题类型、影响范围、复现步骤 +- 若已有修复思路,可一并说明 + +我们会确认收悉,并在有结论后告知是否修复、是否公开披露。 + +## 不在本页范围 + +本页只说明**如何向我们报告漏洞**。技能运行时的权限、RPA 合规、加密发布等产品安全设计,见 `development/` 与 `references/`,不要写进本文件。 diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..07a4f3a --- /dev/null +++ b/SKILL.md @@ -0,0 +1,108 @@ +--- +name: 结题报告初稿助手 +description: "结题报告一键初稿:对比预期指标与实际成果生成完成情况章节,决算明细转规范描述,组装可直接修改的 Word 版《结题报告(初稿)》,叙事章节提供素材要点,严禁编造数据。" +version: 1.0.1 +author: 深圳匠厂科技有限公司 +metadata: + openclaw: + slug: draft-closing-report-default + platform_kit_min_version: "1.2.11" + emoji: "📄" + category: "科研" + brands: + - jiangchang + - huashang + developer_ids: + - 79017 # 开发者本人匠厂用户 ID(设置 → 用户信息 → 复制) +allowed-tools: + - bash +--- + +# 结题报告初稿助手(draft-closing-report-default) + +结题报告"一键初稿生成":对比**预期成果指标**与**实际成果清单**自动生成"完成情况"章节(超额完成/持平/未达标),把**经费决算明细**转成规范描述语,按通用结题骨架组装 Word 版《结题报告(初稿)》(含目录骨架与来源标注)。数据章节全部模板确定性生成;叙事章节只给素材要点与占位指引,须本人终审——**严禁编造数据与虚构成果**。 + +## Agent / 宿主编排 + +- **Agent 是入口与编排者**:帮助教师把成果证明整理成结构化录入、触发初稿生成、解读指标对比结果。 +- 台账数据来自本地库;Agent 不得编造成果、金额或指标;叙事起草时只允许重组初稿中的素材要点并逐句可溯源。 +- Word 渲染由技能依赖 python-docx 完成;异常时 HTML 预览兜底可用。 +- 本技能无 RPA、无浏览器自动化;不修改科研/财务系统数据。 + +## 能力类型与触发 + +| 你要做什么 | Agent 怎么做 | 进任务中心? | +|------------|--------------|--------------| +| 查台账 / 查初稿 / 统计 | 短 CLI:`list` / `list-drafts` / `stats` | 否 | +| 生成单个项目初稿(同步) | `run_skill_action` → `draft-report`(`input.projectNo`) | 否 | +| 批量生成全部待结题初稿 | `run_skill_action` → `draft-batch-pending` | 是(该 Action 为 async) | +| 录入项目/成果/决算 | 数据管理表上操作,或 `run_skill_action` → `add-*`;批量走 `import-data` | 否 | +| 重建 HTML 预览 | `run_skill_action` → `render-html` | 否 | +| 用户只问用法 / 能做什么 | 读根目录 `README.md`;逐步操作再读 `TUTORIAL.md` | — | + +进不进任务中心**只**看该 Action 的 `executionProfile`,与触发入口**无关**。本技能为"数据齐备即可生成"型能力,不存在「准备 vs 执行」两段式。本技能无网页 RPA;宿主账号规范见 `development/ACCOUNT_MANAGER.md`。 + +## 用户意图路由表 + +| 用户意图 | Agent 怎么做 | 进任务中心? | +|------------------------|--------------|--------------| +| "帮我生成 X 项目的结题初稿" | `run_skill_action` → `draft-report`,`input.projectNo` | 否 | +| "把所有待结题项目都生成初稿" | `run_skill_action` → `draft-batch-pending` | 是(因该 Action 为 async) | +| "录入成果/决算/预期指标" | 数据管理表单 `add-*`;批量 JSON 交给 `import-data` | 否 | +| "我的初稿写完了吗 / 文件在哪" | 短 CLI:`list-drafts`;详情 `show-draft --id N` | 否 | +| "重新生成预览" | `run_skill_action` → `render-html` | 否 | +| "统计" | 短 CLI:`stats` | 否 | + +**硬规则:** + +1. `draft-batch-pending` 是本技能唯一 async Action;禁止用 bash 跑它再轮询等待。 +2. 成果、金额、指标必须来自用户/台账,Agent 不得编造;缺数据引导补录而非虚构。 +3. 解读初稿时必须说明"数据章节请核对、叙事章节须本人终审"。 +4. 一个业务能力一个 Action;底层同一 `scripts/main.py` CLI。 + +Agent 执行契约细节:`references/CLI.md`、`references/ACTIONS.md`、`references/SCHEMA.md`。 + +## 共享 Python(必读) + +宿主共享 runtime 在 `{JIANGCHANG_DATA_ROOT}/python-runtime/.venv`,并已前置到 PATH(同时设 `VIRTUAL_ENV`)。短 CLI 一条命令直接跑: + +```bash +python {baseDir}/scripts/main.py health +python {baseDir}/scripts/main.py version +python {baseDir}/scripts/main.py init-db +python {baseDir}/scripts/main.py draft-report --project-no X +``` + +- 公共库来自共享 venv 的 `jiangchang-platform-kit>=1.2.11`;不要 vendor。 +- **禁止**用 `uv run python` 跑技能脚本;**禁止** Agent 自行 `pip install`。 +- 本技能特有依赖 `python-docx` 已在 requirements.txt 声明,由宿主安装技能时写入共享 venv。 + +## 面向用户问答(LLM 规则) + +- 本文(`SKILL.md`)是 LLM / OpenClaw 平台读取的技能入口,**不是**用户市场说明。 +- 当用户询问"这个技能是做什么的 / 怎么用 / 适合什么场景",**必须优先读取**根目录 `README.md` 与 `TUTORIAL.md`,用业务语言回答。 +- **回答「怎么用 / 能做什么」时的要求**:优先读 `TUTORIAL.md`;**禁止**在回答中粘贴 `python` 命令行(除非用户明确索要);**禁止**用 `dir` / `ls` 列技能目录。 +- **禁止**向普通用户暴露 JSON 字段名、表名、`error_code` 等实现细节(除非用户问技术实现)。 + +## 文档分工 + +| 文档 | 读者 | 用途 | +|------|------|------| +| 根目录 `README.md` | 普通用户 | 市场 **说明** Tab(`readme_md`) | +| 根目录 `TUTORIAL.md` | 普通用户 | 市场 **教程** Tab(`tutorial_md`) | +| 根目录 `DEMO.md` | 普通用户 | 市场 **演示** Tab(`demo_video_url` + `demo_md`) | +| 根目录 `CHANGELOG.md` | 普通用户 | 市场 **更新日志** Tab(`changelog`) | +| `SKILL.md`(本文) | LLM / OpenClaw 平台 | 技能入口、触发与运行契约摘要 | +| `references/` | Agent 编排 / 调用 | CLI、Action、schema 契约 | +| `development/` | 开发者 / AI 编程代理 | 需求、开发教程、测试、技术规范 | + +## 目录约定 + +- CLI 入口固定为 `scripts/main.py`;业务逻辑按 `cli / db / service / util` 分层。 +- 业务表:`closing_projects`(结题项目)、`achievements`(成果清单)、`expense_items`(决算明细)、`report_drafts`(初稿记录)。 +- 交付文件:Word 初稿写 `output/`,HTML 预览写 `html/`,重建走固定 Action id `render-html`。 + +## 平台元数据 + +- `metadata.openclaw.developer_ids`:发布后默认可见的用户 ID 列表;**发布前必须替换为本人的匠厂用户 ID**。 +- 当 `access_scope = 0`(不公开)时,平台会把 `developer_ids` 中的用户自动补写到 `skill_user_access`。 diff --git a/TUTORIAL.md b/TUTORIAL.md new file mode 100644 index 0000000..03145ca --- /dev/null +++ b/TUTORIAL.md @@ -0,0 +1,72 @@ +数据管理与对话任务是本技能两种**平行**的用法:科研秘书在表上维护台账、点生成,走数据管理;教师直接要初稿,走对话。本技能不提供定时任务(初稿须本人核对后使用,批量生成也由人在评审季发起)。 + +## 本技能做什么 + +技能维护四份数据:结题项目(含预期成果指标)、成果清单、决算明细、初稿记录。生成初稿时:① 对比预期与实际成果,按类型标记 超额完成/持平/未达标,自动写"完成情况"章节;② 把决算流水按科目汇总并转成规范描述句,写"经费决算"章节;③ 从代表性成果与年度报告摘句提取叙事素材要点;④ 按"背景-进展-完成情况-决算-成果清单-展望"骨架组装 Word 初稿与 HTML 预览。1/2/6 章为素材+占位,须本人撰写。 + +## 开始前 + +- 录入结题项目与预期指标(JSON 数组,如 [{"type":"论文","target":3,"unit":"篇"}]) +- 录入成果清单与决算明细(可从科研系统/财务导出整理后批量导入) +- 确认需要 Word 文件(python-docx 依赖已随技能安装) + +## 入口一:数据管理 + +左侧表列表中能看到本技能的四张表:结题项目、成果清单、决算明细、初稿记录。 + +### 录入台账 + +1. "结题项目"表点顶栏「录入结题项目」:项目编号必填,预期指标粘贴 JSON;同一编号再次提交即更新 +2. "成果清单"表点顶栏「录入成果」:选类型、填名称与期刊/专利号;代表性成果设为 1 +3. "决算明细"表点顶栏「录入决算明细」:科目、金额(元)、日期 + +### 生成初稿 + +1. 在"结题项目"表点顶栏「生成结题初稿」,填项目编号提交 +2. 完成后初稿记录表出现记录;左侧 HTML 报告区打开 `draft-preview`/`draft-编号` 预览 +3. Word 文件路径在初稿记录的"Word 初稿"列,从 output 目录取用 + +## 入口二:对话任务 + +新建任务,选择本技能后直接说事。 + +### 生成初稿 + +> 帮我生成 NSFC2026003 的结题报告初稿 + +助手生成后报出 Word 文件路径,并解读完成情况(哪些超额、哪些未达标)与素材要点数量。 + +### 批量生成 + +> 把所有待结题项目都生成初稿 + +助手批量生成并逐项汇报进度与结果(任务中心可见)。 + +### 补录与查询 + +- "录入一篇论文:《某某研究》,Applied Energy,2026-05" → 成果入库 +- "查一下 X 项目的决算" → 返回明细 +- "重新生成初稿预览" → 重建 HTML + +## 入口三:定时任务 + +本技能不需要定时:初稿须本人核对后使用,批量生成也在评审季由人发起;"到期前提醒"依赖宿主通知能力,属后续扩展。 + +## 主能力对照表 + +| 能力 | 数据管理 | 对话任务 | +|------|----------|----------| +| 生成初稿(单项目) | 「生成结题初稿」按钮 | 直接说项目编号 | +| 批量生成 | 不支持 | 支持(进任务中心,有进度) | +| 录入台账 | 顶栏按钮 + 表格直改 | 对话说明信息 | +| 查看预览 | 「重建初稿预览」+ 侧栏 | 让助手重建后打开 | +| 查询与统计 | 初稿记录表 | 对话查询 | +| 定时生成 | 不提供 | 不提供 | + +## 常见卡住怎么办 + +- **提示"项目不存在或状态不是待结题"**:先在"结题项目"录入该项目,状态为"待结题" +- **完成情况全是"未达标"**:成果清单没录入或类型与预期指标不一致(类型须为 论文/专利/人才培养/其他) +- **Word 打不开/没有生成**:python-docx 依赖异常,重装技能;先用 HTML 预览查看内容 +- **叙事章节只有素材**:这是机制设计——素材要点来自你的成果与年度报告,正文须本人撰写 +- **决算章节提示"明细尚未录入"**:在"决算明细"表补录后重新生成 diff --git a/acceptance-reports/README.md b/acceptance-reports/README.md new file mode 100644 index 0000000..893a44e --- /dev/null +++ b/acceptance-reports/README.md @@ -0,0 +1,30 @@ +# 验收报告目录(acceptance-reports) + +本目录存放**技能交活验收报告**,供技术人员查看并按未通过项整改。 + +## 谁写、写什么 + +- 验收人(或 AI 编程工具)对照 `development/CHECKLIST.md` 执行验收后,**必须**在本目录落盘报告。 +- 标准与条目定义见:`development/CHECKLIST.md`(含强制输出规则)。 + +## 命名(强制) + +```text +YYYYMMDD-HHMMSS-acceptance.md +``` + +示例:`20260722-175000-acceptance.md` + +- 使用验收执行时的本地时间 +- 不要覆盖旧报告;保留历史便于对比是否修好 + +## 技术人员怎么用 + +1. `git pull` 拉取含报告的最新代码(或验收人推送后的提交) +2. 打开本目录中**时间戳最新**的一份 `*-acceptance.md` +3. 只看文末「给技术人员的修改清单」及所有 `FAIL` / 未通过的 `NEED_MANUAL` +4. 改完后告知验收人复验;新一轮会再生成一份新时间戳报告 + +## 模板仓说明 + +本 `skill-template` 仓库通常不产生业务验收报告;此目录随模板复制到新技能后使用。 diff --git a/assets/README.md b/assets/README.md new file mode 100644 index 0000000..1355737 --- /dev/null +++ b/assets/README.md @@ -0,0 +1,13 @@ +# assets + +- `actions.json`:Skill Action manifest 最小示例(按入口定位表决定是否保留/扩展;见 `development/PRODUCT_SURFACES.md`)。每个 action **必须**显式声明 `executionProfile`(`sync` \| `async`)。默认仅 `health` / `version` / `config-path`,不放 toolbar,故无 `bind` 示例。 +- **业务技能注意:** 若主路径在数据管理,准备/载入/导入类 Action 应挂 `toolbar` + `bind.tables`(用户看得见),**不要**只挂 `skill-detail` 却在教程里写成「技能详情点按钮」。见 `development/TUTORIAL_SURFACES.md` §5、`references/ACTIONS.md`「用户可见性」。 +- `html/example-report.html`:HTML 报告页最小壳(`{{TITLE}}` / `{{GENERATED_AT}}` / `{{BODY}}`);含数据管理 iframe 预览布局基线(流体铺满、禁横向滚动);渲染后须写入数据目录 `html/`,见 `references/ACTIONS.md`「HTML 页面报告」。 +- `examples/`:CLI 成功输出形状示例、Action 形状参考(含 `path-root-file-action.example.json`、`selection-label-column.action.example.json`、`render-html.action.example.json` 等;虚构路径与数据)。 +- `schemas/`:轻量 JSON Schema(`skill-actions.schema.json` 为**新技能严格规范**)。 + +`skill-actions.schema.json` 使用 `additionalProperties: false`。正式支持 `bind.tables`(toolbar Action 必填)与可选 `bind.inputMapping` / `columnMapping` / **`labelColumn`**(选择摘要标签列);placements 含 `toolbar` / `row` / `batch` / `cron` / `agent` / `skill-detail`(对齐匠厂宿主)。数据管理 confirm/form 弹层默认展示「已选择」摘要;**form + 有摘要时映射 `readOnly` 默认不占参数区(仍提交)**,可选 **`revealInForm`** 强制显示。可选 `hoverHint` 为数据管理工具栏悬停(省略不显示,不回退)。`concurrency`/`locks` 仍为预留。`placements` 与 `executionProfile` 正交——进任务中心只看 `async`,与入口无关。表级 Action 与 Agent 契约见 [`references/ACTIONS.md`](../references/ACTIONS.md);开发深度规范见 [`development/SKILL_ACTION_RUNTIME.md`](../development/SKILL_ACTION_RUNTIME.md)。 + +- 用户市场四 Tab 见根目录 [`README.md`](../README.md) / [`TUTORIAL.md`](../TUTORIAL.md) / [`DEMO.md`](../DEMO.md) / [`CHANGELOG.md`](../CHANGELOG.md);写法契约见 [`development/TUTORIAL_SURFACES.md`](../development/TUTORIAL_SURFACES.md) +- Agent 调用/编排参考见 [`references/`](../references/)(含 [`ACTIONS.md`](../references/ACTIONS.md)) +- 开发规范见 [`development/`](../development/) diff --git a/assets/actions.json b/assets/actions.json new file mode 100644 index 0000000..134112b --- /dev/null +++ b/assets/actions.json @@ -0,0 +1,522 @@ +{ + "schemaVersion": 1, + "skill": "draft-closing-report-default", + "actions": [ + { + "id": "draft-report", + "label": "生成结题初稿", + "description": "按台账对比预期指标与实际成果、转换决算描述,生成 Word 版《结题报告(初稿)》与 HTML 预览。", + "placements": [ + "agent", + "toolbar" + ], + "executionProfile": "sync", + "interaction": "form", + "bind": { + "tables": [ + "closing_projects" + ] + }, + "entrypoint": { + "type": "cli", + "command": "draft-report", + "args": [ + "--project-no", + "{{projectNo}}" + ] + }, + "inputSchema": { + "type": "object", + "properties": { + "projectNo": { + "type": "string", + "title": "项目编号", + "description": "必填,待结题项目编号。" + } + }, + "required": [ + "projectNo" + ] + }, + "hostEffects": { + "onSuccess": [ + "reloadTable" + ] + } + }, + { + "id": "draft-batch-pending", + "label": "批量生成初稿", + "description": "为全部待结题项目批量生成初稿(任务中心可见进度)。", + "placements": [ + "agent" + ], + "executionProfile": "async", + "entrypoint": { + "type": "cli", + "command": "draft-batch-pending", + "args": [] + } + }, + { + "id": "add-project", + "label": "录入结题项目", + "description": "按项目编号录入/更新结题项目与预期指标(幂等)。", + "placements": [ + "toolbar" + ], + "executionProfile": "sync", + "interaction": "form", + "bind": { + "tables": [ + "closing_projects" + ] + }, + "entrypoint": { + "type": "cli", + "command": "add-project", + "args": [ + "--project-no", + "{{projectNo}}", + "--name", + "{{name}}", + "--pi", + "{{pi}}", + "--department", + "{{department}}", + "--program", + "{{program}}", + "--start", + "{{start}}", + "--end", + "{{end}}", + "--expected-json", + "{{expectedJson}}", + "--excerpt", + "{{excerpt}}", + "--status", + "{{status}}" + ] + }, + "inputSchema": { + "type": "object", + "properties": { + "projectNo": { + "type": "string", + "title": "项目编号", + "description": "必填。" + }, + "name": { + "type": "string", + "title": "项目名称" + }, + "pi": { + "type": "string", + "title": "负责人" + }, + "department": { + "type": "string", + "title": "所属学院" + }, + "program": { + "type": "string", + "title": "项目类型" + }, + "start": { + "type": "string", + "title": "执行期开始", + "display_type": "date" + }, + "end": { + "type": "string", + "title": "执行期结束", + "display_type": "date" + }, + "expectedJson": { + "type": "string", + "title": "预期指标 JSON", + "description": "如 [{\"type\":\"论文\",\"target\":3,\"unit\":\"篇\"}]", + "display_type": "textarea" + }, + "excerpt": { + "type": "string", + "title": "年度报告摘句", + "description": "分号分隔关键句。", + "display_type": "textarea" + }, + "status": { + "type": "string", + "title": "状态", + "enum": [ + "待结题", + "初稿已生成", + "已提交" + ] + } + }, + "required": [ + "projectNo" + ] + }, + "hostEffects": { + "onSuccess": [ + "reloadTable" + ] + } + }, + { + "id": "add-achievement", + "label": "录入成果", + "description": "向成果清单添加一条成果。", + "placements": [ + "toolbar" + ], + "executionProfile": "sync", + "interaction": "form", + "bind": { + "tables": [ + "achievements" + ] + }, + "entrypoint": { + "type": "cli", + "command": "add-achievement", + "args": [ + "--project-no", + "{{projectNo}}", + "--type", + "{{type}}", + "--title", + "{{title}}", + "--venue", + "{{venue}}", + "--date", + "{{date}}", + "--representative", + "{{representative}}" + ] + }, + "inputSchema": { + "type": "object", + "properties": { + "projectNo": { + "type": "string", + "title": "项目编号", + "description": "必填。" + }, + "type": { + "type": "string", + "title": "成果类型", + "enum": [ + "论文", + "专利", + "人才培养", + "其他" + ] + }, + "title": { + "type": "string", + "title": "成果名称" + }, + "venue": { + "type": "string", + "title": "期刊/专利号" + }, + "date": { + "type": "string", + "title": "日期", + "display_type": "date" + }, + "representative": { + "type": "string", + "title": "代表性(1/0)", + "enum": [ + "1", + "0" + ] + } + }, + "required": [ + "projectNo", + "type", + "title" + ] + }, + "hostEffects": { + "onSuccess": [ + "reloadTable" + ] + } + }, + { + "id": "add-expense", + "label": "录入决算明细", + "description": "向决算明细添加一条支出。", + "placements": [ + "toolbar" + ], + "executionProfile": "sync", + "interaction": "form", + "bind": { + "tables": [ + "expense_items" + ] + }, + "entrypoint": { + "type": "cli", + "command": "add-expense", + "args": [ + "--project-no", + "{{projectNo}}", + "--category", + "{{category}}", + "--amount", + "{{amount}}", + "--date", + "{{date}}", + "--description", + "{{description}}" + ] + }, + "inputSchema": { + "type": "object", + "properties": { + "projectNo": { + "type": "string", + "title": "项目编号", + "description": "必填。" + }, + "category": { + "type": "string", + "title": "科目", + "description": "如 差旅费、设备费。" + }, + "amount": { + "type": "number", + "title": "金额(元)" + }, + "date": { + "type": "string", + "title": "发生日期", + "display_type": "date" + }, + "description": { + "type": "string", + "title": "原始摘要" + } + }, + "required": [ + "projectNo", + "category", + "amount" + ] + }, + "hostEffects": { + "onSuccess": [ + "reloadTable" + ] + } + }, + { + "id": "import-data", + "label": "批量导入数据", + "description": "粘贴 JSON 批量导入项目/成果/决算。", + "placements": [ + "agent" + ], + "executionProfile": "sync", + "interaction": "form", + "entrypoint": { + "type": "cli", + "command": "import", + "args": [ + "--projects-json", + "{{projectsJson}}", + "--achievements-json", + "{{achievementsJson}}", + "--expenses-json", + "{{expensesJson}}" + ] + }, + "inputSchema": { + "type": "object", + "properties": { + "projectsJson": { + "type": "string", + "title": "项目 JSON", + "display_type": "textarea" + }, + "achievementsJson": { + "type": "string", + "title": "成果 JSON", + "display_type": "textarea" + }, + "expensesJson": { + "type": "string", + "title": "决算 JSON", + "display_type": "textarea" + } + } + } + }, + { + "id": "list-data", + "label": "查询台账", + "description": "查询结题项目/成果/决算。", + "placements": [ + "agent" + ], + "executionProfile": "sync", + "entrypoint": { + "type": "cli", + "command": "list", + "args": [ + "--kind", + "{{kind}}", + "--project-no", + "{{projectNo}}", + "--status", + "{{status}}" + ] + }, + "inputSchema": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "title": "类型", + "enum": [ + "projects", + "achievements", + "expenses" + ] + }, + "projectNo": { + "type": "string", + "title": "项目编号" + }, + "status": { + "type": "string", + "title": "状态", + "enum": [ + "待结题", + "初稿已生成", + "已提交" + ] + } + } + } + }, + { + "id": "list-drafts", + "label": "查询初稿记录", + "description": "查看已生成的初稿记录。", + "placements": [ + "agent" + ], + "executionProfile": "sync", + "entrypoint": { + "type": "cli", + "command": "list-drafts", + "args": [ + "--limit", + "{{limit}}" + ] + }, + "inputSchema": { + "type": "object", + "properties": { + "limit": { + "type": "number", + "title": "条数" + } + } + } + }, + { + "id": "render-html", + "label": "重建初稿预览", + "description": "重建初稿 HTML 预览(缺省最近 10 份)。", + "placements": [ + "toolbar", + "skill-detail", + "agent" + ], + "executionProfile": "sync", + "interaction": "direct", + "bind": { + "tables": [ + "report_drafts" + ] + }, + "entrypoint": { + "type": "cli", + "command": "render-html", + "args": [] + }, + "steps": [ + { + "when": "afterSuccess", + "do": "refreshCatalog" + } + ] + }, + { + "id": "stats", + "label": "统计", + "description": "查看项目状态分布与成果/决算/初稿数量。", + "placements": [ + "agent", + "skill-detail" + ], + "executionProfile": "sync", + "entrypoint": { + "type": "cli", + "command": "stats", + "args": [] + } + }, + { + "id": "health", + "label": "运行检查", + "description": "检查技能运行环境。", + "placements": [ + "skill-detail" + ], + "executionProfile": "sync", + "entrypoint": { + "type": "cli", + "command": "health", + "args": [] + } + }, + { + "id": "version", + "label": "查看版本", + "description": "返回技能版本。", + "placements": [ + "skill-detail" + ], + "executionProfile": "sync", + "entrypoint": { + "type": "cli", + "command": "version", + "args": [] + } + }, + { + "id": "config-path", + "label": "查看配置位置", + "description": "返回用户 .env 路径。", + "placements": [ + "skill-detail" + ], + "executionProfile": "sync", + "entrypoint": { + "type": "cli", + "command": "config-path", + "args": [] + } + } + ] +} \ No newline at end of file diff --git a/assets/examples/README.md b/assets/examples/README.md new file mode 100644 index 0000000..b205908 --- /dev/null +++ b/assets/examples/README.md @@ -0,0 +1,9 @@ +# 示例 JSON + +- `version-response.json`:`python main.py version` 成功时 stdout 单行对象的形状参考。 +- `skill-policy-snapshot.*.example.json`:Data Policy Snapshot 形状参考(见 `references/POLICY.md`)。 +- `skill-cell-presentation.example.json`:数据管理单元格呈现文档形状参考(见 `references/CELL_PRESENTATION.md`)。 +- `render-html.action.example.json`:HTML 报告「重新生成」Action 形状参考(固定 id `render-html`;见 `references/ACTIONS.md`)。 +- `lookup-action.example.json`:Action 内联 Lookup 形状参考(单选 + `multiple: true` 多选并列;跨表引用勿填编号;见 `references/ACTIONS.md`「跨表引用:Lookup」)。 +- `visible-when-action.example.json`:Action 参数字段条件显隐 `visibleWhen` 形状参考(互斥模式联动;见 `references/ACTIONS.md`「字段条件显隐」;≠ Policy L3)。 +- `replaces-create.action.example.json` / `replaces-update.action.example.json` / `replaces-delete.action.example.json` / `replaces-import.action.example.json`:数据管理默认按钮接管(`replaces`)形状参考;**import 时 UI 仍用宿主「导入数据」面板**,确认后调技能写库(见 `references/ACTIONS.md`「`replaces`」)。 diff --git a/assets/examples/lookup-action.example.json b/assets/examples/lookup-action.example.json new file mode 100644 index 0000000..9757f3b --- /dev/null +++ b/assets/examples/lookup-action.example.json @@ -0,0 +1,83 @@ +{ + "_comment": "形状参考:Action 参数引用同库另一张表时用 Lookup。单选与多选同一 lookup 对象,多选只加 multiple:true。完整契约见 references/ACTIONS.md「跨表引用:Lookup」与 references/SCHEMA.md。", + "schemaVersion": 1, + "skill": "your-skill-slug", + "actions": [ + { + "id": "send-with-template", + "label": "按模板发送(示例)", + "description": "演示内联 lookup 单选:从 message_templates 选模板;-- 表示自动挑选。", + "placements": ["toolbar"], + "executionProfile": "async", + "interaction": "form", + "bind": { + "tables": ["leads"] + }, + "entrypoint": { + "type": "cli", + "command": "engage", + "args": ["--template-id", "{{templateId}}", "--content", "{{content}}"] + }, + "inputSchema": { + "type": "object", + "properties": { + "content": { + "type": "string", + "title": "正文", + "description": "填了就按这里发;留空则用下面的话术模板。", + "display_type": "textarea" + }, + "templateId": { + "type": "number", + "title": "话术模板", + "description": "仅在正文留空时生效。选 -- = 自动挑选。", + "display_type": "lookup", + "lookup": { + "ref_table": "message_templates", + "value_column": "id", + "label_column": "name", + "extra_column": "channel", + "filter_column": "is_active", + "filter_value": 1 + } + } + } + } + }, + { + "id": "probe-with-engines", + "label": "按引擎抽检(多选示例)", + "description": "演示 lookup.multiple:从 engines 多选,提交为逗号分隔 id。", + "placements": ["toolbar"], + "executionProfile": "async", + "interaction": "form", + "bind": { + "tables": ["queries"] + }, + "entrypoint": { + "type": "cli", + "command": "query", + "args": ["batch-probe", "--engine-ids", "{{engineIds}}"] + }, + "inputSchema": { + "type": "object", + "properties": { + "engineIds": { + "type": "string", + "title": "将使用的引擎", + "description": "可多选;提交值为逗号分隔编号,如 1,2。", + "display_type": "lookup", + "lookup": { + "ref_table": "engines", + "value_column": "id", + "label_column": "display_name", + "filter_column": "enabled", + "filter_value": 1, + "multiple": true + } + } + } + } + } + ] +} diff --git a/assets/examples/path-root-file-action.example.json b/assets/examples/path-root-file-action.example.json new file mode 100644 index 0000000..84361b5 --- /dev/null +++ b/assets/examples/path-root-file-action.example.json @@ -0,0 +1,36 @@ +{ + "_comment": "形状参考:Action 表单 file/directory 用 pathRoot 指定原生对话框起始目录(相对技能数据根)。省略则宿主默认 {skill_data}/input。完整契约见 references/ACTIONS.md「本地路径选择与 pathRoot」。勿与宿主导入面板(replaces:import)混用本示例。", + "schemaVersion": 1, + "skill": "your-skill-slug", + "actions": [ + { + "id": "import-pack", + "label": "导入数据包", + "description": "从本技能输入目录子文件夹选择 JSON 并写入业务表。", + "placements": ["toolbar", "agent"], + "executionProfile": "sync", + "interaction": "form", + "bind": { "tables": ["business_items"] }, + "inputSchema": { + "type": "object", + "required": ["file"], + "properties": { + "file": { + "type": "string", + "title": "数据包文件", + "description": "请选择 input/数据包/ 下的 JSON。", + "format": "file", + "display_type": "file", + "pathRoot": "input/数据包" + } + } + }, + "entrypoint": { + "type": "cli", + "command": "import-pack", + "args": ["--file", "{{file}}"] + }, + "steps": [{ "when": "afterSuccess", "do": "reloadTable" }] + } + ] +} diff --git a/assets/examples/render-html.action.example.json b/assets/examples/render-html.action.example.json new file mode 100644 index 0000000..717f90e --- /dev/null +++ b/assets/examples/render-html.action.example.json @@ -0,0 +1,28 @@ +{ + "id": "render-html", + "label": "生成 HTML", + "description": "根据业务数据生成 HTML 报告,写入技能数据目录 html/(示例:请改 bind.tables / entrypoint)。", + "placements": [ + "toolbar", + "skill-detail", + "agent" + ], + "executionProfile": "sync", + "interaction": "direct", + "bind": { + "tables": [ + "your_source_table" + ] + }, + "entrypoint": { + "type": "cli", + "command": "render-html", + "args": [] + }, + "steps": [ + { + "when": "afterSuccess", + "do": "refreshCatalog" + } + ] +} diff --git a/assets/examples/replaces-create.action.example.json b/assets/examples/replaces-create.action.example.json new file mode 100644 index 0000000..94b7ba6 --- /dev/null +++ b/assets/examples/replaces-create.action.example.json @@ -0,0 +1,28 @@ +{ + "id": "item-create", + "label": "新增记录", + "description": "新增业务记录并执行领域副作用(走技能 CLI,非宿主直写库)。", + "replaces": "create", + "placements": [], + "executionProfile": "sync", + "interaction": "form", + "bind": { + "tables": ["items"] + }, + "hostEffects": { + "onSuccess": ["reloadTable"] + }, + "inputSchema": { + "type": "object", + "required": ["name"], + "properties": { + "name": { "type": "string", "title": "名称" }, + "note": { "type": "string", "title": "备注" } + } + }, + "entrypoint": { + "type": "cli", + "command": "item", + "args": ["create", "--name", "{{name}}", "--note", "{{note}}"] + } +} diff --git a/assets/examples/replaces-delete.action.example.json b/assets/examples/replaces-delete.action.example.json new file mode 100644 index 0000000..390f758 --- /dev/null +++ b/assets/examples/replaces-delete.action.example.json @@ -0,0 +1,33 @@ +{ + "id": "item-delete", + "label": "删除记录", + "description": "删除业务记录并清理关联副作用。", + "replaces": "delete", + "placements": [], + "executionProfile": "sync", + "interaction": "confirm", + "confirmation": { + "message": "确定删除该记录?关联数据将一并清理。" + }, + "bind": { + "tables": ["items"], + "inputMapping": { + "id": "$row.$pk" + } + }, + "hostEffects": { + "onSuccess": ["reloadTable", "clearSelection"] + }, + "inputSchema": { + "type": "object", + "required": ["id"], + "properties": { + "id": { "type": "number", "title": "编号", "readOnly": true } + } + }, + "entrypoint": { + "type": "cli", + "command": "item", + "args": ["delete", "--id", "{{id}}"] + } +} diff --git a/assets/examples/replaces-import.action.example.json b/assets/examples/replaces-import.action.example.json new file mode 100644 index 0000000..16c4ce0 --- /dev/null +++ b/assets/examples/replaces-import.action.example.json @@ -0,0 +1,38 @@ +{ + "id": "item-import", + "label": "导入记录", + "description": "由宿主「导入数据」面板调用:解析 Excel/CSV 并写入业务表(含领域校验与派生字段)。", + "replaces": "import", + "placements": [], + "executionProfile": "sync", + "interaction": "form", + "bind": { + "tables": ["items"] + }, + "hostEffects": { + "onSuccess": ["reloadTable"] + }, + "inputSchema": { + "type": "object", + "required": ["filePath"], + "properties": { + "filePath": { + "type": "string", + "title": "导入文件", + "description": "由宿主导入面板传入的 Excel/CSV 路径。", + "format": "file" + }, + "onError": { + "type": "string", + "title": "导入规则(出错时)", + "enum": ["跳过该行,继续导入", "立即停止,不再导入后续行"], + "default": "跳过该行,继续导入" + } + } + }, + "entrypoint": { + "type": "cli", + "command": "item", + "args": ["import", "--file", "{{filePath}}", "--on-error", "{{onError}}"] + } +} diff --git a/assets/examples/replaces-update.action.example.json b/assets/examples/replaces-update.action.example.json new file mode 100644 index 0000000..c2f5b0b --- /dev/null +++ b/assets/examples/replaces-update.action.example.json @@ -0,0 +1,34 @@ +{ + "id": "item-update", + "label": "编辑记录", + "description": "编辑业务记录并重算派生字段。", + "replaces": "update", + "placements": [], + "executionProfile": "sync", + "interaction": "form", + "bind": { + "tables": ["items"], + "inputMapping": { + "id": "$row.$pk", + "name": "$row.name", + "note": "$row.note" + } + }, + "hostEffects": { + "onSuccess": ["reloadTable"] + }, + "inputSchema": { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { "type": "number", "title": "编号", "readOnly": true }, + "name": { "type": "string", "title": "名称" }, + "note": { "type": "string", "title": "备注" } + } + }, + "entrypoint": { + "type": "cli", + "command": "item", + "args": ["update", "--id", "{{id}}", "--name", "{{name}}", "--note", "{{note}}"] + } +} diff --git a/assets/examples/selection-label-column.action.example.json b/assets/examples/selection-label-column.action.example.json new file mode 100644 index 0000000..b6e42a4 --- /dev/null +++ b/assets/examples/selection-label-column.action.example.json @@ -0,0 +1,38 @@ +{ + "id": "process-selected", + "label": "处理勾选", + "description": "对勾选行执行业务处理。示例:声明 labelColumn,让确认弹层「已选择」摘要显示可读名称。", + "placements": ["toolbar", "batch"], + "executionProfile": "async", + "interaction": "confirm", + "confirmation": { + "message": "将按技能业务规则处理勾选记录(具体是否跳过某些状态由 CLI 决定)。" + }, + "bind": { + "tables": ["business_items"], + "inputMapping": { + "ids": "$selection.ids" + }, + "labelColumn": "display_name" + }, + "inputSchema": { + "type": "object", + "required": ["ids"], + "properties": { + "ids": { + "type": "string", + "title": "编号列表", + "description": "逗号分隔主键;宿主会预填勾选行", + "readOnly": true + } + } + }, + "entrypoint": { + "type": "cli", + "command": "process", + "args": ["--ids", "{{ids}}"] + }, + "hostEffects": { + "onSuccess": ["reloadTable"] + } +} diff --git a/assets/examples/skill-cell-presentation.example.json b/assets/examples/skill-cell-presentation.example.json new file mode 100644 index 0000000..6ce7040 --- /dev/null +++ b/assets/examples/skill-cell-presentation.example.json @@ -0,0 +1,29 @@ +{ + "schemaVersion": 1, + "skill": "your-skill-slug", + "table": "business_records", + "rowKey": "id", + "generatedAt": "2026-08-10T06:00:00Z", + "cells": [ + { + "rowId": "42", + "column": "qty", + "tone": "danger", + "message": "库存低于安全线", + "alert": true + }, + { + "rowId": "42", + "column": "status", + "tone": "warning", + "message": "待审核" + }, + { + "rowId": "99", + "column": "print_done_at", + "tone": "danger", + "message": "环节到期未填", + "alert": true + } + ] +} diff --git a/assets/examples/skill-policy-snapshot.allowlist.example.json b/assets/examples/skill-policy-snapshot.allowlist.example.json new file mode 100644 index 0000000..812fc8b --- /dev/null +++ b/assets/examples/skill-policy-snapshot.allowlist.example.json @@ -0,0 +1,13 @@ +{ + "schemaVersion": 1, + "skill": "your-skill-slug", + "actor": { + "userId": "u1", + "displayName": "张三", + "roles": ["clerk"] + }, + "actions": { + "mode": "allowlist", + "ids": ["whoami", "create-record", "sync-records"] + } +} diff --git a/assets/examples/skill-policy-snapshot.scope.example.json b/assets/examples/skill-policy-snapshot.scope.example.json new file mode 100644 index 0000000..80adf17 --- /dev/null +++ b/assets/examples/skill-policy-snapshot.scope.example.json @@ -0,0 +1,28 @@ +{ + "schemaVersion": 1, + "skill": "your-skill-slug", + "actor": { + "userId": "u1" + }, + "actions": { + "mode": "open" + }, + "data": { + "tables": { + "orders": { + "rows": { + "mode": "scope", + "scope": { + "all": [ + { + "field": "owner_user_id", + "op": "eq", + "valueFrom": "actor.userId" + } + ] + } + } + } + } + } +} diff --git a/assets/examples/version-response.json b/assets/examples/version-response.json new file mode 100644 index 0000000..eef63c3 --- /dev/null +++ b/assets/examples/version-response.json @@ -0,0 +1,4 @@ +{ + "version": "1.0.92", + "skill": "your-skill-slug" +} diff --git a/assets/examples/visible-when-action.example.json b/assets/examples/visible-when-action.example.json new file mode 100644 index 0000000..8b5de4f --- /dev/null +++ b/assets/examples/visible-when-action.example.json @@ -0,0 +1,72 @@ +{ + "_comment": "形状参考:互斥模式用 inputSchema.properties.*.visibleWhen 联动显隐,勿只靠 description 灰字。完整契约见 references/ACTIONS.md「字段条件显隐」。勿与 POLICY.md L3 行态 visibleWhen 混淆。", + "schemaVersion": 1, + "skill": "your-skill-slug", + "actions": [ + { + "id": "scrape-links-demo", + "label": "采集链接(示例)", + "description": "演示按采集方式切换参数:关键词路径 vs 作者主页路径。", + "placements": ["toolbar"], + "executionProfile": "async", + "interaction": "form", + "bind": { + "tables": ["demo_items"] + }, + "confirmation": { + "message": "将按所选方式采集链接(示例确认文案)。是否继续?" + }, + "entrypoint": { + "type": "cli", + "command": "run", + "args": [ + "--collect-mode", + "{{collectMode}}", + "--keyword", + "{{keyword}}", + "--author-url", + "{{authorUrl}}", + "--max-count", + "{{maxCount}}" + ] + }, + "inputSchema": { + "type": "object", + "required": ["collectMode"], + "properties": { + "collectMode": { + "type": "string", + "title": "采集方式", + "description": "先选方式,下方只出现对应参数。", + "enum": ["按关键词采集", "按作者主页采集"], + "default": "按关键词采集" + }, + "keyword": { + "type": "string", + "title": "关键词", + "description": "例如 rpa。", + "visibleWhen": { + "field": "collectMode", + "equals": "按关键词采集" + } + }, + "authorUrl": { + "type": "string", + "title": "作者主页链接", + "description": "例如 https://example.com/author/...", + "display_type": "url", + "visibleWhen": { + "collectMode": "按作者主页采集" + } + }, + "maxCount": { + "type": "number", + "title": "数量上限", + "default": 50, + "minimum": 1 + } + } + } + } + ] +} diff --git a/assets/html/draft-preview.html b/assets/html/draft-preview.html new file mode 100644 index 0000000..05ba34d --- /dev/null +++ b/assets/html/draft-preview.html @@ -0,0 +1,155 @@ + + + + + + {{TITLE}} + + + +
+

{{TITLE}}

+

结题报告初稿助手 · {{GENERATED_AT}}

+
+
+
+ {{BODY}} +
+
+ + diff --git a/assets/html/example-report.html b/assets/html/example-report.html new file mode 100644 index 0000000..6d40dc0 --- /dev/null +++ b/assets/html/example-report.html @@ -0,0 +1,119 @@ + + + + + + {{TITLE}} + + + +
+

{{TITLE}}

+

报告生成于 · {{GENERATED_AT}}

+
+
+
+ {{BODY}} +
+
+ + diff --git a/assets/naming/platforms.txt b/assets/naming/platforms.txt new file mode 100644 index 0000000..db8a041 --- /dev/null +++ b/assets/naming/platforms.txt @@ -0,0 +1,13 @@ +amazon +shopee +xinghang +alibaba +kingdee +icbc +pingpong +worldfirst +paypal +lianlian +single-window +export-rebate +etax diff --git a/assets/naming/scopes.txt b/assets/naming/scopes.txt new file mode 100644 index 0000000..3c41cf5 --- /dev/null +++ b/assets/naming/scopes.txt @@ -0,0 +1,4 @@ +all +batch +multi +default diff --git a/assets/naming/verbs.txt b/assets/naming/verbs.txt new file mode 100644 index 0000000..afca45b --- /dev/null +++ b/assets/naming/verbs.txt @@ -0,0 +1,25 @@ +scrape +receive +download +export +add +fill +enter +bind +withdraw +reconcile +verify +submit +query +process +reply +generate +draft +review +confirm +track +upload +match +triage +ship +reprice diff --git a/assets/schemas/skill-actions.schema.json b/assets/schemas/skill-actions.schema.json new file mode 100644 index 0000000..c958855 --- /dev/null +++ b/assets/schemas/skill-actions.schema.json @@ -0,0 +1,491 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://openclaw.local/skill-template/skill-actions.schema.json", + "title": "SkillActionManifest", + "description": "匠厂 skill-template 对新技能的严格 Action manifest 规范(schemaVersion 1)。additionalProperties=false 表示模板推荐契约;宿主运行时可能为兼容历史 manifest 接受部分缺省字段,但新技能不应依赖缺省行为。含可选 hostEffects/steps(匠厂宿主 ≥2.8.5)与可选 hoverHint(数据管理工具栏悬停,需宿主已支持该字段)。", + "type": "object", + "required": ["schemaVersion", "skill", "actions"], + "additionalProperties": false, + "properties": { + "schemaVersion": { + "type": "integer", + "const": 1 + }, + "skill": { + "type": "string", + "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$", + "description": "与 SKILL.md metadata.openclaw.slug 一致" + }, + "actions": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/action" }, + "description": "Action 列表。同一 placement(toolbar/row/batch)下,宿主按本数组中匹配项的相对顺序展示。toolbar/batch:表顶栏全部直接展示(无「更多操作」溢出)。row:行级入口,宿主可呈现为操作列、行右键和/或行内「⋯」(同一批 Action、同一顺序);不在本字段配置排序、截断或菜单树。调整顺序即调整数组先后。无独立 sortOrder 字段。宿主内置刷新/导出/查看等不参与本顺序。表顶栏宽时左右横排,窄时左右分栏且栏内竖排。" + }, + "policy": { + "$ref": "#/$defs/policyDeclaration", + "description": "可选。启用 Data Policy(L1/L2)。缺省或不 enabled = L0,宿主不拉 Snapshot。详见 references/POLICY.md。" + }, + "cellPresentation": { + "$ref": "#/$defs/cellPresentationDeclaration", + "description": "可选。启用数据管理单元格呈现(P1)。缺省或不 enabled = P0,宿主不读标色。详见 references/CELL_PRESENTATION.md。" + } + }, + "$defs": { + "cellPresentationDeclaration": { + "type": "object", + "required": ["enabled"], + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "description": "仅 true 启用;false 等同 P0。" + } + } + }, + "policyDeclaration": { + "type": "object", + "required": ["enabled"], + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "description": "仅 true 启用;false 等同 L0。" + }, + "entrypoint": { + "$ref": "#/$defs/entrypoint", + "description": "enabled=true 时必填。推荐 command=policy,args 可含 {{table}}/{{skill}}/{{userId}}。" + }, + "appliesTo": { + "type": "array", + "items": { + "type": "string", + "enum": ["data-management"] + }, + "description": "缺省 [\"data-management\"]。本期宿主只实现 data-management。" + } + }, + "allOf": [ + { + "if": { + "properties": { "enabled": { "const": true } }, + "required": ["enabled"] + }, + "then": { + "required": ["entrypoint"] + } + } + ] + }, + "placement": { + "type": "string", + "enum": ["toolbar", "row", "batch", "cron", "agent", "skill-detail"], + "description": "稳定支持 toolbar/row/batch/cron/agent/skill-detail(对齐匠厂宿主)。row=行级入口(操作列/行右键/⋯ 等由宿主呈现,无独立 row-context-menu placement)。placements 与 executionProfile 正交,不做条件限制。" + }, + "mutationKind": { + "type": "string", + "enum": ["create", "update", "delete", "import"], + "description": "可选。接管数据管理默认按钮:create=新增、update=编辑、delete=删除、import=导入。须配合非空 bind.tables;无配置时宿主仍直写 SQLite。import 时 UI 仍用宿主「导入数据」面板,确认后调用本 Action 写库。" + }, + "scalarArg": { + "type": ["string", "number", "boolean"] + }, + "inputProperty": { + "type": "object", + "required": ["type"], + "additionalProperties": true, + "properties": { + "type": { + "type": "string", + "enum": ["string", "number", "boolean", "object"], + "description": "模板 Phase 1 仅推荐 string/number/boolean;根 inputSchema.type=object 的 properties 使用基础标量类型" + }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "default": {}, + "enum": { + "type": "array", + "minItems": 1 + }, + "sensitive": { "type": "boolean" }, + "readOnly": { + "type": "boolean", + "description": "只读。有「已选择」且字段来自 inputMapping(或约定 id 字段)时,宿主 form 默认不渲染该控件,预设值仍提交;无摘要时只读展示。勿用 visibleWhen 代替(visibleWhen 隐藏不提交)。" + }, + "revealInForm": { + "type": "boolean", + "description": "可选逃生阀。true 时即使弹层有「已选择」摘要,也仍渲染该 readOnly 字段。不改变提交 payload。省略=映射只读字段在有摘要时默认折叠。" + }, + "column": { + "type": "string", + "minLength": 1, + "pattern": "^[a-z][a-z0-9_]*$", + "description": "可选。绑定业务表物理列名;Action 弹窗(toolbar/batch)与 replaces create/update 抽屉均可复用该列的中文名与 lookup 下拉" + }, + "display_type": { + "type": "string", + "enum": ["textarea", "date", "datetime_unix_seconds", "url", "email", "lookup", "directory", "file"], + "description": "可选。表单控件提示;与 format 二选一即可。lookup=关联下拉(须配 lookup 对象或绑到已有 lookup 列)。file/directory=本地路径选择(可选 pathRoot 指定起始目录,见 ACTIONS.md)。须与 CRUD display_type 语义对齐。" + }, + "format": { + "type": "string", + "enum": ["textarea", "date", "datetime_unix_seconds", "url", "email", "lookup", "directory", "file"], + "description": "可选。display_type 别名,语义相同。" + }, + "pathRoot": { + "type": "string", + "minLength": 1, + "description": "可选。仅对 display_type/format 为 file 或 directory 有意义:原生选文件/目录对话框的起始文件夹,相对当前技能数据根(如 input/库存数据)。省略则宿主默认打开 {skill_data}/input。禁止绝对路径、盘符与 ..。Agent/CLI 忽略。见 references/ACTIONS.md「本地路径选择与 pathRoot」。" + }, + "lookup": { + "$ref": "#/$defs/lookupConfig", + "description": "可选。内联同库表关联 Lookup(与列 options_json lookup 对象同构)。用于当前表没有对应列时;推荐同时设 display_type=lookup。见 ACTIONS.md「跨表引用:Lookup」。" + }, + "visibleWhen": { + "description": "可选。表单字段条件显隐。宿主在数据管理/定时任务参数表单中:规则不满足则隐藏该字段、提交时省略、且不校验 required。支持 {field,equals} / {field,in:[...]},或单键简写 {collectMode:\"按关键词采集\"} / {collectMode:[\"a\",\"b\"]}。Agent/CLI 无 UI 时同样按当前 input 求值后剥离隐藏字段。", + "oneOf": [ + { + "type": "object", + "required": ["field", "equals"], + "additionalProperties": false, + "properties": { + "field": { "type": "string", "minLength": 1 }, + "equals": { "type": ["string", "number", "boolean"] } + } + }, + { + "type": "object", + "required": ["field", "in"], + "additionalProperties": false, + "properties": { + "field": { "type": "string", "minLength": 1 }, + "in": { + "type": "array", + "minItems": 1, + "items": { "type": ["string", "number", "boolean"] } + } + } + }, + { + "type": "object", + "minProperties": 1, + "maxProperties": 1, + "additionalProperties": { + "oneOf": [ + { "type": ["string", "number", "boolean"] }, + { + "type": "array", + "minItems": 1, + "items": { "type": ["string", "number", "boolean"] } + } + ] + } + } + ] + } + } + }, + "lookupConfig": { + "type": "object", + "required": ["ref_table", "value_column", "label_column"], + "additionalProperties": false, + "properties": { + "ref_table": { + "type": "string", + "minLength": 1, + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", + "description": "同库被引用表名" + }, + "value_column": { + "type": "string", + "minLength": 1, + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", + "description": "提交值列(编号 / 外键)" + }, + "label_column": { + "type": "string", + "minLength": 1, + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", + "description": "主文案列" + }, + "extra_column": { + "type": "string", + "minLength": 1, + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", + "description": "可选。第三展示列;有值时宿主拼成「value · label(extra)」" + }, + "filter_column": { + "type": "string", + "minLength": 1, + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", + "description": "可选。等值过滤列" + }, + "filter_value": { + "type": ["string", "number", "boolean", "null"], + "description": "可选。与 filter_column 配对的等值" + }, + "multiple": { + "type": "boolean", + "description": "可选。true=多选 Lookup(存/提交 TEXT 逗号分隔 id);省略或 false=单选(默认)。与 CRUD options_json lookup 同构。" + } + }, + "description": "同库 FK 式 Lookup。展示:value · label;有 extra 时 value · label(extra)。multiple 时多选。宿主亦接受 camelCase 键名。" + }, + "inputSchema": { + "type": "object", + "required": ["type", "properties"], + "additionalProperties": false, + "properties": { + "type": { "const": "object" }, + "required": { + "type": "array", + "items": { "type": "string" } + }, + "properties": { + "type": "object", + "additionalProperties": { "$ref": "#/$defs/inputProperty" } + } + } + }, + "entrypoint": { + "type": "object", + "required": ["type", "command", "args"], + "additionalProperties": false, + "properties": { + "type": { "const": "cli" }, + "command": { + "type": "string", + "minLength": 1 + }, + "args": { + "type": "array", + "items": { "$ref": "#/$defs/scalarArg" }, + "description": "仅允许 string/number/boolean 标量数组" + } + } + }, + "confirmation": { + "type": "object", + "required": ["message"], + "additionalProperties": false, + "properties": { + "message": { + "type": "string", + "minLength": 1 + } + } + }, + "bind": { + "type": "object", + "required": ["tables"], + "additionalProperties": false, + "properties": { + "tables": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "pattern": "^[a-z][a-z0-9_]*$", + "description": "英文 snake_case 业务表名;须真实存在于技能 SQLite / _jiangchang_tables" + }, + "description": "数据管理表级绑定:决定 toolbar/row/batch Action 出现在哪些表。宿主兼容旧技能时,缺失 bind 可视为全表展示;新技能 toolbar Action 必须显式声明。" + }, + "inputMapping": { + "type": "object", + "additionalProperties": { "type": "string", "minLength": 1 }, + "description": "可选。将 $row.$pk / $row.col / $selection.ids 等映射到 inputSchema 字段名,供 row/batch 预填。" + }, + "columnMapping": { + "type": "object", + "additionalProperties": { + "type": "string", + "minLength": 1, + "pattern": "^[a-z][a-z0-9_]*$" + }, + "description": "可选。将 inputSchema 属性名映射到业务表物理列名,供 Action 弹窗与 replaces create/update 抽屉复用列 displayName / lookup options。" + }, + "labelColumn": { + "type": "string", + "minLength": 1, + "pattern": "^[a-z][a-z0-9_]*$", + "description": "可选。数据管理 Action 确认/表单弹层「已选择」摘要用的可读标签列(#id + 标签)。仅影响展示,不改变提交 payload。省略时宿主按常见列名启发(title/name 等),再不行只显示编号。" + } + }, + "description": "表级绑定;toolbar 必填。可选 inputMapping / columnMapping / labelColumn。concurrency/locks 勿写入。" + }, + "hostEffectId": { + "type": "string", + "enum": ["reloadTable", "refreshCatalog", "clearSelection"], + "description": "数据管理 UI 副作用(匠厂宿主 ≥2.8.5)。reloadTable=刷新当前表(含 Data Policy);refreshCatalog=刷新技能目录;clearSelection=清空多选。" + }, + "hostEffects": { + "type": "object", + "additionalProperties": false, + "properties": { + "onSuccess": { + "type": "array", + "items": { "$ref": "#/$defs/hostEffectId" }, + "uniqueItems": true, + "description": "sync 退出 0,或 async 任务已被任务中心接收时触发(不是 Job 跑完)。" + }, + "onFailure": { + "type": "array", + "items": { "$ref": "#/$defs/hostEffectId" }, + "uniqueItems": true, + "description": "sync 失败,或 async 启动被拒时触发。" + } + }, + "description": "可选。UI 副作用简写;省略则宿主不额外改 UI。与 placements/executionProfile/interaction 正交;cron/agent 忽略。新技能有编排需求时优先用 steps。" + }, + "actionStep": { + "type": "object", + "required": ["when", "do"], + "additionalProperties": false, + "properties": { + "when": { + "type": "string", + "enum": ["before", "afterSuccess", "afterFailure"], + "description": "before=CLI 前;afterSuccess/afterFailure 时机同 hostEffects.onSuccess/onFailure。" + }, + "do": { + "type": "string", + "enum": ["deleteByFk", "deleteRows", "reloadTable", "refreshCatalog", "clearSelection"], + "description": "闭环操作或 UI 效果。deleteByFk/deleteRows 须配合 table(及 fk)。" + }, + "table": { + "type": "string", + "minLength": 1, + "pattern": "^[a-z][a-z0-9_]*$", + "description": "deleteByFk / deleteRows 目标表(snake_case)。" + }, + "fk": { + "type": "string", + "minLength": 1, + "pattern": "^[a-z][a-z0-9_]*$", + "description": "deleteByFk 时子表外键列名。" + } + }, + "allOf": [ + { + "if": { + "properties": { "do": { "const": "deleteByFk" } }, + "required": ["do"] + }, + "then": { "required": ["table", "fk"] } + }, + { + "if": { + "properties": { "do": { "const": "deleteRows" } }, + "required": ["do"] + }, + "then": { "required": ["table"] } + } + ] + }, + "action": { + "type": "object", + "required": ["id", "label", "description", "placements", "entrypoint", "executionProfile"], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "minLength": 1 + }, + "label": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string", + "minLength": 1 + }, + "hoverHint": { + "type": "string", + "minLength": 1, + "description": "可选。数据管理工具栏悬停文案。与 description / confirmation.message 独立;省略则不显示悬停,不回退。需宿主已支持该字段。当前实现为顶栏技能按钮,不含宿主内置删除/刷新/新增/导入/导出。" + }, + "placements": { + "type": "array", + "minItems": 0, + "items": { "$ref": "#/$defs/placement" }, + "description": "决定 Action 出现在哪里;与 executionProfile 正交。仅 replaces 接管默认 CRUD 时可为空数组。数据管理 toolbar/row/batch 的顺序由顶层 actions 数组相对顺序决定。表顶栏 toolbar/batch 全部直接展示(无溢出菜单);row 为行级入口(操作列/行右键/⋯),不在本字段配置排序、截断或菜单树。" + }, + "replaces": { "$ref": "#/$defs/mutationKind" }, + "entrypoint": { "$ref": "#/$defs/entrypoint" }, + "inputSchema": { "$ref": "#/$defs/inputSchema" }, + "confirmation": { "$ref": "#/$defs/confirmation" }, + "executionProfile": { + "type": "string", + "enum": ["sync", "async"], + "description": "sync=调用方等待结构化结果(不建 Job);async=宿主建后台 Job 并进任务中心。必须显式写出;长耗时建议 async,但不限制 placements。" + }, + "interaction": { + "type": "string", + "enum": ["auto", "direct", "confirm", "form"], + "description": "数据管理等可点击入口的交互方式(与 executionProfile 正交)。direct=一点即跑;confirm=轻量确认(文案用 confirmation.message);form=参数表单;auto/省略=宿主按 inputSchema+confirmation 推断。cron 触发与 agent 忽略本字段。" + }, + "interactionByPlacement": { + "type": "object", + "additionalProperties": false, + "properties": { + "toolbar": { "type": "string", "enum": ["direct", "confirm", "form"] }, + "row": { "type": "string", "enum": ["direct", "confirm", "form"] }, + "batch": { "type": "string", "enum": ["direct", "confirm", "form"] }, + "cron": { "type": "string", "enum": ["direct", "confirm", "form"] }, + "agent": { "type": "string", "enum": ["direct", "confirm", "form"] }, + "skill-detail": { "type": "string", "enum": ["direct", "confirm", "form"] }, + "create": { "type": "string", "enum": ["direct", "confirm", "form"] }, + "update": { "type": "string", "enum": ["direct", "confirm", "form"] }, + "delete": { "type": "string", "enum": ["direct", "confirm", "form"] }, + "import": { "type": "string", "enum": ["direct", "confirm", "form"] } + }, + "description": "按 placement / replaces 覆盖 interaction(只能写 concrete 模式,不能写 auto)。" + }, + "bind": { "$ref": "#/$defs/bind" }, + "hostEffects": { "$ref": "#/$defs/hostEffects" }, + "steps": { + "type": "array", + "maxItems": 8, + "items": { "$ref": "#/$defs/actionStep" }, + "description": "可选。有序宿主编排(before / afterSuccess / afterFailure);单次最多 8 步。与 hostEffects 可并存(同效果可能各跑一次)。新技能优先用 steps。省略则无额外步骤。需匠厂宿主 ≥2.8.5。" + }, + "authz": { + "type": "object", + "additionalProperties": false, + "properties": { + "capability": { + "type": "string", + "minLength": 1, + "description": "可选文档/静态提示用能力名;宿主不得仅凭此显隐按钮,运行时以 policy Snapshot 为准。" + } + } + } + }, + "if": { + "properties": { + "placements": { + "contains": { "const": "toolbar" } + } + }, + "required": ["placements"] + }, + "then": { + "required": ["bind"] + }, + "allOf": [ + { + "if": { + "required": ["replaces"] + }, + "then": { + "required": ["bind"] + } + } + ], + "description": "concurrency/locks 为未稳定预留字段,勿写入新技能 manifest。placements 含 toolbar 或声明 replaces 时必须声明 bind.tables。无 replaces 时 placements 建议非空(模板测试强制)。" + } + } +} diff --git a/assets/schemas/skill-cell-presentation.schema.json b/assets/schemas/skill-cell-presentation.schema.json new file mode 100644 index 0000000..983c48f --- /dev/null +++ b/assets/schemas/skill-cell-presentation.schema.json @@ -0,0 +1,78 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://openclaw.local/skill-template/skill-cell-presentation.schema.json", + "title": "SkillCellPresentationDocument", + "description": "匠厂数据管理「单元格呈现」文档(schemaVersion 1)。技能按业务规则为「某表某行某列」声明语义 tone(非任意 CSS);宿主映射主题样式。用途不限(时效超时、库存预警、状态异常、必填缺失等)。详见 references/CELL_PRESENTATION.md。", + "type": "object", + "required": ["schemaVersion", "skill", "table", "rowKey", "cells"], + "additionalProperties": false, + "properties": { + "schemaVersion": { + "type": "integer", + "const": 1 + }, + "skill": { + "type": "string", + "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$", + "minLength": 1, + "description": "与 SKILL.md metadata.openclaw.slug / actions.json skill 一致" + }, + "table": { + "type": "string", + "minLength": 1, + "pattern": "^[a-z][a-z0-9_]*$", + "description": "业务表物理名(snake_case)" + }, + "rowKey": { + "type": "string", + "minLength": 1, + "pattern": "^[a-z][a-z0-9_]*$", + "description": "用于匹配行的业务表列名(如 id、feishu_record_id);cells[].rowId 的值取自该列" + }, + "generatedAt": { + "type": "string", + "minLength": 1, + "description": "可选。ISO-8601 或技能约定的生成时间,便于排障" + }, + "cells": { + "type": "array", + "description": "当前应对该表生效的呈现项;空数组 = 该表无特殊呈现(清除旧样式)。同 (rowId, column) 重复时宿主可后者覆盖前者。", + "items": { "$ref": "#/$defs/cellItem" } + } + }, + "$defs": { + "tone": { + "type": "string", + "enum": ["danger", "warning", "info", "success", "muted"], + "description": "语义色板(闭集)。宿主映射到底色/字色/左边线等;禁止技能下发原始 CSS 或任意 hex。" + }, + "cellItem": { + "type": "object", + "required": ["rowId", "column", "tone"], + "additionalProperties": false, + "properties": { + "rowId": { + "type": "string", + "minLength": 1, + "description": "rowKey 列的字符串形式主键/业务键" + }, + "column": { + "type": "string", + "minLength": 1, + "pattern": "^[a-z][a-z0-9_]*$", + "description": "要强调的业务列名(snake_case),必须是真实列" + }, + "tone": { "$ref": "#/$defs/tone" }, + "message": { + "type": "string", + "maxLength": 500, + "description": "可选。悬停/无障碍说明(短文案);不要塞长报告" + }, + "alert": { + "type": "boolean", + "description": "可选,默认 false。true = 可计入进页/同步后的提醒汇总(如 Toast);填完数据并重算后该项消失则提醒自然关闭" + } + } + } + } +} diff --git a/assets/schemas/skill-policy-snapshot.schema.json b/assets/schemas/skill-policy-snapshot.schema.json new file mode 100644 index 0000000..71d4322 --- /dev/null +++ b/assets/schemas/skill-policy-snapshot.schema.json @@ -0,0 +1,196 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://openclaw.local/skill-template/skill-policy-snapshot.schema.json", + "title": "SkillDataPolicySnapshot", + "description": "匠厂 Skill Data Policy Snapshot(schemaVersion 1)。由技能 policy CLI 输出到 stdout;宿主用于数据管理按钮/行列过滤。详见 references/POLICY.md。", + "type": "object", + "required": ["schemaVersion", "skill", "actor", "actions"], + "additionalProperties": true, + "properties": { + "schemaVersion": { + "type": "integer", + "const": 1 + }, + "skill": { + "type": "string", + "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$", + "minLength": 1 + }, + "actor": { + "type": "object", + "required": ["userId"], + "additionalProperties": true, + "properties": { + "userId": { "type": "string", "minLength": 1 }, + "displayName": { "type": "string" }, + "roles": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "attributes": { + "type": "object", + "additionalProperties": { + "type": ["string", "number", "boolean", "null"] + } + } + } + }, + "actions": { + "type": "object", + "required": ["mode"], + "additionalProperties": false, + "properties": { + "mode": { + "type": "string", + "enum": ["allowlist", "open"], + "description": "v1 仅允许 allowlist/open;denylist 非法。" + }, + "ids": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "description": "mode=allowlist 时必填;mode=open 时应省略或 []。" + } + }, + "allOf": [ + { + "if": { + "properties": { "mode": { "const": "allowlist" } }, + "required": ["mode"] + }, + "then": { + "required": ["ids"] + } + } + ] + }, + "hostCapabilities": { + "type": "array", + "items": { + "type": "string", + "enum": ["refresh", "export", "view", "edit", "delete", "import"] + }, + "description": "省略=不限制宿主内置能力;写出则按 allowlist(含 []=全禁)。" + }, + "data": { + "type": "object", + "additionalProperties": false, + "properties": { + "tables": { + "type": "object", + "additionalProperties": { "$ref": "#/$defs/tablePolicy" } + } + } + }, + "extensions": { + "type": "object", + "description": "技能私有扩展;宿主必须忽略未知键。" + } + }, + "$defs": { + "predicate": { + "type": "object", + "required": ["field", "op"], + "additionalProperties": false, + "properties": { + "field": { + "type": "string", + "minLength": 1, + "pattern": "^[a-z][a-z0-9_]*$" + }, + "op": { + "type": "string", + "enum": [ + "eq", + "ne", + "in", + "not_in", + "gt", + "gte", + "lt", + "lte", + "is_null", + "is_not_null" + ] + }, + "value": { + "description": "与 valueFrom 互斥;in/not_in 时为非空数组。" + }, + "valueFrom": { + "type": "string", + "const": "actor.userId", + "description": "v1 仅允许 actor.userId。" + } + } + }, + "scope": { + "oneOf": [ + { "$ref": "#/$defs/predicate" }, + { + "type": "object", + "required": ["all"], + "additionalProperties": false, + "properties": { + "all": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/predicate" } + } + } + }, + { + "type": "object", + "required": ["any"], + "additionalProperties": false, + "properties": { + "any": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/predicate" } + } + } + } + ] + }, + "tablePolicy": { + "type": "object", + "additionalProperties": false, + "properties": { + "accessible": { "type": "boolean" }, + "columns": { + "type": "object", + "additionalProperties": false, + "properties": { + "visible": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z][a-z0-9_]*$" + } + }, + "writable": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z][a-z0-9_]*$" + } + } + } + }, + "rows": { + "type": "object", + "additionalProperties": false, + "properties": { + "mode": { + "type": "string", + "enum": ["scope", "open", "delegate"] + }, + "scope": { "$ref": "#/$defs/scope" } + } + } + } + } + } +} diff --git a/assets/screenshots/gitea-workflow-success.png b/assets/screenshots/gitea-workflow-success.png new file mode 100644 index 0000000..9cce6a0 Binary files /dev/null and b/assets/screenshots/gitea-workflow-success.png differ diff --git a/assets/screenshots/gitea-workflow-tab.png b/assets/screenshots/gitea-workflow-tab.png new file mode 100644 index 0000000..6727162 Binary files /dev/null and b/assets/screenshots/gitea-workflow-tab.png differ diff --git a/assets/screenshots/jc2009-download-page.png b/assets/screenshots/jc2009-download-page.png new file mode 100644 index 0000000..7f54d4d Binary files /dev/null and b/assets/screenshots/jc2009-download-page.png differ diff --git a/assets/screenshots/market-installed-skill.png b/assets/screenshots/market-installed-skill.png new file mode 100644 index 0000000..2968472 Binary files /dev/null and b/assets/screenshots/market-installed-skill.png differ diff --git a/assets/screenshots/new-task-usage.png b/assets/screenshots/new-task-usage.png new file mode 100644 index 0000000..70e132a Binary files /dev/null and b/assets/screenshots/new-task-usage.png differ diff --git a/development/ACCOUNT_MANAGER.md b/development/ACCOUNT_MANAGER.md new file mode 100644 index 0000000..267fda5 --- /dev/null +++ b/development/ACCOUNT_MANAGER.md @@ -0,0 +1,216 @@ +# account-manager 接入标准(账号 / Profile / 租约) + +> 本文是浏览器 RPA 技能接入账号体系的统一标准。开发者和 AI 编程工具在编写 `account_client.py`、处理多账号、排查登录状态或验收浏览器任务前,必须阅读本文。 + +## 目录 + +- [1. 为什么必须依赖 account-manager](#1-为什么必须依赖-account-manager) +- [2. 核心模型](#2-核心模型) +- [3. 强制规则](#3-强制规则) +- [4. 标准生命周期](#4-标准生命周期) +- [5. 账号选择与多账号](#5-账号选择与多账号) +- [6. 创建后去哪里查看](#6-创建后去哪里查看) +- [7. 登录状态与 Profile 验收](#7-登录状态与-profile-验收) +- [8. 租约与异常退出](#8-租约与异常退出) +- [9. 工业级测试](#9-工业级测试) +- [10. 排障顺序与提问证据](#10-排障顺序与提问证据) +- [11. 安全边界](#11-安全边界) + +## 1. 为什么必须依赖 account-manager + +真实用户会长期使用同一个账号,也会在批量发布、矩阵运营、多店铺、多租户等场景中使用多个账号。一次打开临时浏览器、成功操作一两条数据,不能证明技能可交付。 + +account-manager 统一负责: + +- 账号身份及平台归属; +- 一账号一份持久化浏览器 Profile; +- Cookie、会话和登录状态的持续复用; +- 多任务之间的账号租约与并发互斥; +- 账号状态、最近错误和运维排查入口。 + +业务技能只负责编排业务动作,不得再造一套账号库,也不得随意找一个浏览器目录直接运行。 + +## 2. 核心模型 + +| 对象 | 含义 | 业务技能应如何使用 | +|------|------|--------------------| +| `account` | 某个平台上的一个账号身份 | 通过 account-manager CLI 获取,不自行落库 | +| `profile_dir` | 该账号独占的浏览器用户数据目录 | 只传给 `launch_persistent_context`,长期复用 | +| 登录状态 | 目标站 Cookie / Session 等实际会话 | 由业务技能打开同一 Profile 后检查;账号已登记不代表已登录 | +| `lease_token` | 当前任务对账号的临时独占权 | 打开浏览器前取得,所有退出路径释放 | +| `session_status` / error | 会话健康和最近故障线索 | 登录失效或账号异常时按约定回写 | + +关系必须保持为: + +```text +一个账号记录 ──绑定──► 一个稳定 profile_dir + │ + └──任务运行前取得 lease_token ──► 同一时刻只允许一个任务使用 +``` + +## 3. 强制规则 + +1. 所有浏览器 RPA,包括公开页和无需登录的页面,都必须先经 account-manager 取得 `profile_dir`。 +2. 一个账号只能绑定一个稳定 Profile;不同账号禁止共用 Profile。 +3. 禁止使用本机日常 Chrome / Edge 用户目录,禁止每次运行创建无归属的临时 Profile。 +4. 获取账号时必须同时取得租约;仅 `account get ` 不能代替租约。 +5. 业务代码只通过单一 `scripts/service/account_client.py` 集中调用 account-manager CLI。 +6. 禁止 import account-manager 的 `service/`、`db/`、`util/`、`rpa_helpers` 等内部模块。 +7. 未取得非空 `profile_dir` 和 `lease_token` 时禁止打开浏览器。 +8. 账号均被占用时返回 `ERROR:LEASE_CONFLICT`,不得自动创建重复账号或换号绕过。 +9. 所有成功、失败、取消和异常路径都必须在 `finally` 中释放租约。 +10. 密码、Cookie、Token、短信验证码等敏感信息不得写入业务技能配置、数据库、日志或错误信息。 + +对应机器门禁:`POLICY-RPA-004`、`POLICY-RPA-006`。 + +## 4. 标准生命周期 + +```text +解析用户指定账号 + → ensure / pick 账号并取得租约 + → 校验账号平台、profile_dir、lease_token + → 用 profile_dir 启动持久浏览器 + → 打开目标站并检查实际登录状态 + → 执行业务并记录结果 + → 回写会话状态 / 最近错误 + → 关闭浏览器 + → finally 释放租约 +``` + +参考结构: + +```python +account = pick_web_account(platform=TARGET_PLATFORM, account_id=account_id, login_id=login_id) +lease_token = account["lease_token"] +try: + profile_dir = account["profile_dir"] + # 只用 profile_dir 启动 persistent context,然后检查登录并执行任务 +finally: + release_lease(lease_token) +``` + +浏览器启动、登录门禁、业务执行和关闭顺序见 [`RPA.md`](RPA.md) §0.2;CLI 封装边界见 [`ADAPTER.md`](ADAPTER.md)。 + +## 5. 账号选择与多账号 + +选择优先级: + +1. 用户明确指定纯数字 `DEFAULT_ACCOUNT_ID`:先确认账号存在且平台一致,再通过可定位该账号的 pick 流程取得租约;不得只 `get` 后返回空租约。 +2. 用户指定 `DEFAULT_LOGIN_ID`:使用 `ensure-web --login-id` 获取或登记,并取得租约。 +3. 用户未指定账号:按平台 `ensure-web`,有则选择可用账号,无则登记一条。 +4. 多条记录无法唯一确定:返回 `ERROR:AMBIGUOUS_ACCOUNT`,要求用户明确选择。 +5. 匹配账号均被占用:返回 `ERROR:LEASE_CONFLICT`,等待或由运维确认租约状态。 + +批量和矩阵任务必须以账号为隔离单元: + +- 每个账号独立 Profile、独立租约、独立任务结果和失败原因; +- 同一账号默认串行,不因追求吞吐提高同账号并发; +- 一个账号失败不得污染其他账号的 Profile 或结果; +- 设置单账号批量上限、单位时间操作上限和任务总时长; +- 风控、封禁或权限错误不得自动切换下一个账号继续试探。 + +## 6. 创建后去哪里查看 + +账号创建成功后必须可见、可查,不得只相信命令没有报错。 + +### 6.1 宿主数据管理 + +优先在匠厂宿主的**数据管理页面**查看 account-manager 数据: + +- 账号记录:确认账号 ID、平台、账号名称 / 登录标识、状态、`profile_dir`、会话状态; +- 租约记录:确认当前持有者、用途、到期时间和是否已释放; +- 凭据记录:只能查看类型、引用和脱敏值,不应出现明文密码或 Token。 + +宿主版本若直接展示数据库表,对应核心表为 `accounts`、`account_leases`、`credentials`、`platforms`。不得在数据管理页面或 SQLite 工具中随意修改记录。 + +### 6.2 account-manager CLI + +在 account-manager 仓库或宿主提供的入口中检查: + +```powershell +python scripts/main.py account list --platform --limit 50 +python scripts/main.py account get +python scripts/main.py lease list --active +python scripts/main.py account open +``` + +`account open` 仅用于查看指定 Profile。业务运行仍须走带租约的 pick / ensure 流程。 + +### 6.3 本地用户数据目录 + +实际数据按宿主注入的用户隔离: + +```text +{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/account-manager/ +├── account-manager.db +└── profiles/ + └── {domain}/ + └── {platform_key}/ + └── {safe_label}[_{login_id_suffix}]/ +``` + +- `account-manager.db` 保存账号、平台、凭据元数据和租约。 +- `profiles/...` 是真实浏览器用户数据目录,保存对应账号的 Cookie / Session 等浏览器状态。 +- 实际路径以 `account get ` 返回的 `profile_dir` 为准,不得在代码里拼写固定盘符或用户 ID。 +- Profile 正在使用时禁止复制、移动、删除或同时打开;需要迁移或清理时先停止任务并释放租约。 + +## 7. 登录状态与 Profile 验收 + +“账号已创建”只表示账号记录和 Profile 已准备,不代表目标站已经登录。 + +最小验收必须完成: + +1. 数据管理页面或 `account list` 能看到账号记录。 +2. `profile_dir` 非空、真实存在,并位于当前用户的 account-manager 数据目录。 +3. 两个不同账号的 `profile_dir` 不相同。 +4. 第一次用该 Profile 打开目标站,按平台要求人工登录或扫码。 +5. 正常关闭浏览器并释放租约。 +6. 再次取得同一账号租约,用同一 `profile_dir` 重开浏览器。 +7. 验证登录状态仍有效;失效时返回 `ERROR:REQUIRE_LOGIN`,不得假成功。 + +## 8. 租约与异常退出 + +- 租约持有者应使用技能 slug 或可追踪任务标识,`purpose` 写明用途。 +- 租约 TTL 应覆盖正常任务时长;长任务需要平台支持的续租策略,不能依赖永不过期。 +- `finally release_lease(...)` 是最低要求,浏览器关闭失败也不能跳过释放尝试。 +- 进程被强制结束后可能留下租约;先用 `lease list --active` 核对,确认没有真实任务仍在使用后再释放或清理。 +- 禁止看到租约冲突就直接删数据库记录、删 Profile、换账号或新建重复账号。 + +## 9. 工业级测试 + +除单账号冒烟测试外,至少覆盖: + +- 指定账号 ID、指定登录标识和自动选择三条路径; +- 账号不存在、平台不匹配、Profile 缺失、返回格式异常; +- 同一账号并发抢占和 `LEASE_CONFLICT`; +- 业务成功、业务失败、用户取消、浏览器崩溃后的租约释放; +- 登录过期、验证码 / 二次验证、账号风险和权限不足; +- 两个以上账号轮换且 Profile、登录态、结果互不污染; +- 关闭重开后登录保持; +- 批量长时间运行、限流、有限重试和断点续跑。 + +不得以“单账号成功操作一两次”作为多账号或矩阵能力已经交付的证明。 + +## 10. 排障顺序与提问证据 + +开发者和 AI 在群里提问前按顺序自查: + +1. 阅读本文以及 [`RPA.md`](RPA.md)、[`ADAPTER.md`](ADAPTER.md)。 +2. 在数据管理页面确认账号记录是否存在、平台和状态是否正确。 +3. 执行 `account list/get`,核对账号 ID、`profile_dir`、`session_status`。 +4. 在文件系统确认 `profile_dir` 是否存在、是否错用了其他用户目录。 +5. 执行 `lease list --active`,确认是否被其他任务占用或残留。 +6. 查看运行日志、任务中心终态、错误码、失败截图和浏览器当前页面。 +7. 判断问题属于账号不存在、租约冲突、登录失效、Profile 损坏、页面变化还是业务失败。 + +仍需协助时必须提供:技能 slug、平台、脱敏账号标识 / 账号 ID、任务时间、错误码、账号与租约检查结果、`profile_dir` 是否存在、脱敏日志和截图。禁止发送密码、Cookie、Token 或验证码。 + +## 11. 安全边界 + +- 账号和 Profile 只能用于用户或企业明确授权的正常业务操作。 +- 多账号管理用于账号隔离、矩阵业务和稳定调度,不得用于绕过频控、封禁或平台限制。 +- 禁止破解协议、逆向客户端、伪造设备指纹、验证码破解、养号、撞库、代理池轮换或自动换号继续试探。 +- 风控、封禁、安全警告和权限不足必须停止或转人工。 +- 不手工编辑 `account-manager.db`,不在账号使用期间操作 Profile 文件。 + +完整合规与风控边界见 [`AI_RPA.md`](AI_RPA.md)。 diff --git a/development/ADAPTER.md b/development/ADAPTER.md new file mode 100644 index 0000000..3d6b019 --- /dev/null +++ b/development/ADAPTER.md @@ -0,0 +1,133 @@ +# 适配器标准:真实/仿真 × API/RPA 四档模式 + +> 凡是"连接三方系统"(ERP、CRM、SaaS、银行等)的 skill,都应采用本文的 **adapter 四档模式**。同一套业务逻辑可在不同档位间切换;**四档都保留**。 + +先遵守 [`AI_RPA.md`](AI_RPA.md) 的授权与技术红线:`real_api` 只连接公开或明确授权的官方接口;不得通过抓包、逆向或猜测私有协议构造“API”。没有授权 API 时使用正常界面 RPA;正常界面无法闭环且只能绕过安全机制时,停止并上报。 + +## 为什么要分档 + +对接一个外部系统,工程上无非四种方式的组合: + +| | API(有接口) | RPA(操作界面) | +|---|---|---| +| **真实系统** | `real_api` 真实 API | `real_rpa` 真实界面 | +| **仿真/离线** | `mock` 纯内存/fixture | `simulator_rpa` 仿真站点/桌面仿真 | + +### 四档定义 + +| 档位 | 含义 | 默认策略 | +|------|------|----------| +| **`mock`** | 纯内存或 fixture,离线 | **单测 / CI 必过**;mock 不通不要往下走 | +| **`simulator_rpa`** | 仿真站点或桌面仿真,可半集成 | 按业务场景选用 | +| **`real_api`** | 真实 API | 按业务场景选用(有官方接口时) | +| **`real_rpa`** | 真实浏览器/真实系统 | **模板 `.env.example` 默认**;业务自测与交活用此档 | + +- **mock**:纯离线、不联网,给单测 / CI,**保证可重复**。**mock 通 ≠ 开发完成**。 +- **simulator_rpa**:操作仿真平台(如 `sandbox.jc2009.com`),按场景选用。 +- **real_api**:有官方接口时按场景选用。 +- **real_rpa**:真实浏览器路径;模板默认,须真实跑通后再交活。 + +> **口径**:业务配置默认 `real_rpa`;单测/CI 用 `mock`。不得只 mock 通就交活;须 release 到匠厂宿主验收(含用户体验)。 + +配置读取见 `CONFIG.md`:**bootstrap 之后业务代码只通过 `config.get*()` 读 `OPENCLAW_TEST_TARGET` 等项**(进程 env > 用户 `.env` > `.env.example`)。 + +## 目录骨架 + +``` +scripts/service/_adapter/ + __init__.py # dispatch:按档位返回对应 adapter 实例 + base.py # 数据契约(dataclass)+ AdapterBase 接口 + mock.py # 离线仿真,给 CI/单测 + real_api.py # 真实系统 API + sim_rpa.py # 薄仿真平台 RPA adapter(委托 *_playwright.py) + real_rpa.py # 真实系统 RPA(占位,谨慎实现) + +scripts/service/ + _playwright.py # RPA 主流程(厚);与薄 sim_rpa.py 并列 + account_client.py # account-manager subprocess 唯一封装 + browser_session.py # async persistent context 启动 +``` + +模板示例(均在 `examples/`,**不在** `scripts/`): + +- **仿真浏览器 RPA 完整案例**:`examples/simulator_browser_rpa/` — async mock + simulator_rpa + `simulator_playwright.py` + sandbox 页面 + adapter 分层 + 测试 +- **真实浏览器 RPA 完整案例**:`examples/real_browser_rpa/` — 登录/验证码/滚动采集 + `account_client.py` +- **真实 API / 仿真 API**:`examples/real_api/`、`examples/simulator_api/` — 当前为规划占位,尚未沉淀可复制实现 + +复制 adapter 分层时,以修正后的 `examples/simulator_browser_rpa/`(薄 adapter + `simulator_playwright.py`)与 `examples/real_browser_rpa/scripts/service/account_client.py` 为权威参考,在新技能中创建 `scripts/service/_adapter/` 并按 README copy map 选择性复制。 + +> 先判断属于四象限哪一种(`real_browser_rpa` / `real_api` / `simulator_browser_rpa` / `simulator_api`),再读对应 `examples/*/README.md`。示例是**参考架构与边界**,不是业务代码原样复制。 + +## 档位 dispatch + +由 `OPENCLAW_TEST_TARGET` 统一决定用哪个 adapter: + +| `OPENCLAW_TEST_TARGET` | adapter | 用途 | +|---|---|---| +| `unit` / `mock` | `MockAdapter` | 单测 / CI,离线 | +| `simulator_rpa` | `SimRpaAdapter` | 开发/演示,操作仿真平台 | +| `real_api` | `RealApiAdapter` | 生产,走官方接口 | +| `real_rpa` | `RealRpaAdapter` | 生产,操作真实界面 | + +```python +# __init__.py +from jiangchang_skill_core import config + +def get_adapter(): + target = (config.get("OPENCLAW_TEST_TARGET") or "real_rpa").lower() + if target in ("unit", "mock"): + return MockAdapter() + if target == "real_api": + return RealApiAdapter() + if target == "real_rpa": + return RealRpaAdapter() + return SimRpaAdapter() +``` + +`get_adapter()` 实现应配合 `tests/adapter_test_utils.py` 的 profile 策略:**默认必跑测试不得误设 `OPENCLAW_TEST_TARGET=real_*`**。 + +## contract tests + +每个 adapter **必须覆盖同一套契约测试**(复制 `tests/samples/test_service_contract.py.sample`): + +- 至少覆盖:**timeout**、**unauthorized**、**invalid response**、**empty result** +- **mock 不允许真实网络** +- 使用 `FakeAdapter` 或等价 stub 模拟异常路径 + +## 兄弟依赖 + +依赖 account-manager 或其他兄弟技能时: + +1. 在 `SKILL.md` 的 `metadata.openclaw.dependencies.required` 声明。 +2. **普通兄弟技能调用**,优先走统一 **`service.sibling_bridge`**(`call_sibling_json`),**不要**在 `task_service.py`、`task_rpa.py` 等业务流程文件中到处散落 `subprocess.run`。 +3. **account-manager 账号/租约能力**是例外:可参考 `examples/real_browser_rpa/scripts/service/account_client.py` 与 `examples/simulator_browser_rpa/scripts/service/account_client.py`,封装为**单一** `account_client.py`;允许在该文件内部集中通过 subprocess 调 account-manager CLI。**禁止** import `rpa_helpers` 等 account-manager 内部模块;`simulator_rpa` 与 `real_rpa` 均走同一 `pick_web_account` / `release_lease` 模式。 +4. **不允许**直接 `import account-manager` 的内部 Python 模块(如 `service/`、`util/`、`db/`)。 +5. **所有选择路径都必须取得 lease**:包括纯数字 `DEFAULT_ACCOUNT_ID`。`account get ` 只能读取账号,不能单独作为运行结果;必须再通过可定位该账号的 pick 流程取得非空 `lease_token`。随后必须 `finally release lease`;进程被 kill 后可能残留 lease,需按 [`ACCOUNT_MANAGER.md`](ACCOUNT_MANAGER.md) 排查。 +6. **网页 RPA 硬规则**(详见 [`RPA.md`](RPA.md) §0.2):必须先经 `pick_web_account`(底层 **`ensure-web` / `pick-web --ensure`**)拿到 `profile_dir` 再开浏览器;拿不到则失败退出;生产有头;**是否站点登录**按平台约定,但**不等于**可以跳过 account-manager。登记账号 ≠ 站点已登录。 +7. **账号选择优先级**:纯数字 `DEFAULT_ACCOUNT_ID` → 校验指定账号后带租约 pick;`DEFAULT_LOGIN_ID` / 非数字标识 → `ensure-web --login-id --lease`;皆空 → 按平台 `ensure-web --lease`。人读展示优先 `login_id` / `account_label`,不要只用数字 id。多条匹配或无法确保拿到指定 ID → `ERROR:AMBIGUOUS_ACCOUNT`。 +8. **运行前校验**:`profile_dir` 和 `lease_token` 均须非空;账号平台必须匹配;缺任一字段立即失败,不打开浏览器。 + +```python +# 普通兄弟技能 — 走 sibling_bridge +from service.sibling_bridge import call_sibling_json + +result = call_sibling_json("account-manager", ["list", "--limit", "10"]) +``` + +```python +# account-manager 账号/租约 — 集中在 account_client.py(ensure-web) +from service.account_client import pick_web_account, release_lease + +account = pick_web_account(platform="target_platform", login_id=None) +try: + ... +finally: + release_lease(account.get("lease_token")) +``` + +## 相关文档 + +- `RPA.md` — 三端 RPA 技术选型与拟人/反反爬范式 +- `ACCOUNT_MANAGER.md` — 账号、Profile、租约、多账号、查看与排障标准 +- `CONFIG.md` — `.env` 里如何配置运行模式与目标地址 +- `TESTING.md` — 测试 target 与隔离体系 diff --git a/development/AI_RPA.md b/development/AI_RPA.md new file mode 100644 index 0000000..c161d6c --- /dev/null +++ b/development/AI_RPA.md @@ -0,0 +1,234 @@ +# AI + RPA 技能开发总纲 + +本文是技术人员与 AI 编程工具进入本模板后的**第一份架构说明**。先理解本文,再按业务类型阅读 [`RPA.md`](RPA.md)、[`ADAPTER.md`](ADAPTER.md)、[`SKILL_ACTION_RUNTIME.md`](SKILL_ACTION_RUNTIME.md) 与对应 `examples/`。 + +## 目录 + +1. [一句话定义](#1-一句话定义) +2. [职责边界](#2-职责边界) +3. [标准执行闭环](#3-标准执行闭环) +4. [合规与技术红线](#4-合规与技术红线) +5. [工业级 RPA 风控](#5-工业级-rpa-风控) +6. [等待与操作节奏](#6-等待与操作节奏) +7. [三端统一与选型](#7-三端统一与选型) +8. [结果契约](#8-结果契约) +9. [开发与交付顺序](#9-开发与交付顺序) + +## 1. 一句话定义 + +**AI Agent 是用户入口与业务编排者,RPA 是确定性执行器。** + +- Agent 负责理解用户意图、补齐参数、判断风险、选择 Action、发起任务和解释结果。 +- RPA 负责按已验证的步骤操作浏览器、桌面软件或手机,不临时理解模糊业务。 +- 业务规则放在唯一 domain service 中;Agent、CLI、Action、定时任务与数据管理入口复用同一内核。 +- RPA 必须返回结构化结果、进度与存证,由 Agent 用业务语言反馈给用户。 + +本模板追求的是**稳定、合规、可控制、可追溯、可交付的工业级界面自动化**,不是与第三方安全机制对抗。 + +## 2. 职责边界 + +| 层级 | 负责 | 不负责 | +|------|------|--------| +| Agent | 意图理解、缺参追问、风险确认、Action 选择、结果解释 | 不猜界面步骤,不临时改业务规则,不直接绕过安全验证 | +| Action / 编排层 | 参数校验、鉴权、任务状态、进度、暂停停止、日志、人工介入 | 不按入口复制业务逻辑 | +| Domain Service | 业务规则、幂等、防重复、状态流转、结果汇总 | 不关心来自 Agent、定时任务还是工具栏 | +| Adapter / RPA | 调官方 API 或按确定流程操作界面 | 不理解模糊意图,不自行扩大权限或操作范围 | +| 宿主 | Action 展示、任务中心、数据管理、共享 runtime | 不替技能实现业务规则 | + +Agent 与 RPA 的边界必须保持清晰: + +- 把不确定的理解与沟通交给 Agent。 +- 把可验证、可重复的执行步骤交给代码和 RPA。 +- 不让 Agent 在运行时自由生成高风险界面操作。 +- 不让 RPA 根据模糊文本自行决定付款、删除、审批、发布或发送范围。 + +## 3. 标准执行闭环 + +```text +用户自然语言 + → Agent 识别意图与参数 + → 缺参追问 / 风险确认 + → 选择已声明的 Skill Action + → 编排层做校验、鉴权、预检、幂等检查 + → Domain Service 执行业务规则 + → Adapter 选择 API / 浏览器 / 桌面 / 手机 + → RPA 分步骤执行并持续上报进度 + → 验证码、人脸、U盾等转人工处理 + → 返回结构化结果、日志、截图与可选视频 + → Agent 向用户解释结果与下一步 +``` + +### 执行前 + +Agent 与编排层必须确认: + +- 用户意图、操作对象、范围与必填参数明确。 +- 当前账号、数据、设备和目标软件属于用户或企业授权范围。 +- 查询、准备数据与外部副作用已经分开。 +- 付款、删除、审批、发布、发送等不可逆动作已取得明确确认。 +- 浏览器 Profile、桌面窗口或手机设备已通过预检。 +- 同一业务键不会被重复提交。 + +### 执行中 + +- 每个用户可感知的动作都要有中文进度 step。 +- 使用可暂停、可停止的等待;禁止 RPA 主路径裸 `asyncio.sleep`。 +- 同一账号、Profile 或设备默认串行执行。 +- 遇到安全验证、权限不足、频控或风险提示时,转人工或安全停止。 +- 批量任务记录处理位置,支持失败定位和必要的断点续跑。 + +### 执行后 + +- 先验证业务成功标志,再记录成功;不得把“点击完成”当成“业务成功”。 +- 返回成功、部分成功、失败、停止或等待人工等明确状态。 +- 保存必要的运行日志、错误码、失败截图和可选录屏。 +- Agent 根据结构化结果解释完成数量、失败原因和下一步,不直接粘贴技术日志。 + +## 4. 合规与技术红线 + +### 允许 + +- 使用用户或企业合法拥有、明确授权的账号与业务权限。 +- 通过正常界面入口完成用户本来有权完成的操作。 +- 使用浏览器 DOM、Windows UI Automation、Android/iOS 控件树等公开自动化能力。 +- 采用真实点击、逐字输入、自然鼠标轨迹、分段滚动和合理业务节奏。 +- 遇到验证码、人脸、指纹、短信、U盾或动态口令时暂停并请求人工处理。 +- 在授权范围内保存必要的日志、截图、视频和业务结果。 + +### 禁止 + +- 破解、反编译、逆向第三方软件或分析其私有协议。 +- 调用未经公开授权的隐藏接口,或通过抓包拼装私有请求代替正常界面。 +- 绕过登录、验证码、人脸、指纹、短信、U盾、设备绑定、签名或权限校验。 +- 注入、Hook、篡改第三方进程、客户端、APK、IPA、安全模块或通信内容。 +- 窃取、共享或复用未授权的 Cookie、Token、Session、密码或设备凭据。 +- 撞库、暴力尝试、自动破解验证码、批量养号或伪造身份。 +- 通过换账号、换代理、伪造设备指纹、提高并发等方式规避平台限制。 +- 识别到风险提示或封禁后继续自动重试、试探或隐藏自动化行为。 + +“采用 RPA”不代表任何任务都可以自动执行。技能仍须遵守账号授权、数据权限、目标平台规则、合同约定和适用法律;边界不清时停止并请求负责人确认。 + +## 5. 工业级 RPA 风控 + +### 风控信号与处理 + +| 信号 | 标准处理 | +|------|----------| +| 页面或软件加载缓慢 | 等待真实状态,必要时有限重试 | +| 普通网络错误 | 递增退避后有限重试 | +| 登录失效 | 请求用户重新登录 | +| 验证码、短信、人脸、指纹、U盾 | 进入人工处理,禁止自动绕过 | +| 频率限制 | 停止当前批次或长时间退避 | +| 账号风险、封禁或安全警告 | 立即停止,禁止自动换号或继续试探 | +| 权限不足 | 返回明确失败,不尝试扩大或绕过权限 | +| 页面或控件结构变化 | 截图并停止,交由开发人员重新验证 | +| 提交结果不明确 | 查询业务状态;确认前禁止再次提交 | + +### 运行限制 + +- 同一账号、Profile、桌面会话或手机设备默认只运行一个任务。 +- 设置单批数量、单位时间操作量和总运行时长上限。 +- 只对明确可重试错误做有限重试;禁止无限循环。 +- 采用递增退避,不以快速重复点击或刷新解决延迟。 +- 关键副作用采用“准备数据 → 用户确认 → 执行 → 结果核验”两阶段流程。 +- 风控策略用于减少误操作、系统压力和误判,不得用于规避第三方安全控制。 + +## 6. 等待与操作节奏 + +区分三类等待,禁止只靠固定睡眠碰运气。 + +### 状态等待 + +页面加载、控件可用、弹窗消失、保存成功、下载完成等必须等待**真实条件**并设置超时。固定等待不能代替状态判断。 + +自 **platform-kit 1.2.11** / 本模板 **1.0.78** 起: + +- 统一使用 `jiangchang_skill_core.rpa.poll_until`(或 `poll_until_sync`),内部已接 `interruptible_sleep`。 +- 先有界等待就绪,再区分「加载慢」与「页不对」;超时优先 `ERROR:UI_NOT_READY`。 +- 不要把慢机器症状映射成配置错误、网络错误或频控,除非有明确证据。 +- 详细表与纪律见 [`RPA.md`](RPA.md) §0.3 / §4。 + +### 操作节奏 + +真实业务环境中,两个正常业务动作之间默认随机等待 **1~3 秒**: + +```env +STEP_DELAY_MIN=1.0 +STEP_DELAY_MAX=3.0 +``` + +- 输入采用逐字符随机延迟。 +- 鼠标采用自然移动轨迹与小幅随机偏移。 +- 滚动采用分段操作。 +- 提交后等待业务结果,禁止重复点击。 +- 测试 / sandbox 可使用更短节奏,但不得把测试节奏用于真实第三方系统。 + +### 前后缓冲 + +- 浏览器或软件启动:先等待会话与目标页面/窗口真实就绪,再额外缓冲约 `1.5~2.5` 秒。 +- 页面导航:关键控件就绪后,再缓冲约 `1~2` 秒。 +- 普通流程结束:确认业务结果、写日志与存证后,再缓冲约 `2~3` 秒关闭。 +- 付款、审批、发布等重要动作:确认结果并截图后,可缓冲约 `3~5` 秒。 + +缓冲时间只用于稳定界面和改善用户观感,不能作为成功判定。RPA 等待必须使用 `interruptible_sleep` 或共享可控制原语。 + +## 7. 三端统一与选型 + +优先选择最稳定、最受支持的执行方式: + +1. 本地确定性逻辑可以完成时,不启动 RPA。 +2. 有稳定且获得授权的官方 API 时,优先 API。 +3. 只能通过网页操作时,使用浏览器 RPA。 +4. 目标为 Windows 原生软件时,优先 UI Automation。 +5. 目标为手机 App 时,使用系统控件树与正常设备自动化。 +6. 只有破解、逆向或绕过安全机制才能完成时,停止开发并上报,不改走违规路线。 + +三端详细标准见 [`RPA.md`](RPA.md)。当前浏览器 RPA 已有可复制案例;桌面与手机仍须基于真实项目继续沉淀,未验证内容不得包装为成熟方案。 + +## 8. 结果契约 + +RPA 至少返回: + +```json +{ + "success": true, + "status": "success", + "message": "任务已完成", + "summary": { + "total": 10, + "success_count": 9, + "failed_count": 1 + }, + "items": [], + "errors": [], + "artifacts": { + "screenshots": [], + "video_path": null + }, + "next_actions": [] +} +``` + +状态至少能区分: + +- `success`:全部完成。 +- `partial`:部分成功,列出失败项。 +- `failed`:未完成,给出稳定错误码与阶段。 +- `waiting_human`:等待人工验证或接管,不是假失败。 +- `confirmation_required`:等待用户批准副作用。 +- `stopped`:用户或宿主主动停止。 + +具体字段可按业务扩展,但不得只返回控制台文本或把技术 traceback 直接交给用户。 + +## 9. 开发与交付顺序 + +1. 填写 [`REQUIREMENTS.md`](REQUIREMENTS.md) 中的 Agent、授权、风险、人工介入与结果设计。 +2. 判断采用 API、浏览器、桌面或手机,并选择真实 / 仿真 adapter。 +3. 定义用户意图、Action、输入 schema 与确认策略。 +4. 先实现 domain service、结构化结果和 mock 测试。 +5. 再实现 RPA 会话、真实操作、状态等待、进度、截图与人工介入。 +6. 在真实界面上确认 selector / 控件,禁止凭经验猜测。 +7. 完成仿真测试、真实业务闭环和宿主多入口验收。 +8. 使用 [`CHECKLIST.md`](CHECKLIST.md) 做第三方交活验收。 + +AI 编程工具不得为了“跑通”擅自采用破解、逆向、私有协议、验证码绕过、进程注入、无限重试或未授权凭据。正常界面无法完成闭环时,应停止并向负责人报告边界。 diff --git a/development/CHECKLIST.md b/development/CHECKLIST.md new file mode 100644 index 0000000..720c6b4 --- /dev/null +++ b/development/CHECKLIST.md @@ -0,0 +1,201 @@ +# 技能交活验收 Checklist(第三方视角) + +checklist_version: 1.0 + +本清单用于**旁人 / 测试 / AI 编程工具**对任意业务技能做交活验收。 +它描述的是「验什么、怎样算过」,**不是**开发规范摘要。写法与实现约束见同目录其他文档。 + +--- + +## 验收输出强制规则(凡执行本清单必须遵守) + +> 面向 AI 与人工验收员:只要开始按本 Checklist 验收,下列规则**全部强制**,无需用户在提示词里再重复。 + +1. **必须落盘报告**,禁止只在对话里给出口头结论。 +2. **报告目录(唯一合法位置)**:技能仓库**根目录**下的 `acceptance-reports/` + (与 `development/`、`scripts/` 平级;**禁止**写到 `development/`、`tests/`、临时目录或其他路径。) +3. **报告文件名(唯一合法格式)**:`YYYYMMDD-HHMMSS-acceptance.md` + 示例:`20260722-175000-acceptance.md` + 时间取验收执行时的本地时间;**禁止**使用 `report.md`、`验收.md`、无时间戳的文件名。 +4. 若 `acceptance-reports/` 不存在,**先创建该目录再写入**。 +5. 报告**开头**必须写明: + - 完整相对路径:`acceptance-reports/YYYYMMDD-HHMMSS-acceptance.md` + - 技能 slug、当前 git 提交短哈希(若可得)、`checklist_version` +6. 报告正文必须**逐条**对应下方条目,每条给出:`PASS` / `FAIL` / `N/A` / `NEED_MANUAL`,并附简短证据(文件路径、命令、观察结果)。 +7. 报告末尾必须有「给技术人员的修改清单」:只列出未通过项(`FAIL` 与未通过的 `NEED_MANUAL`),按 P0 → P1 → P2 排序。 +8. **禁止修改业务代码**来“凑”验收通过;本清单的执行结果是报告,不是改代码。 +9. 若用户只说「按 Checklist 验收」而未指定路径:仍必须遵守本节省略规则,不得省略落盘。 + +**结果取值** + +| 值 | 含义 | +|----|------| +| PASS | 已满足 | +| FAIL | 不满足 | +| N/A | 本技能不适用(须说明原因) | +| NEED_MANUAL | 须人工点选/真机;报告中写清已做或未做 | + +**级别** + +| 级别 | 含义 | +|------|------| +| P0 | 不通过不可交活 | +| P1 | 限期整改 | +| P2 | 体验建议 | + +--- + +## 0. 技能画像(验收前先填,用于裁剪) + +先根据技能实际情况勾选;后续章节不适用则整项标 `N/A` 并注明原因。 + +- [ ] S0-1 交互形态:纯本地 / API / 网页RPA / 桌面RPA / 手机RPA / 编排(调兄弟技能) +- [ ] S0-2 目标站登录策略:required / optional / not_needed / 不适用 +- [ ] S0-3 是否依赖账号会话(account-manager 或等价 Profile):是 / 否 +- [ ] S0-4 持久化:无 / 另有业务表 +- [ ] S0-5 Skill Action:无 / 仅同步短任务 / 含异步长任务 +- [ ] S0-6 是否需要在任务中心查看进度或结果:是 / 否 +- [ ] S0-7 外部系统账号、数据、设备与操作权限的授权来源: +- [ ] S0-8 是否包含提交、付款、删除、审批、发布、发送等不可逆副作用:是 / 否 +- [ ] S0-9 **入口定位表**(见 `PRODUCT_SURFACES.md` / `REQUIREMENTS` §0.1):已填写 / 未填写 +- [ ] S0-10 本技能宣称的主用户路径涉及:对话 / 数据管理(执行或仅展示) / 定时(可多选;与定位表一致) +- [ ] S0-11 **业务行生命周期**(见 `references/WORK_ITEM_STATUS.md` / `REQUIREMENTS` §0.3):有处理队列且已声明状态→角色映射 / 不适用 +- [ ] S0-12 **HTML 页面报告**(见 `references/ACTIONS.md`「HTML 页面报告」):有侧栏可预览的 `html/*.html` + `render-html` / 不适用 + +--- + +## 1. 安装与可运行(所有技能) + +- [ ] A1-1 **P0** 按平台方式加载/安装后,技能可被识别(有名称,能打开详情或执行入口) +- [ ] A1-2 **P0** 健康检查(如 `health`)能跑完且结果可读;缺关键条件时有明确失败提示,而非空白崩溃 +- [ ] A1-3 **P0** 版本查询(如 `version`)返回的版本与对外宣称一致 +- [ ] A1-4 **P1** 若有用户可见配置:说明可读;按说明改关键项后行为符合说明 + +--- + +## 2. 对外说明是否自洽(所有技能) + +- [ ] A2-1 **P0** 市场「说明」能让非开发者理解:做什么、给谁用、开始前准备什么 +- [ ] A2-2 **P0** 「说明/教程」不以开发目录、内部实现细节作为用户主路径 +- [ ] A2-3 **P1** 「教程」可按真实顺序跟做(准备 → 分入口步骤 → 失败怎么办);结构见 `TUTORIAL_SURFACES.md` +- [ ] A2-4 **P1** 「演示」结构完整(有演示信息位;无视频则标明暂无,不假装有) +- [ ] A2-5 **P0** 「更新日志」能对应到当前交付版本 +- [ ] A2-6 **P0** 平台/Agent 侧说明中,常见用户意图的触发方式与真实能力一致(短操作 vs 长任务不混淆) +- [ ] A2-7 **P0** 「说明」Tab(`README.md`)**不含**分步操作(1/2/3)、Agent 话术块、「入口一/二」专章;操作指引指向「使用教程」 +- [ ] A2-8 **P0** 「教程」Tab 覆盖 `REQUIREMENTS` §0.1 / `actions.json` 中所有「挂」的入口(数据管理 / 对话;结构见 `TUTORIAL_SURFACES.md`) +- [ ] A2-9 **P0** 无 `cron` 时教程有「不需要定时」+ 理由;有 `cron` 时教程写清如何配置 +- [ ] A2-10 **P1** 教程主能力对照表与 `actions.json` 的 `label` 一致 +- [ ] A2-11 **P1** 若有数据管理主路径:准备/载入/导入类能力在对应表 **toolbar** 可见;教程**不以** skill-detail 为唯一主路径 +- [ ] A2-12 **P0** 说明/教程正文**不以** `# 技能名` 或 `# 技能名 · 使用教程` 开篇(宿主页头/Tab 已有);主分节用 `##`,不要把原 `##` 升成 `#`(见 `TUTORIAL_SURFACES.md` §0.1) + +--- + +## 3. 主能力行为(所有技能) + +- [ ] A3-1 **P0** 主成功路径能按宣称完成核心动作(至少 1 次完整闭环) +- [ ] A3-2 **P0** 缺参或非法输入时稳定失败(可读错误,不假成功) +- [ ] A3-3 **P1** 失败可诊断:能从任务记录/提示判断失败阶段,而非只有“失败了” +- [ ] A3-4 **P0** 同一能力从已声明的不同入口触发时,业务结果一致(不出现入口分叉成两套逻辑) +- [ ] A3-5 **P0** `REQUIREMENTS` 入口定位表已填写;定位为「挂」的入口在 `actions.json`(及必要表元数据)中有对应声明 +- [ ] A3-6 **P1** 定位为「不挂」的入口有书面理由(不是空白遗漏) + +--- + +## 4. 调度与长任务(有 Action 或长任务时适用) + +- [ ] A4-1 **P0** 秒级只读/短操作:不进入任务中心也能完成(若技能提供此类能力) +- [ ] A4-2 **P0** 长耗时/浏览器/需进度或取消的能力:经 Agent 或技能动作触发后进入任务中心(或平台等价异步队列) +- [ ] A4-3 **P0** 异步任务在任务中心能看到进行中与结束状态;结束有成功或失败结论 +- [ ] A4-4 **P1** 进行中的进度/步骤信息对用户可理解 +- [ ] A4-5 **P1** 已对外暴露的动作参数覆盖主路径关键输入(无“命令行能做、动作入口不能做”的关键缺口) +- [ ] A4-6 **P0** 若 Action 需引用同库另一张业务表(话术/平台/账号等):参数为 Lookup 下拉(内联 `lookup` 或绑列),**不是**手填内部编号;可选「自动」可留空提交 +- [ ] A4-7 **P1** 若 Action 有互斥采集/操作模式(如「按关键词 / 按主页」):用 `inputSchema` 字段级 **`visibleWhen`** 联动显隐,勿只靠 `description` 灰字说明「仅某某时填写」(见 `references/ACTIONS.md`;勿与 Policy L3 行态 `visibleWhen` 混淆) +- [ ] A4-8 **P1** 行内/勾选打开的 `form`:有「已选择」时参数区不应再重复映射主键(`inputMapping` + `readOnly`);需要强制露出时用 `revealInForm`,**禁止**用 `visibleWhen` 藏主键(会不提交) +--- + +## 5. 账号与浏览器会话(网页 RPA 适用;其他形态标 N/A 或改测等价项) + +- [ ] A5-1 **P0** 未准备好账号/浏览器档案时,主路径失败并提示,不会默默用本机默认浏览器身份跑起来 +- [ ] A5-2 **P0** 登录策略为 required 时:未登录不能把做不成的事当成成功 +- [ ] A5-3 **P0** 交付联调默认能看见浏览器窗口(有头);不能把无头当作唯一可验收形态 +- [ ] A5-4 **P1** 浏览器窗口启动形态满足交付约定(如最大化或可视区域足够操作与人工介入) +- [ ] A5-5 **P1** 验证码或人工确认:有等待与超时;超时后任务失败可理解,不无限挂死或假成功 +- [ ] A5-6 **P0** **NEED_MANUAL** 真实站或约定仿真站主路径至少完整跑通 1 次;仅模拟数据通过不算交活 +- [ ] A5-7 **P0** 账号创建后可在宿主数据管理和 `account list/get` 查到,返回的 `profile_dir` 在当前用户数据目录真实存在 +- [ ] A5-8 **P0** 每个账号使用独立且稳定的 Profile;不同账号不共用,本机日常浏览器目录不参与运行 +- [ ] A5-9 **P0** 指定账号 ID、指定登录标识和自动选择均取得非空租约;成功、失败、取消和异常路径最终释放 +- [ ] A5-10 **P0** **NEED_MANUAL** 登录后关闭浏览器并释放租约,再取同一账号重开,登录状态仍有效;失效时明确提示重新登录 +- [ ] A5-11 **P1** 至少两个账号连续或轮换测试通过,Profile、登录态、结果和失败互不污染 +- [ ] A5-12 **P1** 租约冲突、残留租约和账号登录失效能按文档自助定位,不通过删库、删 Profile 或自动换号处理 + +--- + +## 6. 结果落点(按画像裁剪) + +- [ ] A6-1 **P0** 宣称可追溯时:跑完后任务记录中有对应条目(成功/失败均有) +- [ ] A6-2 **P0** 宣称写入业务数据或可在数据管理查看时:成功跑完后能看到新业务数据;仅有任务摘要、业务侧仍空 → **FAIL** +- [ ] A6-3 **P1** 重复执行同一业务键时,结果符合宣称(更新/去重/追加),无莫名脏数据或静默丢数据 +- [ ] A6-4 **P1** 「零结果成功」与「失败」可区分 +- [ ] A6-5 **P1** 若产出文件:落在约定数据位置且可找到,不丢在不明临时路径 +- [ ] A6-6 **P0** 有业务表时:`sqlite_master` 中业务表 DDL **不得**含 SQLite `FOREIGN KEY` / `REFERENCES`;跨表只存业务 id,关联与级联在 repository/service;界面名称用 `lookup`(见 `references/SCHEMA.md`);可跑 `validate_template_database` / `init-db` 后自检 +- [ ] A6-7 **P0** 有「处理业务行」类 Action 时(见 `WORK_ITEM_STATUS.md`):默认只处理**可操作**角色状态;成功/失败/跳过/进行中再点处理应拒绝或跳过且不产生重复副作用;重做须人工改回可操作。无处理队列 → **N/A** +- [ ] A6-8 **P0** 同上适用时:开始处理后业务表进入**进行中**;成功回写成功类终态;失败回写失败类终态且**失败原因**可读(任务成功但业务行仍停在可操作/进行中 → **FAIL**) +- [ ] A6-9 **P1** 同上适用时:`REQUIREMENTS` §0.3(或等价)已填写本技能状态→角色映射;处理类 Action 确认文案写清仅可操作态 +- [ ] A6-10 **P1** 有 HTML 侧栏预览时(S0-12):预览区横向铺满、无横向滚动条;重新生成后内容与页头时间更新;布局符合 `references/ACTIONS.md`「宿主预览布局」。无 HTML 报告 → **N/A** +- [ ] A6-11 **P1** 业务**状态 / 启用类**列若用户需要筛表或顶栏搜中文:`searchable: 1` 且 `options_json`(或 `boolean` + `options_json`)的 **value 与存库一致**;仅 `searchable: 0` 则宿主不提供列筛与全局搜索。见 `references/SCHEMA.md`「列筛选与全局搜索」 +- [ ] A6-12 **P2** `display_type: boolean` 的展示列推荐同时配置 `options_json` 中文 label(如 停用/启用),避免仅靠宿主默认「是/否」;无 boolean 列 → **N/A** +- [ ] A6-13 **P0** 新技能**不得**创建 `task_logs`;过程/进度/业务结果走运行日志、任务中心、业务表(见 `LOGGING.md`、`SCHEMA.md`) +- [ ] A6-14 **P0** 废弃用户可见表时:`drop_deprecated_user_table` 物理删表 + 清元数据;**禁止**只删元数据留物理表;有迁移时须提供旧库回归测试(见 `SCHEMA.md`、`tests/samples/test_table_deprecation_migration.py.sample`) + +--- + +## 7. 可观测与安全观感(所有技能) + +- [ ] A7-1 **P0** 失败提示或任务结论不泄露密码、完整 Cookie、明文 token 等敏感信息 +- [ ] A7-2 **P0** 统一文件日志(运行日志 / `jiangchang.log`)能定位失败阶段:可见 `task_start` 与 `task_failed|task_success`;失败含 `error_code=`;**不只** `cli_start` +- [ ] A7-3 **P0** 故意失败一条时,文件日志先有 `task_failed`/`warning`,再有 `ERROR:CODE` 或任务中心失败态 +- [ ] A7-4 **P1** 关键步骤有据可查(能对应到开始/关键节点/结束;RPA 宜有多个 `stage=`) +- [ ] A7-5 **P1** 若宣称支持录屏或截图存证:打开开关后关键步骤或失败能留下可查看产物;宣称支持却完全无产物 → **FAIL** +- [ ] A7-6 **P2** 用户可见文案偏业务语言,而非大段开发黑话 +- [ ] A7-7 **P2** 数据管理技能按钮(toolbar / row / batch)中文名对照 `references/ACTIONS.md`「Action 按钮中文名」;不与宿主「查看 / 编辑 / 删除」抢名;打开类成功不弹成功条。行业专有按钮名可自创 + +--- + +## 8. AI + RPA 合规与工业级风控(外部系统技能适用) + +- [ ] A8-1 **P0** 已确认账号、数据、设备和业务操作均在用户或企业授权范围内;边界不明不能以“默认允许”通过 +- [ ] A8-2 **P0** 代码与运行路径不包含破解、反编译、逆向、未授权私有协议、抓包拼隐藏接口、Hook / 注入、验证码破解、设备指纹伪造、撞库、养号、自动换号或代理池规避限制 +- [ ] A8-3 **P0** 验证码、人脸、指纹、短信、U盾、频控、账号风险和权限不足能转人工或安全停止,不自动硬闯 +- [ ] A8-4 **P0** 付款、删除、审批、发布、发送等不可逆动作执行前有明确对象、范围和用户确认 +- [ ] A8-5 **P1** 页面 / 控件加载、保存和业务成功由真实状态判断,不以固定等待代替成功判定 +- [ ] A8-6 **P1** 真实 RPA 两个正常业务动作之间采用约 `1~3` 秒随机节奏;输入、鼠标和滚动符合正常人工操作方式 +- [ ] A8-7 **P1** 同一账号、Profile 或设备默认串行;批量、频率和运行时长有上限 +- [ ] A8-8 **P1** 只对明确可重试错误做有限重试与递增退避;风险、封禁、权限和结果不明确时不盲目重试 +- [ ] A8-9 **P1** 流程结束先核验业务结果并完成日志 / 截图 / 可选视频,再保留合理结束缓冲后关闭会话 +- [ ] A8-10 **P1** Agent 能区分成功、部分成功、失败、等待人工、等待确认和主动停止,并用业务语言解释结果 +- [ ] A8-11 **P0** 关键元素均有真实拾取证据和 element map;没有真实 DOM/UIA/XML 时未由 AI 猜 selector +- [ ] A8-12 **P0** 不可逆动作定位器在目标作用域内唯一;没有用 `.first` / `hwnds[0]` / `instance=0` 掩盖歧义 +- [ ] A8-13 **P1** 页面、窗口、弹窗、iframe、Native/WebView 等作用域明确;列表先定位业务行再定位动作元素 +- [ ] A8-14 **P1** 重启浏览器/软件/App 后关键定位复测通过;动态列表顺序变化不会误点 +- [ ] A8-15 **P1** 定位失败保存截图和 DOM/UIA/XML、候选数量、作用域及目标版本;结构变化时停止而非在线猜新定位器 +- [ ] A8-16 **P1** 所有 fallback 均经过人工验证;图像/OCR/坐标仅在控件树确认不可用后使用 + +--- + +## 9. 质量门禁与交活底线(所有技能) + +- [ ] A9-1 **P0** 仓库默认测试套件通过(执行技能约定的一键测试命令,如 `python tests/run_tests.py -v`) +- [ ] A9-2 **P0** 不以「仅模拟档通过」作为交活依据(有真实档要求时必须真实跑通或书面阶段性豁免) +- [ ] A9-3 **P0** **NEED_MANUAL** 入口定位表中定为「挂」的宿主入口(详情/Agent/工具栏/定时等)按声明抽测可通过;未在定位表中规划且未声明的入口可标 N/A(须引用定位表理由),**禁止**用「未声明」掩盖「从未做入口定位」 +- [ ] A9-4 **P0** 对外版本一致(POLICY-RELEASE-001):`SKILL.md` version = `SKILL_VERSION` = 发布 git tag(无前缀);`release.ps1` 打 tag 前会硬校验,漏改不得发布;CHANGELOG 有对应小节 +- [ ] A9-5 **P1** 开发者按平台规则能安装并测到该技能(若平台有开发者可见性要求) + +--- + +## 10. 报告结尾必填 + +报告末尾固定三块: + +1. **结论**:通过 / 不通过(任意 P0 为 FAIL 或不通过的 NEED_MANUAL → 不通过) +2. **统计**:PASS / FAIL / N/A / NEED_MANUAL 数量;P0 FAIL 列表 +3. **给技术人员的修改清单**:仅未通过项,带条目 ID 与期望现象 diff --git a/development/CONFIG.md b/development/CONFIG.md new file mode 100644 index 0000000..dc64ed2 --- /dev/null +++ b/development/CONFIG.md @@ -0,0 +1,200 @@ +# 配置标准:`.env` 规范与首次落盘机制 + +> 解决"每个 skill 每次都要手动搞一堆环境变量、账号、地址"的痛点。统一约定:**模板内置 `.env.example`,skill 首次运行自动落盘到用户数据目录,之后从那里读。** + +## 核心原则 + +1. **三层优先级**:`进程环境变量` > `{数据目录}/.env` > 仓库 `.env.example` 默认值。 +2. **`.env.example` 进仓库**:带注释的全量默认配置,是配置项的"单一事实来源"。 +3. **首次运行自动 copy(bootstrap)**:`scripts/main.py` 启动与 `cli.app.main()` 均会调用 `util.config_bootstrap.bootstrap_skill_config()`,把仓库根目录 `.env.example` 复制到 + `{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/{slug}/.env`(**已存在则不整体覆盖**,保护用户改过的值)。 +4. **技能升级合并缺失项**:`merge_missing_env_keys` 会把 `.env.example` 中新增、但用户 `.env` 尚未包含的 key **追加**进去(带注释块)。 +5. **敏感信息绝不进 `.env`**:密码/密钥/口令走 account-manager 的 `secret-ref`(见下"红线")。 + +## `.env.example` 注释 = 配置管理用户文案(硬规则) + +匠厂宿主「数据管理 → 配置管理」会把 `.env` / `.env.example` 的注释展示给**最终用户**(英文 key 可保留作标题)。因此注释必须按**产品文案**来写,不能按开发备忘录来写。 + +### 行头与行尾分工 + +| 位置 | 职责 | 要求 | +|------|------|------| +| **行头**(`KEY=` 上一行的 `# …`) | **是什么**:中文说明该配置项干什么 | **每个活跃 key 必有**一行完整中文行头 | +| **行尾**(`KEY=值` 同一行 ` #` 之后) | **怎么填**:可选值、单位、推荐默认、何时要改 | 有枚举、开关、单位时**必有**;纯自由文本也应尽量写清填法 | + +宿主当前会把「行头 + 行尾」拼成 key 下方的多行说明。按上表书写后,用户看到的自然是「先懂是什么,再知道怎么填」。 + +**不要**依赖 `# ── 分组 ──` 装饰线给整组做唯一说明:宿主只会把行头挂到**紧挨着的下一个活跃 key**,后面的 key 不会自动继承。分组可用空行区分;**逐 key 自带行头(+ 行尾)才是硬要求**。 + +### 必须做到 + +1. 每个会进入配置管理的活跃 `KEY=` 都有完整中文**行头**。 +2. 枚举 / `0|1` 开关 / 带单位的数值:必须有中文**行尾**,解释每个可选值或单位,并给出推荐。 +3. 业务专属 key 同样适用本规则,不得只留英文 key、不写中文说明。 +4. 开发向细节(四档 adapter 实现、录屏管线、路径解析 API)写在 `development/ADAPTER.md`、`RPA.md`、`DATA_PATHS.md` 等文档;**禁止**写进 `.env.example` 注释。 + +**机器门禁**:`POLICY-CONFIG-004`(`tests/test_development_policy_guard.py`)会在默认测试 / `release.ps1` 中校验上述「行头+行尾中文、禁止开发文档指向」;不通过不得发布。 + +### 禁止出现在注释中的内容 + +- `见 development/...`、`见 ADAPTER.md`、`见 RPA.md`、`见 DATA_PATHS.md` 等仓库文档指向 +- 默认假设读者是开发者的黑话:如单独甩 `adapter 档位`、`CI`、`account-manager`、`CWD`、`workspace` 而不解释 +- 只写裸枚举(如 `mock | real_rpa`)却不说明每个值对用户意味着什么 +- 多个 key 共用一个行头,后面的 key 没有任何自己的说明 + +若必须提到账号体系:写成用户能懂的「请在账号管理中登记密码 / 平台标识须一致」,不要只写内部模块名。 + +### 正例 + +```ini +# 运行模式:决定本技能用模拟数据还是真实浏览器/接口去执行 +OPENCLAW_TEST_TARGET=real_rpa # 可选:mock=本地模拟,simulator_rpa=仿真站点,real_api=真实接口,real_rpa=真实浏览器;模板默认真实浏览器;本地单测/CI 请用 mock + +# 是否显示浏览器窗口:网页自动化时是否弹出可见浏览器 +OPENCLAW_BROWSER_HEADLESS=0 # 0=显示窗口(必须,便于介入与排查);1=后台静默(勿作日常用法) +``` + +### 反例(禁止) + +```ini +# ── 运行模式 / adapter 档位(见 development/ADAPTER.md)── +OPENCLAW_TEST_TARGET=mock # 见 development/ADAPTER.md;单测用 mock + +# ── 好看视频 / 百度账号(须与 account-manager 平台 key 一致)── +TARGET_PLATFORM=baidu +HAOKAN_VIDEO_SELECTOR=video.art-video +``` + +问题:文档路径与开发黑话;行尾半给用户半给 CI;后续 key 无行头/行尾,用户只看到英文名。 + +## 标准配置项(`.env` 分组) + +每个 skill 的 `.env.example` 至少包含这些通用项(按需增减)。**注释写法须与仓库根目录 `.env.example` 保持同级清晰度**(行头 = 是什么,行尾 = 怎么填): + +```ini +# 运行模式:决定本技能用模拟数据还是真实浏览器/接口去执行 +OPENCLAW_TEST_TARGET=real_rpa # 可选:mock=本地模拟,simulator_rpa=仿真站点,real_api=真实接口,real_rpa=真实浏览器;模板默认真实浏览器;本地单测/CI 请用 mock + +# 目标网站地址:技能要访问的网站或服务地址 +TARGET_BASE_URL=https://sandbox.jc2009.com # 填写完整网址(含 https://);换环境时再改,一般保持默认即可 + +# 默认登录账号:仅填写非敏感的账号标识(如工号/登录名) +DEFAULT_LOGIN_ID= # 留空=按平台自动选用或登记;有值则按该标识筛选;不要填密码 + +# 默认账号编号:仅填账号管理里的纯数字主键(一般留空) +DEFAULT_ACCOUNT_ID= # 留空=自动;只填纯数字;手机号/用户名请填上方登录账号 + +# 是否显示浏览器窗口:网页自动化时是否弹出可见浏览器 +OPENCLAW_BROWSER_HEADLESS=0 # 0=显示窗口(必须,便于介入与排查);1=后台静默(勿作日常用法) + +# 浏览器兼容配置:仅在项目授权与平台规则允许时使用共享兼容能力 +OPENCLAW_PLAYWRIGHT_STEALTH=1 # 1=使用共享兼容配置,0=关闭;不得用于伪造身份或绕过平台限制 + +# 是否录制操作过程:用于留证、复现问题或向他人演示 +OPENCLAW_RECORD_VIDEO=0 # 0=不录屏(默认),1=录屏并生成带说明的视频文件;需要留证时再改为 1 + +# 失败时是否截图:任务出错时自动保存现场截图,便于排查 +OPENCLAW_ARTIFACTS_ON_FAILURE=1 # 1=失败时截图(推荐),0=不截图 + +# 步骤最短等待:两个正常业务动作之间随机停顿的下限 +STEP_DELAY_MIN=1.0 # 单位:秒;一般保持默认,页面不稳定时可略调大 + +# 步骤最长等待:两步操作之间随机停顿的上限 +STEP_DELAY_MAX=3.0 # 单位:秒;默认与最短等待组成 1~3 秒节奏;状态等待须另行判断 + +# 人工处理超时:遇到滑块、验证码、二次验证时,等待人工处理的最长时间 +HUMAN_WAIT_TIMEOUT=180 # 单位:秒;来不及处理时可调大,如 300 + +# 浏览器启动超时(可选):限制 launch 最长时间;注释掉或不设=不限制 +# OPENCLAW_BROWSER_LAUNCH_TIMEOUT_MS=60000 # 单位:毫秒;慢机器时可开启 +``` + +四档运行模式的**实现与测试约定**见 [`ADAPTER.md`](ADAPTER.md);录屏管线见 [`RPA.md`](RPA.md)。这些内容**不要**写回 `.env.example` 注释。 + +**业务专属配置必须带技能前缀**(如 `DEMO_XXX`、`MY_SKILL_XXX`、`SKILL_OUTPUT_DIR`),**不要污染全局命名空间**(禁止 `SCRAPE_1688_*`、`RECEIVE_ORDER_*` 等跨技能前缀写入模板)。 + +### 数据目录子路径(输入 / 输出) + +用户可见目录:文件浏览只有 **`input/`(上行)** 与 **`output/`(下行)**;另有可选 **`html/`** 供宿主扫描 HTML 报告页(见 `DATA_PATHS.md`)。技能写入的业务文件产出默认落在 `output/`;整页报告落在 `html/`;用户放入的待处理文件落在 `input/`。须通过 `util.runtime_paths.resolve_data_path()` 或 `get_input_dir()` / `get_output_dir()` / `get_html_dir()` 解析,**禁止** `os.path.abspath(config.get(...))` 相对 CWD。 + +- 权威说明(开发):[`DATA_PATHS.md`](DATA_PATHS.md) +- `.env.example` 中路径类配置**留空或保持注释即用默认**;若写相对路径,必须相对 **skill 数据目录**,**禁止** `./outputs/...` 这类相对 workspace 的写法 +- 可选 env:`SKILL_INPUT_DIR`、`SKILL_OUTPUT_DIR` 等;若作为活跃 key 出现在配置管理中,同样须有用户向行头/行尾(见模板根 `.env.example`) + +### 录屏开关(`OPENCLAW_RECORD_VIDEO`) + +- **模板默认 `OPENCLAW_RECORD_VIDEO=0`**,降低本地开发 / 调试成本(不启 ffmpeg、不生成 MP4)。 +- 需要**生产留证、合规记录、客户现场复现或排障**时,可在用户 `.env` 或进程环境变量中设为 `OPENCLAW_RECORD_VIDEO=1`。 +- **默认关闭不代表可以删除录屏接入**:RPA / 长任务代码仍须保留 `RpaVideoSession`、`video.add_step` 与 video artifact 写入 `finish()`(见 `RPA.md` §5.3)。 +- **已落盘的用户 `.env` 不会被模板整体覆盖**;老用户保留原值,技能升级时仅通过 `merge_missing_env_keys` **追加** `.env.example` 中新增的 key。 + +## 🚫 红线:敏感信息不进 `.env` + +`.env` 只放**非敏感运行参数**(模式、地址、开关、超时、账号标识)。密码、密钥、动态口令、token—— + +- 走 **account-manager** 注册凭据,用 `--secret-storage env --secret-ref XXX` 引用; +- 真实值由宿主/用户通过进程环境变量注入,**不落任何文件、不进 git**。 + +`.gitignore` 必须包含: + +```gitignore +.env +*.env.local +``` + +## 配置 bootstrap(模板提供) + +模板在 `scripts/util/config_bootstrap.py` 提供 `bootstrap_skill_config()`: + +```python +from util.config_bootstrap import bootstrap_skill_config + +bootstrap_skill_config() # main.py 与 cli.app.main() 启动时调用 +``` + +内部委托共享 runtime 的 `jiangchang_skill_core.config`: + +```python +config.ensure_env_file(SKILL_SLUG, example_path) # 首次落盘 +config.merge_missing_env_keys(example_path, env_path) # 升级后追加缺失 key +``` + +业务代码**只通过 `config.get*` 读配置**,不直接 `os.environ`: + +```python +config.get("TARGET_BASE_URL") +config.get_bool("OPENCLAW_BROWSER_HEADLESS") +config.get_float("STEP_DELAY_MIN", 1.0) +``` + +### 空值、行尾注释与假默认(硬规则) + +- **留空即未配置**:`KEY=` 或 `KEY= # 说明` 必须读成空字符串,再走技能自己的「自动 / ensure」逻辑;**禁止**把示例说明、`# 可填…` 当成真实配置值。 +- **需要 platform-kit ≥ 1.2.2**:旧版解析曾把「空值 + 行尾 `#`」误当成 `# 可填…` 这种假值。技能若依赖正确空值语义,须在 `SKILL.md` 声明不低于该版本。 +- **需要 platform-kit ≥ 1.2.11(本模板默认)**:RPA 有界就绪等待 `poll_until`、可选 `OPENCLAW_BROWSER_LAUNCH_TIMEOUT_MS`、health 中只读 `machine_power`。新技能直接声明 `"1.2.11"`。 +- **网页账号配置语义**(有则用): + - `DEFAULT_ACCOUNT_ID`:仅**纯数字**视为账号主键;校验指定记录后仍必须取得该账号租约,不能只 `account get` + - `DEFAULT_LOGIN_ID`:手机号 / 用户名等;留空则按平台 `ensure-web`;非数字 `DEFAULT_ACCOUNT_ID` 可兼容为 login_id,但不推荐 + - 占位串(`#…`、`none`、`n/a`、`default-account`)一律当未配置 +- **用户隔离必须一致**:调用 account-manager 时继承宿主注入的 `JIANGCHANG_DATA_ROOT` / `JIANGCHANG_USER_ID`;不得覆盖为开发者自己的固定路径,否则账号会写到另一位用户的数据目录。 +- **不要**在 `.env.example` 里塞「看起来像真值」的演示账号(如假手机号),以免首次落盘后被当成用户配置。 + +## health / config-path + +- **`health`**:输出 `collect_runtime_diagnostics` 字段(`platform_kit_version_ok`、`ffmpeg_path` 等),**不打印敏感值**;补充 `env_path` / `env_exists` / `example_path`。 +- **`config-path`**:输出 JSON,包含 `skill`、`env_path`(用户数据目录 `.env`)、`example_path`(仓库 `.env.example` 绝对路径),便于排查落盘位置;业务 skill 建议附加 `list_resolved_data_paths()` 中的子目录路径(见 [`DATA_PATHS.md`](DATA_PATHS.md))。 + +## doctor / setup 命令(可选) + +每个 skill 可提供自检命令: + +- `python scripts/main.py doctor`:检查浏览器、Profile/账号、`.env` 落盘、设备等。 +- `python scripts/main.py setup`:初始化演示账号、首次落盘 `.env`。 + +## 相关文档 + +- `RPA.md` — 三端 RPA 标准与各开关含义(开发向;勿写入 `.env` 注释) +- `ACCOUNT_MANAGER.md` — 账号选择、Profile、租约、数据位置与验收 +- `ADAPTER.md` — `OPENCLAW_TEST_TARGET` 四档模式(开发向;勿写入 `.env` 注释) +- `RUNTIME.md` — `JIANGCHANG_DATA_ROOT` 与数据根约定 +- `DATA_PATHS.md` — 下载/导入/导出等子目录与 `resolve_data_path` 规范 diff --git a/development/DATA_PATHS.md b/development/DATA_PATHS.md new file mode 100644 index 0000000..88a28b0 --- /dev/null +++ b/development/DATA_PATHS.md @@ -0,0 +1,193 @@ +# 用户数据目录与文件路径黄金标准 + +> 本文是 skill **读写本地文件** 的权威约定。凡涉及输入、输出、缓存、RPA 存证等路径,复制模板后须遵守本文;Agent 实现业务 skill 时也应先读本文。 + +--- + +## 1. 根目录 + +所有技能持久化数据(配置、数据库、业务文件)默认落在: + +```text +{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/{skill_slug}/ +``` + +示例: + +```text +D:\jiangchang-data\12500\download-video-baidu-haokan\ +``` + +实现:`util.runtime_paths.get_skill_data_dir()`。 + +**禁止**把技能自己写入的文件落到: + +- Agent / 宿主 **workspace**(进程 CWD) +- 技能安装目录(含 `.openclaw/skills/...`) +- 未在本文声明的任意相对 CWD 路径 + +--- + +## 2. 标准子目录树 + +用户可见 · **文件目录**(数据管理「输入 / 输出」文件浏览器只挂这两个): + +| 子目录 | 方向 | 用途 | 默认 helper | +|--------|------|------|-------------| +| `input/` | 上行 | 用户/Agent 放入的待处理材料(urls.txt、batch.json、CSV、产品表…) | `get_input_dir()` | +| `output/` | 下行 | 技能/宿主交给用户拿走的结果(视频、回单、xlsx/csv/json…) | `get_output_dir()` | + +用户可见 · **HTML 报告页**(宿主扫描挂到技能下,**不是** input/output 文件浏览器): + +| 子目录 | 用途 | 默认 helper | +|--------|------|-------------| +| `html/` | 技能写出的静态 `.html` 报告;宿主一层扫描 `*.html`,侧栏预览 / 右键重新生成 | `get_html_dir()` | + +内部 / 可选目录(**不上侧栏**): + +| 子目录 | 用途 | 默认 helper | +|--------|------|-------------| +| `uploads/` | 待发布到外部平台的 staging(可选) | `get_uploads_dir()` | +| `cache/` | 可再生缓存(页面快照、解析中间结果) | `get_cache_dir()` | +| `temp/` | 单次任务 scratch,任务结束可删 | `get_temp_dir()` | +| `rpa-artifacts/` | RPA 失败截图、录屏中间产物 | `get_rpa_artifacts_dir(batch_id)` | +| `videos/` | RPA 录屏成片 MP4 | `get_videos_dir()` | +| `{skill_slug}.db` | SQLite | `get_db_path()` | +| `.env` | 用户配置 | `config.get_env_file_path()` | + +复制后若业务不需要某目录,可不创建;**但一旦写入该类文件,必须走对应 helper**。 + +**开发者硬规则:** + +- 用户/Agent **放进来**的文件 → `input/`(`get_input_dir()`) +- 技能/宿主 **交给用户拿走**的文件 → `output/`(`get_output_dir()`) +- 可浏览的 **HTML 报告页** → `html/`(`get_html_dir()` / `service.html_page_service`);**不要**把整页报告塞进 `output/` 指望侧栏预览 +- 禁止再引入业务「文件浏览器」第三目录(如旧的 `downloads` / `imports` / `exports` 三分法);`html/` 是报告页扫描面,不是第三种文件浏览器 +- 缓存、临时、RPA 录屏用 `cache` / `temp` / `videos`,不要当成用户可见的「输出目录」 + +宿主约定(与本模板对齐): + +- 工具栏「导入」**UI 一律**打开宿主「导入数据」面板(默认从本技能 `input/` 选文件);「导出」默认写到 `output/` +- **Action 表单**里 `display_type`/`format` 为 `file` 或 `directory` 时:宿主原生对话框默认打开**当前技能** `{skill_data}/input`;若字段声明可选 **`pathRoot`**(相对技能数据根,如 `input/库存数据`),则打开该子目录。禁止在技能里写用户 ID 或绝对盘符。契约见 [`../references/ACTIONS.md`](../references/ACTIONS.md)「本地路径选择与 pathRoot」 +- 若表声明了 `replaces: import`,确认导入后由技能 CLI 写库(见 [`../references/ACTIONS.md`](../references/ACTIONS.md)「`replaces`」);仅有 `replaces: create` 时宿主可按行调 create(兼容) +- 数据管理扫描 `{skill_data}/html/*.html`(一层、非递归);`` → 侧栏显示名;「重新生成」调用 Action id **`render-html`**(见 [`../references/ACTIONS.md`](../references/ACTIONS.md)「HTML 页面报告」) + +--- + +## 3. 两类路径 + +### A. 技能拥有(skill-owned) + +技能**创建或更新**的文件:下载结果、导出报表、失败截图、队列 sidecar 等。 + +- **默认**必须在 `{skill_data_dir}/<子目录>/` 下。 +- 用户可拿走的业务产出 → **`output/`**。 +- **必须**通过 `resolve_data_path()` 或 `get_*_dir()` 解析,**禁止** `os.path.abspath(config.get(...))` 相对 CWD。 + +### B. 用户显式输入(user-owned) + +用户在 CLI / Agent 对话中给出的**已有文件**绝对路径,例如「发布这个视频 `D:\素材\x.mp4`」。 + +- 技能**只读**,路径可以是用户磁盘任意位置。 +- 使用 `resolve_input_path()`:绝对路径原样;**相对路径**解析为 `{skill_data_dir}/input/<相对路径>`,**不**相对 workspace/CWD。 + +--- + +## 4. 配置项(`.env`) + +可选覆盖项(**留空则用默认子目录**): + +```ini +# ── 数据目录子路径(见 development/DATA_PATHS.md)── +# 相对路径相对于 {JIANGCHANG_DATA_ROOT}/{USER_ID}/{slug}/,不是 workspace +# SKILL_INPUT_DIR=input +# SKILL_OUTPUT_DIR=output/videos +# SKILL_UPLOAD_DIR=uploads +# SKILL_CACHE_DIR=cache +# SKILL_TEMP_DIR=temp +``` + +| 规则 | 说明 | +|------|------| +| 未配置 | `{skill_data_dir}/{default_subdir}` | +| 相对值 `output/videos` | `{skill_data_dir}/output/videos` | +| 绝对值 `D:\nas\output` | 高级用户/NAS 挂载,原样使用 | +| **禁止** `./outputs/...` | 会随 CWD 漂移,不得作为 `.env.example` 活跃配置行 | + +业务专属路径 env key 须带 **`SKILL_` 前缀**(或技能 slug 前缀),避免污染全局命名空间。 + +--- + +## 5. 代码用法 + +```python +from util.runtime_paths import ( + get_input_dir, + get_output_dir, + resolve_data_path, + resolve_input_path, + list_resolved_data_paths, +) + +# 下行产出(A 类)——视频、报表等 +dest = os.path.join(get_output_dir(), f"{vid}.mp4") + +# 或带 env 覆盖 +out_dir = resolve_data_path("SKILL_OUTPUT_DIR", "output") + +# CLI -i urls.txt(B 类,相对路径 → input/) +manifest = resolve_input_path(args.input_path) + +# health 诊断 +paths = list_resolved_data_paths() +``` + +**反模式(禁止):** + +```python +# ❌ 相对 CWD,Agent 从 workspace 执行时会写错盘 +os.path.abspath(config.get("DOWNLOAD_OUTPUT_DIR")) + +# ❌ .env.example 里写 ./outputs/videos +DOWNLOAD_OUTPUT_DIR=./outputs/videos + +# ❌ 再拆 downloads / imports / exports 三个用户目录 +``` + +--- + +## 6. health / config-path + +`health` 或 `config-path` 建议输出 `list_resolved_data_paths()` 中的路径,便于排查「文件写到哪里去了」。展示路径须与 `run` 实际使用的一致。 + +--- + +## 7. 与 RPA 存证的关系 + +- 失败截图、录屏中间文件:`rpa-artifacts/{batch_id}/`(见 [`RPA.md`](RPA.md) §5.1) +- 录屏成片:`videos/`(见 [`RPA.md`](RPA.md) §5.3) + +业务 `output/` 与 RPA `videos/` **分工不同**:前者是业务产出文件,后者是操作过程录屏。 + +--- + +## 8. 测试 + +- 单元测试须用 `IsolatedDataRoot`,断言路径落在临时数据根下。 +- `tests/test_runtime_paths.py` 覆盖 `resolve_data_path` / `resolve_input_path`。 +- `POLICY-DATA-PATH-001`:禁止 `.env.example` 中 `./` 路径;禁止业务代码 `abspath(config.get(...))`。 + +### 卸载技能与数据保留 + +宿主**卸载技能只删除安装目录**(如 `.openclaw/skills/{slug}`),**不会**删除 `{JIANGCHANG_DATA_ROOT}/{user_id}/{skill_slug}/` 下的 SQLite、输入输出与日志。重装会复用同一用户库;schema 迁移须在 `init_db()` 中幂等完成(见 [`../references/SCHEMA.md`](../references/SCHEMA.md)「表增删改与退役」)。 + +--- + +## 9. 相关文档 + +| 文档 | 内容 | +|------|------| +| [`RUNTIME.md`](RUNTIME.md) | 数据根、共享 runtime、编码 | +| [`CONFIG.md`](CONFIG.md) | `.env` bootstrap 与三层优先级 | +| [`RPA.md`](RPA.md) | rpa-artifacts / videos 存证 | +| [`POLICY_MATRIX.md`](POLICY_MATRIX.md) | POLICY-DATA-PATH-001 | diff --git a/development/DEVELOPMENT.md b/development/DEVELOPMENT.md new file mode 100644 index 0000000..f6bfd09 --- /dev/null +++ b/development/DEVELOPMENT.md @@ -0,0 +1,1046 @@ +# 技能开发教程 + +这份文档是给**技术人员**看的,目标不是解释概念,而是让你拿到**业务技能仓库**后,可以**一步一步开发出一个新的 skill**。 + +本文默认你开发的是当前最常见的一类业务 skill: + +- 有明确的 `scripts/main.py` CLI 入口 +- 可能需要读写本地 SQLite(业务表按需创建,不是默认必有) +- 可能需要调用兄弟技能或外部 HTTP / RPA +- 业务逻辑主要放在 `scripts/service/` + +发布、对账、代发、报表分析等场景都只是业务特例;模板提供通用骨架,复制后按领域补齐实现即可。 + +## 推荐 AI 开发工具 + +建议用 AI 编程工具辅助搭目录、补样板、写测试与排错。团队宜统一 1~2 个主力,避免协作习惯发散。 + +| 场景 | 建议 | +|------|------| +| 一体化 IDE | Cursor 或 Windsurf | +| 终端深度代理 | Claude Code 或 Aider | +| 国内生态 | Trae / 通义灵码 | +| VS Code 插件 | GitHub Copilot 或 Cline | + +官方下载与文档以各产品站点为准;技能开发步骤以本文与同目录规范为准,不依赖某一家工具的专有流程。 + +开发中常用的**公开公共仓**(skill-template / account-manager / jiangchang-platform-kit 等,可 `git clone` 学习,目录可扩展)见 [`SHARED_REPOS.md`](SHARED_REPOS.md)。 + +## 1. 先理解模板的定位 + +`skill-template` 不是业务 skill,它只是一个**新 skill 仓库模板**。 + +开发任何外部系统技能前,**先读 [`AI_RPA.md`](AI_RPA.md)**。本模板的统一定位是:Agent 负责理解、确认与编排,RPA 负责通过正常界面确定性执行;只做授权范围内的正规业务自动化,禁止破解、逆向、私有协议、验证码绕过或其他对抗第三方安全机制的实现。 + +你不应该直接在这个仓库里开发业务。新技能仓库有**两种合法来源**(详见 §4「第一步」): + +| 来源 | 适用情况 | 怎么做 | +|------|----------|--------| +| **A. Gitea 克隆** | 项目经理已在自建 Gitea([https://git.jc2009.com/](https://git.jc2009.com/),**不是** Gitea 官网)开好业务仓并给你权限 | `git clone` 到本地后按 [`REQUIREMENTS.md`](REQUIREMENTS.md) 开发 | +| **B. 本地模板复制** | 你本机已有 `skill-template` 源码,要新建尚未灌仓的技能目录 | **优先** [`tools/scaffold_skill.ps1`](../tools/scaffold_skill.ps1)(见 [`tools/README.md`](../tools/README.md));也可手工复制但必须清掉模板 `.git` | + +拿到仓库后的推荐顺序: + +0. 按 [`NAMING.md`](NAMING.md) 确定 / 核对 slug(`{verb}-{noun-phrase}-{platform}`) +1. **先填写**本仓 [`REQUIREMENTS.md`](REQUIREMENTS.md)(范围与验收),再写业务代码 +2. 来源 B:在新目录内 `git init` 并绑定**本技能**远端(**不得**保留模板 `.git`) +3. 把占位内容替换掉;**尽早**配置 `developer_ids`(见 §6,开发期宿主自测必做) +4. 再实现 `scripts/service/` 等业务逻辑 +5. 本地测试通过 → `release.ps1` → Gitea CI → 匠厂多入口验收(§15) + +> **Git 红线**:禁止资源管理器整文件夹复制后保留模板 `.git`;`git remote -v` 必须指向新技能仓库,不能仍是 skill-template。 + +## 2. 新 skill 的标准目录结构 + +复制模板后,你应该保留下面这套结构: + +```text +your-skill/ +├─ .github/ +├─ assets/ +├─ development/ +├─ evals/ +├─ references/ +├─ scripts/ +│ ├─ cli/ +│ ├─ db/ +│ ├─ service/ +│ └─ util/ +├─ tests/ +├─ .gitignore +├─ README.md +├─ TUTORIAL.md +├─ DEMO.md +├─ CHANGELOG.md +├─ requirements.txt +├─ release.ps1 +└─ SKILL.md +``` + +各目录职责如下: + +- `assets/`:放示例输出、schema、静态说明资源 +- `README.md`:市场 **说明** Tab(`readme_md`) +- `TUTORIAL.md`:市场 **教程** Tab(`tutorial_md`) +- `DEMO.md`:市场 **演示** Tab(`demo_video_url` + `demo_md`) +- `CHANGELOG.md`:市场 **更新日志** Tab(按版本小节同步 `changelog`) +- `SKILL.md`:面向 LLM / 平台的技能入口 +- `references/`:Agent 运行/编排/调用时渐进加载的补充上下文(如 CLI、SCHEMA) +- `development/`:面向开发者与 AI 编程代理的开发资料、需求、测试与技术规范 +- `scripts/`:放真正的代码 +- `tests/`:放自动化测试 +- `evals/`:放人工验收材料、评估清单、示例场景 + +## 3. `scripts/` 内部如何分层 + +`scripts/` 不是乱放 Python 文件,而是按职责分层: + +```text +scripts/ +├─ main.py +├─ cli/ +├─ db/ +├─ service/ +└─ util/ +``` + +每层的职责要明确: + +- `main.py` + 作用:唯一 CLI 入口 + 只做启动、编码兼容、引入 `cli.app` + +- `cli/` + 作用:参数解析、命令分发、用法说明 + 不写具体业务逻辑 + +- `db/` + 作用:SQLite 连接、建表、repository + 不写页面操作和业务编排 + +- `service/` + 作用:核心业务逻辑 + 比如任务编排、调用兄弟技能、外部 API、可选浏览器自动化 + **负责业务关键节点日志**(`task_start` / 外部调用 / `task_failed` 等,见 [`LOGGING.md`](LOGGING.md)) + +- `util/` + 作用:常量、日志、路径、时间工具、通用帮助函数 + `util/logging_config.py` 是 `jiangchang_skill_core.unified_logging` 的**薄封装**,业务代码应通过它获取 logger + +公共能力(config、logging、runtime_env、rpa、media_assets、video_session、runtime_diagnostics、activity/SRCP)从共享 runtime 的 `jiangchang-platform-kit>=1.2.11` import,**不得**在 `scripts/` 下保留 `jiangchang_skill_core/` 副本。 + +## 3.2 开发 RPA 类 skill + +若 skill 需要浏览器/桌面/手机自动化,按以下顺序落地: + +1. **先读总纲与四份标准**:`AI_RPA.md`(职责、合规、风控与完整闭环)、`ACCOUNT_MANAGER.md`(账号 / Profile / 租约 / 多账号 / 查看排障)、`RPA.md`(含 §0.2:Profile 强制 / 有头 / 登录分平台)、`CONFIG.md`(`.env` 落盘与读取)、`ADAPTER.md`(四档 adapter)。 + - 写任何界面操作代码前再读 `RPA_ELEMENT_LOCATING.md`,先完成真实拾取、element map、唯一性和后置状态。 + - 浏览器读 `RPA_BROWSER.md`;Windows 桌面读 `RPA_DESKTOP.md`;Android/iOS 读 `RPA_MOBILE.md`。 +2. **从 examples 选择性复制**(不要整包照搬 `examples/<mode>/`,见各 example README 的 copy map): + - 若是 **真实浏览器 RPA**(真实网站、登录态、验证码、滚动采集),**必须先读** `examples/real_browser_rpa/README.md`,再按需复制到 `scripts/service/`: + - `examples/real_browser_rpa/scripts/service/browser_session.py` + - `examples/real_browser_rpa/scripts/service/human_verification.py` + - `examples/real_browser_rpa/scripts/service/task_rpa.py` + - `examples/real_browser_rpa/scripts/service/account_client.py` + - 若是 **仿真浏览器 RPA**(自有 sandbox、表单批量提交、可控 DOM),**必须先读** `examples/simulator_browser_rpa/README.md`,再按需复制到 `scripts/service/`: + - `examples/simulator_browser_rpa/scripts/service/browser_session.py`(async) + - `examples/simulator_browser_rpa/scripts/service/account_client.py`(subprocess,对齐 `real_browser_rpa`) + - `examples/simulator_browser_rpa/scripts/service/simulator_playwright.py`(RPA 主流程,复制后改名为 `<platform>_playwright.py`) + - `examples/simulator_browser_rpa/scripts/service/adapter/`(薄 `simulator_rpa` + mock) + - `examples/simulator_browser_rpa/scripts/service/task_service.py`(async 编排) + - `sandbox/demo_app.html` 仅留在 examples,不进入生产 skill + - **禁止**:`rpa_helpers`、sync Playwright 用于完整技能 RPA 主路径、`task_service` 散落 account-manager subprocess + - **网页硬规则**:未拿到非空 `profile_dir + lease_token` 禁止开浏览器;一账号一 Profile;生产 `OPENCLAW_BROWSER_HEADLESS=0`;站点是否登录写进 REQUIREMENTS / 用户教程,**不要**写成「可不接 account-manager」 +3. **只用共享库,不在 skill 里重写反反爬**: + ```python + from jiangchang_skill_core import config + from jiangchang_skill_core.rpa import launch_persistent_browser, anti_detect, wait_for_captcha_pass + ``` + 上述 import 来自宿主共享 runtime 安装的 `jiangchang-platform-kit`,不是技能目录副本。 +4. **单测/CI 须 mock 离线可过**;业务 `.env` 默认 `real_rpa`,须真实跑通。**不得只 mock 通就交活**;须 release 到匠厂宿主验收(见 §15,含用户体验)。多账号或矩阵能力还须按 `ACCOUNT_MANAGER.md` 覆盖至少两个账号、租约冲突、关闭重开保持登录和异常释放,不能以单账号一两次成功代替规模验证。 +5. **Windows 桌面**:技术路线已由 `add-friend-wecom` 全链路验证。先读 `examples/real_desktop_rpa/README.md` 与 `development/RPA_DESKTOP.md`(含 Name/Value、最大化激活、同名叶子父容器),复用窗口唯一性、UIA 作用域、probe、dry-run/assisted 和失败留证范式;目标软件 selector 必须重新拾取,禁止照抄企业微信控件名。 +6. **手机**:先按 `RPA_MOBILE.md` 完成设备、context、element map 和验收设计。当前 `examples/mobile_rpa/` 只是完成规格,尚无真实成功案例;不得把骨架宣传为已跑通实现。 + +**技术路线红线:**正常界面或获得授权的官方 API 无法完成闭环时,停止并向负责人说明;禁止 AI 或开发者擅自改用破解、反编译、逆向、私有协议、Hook / 注入、验证码破解、设备指纹伪造、自动换号或无限重试。 + +--- + +### 3.1 `requirements.txt` 依赖规范 + +技能根目录的 `requirements.txt` 是**标准文件**,用于声明本技能**特有** Python 三方依赖。 + +- **公共依赖**(`jiangchang-platform-kit`、`playwright`、config、runtime diagnostics、RPA 公共能力等)由**宿主共享 runtime** 提供,**不要**写入技能 `requirements.txt`。 +- `SKILL.md` 的 `metadata.openclaw.platform_kit_min_version`(当前 `1.2.11`)是运行契约/兼容性声明,供宿主安装与启用时校验,**不是** pip 依赖声明。 +- 匠厂宿主安装/更新技能后,会将技能 `requirements.txt` 安装到共享 venv:`{JIANGCHANG_DATA_ROOT}/python-runtime/.venv`。 +- **不要**在业务代码中 `subprocess` / `pip install`;缺依赖由 `health` 报错,由宿主负责安装。 +- **版本约束尽量收窄**,降低多技能共享 venv 时的冲突风险。推荐范围写法: + - `pkg>=1.2.0,<2` +- 对**原生扩展 / 高风险依赖**(如 `chromadb`、`onnxruntime`)建议 pin 或窄范围,避免无上限: + - 不推荐:`chromadb>=0.5.0` + - 推荐:`chromadb>=0.5.23,<0.6` +- **不要**把系统组件(VC++ Runtime、浏览器安装包等)写进 `requirements.txt`;这类前置条件写在 `health` / preflight 文档与错误提示中。 +- 若技能无额外 Python 依赖,可保留空文件或仅含注释说明;**不要**为 platform-kit 或 playwright 保留占位行。 +- 独立本地开发环境若缺少公共包,可手动安装;**生产/宿主运行**由共享 runtime 负责。 + +### 3.3 公共能力不要复制 + +以下模块由 platform-kit 提供,技能内**不要**重复实现或 vendored 拷贝: + +| 能力 | 导入方式 | +|------|----------| +| config | `from jiangchang_skill_core import config` | +| logging | `from jiangchang_skill_core.unified_logging import ...` 或 `util.logging_config` 薄封装 | +| runtime_env | `from jiangchang_skill_core.runtime_env import ...` 或 `util.runtime_paths` 薄封装 | +| rpa | `from jiangchang_skill_core.rpa import ...` | +| media_assets | `from jiangchang_skill_core.media_assets import ...` | +| video_session | `from jiangchang_skill_core.rpa.video_session import RpaVideoSession` | +| runtime_diagnostics | `from jiangchang_skill_core import collect_runtime_diagnostics, format_runtime_health_lines` | + +`health` 应委托 `collect_runtime_diagnostics`,不要在 `scripts/util/` 自建 `runtime_diagnostics.py`。 + +### 3.4 发布打包约束(PyArmor 与编码) + +release workflow 会对 `scripts/` 下的 Python 源码做加密/打包。当前发布链路可能运行在 **PyArmor 免费/试用模式**,因此有两条**发布硬约束**: + +**单文件行数(仅 `scripts/**/*.py`)** + +- 每个 `scripts/` 下 Python 文件必须 **小于 1000 行**(`>= 1000` 可能在 CI 加密阶段失败)。 +- 推荐单文件控制在 **900 行以内**;接近上限时应拆分模块。 +- 不要把完整 RPA 主流程、DOM 选择器、数据库、CLI、日志全部堆进一个大文件。 +- 正确做法是拆分到 `scripts/cli/`、`scripts/service/`、`scripts/db/`、`scripts/util/`。 + +`examples/` 里的 Python 示例也建议保持模块化,但**行数限制不作为 examples 的发布硬约束**;不要对 README、HTML、测试数据等文档/资源套用 PyArmor 行数规则。 + +**文本编码(UTF-8 without BOM)** + +- 所有提交到 skill 的源码、文档、配置文本文件必须是 **UTF-8 without BOM**。 +- Python 文件**绝对不能**带 UTF-8 BOM;否则 PyArmor/CI 可能报:`invalid non-printable character U+FEFF`。 +- Windows 编辑器容易保存成 UTF-8 BOM,提交前须确认文件开头无 `U+FEFF`。 +- 不要只写「UTF-8」,必须明确 **UTF-8 without BOM**。 + +本地可用 `python tests/run_tests.py release_packaging_constraints -v` 自检;默认必跑套件中的 `test_release_packaging_constraints.py` 会守护上述规则。 + +## 4. 开发一个新 skill 的标准步骤 + +下面这套顺序建议严格按步骤做,不要一上来就直接写 `service`。 + +### 第一步:拿到新技能仓库(两种来源) + +例如你要开发 `disburse-payroll-icbc` 一类领域 skill(目录名 = slug)。下列两种来源**同等合法**,按你实际拿到仓库的方式选一条。 + +#### 来源 A:从 Gitea 克隆(项目经理已开仓) + +1. 确认项目经理已在自建 Gitea([https://git.jc2009.com/](https://git.jc2009.com/),公司基于开源 Gitea 自建的代码仓库,**不要**访问 Gitea 官网当作业务仓地址)创建**本技能**仓库,并给你拉取/推送权限。 +2. 克隆到本地(示例): + +```powershell +git clone https://git.jc2009.com/<org>/<your-skill-slug>.git +cd <your-skill-slug> +git remote -v # 确认 origin 指向本技能仓,不是 skill-template +``` + +3. 若仓内已是从本模板 scaffold 好的结构,直接进入「第二步」与 [`REQUIREMENTS.md`](REQUIREMENTS.md);**不要**再整仓复制 `skill-template` 覆盖,以免冲掉已有提交或串 Git 历史。 + +#### 来源 B:本地已有 skill-template 时复制 / scaffold + +在 **skill-template 仓库根目录**执行(推荐): + +```powershell +.\tools\scaffold_skill.ps1 -Slug disburse-payroll-icbc -Destination D:\OpenClaw\client-gdcm\disburse-payroll-icbc +cd D:\OpenClaw\client-gdcm\disburse-payroll-icbc +git init +git remote add origin <你的新技能 Gitea/Git 仓库 URL> +git remote -v # 确认 origin 不是 skill-template +``` + +跨平台可用 `python tools/scaffold_skill.py --slug ... --destination ...`(见 [`tools/README.md`](../tools/README.md))。 + +脚手架会排除 `.git`、缓存与 `.env`,并删除 `.openclaw-skill-template` 标记;**不会**自动 `git init`。 + +##### 禁止方式(来源 B) + +| 做法 | 后果 | +|------|------| +| ❌ 资源管理器整文件夹复制后不做 Git 清理 | 隐藏 `.git` 被带走,push 串到 template | +| ❌ 保留模板 `.git` 只改 `remote url` | 历史、分支、对象库仍属 template | +| ❌ 未删 `.git` 就 `git init` | 嵌套/混乱仓库,难以排查 | + +##### 若已手工复制(补救) + +```powershell +cd <新技能目录> +Remove-Item -Recurse -Force .git +Remove-Item -Recurse -Force .pytest_cache -ErrorAction SilentlyContinue +Remove-Item -Force .openclaw-skill-template -ErrorAction SilentlyContinue +git init +git remote add origin <新技能仓库 URL> +git remote -v +``` + +##### AI / 编程代理复制红线 + +| 禁止 | 说明 | +|------|------| +| 保留模板 `.git` | 必须先 `Remove-Item -Recurse -Force .git` 再 `git init` | +| `origin` 仍指向 skill-template | 复制后必须 `git remote -v` 自检 | +| 保留 `.openclaw-skill-template` | 仅 template 源仓库可有;业务技能不得保留 | +| 复制 `.pytest_cache` / `__pycache__` | 用 scaffold 已排除;手工复制应删 | + +目录名要和 skill slug 对齐,后面很多地方都依赖这个命名。 + +### 第二步:先改关键标识与占位 + +拿到仓库并填好 / 更新 [`REQUIREMENTS.md`](REQUIREMENTS.md) 后,优先改下面这些地方: + +1. `SKILL.md` +2. 根目录市场四 Tab:`README.md` / `TUTORIAL.md` / `DEMO.md` / `CHANGELOG.md` +3. `scripts/util/constants.py` +4. `references/` 与 `development/` 下的文案 +5. `scripts/service/` 下的业务占位实现(优先改 `task_service.py`) + +最先要统一的是: + +- 技能中文名 +- `slug` +- 平台中文名 +- 平台内部键 +- 日志 logger 名 + +**开发 / 联调阶段务必尽早**配置 `SKILL.md` 中的 `metadata.openclaw.developer_ids`(完整目的与取 ID 步骤见 §6「关于 developer_ids」)。开发期技能在匠厂常为不公开;不加本人用户 ID,**技术人员自己也无法在技能市场看到并安装自测**。 + +## 5. 哪些占位内容必须替换 + +复制后,至少要全局检查并替换下面这些内容: + +- `your-skill-slug` +- `your-platform-key`(若 skill 涉及外部平台对接,可能会用到这类占位) +- `技能开发模板(通用业务版)` +- `你的平台名` +- `openclaw.skill.your_skill_slug` + +如果你的 skill 对外 CLI 需要自定义文案或别名(例如发布类对外仍叫 `publish`),通常还要替换: + +- `run` 命令中的中文提示与别名策略(见 `../references/CLI.md`) +- `../references/CLI.md` 的命令示例 +- `../README.md` 的用户话术 +- `../references/SCHEMA.md` 的字段映射补充说明 + +如需浏览器自动化,不要在模板里保留空的 `platform_playwright.py`;请按业务新建 `xxx_playwright.py`(命名自定),并在 `task_service.py` 中按需引用。 + +## 6. `SKILL.md` 与市场四 Tab 材料应该怎么写 + +### `SKILL.md`(LLM / 平台入口) + +`SKILL.md` 是给 LLM 与 OpenClaw 平台看的技能入口,**不是**用户市场详情页。 + +应该重点写: + +- YAML frontmatter:`name`、`description`、`version`、`metadata.openclaw`(含 `slug`、`platform_kit_min_version`、`developer_ids` 等) +- 何时触发本技能、CLI 入口摘要、运行契约要点 +- 指向 `references/`(Agent 调用)与 `development/`(开发规范)的分工说明 + +不要在 `SKILL.md` 里写大量用户市场文案或实现细节;**不要**把演示视频 URL 写进 `SKILL.md`(放 `DEMO.md` frontmatter)。 + +### 市场四 Tab(根目录,面向普通用户) + +发布 CI 会把下列文件同步到技能市场详情 Tabs(有文件才更新对应字段): + +| 文件 | 市场 Tab | 同步字段 | +|------|----------|----------| +| `README.md` | 说明 | `readme_md`(frontmatter `description` 作列表简介) | +| `TUTORIAL.md` | 教程 | `tutorial_md` | +| `DEMO.md` | 演示 | `demo_md` + frontmatter `demo_video_url` | +| `CHANGELOG.md` | 更新日志 | 匹配当前 tag 版本的 `##` 小节 → `changelog` | + +**`README.md`(说明 Tab)**:能力、场景、前置条件、结果形态、注意事项、概念类 FAQ。含 frontmatter `description`(**不要**写「如何开始使用」)。**禁止**分步操作、Agent 话术块、「入口一/二」专章、逐步排障——这些属教程。**禁止**文首再写 `# 技能名`(页头已有);正文主分节用 `##`。完整契约见 [`TUTORIAL_SURFACES.md`](TUTORIAL_SURFACES.md) §0.1 / §1。 + +**`TUTORIAL.md`(教程 Tab)**:按 [`TUTORIAL_SURFACES.md`](TUTORIAL_SURFACES.md) 写三入口结构(数据管理 ∥ 对话;定时有则教、无则 N/A)+ 主能力对照表 + 常见卡住。步骤必须与 `REQUIREMENTS` §0.1、`actions.json` 及宿主真实 UI 对齐;**禁止**把仅 `skill-detail` 的 Action 写成用户主路径。**禁止**文首 `# 技能名 · 使用教程`;正文最高用 `##`。不要写 CLI / 目录 / 开发规范。 + +**`DEMO.md`**:演示视频与补充说明。frontmatter 必须含 `demo_video_url`(无视频时用 `""`);正文可选。 + +**`CHANGELOG.md`**:每个正式版本一节(如 `## 1.0.46`)。打 tag 前为即将发布的版本新增小节;用户可读的变化,少写内部实现。 + +实现细节放在: + +- `development/` +- `references/`(CLI / SCHEMA) +- 代码注释与 `service/` 实现 + +### 关于 `metadata.openclaw.developer_ids`(开发自测必做) + +#### 目的(先理解再填) + +开发 / 测试阶段,技能 `release` 到匠厂后,平台侧**默认不公开**(`access_scope = 0`): + +- 技能市场里对普通人**不可见** +- **若不额外授权,连开发该技能的技术人员自己也看不见、装不了** +- 看不见 → 无法安装 → 无法在宿主做 §15 的多入口验收(新建任务、数据管理、定时任务等) + +因此 `developer_ids` 不是可有可无的装饰字段,而是**开发期自测通行证**:把本人(及需要一起验收的同事)的匠厂**用户 ID** 写进 `SKILL.md`,发布时平台将这些用户补进可见 / 可访问范围,使开发者能在「对其他人仍不可见」的前提下完成安装与测试。 + +#### 如何获取匠厂用户 ID(完整步骤) + +1. **下载并安装**匠厂客户端:[https://jc2009.com/product.html](https://jc2009.com/product.html) +2. **注册并登录**(使用将用于开发自测的账号) +3. 打开客户端左下角头像旁的**设置**(齿轮) +4. 在 **用户信息** 中查看 **用户 ID**(正整数),点击旁边的 **复制** +5. 将该 ID 写入本技能 `SKILL.md` 的 `metadata.openclaw.developer_ids`(见下例) +6. **必须替换**模板里的示例 ID(如 `10032`、`12428` 等占位),不要原样留着模板作者的 ID 却指望自己账号能看见技能 + +#### 在 `SKILL.md` 中声明 + +```yaml +metadata: + openclaw: + slug: your-skill-slug + category: 通用 + developer_ids: + - 12345 # 换成「你的用户ID」(匠厂「设置 → 用户信息」复制的正整数) +``` + +约定如下(原有规则保留): + +- 只允许填写正整数用户 ID(来自匠厂宿主,不是 Gitea / Git 账号名) +- 推荐使用数组,即使当前只有 1 个开发者 +- 发布时平台会把这些用户自动补写到 `skill_user_access` +- 第一个 ID 会同步到 `skills.developer_id` +- 一期只做“补授权”,不会因为你 later 修改数组而自动撤销旧授权 +- **首次正式 release 前**就应配好;配错或漏配时,CI 可能成功,但你在技能市场仍找不到技能 + +## 7. 文档目录分工 + +当前规范 skill 的文档按读者拆分: + +**根市场材料(四 Tab)** + +- `README.md` — 说明(含 frontmatter `description`) +- `TUTORIAL.md` — 使用教程 +- `DEMO.md` — 演示(`demo_video_url` + 正文) +- `CHANGELOG.md` — 按版本更新日志 + +**`SKILL.md`** + +- LLM / OpenClaw 平台技能入口 + +**`references/`**(Agent 运行/编排/调用时渐进加载) + +- `README.md` — Agent 参考索引(**不是**市场说明;不得含 YAML frontmatter 或 `description`) +- `CLI.md` — 命令、参数、默认值、兄弟技能调用方式 +- `SCHEMA.md` — 数据库路径、`_jiangchang_*` 数据管理契约、日志表结构 +- `ACTIONS.md` — Skill Action manifest、宿主入口、参数与执行契约 +- 可按需增加 `ERRORS.md`、`INTEGRATION.md` 等编排向补充 + +**`development/`**(开发者 / AI 编程代理) + +- `REQUIREMENTS.md` — 需求与验收 +- `DEVELOPMENT.md` — 开发教程(本文档) +- `TESTING.md` — 测试分层与隔离(详见 [`TESTING.md`](TESTING.md)) +- `LOGGING.md` — 日志分层、必打节点、敏感信息红线 +- `ADAPTER.md`、`RPA.md`、`CONFIG.md`、`RUNTIME.md` — 技术规范 + +## 8. `assets/` 应该放什么 + +`assets/` 不放业务代码,只放辅助材料。 + +建议放: + +- `actions.json` + 宿主 Action 入口 manifest(可选;见 `references/ACTIONS.md`) + +- `examples/` + 比如 `version` 输出示例 + +- `schemas/` + 比如 `skill-actions.schema.json`、机读 JSON 的 schema + +不要把正式研发文档放到 `assets/`。 +用户市场四 Tab 进根 `README.md` / `TUTORIAL.md` / `DEMO.md` / `CHANGELOG.md`;Agent 编排资料进 `references/`;开发规范进 `development/`。 + +## 9. `cli` 层怎么写 + +建议保持一个原则: + +- `cli` 只负责解析参数和路由 +- `service` 才负责干活 + +也就是说,`cli/app.py` 的职责是: + +1. 打印帮助 +2. 定义 `run / health / version / init-db`(及业务子命令) +3. 把参数转交给 `service.task_service`(或薄适配后再进 domain service) + +不要在 `cli/app.py` 里直接写: + +- 浏览器自动化 +- 子进程调用兄弟技能 +- SQLite 逻辑 +- 与 toolbar / cron / agent 来源相关的业务分叉 + +## 10. `service` 层怎么写 + +`service` 是核心层。推荐拆分(**单业务内核、多入口**): + +```text +cli/app.py → 只解析参数 +service/task_service.py → job_context / emit / checkpoint / finish / 错误转换 +service/<domain>_service.py → 唯一业务内核(同步、处理、统计等) +db/*_repository.py → 事务与读写 +assets/actions.json → placements / bind / executionProfile / entrypoint +``` + +通常还可以有: + +- `sibling_bridge.py` + 放通用兄弟技能子进程工具(`call_sibling_json`);**具体调用哪个 slug 由业务在 `task_service.py` 或 domain service 决定**,不要在本文件硬编码发布类专用 helper + +- `xxx_playwright.py`(按需新建) + 浏览器后台自动化 **不在模板默认结构中**;若确有需要,按命名约定自建模块并由 domain / task_service 引用 + +- `entitlement_service.py` + 放鉴权逻辑 + +禁止:toolbar / cron / Agent / CLI 各自复制一套采集或同步实现;为兼容旧命令再写第二套内核。 + +### 日志要求(`cmd_run` / 核心业务) + +`service` 层是**关键业务日志**的落点,须遵循 [`LOGGING.md`](LOGGING.md): + +- `cmd_run` 及主任务入口必须:`log.info("task_start ...")`;失败路径必须 `log.exception("task_failed ...")`。 +- 外部 API、兄弟技能、RPA 操作须记录**开始 / 结束 / 耗时 / 状态**(`external_call_start` / `external_call_done elapsed_ms=...`)。 +- 长任务 / RPA 须配合 `activity.emit` 或 `RpaVideoSession.add_step` 输出用户可见进度;`cmd_run` 须 `job_context` + 各出口 `finish`(见 [`LOGGING.md`](LOGGING.md) §2.5)。 +- 使用 `from util.logging_config import get_skill_logger`,**不要**用 `print` 替代 logger 做排障日志。 +- **禁止**在日志中写入 password、token、cookie 等敏感明文(见 `LOGGING.md` §敏感信息红线)。 + +### 一个很重要的原则 + +不要把所有逻辑都堆进一个文件。 + +推荐流向是: + +`cli.app` -> `service.task_service` -> `service.<domain>_service` -> `db` + +(旁路:`sibling_bridge` / 可选 `xxx_playwright` 仅由 service 调用) + +## 11. `db` 层怎么写 + +如果你的 skill 需要记录日志或本地状态,建议: + +- `db/connection.py` + 只做连接和建表(模板默认只建 `_jiangchang_*` 元数据表;业务表按需 CREATE) + **业务表之间禁止 SQLite `FOREIGN KEY` / `REFERENCES`**:只存业务 id,存在性 / 级联 / 删除在 repository/service 处理(见 [`references/SCHEMA.md`](../references/SCHEMA.md)「表与表之间:禁止 SQLite 外键」) + +- `db/<domain>_repository.py` + 只做业务表增删查改(有业务表才建;模板不再默认提供任务审计表) +- `db/display_metadata.py` + 创建 `_jiangchang_tables` / `_jiangchang_columns` 并幂等写入中文展示名(见 `references/SCHEMA.md`) +- `db/display_metadata_validator.py` + 元数据完整性、editable 权限、options_json 等自检 +- `db/timestamp_columns.py` + 标准时间字段 trigger 与维护约定(`created_at` / `updated_at`,Unix 秒级) + +不要在 `db` 层里: + +- 调浏览器 +- 打印用户提示 +- 拼接业务流程 + +## 11.5 Skill Action manifest(按入口定位挂载) + +先读 [`PRODUCT_SURFACES.md`](PRODUCT_SURFACES.md):对话 / 数据管理 / 定时任务**平级**;哪个主能力出现在哪个入口,由本技能 UX 在 `REQUIREMENTS` 入口定位表中定义。 + +若定位表要求出现在宿主**数据管理按钮**、**技能详情**、**定时任务**或 **Agent 直接调用**中,提供 `assets/actions.json` 并写入对应 `placements`。 + +- **产品入口与何时走哪个**见 [`PRODUCT_SURFACES.md`](PRODUCT_SURFACES.md) +- **运行时金标准**见 [`SKILL_ACTION_RUNTIME.md`](SKILL_ACTION_RUNTIME.md) +- 字段契约见 [`references/ACTIONS.md`](../references/ACTIONS.md)(`placements` / `bind.tables` / `executionProfile` 正交) +- JSON Schema 见 [`assets/schemas/skill-actions.schema.json`](../assets/schemas/skill-actions.schema.json) +- 模板最小示例仅暴露 `health` / `version` / `config-path`(均显式 `executionProfile: "sync"`;**不**自动加 toolbar);CLI 另含 `init-db` 供宿主静默建库(不必做成 Action) +- 含 `toolbar` 的 Action:**必须**声明合法 `bind.tables`(`POLICY-SKILL-ACTION-003`) +- 含浏览器 RPA / 长耗时的业务技能:**必须**增加至少一条 `"executionProfile": "async"` 且 `placements` 含 `agent` 的 action(`POLICY-SKILL-ACTION-001`) +- 入口定位表确认无任何宿主结构化入口需求时,**可以删除** `actions.json`;删除后勿保留失效引用 +- scaffold **不会**移除 Schema 中的 `bind` 支持,**不会**生成平台业务 Action 或业务表名;仅替换占位 slug + +宿主执行方式: + +```text +python {skillRoot}/scripts/main.py <command> <args...> +``` + +CLI 必须提供稳定退出码、结构化成功输出,以及 `ERROR:<CODE>:` / `HINT:` 错误契约(详见 `references/ACTIONS.md`)。 + +### 编排范式:单内核 + 多入口 + +长任务 / RPA / 数据同步技能推荐拆分(勿把 `job_context` 塞进纯业务内核): + +```text +<domain>_service.sync_*(...) # 唯一业务内核:读外源 → 写本地库(或核心处理) +cmd_sync_* (...) # 编排:job_context + 调用内核 + emit/finish(async 时) +CLI / Action / cron / Agent # 全部进入同一 cmd_* → 同一内核 +``` + +RPA 批量亦可: + +```text +execute_single_<biz>(...) # 鉴权 + 业务 + task_log;无 job_context +cmd_run(...) # 单条:job_context + execute_single + finish +cmd_run_pick(N, ...) # 批量顺序:job_context + for-loop + emit(progress) + finish +cmd_stats() # 只读 sync:JSON,无 job_context +``` + +- 批量数量用 **`pickCount` / `--pick N` 参数**(任意正整数),不写死条数。 +- **顺序执行、不并发**同一浏览器 Profile / account lease。 +- **同步数据**与**宿主表导出**职责分离:同步不写 CSV/Excel;表导出交给数据管理。 +- **HTML 页面报告**(可选):需要侧栏预览整页时,写入 `html/` + Action `render-html`;契约见 [`../references/ACTIONS.md`](../references/ACTIONS.md)「HTML 页面报告」(含**宿主预览布局**底线)、[`DATA_PATHS.md`](DATA_PATHS.md);薄写入 helper 见 `scripts/service/html_page_service.py`。建议从 `assets/html/example-report.html` 复制壳;`{{BODY}}` 内数据须转义;样式升级后提醒用户重新生成。 +- 详情见 [`SKILL_ACTION_RUNTIME.md`](SKILL_ACTION_RUNTIME.md);契约样例见 `tests/samples/test_sync_contract.py.sample`、`tests/samples/test_action_core_contract.py.sample`。 + +### 队列 + pick(可选) + +有「待处理列表」时:队列表 `pending → processing → success/failed`;数据管理录入或 `import-*` 入库;`run --pick N` 消费;失败用 `reset` 回 `pending`(不自动重试)。Manifest 上配置 `*-run-pick`(常见为 async + toolbar/cron/agent;有 toolbar 则须 `bind.tables`)。 + +## 12. 如何接兄弟技能 + +如果 skill 要依赖兄弟 skill,不要在业务代码里写死绝对路径。 + +统一通过: + +- `scripts/util/runtime_paths.py` +- `scripts/service/sibling_bridge.py` + +来做调用。 + +调用哪些兄弟技能由具体业务决定;请在 `task_service.py` 中使用 `service.sibling_bridge.call_sibling_json(skill_slug, args)` 或 `get_sibling_main_path(skill_slug)`,不要在本模板仓库的 `sibling_bridge.py` 中堆积特定技能函数。 + +调用原则: + +1. 通过 `get_skills_root()` 找到兄弟技能根目录 +2. 再拼出对应 `scripts/main.py` +3. 用子进程调用 +4. 机器可读输出优先 JSON + +## 13. 如何开发一个新 skill + +不管你开发的是发布类、采集类、分析类还是知识库类 skill,建议都先按下面这个顺序推进: + +1. 先把目录结构搭完整 +2. 先让 `health` / `version` 跑通 +3. 再把核心 `service` 骨架跑通 +4. 再接兄弟技能桥接、数据库或外部系统 +5. 最后再补浏览器自动化、复杂流程编排或高风险集成 + +不要一开始就直接写页面选择器、复杂接口编排或深层业务逻辑。 + +推荐先确保这些基础能力正常: + +- CLI 入口能跑通 +- 基础命令输出稳定 +- 关键依赖能取到 +- 日志或本地状态能落下来 +- 错误返回值格式定好了 + +如果你的 skill 属于外联型 / RPA 型,可以把上面的“核心 `service`”具体落成 `task_service.py`,再按需接 `sibling_bridge.py`、(可选)`*_playwright.py`。这只是示例路径组合,不代表模板绑定某一业务领域。 + +## 14. 本地开发的最小验证顺序 + +建议每次新 skill 开发时按下面顺序验证: + +### 1. 验证入口 + +本地验证请优先使用宿主共享 Python(与 Agent CLI 相同契约),例如: + +```bash +python scripts/main.py health +python scripts/main.py version +``` + +**禁止**对技能脚本使用 `uv run python`。 + +```bash +python scripts/main.py -h +python scripts/main.py <your-command> -h +``` + +### 2. 验证 CLI 路由 + +```bash +python scripts/main.py -h +python scripts/main.py <your-command> -h +``` + +### 3. 验证本地日志与数据库 + +如果你的 skill 需要本地日志或数据库,再继续: + +```bash +python scripts/main.py <your-read-command> +``` + +执行一次 `run`(或你的主任务命令)后,应确认**统一日志文件**已生成并可检索: + +```text +{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/logs/jiangchang.log +``` + +其中应能看到 `cli_start`、`task_start` 等带 `trace_id` / `skill_slug` 的行。路径与分层说明见 [`LOGGING.md`](LOGGING.md)。 + +### 4. 最后再验证真实业务 + +比如: + +```bash +python scripts/main.py <your-command> +``` + +## 14.5 测试驱动的开发顺序 + +新 skill 不应该等所有业务做完才补测试。建议在每个开发阶段都先把对应测试跑一遍: + +1. **目录搭建后**:跑 `python tests/run_tests.py -v`,确认全部默认测试套件通过。 + - 此时 `test_skill_metadata.py` 会校验 `SKILL.md` slug 与 `constants.SKILL_SLUG` 一致;如果你只改了一边,会立刻发现。 +2. **改完 constants 后**:再跑一次必跑套件,确认未引入回归。 +3. **写完 service 业务后**:从 `tests/samples/test_service_contract.py.sample` 复制一份做契约测试。 +4. **接外部系统时**:写在 `tests/integration/`,并配合 `OPENCLAW_TEST_TARGET` 显式开启。 + +详见 `TESTING.md`。 + +## 15. 发布到正式环境验证 + +### release workflow 通道 + +`.github/workflows/release_skill.yaml` 默认引用: + +```yaml +uses: client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-release-skill.yaml@main +``` + +约定如下: + +- `jiangchang-platform-kit` 的 `main` 分支即稳定通道;kit 的开发、测试、验证须在合并 `main` 前完成。 +- skill **不需要**锁定 workflow 版本(不要改为 `@v1`、`@v1.0.x` 等)。 +- skill 运行时最低能力要求通过 `SKILL.md` 的 `metadata.openclaw.platform_kit_min_version` 声明。 + +在执行 `release.ps1` 之前,必须先在本地确认 `python tests/run_tests.py -v` 通过。测试不通过不得发起正式发布。 + +**POLICY-RELEASE-001(硬门禁)**:发布前必须将 `SKILL.md` 的 `version:`、`scripts/util/constants.py` 的 `SKILL_VERSION`、以及 `CHANGELOG.md` 对应小节,调整为**即将打出的**版本(与 `.\release.ps1 -DryRun` 打印的 `Next tag` 去掉 `v` 前缀一致)。`release.ps1` **不会**自动改这些字段;若三者与 Next tag 不一致,脚本在跑测试/打 tag **之前**直接失败,避免市场「最新版本」(git tag) 与安装包「当前版本」(本地 `SKILL.md`) 长期错位、技能市场一直显示「更新」。 + +建议流程: + +1. `.\release.ps1 -DryRun` 查看 `Next tag`(例如 `v1.0.104`) +2. 将 `SKILL.md` / `SKILL_VERSION` / `CHANGELOG` 改为 `1.0.104` +3. 再执行正式 `.\release.ps1`(可用 `-AutoCommit`) + +`release.ps1` 会在 **auto commit 之前**、推送 tag 之前,对**当前工作区**自动执行默认测试(`python tests/run_tests.py -v`);失败则中止 release,不会留下失败状态的自动提交。仅紧急排障可使用 `-SkipTests` 跳过(会打印黄色警告)。`-DryRun` 预览发布时**仍会实际运行**版本对齐门禁与默认测试,便于在不打 tag 的情况下验证。 + +新增或调整开发规范时,须同步 [`development/POLICY_MATRIX.md`](POLICY_MATRIX.md) 与 [`tests/test_development_policy_guard.py`](../tests/test_development_policy_guard.py)(或注明由既有测试覆盖)。 + +当本地开发、自测和联调完成后,还需要把 skill 发布到正式环境做一次完整验证。建议技术人员严格按下面顺序执行,不要跳步。 + +### 第一步:在 skill 根目录执行 `release.ps1` + +在当前 skill 仓库根目录执行: + +```powershell +.\release.ps1 +``` + +发布前 **`developer_ids` 应已配置**(见 §6)。本步触发的发布工作流除了同步 `skills` / `skill_versions` 外,还会在平台侧自动补开发者可见权限;发布后应重点确认:用本人账号能在技能市场看到并安装该技能。 + +这一步会自动完成标准发布动作,包括: + +1. 检查当前仓库状态 +2. 自动提交尚未提交的改动 +3. 推送最新代码到远程仓库 +4. 自动创建新的语义化版本 tag +5. 推送 tag,触发后续 CI 流程 + +如果你只是想先预览发布动作,可以先执行: + +```powershell +.\release.ps1 -DryRun +``` + +### 第二步:到 Gitea 仓库查看工作流 + +发布命令执行完成后,远程仓库会自动触发 Gitea 工作流。此时应立即进入对应 skill 的仓库页面,切换到“工作流”页签查看执行状态。 + +重点确认: + +- 是否已经出现最新一次发布记录 +- 是否由最新提交触发 +- 是否成功执行完 `release_skill.yaml` + +下面这张图演示了在仓库页进入“工作流”的位置: + +![Gitea 仓库工作流入口](../assets/screenshots/gitea-workflow-tab.png) + +下面这张图演示了工作流成功后的状态页面: + +![Gitea 工作流成功示例](../assets/screenshots/gitea-workflow-success.png) + +### 第三步:确认工作流执行成功 + +只有当工作流状态为成功,才说明正式发布产物已经正确构建完成。 + +如果工作流失败,不要继续做平台验证,而应该先回到代码仓库排查,例如: + +- 发布脚本是否正常推送 +- `SKILL.md`、根市场四 Tab(`README.md` / `TUTORIAL.md` / `DEMO.md` / `CHANGELOG.md`)、`scripts/`、`references/`、`development/` 是否齐全 +- 工作流文件是否存在 +- 发布包结构是否符合模板规范 + +### 第四步:进入匠厂客户端(用于安装验收) + +当工作流成功后,就可以进入匠厂客户端验证最终安装效果。 + +若你已按 §6 为 `developer_ids` 安装并登录过匠厂,**直接使用同一客户端、同一账号**即可,无需重复下载。若尚未安装: + +匠厂产品下载地址: + +- [https://jc2009.com/product.html](https://jc2009.com/product.html) + +打开页面后,下载并安装匠厂客户端。下面这张图演示了下载入口位置: + +![匠厂客户端下载入口](../assets/screenshots/jc2009-download-page.png) + +匠厂产品页可从这里进入:[产品下载 - 匠厂](https://jc2009.com/product.html) + +> 取用户 ID 写入 `developer_ids` 的完整步骤见 §6;此处侧重发布后的安装验收。 + +### 第五步:在技能市场检查最新 skill + +使用已写入 `developer_ids` 的账号登录并启动匠厂后,进入左侧“技能市场”,搜索或查找刚刚发布的 skill,确认以下内容: + +- 技能可以被正常检索到(开发期不公开时,**仅** `developer_ids` 内账号可见) +- 技能名称、说明、版本信息正确 +- 最新版本已经同步出来 +- 可以正常安装或更新 + +下面这张图演示了在技能市场中查看已发布 skill 的位置: + +![技能市场中的已发布技能](../assets/screenshots/market-installed-skill.png) + +### 第六步:安装并实际启用该 skill + +在技能市场中找到目标 skill 后,执行安装或更新操作,确保客户端本地已经拿到最新版本。 + +这一步建议至少确认: + +- 安装按钮可以正常执行 +- 安装后状态正常 +- 不会出现缺文件、缺入口或安装失败的问题 + +### 第七步:按入口定位与声明做宿主多入口验收(须看用户体验) + +安装完成后,不要只停留在“已安装”状态,也**不要**只在本地 mock 通就认为完成。对话任务 / 数据管理 / 定时任务在宿主上**平级**(见 [`PRODUCT_SURFACES.md`](PRODUCT_SURFACES.md))。先核对本技能 `REQUIREMENTS` **入口定位表**,再按 `assets/actions.json` 的 `placements` / `executionProfile` 对「定位为挂」的入口逐项自测(字段契约见 [`SKILL_ACTION_RUNTIME.md`](SKILL_ACTION_RUNTIME.md)、[`../references/ACTIONS.md`](../references/ACTIONS.md))。 + +- 定位为「挂」却未声明或测不到 → **验收失败** +- 定位为「不挂」且写了理由 → 可跳过该入口 +- **禁止**从未做入口定位、却用「未声明所以跳过」交差 +- 不要只测对话、忽略定位表中已挂的其它入口 + +**用户体验(与功能入口并列,后台通了不算完):** + +- 失败时用户能否看懂原因(对话 / 任务中心 / 提示),不是只有技术日志 +- 长任务(async)是否有可见进度,能否取消或等待说明 +- 网页 RPA 是否有头运行;需登录/验证码时是否停下等人,而不是静默失败 +- 数据管理:表名/字段中文、按钮文案是否清楚;结果是否写回用户看得见的地方 +- 市场说明 / 教程是否与真实行为一致;**说明 Tab 不含分步操作**,教程覆盖已挂入口(见 [`TUTORIAL_SURFACES.md`](TUTORIAL_SURFACES.md)) + +| 宿主入口(侧栏 / 界面) | 技能侧如何挂上 | 建议验收什么 | +|-------------------------|----------------|--------------| +| **新建任务**(对话 Agent) | `placements` 含 `agent`;或短查询走共享 Python CLI | 自然语言能触发主流程;长任务 / RPA 须走 `run_skill_action`,禁止 bash 干等 | +| **数据管理 · 表顶栏** | `placements` 含 `toolbar`(须合法非空 `bind.tables`) | 左侧能看到本技能库表;表顶栏出现按钮;点按行为符合预期 | +| **数据管理 · HTML 报告** | 写出 `html/*.html`;提供 Action id `render-html` | 侧栏出现报告项;预览正常;右键「重新生成」成功且时间/内容更新 | +| **数据管理 · 行级** | `placements` 含 `row`(建议 `bind.tables` + `bind.inputMapping` 预填主键) | 操作列和/或行右键(及可选「⋯」)出现本技能行级 Action(需有主键列);点按后入参/行为与壳无关、均正确 | +| **数据管理 · 批量** | `placements` 含 `batch` | 勾选行后批量按钮可用;无勾选时禁用;批量行为正确 | +| **定时任务** | `placements` 含 `cron`(创建时选「技能直调」) | 能选到本技能 Action、保存并触发;到点或「立即运行」行为正确 | +| **任务中心** | 不是 placement;由 `executionProfile: "async"` 决定是否进 Job | 长任务出现进度 / 可取消;来源标签与触发入口一致 | +| **技能市场 → 技能详情** | `placements` 含 `skill-detail`(详情直调) | **必验**安装与四 Tab。详情页「技能直调」按钮随宿主版本上线;**已声明则须在具备该 UI 的宿主上完成按钮自测**(与其它入口同等对待,不要只靠 Agent) | + +补充说明: + +- **进不进任务中心只看 `executionProfile`**,与从哪个入口触发无关(既有正交规则不变)。 +- 数据管理还依赖库表元数据(`_jiangchang_*` 等,见 [`../references/SCHEMA.md`](../references/SCHEMA.md));仅有 Action、没有可展示库表时,侧栏可能看不到表。 +- `row` / `batch` / `toolbar` / `cron` / `agent` / `skill-detail` 均以**当前匠厂宿主已实现能力**为准;字段与示例见 [`../references/ACTIONS.md`](../references/ACTIONS.md)。 + +**新建任务(对话)最小检查:** + +- 新任务中可以正常选择或触发该 skill +- skill 能被正确唤起 +- 主要命令或主流程可以运行 +- 返回结果、日志和行为符合预期 + +下面这张图演示了安装后在任务界面中实际使用 skill 的场景: + +![新建任务中使用技能](../assets/screenshots/new-task-usage.png) + +若第五步在技能市场**搜不到**本技能:先核对登录账号的用户 ID 是否已写入 `developer_ids` 并随本次 release 发布(见 §6);不要只反复重装客户端。 + +## 16. 发布前检查清单 + +每个新 skill 发布前,建议技术人员逐条确认: + +- [ ] 目录结构符合当前模板 +- [ ] slug 符合 [`NAMING.md`](NAMING.md)(verb-noun-platform) +- [ ] 目录名、`SKILL.md` slug、`constants.SKILL_SLUG` 三者一致 +- [ ] `SKILL.md` 中 slug、名称、描述都已替换 +- [ ] `SKILL.md` 的 `developer_ids` 已换成**本人**匠厂用户 ID(设置 → 用户信息 → 复制),不是模板示例 ID +- [ ] `scripts/util/constants.py` 已修改 +- [ ] `../references/CLI.md` 示例命令已改成真实命令 +- [ ] `service` 下的核心业务文件(如 `task_service.py`)已按领域改名并实现 +- [ ] 没有残留旧平台名 +- [ ] `health` / `version` 可运行 +- [ ] `.env.example` 注释已按 [`CONFIG.md`](CONFIG.md)「行头 = 是什么、行尾 = 怎么填」写全(面向配置管理用户;无 `见 development/...` 等开发文档指向) +- [ ] `.gitignore` 生效,没有把 `__pycache__` 提交进去 +- [ ] `release.ps1` 存在(发布前会自动跑 `python tests/run_tests.py -v`;失败不得打 tag) +- [ ] `.github/workflows/release_skill.yaml` 存在 +- [ ] `python tests/run_tests.py -v` 全部通过(含 `test_development_policy_guard.py`) +- [ ] 主任务入口有关键日志事件词:`task_start`;失败路径 `task_failed error_code=` + `logger.exception`/`warning`(见 [`LOGGING.md`](LOGGING.md) §0、§3) +- [ ] RPA / 域模块(`scripts/service/<domain>/` 等)非零文件日志;`print(ERROR:)` 旁有 logger(`POLICY-LOGGING-006`~`008`) +- [ ] 本地跑一次主任务后,统一日志能 grep 到 §3.4 最低可验收串(不只 `cli_start`) +- [ ] 日志不泄露 password / token / cookie 等敏感明文(见 [`LOGGING.md`](LOGGING.md)) +- [ ] 新增 hard 规范已写入 `development/POLICY_MATRIX.md` 并有对应自动检测 +- [ ] `scripts/**/*.py` 单文件 **< 1000 行**(PyArmor 试用/免费模式限制;推荐 < 900 行) +- [ ] 源码/文档/配置为 **UTF-8 without BOM**(Python 文件不得含 `U+FEFF`) +- [ ] 没有新增默认必跑测试访问真实网络或浏览器 +- [ ] 如有 integration 测试需求,已写在 `tests/integration/` 下并保持 `.sample` 后缀 +- [ ] 本仓库**不是** skill-template 的误复制(根目录**无** `.openclaw-skill-template`) +- [ ] `git remote -v` 指向**本技能**远端,URL 不含 skill-template 仓库名 +- [ ] `git log` 首条提交属于本技能(非模板历史)(来源 A 从 Gitea 克隆的已有业务仓,以该仓历史为准) +- [ ] 业务 `.env` / `.env.example` 默认 `OPENCLAW_TEST_TARGET=real_rpa`(单测/CI 仍用 mock);**未只靠 mock 交活** +- [ ] 发布后计划在宿主按 §15 第七步验收:已声明入口 + **用户体验**(进度、失败提示、有头 RPA、文案一致等) +- [ ] 网页 RPA:`ensure-web` 取得 `profile_dir` 再开浏览器;`.env` 默认有头;REQUIREMENTS 写明登录策略(required/optional/not_needed,会话依赖型勿抄 optional);用户 README/TUTORIAL 区分「登记账号」与「站点登录」(`RPA.md` §0.2 / `POLICY-RPA-004`);依赖 kit 空值解析等修复时上调 `platform_kit_min_version` + +## 17. 常见错误 + +### 错误 1:只改了目录名,没改 slug + +表现: + +- 数据目录不对 +- 版本输出不对 +- 日志名不对 + +要检查: + +- `SKILL.md` +- `scripts/util/constants.py` + +### 错误 2:平台中文名改了,内部键没改 + +表现: + +- 兄弟技能筛选账号失败 +- `run` 命令走错平台或 task_type + +要检查: + +- `task_service.py` +- `sibling_bridge.py` +- `../references/CLI.md` + +### 错误 3:把业务逻辑写进 CLI + +表现: + +- 文件越来越乱 +- 不方便测 +- 参数和业务耦合太重 + +要改回: + +- `cli` 只做参数解析 +- `service` 才做业务 + +### 错误 4:保留了旧模板结构 + +表现: + +- 仓库同时存在 `docs/`、`optional/`、`skill_main.py` +- 技术人员不知道看哪一套 + +现在的新模板原则是: + +- 不做旧结构兼容 +- 用户市场四 Tab 在根 `README.md` / `TUTORIAL.md` / `DEMO.md` / `CHANGELOG.md`,Agent 资料在 `references/`,开发规范在 `development/` +- 统一走 `scripts/main.py` 作为 CLI 入口 + +### 错误 5:平台放 slug 最前 + +表现: + +- slug 形如 `erp-download-settlement-report`(平台段错误地放在最前) +- 语义校验或 `test_slug_naming` 失败 + +要改: + +- 动词放首段,平台放末段,例如 `download-settlement-report-erp` +- 见 [`NAMING.md`](NAMING.md) + +### 错误 6:noun 段过长导致超 48 字符 + +表现: + +- slug 超过 48 字符 +- 中间名词段堆砌过多 + +要改: + +- 精简 noun-phrase,保留核心业务含义 +- 见 [`NAMING.md`](NAMING.md) §3 硬约束 + +### 错误 7:只改目录名未改 slug + +表现: + +- 与「错误 1」相同,但目录已改名而 `SKILL.md` / `constants.py` 仍为占位 + +要检查: + +- `SKILL.md` 的 `metadata.openclaw.slug` +- `scripts/util/constants.py` 的 `SKILL_SLUG` + +### 错误 8:复制模板时带走了 `.git` + +表现: + +- `git remote -v` 仍显示 skill-template 的 origin +- push 到错误仓库,或 log 里全是模板历史 +- 新技能目录里仍有 `.openclaw-skill-template` + +修复: + +```powershell +Remove-Item -Recurse -Force .git +Remove-Item -Force .openclaw-skill-template -ErrorAction SilentlyContinue +git init +git remote add origin <新技能仓库 URL> +``` + +以后复制请优先用 `tools/scaffold_skill.ps1`(见 [`tools/README.md`](../tools/README.md))。 + +## 18. 推荐开发顺序总结 + +如果让一个新人照着做,我建议他按这个顺序: + +1. 按 [`NAMING.md`](NAMING.md) 确定 slug,复制模板并改目录名 +2. 改 `SKILL.md` 与根市场四 Tab(`README.md` / `TUTORIAL.md` / `DEMO.md` / `CHANGELOG.md`) +3. 改 `scripts/util/constants.py` +4. 改 `references/` 与 `development/` +5. 改 `scripts/cli/app.py` +6. 改 `scripts/service/` +7. 跑 `python tests/run_tests.py -v` +8. 跑 `health` / `version` +9. 再做业务联调 +10. 最后 release + +## 19. 这份模板的底线要求 + +以后新建 skill,至少要满足这几点: + +- 目录结构统一 +- 入口统一为 `scripts/main.py` +- 用户市场四 Tab 在根 `README.md` / `TUTORIAL.md` / `DEMO.md` / `CHANGELOG.md`,Agent 资料在 `references/`,开发规范在 `development/` +- 业务核心逻辑统一放 `scripts/service/` +- 不再使用旧模板历史结构 + +如果做不到这些,后面 skill 一多,就会越来越乱。 diff --git a/development/LOGGING.md b/development/LOGGING.md new file mode 100644 index 0000000..8c208ff --- /dev/null +++ b/development/LOGGING.md @@ -0,0 +1,359 @@ +# 日志规范 + +本文件是 skill 日志规范的**权威入口**,面向技术人员与 AI 编程代理。涉及长任务、RPA、外部系统对接的技能,**必须**在实现前通读本文。 + +自动 hard policy(`POLICY-LOGGING-001`~`008`)只保证底线;阶段是否够细仍须对照本文事件词表做 review。复制技能后可用 [`tests/samples/test_logging_contract.py.sample`](../tests/samples/test_logging_contract.py.sample) 做契约断言。 + +--- + +## 0. 验收铁律(先读) + +| 通道 | 给谁看 | 能否单独当排障依据 | +|------|--------|-------------------| +| 统一文件日志 `jiangchang.log` | 运维 / 离线打包 | **必须能单独定位到失败阶段与 error_code** | +| Activity / 任务中心 `emit` / `finish` | 用户进度与终态 | **不能**替代文件日志 | +| `print` / stdout | CLI 一眼结果 | **禁止**作为唯一失败记录 | +| 业务表 | 用户可见业务结果 | 有业务才建;**不是**默认排障通道 | + +硬规定: + +1. **没有关键节点文件日志 = 客户现场不可排障。** +2. **`emit` ≠ 文件日志**:任务中心有进度,而 `jiangchang.log` 只有 `cli_start`,视为交付不合格。 +3. **失败出口必须先写文件日志,再 `print` / `finish`**:凡 `print(...ERROR:...)`、`finish(status=failed|partial)`、队列 `mark_failed`,同一出口须有 `log.warning` / `log.exception`(含 `error_code=` 或 `task_failed`)。 +4. **域模块也要打点**:`scripts/service/<domain>/`、`*_flow.py`、`*_driver.py` 等业务文件不得零日志(见 `POLICY-LOGGING-006`)。 + +--- + +## 1. 为什么日志是必需的 + +OpenClaw 技能常在客户电脑上异步执行,且大量依赖 RPA、浏览器、ERP、微信等外部系统。这类任务具备以下特征: + +- **耗时长**:单次 run 可能数分钟甚至更久,stdout 长时间无输出不代表卡死。 +- **失败点多**:网络抖动、页面改版、登录态失效、验证码、U 盾等都会导致中途失败。 +- **环境差异大**:客户 OS、浏览器版本、代理、杀毒软件、屏幕分辨率各不相同,无法复现开发机行为。 +- **必须离线排查**:技术支持往往无法远程登录客户电脑,只能依赖本地落盘的日志与 artifacts。 + +日志用于定位: + +| 维度 | 说明 | +|------|------| +| 失败阶段 | 任务停在鉴权、取号、打开页面、提交还是等待结果 | +| 输入范围 | `task_type`、`target_id`、`input_id`、`batch_id` | +| 外部系统状态 | HTTP 状态、RPA 步骤、兄弟技能返回码 | +| 耗时 | `elapsed_ms`、批量 `current/total` | +| 重试与人工介入 | 第几次重试、是否等待验证码 / U 盾 | +| 存证路径 | 截图、录屏、`video_log`、失败 artifacts | + +--- + +## 2. 日志分层 + +技能日志分三层,职责互不替代: + +### 2.1 统一文件日志(开发 / 运维排查) + +**来源**:`jiangchang_skill_core.unified_logging`(技能内通过 `util.logging_config` 薄封装 import)。 + +| API | 用途 | +|-----|------| +| `setup_skill_logging` | CLI 入口启动时初始化(`cli/app.py` 的 `main()`) | +| `get_skill_logger` | 获取带 `skill_slug` 的 logger | +| `subprocess_env_with_trace` | 子进程 / 兄弟技能调用时传递 `trace_id` | + +**路径**: + +```text +{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/logs/jiangchang.log +``` + +**特征**:每行带 `trace_id`、`skill_slug`;适合 grep、离线打包发给技术支持。 + +### 2.2 Activity / Run Journal(宿主 UI 与用户可见进度) + +**来源**:`jiangchang_skill_core.activity` + +| API | 用途 | +|-----|------| +| `emit` | 推送用户可读进度(不写 stdout);进入步骤前自动步骤闸门(SRCP) | +| `step` | 结构化步骤 | +| `finish` | 任务结束;**唯一**写单行 result JSON 到 stdout 的场景 | +| `job_context` | 包裹 `cmd_run`;未捕获异常 / `JobStopped` 时自动 `finish` | +| `rpa_step` | RPA 专用步骤:闸门 + ▶/✓ emit | +| `interruptible_sleep` | RPA 等待(替代裸 `asyncio.sleep`),可暂停/停止 | +| `checkpoint` | 长循环内 consult control(一般由 kit 自动调用) | + +**路径**: + +```text +{JIANGCHANG_DATA_ROOT}/.jiangchang/runs/{job_id}.jsonl +``` + +**约定**: + +- `emit` **不写 stdout**;长任务应持续 emit,避免用户以为卡死。 +- `finish` 才输出单行 result JSON 供宿主解析。 +- RPA 类技能:`RpaVideoSession.add_step` 会自动同步 activity(无需重复手写每步 emit)。 +- **禁止**自建 `scripts/util/progress.py` 或向 stdout 打印 `type:progress` JSON;进度只走 Run Journal。 +- 宿主通过 `{job_id}.control.json` 下发暂停/继续/停止;技能侧由 platform-kit 在步骤闸门自动处理(见 §2.5)。 + +### 2.5 步骤控制(SRCP v1,platform-kit >= 1.2.0) + +**权威行为定义在本文**;platform-kit README 为实现细节参考。 + +| 通道 | 方向 | 载体 | +|------|------|------| +| 进度 | 技能 → 宿主 | Run Journal(`emit` / `rpa_step` / `add_step`) | +| 结果 | 技能 → 宿主 | `finish()` → Journal 终态 + stdout 一行 result | +| 控制 | 宿主 → 技能 | `{JIANGCHANG_DATA_ROOT}/.jiangchang/runs/{job_id}.control.json` | + +`control.json` 的 `command` 只认:`none` | `pause` | `resume` | `stop`。 + +技能作者三件事: + +1. `cmd_run` 外包 `with job_context(skill=SKILL_SLUG):` +2. 关键步骤 `emit(...)` 或 `@rpa_step` / `video.add_step` +3. 每个出口 `finish(status=..., message=..., skill=SKILL_SLUG, **fields)` — **不要**手写多行 JSON 结果 + +**单内核多入口**:`execute_single_*` 不含 `job_context`;仅 `cmd_run` / `cmd_run_pick` 包裹。批量 pick 循环内 `checkpoint()` + `emit(..., progress=...)`;部分失败用 `finish(status="partial")`。完整范式见 [`SKILL_ACTION_RUNTIME.md`](SKILL_ACTION_RUNTIME.md)。 + +RPA 等待用 `interruptible_sleep`,**不要**裸 `asyncio.sleep`(否则暂停响应滞后)。 + +暂停在**步骤边界**生效(当前 `@rpa_step` / `emit` 执行完后、下一步开始前),与影刀「当前指令完成后暂停」一致。 + +Journal 会写入 `type: lifecycle` 事件(`paused` / `resumed` / `stopping`),供宿主任务中心显示状态。 + +### 2.3 可观测性三分法(禁止 task_logs) + +| 信息类型 | 去哪里 | 技能怎么做 | +|----------|--------|------------| +| **过程**(步骤、异常栈、排查) | `jiangchang.log` / 宿主「运行日志」 | `log.info` / `log.exception`,关键节点见 §3 | +| **进度 / 终态**(任务中心) | Run Journal:`emit` / `finish` | `job_context` + `finish(status=...)` | +| **业务结果** | 业务表字段(status、产物路径、统计等) | repository 回写;见 [`WORK_ITEM_STATUS.md`](../references/WORK_ITEM_STATUS.md) | + +模板**默认不建** `task_logs` 等通用任务审计表。**不要**用业务表冒充任务进度,也不要用任务表代替业务状态机。 + +### 2.4 RPA video step / artifacts(用户可见动作与存证) + +**来源**:`RpaVideoSession`、`rpa-artifacts/` 目录 + +- video artifact 是**标准可启用能力**,**不保证默认每次生成 MP4**(模板默认 `OPENCLAW_RECORD_VIDEO=0`)。 +- **`OPENCLAW_RECORD_VIDEO=0`**:不生成最终录屏,但 RPA / 长任务仍须保留 `RpaVideoSession`、`video.add_step` 与 Activity 进度同步。 +- **`OPENCLAW_RECORD_VIDEO=1`**:`finish()` 字段应包含 `video_path`、`raw_video`、`video_log`、`video_warnings` 等;CLI 可打印录屏路径与诊断。 +- 用户可见的中文步骤(「打开登录页」「点击提交」)。 +- 失败截图:`{数据目录}/rpa-artifacts/{batch_id}/...`(受 `OPENCLAW_ARTIFACTS_ON_FAILURE` 控制,默认开) + +详见 [`RPA.md`](RPA.md) §5 存证与录屏规范。 + +--- + +## 3. 固定事件词表(生产代码须出现) + +日志消息推荐 **key=value**。下列**事件名字符串**须出现在 `log.info` / `log.warning` / `log.exception` 的消息中(可用 f-string / `%s`,但事件名本身须可 grep)。 + +### 3.1 长任务通用最低集(所有业务 skill) + +| 事件名 | 级别 | 说明 | +|--------|------|------| +| `cli_start` | info | CLI 入口(模板已提供) | +| `task_start` | info | 主任务开始(含 `task_type=`) | +| `entitlement_ok` / `entitlement_failed` | info / warning | 鉴权结果 | +| `item_start` / `item_done` | info | 批量逐条时;单条可用 `task_start`/`task_success` 代替 | +| `task_success` 或 `task_failed` | info / warning\|exception | 终态;失败须含 `error_code=` | + +### 3.2 RPA / 桌面 / 浏览器额外最低集 + +当 `scripts/service/` 使用 `RpaVideoSession` / `uiautomation` / `playwright` / `@rpa_step` 时,额外要求: + +| 事件名 | 说明 | +|--------|------| +| `rpa_start` / `rpa_done` 或 `rpa_failed` | RPA 段起止(可与 `task_*` 同条合并,但事件名须可 grep) | +| `stage=` | 至少 3 个不同阶段里程碑(如 `open_window` / `locate_control` / `submit` / `wait_result`) | +| `risk_blocked` | 若有配额/工作时段/熔断等前置拦截 | + +**不要**要求每个 Click 一行日志;要求的是**阶段里程碑 + 全部失败出口**。 + +### 3.3 其它常见节点(按业务选用) + +| 阶段 | 建议事件 | +|------|----------| +| adapter 选择 | `adapter_selected adapter=...` | +| 账号 / lease | `account_acquired` / `account_released` | +| 外部 API | `external_call_start` / `external_call_done`(含 `elapsed_ms=` / `status=`) | +| 兄弟技能 | `sibling_call_start` / `sibling_call_done` | +| 重试 / 跳过 / 人工 | `retry_attempt` / `skipped` / `human_intervention` | +| 存证 | `artifact_saved` / `video_path=` | + +### 3.4 最低可验收日志串(示例) + +mock 或真实跑完一条主路径后,`jiangchang.log`(或宿主「运行日志」)应能 grep 到类似: + +```text +cli_start argv=... +task_start task_type=add-one target_id=... +entitlement_ok task_type=add-one +rpa_start stage=open_window +rpa_start stage=locate_control +rpa_start stage=submit +task_failed error_code=ELEMENT_NOT_FOUND ... +``` + +成功路径则出现 `task_success` 而非 `task_failed`。若只有 `cli_start` → **不合格**。 + +--- + +## 3.5 必打节点与 Activity 对照 + +以下节点除文件日志外,长任务仍应 `emit` / `video.add_step`(用户可见),但**不能只 emit 不打文件日志**。 + +| 阶段 | 文件日志 | Activity | +|------|----------|----------| +| 任务开始 | `task_start` | `emit("开始…")` | +| RPA 步骤 | `stage=...` | `add_step` / `@rpa_step` | +| 批量进度 | `item_start` / `batch_progress` | `emit(..., progress=)` | +| 失败 | `task_failed error_code=` | `finish(failed\|partial)` | + +--- + +## 4. 字段规范 + +推荐在 `log.info` 等消息中使用 **key=value** 结构化字段,便于 grep: + +| 字段 | 含义 | +|------|------| +| `task_type` | 任务类型 | +| `target_id` | 目标标识(平台、店铺、报表类型等) | +| `input_id` | 输入记录 ID | +| `batch_id` | 批次 / 录屏批次 | +| `stage` | 阶段:`auth` / `run` / `cleanup` | +| `system` | 外部系统名:`erp` / `wechat` / `1688` | +| `operation` | 操作名:`submit_order` / `fetch_report` | +| `adapter` | adapter 档位:`mock` / `simulator_api` / `real_api` | +| `elapsed_ms` | 耗时毫秒 | +| `status` | `ok` / `failed` / `timeout` / `skipped` | +| `error_code` | 业务错误码(非 Python 异常名) | +| `artifact_path` | 截图 / 附件路径 | +| `video_path` | 录屏成品路径 | + +示例: + +```text +external_call_done system=erp operation=download_report elapsed_ms=4523 status=ok +batch_progress current=7 total=20 target_id=store-001 +``` + +--- + +## 5. 日志级别规范 + +| 级别 | 使用场景 | +|------|----------| +| **info** | 关键阶段、成功路径、外部调用开始/结束、批量进度 | +| **warning** | 可恢复问题、重试、跳过、人工介入、部分失败、鉴权失败(业务可预期) | +| **exception** | 异常路径;**必须**用 `log.exception(...)` 保留 traceback | +| **debug** | 低频诊断;**默认不依赖 debug 才能排查问题** | + +原则:**生产排障应主要靠 info + warning + exception**,不要把 debug 当作唯一线索。 + +--- + +## 6. 敏感信息红线 + +### 禁止记录原始值 + +以下字段**不得**以明文出现在日志、Activity、`finish()` 字段、stdout: + +- `password`、`token`、`cookie`、`secret`、`api_key`、`authorization` +- 验证码、动态口令全文 +- 身份证、银行卡、手机号**完整值** + +### 建议记录的安全替代 + +| 替代字段 | 示例 | +|----------|------| +| `credential_ref` | vault / account-manager 引用 ID | +| `account_id` | 账号表主键 | +| `masked_user` | `138****8000` | +| `domain` | `erp.example.com` | +| `last4` | 卡号后四位 | +| `hash` / `short_id` | 内容摘要 | + +变量名可以叫 `token`,但日志里不能出现 `token=eyJhbG...` 或 f-string 拼接明文。 + +--- + +## 7. 推荐代码示例(service 层) + +```python +from util.logging_config import get_skill_logger +from jiangchang_skill_core.activity import emit, finish, job_context, rpa_step, interruptible_sleep + +from util.constants import SKILL_SLUG + +log = get_skill_logger() + +def cmd_run(target=None, input_id=None): + task_type = "your_task" + with job_context(skill=SKILL_SLUG): + log.info( + "task_start task_type=%s target_id=%s input_id=%s", + task_type, target, input_id, + ) + emit("开始处理任务", skill=SKILL_SLUG, stage="run") + try: + ok, reason = check_entitlement(SKILL_SLUG) + if not ok: + log.warning("entitlement_failed task_type=%s reason=%s", task_type, reason) + finish(status="failed", message=reason, skill=SKILL_SLUG, error_code="ENTITLEMENT_DENIED") + return 1 + + t0 = time.monotonic() + # ... 外部调用 / RPA:@rpa_step 或 video.add_step;等待用 interruptible_sleep ... + elapsed_ms = int((time.monotonic() - t0) * 1000) + log.info( + "external_call_done system=%s operation=%s elapsed_ms=%d status=%s", + "erp", "submit", elapsed_ms, "ok", + ) + + finish(status="success", message="任务完成", skill=SKILL_SLUG) + return 0 + except Exception: + log.exception( + "task_failed task_type=%s target_id=%s input_id=%s", + task_type, target, input_id, + ) + emit("任务失败,已记录诊断信息", type="warn", skill=SKILL_SLUG, stage="run") + finish(status="failed", message="任务执行异常", skill=SKILL_SLUG) + return 1 +``` + +模板示范见 [`scripts/service/task_service.py`](../scripts/service/task_service.py) 的 `cmd_run`。 + +--- + +## 8. 错误排查顺序 + +客户现场或测试机排障,建议按以下顺序(由快到慢、由用户可见到运维细节): + +1. **CLI stdout** — 是否有 `ERROR:<CODE>:`、`HINT:`、模板提示文案。 +2. **任务中心 / Run Journal** — `{JIANGCHANG_DATA_ROOT}/.jiangchang/runs/{job_id}.jsonl`(进度与 `finish` 终态)。 +3. **统一日志文件** — `{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/logs/jiangchang.log`(grep `trace_id`、`task_failed`)。 +4. **业务表** — 宿主数据管理中该技能自己的业务结果(有才建)。 +5. **RPA artifacts** — `rpa-artifacts/` 失败截图;若 `OPENCLAW_RECORD_VIDEO=1` 还有录屏 MP4、`video_log`。未开启录屏时可能只有截图或日志,需要完整录屏请在用户 `.env` 中设置 `OPENCLAW_RECORD_VIDEO=1`。 + +长时间无 stdout **不代表卡死**;先看 Run Journal / Activity,再看统一日志。 + +--- + +## 9. 与现有文档关系 + +| 文档 | 关系 | +|------|------| +| [`RUNTIME.md`](RUNTIME.md) | 统一日志能力来源、`util.logging_config` 薄封装、数据根约定 | +| [`TESTING.md`](TESTING.md) | 日志相关 hard policy 测试(`test_development_policy_guard.py`) | +| [`RPA.md`](RPA.md) | RPA step、`RpaVideoSession`、video / artifacts 标准 | +| [`SCHEMA.md`](../references/SCHEMA.md) | 业务表结构与展示元数据 | +| [`CLI.md`](../references/CLI.md) | 最小命令与手工排查入口 | +| [`CONFIG.md`](CONFIG.md) | 敏感配置不进日志;`.env` 边界 | +| [`DEVELOPMENT.md`](DEVELOPMENT.md) | `service/` 层日志职责与发布前检查清单 | +| [`SKILL_ACTION_RUNTIME.md`](SKILL_ACTION_RUNTIME.md) | 任务中心 / async Job 与 `job_context` 边界 | +| [`POLICY_MATRIX.md`](POLICY_MATRIX.md) | 可自动检测的 logging hard policy 索引 | diff --git a/development/NAMING.md b/development/NAMING.md new file mode 100644 index 0000000..d057f68 --- /dev/null +++ b/development/NAMING.md @@ -0,0 +1,237 @@ +# 技能 slug 命名规范(verb-noun-platform) + +> 本文是匠厂**新业务 skill** 的 slug / Git 仓库名 / 目录名命名权威说明。复制 `skill-template` 出去开发新技能前**必读**;Agent 在开发者询问命名或 slug 时也应先读本文。 + +--- + +## 1. 适用范围 + +| 范围 | 说明 | +|------|------| +| ✅ **适用** | 从 `skill-template` **复制出去的新技能** | +| ❌ **不追溯** | 已上线历史技能(如 `account-manager`、`reconcile-finance`、`scrape-contacts-lianjia` 等)保持不动 | +| ❌ **不修改** | 其他仓库(尤其 `account-manager`)不在本文约束范围内 | +| ✅ **模板占位** | 模板本体的 `your-skill-slug` 占位必须继续通过全部测试 | + +**新技能勿仿**历史 2 段无平台形态(如 `reconcile-finance`)或不符合 verb-noun-platform 的旧 repo 名;校验器对历史 repo **不追溯**,但规范中明确标注为反模式。 + +--- + +## 2. 命名格式 + +标准形态(约 95% 新技能): + +```text +{verb}-{noun-phrase}-{platform} +``` + +| 部分 | 规则 | +|------|------| +| **verb** | 1 个词,来自 [动词白名单](#5-动词白名单) | +| **noun-phrase** | 1~3 个词(按 `-` 分段),描述业务对象 | +| **platform** | 1 个词,来自 [平台白名单](#6-平台白名单);可含连字符(如 `single-window`) | + +**展示层分工不变:** + +| 字段 | 语言 | 示例 | +|------|------|------| +| slug / repo / 目录 / `SKILL_SLUG` / DB 文件名 | **英文** kebab-case | `download-settlement-report-amazon` | +| `SKILL.md` 的 `name`、`description`、根 `README.md` | **中文** | `亚马逊结算报表下载` | + +--- + +## 3. 硬约束(新技能强制校验) + +| 约束 | 值 | +|------|-----| +| 格式 | `{verb}-{noun-phrase}-{platform}`(标准型,默认) | +| 总段数(按 `-` 分割) | **3~5 段**(标准型);scope 型见 [§4](#4-三类合法形态) | +| 总长度 | **≤ 48 字符** | +| 字符集 | 小写字母、数字、连字符;kebab-case | +| 禁止 | 中文 slug、下划线、**平台放最前**(如 `amazon-download-xxx`) | + +实现校验见 `scripts/util/slug_naming.py`;`scripts/db/display_metadata_validator.py` 在 `SKILL.md` frontmatter 自检时调用。 + +--- + +## 4. 三类合法形态 + +### 4.1 标准型(95%) + +`verb-noun-platform` 或 `verb-noun-noun-platform`(3~5 段) + +- 第 1 段 ∈ 动词白名单 +- 最后 1 段(或 hyphenated 平台的最后 2 段)∈ 平台白名单 +- 中间为 noun-phrase(1~3 段) + +示例: + +- `download-settlement-report-amazon` +- `add-product-shopee` +- `enter-sales-receipt-kingdee` +- `fill-export-declaration-single-window` + +### 4.2 scope 型(少见) + +`verb-noun-scope` + +- 最后 1 段 ∈ scope 白名单:`all` | `batch` | `multi` | `default` +- 第 1 段 ∈ 动词白名单 +- 总段数 3~5 + +示例:`generate-article-all` + +> 说明:`geo-generate-article-all` 若作为新技能且不在豁免表、首段 `geo` 不在动词表,应写为 `generate-article-all`。 + +### 4.3 历史豁免(不校验语义) + +以下 slug 跳过 verb/platform 语义校验(见 `LEGACY_EXEMPT_SLUGS`): + +- `account-manager` +- `skill-template` +- `your-skill-slug` / `your_skill_slug`(模板占位) + +--- + +## 5. 动词白名单 + +词表文件:[`assets/naming/verbs.txt`](../assets/naming/verbs.txt)(每行一词,便于后续扩展) + +| 动词 | 动词 | 动词 | 动词 | +|------|------|------|------| +| scrape | receive | download | export | +| add | fill | enter | bind | +| withdraw | reconcile | verify | submit | +| query | process | reply | generate | +| draft | review | confirm | track | +| upload | match | triage | ship | +| reprice | | | | + +--- + +## 6. 平台白名单 + +词表文件:[`assets/naming/platforms.txt`](../assets/naming/platforms.txt) + +| 平台 | 平台 | 平台 | +|------|------|------| +| amazon | shopee | xinghang | +| alibaba | kingdee | icbc | +| pingpong | worldfirst | paypal | +| lianlian | single-window | export-rebate | +| etax | | | + +scope 词表:[`assets/naming/scopes.txt`](../assets/naming/scopes.txt) — `all`、`batch`、`multi`、`default` + +--- + +## 7. slug 与展示字段分工 + +```yaml +--- +name: 亚马逊结算报表下载 # 中文,用户可见 +description: "从卖家中心下载结算报表并入库" # 中文说明 +metadata: + openclaw: + slug: download-settlement-report-amazon # 英文机器标识 +--- +``` + +同步修改: + +- 目录名 +- `metadata.openclaw.slug` +- `scripts/util/constants.py` 中的 `SKILL_SLUG` +- 默认数据库路径 `{slug}/{slug}.db` + +三者必须一致。 + +--- + +## 8. 与 account-manager 的关系 + +slug **末段 platform** 与 `account-manager` 中的 `platform_key` **语义对应**,但**不要求字符串完全相等**。 + +| slug 末段 (platform) | account-manager platform_key(示例) | +|----------------------|--------------------------------------| +| amazon | `amazon_sim` / `amazon` | +| shopee | `shopee` | +| kingdee | `kingdee` | + +新技能若需账号下发,在 `REQUIREMENTS.md` §0 填写拟用的 `platform_key`,并与兄弟技能调用参数对齐。 + +--- + +## 9. 复制新技能流程 + +1. **先定 slug** — 按本文规范拟定 `{verb}-{noun-phrase}-{platform}`,确认在白名单内且 ≤ 48 字符 +2. **复制目录** — **推荐** [`tools/scaffold_skill.ps1`](../tools/scaffold_skill.ps1)(`-Slug` 与目录名一致);勿整包复制带走 `.git` +2.5. **清理 Git(若未用 scaffold)** — 删除 `.git`、`.openclaw-skill-template`、`.pytest_cache`;再 `git init` 与 `git remote add origin` +3. **全局替换** — `your-skill-slug` → 新 slug;更新 `SKILL.md`、`constants.py`、文档中的占位 +4. **填写 REQUIREMENTS** — 完成 [`REQUIREMENTS.md`](REQUIREMENTS.md) §0 技能标识 +5. **开发 & 自检** — `python tests/run_tests.py -v`,含 slug 语义测试;`git remote -v` 确认非 template + +--- + +## 10. 正例 / 反例 + +### ✅ 正例 + +| slug | 说明 | +|------|------| +| `download-settlement-report-amazon` | 标准型,4 段 | +| `add-product-shopee` | 标准型,3 段 | +| `enter-sales-receipt-kingdee` | 标准型 | +| `fill-export-declaration-single-window` | 标准型,平台含连字符 | +| `generate-article-all` | scope 型 | + +### ❌ 反例 + +| slug | 原因 | +|------|------| +| `amazon-download-settlement-report` | 平台在最前 | +| `enter-cross-border-sales-receipt-voucher-kingdee` | 过长、noun 臃肿 | +| `reconcile-finance` | 新技能禁止 2 段无平台 | +| `download_settlement_amazon` | 非 kebab-case(下划线) | + +--- + +## 11. 反模式 + +- **平台前置**:`amazon-download-...`、`shopee-add-...` +- **英文占位 name**:复制后仍保留 `My Skill` 或纯英文 slug 风格名称 +- **只改目录名**:`SKILL.md` slug 与 `constants.SKILL_SLUG` 未同步 +- **noun 堆砌**:中间段过多导致超 48 字符或难以阅读 +- **仿历史 2 段 repo**:`reconcile-finance`、`content-manager` 等 + +--- + +## 12. 演进说明(当前不启用) + +当技能库规模 **> 200** 时,可再评估引入 `{domain}-verb-noun-platform` 前缀形态。**当前不启用**,新技能一律使用本文 verb-noun-platform 标准型或 scope 型。 + +--- + +## 13. 历史形态说明 + +以下为代表性**历史 repo**,新技能**勿仿**: + +| 历史 slug | 说明 | +|-----------|------| +| `account-manager` | 跨平台账号管理,非 verb-noun-platform | +| `reconcile-finance` | 2 段无 platform | +| `scrape-contacts-lianjia` | 平台名不在当前白名单(链家) | +| `skill-template` | 模板仓库本身 | + +校验器通过 `LEGACY_EXEMPT_SLUGS` 对模板占位与少数豁免 slug 跳过语义检查;**新复制出去的业务 skill 不在豁免表内**,必须满足 §3 硬约束。 + +--- + +## 14. 相关文档 + +| 文档 | 内容 | +|------|------| +| [`DEVELOPMENT.md`](DEVELOPMENT.md) | 复制模板步骤、发布前检查清单 | +| [`REQUIREMENTS.md`](REQUIREMENTS.md) | §0 技能标识必填项 | +| [`../references/SCHEMA.md`](../references/SCHEMA.md) | kebab-case 与数据库路径 | +| [`../SKILL.md`](../SKILL.md) | frontmatter 命名摘要 | diff --git a/development/POLICY_MATRIX.md b/development/POLICY_MATRIX.md new file mode 100644 index 0000000..bd5ac4e --- /dev/null +++ b/development/POLICY_MATRIX.md @@ -0,0 +1,76 @@ +# 开发规范 → 自动检测索引 + +本文件是 **development/*.md 规范到机器检测的追溯表**,不是另一份长规范。新增或调整 hard 规则时,须同步更新本矩阵与 `tests/test_development_policy_guard.py`(或注明已有测试覆盖)。 + +| policy_id | 规则摘要 | 来源 | 强制级别 | 自动检测 | 对应测试 | +|-----------|----------|------|----------|----------|----------| +| POLICY-STRUCTURE-001 | 标准 `scripts/` 分层:`main.py`、`cli/`、`service/`、`db/`、`util/` 必须存在 | development/DEVELOPMENT.md §2 新 skill 的标准目录结构;development/RUNTIME.md §目录结构 | hard | 目录存在性检查 | `tests/test_development_policy_guard.py::TestPolicyStructure001` | +| POLICY-RUNTIME-001 | 不得 vendored `scripts/jiangchang_skill_core/` | development/RUNTIME.md §共享 Python Runtime、§明确禁止 | hard | 目录不存在检查 | `tests/test_development_policy_guard.py::TestPolicyRuntime001`(另见 `tests/test_platform_import.py`) | +| POLICY-RUNTIME-002 | `requirements.txt` 非注释依赖行不得声明 `jiangchang-platform-kit` / `playwright` | development/RUNTIME.md §共享 Python Runtime;development/DEVELOPMENT.md §3.1 requirements.txt 依赖规范 | hard | 解析依赖行 | `tests/test_development_policy_guard.py::TestPolicyRuntime002`(另见 `tests/test_platform_import.py`) | +| POLICY-RUNTIME-003 | Agent/CLI 文档须要求共享 Python(python-runtime / PATH 上的 `python` + `{baseDir}`);须明确**禁止**对技能脚本使用 `uv run python` | development/RUNTIME.md;references/CLI.md;SKILL.md | hard | 文档标记扫描 | `tests/test_docs_standards.py::test_cli_md_requires_shared_python_not_uv_run`;`tests/test_docs_standards.py::test_skill_md_requires_shared_python_not_uv_run` | +| POLICY-AGENT-001 | `SKILL.md` 须含「用户意图路由表」结构;`CLI.md` 须含短 CLI 白名单 / Action 强制黑名单表;用法回答须禁止无故粘贴 CLI、禁止为答用法列目录挖 references | SKILL.md;references/CLI.md | hard | 文档标记扫描 | `tests/test_docs_standards.py::test_skill_md_has_intent_routing_table`;`tests/test_docs_standards.py::test_cli_md_has_agent_routing_tables`;`tests/test_docs_standards.py::test_skill_md_how_to_forbids_cli_paste` | +| POLICY-INSTALL-001 | 交付执行文件不得含 `pip install` / `python -m pip install` / `uv pip install` / `playwright install` | development/RUNTIME.md §共享 Python Runtime;development/RPA.md §1.4 Playwright 与安装边界;development/DEVELOPMENT.md §3.1 | hard | 扫描 `scripts/**/*.py`、`*.ps1`、`*.sh`、`requirements.txt`、`.github/workflows/*`(不扫 `development/*.md`) | `tests/test_development_policy_guard.py::TestPolicyInstall001` | +| POLICY-CONFIG-001 | 仓库根目录必须存在 `.env.example` | development/CONFIG.md §核心原则、§标准配置项 | hard | 文件存在性 | `tests/test_development_policy_guard.py::TestPolicyConfig001` | +| POLICY-CONFIG-002 | `.gitignore` 必须忽略 `.env` 与 `*.env.local` | development/CONFIG.md §红线:敏感信息不进 .env | hard | 解析 `.gitignore` 行 | `tests/test_development_policy_guard.py::TestPolicyConfig002` | +| POLICY-CONFIG-003 | 业务代码不得直接 `os.environ` / `os.getenv` / `os.putenv` / `environ.get`;须经 `jiangchang_skill_core.config.get*`(允许 `main.py`、测试、config_bootstrap/runtime 边界文件) | development/CONFIG.md §配置 bootstrap;development/ADAPTER.md §档位 dispatch | hard | 扫描 `scripts/**/*.py` 并排除边界文件 | `tests/test_development_policy_guard.py::TestPolicyConfig003` | +| POLICY-CONFIG-004 | `.env.example` 每个活跃 `KEY=` 必须有中文**行头**(是什么)与中文**行尾**(怎么填);注释禁止 `development/`、`*.md` 文档指向、`account-manager`、`adapter 档位` 等开发黑话 | development/CONFIG.md §.env.example 注释 = 配置管理用户文案 | hard | 解析 `.env.example` 活跃行与注释 | `tests/test_development_policy_guard.py::TestPolicyConfig004` | +| POLICY-TESTING-001 | 默认根目录 `tests/test_*.py` 不得硬编码 `real_api` / `real_rpa` / `OPENCLAW_TEST_TARGET`(白名单仅 `test_adapter_profile_policy.py`,用于档位 helper 自测) | development/TESTING.md §默认必跑测试要做什么、§4 测试目标档位 | hard | 扫描 `tests/test_*.py` 非注释行;白名单外禁止上述文本 | `tests/test_development_policy_guard.py::TestPolicyTesting001` | +| POLICY-TESTING-002 | `tests/integration/` 下 Python 测试文件若存在,默认应为 `.sample`,不得进入默认套件 | development/TESTING.md §7 真实联调测试的安全约束、§8 新 skill 的最小测试清单 | hard | 检查 `tests/integration/*.py` 无 `.sample` 后缀 | `tests/test_development_policy_guard.py::TestPolicyTesting002` | +| POLICY-RPA-001 | 不得 import/use account-manager 内部 `rpa_helpers` / `inject_account_manager_scripts_path` / `get_account_credential` | development/ADAPTER.md §兄弟依赖;development/RPA.md §1.7 | hard | 扫描 `scripts/**/*.py` 禁止模式 | `tests/test_development_policy_guard.py::TestPolicyRpa001`(另见 `tests/test_no_rpa_helpers_import.py`) | +| POLICY-RPA-002 | RPA 交付代码不得直接执行 ffmpeg 拼 MP4;应使用 `RpaVideoSession`(允许 `ffmpeg_path` 等诊断字段) | development/RPA.md §5.3 录屏成片标准 | hard | 扫描 `scripts/**/*.py` 禁止 `subprocess` / `os.system` / `os.popen` 直接调用 ffmpeg(`task_run_support.py` 保留豁免) | `tests/test_development_policy_guard.py::TestPolicyRpa002` | +| POLICY-RPA-003 | 即使默认 `OPENCLAW_RECORD_VIDEO=0`,RPA / 长任务模板也必须保留录屏能力:`RpaVideoSession`、`video.add_step`、video artifact merge 进 `finish()` 字段 | development/RPA.md §5.3;development/LOGGING.md;development/CONFIG.md | hard | 扫描 `scripts/service/*.py`(service 层整体须含上述接入) | `tests/test_development_policy_guard.py::TestPolicyRpa003` | +| POLICY-RPA-004 | 网页 RPA:`.env.example` 若声明 `OPENCLAW_BROWSER_HEADLESS` 则默认必须为 `0`;`scripts/` 若使用 `launch_persistent_*` 则必须同时存在 `pick_web_account`(先 Profile 再开浏览器;实现上应走 ensure-web);登录不全局强制,但不得跳过 account-manager | development/RPA.md §0.2;development/ADAPTER.md §兄弟依赖 | hard | 解析 `.env.example` + 扫描 `scripts/**/*.py` | `tests/test_development_policy_guard.py::TestPolicyRpa004` | +| POLICY-RPA-005 | 必须保留 AI + RPA 总纲,明确 Agent / RPA 职责、授权范围、禁止破解/逆向/私有协议/安全验证绕过、人工介入、状态等待;真实业务默认动作节奏为 1~3 秒 | development/AI_RPA.md §4–§6;development/RPA.md §0.0/§0.3 | hard(文档与默认配置) / soft(真实行为) | 检查总纲关键边界;解析 `.env.example` 的 `STEP_DELAY_MIN=1.0` / `STEP_DELAY_MAX=3.0` | `tests/test_development_policy_guard.py::TestPolicyRpa005` | +| POLICY-RPA-006 | 浏览器 RPA 必须保留 account-manager 生命周期标准;所有样例账号选择路径都须返回非空 `profile_dir + lease_token`,指定数字账号 ID 不得退化为无租约 `account get` | development/ACCOUNT_MANAGER.md;development/ADAPTER.md §兄弟依赖;development/RPA.md §0.2 | hard | 检查标准文档关键标记;扫描真实/仿真 `account_client.py` 的指定 ID 带租约实现并禁止空租约赋值 | `tests/test_development_policy_guard.py::TestPolicyRpa006` | +| POLICY-PACKAGING-001 | `scripts/**/*.py` 单文件 < 1000 行 | development/DEVELOPMENT.md §3.4 发布打包约束;development/RUNTIME.md §发布打包约束 | hard | 行数统计 | **已有测试覆盖**:`tests/test_release_packaging_constraints.py::test_scripts_py_files_under_pyarmor_line_limit` | +| POLICY-PACKAGING-002 | 文本文件 UTF-8 without BOM,无 `U+FEFF` | development/DEVELOPMENT.md §3.4;development/RUNTIME.md §编码与输出 | hard | BOM / 解码检查 | **已有测试覆盖**:`tests/test_release_packaging_constraints.py::test_text_files_are_utf8_without_bom` | +| POLICY-RELEASE-001 | 发布 tag(去掉前缀)必须等于 `SKILL.md` 的 `version:` 且等于 `scripts/util/constants.py` 的 `SKILL_VERSION`;`release.ps1` 打 tag 前硬失败,不自动改写字段 | development/DEVELOPMENT.md §15 发布;宿主市场「最新版本」来自 tag、「当前版本」来自包内 `SKILL.md` | hard | `release.ps1` 门禁 + 日常版本字段对齐测试 | `release.ps1`(`Assert-SkillReleaseVersionAlignment`);`tests/test_development_policy_guard.py::TestPolicyRelease001`;`tests/test_skill_metadata.py::test_skill_version_alignment` | +| POLICY-DOCS-001 | 本矩阵存在且包含上述全部 `policy_id` | 本次规范化约定 | hard | 解析 `development/POLICY_MATRIX.md` | `tests/test_development_policy_guard.py::TestPolicyDocs001` | +| POLICY-LOGGING-001 | CLI 入口必须调用 `setup_skill_logging` | development/LOGGING.md;development/RUNTIME.md | hard | 扫描 `scripts/cli/app.py` 含 `setup_skill_logging` 与 logger info | `tests/test_development_policy_guard.py::TestPolicyLogging001` | +| POLICY-LOGGING-002 | service 主任务入口必须使用 `get_skill_logger` | development/LOGGING.md;development/DEVELOPMENT.md | hard | 扫描 `scripts/service/task_service.py` | `tests/test_development_policy_guard.py::TestPolicyLogging002` | +| POLICY-LOGGING-003 | 主任务入口须含 `log.info`/`logger.info`、`log.exception`/`logger.exception` | development/LOGGING.md | hard | 扫描 `scripts/service/task_service.py` | `tests/test_development_policy_guard.py::TestPolicyLogging003` | +| POLICY-LOGGING-004 | 长任务 / RPA 模板须使用 Activity 或 RPA video step 输出进度 | development/LOGGING.md;development/RPA.md | hard | 扫描 `scripts/service/task_service.py` 含 `emit(` / `activity.emit` / `video.add_step` | `tests/test_development_policy_guard.py::TestPolicyLogging004` | +| POLICY-LOGGING-005 | 交付代码不得在 logging 调用行以 `key=value` 形式记录明显敏感字段 | development/LOGGING.md;development/CONFIG.md | hard | 扫描 `scripts/**/*.py` 的 logging 调用行(不含注释) | `tests/test_development_policy_guard.py::TestPolicyLogging005` | +| POLICY-LOGGING-006 | `scripts/service/**/*.py` 业务域文件须有 `log/logger/_logger.(info|warning|error|exception)`;豁免薄工具:`__init__.py`、`task_run_support.py`、`entitlement_service.py`、`sibling_bridge.py` | development/LOGGING.md §0、§3 | hard | 扫描 service 树 | `tests/test_development_policy_guard.py::TestPolicyLogging006` | +| POLICY-LOGGING-007 | 若 service 层含 RPA 标记(`RpaVideoSession` / `uiautomation` / `playwright` / `rpa_step`),则须出现事件词 `task_start`、`task_failed` 或 `rpa_failed`、以及 `stage=` | development/LOGGING.md §3.1–§3.2 | hard | 合并扫描 `scripts/service/**/*.py` | `tests/test_development_policy_guard.py::TestPolicyLogging007` | +| POLICY-LOGGING-008 | `scripts/service/**/*.py` 中若 `print` 含 `ERROR:`,同一函数体内须有 `log/logger/_logger.(warning|error|exception)` | development/LOGGING.md §0 铁律 3 | hard | 按 `def` 切分共现检查 | `tests/test_development_policy_guard.py::TestPolicyLogging008` | +| POLICY-CONTROL-001 | 不得存在 `scripts/util/progress.py` 或自建 stdout 进度模块 | development/LOGGING.md §2.5;development/RPA.md §0.1 | hard | 文件不存在检查 | `tests/test_development_policy_guard.py::TestPolicyControl001` | +| POLICY-CONTROL-002 | `task_service.py` 须使用 `job_context(` 与 `finish(` | development/LOGGING.md §2.5、§7 | hard | 扫描 `scripts/service/task_service.py` | `tests/test_development_policy_guard.py::TestPolicyControl002` | +| POLICY-CONTROL-003 | `scripts/service/*.py` 不得裸 `asyncio.sleep`(RPA 等待须 `interruptible_sleep`) | development/RPA.md §0.1 | hard | 扫描 service 层 Python | `tests/test_development_policy_guard.py::TestPolicyControl003` | +| POLICY-DATA-PATH-001 | 技能拥有路径须经 `resolve_data_path` / `get_*_dir`;`.env.example` 禁止 `./` CWD 相对路径;业务代码禁止 `abspath(config.get(...))` | development/DATA_PATHS.md | hard | 扫描 `.env.example` 与 `scripts/**/*.py` | `tests/test_development_policy_guard.py::TestPolicyDataPath001` | +| POLICY-SKILL-ACTION-001 | 存在 `development/SKILL_ACTION_RUNTIME.md`;schema 含 `executionProfile`;业务技能(非模板占位 slug)若 service 含 `RpaVideoSession` 则 `actions.json` 须有 `executionProfile=async` 且 placements 含 `agent` 的 action | development/SKILL_ACTION_RUNTIME.md;references/ACTIONS.md | hard | 文档/schema/manifest 检查 | `tests/test_development_policy_guard.py::TestPolicySkillAction001` | +| POLICY-SKILL-ACTION-002 | 每个 Action 必须显式声明 `executionProfile`,且只能是 `sync` 或 `async` | development/SKILL_ACTION_RUNTIME.md;references/ACTIONS.md;assets/schemas/skill-actions.schema.json | hard | schema required + manifest 扫描 | `tests/test_development_policy_guard.py::TestPolicySkillAction002`;`tests/test_actions_manifest.py` | +| POLICY-SKILL-ACTION-003 | toolbar Action 必须声明合法非空 `bind.tables`(snake_case、唯一) | references/ACTIONS.md;assets/schemas/skill-actions.schema.json | hard | schema if/then + manifest 扫描 | `tests/test_development_policy_guard.py::TestPolicySkillAction003`;`tests/test_actions_manifest.py` | +| POLICY-SKILL-ACTION-004 | `placements` 与 `executionProfile` 正交;文档不得把数据管理入口与异步执行方式硬绑定;Schema 须接受 toolbar/row/batch/cron/agent/skill-detail × sync/async 全部组合 | development/SKILL_ACTION_RUNTIME.md;references/ACTIONS.md;assets/schemas/skill-actions.schema.json | hard | Schema 6×2 矩阵校验 + 文档禁止短语扫描 | `tests/test_actions_manifest.py::test_schema_allows_all_placement_execution_profile_combinations`;`tests/test_development_policy_guard.py::TestPolicySkillAction004` | +| POLICY-TUTORIAL-001 | 存在 `development/TUTORIAL_SURFACES.md`;`TUTORIAL.md` 须含数据管理/对话/定时/对照表骨架;`README.md` 禁止「告诉 Agent」专章与入口专章;说明/教程正文禁止与页头重复的 `#` H1(主分节用 `##`);业务技能教程与 `actions.json` placements 对齐(无 cron 须声明不需要定时) | development/TUTORIAL_SURFACES.md;development/CHECKLIST.md A2-7~A2-12 | hard | 文档存在性 + 章节/反模式扫描 + placements 对齐 | `tests/test_tutorial_surfaces.py`;`tests/test_docs_standards.py::test_tutorial_surfaces_contract_doc_and_checklist` | +| POLICY-ARCH-CORE-001 | 同一业务能力的 Action、CLI、Agent、cron、数据管理入口复用同一业务内核(文档要求已声明) | development/DEVELOPMENT.md;development/SKILL_ACTION_RUNTIME.md | hard(文档标记) / soft(语义) | 文档关键表述存在性;真实复用靠 sample/review | `tests/test_development_policy_guard.py::TestPolicyArchCore001`;`tests/samples/test_action_core_contract.py.sample` | +| POLICY-DATA-SYNC-001 | 同步型业务须遵循幂等、事务、完整性与空结果保护(文档已声明) | references/SCHEMA.md;development/SKILL_ACTION_RUNTIME.md | hard(文档标记) / soft(语义) | 文档关键表述存在性;完整语义靠 sample/review | `tests/test_development_policy_guard.py::TestPolicyDataSync001`;`tests/samples/test_sync_contract.py.sample` | + +--- + +## 暂不自动检测(软规则) + +以下规范仍须人工遵守或依赖 code review / AI 提示词 / sample 契约,**不**做成脆弱关键字扫描: + +| 规则摘要 | 来源 | 原因 | +|----------|------|------| +| `cli/` 只解析参数、不写业务逻辑 | development/DEVELOPMENT.md §9 | 需语义理解,无法可靠静态判定 | +| `service/` 编排流向与模块拆分粒度 | development/DEVELOPMENT.md §10 | 架构软约束;见 `test_action_core_contract.py.sample` | +| 是否真正复用同一业务内核 / 是否按 source 分叉 | development/SKILL_ACTION_RUNTIME.md §5 | 需调用图与人工审查 | +| 是否满足完整快照语义 / 空结果保护实现 | references/SCHEMA.md | 见 `test_sync_contract.py.sample` | +| 是否把系统导出与业务同步混淆 | references/ACTIONS.md;SCHEMA.md | 文档 + sample;勿做源码关键词误报扫描 | +| `db/` 层不得调浏览器或拼业务流程 | development/DEVELOPMENT.md §11 | 需跨文件调用图 | +| slug 命名语义(verb-noun-platform) | development/NAMING.md | 部分覆盖于 `tests/test_slug_naming.py`,非本矩阵 hard policy | +| 复制模板不得保留 `.git` / template marker | development/DEVELOPMENT.md §4 | 由 `tests/test_scaffold_guard.py` 守护,属脚手架场景 | +| 共享 runtime 导入来源非技能目录 | development/RUNTIME.md | 运行时依赖已安装包,见 `tests/test_platform_import.py` | +| RPA 真实轨迹、选择器纪律、状态等待、HITL 超时与有限重试是否正确实现 | development/AI_RPA.md;development/RPA.md §0–§1 | 文档和默认节奏已守护,真实行为与 DOM 质量仍需人工验收 | +| RPA 阶段日志是否「够细」(每个控件) | development/LOGGING.md §3.2 | hard 已要求里程碑事件词与域模块有日志;逐步粒度靠 sample 契约与验收 | +| adapter 四档契约测试覆盖 timeout/unauthorized 等 | development/ADAPTER.md §contract tests | 需业务实现后人工补测 | +| `SKILL.md` / `constants.SKILL_SLUG` 一致性 | development/DEVELOPMENT.md §16 | 已有 `tests/test_skill_metadata.py` | +| platform_kit_min_version >= 1.2.11 | development/RUNTIME.md | 已有 `tests/test_platform_import.py` | +| 宿主按 `bind.tables` 过滤 toolbar 按钮 | references/ACTIONS.md | 待宿主升级后完整生效;模板侧已强制显式绑定 | +| form +「已选择」时映射 `readOnly` 默认不占参数区(仍提交);`revealInForm` 逃生阀 | references/ACTIONS.md「选择摘要」 | soft(宿主 UX);文档与 schema 已声明;勿用 `visibleWhen` 代替 | +| 数据管理技能按钮中文名(`label`)对照 ACTIONS.md 建议表;打开类成功静默 | references/ACTIONS.md「Action 按钮中文名」 | 参考非白名单;行业词允许自创;勿做 label 关键字扫描 | +| Data Policy 未启用(L0)时宿主不得拉 Snapshot / 不得改按钮与行列行为 | references/POLICY.md | soft;宿主实现契约 | +| Data Policy 启用后 fail-closed(禁止回退 L0 全开);写路径 domain 必须再鉴权 | references/POLICY.md §6–§7 | soft;`POLICY-SKILL-AUTHZ-*` 硬门禁可后续加 | +| `actions.mode` 仅 allowlist/open;`hostCapabilities` 省略=不限制;scope `valueFrom` 仅 `actor.userId` | references/POLICY.md §0.1 | soft;Schema 已约束 | diff --git a/development/PRODUCT_SURFACES.md b/development/PRODUCT_SURFACES.md new file mode 100644 index 0000000..1319808 --- /dev/null +++ b/development/PRODUCT_SURFACES.md @@ -0,0 +1,188 @@ +# 宿主产品入口(对话 / 数据管理 / 定时任务) + +本文给**技能开发者**与 **AI 编程助手**读:说明匠厂宿主上几个**平级**业务入口各自干什么、**什么时候走哪个**、以及如何为本技能做入口定位。 + +字段怎么写见 [`../references/ACTIONS.md`](../references/ACTIONS.md)、[`../references/SCHEMA.md`](../references/SCHEMA.md);任务中心 / sync·async 见 [`SKILL_ACTION_RUNTIME.md`](SKILL_ACTION_RUNTIME.md)。 + +**与其它分类正交:** + +| 文档维度 | 回答的问题 | +|----------|------------| +| 本文(产品入口) | 用户从宿主哪条路径触发同一能力 | +| Type A–D / 目录分层 | 技能有没有库、会不会调兄弟技能 | +| RPA 四象限 | 外部系统怎么接 | + +不要把三者合并成一个大枚举。 + +--- + +## 1. 入口是什么(先立规矩) + +技能跑在**匠厂宿主**上,不是独立软件。宿主侧栏里的入口,是同一技能能力的不同**触发场景**: + +| 今日常见入口 | 触发方式 | 底层 | +|--------------|----------|------| +| **对话任务**(新建任务) | 自然语言 → Agent | 短 CLI,或 `run_skill_action`(`placements` 含 `agent`) | +| **数据管理** | 点工具栏 / 行 / 批量 / 表单(UI) | `run_skill_action`(`source=data-management`)→ **同一 CLI** | +| **定时任务** | 到点「技能直调」或定时 Agent | `run_skill_action`(`cron` / `cron-agent`)→ **同一 CLI** | + +**铁律:** + +1. **入口平级**:没有「对话主、数据管理/定时辅」。只是触发场景与交互方式不同,各自有优势。 +2. **同一业务能力、同一 domain service**:禁止按入口复制逻辑,禁止按 `source` 分叉业务结果。 +3. **同能力可挂多个入口**:不是三选一;按本技能 UX 决定挂哪些。 +4. **可扩展**:以后若宿主增加新入口,仍是「新触发场景 + 同一内核」,定位方法不变(定位表多一列即可)。 +5. **任务中心不是业务入口**:只表示 `executionProfile: async` 的执行壳;与从哪个入口点进来无关。 + +--- + +## 2. 什么时候走哪个入口 + +### 2.1 对话任务——「用嘴发起」 + +**适合:** + +- 用户用自然语言描述意图,条件一次说清或还要追问 +- 探索式:「能不能帮我……」「先看看再决定」 +- 对象临时指定(贴链接、说一个范围) +- 需要 Agent 解释结果、失败原因、下一步 +- 用户不想打开数据管理,只想在聊天里办完 + +**技术自问:** 用户会不会用一句话说清这件事?需要模型帮理解和确认吗?——会,就为该能力挂 `agent`(长任务 / RPA 必须 Action + 通常 `async`;短查询可用共享 Python CLI)。 + +**注意:** 若技能还有「对着表运营 / 定时啃队列」等场景,**不要把对话当成唯一路径**,否则传统用户与省 token 路径会丢掉。 + +### 2.2 数据管理——「用界面发起 + 看着数据做」 + +数据管理有两层,**同等重要**: + +1. **执行面**:工具栏 / 行级 / 批量 / `replaces` 接管新增·编辑·删除·**导入** —— 与对话一样触发同一 CLI,走完整业务闭环(传统用户可不聊天)。导入按钮的 UX 始终是宿主「导入数据」面板;有 `replaces:import` 时确认后由技能写库。 +2. **运营面**:业务表、状态流向、配置、input/output、日志、HTML —— 看清数据从哪来、到哪去、卡在哪。 + +**适合:** + +- 在业务数据上操作:看队列、选一行重试、勾选批量、顶栏「处理 N 条 / 同步」 +- 已知流程、参数结构化(表单 / 确认 / 一点即跑)——**省 token**,不必每次过模型 +- 准备与执行拆开:表里先准备,再在表上点执行 +- 服务不愿用 AI 对话的用户 + +**技术自问:** 用户会不会打开这张表,靠点选就把主流程跑完?——要,就挂 `toolbar` / `row` / `batch` / `replaces`,并做好 `_jiangchang_*` 中文元数据。 + +**不要:** 把数据管理理解成「只给开发者看 SQLite」;也不要在完全没有结构化操作对象时硬造空表按钮。 + +### 2.3 定时任务——「到点自动发起」 + +**适合:** + +- 同一能力需按节奏重复:消费队列、定期同步、巡检 +- 无人值守:默认参数即可跑,人不在场也正确、安全 +- 与对话 / 数据管理挂**同一个** Action,只改触发方式 + +**技术自问:** 这件事明天能不能在人不盯着时自动再跑一遍?——能,挂 `cron`;不能,定位表写明不挂及理由。 + +**慎挂:** 每次对象都不同须人指定;强不可逆且须当场确认;主路径依赖验证码 / 人工介入。 + +### 2.4 场景对照(贴需求用) + +| 若用户场景是… | 优先考虑 | 说明 | +|---------------|----------|------| +| 「帮我处理这个……」(临时、口语) | 对话 | 灵活、可追问 | +| 「对着表点一下就跑」 | 数据管理 | 结构化、省 token、传统用户 | +| 「每天自动跑,不用我管」 | 定时 | 无人值守 | +| 「偶尔对话下发,平时表上运营,夜里自动啃队列」 | **多个入口都挂同一能力** | 平级互补,不是重复开发 | +| 「只看结果,从不在表上触发」 | 表可仅展示;执行挂对话/定时 | 由本技能 UX 定 | + +--- + +## 3. 如何为本技能做入口定位(必做) + +模板**不**规定「某类技能必须三入口都挂满」。由技术按**本技能干什么 + 用户体验**填写定位表(写入 [`REQUIREMENTS.md`](REQUIREMENTS.md))。 + +对每个**主能力**(一条业务 CLI / 一个 Action)填一行: + +| 主能力(Action id / CLI) | 对话 | 数据管理 | 定时 | 不挂理由(若不挂) | +|---------------------------|------|----------|------|--------------------| +| (例)`download-run-pick` | 挂 | toolbar | 挂 | — | +| (例)`download-video` | 挂 | 不挂 | 不挂 | 对象每次不同,须人指定链接 | + +**数据管理列怎么写:** `不挂` / `toolbar` / `row` / `batch` / `replaces` / `仅展示表(无业务按钮)`(可组合)。 + +**规则只有三条:** + +1. 定位为「挂」的入口:必须在 `actions.json`(及必要元数据)中声明,且宿主可测通,并走同一内核。 +2. 定位为「不挂」的入口:必须有一句理由;禁止空白「忘了」。 +3. 未来新入口:表上多一列,方法不变。 + +完整 ≠ 每个命令塞满所有入口;完整 = **想清楚用户路径后,该出现的入口都挂上了,且同内核**。 + +--- + +## 4. 怎么挂(最小路径) + +| 我想让用户… | 主要改什么 | +|-------------|------------| +| 能在对话里触发 | `SKILL.md` 意图路由 + `actions.json` 的 `placements` 含 `agent`(长任务 `async`) | +| 能在数据管理看见结果 / 过程 | 业务表 + `init-db` + `_jiangchang_*`(见 SCHEMA) | +| 能在表上点按钮走闭环 | `placements` 含 `toolbar`/`row`/`batch`,`toolbar` 须 `bind.tables` | +| 能设定时自动跑 | 同一 Action 的 `placements` 含 `cron`;参数适合无人值守 | + +细节与 Schema:[`../references/ACTIONS.md`](../references/ACTIONS.md)。运行时:[`SKILL_ACTION_RUNTIME.md`](SKILL_ACTION_RUNTIME.md)。 + +--- + +## 5. 反例(不要这样做) + +- 只做对话 Action,从未填写入口定位表,也未考虑数据管理 / 定时用户路径 +- 为 toolbar / cron / agent 复制三套业务实现,或按 `source` 分叉结果 +- 把数据管理当成只读调试窗,主闭环只能聊天完成(若 UX 上本应支持 UI) +- 不可逆、须确认的主路径默认挂 `cron` 且无产品确认 +- 用「Action 可选 / 定时以后再说」当作「可以不思考入口」 + +--- + +## 6. 附录:定位表示例(参考,非强制) + +### 示例 A:队列消费类(对话 + 表上处理 + 定时) + +| 主能力 | 对话 | 数据管理 | 定时 | 理由摘要 | +|--------|------|----------|------|----------| +| 入队 / 准备 | 挂 | 可 replaces 或表新增 | 不挂 | 准备态由人或对话写入 | +| 处理 N 条 pending | 挂 | toolbar | 挂 | 运营点选与无人值守同一内核 | +| 单条立即执行 | 挂 | 可选 row | 不挂 | 对象临时指定 | + +### 示例 B:单次确认发布类 + +| 主能力 | 对话 | 数据管理 | 定时 | 理由摘要 | +|--------|------|----------|------|----------| +| 发布一条 | 挂 | 按是否落草稿表决定 | 不挂 | 不可逆,需人确认;不做无人值守 | + +### 示例 C:无库短查询 + +| 主能力 | 对话 | 数据管理 | 定时 | 理由摘要 | +|--------|------|----------|------|----------| +| 查询 | 挂(或短 CLI) | 不挂 | 不挂 | 无本地业务表与重复批跑需求 | + +--- + +## 7. 用户教程如何投影本文 + +入口定位表填完后,**必须**落到用户市场文档(见 [`TUTORIAL_SURFACES.md`](TUTORIAL_SURFACES.md)): + +| 定位结果 | `TUTORIAL.md` | `README.md` | +|----------|---------------|-------------| +| 挂数据管理 | 「入口一」专章 + 表/按钮步骤 | 仅一句「支持数据管理」 | +| 挂对话 | 「入口二」专章 + 话术 | 仅一句「支持对话」;**禁止**话术块 | +| 挂定时 | 「入口三」教怎么建 | 可选一句边界 | +| 不挂定时 | 「入口三」写不需要 + 理由 | 可选一句「不提供定时」 | + +准备/载入类 Action:优先 `toolbar` + `bind.tables`,避免教程写「技能详情点按钮」而宿主详情页不渲染。 + +--- + +## 8. 相关文档 + +- 需求里填定位表:[`REQUIREMENTS.md`](REQUIREMENTS.md) +- **用户说明 / 教程怎么写:** [`TUTORIAL_SURFACES.md`](TUTORIAL_SURFACES.md) +- 交活怎么验:[`CHECKLIST.md`](CHECKLIST.md) +- 开发步骤中的 Action 挂载:[`DEVELOPMENT.md`](DEVELOPMENT.md) §11.5、§15 +- Agent 侧字段契约:[`../references/ACTIONS.md`](../references/ACTIONS.md) diff --git a/development/README.md b/development/README.md new file mode 100644 index 0000000..d869617 --- /dev/null +++ b/development/README.md @@ -0,0 +1,51 @@ +# 开发资料入口 + +本目录面向**人类开发者**与 **AI 编程代理**。技术人员与编程 AI **以本目录为主读路径**;`references/` 是 CLI / Action / Schema 契约细节,根目录市场四 Tab 面向最终用户。 + +## 新技能全流程(先看这张表) + +按顺序做;细节与约束见 [`DEVELOPMENT.md`](DEVELOPMENT.md) 对应章节(**不替代**下文深度规范,只作导航)。 + +| 步骤 | 做什么 | 详见 | +|------|--------|------| +| 1 | **拿到仓库**:① 项目经理在 [自建 Gitea](https://git.jc2009.com/)(`git.jc2009.com`,非 Gitea 官网)开仓并授权后 `git clone`;或 ② 本地已有 `skill-template` 时用 scaffold / 复制出新目录(遵守 Git 红线) | [`DEVELOPMENT.md`](DEVELOPMENT.md) §1、§4 | +| 2 | **先读宿主产品入口**:对话 / 数据管理 / 定时任务平级;何时走哪个;为本技能填入口定位表 | [`PRODUCT_SURFACES.md`](PRODUCT_SURFACES.md) | +| 2b | **用户市场文档**:说明 Tab ≠ 教程 Tab;教程按三入口写;与 §0.1 / actions 对齐 | [`TUTORIAL_SURFACES.md`](TUTORIAL_SURFACES.md) | +| 3 | **先读 AI + RPA 总纲**;界面技能先完成元素拾取/element map,再按浏览器、桌面或手机读分端手册 | [`AI_RPA.md`](AI_RPA.md)、[`RPA_ELEMENT_LOCATING.md`](RPA_ELEMENT_LOCATING.md) | +| 4 | 按 [`NAMING.md`](NAMING.md) 确认 slug;**先填** [`REQUIREMENTS.md`](REQUIREMENTS.md)(含入口定位表),再写业务代码 | [`REQUIREMENTS.md`](REQUIREMENTS.md)、[`NAMING.md`](NAMING.md) | +| 5 | 替换标识与占位,按四象限 / examples 实现 `scripts/service/` | [`DEVELOPMENT.md`](DEVELOPMENT.md) §4~§14 | +| 6 | **配置 `developer_ids`(开发自测必做)**:开发期技能在匠厂默认不公开;不加则开发者自己也装测不了。从宿主设置复制用户 ID 写入 `SKILL.md` | [`DEVELOPMENT.md`](DEVELOPMENT.md) §6「关于 developer_ids」 | +| 7 | 本地单测(mock)通过后,按业务默认 **`real_rpa`** 真实跑通;再 `release.ps1`;看 Gitea CI。**mock 通 ≠ 完成** | [`ADAPTER.md`](ADAPTER.md);[`DEVELOPMENT.md`](DEVELOPMENT.md) §15 | +| 8 | 匠厂安装后,按**入口定位表已挂**的入口自测,并看**用户体验**(进度、失败提示、有头 RPA 等) | [`DEVELOPMENT.md`](DEVELOPMENT.md) §15;[`PRODUCT_SURFACES.md`](PRODUCT_SURFACES.md);[`SKILL_ACTION_RUNTIME.md`](SKILL_ACTION_RUNTIME.md) | +| 9 | **交活验收(第三方)**:对照 [`CHECKLIST.md`](CHECKLIST.md) 验收;报告写入仓库根目录 [`acceptance-reports/`](../acceptance-reports/) | [`CHECKLIST.md`](CHECKLIST.md) | + +脚手架与 Git 防串库:[`../tools/README.md`](../tools/README.md)(`scaffold_skill.ps1`)。业务仓也可直接从自建 Gitea clone,见 [`DEVELOPMENT.md`](DEVELOPMENT.md) §4 来源 A。 + +**公开公共仓(可 clone 学习,可扩展登记):** [`SHARED_REPOS.md`](SHARED_REPOS.md) — 当前含 skill-template、account-manager、jiangchang-platform-kit。 + +## 深度规范阅读顺序 + +开始定制 skill 前,建议按以下顺序阅读: + +1. [`PRODUCT_SURFACES.md`](PRODUCT_SURFACES.md) — **宿主产品入口**:对话 / 数据管理 / 定时任务平级;何时走哪个;入口定位表 +1b. [`TUTORIAL_SURFACES.md`](TUTORIAL_SURFACES.md) — **用户说明 / 教程契约**:README≠TUTORIAL;三入口结构;skill-detail 不可作主路径 +2. [`AI_RPA.md`](AI_RPA.md) — **AI Agent + RPA 总纲**:职责边界、合规红线、风控、操作节奏与完整闭环 +3. [`ACCOUNT_MANAGER.md`](ACCOUNT_MANAGER.md) — **浏览器账号标准**:一账号一 Profile、租约、多账号、数据查看、验收与排障 +4. [`SHARED_REPOS.md`](SHARED_REPOS.md) — **公共仓库目录**(模板 / 账号管理 / platform-kit 等,可扩展) +5. [`REQUIREMENTS.md`](REQUIREMENTS.md) — 需求文档模板与验收标准(含入口定位表) +6. [`NAMING.md`](NAMING.md) — slug / 仓库名命名规范(复制模板前必读) +7. [`DEVELOPMENT.md`](DEVELOPMENT.md) — 完整开发步骤与目录规范 +8. [`TESTING.md`](TESTING.md) — 测试分层、隔离数据根与档位开关 +9. [`LOGGING.md`](LOGGING.md) — 日志分层、必打节点、敏感信息红线(**涉及长任务、RPA、外部系统时必读**) +10. [`ADAPTER.md`](ADAPTER.md) — 涉及外部系统对接时 +11. [`RPA.md`](RPA.md) — 涉及浏览器 / 桌面 / 手机自动化时的会话、风控和工程总纲 +12. [`RPA_ELEMENT_LOCATING.md`](RPA_ELEMENT_LOCATING.md) — **所有界面技能必读**:真实拾取、element map、唯一性、降级和 AI 编程约束 +13. 按执行端选读:[`RPA_BROWSER.md`](RPA_BROWSER.md) / [`RPA_DESKTOP.md`](RPA_DESKTOP.md) / [`RPA_MOBILE.md`](RPA_MOBILE.md) +14. [`CONFIG.md`](CONFIG.md) — `.env` 规范与 bootstrap 机制 +15. [`DATA_PATHS.md`](DATA_PATHS.md) — 下载/导入/导出等本地文件路径标准(涉及文件读写时必读) +16. [`SKILL_ACTION_RUNTIME.md`](SKILL_ACTION_RUNTIME.md) — Skill Action sync/async、任务中心、Agent 禁令、队列 pick(涉及 RPA / 长任务 / 数据管理按钮 / Cron / Agent 时必读) +17. [`RUNTIME.md`](RUNTIME.md) — 共享 runtime、数据路径、发布打包与编码约定 +18. [`CHECKLIST.md`](CHECKLIST.md) — **交活验收清单(第三方视角)**;报告输出到 [`../acceptance-reports/`](../acceptance-reports/) + +Agent 调用契约见 [`../references/CLI.md`](../references/CLI.md)、[`../references/SCHEMA.md`](../references/SCHEMA.md)、[`../references/ACTIONS.md`](../references/ACTIONS.md)。 +用户市场四 Tab 见根目录 [`README.md`](../README.md) / [`TUTORIAL.md`](../TUTORIAL.md) / [`DEMO.md`](../DEMO.md) / [`CHANGELOG.md`](../CHANGELOG.md),不要写进本目录。 diff --git a/development/REQUIREMENTS.md b/development/REQUIREMENTS.md new file mode 100644 index 0000000..bd53aa1 --- /dev/null +++ b/development/REQUIREMENTS.md @@ -0,0 +1,64 @@ +# REQUIREMENTS —— 结题报告初稿助手(draft-closing-report-default) + +> 需求来源:《核心建设内容:各部门"Skill"深度需求规格书》§4.1 科研处 Skill 002(结题报告"一键初稿生成")。 +> 详细设计见工作区《科研处Skill002-结题报告一键初稿生成-开发文档.md》。 +> 定位原则:辅助非决策、审慎非激进 —— 数据章节模板生成零差错;叙事只给素材要点,严禁编造。 + +## 0. 技能标识 + +- slug:`draft-closing-report-default`(draft ∈ 动词白名单;4 段 28 字符) +- 中文 name:结题报告初稿助手 +- account-manager platform_key:不适用;网页 RPA 登录策略:`not_needed` +- 特有依赖:requirements.txt 声明 `python-docx>=1.1.0,<2` + +## 0.1 宿主入口定位 + +| 主能力(Action id / CLI) | 对话 | 数据管理 | 定时 | 教程章节 | 不挂理由 | +|---------------------------|------|----------|------|----------|----------| +| `draft-report`(sync) | 挂 | toolbar(bind closing_projects,表单) | 不挂 | 入口一/入口二 | 生成由人发起并须本人核对 | +| `draft-batch-pending`(async) | 挂 | 不挂 | 不挂 | 入口二 | 批量初稿仍须本人逐份核对 | +| `add-project` / `add-achievement` / `add-expense` | 不挂 | toolbar(对应表,表单) | 不挂 | 入口一 | 结构化录入 | +| `import-data`(import,三类 JSON) | 挂 | 不挂 | 不挂 | 入口二 | 批量 JSON 走对话 | +| `list-data` / `list-drafts` / `stats` | 挂 | 仅展示表 | 不挂 | 入口二 | 查询类 | +| `render-html` | 挂 | toolbar(bind report_drafts)+ skill-detail | 不挂 | 入口一/入口二 | 预览重建 | +| `health` / `version` / `config-path` | 不挂 | 不挂 | 不挂 | N/A | 运维诊断 | + +整技能无 cron placement:"到期前提醒"依赖宿主通知能力属扩展;`TUTORIAL.md` 入口三已写明。 + +## 0.2 AI + RPA 协作与授权边界 + +- 本技能**无 RPA**;不连接科研/财务系统;台账由用户录入或导入。 +- 叙事正文的 LLM 起草由宿主 Agent 承担(硬规则:只重组给定素材、逐句可溯源);技能内核不调用大模型 API。 +- 禁止路线(全技能适用):破解/逆向/私有协议等,本技能不涉及。 + +## 0.3 业务行生命周期 + +不适用:初稿为同步生成 + 本地文件写出,无处理队列。 + +## 4. 功能范围(必须实现) + +- 基座命令(`run` 为模板演示占位) +- `add-project` / `add-achievement` / `add-expense` / `import` / `list` +- `draft-report`(主命令)/ `draft-batch-pending`(async)/ `list-drafts` / `show-draft` / `render-html` / `stats` +- 业务表 4 张 + 展示元数据;`assets/actions.json` 按上表声明 + +可后续扩展:科研/财务系统直连、结题提醒定时、多机构模板、图表嵌入、叙事全自动。 + +## 6. 输入输出 + +- 输入:结题项目(含 expected_json 预期指标)、成果(type/title/venue_or_no/ach_date/is_representative)、决算(category/amount/occurred_date/description)、年度报告摘句 +- 输出:JSON(`ok/batch_no/draft_id/project_no/draft_file/preview_file/completion`);`ERROR:PROJECT_NOT_FOUND|PROJECT_INVALID|DRAFT_NOT_FOUND|QUERY_FAILED|ENTITLEMENT_DENIED` +- 文件:`output/结题报告初稿-{project_no}.docx`、`html/draft-{id}.html`、`html/draft-preview.html` + +## 9. 验收标准 + +- slug/版本三处一致(1.0.1);套件全绿(含 python-docx 环境) +- 金标准覆盖 C1 边界(超额/持平/未达标/待补)、C3 占比合计、C4 来源标注、C5 章节顺序、W1 占位、docx 内容断言 +- 三场景真实跑通:全量数据、缺年度报告(叙事占位)、缺决算(决算占位);批量模式全流程 +- `actions.json` 与定位表、TUTORIAL 一致;`developer_ids`=79017;发布链路 success;宿主多入口验收(含 Word 下载打开) + +## 11. 变更记录 + +| 日期 | 版本 | 变更人 | 内容 | +|------|------|--------|------| +| 2026-09-13 | v1.0 | 开发组 | 初版 | diff --git a/development/RPA.md b/development/RPA.md new file mode 100644 index 0000000..261b58e --- /dev/null +++ b/development/RPA.md @@ -0,0 +1,444 @@ +# RPA 操作标准(浏览器 / 桌面 / 手机) + +> 本文是团队 RPA 开发的**统一标准**。任何需要"自动操作软件界面"的 skill,都应先读这份文档,按这里的选型和范式落地,不要每个项目重新踩坑。 + +先读 [`AI_RPA.md`](AI_RPA.md):Agent 负责理解、确认与编排,RPA 只负责授权范围内的确定性界面执行。RPA 是代替人完成正常业务操作的工具,**不是**破解、逆向、私有协议调用或对抗第三方安全机制的手段。 + +网页账号 / Profile 依赖公开兄弟技能 **account-manager**;浏览器 RPA 原语来自 **jiangchang-platform-kit**。账号设计、查看、租约、多账号和验收标准先读 [`ACCOUNT_MANAGER.md`](ACCOUNT_MANAGER.md);二者 Gitea 地址、clone 方式与红线见 [`SHARED_REPOS.md`](SHARED_REPOS.md)。 + +我们开发的各类 skill,本质上都是在替人操作三类界面:**浏览器、桌面软件、手机软件**。三类的底层技术不同,但**工程范式相同**:可控会话(Profile)、有头运行、拟人操作、失败存证、人工兜底。 + +**开始写界面操作代码前,必须先读 [`RPA_ELEMENT_LOCATING.md`](RPA_ELEMENT_LOCATING.md),完成真实拾取、元素清单、唯一性和后置状态设计。** 分端实操: + +- 浏览器:[`RPA_BROWSER.md`](RPA_BROWSER.md) +- Windows 桌面:[`RPA_DESKTOP.md`](RPA_DESKTOP.md) +- Android / iOS:[`RPA_MOBILE.md`](RPA_MOBILE.md) + +--- + +## 0. 三端通用范式(先看这个) + +无论操作哪类界面,都遵循同一套约定: + +| 约定 | 说明 | +|------|------| +| **可控会话 / Profile** | 网页版见下方 **§0.2**(必须走 account-manager 的 `profile_dir`);桌面/手机用对应持久会话,不硬编码密码 | +| **有头运行** | 生产 RPA **必须有头**(见 §0.2);禁止把无头当交付/联调常态 | +| **正常人工节奏** | 真实点击、逐字输入、自然鼠标轨迹、分段滚动;严禁 JS 直接设值/JS 点击/JS 跳转绕过页面流程 | +| **步骤间随机等待** | 每两个正常业务动作之间 `random_delay(min,max)`,真实环境默认 **1~3 秒**;状态等待另算,不得用固定睡眠代替 | +| **人工兜底(HITL)** | 滑块 / 短信验证码 / 人脸 / U盾 / 动态口令 → **停下来轮询等人工**,超时报 `ERROR:XXX_NEED_HUMAN`,绝不自动硬闯 | +| **失败存证** | 失败必截图,合规场景全程录屏,统一存 `{数据目录}/rpa-artifacts/{batch_id}/{tag}_{ts}.png` | +| **选择器纪律** | 先真实拾取并填写 element map;语义/可访问性属性优先;关键动作唯一匹配;**严禁凭记忆猜 selector 或用 first/index 掩盖歧义** | +| **统一错误码** | `ERROR:REQUIRE_LOGIN` / `ERROR:CAPTCHA_NEED_HUMAN` / `ERROR:RATE_LIMITED` / `ERROR:LOGIN_TIMEOUT` 等,见下方错误码表 | +| **步骤可控制** | 每条 RPA 指令 = 一个 `@rpa_step` 或 `video.add_step`;等待用 `interruptible_sleep`;宿主通过 control.json 暂停/继续/停止(SRCP,见 [`LOGGING.md`](LOGGING.md) §2.5) | +| **幂等 / 断点续跑** | 批量操作记录"已处理到第几条",崩溃后能续跑、不重复提交 | + +> 三端各自实现一个会话抽象 `RpaSession`(launch / login / act / screenshot / close),上层 skill 不感知是浏览器还是手机。 + +### 0.0 合规红线 + +- 只使用用户或企业合法拥有、明确授权的账号、数据、设备和业务权限。 +- 优先正常界面或获得授权的官方 API;只有破解、逆向或绕过安全机制才能实现时,停止并上报。 +- 禁止反编译、私有协议、抓包拼装隐藏接口、Hook / 注入、客户端篡改、验证码破解、设备指纹伪造、撞库、养号、自动换号或代理池规避限制。 +- 验证码、人脸、指纹、短信、U盾、动态口令必须交给人工;权限不足、账号风险、频控或封禁必须安全停止。 +- 合规与完整职责边界以 [`AI_RPA.md`](AI_RPA.md) 为准。 + +### 0.1 步骤控制与进度(SRCP,platform-kit >= 1.2.0) + +- **禁止** `scripts/util/progress.py` 或 `print({"type":"progress",...})`;进度只走 `emit` / Run Journal。 +- **禁止** RPA 主路径裸 `asyncio.sleep`;用 `jiangchang_skill_core.activity.interruptible_sleep`。 +- RPA 函数用 `@rpa_step("中文步骤名")` 拆分;或在 `RpaVideoSession` 内 `video.add_step("中文步骤")`。 +- `cmd_run` 须 `with job_context(skill=SKILL_SLUG):`,所有出口 `finish(...)`。 +- 暂停在步骤边界生效;浏览器会话暂停时不强制关闭,恢复后同进程继续。 + +```python +from jiangchang_skill_core.activity import emit, finish, job_context, interruptible_sleep, rpa_step + +@rpa_step("打开登录页") +async def open_login(page): + await page.goto("https://example.com") + await interruptible_sleep(1.5) +``` + +规范权威定义:[`LOGGING.md`](LOGGING.md) §2.5。金样代码:[`scripts/service/task_service.py`](../scripts/service/task_service.py)。 + +### 0.2 网页版 RPA 硬规则(Profile / 有头 / 登录分平台) + +凡**打开网页、用浏览器自动化**的技能(真实站或仿真站),必须遵守(`POLICY-RPA-004`): + +| 规则 | 要求 | +|------|------| +| **Profile 强制** | 启动浏览器前必须已从 **account-manager**(经本技能 `account_client`)拿到可用的 **`profile_dir`(及租约约定)**。只用该目录做 `launch_persistent_context`。 | +| **首启用 ensure** | 业务技能默认走 **`account ensure-web`**(或 `pick-web --ensure`),经 `account_client.pick_web_account` 封装。无匹配账号时自动 `add-web` 再返回;**有账号但均被租约占用 → `ERROR:LEASE_CONFLICT`,禁止再新建重复账号**。 | +| **拿不到就停** | 未拿到 `profile_dir`、ensure 失败、租约失败 → **立即失败退出,禁止开浏览器**。不得静默落到系统默认 Chrome/Edge 用户目录。 | +| **有头强制** | 生产与用户侧联调:**`OPENCLAW_BROWSER_HEADLESS=0`(有头)**。禁止把无头(`=1`)当作交付或真实站联调常态(无头易风控、难人工介入,且易污染身份)。 | +| **登录不全局强制** | **是否要在目标站完成登录**由平台决定(如部分公开页可不登录也能闭环)。由做该技能的同事在 `REQUIREMENTS.md` / 用户教程中约定;**模板不强制「所有网页 RPA 必须先登录」**。 | +| **登记 ≠ 登录** | `ensure-web` / `add-web` 只保证有账号记录与 Profile 目录;**不等于**目标站已登录。站点登录仍由本技能 `ensure_logged_in`(或等价)在有头浏览器中完成。 | +| **登录 ≠ Profile** | 「可不登录」**绝不等于**「可以不接 account-manager」。无论目标站要不要登录,网页操作都依赖账号管理下发的 **Profile**。 | +| **登录策略 = required 时** | 每轮业务主路径在 `goto` 后必须过登录门禁 + 验证码/人工等待(`HUMAN_WAIT_TIMEOUT`);**禁止**把会话依赖型采集默认抄成 `optional`。 | +| **账号必须可见可查** | 创建后须能在宿主数据管理、`account list/get` 和实际 `profile_dir` 中核验;不得把“命令没报错”当作创建与登录验收。 | +| **一账号一 Profile** | 不同账号必须使用不同且稳定的 `profile_dir`;不得共用、临时创建或指向本机日常浏览器目录。 | +| **租约贯穿任务** | 所有账号选择路径(包括指定数字 ID)都必须取得非空 `lease_token`;成功、失败、取消和异常均在 `finally` 释放。 | + +**反模式(禁止):** + +- 未拿到 `profile_dir` 仍 `launch` / 仍打开页面 +- 用系统默认用户数据目录或随意空目录反复跑真实站 → **污染本机 Profile、加重风控** +- 把「本站可不登录」写成「可以不走 account-manager」 +- 真实站联调默认无头狂跑 +- 仅用哈希 class / 不稳定 CSS 作为**唯一**登录态判定(须结合稳定文案、`get_by_role`、URL/业务 DOM;F12 实页确认) +- 把「业务空结果」(如无评论)与 `LEASE_CONFLICT` / `REQUIRE_LOGIN` 混成同一错误码 + +**参考实现:** `examples/real_browser_rpa/`、`examples/simulator_browser_rpa/`(`pick_web_account` → ensure-web → RPA → `finally release_lease`)。 + +账号完整生命周期、数据目录、数据管理查看方式、矩阵场景和排障证据见 [`ACCOUNT_MANAGER.md`](ACCOUNT_MANAGER.md)。不得只测试单账号一两次就宣称支持批量或多账号。 + +--- + +### 0.3 工业级风控、等待与收尾 + +**状态等待优先:**页面加载、控件可用、弹窗消失、保存成功、下载完成和业务结果出现,必须等待真实条件并设置超时;禁止用固定 `sleep(3)` 假设成功。 + +**有界就绪等待(platform-kit ≥ 1.2.11 必用):** + +- 业务技能的状态等待统一使用共享 API:`jiangchang_skill_core.rpa.poll_until`(async)或 `poll_until_sync`。 +- **禁止**在 service 主路径再发明平行的 `deadline + sleep` / 私有 `_wait_*` 轮询作为标准答案(HITL 专用等待除外,仍可用 kit 的 `wait_for_login` / `wait_for_captcha_pass`)。 +- 必须区分:**还在加载**(超时内继续等)vs **真的不是目标页/目标态**(有界等待失败后再判)。 +- 超时默认映射 `ERROR:UI_NOT_READY`(或业务等价就绪码);**不要**把「慢」直接判成 `PAGE_TYPE_MISMATCH`、配置门禁失败或 `ERROR:RATE_LIMITED`。 +- 超时秒数须允许环境变量 / `.env` 覆盖(慢机器、限频、轻量电源场景可调大)。 +- 可选浏览器启动超时:`launch_persistent_browser(..., timeout_ms=...)` 或 `OPENCLAW_BROWSER_LAUNCH_TIMEOUT_MS`;超时码 `ERROR:BROWSER_START_TIMEOUT`。默认不开启,与历史行为兼容。 +- `health` 可带只读 `machine_power`(供电/限频提示);**不得**仅凭该信号 hard-fail 全技能。 + +**操作节奏:** + +- 两个正常业务动作之间默认随机等待 `1~3` 秒。 +- 输入采用逐字符随机延迟;鼠标使用自然轨迹;滚动采用分段操作。 +- 同一账号、Profile、桌面会话或手机设备默认串行。 +- 单批数量、单位时间操作量和运行时长必须有上限。 +- 只对明确可重试错误做有限重试;普通网络错误可递增退避,风险提示和权限错误不得自动重试。 + +**前后缓冲:** + +- 启动浏览器、桌面软件或手机会话后,先等待目标页面 / 窗口 / 控件真实就绪,再额外缓冲约 `1.5~2.5` 秒。 +- 页面导航后,关键控件就绪再缓冲约 `1~2` 秒。 +- 流程结束时,先确认业务结果、写日志并完成存证,再缓冲约 `2~3` 秒关闭会话。 +- 付款、审批、发布等重要动作确认结果并截图后,可缓冲约 `3~5` 秒。 + +缓冲用于稳定界面和改善用户体验,**不能作为成功判断**。RPA 主路径使用 `interruptible_sleep` 等可控制等待,确保暂停与停止能够生效。 + +**风险信号:** + +| 信号 | 标准处理 | +|------|----------| +| 验证码 / 人脸 / 指纹 / U盾 | 进入人工处理 | +| 频率限制 | 停止当前批次或长时间退避 | +| 账号风险 / 封禁 / 安全警告 | 立即停止,禁止换号继续 | +| 权限不足 | 明确失败,不尝试绕过 | +| 页面 / 控件结构变化 | 截图并停止,重新人工验证定位 | +| 提交结果不明确 | 查询业务状态,确认前禁止再次提交 | + +--- + +## 1. 浏览器(标准已成熟) + +**选型:Playwright + 系统 Chrome/Edge。** 这是团队验证最充分的一条线(标杆技能 `1688-scrape-contacts`、`receive-order` 已落地)。 + +元素拾取、定位优先级、iframe/弹窗/Shadow DOM、动态列表和唯一性标准见 [`RPA_BROWSER.md`](RPA_BROWSER.md)。本节继续定义会话、启动、HITL 和工程边界。 + +### 1.0 生产路径(优先) + +| 项 | 标准 | +|----|------| +| 浏览器 | **优先系统 Chrome/Edge** + `launch_persistent_context`,`channel="chrome"` 或 Edge;不用内置 Chromium,**技能内不要 `playwright install`** | +| Profile / 账号 | **必须**经 account-manager **`ensure-web`**(或 `pick-web --ensure`)取得 `profile_dir`(见 §0.2);密码不进 `.env`;**是否站点登录**按平台在需求/教程约定;登记 ≠ 已登录 | +| CDP | **仅作诊断 / 桌面宿主类场景**;**不要**作为强风控站点的默认生产路径 | +| 行为 | 模拟真实用户:真实点击、键盘、鼠标、地址栏输入;**不要**拼接搜索结果 URL、DOM 注入、`el.value=`、JS 跳转 | +| 模式 | **有头** `OPENCLAW_BROWSER_HEADLESS=0`(生产强制,见 §0.2) | +| 浏览器兼容配置 | `OPENCLAW_PLAYWRIGHT_STEALTH` 仅用于经评估的兼容配置;不得用于伪造身份或绕过平台限制 | + +### 1.1 Playwright 启动标准 + +1. **有头**:`OPENCLAW_BROWSER_HEADLESS=0`(`.env.example` 默认;网页 RPA 生产路径禁止改成无头常态,见 §0.2)。 +2. **stealth 须受控**:`OPENCLAW_PLAYWRIGHT_STEALTH` 仅在目标平台授权、规则与项目评估允许时启用;不得把它理解为规避风控或伪造设备身份。 +3. **不要在技能里自行安装 playwright**;由宿主共享 runtime 提供。 +4. **不要默认传 `--no-sandbox`**(除非特定容器环境且已评估风险)。 +5. **不要默认传 `--disable-blink-features=AutomationControlled`**;platform-kit stealth 已覆盖,额外 flag 可能适得其反。 +6. **可以** `ignore_default_args=["--enable-automation"]`(platform-kit `launch_persistent_browser` 已处理)。 +7. **强风控平台**:优先真实点击、键盘、鼠标、地址栏、account-manager 持久 profile;**不要**直接拼接搜索结果 URL 或 DOM 注入。 +8. **先 Profile 再开浏览器**:`user_data_dir` 必须来自 ensure/pick 到的 `profile_dir`;未拿到则不得进入本节后续步骤。 + +浏览器兼容配置由共享 `jiangchang_skill_core.rpa` 提供(platform-kit **>= 1.2.0**)。业务技能不得自行扩展指纹伪造、代理轮换或规避安全检测的代码;遇到明确风控信号时转人工或停止。 + +**拟人操作**(必做): + +- 输入:逐字符 `keyboard.type(delay=90~240ms)`,**先真实点击聚焦再输入,绝不 `el.value=`**。 +- 鼠标:贝塞尔曲线轨迹 + 微抖动;进场随机晃动。 +- 导航:用真实点击触发,**不要 `window.location.href=` / JS 点击跳转**。 +- 翻页:真实点击翻页控件,注意排除禁用态。 +- 延迟:两个正常业务动作之间 `random_delay`(`.env` 中 `STEP_DELAY_MIN/MAX`,默认 1~3 秒);页面状态仍须显式等待。 + +### 1.2 页面启动标准 + +**`launch_persistent_context` 的 `args` 只能放 Chrome 启动参数**(例如 `--start-maximized`、`--disable-blink-features=AutomationControlled` 等)。 + +**绝对不要把 `https://...` 这类 URL 放进 `args`。** 否则 Playwright 会报错: + +``` +BrowserType.launch_persistent_context: Arguments can not specify page to be opened +``` + +**正确做法(生产路径):** + +1. `launch_persistent_context(user_data_dir=profile_dir, executable_path=chrome, args=chrome_args, ...)` +2. `page = await context.new_page()` +3. `await page.goto(start_url, wait_until="domcontentloaded", timeout=60000)` + +对强风控站点,如果需要更拟人,可以启动后通过**地址栏输入 / 真实点击进入**目标页,但**仍然不能把 URL 塞进 launch args**。 + +完整参考实现见: + +- `examples/real_browser_rpa/README.md`(**开发真实浏览器 RPA 类 skill 时必须先读**) +- `examples/real_browser_rpa/scripts/service/browser_session.py` + +**生产路径不要依赖 CDP 接管现有页面**;CDP 仅用于诊断或桌面宿主已打开浏览器的场景。 + +### 1.3 HITL / 验证码 + +- 自动处理失败时**允许等待人工**:滑块、短信、人脸、U盾、动态口令等。 +- 等待人工验证时必须有:**字幕 step**(用户可见动作说明)、**结构化日志**、**超时**(`HUMAN_WAIT_TIMEOUT`)。 +- 检测到风控页(URL/DOM 特征)→ 抛 `ERROR:CAPTCHA_NEED_HUMAN`,轮询等待或超时,**不要强行绕过平台安全机制**。 +- **不要自动操作滑块**。 + +> 共享库:`jiangchang_skill_core.rpa.wait_for_captcha_pass`;**真实浏览器 RPA 成功案例**见 `examples/real_browser_rpa/`: +> +> - 先读 `examples/real_browser_rpa/README.md` +> - 再参考 `examples/real_browser_rpa/scripts/service/human_verification.py` +> - 再参考 `examples/real_browser_rpa/scripts/service/task_rpa.py` + +### 1.4 Playwright 与安装边界 + +- Playwright 是宿主共享 runtime 提供的通用 Python 包能力;**正式 skill 不负责安装**。 +- skill 侧**禁止**在 `requirements.txt`、安装脚本、运行脚本、根 `README.md` 用户说明中加入 `playwright install`、`pip install playwright` 或任何自动安装浏览器驱动的逻辑。 +- 真实浏览器 RPA 默认使用**系统 Chrome/Edge**,由宿主/runtime 管理。 +- **不要**执行 `playwright install chromium`、`playwright install chrome` 等浏览器驱动安装命令。 +- 仅当**本地独立开发环境排障**时,开发者可手工检查或补装 Python 包 `playwright`;该操作**不得**进入 skill 交付文件与自动流程。 + +页面启动仍遵循 1.2:`launch_persistent_context` 的 `args` 只能放 Chrome 启动参数,**不能把 URL 塞进 `args`**;打开页面必须通过 `new_page()` + `goto()`,或通过真实地址栏/点击进入。 + +### 引用方式 + +共享实现位于宿主共享 runtime 安装的 `jiangchang-platform-kit`(`jiangchang_skill_core.rpa`)。复制后的业务技能直接 import,**技能仓库不得保留 rpa 公共代码副本**: + +```python +from jiangchang_skill_core.rpa import ( + launch_persistent_browser, + anti_detect, + wait_for_captcha_pass, + capture_failure, + errors, +) +from jiangchang_skill_core.rpa.stealth import stealth_enabled, STEALTH_INIT_SCRIPT +``` + +- `RpaVideoSession` 来自 platform-kit **>= 1.2.0**;ffmpeg、背景音乐、media-assets 由 platform-kit 统一解析;已提供前置/后置缓冲、字幕、TTS 旁白、背景音乐循环、结尾淡出。 +- `health` 对上述资源做只读诊断,不下载、不修复。 + +### 1.5 真实浏览器 RPA 示例(必读) + +开发**真实网站 + 浏览器操作 + 登录态/验证码/滚动采集**类 skill 时: + +1. **必须先阅读** `examples/real_browser_rpa/README.md` +2. 再参考以下代码: + - `examples/real_browser_rpa/scripts/service/browser_session.py` — persistent context 启动 + - `examples/real_browser_rpa/scripts/service/human_verification.py` — 人工验证等待 + - `examples/real_browser_rpa/scripts/service/task_rpa.py` — RPA 主流程 + - `examples/real_browser_rpa/scripts/service/account_client.py` — account-manager CLI 封装 + +仿真浏览器 RPA 的 adapter 分层权威参考见 `examples/simulator_browser_rpa/`(含 async `simulator_playwright.py`、薄 `simulator_rpa` adapter、`account_client.py` subprocess),**不能**替代真实浏览器 RPA 规范。 + +### 1.6 仿真浏览器 RPA 示例(必读) + +开发**自有仿真页面 / sandbox / 可控 DOM 的浏览器 RPA**(表单填写、批量提交、弹窗确认)时: + +1. **必须先阅读** `examples/simulator_browser_rpa/README.md` +2. 再参考: + - `examples/simulator_browser_rpa/scripts/service/browser_session.py` — async persistent context(**不含 goto**) + - `examples/simulator_browser_rpa/scripts/service/simulator_playwright.py` — RPA 主流程(厚) + - `examples/simulator_browser_rpa/scripts/service/adapter/`(薄 `simulator_rpa` + mock + dispatch) + - `examples/simulator_browser_rpa/scripts/service/account_client.py` — account-manager subprocess + - `examples/simulator_browser_rpa/scripts/service/task_service.py` — async 编排 + - `examples/simulator_browser_rpa/sandbox/demo_app.html` + +> **先选类型再写代码**:真实第三方网站 → `real_browser_rpa`;自有 sandbox / 行业仿真 → `simulator_browser_rpa`。不要跨类型照抄 selector 或业务流程。 + +### 1.7 行业仿真平台浏览器 RPA(jc2009 类) + +适用:**自有或共享 `sandbox.jc2009.com` 等行业仿真平台**,不是真实高风控第三方站。 + +工程范式与 §1.5 相同: + +| 项 | 标准 | +|----|------| +| Playwright | **async** 贯穿;禁止 sync Playwright 用于完整 RPA 主路径 | +| 分层 | **薄 adapter** + `{domain}_playwright.py`(示例:`simulator_playwright.py`)+ `account_client.py` subprocess | +| 登录 | **双层**:门户 HITL(`#portal-user` / `#portal-pass` 类泛化 DOM)+ 业务系统登录(技能内自写) | +| 账号 | `url` 用行业根,不用 `/login`;`auth_strategy=per_session_manual`;`pick_web_account`(底层 ensure-web)+ `release_lease` | +| 禁止 | **不要** `import account-manager` 的 `rpa_helpers` 等内部模块 | +| Selector | 用户可见文案 / `get_by_role` / `name` 优先;`data-testid` 有则用、无则 fallback;共享 sandbox **不要求**为技能加 testid | +| Profile | Chrome persistent profile 可能缓存旧 SPA → 联调排障:手工 `--user-data-dir` 清站点数据 | + +权威 example:**修正后的** `examples/simulator_browser_rpa/`(不是任何生产技能仓库)。 + +--- + +## 2. 桌面软件(Windows 原生程序) + +**选型:Windows UI Automation(UIA)为主 + 图像识别兜底。** 当前已验证成功案例 `add-friend-wecom` 使用 Python `uiautomation`;`pywinauto(backend="uia")` 是兼容方案。完整拾取、定位和降级标准见 [`RPA_DESKTOP.md`](RPA_DESKTOP.md)。 + +桌面端常见于 ERP 客户端、网银控件、银企直连等本地程序。优先走可访问性树(控件 ID/名字),坐标点击只做最后兜底。 + +| 技术 | 优先级 | 适用 / 说明 | +|------|--------|------| +| **uiautomation** | ✅ 已验证首选 | `add-friend-wecom` 真实成功案例使用;直接读取 UIA Window/Control 树 | +| **pywinauto(`backend="uia"`)** | ✅ 兼容 | 基于微软 UI Automation 树,拿控件 AutomationId/Name/ControlType,**稳定、不依赖屏幕坐标**,纯 Python | +| **FlaUI**(经 pythonnet 调 .NET) | 备选 | UIA 拿不到的复杂/自绘控件时更完整,但需引入 .NET 运行时 | +| **Playwright** | 特例 | 目标是 **Electron 套壳应用**(很多新 SaaS 客户端)时,可当浏览器驱动 | +| **PyAutoGUI / SikuliX(图像识别)** | ⚠️ 兜底 | 控件树完全拿不到时(Flash/远程桌面/纯自绘 UI);**靠截图找图+坐标,分辨率/缩放一变就崩**,仅最后手段 | + +### 桌面端注意事项 + +- **窗口聚焦/置顶**:操作前确保目标窗口前置,避免误操作其它窗口。 +- **DPI/缩放**:图像识别方案必须固定显示缩放比例;UIA 方案不受影响(优先用 UIA 即是为此)。 +- **存证**:同样要失败截图(截目标窗口/全屏),存到 `rpa-artifacts`。 +- **人工兜底**:U盾插拔、动态口令、人脸 → 停下等人工,超时 `ERROR:XXX_NEED_HUMAN`。 +- **禁止侵入**:不注入或 Hook 第三方进程,不修改客户端文件,不反编译或调用未授权私有协议。 + +```bash +pip install uiautomation # 当前真实成功案例 +# 或:pip install pywinauto # UIA backend +# 图像兜底:pip install pyautogui opencv-python +``` + +> 状态:UIA 技术路线已经 `add-friend-wecom` 真实项目验证。脱敏、收紧后的参考 example 见 `examples/real_desktop_rpa/`;目标软件 selector 仍必须重新拾取。 + +--- + +## 3. 手机软件(USB 连接电脑) + +**选型:Android 用 uiautomator2(或 Appium);iOS 用 Appium + WebDriverAgent(需 Mac)。** 底层都是经 USB 的 ADB / WDA。 + +设备准备、Appium Inspector/UIAutoDev 拾取、Native/WebView、定位优先级和失败 XML 留证见 [`RPA_MOBILE.md`](RPA_MOBILE.md)。 + +| 平台 | 技术 | 优先级 | 说明 | +|------|------|--------|------| +| **Android** | **uiautomator2**(python 原生) | ✅ 首选 | ADB over USB,直接拿控件树点击/输入,比 Appium 轻快,纯 Python | +| Android | **Appium**(uiautomator2 driver) | 备选 | 需要跨平台统一接口、或团队已有 Appium 资产时用 | +| Android | **Airtest + Poco**(网易开源) | 兜底 | 图像+控件混合,自带 IDE 可录制;控件树拿不到时用 | +| **iOS** | **Appium + WebDriverAgent(XCUITest)** | 唯一可行 | **必须有一台 Mac 做中转**,Windows host 无法直接驱动 iOS | +| 投屏/人工介入 | **scrcpy** | 辅助 | USB 投屏到电脑,配合人工过验证码/人脸 | + +### 手机端注意事项 + +- **设备就绪检查**:`adb devices` 确认已授权连接;放进 `doctor` 自检。 +- **登录态**:靠 App 自身保持登录,必要时引导人工首登一次。 +- **人工兜底**:短信验证码、人脸、指纹 → scrcpy 投屏让人工完成,程序轮询等待。 +- **存证**:失败时 `adb screencap` / Appium 截图存 `rpa-artifacts`。 +- **设备边界**:使用正常授权设备与官方 App;不 Root / 越狱,不 Hook,不修改 APK / IPA,不伪造设备身份或绕过签名、绑定与安全校验。 + +```bash +# Android(首选) +pip install uiautomator2 +python -m uiautomator2 init # 初始化设备端 agent +# 或统一走 Appium:pip install Appium-Python-Client(另需 Appium Server) +``` + +> 状态:手机端标准已定义,但**尚无团队真实成功案例**。`examples/mobile_rpa/` 仅是 example 完成规格,不能作为已跑通代码照抄;收到成功案例后再升级状态。 + +--- + +## 4. 统一错误码(RPA 场景) + +skill 退出/抛错统一用 `ERROR:` 前缀 + 稳定码,方便宿主与上层判断与重试: + +| 错误码 | 含义 | 上层处理建议 | +|--------|------|------| +| `ERROR:REQUIRE_LOGIN` | 未登录 / 登录态失效 | 触发登录流程(有头等待人工) | +| `ERROR:LOGIN_TIMEOUT` | 等待人工登录超时 | 提示用户重跑并及时操作 | +| `ERROR:CAPTCHA_NEED_HUMAN` | 命中滑块/验证码拦截 | 暂停等人工,或转人工队列 | +| `ERROR:LEASE_CONFLICT` | 已有账号但均被租约占用(ensure 不新建) | 稍后重试或释放租约;**不是**「无账号」 | +| `ERROR:AMBIGUOUS_ACCOUNT` | `--login-id` / `--label` 匹配到多条 | 改用数字 id 或更精确标识 | +| `ERROR:RATE_LIMITED` | 触发频控 | 退避后重试 | +| `ERROR:MISSING_BROWSER` | 未检测到 Chrome/Edge | 提示安装 | +| `ERROR:BROWSER_START_TIMEOUT` | 浏览器启动超时(可选 timeout) | 可重试;检查机器负载/供电;可调大 `OPENCLAW_BROWSER_LAUNCH_TIMEOUT_MS` | +| `ERROR:UI_NOT_READY` | 有界等待内页面/控件未就绪 | **默认可重试**;勿与「页类型错误/配置未开」混用;勿轻易标 session fatal | +| `ERROR:ENV_DEGRADED` | 环境降级提示类(限频/供电不足等,技能选用) | 可重试;提示接电源/退出省电/减小并发;**不是**配置错误 | +| `ERROR:DEVICE_NOT_READY` | 手机未连接/未授权 | 检查 USB/ADB | +| `ERROR:WINDOW_NOT_FOUND` | 桌面目标窗口未找到 | 检查程序是否启动 | + +业务空结果(如「本页无评论」)用业务码或成功空列表表达,**不要**复用上表账号/登录错误码。 + +**归因纪律(防误诊):** + +- 「未开 `ALLOW_REAL_RPA` / 档位门禁」必须用技能自己的配置类错误码,**禁止**与真实启动失败、启动超时共用一个 hint。 +- 页面慢导致的超时优先 `UI_NOT_READY` / 业务就绪码;仅当有明确频控 DOM/文案时才用 `RATE_LIMITED`。 +- 是否列入技能 `SESSION_FATAL` / `RETRYABLE` 由技能自定,但 **UI 未就绪 / 启动超时默认不应整批不可重试终止**(除非会话已明确不可恢复,如未登录、验证码人工超时)。 + +--- + +## 5. 存证与录屏规范 + +### 5.1 截图存证 + +- **路径**:`{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/{slug}/rpa-artifacts/{batch_id}/{tag}_{timestamp}.png` +- **失败必截图**:受 `OPENCLAW_ARTIFACTS_ON_FAILURE`(默认开)控制。 +- **Playwright 不负责录屏**,仅浏览器自动化。 +- **常见 tag**:`before_submit` / `after_submit` / `captcha` / `login_fail` / `error`。 + +### 5.2 RPA 视频 step 标准 + +字幕是**用户可见动作说明**,不是技术日志。step 要贴近真实动作,不要只在大流程入口打点。 + +推荐关键动作示例(按业务裁剪): + +- 启动浏览器 +- 打开首页 +- 检查登录状态 +- 定位输入框 +- 输入关键词:xxx +- 点击搜索 +- 等待结果 +- 打开详情页 +- 提取信息 +- 写入结果 +- 任务完成 + +技术诊断、重复跳过、DB 写入可以显示但**通常不需要旁白**。 + +`title` / `closing_title` 必须由 skill 传入**中文业务文案**(如「开始执行示例任务」「示例任务执行完成」)。 + +### 5.3 录屏成片标准 + +- **skill-template 默认 `OPENCLAW_RECORD_VIDEO=0`**(开发调试更快,不启 ffmpeg)。 +- **`OPENCLAW_RECORD_VIDEO=1` 时启用录屏**:字幕、旁白、背景音、MP4 成片由 platform-kit 统一处理。 +- 使用 platform-kit 的 **`RpaVideoSession`**;**skill 不要自行合成视频**(不要自己调 ffmpeg 拼 MP4)。 +- **默认关闭 ≠ 可以删除录屏接入**:RPA / 长任务 **service 层**(`scripts/service/*.py`)**必须保留** `RpaVideoSession`、`video.add_step`、video artifact merge 进 `finish()`(见 `POLICY-RPA-003`)。 +- `OPENCLAW_RECORD_VIDEO=0` 时 session **无副作用**(不启 ffmpeg、不写字幕文件、不生成最终 MP4),但 **`RpaVideoSession.add_step` 仍可同步 Activity 进度**。 +- 生产留证 / 合规 / 客户现场复现时,由用户在 `.env` 或进程环境变量中开启 `OPENCLAW_RECORD_VIDEO=1`。 +- **ffmpeg 是唯一录屏器**(Windows:`gdigrab` + `desktop`)。 +- **最终视频**(`OPENCLAW_RECORD_VIDEO=1` 时):`{skill_data_dir}/videos/{skill_slug}_{yyyyMMdd_HHmmss}_{batch_id}.mp4` +- **中间产物**:`rpa-artifacts/{batch_id}/capture.mp4`、`subtitles/`、`logs/` 等。 +- 任务完成后 CLI / `finish()` 应包含 video 字段(开启录屏时有路径;关闭时仍保留 `video` 结构与诊断占位):`video_path`、`raw_video`、`video_log`、`video_warnings`、`music_path`、`voiceover_path`、`audio_warnings`(见 `scripts/service/task_run_support.py`)。 + +模板最小示范见 `scripts/service/task_service.py` 的 `_run_template_demo()`。 + +--- + +## 相关文档 + +- `RPA_ELEMENT_LOCATING.md` — 三端共用的拾取、element map、唯一性、降级和 AI 编程约束 +- `RPA_BROWSER.md` — 浏览器 Pick Locator、DOM 作用域和定位质量 +- `RPA_DESKTOP.md` — Windows UIA 拾取与已验证桌面路线 +- `RPA_MOBILE.md` — Android/iOS 拾取、context、设备与 XML 留证 +- `ADAPTER.md` — 真实/仿真 × API/RPA 的四档适配器模式 +- `CONFIG.md` — `.env` 配置规范与首次落盘机制 +- `RUNTIME.md` — 运行时目录与环境变量约定 diff --git a/development/RPA_BROWSER.md b/development/RPA_BROWSER.md new file mode 100644 index 0000000..15ede81 --- /dev/null +++ b/development/RPA_BROWSER.md @@ -0,0 +1,123 @@ +# 浏览器 RPA 元素拾取与定位标准 + +> 先读 [`RPA_ELEMENT_LOCATING.md`](RPA_ELEMENT_LOCATING.md)。本文只定义浏览器端的拾取、定位、验证和常见陷阱;账号、Profile、HITL 和录屏仍以 [`RPA.md`](RPA.md) 为准。 + +## 目录 + +1. 拾取工具 +2. 拾取步骤 +3. 定位优先级 +4. 特殊上下文 +5. 验证标准 +6. 禁止模式 +7. example 路由 + +## 1. 拾取工具 + +优先使用 Playwright Inspector / Codegen 的 **Pick Locator**,辅以 Chrome/Edge DevTools: + +- Pick Locator:点选页面元素,获取 role/label/text/testid 等候选定位器。 +- DevTools Elements:确认真实 DOM、属性、父容器、iframe 和 Shadow DOM。 +- Playwright trace/screenshot:复核动作发生时的页面状态。 + +检查器只负责给出候选。复制到生产代码前必须人工确认作用域、唯一性和稳定性。 + +## 2. 拾取步骤 + +1. 使用真实 `profile_dir` 和有头浏览器打开目标页面。 +2. 手工进入发生动作的准确页面、标签页和弹窗状态。 +3. 用 Pick Locator 点选元素,先记录建议 locator。 +4. 在 DevTools 中确认: + - accessible role/name + - label、placeholder、可见文本 + - 稳定 id/name + - `data-testid`(仅目标系统已提供或自有系统可维护时) + - 所在 iframe、dialog、表格行或卡片 +5. 先定位业务容器,再在容器内定位动作元素。 +6. 运行 `count()`;关键动作期望 `1`。 +7. 验证 `visible`、`enabled`、必要时 `editable/checked`。 +8. 执行动作并断言 URL、业务容器、成功提示、单号或状态变化。 +9. 刷新页面、关闭重开浏览器后复测。 + +## 3. 定位优先级 + +从高到低: + +1. `get_by_role(role, name=...)` +2. `get_by_label(...)` +3. 自有系统的稳定 `get_by_test_id(...)` +4. `get_by_placeholder(...)`、`get_by_alt_text(...)`、`get_by_title(...)` +5. 稳定业务文本,优先用于非交互元素或父容器 +6. 稳定 `id` / `name` / 短 CSS +7. 容器 + `filter(has_text=...)` / `has=...` +8. 短 XPath +9. 已验证的顺序定位 + +示例: + +```python +dialog = page.get_by_role("dialog", name="订单确认") +submit = dialog.get_by_role("button", name="确认提交") +if await submit.count() != 1: + raise RuntimeError("ERROR:ELEMENT_AMBIGUOUS submit_button") +await submit.wait_for(state="visible") +await submit.click() +await page.get_by_text("提交成功", exact=True).wait_for(state="visible") +``` + +### 列表与重复按钮 + +不要直接选择全页第一个“添加”: + +```python +row = page.get_by_role("row").filter(has_text="客户A") +button = row.get_by_role("button", name="添加") +``` + +如果业务键不唯一,先停止并要求补充输入,不要用 `.first` 猜。 + +## 4. 特殊上下文 + +### iframe + +先确认元素属于哪个 iframe,再使用 `frame_locator`。不得因为主页面找不到就改用坐标。 + +### 新标签页和弹窗 + +点击前注册 popup/new page 等待;取得新 Page 后再定位。关闭弹窗不等于业务成功。 + +### Shadow DOM + +Playwright CSS locator 默认可穿透 open Shadow DOM;XPath 不穿透。closed Shadow DOM 无法正常读取时,评估目标应用是否提供可访问性属性,不能注入破解。 + +### 动态列表 + +等待列表完成加载后再读取;`locator.all()` 不会等待动态列表稳定。滚动或刷新后重新获取 locator。 + +### Canvas + +先确认是否存在可访问性节点或外层业务控件。完全无 DOM 时才进入局部图像方案,并记录分辨率、缩放和识别区域。 + +## 5. 验证标准 + +- locator 在真实目标页面匹配数量符合元素清单。 +- 不因语言、窗口宽度、列表顺序或一次刷新就指向不同业务对象。 +- 动作前检查可见、可用;动作后等待真实业务状态。 +- 登录态至少结合两个证据:例如稳定用户菜单 + 登录按钮不存在,不能只看单个哈希 class。 +- 真实和仿真站明确区分:仿真可以使用 `fill()` 加速测试;真实生产输入遵守项目拟人节奏。 + +## 6. 禁止模式 + +- 用全页所有 `input` 的尺寸和位置猜搜索框。 +- 用 `.first` 绕过严格模式。 +- 从 DevTools 复制深层 `nth-child` 或绝对 XPath。 +- 只等 URL 含 `search` 就判定结果成功。 +- 页面结构变化后动态生成 selector 并继续提交。 +- 为方便测试给第三方网站注入 testid。 + +## 7. example 路由 + +- 真实网站、登录/HITL/滚动:[`../examples/real_browser_rpa/README.md`](../examples/real_browser_rpa/README.md) +- 自有 sandbox、表单和 adapter:[`../examples/simulator_browser_rpa/README.md`](../examples/simulator_browser_rpa/README.md) + +复制 example 前先完成目标站元素清单;example 中的任何 URL、selector 和业务字段都不得跨平台照抄。 diff --git a/development/RPA_DESKTOP.md b/development/RPA_DESKTOP.md new file mode 100644 index 0000000..c8d1431 --- /dev/null +++ b/development/RPA_DESKTOP.md @@ -0,0 +1,189 @@ +# Windows 桌面 RPA 元素拾取与定位标准 + +> 先读 [`RPA_ELEMENT_LOCATING.md`](RPA_ELEMENT_LOCATING.md)。Windows 桌面首选 **UI Automation(UIA)**。当前已验证成功案例 `add-friend-wecom` 使用 Python `uiautomation`;`pywinauto(backend="uia")` 是兼容方案。标准约束的是 UIA 方法,不强制混用两个库。 + +## 目录 + +1. 已验证基线 +2. 拾取工具和步骤 +3. 定位优先级 +4. 窗口与控件规则 +5. 输入、点击与等待 +6. UIA 不可用时的降级 +7. 诊断和留证 +8. example 路由 + +## 1. 已验证基线 + +真实成功来源:`D:\OpenClaw\client-marketing\add-friend-wecom`(含通讯录 → 新的客户 → 添加 → 搜索 → 发送全链路)。 + +该案例验证了: + +- 通过进程/窗口类名/标题找到企业微信顶层窗口。 +- 主窗激活后**最大化**,避免侧栏折叠导致控件不可见或坐标漂移。 +- 从 HWND 获取 UIA 根控件;在**子业务 pane**(如「企业微信-通讯录」)内缩小作用域。 +- 区分 UIA `Name` 与 `Value`:可见中文常在 Value / 子 Label,不在 Name。 +- 通用叶子名(`item` / `status_layout` / `wbtn_container`)必须绑业务父容器,并用文案校验。 +- 真实点击后校验后置状态(标题、专用容器、子窗口)。 +- 处理独立弹窗与嵌套弹窗。 +- 提供 `probe-ui` 诊断和 `dry-run / assisted / auto` 三档。 + +example 只提炼通用模式;企业微信名称、窗口类、控件名、列表顺序和业务结果不得复制到其他软件。 + +## 2. 拾取工具和步骤 + +可使用影刀元素拾取器、Microsoft Inspect/Accessibility Insights、FlaUInspect 或库自带树打印能力。 + +操作步骤: + +1. 启动目标软件,登录并手工进入准确页面;需要完整侧栏时先**最大化**。 +2. 记录目标进程名、PID、主窗口标题和 Window Class。 +3. 用检查器点选目标元素,记录: + - AutomationId + - **Name**(内部 id,可能是 `org_op_new` / `status_layout`) + - **Value**(可见中文文案常在这里,如「通讯录」「新的客户」) + - ControlType / ClassName / FrameworkId + - IsEnabled / IsOffscreen / BoundingRectangle + - 父容器链(含业务子窗/pane 标题) +4. 保存截图和原始 UIA 树/拾取器导出文件。 +5. 从长路径中找出稳定窗口、业务父容器和叶子属性。 +6. 删除仅反映当前布局的深层 index;确需 index 时写明业务依据,且优先用文案定位。 +7. 写入 element map(Name 与 Value 分开登记)。 +8. 运行 `probe-ui`(建议先激活并最大化),验证关键窗口和元素。 +9. 关闭重开软件、切换一次页面后再次验证。 + +### Name / Value(易错) + +- 影刀/检查器里的**选择器显示名**(如「新的客户」)≠ 控件 UIA `Name`。 +- 许多国产客户端:`Name` 是内部 id,**界面中文在 `Value` 或子控件 Value**。 +- 拾取时两者都记;定位时勿把业务中文当成 `Name=` 去搜。 + +### 从拾取结果提炼定位器 + +影刀等工具可能导出很长的 UIA 路径。不要整条照抄: + +```text +原始:主窗口 → 通讯录 pane → wlist_view → item[2] → status_layout + → … → main_chattext(Value=新的客户) +提炼:通讯录 pane → wlist_view → 行内 main_chattext.Value=新的客户 → 点击该行 status_layout +禁止:全树第一个 Name=status_layout(常为「群聊」等其它行) +``` + +长路径用于排障和理解结构;生产定位器使用最短、唯一、可解释的稳定组合。 + +## 3. 定位优先级 + +从高到低: + +1. `Process/PID + WindowClass + WindowTitle`(及稳定业务子 pane) +2. 目标窗口内 `AutomationId + ControlType` +3. 稳定内部 `Name + ControlType`(如 `org_op_new`、`searchedit`) +4. 业务父容器 + 稳定子元素属性 +5. **可见业务文案(优先读 Value / 子 Label)+ 相对关系** +6. `ClassName/FrameworkId` 辅助组合 +7. 有业务依据的 index(仅兜底;须写清父容器与序号含义,禁止「过滤后的列表 [2]」与「父节点 child index=2」混用) +8. UIA 元素实时 BoundingRectangle 派生坐标 +9. 局部截图模板/OCR +10. 写死绝对坐标:禁止作为生产方案 + +顶层窗口或关键按钮出现多个候选时,返回 `ERROR:ELEMENT_AMBIGUOUS`,不得默认使用第一个。 + +同名叶子(如多处 `wbtn_container`)必须限制在业务父容器内(如 `search_external`),并可用子文案(如 Value=`添加`)校验;禁止点搜索旁通用「+」。 + +## 4. 窗口与控件规则 + +### 窗口 + +- 校验窗口属于预期进程和当前用户会话。 +- 主窗口、**应用内业务子 pane**、搜索窗、确认窗分别建立作用域。 +- 进入依赖完整侧栏/工具栏的页面时:**激活后最大化**;`activate` **禁止**对已最大化窗口无条件调用 `SW_RESTORE`(会还原成普通窗,导致折叠与坐标漂移)。仅最小化时才 Restore。 +- 应用可能重建窗口;页面切换后重新取 HWND 和 UIA root。 +- 自动化进程与目标进程权限级别必须兼容;不要为了绕过权限注入目标进程。 +- 多开场景必须由账号、PID或明确窗口信息选择,歧义时停止。 + +### 控件 + +- 在最小业务父容器中搜索。 +- 默认要求唯一匹配。 +- 检查可见、启用、未离屏和边界矩形有效。 +- 虚拟列表只包含当前实例化元素;滚动后重新枚举。 +- 不长期保存 Control 对象。 + +## 5. 输入、点击与等待 + +### 点击 + +1. 激活目标窗口(主窗按需最大化;子对话框不要误 Maximize)。 +2. 重新定位元素。 +3. 检查边界矩形。 +4. 使用 UIA Click/Invoke 或正常鼠标点击。 +5. 等待后置窗口/控件/业务状态(如标题文案、专用容器、子窗口 Class)。 + +关键导航建议日志:`locate`(策略/文案)→ `click` → `verify`(后置)。这与 [`LOGGING.md`](LOGGING.md)「不必每个 Click 一行」不冲突:要求的是**关键跳转可观测**,不是像素级刷屏。 + +UIA 点击失败时可以用该元素刚读取的 BoundingRectangle 中心点降级,但必须记录降级、确认窗口前台并验证后置状态;不得静默改点写死坐标。 + +### 输入 + +- 先点击聚焦。 +- 清空并逐字输入,敏感值不写日志。 +- 输入后读取 Value/Text 或通过后续业务状态验证。 +- 剪贴板可能泄露敏感信息,非必要不使用。 + +### 等待 + +优先等待: + +- 目标窗口出现/消失。 +- 控件出现、启用或状态变化。 +- 列表产生业务结果。 +- 成功/失败提示出现。 + +固定 sleep 只作短缓冲,并使用可中断等待;不能作为成功条件。 + +## 6. UIA 不可用时的降级 + +先检查: + +1. 权限级别和 Windows 会话是否一致。 +2. 是否选错进程或子窗口;主窗是否未最大化导致侧栏未实例化。 +3. 控件是否离屏或尚未实例化。 +4. 应用是否为 Electron(可评估 Playwright)。 +5. 是否为自绘、游戏引擎、远程桌面或安全控件。 +6. 空树/树抖动时:最大化主窗;用 Inspect/影刀附着一次后再 probe(远程桌面工具可能干扰无障碍树)。 + +只有控件树确认不可用时才使用图像: + +- 固定或记录 DPI、缩放、分辨率和主题。 +- 限定目标窗口和搜索区域。 +- 记录模板来源、置信度阈值和多匹配处理。 +- 匹配不唯一时停止。 +- 点击后仍验证业务状态。 + +U盾、人脸、动态口令和安全键盘交给人工,不用 OCR、Hook 或坐标绕过。 + +## 7. 诊断和留证 + +桌面 `health` / `probe-ui` 至少输出: + +- UIA 依赖是否可用。 +- 目标进程和主窗口是否存在;是否已最大化(若业务需要)。 +- 关键窗口/控件是否各唯一匹配。 +- 当前窗口标题、Class、PID。 +- DPI/缩放诊断。 + +example 里的最小 `probe`(只探主窗+个别入口)**不等于**生产 probe 完整度;业务技能应对关键跳转节点分别探测或做深探。 + +手开 PowerShell 跑 `stats` / `health` / dump 时,必须显式对齐宿主的 `CLAW_USER_ID` / 数据根,否则会读到默认用户空库,误判熔断/冷却。 + +定位失败保存: + +- 目标窗口和全屏截图。 +- UIA 树。 +- 顶层窗口枚举。 +- element map 逻辑名、候选数量和属性。 +- 应用版本、Windows版本、DPI和会话类型。 + +## 8. example 路由 + +真实成功参考见 [`../examples/real_desktop_rpa/README.md`](../examples/real_desktop_rpa/README.md)。该 example 默认只演示 `probe`、`dry-run` 和 `assisted`;不可逆动作必须由业务技能显式实现确认策略。 diff --git a/development/RPA_ELEMENT_LOCATING.md b/development/RPA_ELEMENT_LOCATING.md new file mode 100644 index 0000000..8ecb1c3 --- /dev/null +++ b/development/RPA_ELEMENT_LOCATING.md @@ -0,0 +1,178 @@ +# RPA 元素定位与拾取总标准 + +> 本文定义浏览器、Windows 桌面和手机三端共用的“元素工程”标准。开始写任何界面操作代码前必须先完成元素拾取、元素清单和定位验证。分端实操分别见 [`RPA_BROWSER.md`](RPA_BROWSER.md)、[`RPA_DESKTOP.md`](RPA_DESKTOP.md)、[`RPA_MOBILE.md`](RPA_MOBILE.md)。 + +## 目录 + +1. 核心术语 +2. 强制工作流 +3. 元素清单 +4. 定位器质量规则 +5. 动作闭环 +6. 降级决策树 +7. 失败留证 +8. AI 编程约束 +9. 交付门禁 + +## 1. 核心术语 + +| 术语 | 定义 | +|---|---| +| **拾取** | 使用检查器在真实界面上点选元素,读取 DOM、UIA 或移动控件树属性 | +| **定位器** | 根据稳定属性在运行时重新找到元素的规则 | +| **作用域** | 页面、iframe、窗口、弹窗、父容器、列表行或 WebView 等查找边界 | +| **锚点** | 与目标元素存在稳定业务关系的父级、标题、标签或同一行元素 | +| **后置状态** | 动作完成后必须出现或消失的真实业务状态 | +| **降级** | 主定位器不可用时,改用已验证的备用定位器或图像方案 | +| **界面快照** | 失败现场的截图和 DOM/UIA/XML 控件树 | + +## 2. 强制工作流 + +按以下顺序开发,不得跳过拾取直接猜 selector: + +1. 在真实目标版本上手工走通业务流程,记录每一步的前置状态和成功表现。 +2. 使用对应端检查器拾取元素,保存截图和原始 DOM/UIA/XML。 +3. 识别正确作用域:页面/iframe、进程/窗口、Native/WebView、弹窗或列表行。 +4. 为每个关键元素填写元素清单。 +5. 按分端优先级生成候选定位器。 +6. 验证候选定位器在目标作用域中匹配数量符合预期,关键动作默认必须恰好为 `1`。 +7. 验证元素可见、启用、未离屏且边界矩形有效。 +8. 执行动作,并等待真实后置状态。 +9. 重启目标程序或会话后再次验证;动态列表还要更换数据验证。 +10. 记录已验证的软件版本、页面状态、系统缩放/设备信息和日期。 + +未完成第 2~6 步时,代码只能保留明确的 `TODO: NEED_REAL_UI_INSPECTION`,不得写“看起来可能正确”的 selector。 + +开发期推荐使用独立仓 [jiangchang-ui-recorder](https://git.jc2009.com/client-jiangchang/jiangchang-ui-recorder)(本机常见路径 `D:\OpenClaw\client-jiangchang\jiangchang-ui-recorder`,`python run.py`):以 **Raw Event Journal → Action → Element Catalog → 异步 Observation** 录制,优先把 `action_flow.json` 与 `element_catalog.json` 交给 AI 编程工具;原始事件可重新归并,tree/index 作为诊断证据。默认不再让完整页面 dump 阻塞动作采集。本模板仓内的 `tools/ui_inspector` 已移除,勿再从 skill-template 启动。 + +## 3. 元素清单 + +每个关键输入、按钮、列表、弹窗、成功状态和错误状态都必须登记。推荐 YAML: + +```yaml +logical_name: submit_button +purpose: 提交当前业务单据 +surface: browser # browser | windows | android | ios +screen: 订单确认 +scope: + kind: dialog + locator: role=dialog,name=订单确认 +primary_locator: + strategy: role + value: button,name=确认提交 +fallback_locators: [] +expected_count: 1 +ready_condition: + visible: true + enabled: true +postcondition: + description: 出现订单号且状态为已提交 +failure_evidence: + - screenshot + - ui_tree +verified_target_version: "待填写" +verified_at: "YYYY-MM-DD" +``` + +要求: + +- `logical_name` 使用业务语义,不使用 `button1`、`element2`。 +- `scope` 不得省略;全页面、全桌面或全设备盲搜不作为默认生产方案。 +- `fallback_locators` 只能填写人工验证过的定位器。 +- 不可逆动作必须同时登记确认前状态和提交后状态。 +- selector 变化时更新 `verified_target_version` 和验证日期。 + +## 4. 定位器质量规则 + +### 4.1 必须满足 + +- 关键动作定位器在目标作用域中唯一。 +- 定位器依赖业务语义、可访问性属性或稳定接口标识。 +- 每次页面/窗口/列表刷新后重新定位,不长期缓存元素句柄。 +- 同一元素的主定位器和 fallback 使用不同的稳定证据,不是同一脆弱路径的轻微改写。 +- 多结果是合法业务场景时,先定位业务容器/行,再在容器内定位按钮。 + +### 4.2 默认禁止 + +- 用 `.first`、`.nth(0)`、`hwnds[0]`、`instance=0` 消除本应解决的歧义。 +- 长绝对 XPath、深层 DOM/UIA 路径和大量 index。 +- 哈希 class、随机生成 id、易变化坐标作为唯一依据。 +- 扫描所有输入框后按宽度、位置“猜”目标。 +- 捕获所有异常后继续尝试随机 selector 或随机坐标。 +- UI结构变化后自动生成新 selector 并继续执行不可逆动作。 + +确有稳定业务顺序时可以使用 index,但必须在元素清单中写明锚点、顺序依据、匹配总数和版本验证结果。 + +## 5. 动作闭环 + +每一个动作实现为一个小状态机: + +```text +确认前置状态 +→ 获取正确作用域 +→ 重新定位元素 +→ 校验唯一/可见/可用 +→ 激活并执行真实输入 +→ 等待后置状态 +→ 记录成功证据 +``` + +- 缓冲等待只用于改善稳定性,不能替代后置状态。 +- 点击后状态不明确时先查询业务状态,禁止再次点击。 +- 付款、发送、删除、审批、发布等动作如果无法确认结果,返回 `ERROR:RESULT_UNCONFIRMED`。 +- 列表滚动、页面切换、窗口重建和移动端 Activity 切换后重新获取元素。 + +## 6. 降级决策树 + +找不到元素时依次检查: + +1. 目标应用、页面或设备是否正确启动并处于预期账号。 +2. 是否选错页面、iframe、进程、窗口、弹窗、Native/WebView 上下文。 +3. 元素是否尚未加载、被遮挡、禁用、离屏或在虚拟列表未实例化区域。 +4. 是否因管理员权限、远程会话、设备授权或驱动版本无法读取控件树。 +5. 主定位器属性是否变化;对比保存的界面快照。 +6. 是否可以用已验证的父容器、业务文案或相对锚点定位。 +7. 控件是否为 Canvas、自绘、远程桌面或未暴露可访问性树。 +8. 最后才评估局部图像/OCR/坐标方案。 + +任何一级确认“界面结构变化”时,截图、导出控件树并停止;不得在生产任务中让 AI 临时猜新定位器。 + +## 7. 失败留证 + +定位失败至少保存: + +- 当前屏幕/窗口截图。 +- 当前 DOM、UIA 或移动端 XML。 +- 页面 URL、进程/PID/窗口标题或设备 serial、包名、Activity、context。 +- 逻辑元素名、作用域、主定位器和已尝试 fallback。 +- 每个候选匹配数量、可见/启用/离屏状态和边界矩形。 +- 目标应用版本、浏览器版本或设备系统版本。 +- 失败阶段、错误码、时间和 batch_id。 + +敏感输入、Cookie、token、手机号和客户数据必须脱敏。 + +## 8. AI 编程约束 + +向 AI 编程工具提供本模板时,必须要求它: + +1. 先阅读本总标准和对应分端手册。 +2. 先输出业务步骤表和元素清单,再生成代码。 +3. 只使用真实拾取得到的属性;缺少属性时明确停止并索要界面快照。桌面端须同时记录 **Name 与 Value**(可见中文常在 Value;见 [`RPA_DESKTOP.md`](RPA_DESKTOP.md) §2)。 +4. 把目标系统 selector 集中到 element map/selector catalog,不散落在业务流程中。 +5. 为每个动作实现前置状态、定位、动作和后置状态。 +6. 把诊断探针、界面树导出和失败截图作为实现的一部分。 +7. 不把 mock 通过、命令未报错或弹窗关闭当成真实业务成功。 +8. 不擅自切换到 Hook、注入、私有协议、验证码破解或其他禁止路线。 + +## 9. 交付门禁 + +- [ ] 关键元素均有元素清单和真实拾取证据 +- [ ] 每个不可逆动作默认唯一定位 +- [ ] 重启会话后定位仍然有效 +- [ ] 动态数据或列表顺序变化后不会误点 +- [ ] 每个动作有真实后置状态 +- [ ] UI结构变化会停止并留证 +- [ ] fallback 均已人工验证 +- [ ] 截图和控件树不泄露敏感数据 +- [ ] 真实主路径至少完整跑通一次 +- [ ] example 状态明确区分“已验证成功”和“规范骨架” diff --git a/development/RPA_MOBILE.md b/development/RPA_MOBILE.md new file mode 100644 index 0000000..4e2dd05 --- /dev/null +++ b/development/RPA_MOBILE.md @@ -0,0 +1,141 @@ +# 手机 RPA 元素拾取与定位标准 + +> 先读 [`RPA_ELEMENT_LOCATING.md`](RPA_ELEMENT_LOCATING.md)。Android 首选 uiautomator2 或 Appium UiAutomator2;iOS 使用 Appium + XCUITest/WebDriverAgent。当前模板尚无团队真实成功案例,因此本文是强制设计标准,example 仅为规格骨架,不得宣称已生产验证。 + +## 目录 + +1. 状态与边界 +2. 设备准备 +3. 拾取工具和步骤 +4. Android 定位优先级 +5. iOS 定位优先级 +6. Native/WebView 与动态界面 +7. 手势、输入和等待 +8. 降级与留证 +9. example 路由 + +## 1. 状态与边界 + +- Android 标准可用于设计和实现,但交付前必须在授权真机和官方 App 上验证。 +- iOS 必须有 Mac、签名可用的 WebDriverAgent 和授权设备。 +- 不 Root、不越狱、不 Hook、不修改 APK/IPA、不伪造设备身份。 +- 短信、人脸、指纹、系统安全确认交给人工。 + +## 2. 设备准备 + +每次运行前检查: + +- 唯一设备 serial/UDID;多个设备时不得默认取第一个。 +- USB/网络连接和调试授权。 +- Android 包名/Activity,或 iOS bundle id。 +- 屏幕是否解锁、方向、分辨率和系统弹窗状态。 +- 自动化 driver/service 是否可用。 +- 设备租约/互斥;同一设备默认串行。 + +`doctor` 应输出上述信息,但不得输出设备隐私数据。 + +## 3. 拾取工具和步骤 + +Android 可使用 Appium Inspector、uiautomator2 的 UIAutoDev/层级导出;iOS 使用 Appium Inspector。 + +1. 在真机上手工进入准确页面。 +2. 刷新 Inspector screenshot 和 page source。 +3. 在截图或 XML 树中点选元素。 +4. 记录: + - Android:resource-id、content-desc、text、class、package、clickable、enabled、bounds + - iOS:name/accessibility id、label、value、type、enabled、visible +5. 确认当前 context 是 Native 还是正确 WebView。 +6. 记录稳定父容器、列表行和业务锚点。 +7. 在 Inspector 搜索定位器,确认匹配数量。 +8. 写入 element map,保存 screenshot + XML。 +9. 重启 App、重新进入页面并复测。 + +截图上看到的元素不一定存在于控件树;必须以 page source/可访问性树验证,不能直接凭坐标写代码。 + +## 4. Android 定位优先级 + +从高到低: + +1. 稳定且唯一的 `resourceId` +2. `description/content-desc`(accessibility) +3. 稳定 `text + className` +4. 业务父容器内的子元素组合 +5. UiSelector / 相对关系 +6. 短 XPath +7. 已验证的 instance/index +8. 图像/坐标 + +示例: + +```python +search = d(resourceId="com.example:id/search_input") +if search.count != 1: + raise RuntimeError("ERROR:ELEMENT_AMBIGUOUS search_input") +search.click() +search.set_text(keyword) +if not d(resourceId="com.example:id/result_list").wait(timeout=10): + raise RuntimeError("ERROR:RESULT_TIMEOUT") +``` + +第三方 App 没有 resource-id 时,使用可访问性描述、稳定文案和父容器组合;不得修改第三方 APK 添加 id。 + +## 5. iOS 定位优先级 + +从高到低: + +1. accessibility id / name +2. `type + name/label/value` +3. iOS predicate string +4. iOS class chain +5. XPath +6. 图像/坐标 + +XPath 依赖完整 XML 且通常较慢,只作后备。多个同名按钮时先定位 navigation bar、cell、alert 或其他业务容器。 + +## 6. Native/WebView 与动态界面 + +### Native/WebView + +- 先枚举 contexts。 +- Native 元素使用移动端控件树。 +- WebView 元素切换到正确 web context 后使用 Web 定位器。 +- context 数量或名称变化时按包名、URL或页面特征验证,不默认取第一个 WebView。 + +### 动态列表 + +- 虚拟列表只暴露屏幕附近元素。 +- 每次滚动后重新定位。 +- 用业务键/行文案选择条目,不依赖当前 instance。 +- 到达末尾必须有明确状态或滚动前后内容对比。 + +### 系统弹窗 + +权限、通知、文件选择等可能属于系统包或 SpringBoard;为其建立独立作用域和显式处理策略,不把它们当业务 App 元素。 + +## 7. 手势、输入和等待 + +- 点击前确认元素可见、启用且 bounds 有效。 +- 输入后读取文本或等待结果状态。 +- 滑动以元素/屏幕相对区域描述,不跨设备写死绝对像素。 +- 页面切换等待 Activity/view/context 或业务元素变化。 +- 后台切前台、横竖屏变化后重新获取元素。 +- 人工验证期间可用 scrcpy/Appium Inspector 辅助观察,程序轮询真实状态并有超时。 + +## 8. 降级与留证 + +找不到元素先检查 context、Activity、系统弹窗、滚动位置和页面 source。确认控件树未暴露后才评估局部图像/OCR。 + +失败保存: + +- 设备 screenshot。 +- page source XML。 +- serial 脱敏值、包名、Activity、context 列表。 +- 屏幕分辨率、方向和系统版本。 +- element map 逻辑名、定位器和匹配数量。 +- App 版本、driver 版本和失败阶段。 + +图像方案必须限定设备矩阵和搜索区域;匹配不唯一时停止。 + +## 9. example 路由 + +手机 example 规格见 [`../examples/mobile_rpa/README.md`](../examples/mobile_rpa/README.md)。在团队提供真实成功案例前,该目录只能作为实施清单和验收契约,不能作为“已跑通代码”宣传或直接复制。 diff --git a/development/RUNTIME.md b/development/RUNTIME.md new file mode 100644 index 0000000..1ef4900 --- /dev/null +++ b/development/RUNTIME.md @@ -0,0 +1,145 @@ +# 运行时约定 + +## 共享 Python Runtime + +**skill-template** 及复制出的新技能,公共能力均来自宿主匠厂安装的共享 Python Runtime(`jiangchang-platform-kit>=1.2.11` 及其传递依赖,含 `playwright`)。`jiangchang_skill_core` **不得**在技能仓库内 vendored,应由共享 venv 的 site-packages 提供。 + +技能根目录 `requirements.txt` **只声明技能特有依赖**;**不要**重复声明 `jiangchang-platform-kit` 或 `playwright`。`SKILL.md` 的 `platform_kit_min_version` 是运行契约,**不是** pip 依赖声明。 + +kit **源码仓**(可 clone 学习,勿 vendor)与其它公开公共仓登记见 [`SHARED_REPOS.md`](SHARED_REPOS.md)。 + +### 何时上调 `platform_kit_min_version` + +| 情况 | 做法 | +|------|------| +| 技能依赖 kit 某次修复/新 API(如 `.env` 空值+行尾 `#` 解析、`poll_until`、启动超时) | 把 `metadata.openclaw.platform_kit_min_version` 调到**需要的最低版本**(模板默认 `"1.2.11"`) | +| PyPI/私有源刚发了更新,但本技能行为未依赖 | **不必**仅为「追新」抬版本 | +| 宿主升级时机 | 宿主在技能/宿主声明的最低版本**未满足**时按需升级共享 venv;**不会**仅因源上有更新就后台拉 latest | + +模板默认声明须与当前文档所依赖的最低 kit 能力对齐(见根 `SKILL.md`)。 + +| 场景 | 推荐做法 | +|------|----------| +| 日常运行(宿主 / Agent) | 使用 PATH 上的共享 venv `python`(`{JIANGCHANG_DATA_ROOT}/python-runtime/.venv`) | +| 手工排查 / 测试机(环境未注入时) | `{JIANGCHANG_DATA_ROOT}/python-runtime/.venv/.../python` 执行 `scripts/main.py` | +| **禁止** | 在技能目录内 `uv run python …` — 会创建临时 venv,加载不到宿主共享 runtime | + +占位命令(路径因环境而异,勿写死本机目录): + +```text +推荐(已注入环境时): + python {baseDir}/scripts/main.py health + +Windows(未注入时): + {JIANGCHANG_DATA_ROOT}\python-runtime\.venv\Scripts\python.exe {baseDir}\scripts\main.py health + +Unix(未注入时): + {JIANGCHANG_DATA_ROOT}/python-runtime/.venv/bin/python3 {baseDir}/scripts/main.py health +``` + +共享解释器通常位于 `{JIANGCHANG_DATA_ROOT}/python-runtime/.venv`。数据根由宿主注入(见 `jiangchang_skill_core.runtime_env`)。 + +## Runtime 诊断(platform-kit 1.2.11+;含既有 1.2.2+ 字段) + +`health` 命令通过 **`jiangchang_skill_core.collect_runtime_diagnostics`** 输出共享 runtime 诊断,**不在技能内重复实现**。典型字段: + +- `skill_slug`、`python_executable`、`platform_kit_version`、`platform_kit_min_version` +- `jiangchang_skill_core_file` — 若从技能目录加载会输出 `runtime_issue[warning]` +- `JIANGCHANG_DATA_ROOT` / `resolved_data_root` +- `media_assets_root`、`ffmpeg_path`、`background_music_*` +- `record_video_enabled`、`runtime_issue[warning|error]` +- `machine_power` — 只读供电/限频探测(探不到则 `probed=false`);**不得**单独作为 hard-fail 依据 + +`health` 是**只读诊断**,**不会**触发 media-assets 下载或修复。并补充 `env_path` / `env_exists` / `example_path` 配置路径字段。 + +## 配置 bootstrap + +- 仓库内 `.env.example` 是配置模板(单一事实来源)。 +- 用户实际 `.env`:`{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/{skill_slug}/.env`。 +- `scripts/main.py` 与 `cli.app.main()` 启动时调用 `util.config_bootstrap.bootstrap_skill_config()`。 +- 配置优先级:**进程环境变量** > **用户 `.env`** > **`.env.example` 默认值**。 +- 公共 `config` / `merge_missing_env_keys` 来自共享 runtime 的 `jiangchang-platform-kit>=1.2.11`,**不得** vendored `scripts/jiangchang_skill_core/`。 + +## media-assets / ffmpeg / 背景音乐 + +背景音乐、ffmpeg 等共享资源由 platform-kit 统一解析,默认路径: + +```text +{JIANGCHANG_DATA_ROOT}/shared/media-assets +``` + +(由宿主注入 `JIANGCHANG_DATA_ROOT`。) + +RPA 录屏成片(`RpaVideoSession`)、ffmpeg 路径、背景音乐探测均由 platform-kit 提供;技能只 import,不重复实现。 + +## 目录结构 + +新技能建议采用以下根目录结构: + +- `assets/` +- `development/` +- `references/` +- `scripts/` +- `tests/` +- `evals/` + +**标准 `scripts/` 分层(不含 `jiangchang_skill_core/` 副本):** + +- `scripts/main.py`:唯一 CLI 入口 +- `scripts/cli/`:参数解析与命令分发 +- `scripts/db/`:SQLite 或本地持久化层(标准时间字段见 `references/SCHEMA.md`) +- `scripts/service/`:业务用例与外部交互 +- `scripts/util/`:通用工具、常量、日志、路径薄封装 + +## 数据路径 + +推荐: + +```text +{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/{skill_slug}/ +``` + +数据库文件建议: + +```text +{...}/{skill_slug}.db +``` + +**业务文件(输入、输出、缓存等)** 须落在上述数据目录的标准子目录下,**不得**相对 workspace/CWD 写入。用户可见目录为 `input/`(上行)与 `output/`(下行)。完整子目录树、env 覆盖规则、`resolve_data_path()` 用法见 **[`DATA_PATHS.md`](DATA_PATHS.md)**。 + +- 业务表使用英文 snake_case;中文表名/字段名写入 `_jiangchang_tables` / `_jiangchang_columns`(见 `references/SCHEMA.md`)。 +- 界面字段顺序由 `CREATE TABLE` 列定义顺序决定,**不要**维护 `display_order` 来调整顺序。 + +## 明确禁止 + +- **不得** vendored `scripts/jiangchang_skill_core/` +- **不得**在技能内本地重复实现 `RuntimeDiagnostics` / `collect_runtime_diagnostics` +- **不得**写死开发机绝对路径作为运行时约定 + +## 测试时的运行时隔离 + +本模板的 `tests/_support.IsolatedDataRoot` 会在测试期间设置: + +- `JIANGCHANG_DATA_ROOT` → 临时 tempfile 目录 +- `JIANGCHANG_USER_ID` → `_test` + +退出时两个变量恢复,临时目录删除。所有 DB / 文件写入都被限制在临时目录内,不污染本机。 + +详见 `TESTING.md` 第 3 节。 + +## 编码与输出 + +- 所有提交到 skill 的源码、文档、配置文本文件必须是 **UTF-8 without BOM**(不要只写「UTF-8」)。 +- Python 文件**绝对不能**带 UTF-8 BOM;否则 release CI 的 PyArmor 阶段可能报:`invalid non-printable character U+FEFF`。 +- Windows 编辑器容易保存成 UTF-8 BOM;提交前确认文件开头无 `U+FEFF`。 +- Windows 终端建议在 `scripts/main.py` 里做 UTF-8 stdout/stderr 包装 +- 机读输出优先单行 JSON +- 错误前缀建议统一 `ERROR:` + +## 发布打包约束(`scripts/**/*.py`) + +release workflow 加密 `scripts/` 源码时,可能运行在 PyArmor 免费/试用模式: + +- `scripts/**/*.py` 单文件必须 **< 1000 行**(`>= 1000` 可能在 CI 失败);推荐 **< 900 行**。 +- 接近上限时拆分到 `cli/`、`service/`、`db/`、`util/`,不要把 RPA 主流程、选择器、DB、CLI 堆进单文件。 +- 此行数限制**仅适用于** `scripts/**/*.py`;不限制 README 等文档行数,也不把 examples 里的 HTML/README 纳入 PyArmor 行数约束。 diff --git a/development/SHARED_REPOS.md b/development/SHARED_REPOS.md new file mode 100644 index 0000000..bfbce29 --- /dev/null +++ b/development/SHARED_REPOS.md @@ -0,0 +1,131 @@ +# 公共仓库目录(可扩展) + +本页是技能开发用到的**公开公共仓唯一登记处**,面向技术人员与 AI 编程代理。 + +- 仓均在公司**自建 Gitea**:[https://git.jc2009.com/](https://git.jc2009.com/)(基于开源 Gitea 自建,**不是** Gitea 官网)。 +- 技术人员一般**可直接访问、随时 `git clone` 到本地**,用于研究、学习、对照与跟进更新。 +- **当前登记 3 项**;以后新增公开公共仓时,**只在本页按类型加行**,不必改散落长文。 + +深度规范仍在 [`RPA.md`](RPA.md)、[`RUNTIME.md`](RUNTIME.md)、[`ADAPTER.md`](ADAPTER.md)、[`DEVELOPMENT.md`](DEVELOPMENT.md);本页只做**指路 + 边界**。 + +--- + +## 1. 类型(可扩展) + +| 类型 id | 含义 | 典型消费方式 | +|---------|------|--------------| +| `skill-template` | 业务技能作者脚手架与规范源 | clone 学习;`scaffold` / 复制出新业务仓;**不要**在模板仓写业务 | +| `sibling-skill` | 与业务技能并列安装的公开基础设施技能 | clone 学契约;运行时经 **CLI / 封装客户端** 调用 | +| `python-sdk` | 共享 Python 库(源码仓 + 宿主共享 venv 安装) | clone 读实现 / 贡献;运行时 **`import`**,**禁止 vendor** 进技能仓 | +| `asset-bundle` | (预留)共享媒体/资源包等 | 按该仓说明拉取或由 kit/宿主解析 | +| `tooling` | (预留)发布脚本、脚手架、CI 辅助等 | clone 使用或引用其 workflow | + +新增类型时:先在本表加一行类型说明,再在 §2 登记表增加实例。 + +--- + +## 2. 当前登记表 + +| 短名 | 类型 | Gitea(唯一入口) | 一句话职责 | +|------|------|-------------------|------------| +| **skill-template** | `skill-template` | [https://git.jc2009.com/client-commons/skill-template](https://git.jc2009.com/client-commons/skill-template) | 新业务技能的开发模板、目录规范与发布约定 | +| **account-manager** | `sibling-skill` | [https://git.jc2009.com/client-commons/account-manager](https://git.jc2009.com/client-commons/account-manager) | 账号 / 凭据 / 浏览器 Profile / 租约的统一入口 | +| **jiangchang-platform-kit** | `python-sdk` | [https://git.jc2009.com/client-jiangchang/jiangchang-platform-kit](https://git.jc2009.com/client-jiangchang/jiangchang-platform-kit) | 共享 SDK(`jiangchang_skill_core` 等);技能发布可复用 CI 也在此仓 | + +本地常见路径(仅作对照,以你机器为准): + +- `D:\OpenClaw\client-commons\skill-template` +- `D:\OpenClaw\client-commons\account-manager` +- `D:\OpenClaw\client-jiangchang\jiangchang-platform-kit` + +### 2.1 skill-template + +| 项 | 说明 | +|----|------| +| **Clone 为了什么** | 读 `development/`、跟模板版本、用 `tools/scaffold_skill` 开新技能 | +| **怎么用** | 来源 B:scaffold;或学完后在业务仓开发。规范以本仓为准 | +| **红线** | 不要在模板仓写业务;业务仓 `origin` 不得指向本仓;不得保留 `.openclaw-skill-template` | +| **深入** | 本目录 [`README.md`](README.md)、[`DEVELOPMENT.md`](DEVELOPMENT.md) | + +```powershell +git clone https://git.jc2009.com/client-commons/skill-template.git +``` + +### 2.2 account-manager + +| 项 | 说明 | +|----|------| +| **Clone 为了什么** | 读 CLI / `references/INTEGRATION.md`、平台表、租约与错误码;排 `NO_ACCOUNT` / `LEASE_CONFLICT` 等 | +| **为什么必须用** | 支撑真实业务的一账号一 Profile、登录状态持久化、多账号矩阵和并发互斥;禁止浏览器“裸奔” | +| **怎么用(业务技能)** | 经 `scripts/service/account_client.py`(或等价)**subprocess 调 CLI**;网页 RPA 须先拿到 `profile_dir + lease_token`(`ensure-web` / `pick-web`) | +| **红线** | **禁止**业务技能 `import` 其内部 `rpa_helpers` / 散落存密码(见 `POLICY-RPA-001`);对方文档若写「同进程 rpa_helpers」,**以本模板约束为准** | +| **创建后查看** | 宿主数据管理;CLI `account list/get`、`lease list`;本地用户数据目录中的 `account-manager.db` 与 `profiles/` | +| **深入** | 对方仓 `references/INTEGRATION.md`、`CLI.md`、`SCHEMA.md`;本模板 [`ACCOUNT_MANAGER.md`](ACCOUNT_MANAGER.md)、[`RPA.md`](RPA.md) §0.2、[`ADAPTER.md`](ADAPTER.md) | + +```powershell +git clone https://git.jc2009.com/client-commons/account-manager.git +``` + +### 2.3 jiangchang-platform-kit + +| 项 | 说明 | +|----|------| +| **Clone 为了什么** | 读 `jiangchang_skill_core`(config / logging / activity·SRCP / rpa / sibling_bridge 等)、跟版本、贡献共享 API | +| **怎么用(业务技能)** | 运行时由**宿主共享 venv** 提供;技能内 `import jiangchang_skill_core...`。`SKILL.md` 的 `platform_kit_min_version` 是兼容声明 | +| **红线** | **不要**把 `jiangchang-platform-kit` / `playwright` 写入技能 `requirements.txt`;**不要**在技能仓 vendor `jiangchang_skill_core/`(`POLICY-RUNTIME-002`) | +| **深入** | 对方仓 `README.md`;本模板 [`RUNTIME.md`](RUNTIME.md)、[`LOGGING.md`](LOGGING.md) | + +```powershell +git clone https://git.jc2009.com/client-jiangchang/jiangchang-platform-kit.git +``` + +私有 PyPI 索引(维护/排障用,日常技能开发通常不必手装): +`https://git.jc2009.com/api/packages/client-jiangchang/pypi/simple/` + +--- + +## 3. 三者关系(别当成三个无关收藏夹) + +```text +规范 / 脚手架 ──────────► skill-template(本仓) + │ +业务技能 scripts/ ──import──► 宿主共享 venv 中的 platform-kit + │ + └──CLI──► account-manager(兄弟技能,要先拿到 profile_dir 等) +``` + +- **写规范、开新仓** → 打开 **skill-template** +- **少写公共 Python、进度/RPA 原语** → 用 **kit**(运行靠宿主;源码可 clone) +- **账号与浏览器 Profile** → 用 **account-manager**(运行靠 CLI;源码可 clone) + +--- + +## 4. 何时该打开哪个仓 + +| 场景 | 优先打开 | +|------|----------| +| 不知道新技能目录/发布/developer_ids 怎么做 | **skill-template** `development/` | +| 定 `platform_key`、ensure-web、租约、凭证存储 | **account-manager** + 本模板 RPA/ADAPTER | +| 账号创建后不知道去哪看、登录态不保持、多账号互相污染 | 本模板 **ACCOUNT_MANAGER.md** + account-manager 数据管理 / CLI | +| 看 `emit` / `finish` / `@rpa_step` / `launch_persistent_browser` 实现 | **jiangchang-platform-kit** | +| 技能发布 CI / reusable workflow 从哪来 | **jiangchang-platform-kit** `.github/workflows/` | +| 「公共东西到底在哪」 | **本页登记表** | + +--- + +## 5. 以后如何新增一条公共仓(维护约定) + +1. 确认公开可访问,URL 使用 `https://git.jc2009.com/...`(勿写 Gitea 官网)。 +2. 选定或新增 §1 **类型**。 +3. 在 §2 **登记表**加一行;必要时增加 `§2.x` 短节(消费方式 + 红线 + clone 示例)。 +4. 若改变业务技能硬约束,同步 [`POLICY_MATRIX.md`](POLICY_MATRIX.md) / 相关深度文档。 +5. **不要**只在某个业务技能 README 里私藏链接而不登记本页。 + +--- + +## 6. 相关链接 + +- 开发入口:[`README.md`](README.md) +- 运行时:[`RUNTIME.md`](RUNTIME.md) +- 网页 RPA / 账号:[`RPA.md`](RPA.md)、[`ADAPTER.md`](ADAPTER.md) +- 脚手架:[`../tools/README.md`](../tools/README.md) diff --git a/development/SKILL_ACTION_RUNTIME.md b/development/SKILL_ACTION_RUNTIME.md new file mode 100644 index 0000000..95aa005 --- /dev/null +++ b/development/SKILL_ACTION_RUNTIME.md @@ -0,0 +1,213 @@ +# Skill Action Runtime(宿主任务中心契约) + +本文是匠厂宿主 **Skill Action / 任务中心** 的技能侧金标准。涉及浏览器 RPA、长耗时任务、数据管理按钮、定时任务或 Agent 直接调用时**必读**。 + +**产品入口(平级关系、何时挂哪个)**先读 [`PRODUCT_SURFACES.md`](PRODUCT_SURFACES.md);本文管 sync/async、任务中心与编排。 + +**边界:** + +| 文档 | 职责 | +|------|------| +| 本文 | sync/async、任务中心、Agent 禁令、单内核编排、队列 pick、同步 vs 导出 | +| [`../references/ACTIONS.md`](../references/ACTIONS.md) | `actions.json` 字段、placements、`bind.tables`、inputSchema、跨表 Lookup、字段条件显隐 `visibleWhen`、本地路径 `file`/`directory` + 可选 **`pathRoot`**、选择摘要「已选择」与映射 **`readOnly` / `revealInForm`**、**按钮中文名 `label` 参考** | +| [`LOGGING.md`](LOGGING.md) | `job_context` / `emit` / `finish` / Run Journal | +| [`DATA_PATHS.md`](DATA_PATHS.md) | 下载/导入等落盘路径 | + +宿主实现细节以匠厂 Host API 为准;技能侧只需保证 manifest 与 CLI 契约稳定。 + +--- + +## 1. 核心原则 + +1. **宿主管调度,技能管业务** — 不自行实现任务中心 / Job 轮询 / 后台守护进程。 +2. **单业务内核、多入口** — 数据管理、定时任务、Agent、技能详情与直接 CLI 复用同一 domain service;禁止按入口复制业务逻辑。 +3. **五职责正交** — `placements` / `bind.tables` / `executionProfile` / `entrypoint` / domain service 各管一事(见 ACTIONS.md)。可选第六轴 **policy**(见 `references/POLICY.md`):未启用 = L0 与今日一致。可选 **`hostEffects` / `steps`**(见 ACTIONS.md):数据管理交互前后的 UI/级联编排;省略 = 不额外改 UI(匠厂宿主 ≥2.8.5)。可选 **`hoverHint`**(见 ACTIONS.md):数据管理工具栏悬停;省略 = 不显示,不回退说明或确认黄条。可选 **`visibleWhen`**(见 ACTIONS.md):Action 参数表单字段条件显隐;省略 = 字段始终平铺;**勿与** Policy L3 行态 `visibleWhen` 混淆;**勿**用其藏映射主键。可选 **`pathRoot`**(见 ACTIONS.md):`file`/`directory` 原生选路对话框起始目录(相对技能数据根;省略默认 `{skill_data}/input`)。可选 **`bind.labelColumn`**(见 ACTIONS.md):选择摘要可读标签列;省略 = 宿主常见列名启发。可选 **`revealInForm`**(见 ACTIONS.md):有「已选择」时仍强制显示某 `readOnly` 字段;省略 = 映射只读默认不占 form 参数区(仍提交)。可选 **`cellPresentation`**(见 `references/CELL_PRESENTATION.md`):单元格语义标色;省略 = P0。 +4. **长耗时建议 async** — 开浏览器、RPA、HTTP 大文件下载等建议 `executionProfile: "async"`,进**任务中心**;这是对**方法**的选择,**不**限制可以出现在哪些 placement / 入口。 +5. **Agent:短 CLI / 长 Action** — 短查询可用共享 Python bash CLI;长任务 / RPA **禁止** `exec` + `process poll`,必须 `run_skill_action`。 +6. **进度走 Journal** — `emit` / `video.add_step` → Run Journal;UI / Skill Run Card / 任务中心展示;**不靠 stdout 流**。 +7. **显式声明 `executionProfile`** — 心智默认 sync、例外才 async;每个 action **必须**写明 `sync` 或 `async`;宿主省略时按 sync,新技能仍禁止赌缺省;禁止第三种模糊模式(`auto` / `background` / `deferred` 等)。 + +--- + +## 2. `executionProfile`:sync vs async + +### 心智(先记住) + +1. **默认按 sync 想**:多数 Action(诊断、统计、入队、秒级查询)是短调用,当场返回,**不进**任务中心。 +2. **只有特殊情况标 async**:开浏览器 / RPA / 长下载 / 需要进度·暂停·停止时,才显式 `"executionProfile": "async"`,进入**任务中心**。 +3. **匠厂宿主**:`actions.json` 若**省略** `executionProfile`,按 **`sync`** 处理(不再把漏写当成进任务中心)。 +4. **新技能仍须显式写出** `sync` 或 `async`(模板 Schema / `POLICY-SKILL-ACTION-002` 硬门禁),禁止赌宿主缺省;显式写出也便于人和 AI 阅读。 + +| 值 | 宿主行为 | 超时 | 任务中心 | +|----|----------|------|----------| +| **`sync`**(或省略时的宿主缺省) | 调用方等待 CLI 结束,直接返回结构化结果或结构化错误;**不**创建后台 Job | 默认约 **60s**(`SKILL_ACTION_SYNC_TIMEOUT_MS`) | ❌ 不进 | +| **`async`**(须显式声明) | 后台 spawn Job,立即返回 `jobId`;`emit` / `checkpoint` / `finish`;长任务应支持暂停/停止/取消检查 | Job 本身无默认墙钟上限 | ✅ 进入 | + +直接执行 CLI:终端等待进程结束。宿主经 Action 调用同一 CLI:再按 `executionProfile` 分流。技能不得自建任务中心。 + +### 选择建议(不是 placement 硬限制) + +| 场景 | 建议 | +|------|------| +| 浏览器 / Playwright / RPA 主路径 | `async` + 建议 `confirmation` | +| 单次可能接近或超过约 60s 的网络/下载/同步 | `async` | +| `health` / `version` / `config-path` / 纯统计 / 秒级查询 | `sync` | +| 任意 placement(含 toolbar) | 允许 sync 或 async;以 manifest 为准 | + +合法:`sync|async` × `toolbar|row|batch|cron|agent|skill-detail`。 + +历史说明:旧文档曾错误地把数据管理按钮入口与异步执行方式绑死。该约束**已废止**;模板测试与 Schema **不得**再建立「入口位置决定 sync/async」一类硬耦合。旧版宿主曾「省略 → async」;**当前宿主为「省略 → sync」**。新技能仍须显式声明,不得依赖省略行为。 + +--- + +## 3. 多入口如何感知 sync / async + +多个入口统一走 `POST /api/skill-actions/run`,可能带 `source.kind`: + +| 入口(匠厂侧栏 / 界面) | 常见 `source.kind` | 技能侧 | 开发者怎么测 | +|-------------------------|-------------------|--------|--------------| +| **新建任务**(对话 Agent) | `agent` | `placements` 含 `agent`;`run_skill_action` | 侧栏「新建任务」→ 对话触发 | +| **数据管理 · 顶栏** | `data-management` | `toolbar` + 必填 `bind.tables` | 侧栏「数据管理」→ 表顶栏按钮 | +| **数据管理 · 行级** | `data-management` | `row`(建议 `bind.tables` + `inputMapping`) | 操作列和/或**行右键**(及可选「⋯」)可见并触发(需主键);技能配什么就显示什么 | +| **数据管理 · 批量** | `data-management` | `batch` | 勾选行后点批量按钮 | +| **定时任务** | `cron` | `placements` 含 `cron` | 侧栏「定时任务」→「技能直调」 | +| **任务中心** | (各入口触发的 Job) | `executionProfile: "async"` | 侧栏「任务中心」查看进度 / 取消;**不是** placement | +| **技能详情** | `skill-detail` | `placements` 含 `skill-detail` | 市场详情安装/四 Tab;详情直调按钮随宿主上线后**同等自测** | + +技能 **不要** 在 Python 里按 `source` / placement 写业务分叉;宿主读 manifest 分流展示与等待策略。 +**已声明的入口都要自测**,不要只测对话。操作清单见 [`DEVELOPMENT.md`](DEVELOPMENT.md) §15 第七步。 + +--- + +## 4. Action 分类与 placements(示例,不是限制) + +下表只表示常见组合示例。**placements 以 manifest 为准;sync/async 不影响展示入口。** + +| Action 类型 | 常见 `executionProfile` | 常见 placements | `confirmation` | +|-------------|-------------------------|-----------------|----------------| +| 运行检查 / 版本 / 配置路径 | `sync` | `skill-detail`, `agent` | 无 | +| 库 / 队列统计 | `sync` | `skill-detail`, `agent` | 无 | +| 外源 → 本地库同步 | `sync` 或 `async` | 可含 `toolbar`(须 `bind.tables`)、`cron`、`agent`、`skill-detail` | 可选 | +| 单行 / 批量数据管理动作 | `sync` 或 `async` | `row` / `batch`(建议 `bind.tables`;行内常用 `inputMapping`) | 可选 | +| 数据导入 / 失败重置 | `sync` 或 `async` | `skill-detail`, `agent` | 可选 | +| **单条**副作用 RPA | `async`(建议) | `agent`, `skill-detail` | **建议** | +| **批量顺序** RPA(pick) | `async`(建议) | `toolbar`, `cron`, `agent` | **建议** | + +禁忌: + +- 高副作用 action 不要默认只挂 `toolbar`(应有 confirmation)。 +- 含 `sensitive` 参数的 action 不要挂 `toolbar` / `cron`(除非有明确安全设计)。 +- 不要为了「按钮多」暴露所有 CLI 子命令。 +- 不要按入口复制多套采集 / 同步 / 处理实现。 + +--- + +## 5. 技能内编排范式(单内核 + 多入口) + +推荐分层: + +```text +scripts/cli/app.py # 参数解析 → 调用 task / service +scripts/service/task_service.py # job_context / emit / checkpoint / finish / 错误转换 +scripts/service/<domain>_service.py # 唯一业务内核 +scripts/db/*_repository.py # 只负责读写与事务 +assets/actions.json # 声明入口、参数、placement、executionProfile、bind +``` + +长任务 / RPA 常见拆分(勿把 `job_context` 塞进纯业务内核): + +```text +execute_single_<biz>(...) # 纯业务:鉴权 + 核心逻辑 + task_log;不含 job_context +cmd_run(...) # 单条:job_context + execute_single + finish +cmd_run_pick(N, ...) # 批量:job_context + for-loop execute_single + emit(progress) + finish +cmd_stats() # 只读 sync:JSON stdout,无 job_context +``` + +要点: + +1. **`job_context` 只包编排层**(`cmd_run` / `cmd_run_pick`),不包 `execute_single_*` / domain service。 +2. 批量 **顺序、不并发**;数量由参数 `pickCount` / `--pick N` 传入。 +3. pick 循环内:`checkpoint()` + `emit("正在处理 {i}/{total}", progress={...})`。 +4. 结束:全成功 `finish(success)`;部分失败 `finish(partial)`;全失败 `finish(failed)`。 +5. 同一浏览器 Profile / account lease:**禁止并发**。 +6. 兼容旧命令(例如旧 export 需先刷新)必须调用同一 sync / domain 内核,禁止重写采集逻辑。 + +详见 [`LOGGING.md`](LOGGING.md) §2.5 与 `scripts/service/task_service.py` 示范。可复用契约样例见 `tests/samples/test_action_core_contract.py.sample`、`tests/samples/test_sync_contract.py.sample`。 + +--- + +## 6. 队列 + pick(可选,Type B 批量) + +有「待处理列表」的技能建议: + +| 能力 | 约定 | +|------|------| +| 队列表 | `status`: `pending` → `processing` → `success` / `failed` | +| 入库 | 数据管理 CRUD,或 `import-*`(相对 `{skill_data}/input/`,见 [`DATA_PATHS.md`](DATA_PATHS.md)) | +| 消费 | `run --pick N`,默认仅 `pending` | +| 重试 | `reset` 或数据管理改 `pending`;**failed 不要自动重试** | +| 批次 | 可选 `batch_id`,便于筛选与任务中心对照 | +| Manifest | `*-run-pick`:建议 `async`,`placements` 按需含 `toolbar` / `cron` / `agent`;有 toolbar 则 `bind.tables` 必填 | + +批量有两种合理形态(都是**顺序**): + +| 形态 | 任务中心条数 | 适用 | +|------|-------------|------| +| 一次 `run-pick` 处理 N 条 | **1** 条 Job | 同类 RPA、同 Profile、定时消费队列 | +| Agent/UI 触发 N 次单条 `run` | **N** 条 Job | 每条独立追踪 / 独立重试 | + +默认优先 **一次 pick、一条 Job**。 + +--- + +## 7. 业务同步与系统导出 + +| 能力 | 方向 | 负责方 | +|------|------|--------| +| 同步 / refresh / collect | 外部来源 → 技能本地 SQLite | 技能 domain service | +| 导出当前数据表 | 本地库 → CSV/Excel | **宿主**数据管理统一导出 | +| 特殊业务报告(文件) | 多表合并 / 特殊格式 → `output/` | 仅系统导出不足时提供 `export-*` | +| HTML 页面报告 | 写入 `{skill_data}/html/*.html` | 技能生成;宿主扫描预览;重新生成 Action id 固定为 **`render-html`** | + +同步 Action 默认写库、不写导出文件。HTML 报告契约见 [`../references/ACTIONS.md`](../references/ACTIONS.md)「HTML 页面报告」与 [`DATA_PATHS.md`](DATA_PATHS.md)。通用幂等 / 事务 / 空结果保护见 [`../references/SCHEMA.md`](../references/SCHEMA.md)。 + +--- + +## 8. Agent / SKILL.md 契约(复制后必改) + +业务技能 `SKILL.md` / `references/` 应明确(Agent 主读,勿只写在本文): + +1. 短查询:共享 Python(PATH 上的 `python`)跑 CLI;**禁止** `uv run`。 +2. 长任务 / RPA / async Action:必须通过宿主 **Skill Action**(`run_skill_action`);**禁止** `exec` + `process poll`。 +3. 单条 vs 批量 action id 与参数(如 `url` / `pickCount`)。 +4. `executionProfile: async` → 进度看**任务中心** / Skill Run Card;`sync` → 当场结果。与入口无关。 +5. 多入口复用同一业务内核。 + +模板源仓库的 `SKILL.md` / `references/ACTIONS.md` 含占位段落;scaffold 后按业务改写。 + +--- + +## 9. Policy + +自动检测索引见 [`POLICY_MATRIX.md`](POLICY_MATRIX.md): + +- **POLICY-SKILL-ACTION-001**:Runtime 文档存在;schema 含 `executionProfile`;有 RPA 的业务技能须声明 async agent action。 +- **POLICY-SKILL-ACTION-002**:每个 Action 显式声明 `executionProfile`(sync/async)。 +- **POLICY-SKILL-ACTION-003**:toolbar Action 必须声明合法 `bind.tables`。 +- **POLICY-SKILL-ACTION-004**:placements 与 executionProfile 正交(文档/测试不得写死耦合)。 +- **POLICY-ARCH-CORE-001** / **POLICY-DATA-SYNC-001**:见矩阵;完整语义多用 sample / code review。 + +--- + +## 10. 与宿主 API(参考) + +| API | 用途 | +|-----|------| +| `GET /api/skill-actions/{skillSlug}` | 读 manifest | +| `POST /api/skill-actions/run` | 执行 action(sync inline / async job) | +| `GET /api/skill-actions/jobs` | 任务中心列表 | +| `GET /api/skill-actions/jobs/{jobId}` | 单 Job 详情 / 事件 | + +Agent 工具(插件):`list_skill_actions` → `get_skill_action` → `run_skill_action`;正常对话**不要**轮询 `get_skill_action_job`(交给 UI / 任务中心)。 + +表级 `bind.tables` 过滤依赖宿主支持;旧宿主忽略绑定时,toolbar Action 可能在技能全部数据表显示——新技能仍须显式绑定,待宿主升级后完整按表过滤生效。 diff --git a/development/TESTING.md b/development/TESTING.md new file mode 100644 index 0000000..5d9320c --- /dev/null +++ b/development/TESTING.md @@ -0,0 +1,282 @@ +# 测试开发指南 + +面向复制 `skill-template` 后的新业务 skill:**如何把自动化测试当作一等公民**,而不是等业务写完再补文档级别的空话。本文串起模板自带的 unittest 入口、`tests/` 目录分层与安全档位约定;**更细的开关取值、表格字段与环境变量组合仍以 [`tests/README.md`](../tests/README.md) 为权威来源**。建议你随手开一个编辑器分页:`TESTING.md`(本篇)、[`DEVELOPMENT.md`](DEVELOPMENT.md)(整体节奏)、[`tests/README.md`](../tests/README.md)(落地细则)。 + +默认心智模型可以用一句话概括:**根目录 `test_*.py` = CI / 本地每次提交都应能通过的无外联套件**;`*integration*`、`*.sample`、`desktop/` = 只在人被明确要求时才启用的高风险或重量级路径。 + +--- + +## 1. 测试体系总览 + +本模板把测试分成四层漏斗:**默认必跑(unittest + `run_tests.py`)**、从 `tests/samples/` **按需复制的 service / golden**、放在 `tests/integration/` **默认不落盘的仿真或真实联调范式(多数仍是 `.sample`)**、以及 **desktop E2E(pytest + 宿主 SDK)**。 + +[`tests/run_tests.py`](../tests/run_tests.py) 只做三件事:把 `scripts/` 与 `tests/` 放进 `sys.path`、做 Windows UTF-8 包装、收集 **`tests/` 根目录**下的 `test_*.py`。它不递归子目录——这正是刻意的安全边界:**不想让 AI 或拷贝粘贴 accidentally 把 integration 拉进默认套件**。 + +当你在设计一个新 skill 的测试策略时,请先问自己:**这段代码在没有外部凭证与浏览器的前提下是否有意义?** 若有,留在默认套件;若无,放进 integration / `.sample`,并要求明确的 `OPENCLAW_TEST_TARGET` 组合开关。 + +更深表格化的目录映射、`FakeAdapter` 与 profile 的耦合细节见 [`tests/README.md`](../tests/README.md) 开头章节『我该把测试写在哪里』。 + +--- + +## 2. 默认必跑测试要做什么 + +必跑套件要像一个紧张的守门员:**快、确定、离线**。典型覆盖: + +- CLI:导入 [`cli.app`](../scripts/cli/app.py) 走解析链路、`health`(runtime diagnostics)/ `version` / `init-db` 冒烟; +- 架构守护:无 `scripts/jiangchang_skill_core/`、`platform-kit>=1.2.0` 导入来源、文档/runtime 标准(见 `test_platform_import.py` 等); +- **真实 subprocess**:[`tests/test_entrypoint_subprocess.py`](../tests/test_entrypoint_subprocess.py) 再调用一遍 `python scripts/main.py`,防路径漂移; +- 运行时:`runtime_paths` 与 **`JIANGCHANG_*` 隔离**; +- `SKILL.md` YAML slug vs [`constants.SKILL_SLUG`](../scripts/util/constants.py); +- SQLite 骨架:`init_db` 幂等创建元数据表,默认不建业务表; +- 数据管理元数据:`_jiangchang_*` 幂等初始化、中文展示名、`PRAGMA table_info` 字段顺序、editable 权限(见 `test_display_metadata.py`、`test_data_management_contract.py`); +- Skill Action manifest:`assets/actions.json` 结构、CLI 映射、placements 与 inputSchema 守护(见 `test_actions_manifest.py`); +- 标准时间字段:`created_at` / `updated_at` 默认值、trigger 维护与 `datetime_unix_seconds` 元数据(见 `test_timestamp_columns.py`); +- **adapter profile**:[`tests/test_adapter_profile_policy.py`](../tests/test_adapter_profile_policy.py) + [`adapter_test_utils`](../tests/adapter_test_utils.py) ——验证在未授权情况下绝不误判开启真实网络/RPA。 +- **发布打包守护**:[`tests/test_release_packaging_constraints.py`](../tests/test_release_packaging_constraints.py) ——`scripts/**/*.py` 单文件 < 1000 行、文本文件 UTF-8 without BOM。 +- **开发规范守护(hard policy)**:[`tests/test_development_policy_guard.py`](../tests/test_development_policy_guard.py) ——规则索引见 [`development/POLICY_MATRIX.md`](POLICY_MATRIX.md),只执行可机器检测的 hard 规则。 +- **slug 语义**:[`tests/test_slug_naming.py`](../tests/test_slug_naming.py) ——verb-noun-platform 命名规范校验。 +- **HTML 报告壳**:[`tests/test_html_page_service.py`](../tests/test_html_page_service.py) 守护 `assets/html/example-report.html` 的数据管理 iframe 预览布局基线;业务技能若有自定义 `assets/html/*.html`,建议在自有仓库加同类静态断言(见 `references/ACTIONS.md`「宿主预览布局」)。 + +**原则:`tests/test_*.py` 不允许隐形访问外网、不允许拉起真实浏览器、不允许读写开发者机器的真实数据根**。如果需要仿真服务器,也应仅在 integration(并由档位变量放行)。 + +套件能力与表格参见 [`tests/README.md`](../tests/README.md) 「1.2 默认套件覆盖」。 + +--- + +## 3. 数据隔离:IsolatedDataRoot + +[`tests/_support.py`](../tests/_support.py) 提供的上下文管理器 `IsolatedDataRoot()`:进入一个专用临时目录,写入 `JIANGCHANG_DATA_ROOT`(tempfile)与 `JIANGCHANG_USER_ID` → `_test`。 + +结束时:**恢复原 environ**,删除目录。 + +这样可以断言:**SQLite DB / spill files / caches** 都在 sandbox;不会在开发者桌面遗留 `{REAL_ROOT}`。 + +示例: + +```python +from _support import IsolatedDataRoot + +def test_whatever(): + with IsolatedDataRoot(): + from db.connection import init_db + init_db() + # …断言读写均在隔离路径… +``` + +**不要把真实凭证路径硬编码进默认测试**:隔离不等于你有权触碰真实目录。 + +--- + +## 4. 测试目标档位(OPENCLAW_TEST_TARGET) + +模板采用统一闸门:**你想跑到哪一层外部世界,就用变量明说**。合法档位(非法值会让 helper 抛错)如下——**直接摘录自 [`tests/README.md`](../tests/README.md) §5.1**: + +| 取值 | 含义 | +|------|------| +| `unit` | **默认**:单元 / 内存 / mock,不跑仿真与真实外联 | +| `mock` | 与 `unit` 类似的安全档位(显式语义) | +| `simulator_api` | 仅允许 **仿真 HTTP** 类集成(如 localhost) | +| `simulator_rpa` | 仅允许 **仿真页面 / 录播 RPA** | +| `real_api` | 真实 API(显式设 `OPENCLAW_TEST_TARGET=real_api`) | +| `real_rpa` | 真实 RPA(显式设 `OPENCLAW_TEST_TARGET=real_rpa`) | + +未设置环境变量 ⇒ 等价 `unit`。 + +默认策略摘要:**不要在 unittest 必跑路径误设 `OPENCLAW_TEST_TARGET=real_*`**(CI 仍离线)。业务 `.env` 模板默认是 `real_rpa`,与「单测用 mock」不冲突;**mock 通 ≠ 交活**。 + +档位读取与业务代码一致:经 `jiangchang_skill_core.config.get("OPENCLAW_TEST_TARGET")`(见 `CONFIG.md`)。 + +--- + +## 5. FakeAdapter:怎么模拟外部系统 + +[`tests/adapter_test_utils.py`](../tests/adapter_test_utils.py) 暴露 `FakeAdapter`,典型四种 **mode**: + +| mode | 用途 | +|------|------| +| `success` | 构造干净的成功响应路径 | +| `timeout` | 模拟悬挂 / 慢链路 | +| `invalid_response` | 畸形负载 / schema drift | +| `unauthorized` | token / license / cookie 失效语义 | + +**何时用它**:service 层出现『调用第三方 HTTP / RPA stub』但又不能把真实系统纳入 CI。**契约测试**(复制 [`tests/samples/test_service_contract.py.sample`](../tests/samples/test_service_contract.py.sample))应优先组合 FakeAdapter,而不是直接把 CLI when-json 断言堆上天。 + +把它看成:**你把不确定性收敛到可控的测试矩阵里**,而不是在生产日志里才第一次看到错位字段。 + +--- + +## 6. 怎么从 .sample 启用一个测试 + +步骤模板: + +1. 找到范式文件(例如 [`tests/samples/test_service_contract.py.sample`](../tests/samples/test_service_contract.py.sample))。 +2. **复制**到 `tests/` 根:`tests/test_service_contract.py`(去掉 `.sample`)。 +3. 打开副本:**替换占位函数名 /技能特有枚举 / adapter profile**,删掉与本技能无关的示例断言。 +4. 本地执行:`python tests/run_tests.py -v [可选筛选关键词]`。 +5. **不要把 integration `.sample` 批量改名混进根目录**——除非你已经读过 [`tests/integration/README.md`](../tests/integration/README.md) 的风险清单。 + +Golden fixture 流程同理([`tests/samples/test_golden_cases.py.sample`](../tests/samples/test_golden_cases.py.sample))。 + +--- + +## 7. 真实联调测试的安全约束 + +任何 touching **真实租户数据** 的路径: + +1. **禁止**硬编码 token / cookie / 内部域名落入仓库; +2. **禁止**默认套件隐式导入 integration; +3. **真实 RPA** 只能标记为手动触发(双人复核 / 本地 `.env` 不入库)。 + +范式阅读 [`tests/integration/README.md`](../tests/integration/README.md):那里有针对凭证来源、目录 artifact 忽略策略的补充。 + +记住:**测试代码也是一种部署面**,别把 staging 凭证写死进仓库。 + +--- + +## 7.1 simulator_rpa 联调检查表 + +浏览器 RPA 走 `simulator_rpa` 档位联调前,逐项确认: + +- [ ] 数据目录 `.env` 中 `OPENCLAW_TEST_TARGET=simulator_rpa`(非 `mock` / `unit`) +- [ ] account-manager:`ensure-web`(或 `pick-web --ensure`)+ 账号有 `profile_dir`(无 profile 禁止开浏览器,见 `RPA.md` §0.2) +- [ ] `OPENCLAW_BROWSER_HEADLESS=0`(联调有头;勿默认无头) +- [ ] 目标 sandbox UI 已部署(跨团队;本地可用 `sandbox/demo_app.html`) +- [ ] 失败先查 `rpa-artifacts/` 截图,再查 Chrome profile 缓存(`--user-data-dir` 手工打开清站点数据) +- [ ] 默认 `python tests/run_tests.py -v` 仍全部通过(mock 离线);example 内 `pytest` 不启真实浏览器 + +参考实现:`examples/simulator_browser_rpa/README.md`。 + +--- + +## 8. 新 skill 的最小测试清单 + +以下清单 **原文摘自 [`tests/README.md`](../tests/README.md) 「新技能最小测试清单」**(复制新仓库后逐项勾选): + +- [ ] `python tests/run_tests.py -v` 能通过。 +- [ ] `python scripts/main.py health` 能通过。 +- [ ] `python scripts/main.py version` 输出 JSON,且 `skill` 与目录名 / `SKILL.md` / `constants.SKILL_SLUG` 一致。 +- [ ] `python scripts/main.py init-db` 幂等创建本地库并输出 JSON(`ok` / `skill` / `db_path`)。 +- [ ] 所有 DB / 文件写入都在 `IsolatedDataRoot`(或等价隔离)下测试,不写真实数据目录。 +- [ ] 外部系统默认使用 mock / `FakeAdapter`,不访问真实 API,不打开真实 RPA。 +- [ ] 至少有 1 个成功路径测试。 +- [ ] 至少有 1 个缺必填字段 / 非法输入测试。 +- [ ] 如果有 adapter,至少覆盖 timeout / unauthorized / invalid response(可用 fake 模拟)。 +- [ ] 如果有解析 / 计算 / 校验类业务,至少保留 1 组 golden fixture(脱敏)。 +- [ ] 若有 `assets/actions.json`,`tests/test_actions_manifest.py` 通过;不需要 Action 时可删除 manifest。 +- [ ] 若有本地 SQLite 业务表,`_jiangchang_*` 元数据与 `tests/test_data_management_contract.py` 规则一致。 +- [ ] 真实 API / 真实 RPA 测试只放 `tests/integration/`,并且默认 `.sample`,不默认运行。 + +--- + +## 9. AI 编程工具使用测试时的红线 + +改编自 [`tests/README.md`](../tests/README.md) 「AI 编程工具注意事项」,压缩成 skill 开发者视角: + +| 红线 | 解释 | +|------|------| +| 不改业务凑测试 | 除非需求变更已确认,否则**不要为了让 CI 变绿而砍掉业务分支** | +| **默认套件零外联** | 不把真实 HTTP / 浏览器写进 `tests/test_*.py` | +| `.sample` 尊重 | 集成范式改名前先读完 README;别让 `.sample` 悄悄变成根测试 | +| **零硬编码凭证** | token / cookie / 生产 URL → 用虚构域名或 vault ref | +| mock 优先 | 逻辑应在 service + FakeAdapter,而不是巨胖 CLI 断言 | +| 结构化错误 | 断言错误码字段,而不是 substring of stderr 漂移集合 | +| **integration = 显式档位** | 仅 `OPENCLAW_TEST_TARGET`(`real_*` 只放 integration / 手动触发) | + +--- + +## 9.1 开发规范守护测试 + +[`tests/test_development_policy_guard.py`](../tests/test_development_policy_guard.py) 将 [`development/POLICY_MATRIX.md`](POLICY_MATRIX.md) 中的 **hard policy** 落地为默认必跑 unittest: + +- 每条失败信息包含 `policy_id` 与来源文档路径,便于定位规范出处。 +- 只覆盖目录结构、runtime 依赖边界、配置读取、默认测试安全档位、RPA 禁令等**可静态扫描**规则;软规则列在矩阵「暂不自动检测」小节,**不**做成测试。 +- `POLICY-TESTING-001`:根目录 `tests/test_*.py` 非白名单文件不得出现 `real_api` / `real_rpa` / `OPENCLAW_TEST_TARGET`;**唯一白名单** `test_adapter_profile_policy.py`(档位 helper 自测)。`adapter_test_utils.py` 与 `tests/integration/*.sample` 不在默认套件内,不受此条扫描。 +- `POLICY-RPA-002`:只禁止 `subprocess` / `os.system` / `os.popen` **直接执行** ffmpeg,不误伤 `ffmpeg_path` 等诊断字段。 +- 与已有测试分工:`test_release_packaging_constraints.py` 继续守护 PyArmor 行数与 UTF-8 BOM;`test_no_rpa_helpers_import.py` 与 policy guard 的 `POLICY-RPA-001` 语义一致,二者并存防回归。 + +### 日志规范测试 + +[`tests/test_development_policy_guard.py`](../tests/test_development_policy_guard.py) 中的 `POLICY-LOGGING-*` 规则会检查 logging **hard policy**(见 [`POLICY_MATRIX.md`](POLICY_MATRIX.md)): + +- CLI 是否调用 `setup_skill_logging`(`POLICY-LOGGING-001`) +- `task_service.py` 是否使用 `get_skill_logger`、含 `log.info` / `log.exception`(`POLICY-LOGGING-002` / `003`) +- 长任务模板是否使用 Activity 或 RPA video step(`POLICY-LOGGING-004`) +- 交付代码是否在 logging 调用中拼接明显敏感字段(`POLICY-LOGGING-005`) +- **域模块**(`scripts/service/**`,豁免薄工具)是否有文件日志调用(`POLICY-LOGGING-006`) +- RPA 技能是否具备事件词 `task_start` / `task_failed|rpa_failed` / `stage=` / 鉴权事件(`POLICY-LOGGING-007`) +- `print(...ERROR:)` 是否与同函数内 `log.warning|error|exception` 共现(`POLICY-LOGGING-008`) + +**局限**:hard 保证事件词与「失败先落盘」底线,**不**判断每个控件一步一日志。复制新 skill 后仍建议对照 [`LOGGING.md`](LOGGING.md) §3 事件词表,并按需启用 [`tests/samples/test_logging_contract.py.sample`](../tests/samples/test_logging_contract.py.sample)。 + +新增或调整开发规范时:**先更新 `POLICY_MATRIX.md`,再补/改 `test_development_policy_guard.py`(或注明已有测试覆盖)**。 + +--- + +## 10. 测试和发布的关系 + +[`release.ps1`](../release.ps1) **默认**在 **auto commit 之前**、创建 tag 之前,对**当前工作区**执行 `python tests/run_tests.py -v`;测试失败会终止 release,不会自动提交、不会打 tag。仅紧急排障时可传 `-SkipTests`(会输出黄色警告,不应作为常规发布路径)。`-DryRun` 仍会实际运行默认测试。 + +即便手工发布,也应在运行 `release.ps1` 之前确认 `python tests/run_tests.py -v` 绿色。失败的默认套件意味着: + +- 打包路径可能根本不可运行; +- CI 加密前的静态假设可能在宿主崩溃; +- metadata slug 漂移将被市场拒绝。 + +把『本地 unittest 绿』视作 tag 的前置条件,而不是『有空再跑』。发布流水线成功后仍要做安装验证——那是另一个维度;**测试是第一个维度**。 + +--- + +## 11. pytest 收集卫生标准 + +模板根目录提供 `pytest.ini`(或等价配置),约束: + +- **只收集** `test_*.py` / `*_test.py` +- **不让** `.txt`、结果文件、日志文件被 pytest 误收集 +- `norecursedirs` 排除 `integration/`、`desktop/`、`samples/`、`fixtures/`、`artifacts/`、`diagnostics/` + +测试结果文件**不要**放在 `tests/` 根目录;应放 `tests/artifacts/` 或 `tests/diagnostics/` 并加入 `.gitignore`。 + +默认 `python tests/run_tests.py` 仍只发现 `tests/` 根目录一层 `test_*.py`(不递归子目录),与 pytest 策略一致。 + +--- + +## 12. RPA / video 测试标准 + +- 模板 `.env.example` 默认 **`OPENCLAW_RECORD_VIDEO=0`**;默认必跑测试**不应启动真实 ffmpeg**(patch session 或显式设 `OPENCLAW_RECORD_VIDEO=0`)。 +- **但测试必须守护录屏接入能力**(`POLICY-RPA-003` 扫描 `scripts/service/*.py`):`RpaVideoSession` 被构造、`video.add_step` 被调用、video artifact 结构 merge 进 `finish()`(`video` / `video_path` 等字段),与默认是否录屏无关。 +- 断言 `title` / `closing_title` 是**中文**业务文案。 +- 断言 step 文案贴近用户动作,不是技术日志(如「准备执行示例任务」而非「enter cmd_run」)。 +- 若需测试**真实录屏**(ffmpeg 成片),必须显式设 `OPENCLAW_RECORD_VIDEO=1`,并放在 `tests/integration/` 或手动触发场景,**不得**进入默认 unittest 套件。 + +参考 `tests/test_video_service.py`。 + +--- + +## 13. 宿主 E2E 标准 + +- 使用 `jiangchang_desktop_sdk.e2e_helpers`(见 `tests/desktop/` 下 `.sample`)。 +- **不伪造**用户数据目录;通过宿主 IPC 获取真实 `skill_data_dir`。 +- E2E **不自动生成**真实密钥或生产凭证。 + +--- + +## 14. 测试前检查清单(模板守护) + +复制新 skill 或修改模板后,确认: + +- [ ] `requirements.txt` **不含** `jiangchang-platform-kit` / `playwright` +- [ ] 无 `scripts/jiangchang_skill_core/` vendored 副本 +- [ ] `platform_kit_min_version` **>= 1.2.11**(`SKILL.md` + `constants.py`) +- [ ] `health` 能输出 `platform_kit_version_ok`(或等价诊断行) +- [ ] `config-path` 可输出用户 `.env` 路径 JSON +- [ ] `pytest.ini` 存在且 `python_files` 只收集 `test_*.py` / `*_test.py` +- [ ] pytest **不会**误收集 `tests/` 下的 `.txt` / 日志 / 结果文件 +- [ ] `python tests/run_tests.py -v` 全部通过 +- [ ] `scripts/**/*.py` 单文件 < 1000 行(`test_release_packaging_constraints.py`) +- [ ] 文本文件为 UTF-8 without BOM,无 `U+FEFF`(同上) + +--- + +- [`tests/README.md`](../tests/README.md) — 表格、变量与目录细则 +- [`DEVELOPMENT.md`](DEVELOPMENT.md) §14.5 — 测试驱动的开发顺序 +- [`tests/integration/README.md`](../tests/integration/README.md) — 高风险用法 diff --git a/development/TUTORIAL_SURFACES.md b/development/TUTORIAL_SURFACES.md new file mode 100644 index 0000000..fa9e3c8 --- /dev/null +++ b/development/TUTORIAL_SURFACES.md @@ -0,0 +1,222 @@ +# 用户市场文档契约(说明 Tab / 教程 Tab) + +本文给**技能开发者**与 **AI 编程助手**读:规定根目录 `README.md`(技能说明)与 `TUTORIAL.md`(使用教程)**写什么、不写什么**,以及如何与宿主三入口、入口定位表、`actions.json` 对齐。 + +**单一真相链:** + +```text +REQUIREMENTS.md §0.1 入口定位表 + ↓ +assets/actions.json(placements / label / bind) + ↓ +TUTORIAL.md(用户可读、分入口操作) +README.md(能力与边界;不写步骤) +``` + +产品入口「挂什么」见 [`PRODUCT_SURFACES.md`](PRODUCT_SURFACES.md)。本文只管**用户市场文档怎么投影**。 + +--- + +## 0. 与四 Tab 的关系 + +| 文件 | 市场 Tab | 本文角色 | +|------|----------|----------| +| `README.md` | **说明** | §1:是什么 / 给谁 / 边界 | +| `TUTORIAL.md` | **教程** | §2~§6:怎么用(分入口) | +| `DEMO.md` | 演示 | 观看顺序可对齐三入口,不另造标准 | +| `CHANGELOG.md` | 更新日志 | 与文档契约无关 | + +**铁律:** 说明就是说明,教程就是教程。**禁止**在 README 里写分步操作、Agent 话术块、排障「点哪里」;这些一律进 `TUTORIAL.md`。 + +### 0.1 标题层级(说明 / 教程共用) + +宿主详情页**已经**展示技能名;「说明 / 使用教程」Tab(及数据管理右侧教程抽屉)**已经**标明文档类型。因此: + +| 层级 | 用途 | +|------|------| +| `#` | **正文不要用**(会与页头/Tab 标题重复抢视觉) | +| `##` | 主分节(能帮你做什么、入口一:数据管理…) | +| `###` | 分节内步骤 | + +**禁止:** + +- `README.md` 文首 `# 【技能名】` / `# 审计疑点辅助` 这类与页头重复的 H1 +- `TUTORIAL.md` 文首 `# 【技能名】· 使用教程` 这类与 Tab/抽屉标题重复的 H1 + +去掉重复 H1 后,**不要**把原来的 `##` 全部升成 `#`。 + +--- + +## 1. README.md(说明 Tab) + +### 1.1 必须回答 + +| 问题 | 写法 | +|------|------| +| 这是什么 | 开篇一两句价值(**普通段落或引用**,不要再写技能名 H1) | +| 能做什么 | 3~6 条能力 bullet(**无步骤编号**) | +| 适合谁 / 场景 | 业务语言 | +| 使用前准备 | 安装、账号、权限等**前置条件**;不写「去哪张表点哪」 | +| 执行后得到什么 | 结果形态(表、报告、文件),**不教怎么生成** | +| 注意事项 | 边界、风险、人工复核 | +| 怎么使用(可选,短) | **一两句**:支持哪些入口 + **「步骤见使用教程 Tab」** | + +### 1.2 禁止写入 README + +- `1.` `2.` `3.` 操作步骤(如「选品任务 → 点某某按钮」) +- 「你可以这样告诉 Agent」整节或大块 `>` 示例话术 +- 「入口一 / 入口二」专章结构 +- 逐步排障(「先点 A 再点 B」) +- frontmatter `description` 不要写「以及如何开始使用」类教程承诺 + +### 1.3 FAQ 边界 + +- README FAQ:**概念类**(和真实系统区别、从哪开始学、是否支持定时)→ 可指向「使用教程」Tab +- **操作类**排障 → 只写在 `TUTORIAL.md`「常见卡住怎么办」 + +### 1.4 建议章节(标题可微调) + +```text +(开篇一两句价值;不要写 # 技能名) +## 这个技能能帮你做什么 +## 适合什么场景 +## 使用前需要准备什么 +## 怎么使用 ← 仅短摘要 + 指向教程 +## 执行后你会得到什么 +## 注意事项 +## 常见问题 ← 概念类 +``` + +--- + +## 2. TUTORIAL.md(教程 Tab)必选结构 + +复制业务技能后,**验收章节(标题可微调,语义不可缺)**: + +```text +(开篇:数据管理与对话平行;定时有/无一句。不要写 # 技能名 · 使用教程) + +## 本技能做什么 ← 业务数据流(非 CLI/SQL) +## 开始前 +## 入口一:数据管理 ← §0.1 有 toolbar/row/batch/replaces 则必填 +## 入口二:对话任务 ← §0.1 有 agent 则必填 +## 入口三:定时任务 ← 有 cron:怎么用;无 cron:不需要 + 理由 +## 主能力对照表 ← 双挂能力必填 +## 常见卡住怎么办 +``` + +### 2.1 与入口定位表的关系 + +| §0.1 / actions | TUTORIAL 要求 | +|----------------|---------------| +| 任一能力挂数据管理(toolbar/row/batch/replaces) | **必须**有「入口一:数据管理」专章 | +| 任一能力挂 `agent` | **必须**有「入口二:对话任务」专章(**禁止**用「或者你也可以说…」替代专章) | +| 任一能力挂 `cron` | **必须**有「入口三」且写清如何建定时、选哪个 Action | +| **无** `cron` | **必须**写「本技能不需要定时」+ 一句理由(与 §0.1 一致) | +| 某能力只挂一端 | 对照表注明「仅数据管理」或「仅对话」 | + +### 2.2 平行关系(数据管理 ∥ 对话) + +- 两者是**平行主入口**,不是「对话主、点表辅」或反过来 +- 禁止全文只有「第一步:打开对话说清目标」且无数据管理专章 +- 允许用户只用其中一种;教程须写清「两种效果一致,选你习惯的」 + +--- + +## 3. 入口一:数据管理(怎么写) + +适合写: + +1. 左侧有哪些业务表、各干什么(业务语言) +2. 首次准备(载入/导入/新建)——**按钮必须出现在用户能看到的地方**(见 §5) +3. 主流程:进哪张表 → 是否要选中行 → 点哪个 `label` → 去哪看结果 +4. 行级 / 批量操作(若有 `row` / `batch`) + +按钮中文名 = `assets/actions.json` 的 `label`,不要另造别名。 + +--- + +## 4. 入口二:对话任务(怎么写) + +适合写: + +1. 什么时候用对话(与数据管理互补) +2. 示例话术表(与主能力一一对应) +3. Agent 可能追问什么、用户怎么答 + +**不要**把对话写成唯一路径;也不要把话术塞进 README。 + +--- + +## 5. 准确性铁律(用户看得见才写) + +教程步骤必须对应 **宿主真实 UI**,不是只对应 manifest 声明。 + +| 规则 | 说明 | +|------|------| +| **placements 对齐** | 教程写的入口 ∈ `actions.json` 已挂 placement | +| **skill-detail 不可作主路径** | 声明了 `skill-detail` **不等于**详情页一定渲染出按钮;当前宿主常只显示四 Tab。**禁止**把「技能详情 → 点某某」写成用户主路径 | +| **准备/载入/导入类** | 优先挂 **数据管理 `toolbar`** + `bind.tables`(空表也可点的能力尤其如此);可保留 `agent`;`skill-detail` 仅作可选兼容 | +| **写入后刷新** | 建议 `steps: [{ "when": "afterSuccess", "do": "refreshCatalog" }]`,避免用户点完表不刷新 | +| **对照表** | 「想做什么 \| 数据管理 \| 对话」覆盖双挂主能力;`label` 与界面一致 | + +字段细节见 [`../references/ACTIONS.md`](../references/ACTIONS.md)「用户可见性与教程准确性」。 + +--- + +## 6. 入口三:定时任务(可选) + +### 6.1 何时挂 `cron`(教程教「怎么用定时」) + +- 参数相对固定、按节奏重复(同步、巡检、消费队列) +- 无人值守仍安全、可幂等或可失败重试 +- 与数据管理是「白天点选 + 夜里自动啃」组合 + +教程写:侧栏定时任务 → 新建 → 选本技能 → 选 Action → 填参 → 保存/立即运行。 + +### 6.2 何时不挂(教程写「不需要」) + +- 一次性 / 课堂演示,对象当场指定 +- 强不可逆、须当场确认 +- 每次对象不同、须人指定链接或范围 +- 主路径依赖验证码 / 人工介入 + +决策三问(与 [`PRODUCT_SURFACES.md`](PRODUCT_SURFACES.md) §2.3 一致): + +1. 明天人不盯着、同样参数再跑一遍,业务上合理吗? +2. 失败后自动重跑可接受吗? +3. 有没有须人当场确认或动态指定的输入? + +①是 且 ②可接受 且 ③否 → 可挂 cron;否则不挂,§0.1 写理由,教程声明 N/A。 + +--- + +## 7. 复制后自检(发布前) + +- [ ] README 无分步操作、无 Agent 话术块 +- [ ] TUTORIAL 有三入口结构(定时有或 N/A) +- [ ] 对照表与 `actions.json` `label` 一致 +- [ ] 准备类按钮在数据管理表顶可见(若技能有数据管理主路径) +- [ ] 教程未把 skill-detail 当唯一载入/主流程入口 +- [ ] §0.1「挂」的入口在 TUTORIAL 有专章;「不挂」有理由且教程一致 + +交活清单见 [`CHECKLIST.md`](CHECKLIST.md) A2-7~A2-11。 + +--- + +## 8. 参考样例 + +已验收参考(业务技能,非模板正文):`client-gdcm/review-sku-selection-default` +(说明 Tab 瘦身 + 教程三入口 + 「载入课堂样例」挂 `selection_cases` toolbar。) + +复制模板后**不要**照搬选品表名与样例编码;只复用**章节结构与职责划分**。 + +--- + +## 9. 相关文档 + +- 宿主入口何时挂:[`PRODUCT_SURFACES.md`](PRODUCT_SURFACES.md) +- 入口定位表:[`REQUIREMENTS.md`](REQUIREMENTS.md) §0.1 +- Action placements / 可见性:[`../references/ACTIONS.md`](../references/ACTIONS.md) +- 市场四 Tab 同步:[`DEVELOPMENT.md`](DEVELOPMENT.md) §6 附近「用户市场四 Tab」 +- 交活验收:[`CHECKLIST.md`](CHECKLIST.md) §2 diff --git a/evals/README.md b/evals/README.md new file mode 100644 index 0000000..fadf59e --- /dev/null +++ b/evals/README.md @@ -0,0 +1,5 @@ +# evals + +这里放人工评估脚本、示例输入输出、回归验收清单等材料。 + +模板仓库保持最小占位,不再放旧式杂项代码片段。 diff --git a/evals/golden-cases.md b/evals/golden-cases.md new file mode 100644 index 0000000..842245d --- /dev/null +++ b/evals/golden-cases.md @@ -0,0 +1,23 @@ +# 人工验收评估场景(金标准用例) + +> 配套自动化测试:`tests/test_draft_rules.py`。 + +## 场景 A:全量数据完整初稿 + +预期 论文3/专利1,实际 论文4/专利1,决算多科目 → 完成情况"超额/持平"、决算占比合计 100%、docx 含全部六章节。 + +## 场景 B:缺年度报告 + +叙事素材仅来自代表性成果;第 2 章出现 [待补充] 占位。 + +## 场景 C:缺决算 + +决算章节生成"明细尚未录入"占位,不阻塞整稿。 + +## 场景 D:批量生成 + +多个待结题项目 → 任务中心进度、逐份 Word 生成、状态改为"初稿已生成"。 + +## 场景 E:宿主 Word 验收 + +初稿记录中取 Word 路径 → 下载打开 → 章节顺序、表格、来源标注与 [待补充] 提示齐全。 diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..5f9cdff --- /dev/null +++ b/examples/README.md @@ -0,0 +1,88 @@ +# Skill Template Examples + +`examples/` 是面向**技术人员与 AI 编程工具**的**核心实现案例**目录:提供可运行、可测试、可复制的参考代码,用于指导新技能的 `scripts/` 应如何编写。 + +**`examples/` 不是完整 skill 仓库。** 不要整包照搬 `examples/<mode>/` 到新技能根目录。应按各 example README 的 **copy map**,**选择性复制**到新技能的 `scripts/`(及按需的 `tests/`)。 + +复制任何案例前先读 [`../development/AI_RPA.md`](../development/AI_RPA.md):案例只示范授权范围内的正常界面自动化,禁止把它扩展为破解、逆向、私有协议、验证码绕过、Hook / 注入、自动换号或规避平台限制的实现。 + +根 `README.md`、`SKILL.md`、`references/`、`development/` 仍按模板主目录规则编写,**不要**从 `examples/` 复制。 + +## 外部交互示例矩阵 + +adapter 仍是 `真实/仿真 × API/RPA` 四档;RPA 再按浏览器、Windows 桌面和手机三种界面拆分。原四类目录不可删除、不可移动,桌面和手机目录用于补足界面维度。 + +| 目录 | 类型 | 状态 | 适用场景 | +|---|---|---|---| +| `real_browser_rpa` | 真实浏览器 RPA | **已有案例** | 真实第三方网站、登录态、人工验证、滚动采集 | +| `simulator_browser_rpa` | 仿真浏览器 RPA | **已有案例** | 自有 sandbox、可控页面、表单 RPA、adapter 分层 | +| `real_desktop_rpa` | 真实 Windows 桌面 RPA | **已有成功来源并已提炼** | UIA 拾取、窗口/控件、probe、dry-run/assisted | +| `mobile_rpa` | Android/iOS RPA | **仅规格,待成功案例** | 设备、控件树、Native/WebView、example 完成契约 | +| `real_api` | 真实 API | **占位** | 真实系统接口、token、权限、限流(尚未沉淀可复制实现) | +| `simulator_api` | 仿真 API | **占位** | mock/sandbox API、无需浏览器(尚未沉淀可复制实现) | + +## 两类浏览器 RPA 的区别 + +| | real_browser_rpa | simulator_browser_rpa(修正后) | +|---|---|---| +| 目标 | 真实第三方网页 | 自有仿真页面 / sandbox / jc2009 类 | +| 风控 | 高(验证码、登录态、反爬) | 低(DOM 可控) | +| 账号 | account-manager + profile | account-manager + profile(`simulator_rpa` 档) | +| 用途 | 生产级真实采集/操作参考 | adapter 分层 + 表单 RPA 教学 | + +### 工程范式对比 + +| 工程范式 | real_browser_rpa | simulator_browser_rpa(修正后) | +|---|---|---| +| async Playwright | ✅ | ✅ | +| 薄 adapter + `*_playwright.py` | `task_rpa.py` | `simulator_playwright.py` | +| account-manager subprocess | ✅ | ✅(`simulator_rpa` 档位) | + +两者工程范式一致,差异在目标系统(真实站 vs 仿真站/本地 demo)。 + +## 如何选择示例 + +| 你的场景 | 参考目录 | +|---|---| +| 真实网站 + 浏览器 + 登录/验证码/滚动 | **real_browser_rpa** | +| 仿真页面 + 浏览器 + 表单/批量提交 | **simulator_browser_rpa** | +| Windows 原生软件 + UIA | **real_desktop_rpa** | +| Android/iOS App | **mobile_rpa**(当前仅规格,不可作为成功实现参考) | +| 真实系统 API | **real_api**(当前占位,不可作为实现参考) | +| mock / sandbox API(无浏览器) | **simulator_api**(当前占位,不可作为实现参考) | + +## 轻量结构约定 + +每个 `examples/<mode>/` 推荐结构: + +```text +examples/<mode>/ + README.md + scripts/ + service/ + util/ + tests/ +``` + +- `simulator_browser_rpa` 可额外包含 `sandbox/` +- `real_desktop_rpa` 可额外包含脱敏的 `assets/element-map` 与 selector capture +- 仅未来确实需要样例数据时可加 `fixtures/` +- **不要**在 `examples/<mode>/` 下要求或添加:`SKILL.md`、根市场 `README.md`、`references/`、`development/`、`assets/`、`.github/`、release workflow 等完整 skill 仓库层文件 + +## `scripts/` 与 `examples/` 边界 + +| | `scripts/` | `examples/` | +|---|---|---| +| 定位 | 真实技能业务逻辑的标准骨架 | 核心实现案例与复制参考 | +| 内容 | 干净的技能骨架,无教学/demo 代码 | 可运行参考实现,非完整 skill | +| 旧示例 | **不得**出现 `example_*` 目录或文件 | 四类一级目录固定保留 | + +复制规则: + +- 真实浏览器 RPA → 参考 `examples/real_browser_rpa/` +- 仿真浏览器 RPA → 参考 `examples/simulator_browser_rpa/`(adapter 分层权威参考:`scripts/service/adapter/`) +- Windows 桌面 RPA → 参考 `examples/real_desktop_rpa/`(真实成功来源:`add-friend-wecom`) +- 手机 RPA → 先按 `examples/mobile_rpa/` 定义完成标准;真实成功案例补齐前不得照骨架宣称跑通 +- `real_api/`、`simulator_api/` 当前为规划占位,**不得**作为实现参考 + +> 示例提供的是**参考架构与边界**,不是业务代码原样复制。复制前先读对应 README 的 copy map 与「禁止照抄」章节。 diff --git a/examples/mobile_rpa/README.md b/examples/mobile_rpa/README.md new file mode 100644 index 0000000..985574b --- /dev/null +++ b/examples/mobile_rpa/README.md @@ -0,0 +1,94 @@ +# Mobile RPA Example Specification + +本目录定义 Android/iOS example 必须具备的结构和验收契约。**当前没有团队真实成功案例,因此本目录不是可复制实现,也不得宣称已跑通。** + +先读: + +1. [`../../development/RPA_ELEMENT_LOCATING.md`](../../development/RPA_ELEMENT_LOCATING.md) +2. [`../../development/RPA_MOBILE.md`](../../development/RPA_MOBILE.md) + +## 为什么现在不放伪代码实现 + +移动端 selector、driver、Activity/context、权限弹窗和设备兼容性必须在真实授权设备上验证。没有成功案例时放一套看似完整的代码,会诱导开发者和 AI 猜 `resource-id`、默认取第一个设备或用坐标硬点,违背模板目标。 + +## 未来成功 example 的固定结构 + +```text +mobile_rpa/ + README.md + android/ + README.md + assets/element-map.yaml + scripts/service/device_session.py + scripts/service/mobile_locator.py + scripts/service/example_flow.py + tests/test_locator_contract.py + tests/integration/test_real_device.py.sample + ios/ + README.md + assets/element-map.yaml + scripts/service/device_session.py + scripts/service/mobile_locator.py + scripts/service/example_flow.py + tests/test_locator_contract.py + tests/integration/test_real_device.py.sample +``` + +只有对应端出现真实成功案例后才创建代码文件。 + +## Android example 完成标准 + +- 唯一设备 serial 和设备互斥 +- doctor:ADB授权、包名、Activity、屏幕状态、driver +- Appium Inspector/UIAutoDev 拾取步骤和原始 XML +- element map:resource-id/content-desc/text/class/父容器 +- Native/WebView context 选择 +- 点击、输入、滑动和真实后置状态 +- 权限弹窗、系统弹窗和人工验证 +- screenshot + page source 失败留证 +- 虚拟列表滚动后重新定位 +- 至少一台授权真机完整跑通 + +## iOS example 完成标准 + +- Mac、WebDriverAgent签名和设备 UDID 准备 +- doctor:WDA、bundle id、系统版本、屏幕状态 +- Appium Inspector 拾取和原始 page source +- accessibility id/name、predicate/class chain 定位 +- Native/WebView context 选择 +- 系统 alert 和人工验证 +- screenshot + page source 失败留证 +- 至少一台授权真机完整跑通 + +## element map 最小字段 + +```yaml +logical_name: login_button +surface: android +screen: 登录页 +scope: + package: com.example.app + activity: .LoginActivity +primary_locator: + strategy: resource_id + value: com.example.app:id/login +expected_count: 1 +ready_condition: + visible: true + enabled: true +postcondition: + description: 首页业务标记出现 +verified_device: 待填写 +verified_app_version: 待填写 +``` + +## 状态升级规则 + +满足以下全部条件后,才可把本目录状态改为“已有成功案例”: + +1. 提供用户确认的成功项目来源。 +2. 在真实授权设备上跑通完整业务闭环。 +3. 保存脱敏 element map 和失败证据样例。 +4. 有默认离线单测和显式真实集成测试。 +5. 经过设备重连、App重启和页面变化验证。 +6. 用户人工验收通过。 diff --git a/examples/real_api/README.md b/examples/real_api/README.md new file mode 100644 index 0000000..7a82850 --- /dev/null +++ b/examples/real_api/README.md @@ -0,0 +1,35 @@ +# Real API Example + +这个目录用于沉淀「真实系统 API」类 skill 的可复制成功案例。 + +## 适用场景(规划) + +- 目标系统提供真实 API +- 需要 token / secret / credential +- 需要请求重试、权限校验、限流处理、schema 校验和幂等写库 + +## 当前状态:规划占位 + +- **本目录当前为规划占位**,仅保留最小结构(`README.md`、`scripts/`、`tests/` 占位)。 +- **尚未沉淀可复制实现**;目录内无参考代码。 +- **AI 编程工具不得**基于空目录自行脑补 API 对接实现。 +- 后续补齐可复制案例后,再按 README copy map 选择性复制到新技能 `scripts/`。 + +## 轻量结构 + +```text +real_api/ + README.md + scripts/ + service/ + util/ + tests/ +``` + +不要在本目录添加 `SKILL.md`、市场 README、`references/`、`development/`、`.github/` 等完整 skill 仓库层文件。 + +## 复制边界(待案例补齐后生效) + +- 参考代码将从 `examples/real_api/scripts/` 选择性复制到新技能 `scripts/` +- 根 `README.md`、`SKILL.md`、`references/` 仍按模板主目录规则编写 +- 当前阶段:**不可作为实现参考** diff --git a/examples/real_api/scripts/service/.gitkeep b/examples/real_api/scripts/service/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/examples/real_api/scripts/util/.gitkeep b/examples/real_api/scripts/util/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/examples/real_api/tests/.gitkeep b/examples/real_api/tests/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/examples/real_browser_rpa/README.md b/examples/real_browser_rpa/README.md new file mode 100644 index 0000000..bc01fa3 --- /dev/null +++ b/examples/real_browser_rpa/README.md @@ -0,0 +1,204 @@ +# Real Browser RPA Example + +这是一个真实网站浏览器 RPA skill 的可复制参考实现,适用于需要登录、人工验证、搜索、滚动加载、采集和入库的场景。 + +**说明:** + +- 本示例是通用模板,不绑定具体平台。 +- 示例抽象自已跑通的真实浏览器 RPA skill(抖音视频链接采集),但代码已改成中性示例命名。 +- 新 skill 开发时可以复制本目录,再替换目标站点、选择器、解析逻辑和业务字段。 +- 只用于授权范围内的正常界面操作;禁止破解、逆向、私有协议、验证码绕过、自动换号或其他规避平台限制的扩展。 + +## 适用场景 + +- 真实网站操作(非 sandbox / mock) +- 需要登录态与 persistent Chrome/Edge profile +- 需要通过 account-manager 管理账号和 `profile_dir` +- 需要处理人工登录、滑块验证码、短信验证码 +- 需要关键词搜索、滚动加载、采集可见条目 +- 需要结构化日志、用户友好的停止原因、失败留痕(截图/视频) + +## 目录结构 + +``` +real_browser_rpa/ +├── README.md +├── scripts/ +│ ├── service/ +│ │ ├── account_client.py # subprocess 调用 account-manager +│ │ ├── browser_session.py # 系统 Chrome + persistent context +│ │ ├── human_verification.py # 滑块/短信/保存登录弹窗检测(不依赖 task_rpa 错误类型) +│ │ ├── task_rpa.py # RPA 主流程 +│ │ └── task_service.py # 轻量编排(示例无 DB) +│ └── util/ +│ ├── constants.py # 平台常量、selector 占位 +│ └── logging.py # 结构化日志与脱敏 +├── assets/ +│ └── element-map.sample.yaml # 元素清单模板;真实拾取后填写 +└── tests/ + ├── test_human_verification.py # 验证码误判防护 + └── test_stop_reason.py # 停止原因文案 +``` + +## 核心流程 + +1. **ensure account** — 通过 `account_client.pick_web_account()`(底层 `ensure-web`)获取 `profile_dir` 与租约;无账号则自动登记,全忙则 `LEASE_CONFLICT` 不重复建号。登记 ≠ 站点已登录。 +2. **启动浏览器** — `launch_persistent_context` + `new_page()` + `page.goto(start_url)` +3. **人工验证(启动后)** — 检测滑块/短信,等待用户完成 +4. **等待登录** — 检测登录按钮消失 / 登录态 marker 出现 +5. **保存登录弹窗** — 等待“是否保存登录信息”倒计时消失 +6. **人工验证(登录后)** — 再次检测 +7. **搜索** — 定位搜索框 → 输入关键词 → 点击搜索 +8. **等待结果容器** — `#search-result-container`(示例 selector) +9. **滚动采集循环** — 采集可见条目 → 判断 stop_reason → 滚动加载 +10. **返回结果** — `ScrapeRunResult` + `format_stop_reason_for_user()` +11. **释放租约** — `finally` 中 `release_lease()` + +## 开发新 skill 时如何复制 + +1. **先阅读**本 README 及下方「Copy map」「复制边界」章节 +2. **先完成元素工程**:阅读 `development/RPA_ELEMENT_LOCATING.md` 与 `development/RPA_BROWSER.md`,在真实站用 Pick Locator/F12 拾取并填写 element map +3. 按 copy map **选择性复制**到新技能 `scripts/`(**不要**整包照搬 `examples/real_browser_rpa/`) +4. 修改 `util/constants.py` 中的 `TARGET_PLATFORM`、`DEFAULT_START_URL`、selector 常量 +5. 替换 `task_rpa.py` 中的登录态 marker、搜索框/结果解析逻辑(**必须 F12 实测**) +6. 替换 `human_verification.py` 中的验证码 selector(若目标站点 DOM 不同) +7. 在 `task_service.py` 中接入真实 DB、RpaVideoSession +8. 在新技能根目录补齐 `scripts/main.py` CLI;`SKILL.md` 与根 `README.md` 按模板主目录规则编写,**不要**复制本 README + +## Copy map(复制到新技能 `scripts/`) + +| 来源(examples) | 目标(新技能) | 说明 | +|---|---|---| +| `examples/real_browser_rpa/scripts/service/browser_session.py` | `scripts/service/browser_session.py` | persistent context 启动范式 | +| `examples/real_browser_rpa/scripts/service/account_client.py` | `scripts/service/account_client.py` | account-manager CLI 集中封装 | +| `examples/real_browser_rpa/scripts/service/human_verification.py` | `scripts/service/human_verification.py` | 人工验证等待 | +| `examples/real_browser_rpa/scripts/service/task_rpa.py` | `scripts/service/<业务名>_rpa.py` 或 `scripts/service/task_rpa.py` | RPA 主流程,按业务改名 | +| `examples/real_browser_rpa/scripts/service/task_service.py` | `scripts/service/task_service.py` | 参考并合并编排层 | +| `examples/real_browser_rpa/scripts/util/constants.py` | `scripts/util/constants.py` | 平台常量与 selector(须替换) | +| `examples/real_browser_rpa/scripts/util/logging.py` | `scripts/util/logging.py` | 可选,按需合并 | +| `examples/real_browser_rpa/assets/element-map.sample.yaml` | `assets/<platform>-element-map.yaml` | 真实 Pick Locator/F12 拾取后填写 | +| `examples/real_browser_rpa/tests/` | `tests/` | 可按需复制测试思路 | +| `examples/real_browser_rpa/README.md` | — | **只读参考**,不复制为新技能根 README | + +**复制时必须替换:** selector、URL、平台名称、账号平台字段、slug、logger 名等业务标识。**不要**照搬示例字段到其他平台。 + +**浏览器与启动约束:** + +- **先 Profile 再开浏览器**:必须 `pick_web_account`(底层 ensure-web)拿到 `profile_dir`;拿不到则失败退出,禁止落到系统默认用户目录(见模板 `development/RPA.md` §0.2) +- **RPA 等待**:主路径用 `interruptible_sleep`,禁止裸 `asyncio.sleep`(`POLICY-CONTROL-003`) +- **有头**:生产 / 联调 `OPENCLAW_BROWSER_HEADLESS=0`;勿把无头当交付常态 +- **登录按平台**:是否要求站点登录由本技能约定;「可不登录」≠「可不接 account-manager」 +- **不要**在技能内安装 Playwright;浏览器与 Python 包由宿主/runtime 提供 +- `launch_persistent_context` 的 `args` **只放 Chrome 参数**;**不要把 URL 放进 `args`** +- 页面必须通过 `new_page()` + `goto()`,或通过真实地址栏/点击进入 + +## 复制边界:哪些能复制,哪些必须替换 + +### 可以直接参考/复制的 + +- `browser_session.py` 的启动结构:**persistent context → new_page → goto** +- `human_verification.py` 的人工验证等待模式(容器级检测 + 等待人工,不自动破解) +- `task_rpa.py` 的流程分层、结构化日志点、`stop_reason` 设计 +- `account_client.py` 的 account-manager CLI **集中封装**方式(subprocess 仅限此文件) +- `tests/test_human_verification.py` 的误判防护测试思路 + +### 必须替换的 + +- `SKILL_SLUG` +- `TARGET_PLATFORM` +- `LEASE_HOLDER` +- `DEFAULT_START_URL` +- `SEARCH_INPUT_SELECTORS` / `SEARCH_BUTTON_SELECTOR` +- `RESULT_CONTAINER_SELECTOR` / `RESULT_ITEM_SELECTOR` +- `SCROLL_CONTAINER_SELECTORS` +- `RESULT_END_TEXT` +- `LOGGED_IN_SELECTORS` / `LOGGED_OUT_SELECTOR`(见下方说明) +- `_parse_item_locator()` 业务字段解析 +- 用户可见中文文案 +- 数据库写入逻辑 +- 录屏/截图业务标题 + +### 禁止照抄的 + +- 不要照抄目标站点 selector(示例 selector 不等于真实 DOM) +- 不要照抄平台名、slug、logger 名 +- 不要依赖 `D:\OpenClaw\client-commons\account-manager\scripts\main.py` 等开发机绝对路径作为生产依赖 +- 不要把示例里的 result selector 当成真实站点 selector +- 不要复制“扫描所有 input 再按宽度/位置打分”的诊断式猜测作为生产主定位器 +- 不要用 `.first` / `.nth` 绕过多匹配;关键动作必须先解决唯一性 +- 不要把示例代码原样发布为业务 skill +- 不要把真实账号、手机号、cookie、token 写入代码或 README + +### 登录 selector 说明 + +`task_rpa.py` 中的 `LOGGED_IN_SELECTORS` 与 `LOGGED_OUT_SELECTOR` **只是示例**,偏中文「登录」按钮和常见站点结构。复制到新 skill 后: + +- **必须**用目标站点 F12 / DOM 实测后替换 +- **不允许**凭经验猜 selector +- **不允许**因为示例在本机曾跑通就直接照搬到其他平台 + +### account-manager 路径解析说明 + +`account_client.py` 末尾的 `D:\OpenClaw\client-commons\account-manager\scripts\main.py` 是**开发环境兜底路径**,仅用于模板/本机调试。复制到真实 skill 后: + +- **优先**通过 `ACCOUNT_MANAGER_ROOT`、Platform Kit `get_sibling_skills_root` 或宿主运行环境解析 account-manager +- **不要**把个人机器路径作为生产依赖 + +## 必须保留的安全原则 + +- **不 import account-manager 内部模块** — 只通过 CLI/subprocess 调用 +- **不自动破解验证码** — 滑块/短信只检测 + 等待人工完成 +- **不对抗平台风控** — 频控、账号风险、封禁或权限不足时停止并交由人工处理 +- **日志脱敏** — 不输出完整手机号/账号 +- **租约释放** — `ensure-web` / `pick-web --lease` 后必须在 `finally` 释放 +- **失败留痕** — 真实 skill 应在关键失败点截图(示例中已留注释位) + +## 常见坑 + +| 坑 | 正确做法 | +|---|---| +| 直接 `import account_manager.service...` | 通过 `subprocess` 调用 `account-manager/scripts/main.py` | +| 把 URL 放进 `launch_persistent_context(args=[url])` | `args` 只放 Chrome 参数;URL 用 `page.goto(start_url)` | +| 启动后直接操作 DOM | 先 `context.new_page()` 再 `page.goto()` | +| 用整页 `body.inner_text()` 判断验证码 | 限定在 `[role='dialog']`、`.vc-captcha-verify` 等容器 | +| 搜索结果含“验证码教程”文字就误判 | 单元测试覆盖:无验证 DOM 时不应 `present=True` | +| 无结构化日志 | 关键节点打 `rpa_start` / `browser_ready` / `collect_round` 等 | +| 把 CLI 参数暴露给普通用户 | 用户说明放根 `README.md`;运行契约与触发规则放 `SKILL.md`(LLM/平台入口) | + +## 需要替换的内容清单 + +| 文件 | 替换项 | +|---|---| +| `util/constants.py` | `SKILL_SLUG`、`TARGET_PLATFORM`、`DEFAULT_START_URL`、所有 selector | +| `browser_session.py` | `get_start_url()` 配置来源、stealth 脚本(可选) | +| `human_verification.py` | `SLIDER_SELECTORS`、`SMS_SELECTORS`、`DIALOG_TEXT_CONTAINERS` | +| `task_rpa.py` | `LOGGED_IN_SELECTORS`、`LOGGED_OUT_SELECTOR`、`_parse_item_locator()` | +| `account_client.py` | `PLACEHOLDER_PLATFORM`、`LEASE_HOLDER`、账号 setup 文案 | +| `task_service.py` | 接入 DB、entitlement、视频留痕 | + +## 依赖说明 + +本目录是 **示例**,不影响 skill-template 根目录运行。真实 skill 需要: + +- Python 3.10+ +- Python 包 `playwright`:属于**宿主共享 runtime** 的通用能力;技能侧**不要**在 `requirements.txt` 中重复声明,也**不要**在代码或脚本中执行 `pip install playwright` +- 真实 skill **默认使用系统 Chrome/Edge**,**不使用** Playwright 下载的内置 Chromium +- 技能侧**不要**执行 `playwright install`、`playwright install chromium` 或类似命令 +- 若本地独立开发环境缺少 Playwright Python 包,只能作为开发机临时环境补装;**生产/宿主运行**由共享 runtime 解决 +- `jiangchang_skill_core`(如 `find_chrome_executable` 等,由 platform-kit 提供) +- 兄弟 skill `account-manager` + +## 运行测试 + +```bash +cd examples/real_browser_rpa +python -m pytest tests/ -v +``` + +测试为纯函数/Mock DOM,**不需要启动真实浏览器**。 + +## 当前状态 + +- 示例代码已就绪,可直接作为新 skill 复制起点。 +- 未绑定具体平台业务字段;selector 为占位示例。 +- 未接入真实数据库与 CLI 入口。 diff --git a/examples/real_browser_rpa/assets/element-map.sample.yaml b/examples/real_browser_rpa/assets/element-map.sample.yaml new file mode 100644 index 0000000..a7bc28f --- /dev/null +++ b/examples/real_browser_rpa/assets/element-map.sample.yaml @@ -0,0 +1,45 @@ +surface: browser +status: placeholder_requires_real_site_inspection +elements: + - logical_name: search_input + purpose: 输入搜索关键词 + scope: + kind: main + business_container: 待真实拾取 + primary_locator: + strategy: role_or_label + value: 待真实拾取 + fallback_locators: [] + expected_count: 1 + ready_condition: + visible: true + enabled: true + postcondition: + description: 搜索结果容器出现 + verified_target_version: 待填写 + + - logical_name: search_button + purpose: 提交搜索 + scope: + logical_parent: search_form + primary_locator: + strategy: role + value: button,name=搜索 + fallback_locators: [] + expected_count: 1 + postcondition: + description: 结果页或业务错误状态出现 + verified_target_version: 待填写 + + - logical_name: result_container + purpose: 承载搜索结果 + scope: + kind: main + primary_locator: + strategy: stable_business_container + value: 待真实拾取 + fallback_locators: [] + expected_count: 1 + postcondition: + description: 列表项、空结果或结束标记可区分 + verified_target_version: 待填写 diff --git a/examples/real_browser_rpa/scripts/service/account_client.py b/examples/real_browser_rpa/scripts/service/account_client.py new file mode 100644 index 0000000..b068608 --- /dev/null +++ b/examples/real_browser_rpa/scripts/service/account_client.py @@ -0,0 +1,355 @@ +"""account-manager CLI 集成(仅 subprocess,不 import 兄弟 skill 内部模块)。 + +业务首启默认 ``ensure-web``(有则用、无则 add-web);登记 ≠ 站点已登录。 +""" + +from __future__ import annotations + +import json +import logging +import os +import subprocess +import sys +from typing import Any, Dict, List, Optional, Tuple + +from jiangchang_skill_core import config +from jiangchang_skill_core.runtime_env import get_sibling_skills_root + +from util.constants import LEASE_HOLDER, LEASE_TTL_SEC, TARGET_PLATFORM +from util.logging import mask_text + +logger = logging.getLogger(__name__) + +PLACEHOLDER_PLATFORM = TARGET_PLATFORM + +ACCOUNT_SETUP_MESSAGE = ( + f"未能取得可用的 {PLACEHOLDER_PLATFORM} 账号(ensure-web 失败),所以还没有打开浏览器。" + f"请确认已安装 account-manager,并检查 platform={PLACEHOLDER_PLATFORM} 账号状态后重试。" +) + +LEASE_BUSY_MESSAGE = ( + "该平台下已有账号,但均被其他任务占用。请稍后重试或释放租约;不会自动新建账号。" +) + +DEFAULT_AUTH_STRATEGY = "qr_code_manual" +DEFAULT_ACCOUNT_LABEL = f"{PLACEHOLDER_PLATFORM} 默认" +DEFAULT_START_URL = "https://www.example.com/" + + +class AccountManagerError(Exception): + def __init__(self, code: str, message: str) -> None: + super().__init__(message) + self.code = code + self.message = message + + +def mask_login_id(login_id: str) -> str: + return mask_text(login_id) + + +def format_account_label(account: dict) -> str: + """人读账号标识:优先 login_id / 备注,最后才用数字 id。""" + for key in ("login_id", "account_label", "label"): + v = account.get(key) + if v is not None and str(v).strip(): + return str(v).strip() + for key in ("id", "account_id"): + v = account.get(key) + if v is not None and str(v).strip(): + return str(v).strip() + return "" + + +def _is_placeholder_config(raw: str) -> bool: + s = (raw or "").strip() + if not s: + return True + if s.startswith("#"): + return True + low = s.lower() + if low in ("default-account", "none", "null", "n/a", "-"): + return True + return False + + +def resolve_account_selectors_from_config() -> Tuple[Optional[str], Optional[str]]: + """从配置解析 (account_id, login_id)。 + + - ``DEFAULT_ACCOUNT_ID``:仅纯数字视为账号主键;其它非空非占位当作 login_id + - ``DEFAULT_LOGIN_ID``:手机号/用户名(可覆盖上一项推导的 login_id) + - 两者皆空:返回 (None, None) → 调用方走按平台 ensure-web + """ + account_id: Optional[str] = None + login_id: Optional[str] = None + + aid_raw = (config.get("DEFAULT_ACCOUNT_ID") or "").strip() + if not _is_placeholder_config(aid_raw): + if aid_raw.isdigit(): + account_id = aid_raw + else: + login_id = aid_raw + logger.info("default_account_id_treated_as_login_id value=%s", aid_raw) + + lid_raw = (config.get("DEFAULT_LOGIN_ID") or "").strip() + if not _is_placeholder_config(lid_raw): + login_id = lid_raw + + if account_id: + return account_id, None + return None, login_id + + +def _resolve_account_manager_main() -> str: + """解析 account-manager CLI 入口路径。 + + 优先级:ACCOUNT_MANAGER_ROOT → get_sibling_skills_root → 开发机兜底。 + 末尾 dev 路径仅用于模板/本机调试,复制到真实 skill 后不要依赖个人机器绝对路径。 + """ + env_root = (config.get("ACCOUNT_MANAGER_ROOT") or os.getenv("ACCOUNT_MANAGER_ROOT") or "").strip() + if env_root and os.path.isfile(os.path.join(env_root, "scripts", "main.py")): + return os.path.join(os.path.abspath(env_root), "scripts", "main.py") + + scripts_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + skills_root = get_sibling_skills_root(scripts_dir) + candidate = os.path.join(skills_root, "account-manager", "scripts", "main.py") + if os.path.isfile(candidate): + return candidate + + # 开发环境兜底:仅模板/本机调试;生产依赖宿主注入的路径变量,勿硬编码个人目录 + dev = r"D:\OpenClaw\client-commons\account-manager\scripts\main.py" + if os.path.isfile(dev): + return dev + + raise AccountManagerError( + "ACCOUNT_NOT_FOUND", + "未找到 account-manager,请配置 ACCOUNT_MANAGER_ROOT 或安装 account-manager 技能。", + ) + + +def _parse_last_json(stdout: str) -> Any: + lines = [ln.strip() for ln in (stdout or "").splitlines() if ln.strip()] + for raw in reversed(lines): + if raw.startswith("ERROR:"): + continue + try: + return json.loads(raw) + except json.JSONDecodeError: + continue + raise AccountManagerError("UNKNOWN_ERROR", "stdout 中没有可解析的 JSON。") + + +def _run_argv(argv_suffix: List[str]) -> subprocess.CompletedProcess[str]: + main_py = _resolve_account_manager_main() + env = os.environ.copy() + return subprocess.run( + [sys.executable, main_py, *argv_suffix], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + env=env, + ) + + +def _normalize_error_code(raw: str) -> str: + code = (raw or "").strip() + if code.startswith("ERROR:"): + code = code[6:] + return code + + +def _validate_pick_payload(payload: dict[str, Any]) -> dict[str, Any]: + if not isinstance(payload, dict): + raise AccountManagerError("ACCOUNT_NOT_FOUND", "ensure-web 返回格式异常。") + if payload.get("success") is False: + err = payload.get("error") if isinstance(payload.get("error"), dict) else {} + code = _normalize_error_code(str(err.get("code") or "")) + message = str(err.get("message") or "") + if code == "LEASE_CONFLICT" or "LEASE_CONFLICT" in code: + raise AccountManagerError("LEASE_CONFLICT", message or LEASE_BUSY_MESSAGE) + if code == "AMBIGUOUS_ACCOUNT" or "AMBIGUOUS_ACCOUNT" in code: + raise AccountManagerError( + "AMBIGUOUS_ACCOUNT", + message or "匹配到多条账号,请改用数字 id 或更精确的登录标识。", + ) + if code in ("NO_ACCOUNT", "ACCOUNT_NOT_FOUND") or "NO_ACCOUNT" in code: + raise AccountManagerError("NO_ACCOUNT", message or "没有可用账号。") + raise AccountManagerError(code or "ENSURE_WEB_FAILED", message or "ensure-web 失败。") + + if not payload.get("profile_dir"): + raise AccountManagerError("ACCOUNT_NOT_FOUND", "ensure-web 返回的账号缺少 profile_dir。") + if not payload.get("lease_token"): + raise AccountManagerError("LEASE_REQUIRED", "account-manager 未返回 lease_token,禁止打开浏览器。") + return payload + + +def _ensure_web_with_lease( + platform: str, + *, + login_id: Optional[str] = None, +) -> Dict[str, Any]: + start_url = (config.get("TARGET_BASE_URL") or DEFAULT_START_URL).strip() or DEFAULT_START_URL + lid = (login_id or "").strip() or None + label = f"{platform} {lid}" if lid else DEFAULT_ACCOUNT_LABEL + argv = [ + "account", + "ensure-web", + "--platform", + platform, + "--url", + start_url, + "--auth-strategy", + DEFAULT_AUTH_STRATEGY, + "--label", + label, + "--lease", + "--holder", + LEASE_HOLDER, + "--ttl-sec", + LEASE_TTL_SEC, + "--purpose", + "rpa", + ] + if lid: + argv.extend(["--login-id", lid]) + + proc = _run_argv(argv) + out = proc.stdout or "" + if proc.returncode != 0 and not out.strip(): + raise AccountManagerError( + "ENSURE_WEB_FAILED", + (proc.stderr or "").strip() or "ensure-web 子进程失败", + ) + payload = _parse_last_json(out) + if isinstance(payload, dict) and payload.get("success") is False: + return _validate_pick_payload(payload) + if not isinstance(payload, dict): + raise AccountManagerError("ACCOUNT_NOT_FOUND", "ensure-web 返回格式异常。") + return _validate_pick_payload(payload) + + +def _pick_existing_with_lease( + platform: str, + *, + login_id: Optional[str] = None, + label: Optional[str] = None, +) -> Dict[str, Any]: + argv = [ + "account", + "pick-web", + "--platform", + platform, + "--lease", + "--holder", + LEASE_HOLDER, + "--ttl-sec", + LEASE_TTL_SEC, + "--purpose", + "rpa", + ] + if login_id: + argv.extend(["--login-id", login_id]) + elif label: + argv.extend(["--label", label]) + else: + raise AccountManagerError("AMBIGUOUS_ACCOUNT", "指定账号缺少可用于带租约选择的登录标识或名称。") + + proc = _run_argv(argv) + out = proc.stdout or "" + if proc.returncode != 0 and not out.strip(): + raise AccountManagerError( + "PICK_WEB_FAILED", + (proc.stderr or "").strip() or "pick-web 子进程失败", + ) + payload = _parse_last_json(out) + return _validate_pick_payload(payload) + + +def _pick_by_id_with_lease(platform: str, account_id: int) -> Dict[str, Any]: + proc = _run_argv(["account", "get", str(account_id)]) + out = proc.stdout or "" + if proc.returncode != 0 or out.startswith("ERROR:"): + raise AccountManagerError("ACCOUNT_NOT_FOUND", f"指定账号 {account_id} 不存在或获取失败。") + raw = _parse_last_json(out) + if isinstance(raw, dict) and "data" in raw and isinstance(raw["data"], dict): + data = raw["data"] + elif isinstance(raw, dict): + data = raw + else: + raise AccountManagerError("ACCOUNT_NOT_FOUND", "account get 返回非对象 JSON。") + + if data.get("success") is False: + raise AccountManagerError("ACCOUNT_NOT_FOUND", f"指定账号 {account_id} 不存在或获取失败。") + platform_key = str(data.get("platform_key") or "").lower() + if platform_key != platform.lower(): + raise AccountManagerError( + "ACCOUNT_NOT_FOUND", + f"账号 {account_id} 不是 {platform} 平台账号。", + ) + profile_dir = str(data.get("profile_dir") or "").strip() + if not profile_dir: + raise AccountManagerError("ACCOUNT_NOT_FOUND", f"账号 {account_id} 缺少 profile_dir。") + if str(data.get("status") or "active").lower() != "active": + raise AccountManagerError("ACCOUNT_NOT_FOUND", f"账号 {account_id} 当前不是 active 状态。") + + try: + selected = _pick_existing_with_lease( + platform, + login_id=(str(data.get("login_id") or "").strip() or None), + label=(str(data.get("account_label") or "").strip() or None), + ) + except AccountManagerError as exc: + if exc.code in ("NO_ACCOUNT", "ACCOUNT_NOT_FOUND"): + raise AccountManagerError("LEASE_CONFLICT", LEASE_BUSY_MESSAGE) from exc + raise + if str(selected.get("id")) != str(account_id): + release_lease(str(selected.get("lease_token") or "")) + raise AccountManagerError( + "AMBIGUOUS_ACCOUNT", + f"无法唯一取得指定账号 {account_id} 的租约,请检查重复的登录标识或账号名称。", + ) + return selected + + +def pick_web_account( + platform: str, + account_id: Optional[str] = None, + login_id: Optional[str] = None, +) -> dict: + """获取网页账号(profile_dir + lease_token)。 + + 优先级:数字 account_id → login_id(ensure-web --login-id)→ 按平台 ensure-web。 + """ + platform_key = (platform or PLACEHOLDER_PLATFORM).strip() or PLACEHOLDER_PLATFORM + + aid_raw = (account_id or "").strip() + if aid_raw and not _is_placeholder_config(aid_raw): + if not aid_raw.isdigit(): + if not (login_id or "").strip(): + login_id = aid_raw + aid_raw = "" + else: + return _pick_by_id_with_lease(platform_key, int(aid_raw)) + + lid = (login_id or "").strip() or None + if lid and _is_placeholder_config(lid): + lid = None + + try: + return _ensure_web_with_lease(platform_key, login_id=lid) + except AccountManagerError as exc: + if exc.code in ("NO_ACCOUNT", "ACCOUNT_NOT_FOUND") or "NO_ACCOUNT" in exc.code: + raise AccountManagerError("ACCOUNT_SETUP_REQUIRED", ACCOUNT_SETUP_MESSAGE) from exc + raise + + +def release_lease(lease_token: Optional[str]) -> None: + token = (lease_token or "").strip() + if not token: + return + try: + proc = _run_argv(["lease", "release", token]) + if proc.returncode != 0: + logger.warning("lease_release_failed stdout=%s", (proc.stdout or "").strip()) + except Exception: + logger.warning("lease_release_exception", exc_info=True) diff --git a/examples/real_browser_rpa/scripts/service/browser_session.py b/examples/real_browser_rpa/scripts/service/browser_session.py new file mode 100644 index 0000000..4b89336 --- /dev/null +++ b/examples/real_browser_rpa/scripts/service/browser_session.py @@ -0,0 +1,135 @@ +"""浏览器会话启动(系统 Chrome/Edge + Playwright persistent context)。""" + +from __future__ import annotations + +import logging +import os +from typing import Any, Optional, Tuple + +from util.constants import DEFAULT_START_URL + +logger = logging.getLogger(__name__) + +DEFAULT_PAGE_TIMEOUT_MS = 15_000 +GOTO_TIMEOUT_MS = 60_000 + +STEALTH_INIT_SCRIPT = """ +(() => { + try { Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); } catch (e) {} +})(); +""" + + +def get_start_url() -> str: + return (os.getenv("TARGET_START_URL") or DEFAULT_START_URL).strip() or DEFAULT_START_URL + + +def _headless_from_env() -> bool: + v = (os.getenv("OPENCLAW_BROWSER_HEADLESS") or "0").strip().lower() + return v in ("1", "true", "yes", "on") + + +def _find_chrome_executable() -> str | None: + try: + from jiangchang_skill_core.runtime_env import find_chrome_executable + + chrome = find_chrome_executable() + if chrome and os.path.isfile(chrome): + return chrome + except ImportError: + pass + + candidates = [ + os.path.expandvars(r"%ProgramFiles%\Google\Chrome\Application\chrome.exe"), + os.path.expandvars(r"%ProgramFiles(x86)%\Google\Chrome\Application\chrome.exe"), + os.path.expandvars(r"%ProgramFiles%\Microsoft\Edge\Application\msedge.exe"), + os.path.expandvars(r"%ProgramFiles(x86)%\Microsoft\Edge\Application\msedge.exe"), + ] + for path in candidates: + if path and os.path.isfile(path): + return path + return None + + +async def start_browser_session( + profile_dir: str, + *, + headless: Optional[bool] = None, +) -> Tuple[Any, Any, Any]: + """ + 启动持久化 Chrome profile,new_page + goto 入口页,返回 (playwright, context, page)。 + + 真实 skill 需要安装 playwright 与 jiangchang_skill_core。 + """ + from playwright.async_api import async_playwright + + hl = headless if headless is not None else _headless_from_env() + start_url = get_start_url() + chrome = _find_chrome_executable() + if not chrome: + raise RuntimeError( + "ERROR:MISSING_BROWSER 未检测到 Chrome 或 Edge 浏览器,请先安装系统浏览器后重试。" + ) + + launch_args = ["--start-maximized", "--disable-blink-features=AutomationControlled"] + logger.info( + "browser_launch chrome=%s profile_dir=%s start_url=%s args=%s headless=%s", + chrome, + profile_dir, + start_url, + launch_args, + hl, + ) + print(f"[浏览器] 系统 Chrome: {chrome}") + print(f"[浏览器] profile_dir: [{profile_dir}]") + print(f"[浏览器] startup_url={start_url}") + print(f"[浏览器] args={launch_args}") + + pw = await async_playwright().start() + context = None + try: + context = await pw.chromium.launch_persistent_context( + user_data_dir=profile_dir, + headless=hl, + executable_path=chrome, + locale="zh-CN", + no_viewport=True, + args=launch_args, + ignore_default_args=["--enable-automation"], + ) + await context.add_init_script(STEALTH_INIT_SCRIPT) + + page = await context.new_page() + page.set_default_timeout(DEFAULT_PAGE_TIMEOUT_MS) + + try: + await page.goto(start_url, wait_until="domcontentloaded", timeout=GOTO_TIMEOUT_MS) + except Exception as exc: + raise RuntimeError( + f"浏览器已启动,但未能打开目标首页 {start_url}。" + "请检查网络、Chrome profile、代理或页面访问状态。" + f"({exc})" + ) from exc + + return pw, context, page + except Exception: + if context is not None: + try: + await context.close() + except Exception: + pass + await pw.stop() + raise + + +async def close_browser_context(pw, context) -> None: + try: + if context is not None: + await context.close() + except Exception: + pass + try: + if pw is not None: + await pw.stop() + except Exception: + pass diff --git a/examples/real_browser_rpa/scripts/service/human_verification.py b/examples/real_browser_rpa/scripts/service/human_verification.py new file mode 100644 index 0000000..e6ed82e --- /dev/null +++ b/examples/real_browser_rpa/scripts/service/human_verification.py @@ -0,0 +1,450 @@ +"""人工验证等待:保存登录倒计时、滑块、短信验证码等(不自动破解)。""" + +from __future__ import annotations + +import asyncio +import logging +import random +import re +import time +from dataclasses import dataclass +from typing import Optional + +from util.constants import LOG_LOGGER_NAME + +logger = logging.getLogger(LOG_LOGGER_NAME) + +TEXT_EXCERPT_MAX = 120 + +SAVE_LOGIN_STRONG_MARKERS = ( + "是否保存登录信息", + "下次登录更便捷", + "保存登录信息", +) + +SAVE_LOGIN_CONTAINER_SELECTORS = ( + "[role=\"dialog\"]", + ".semi-modal", + ".semi-modal-content", +) + +SLIDER_SELECTORS = [ + ".vc-captcha-verify.slide", + ".captcha_verify_bar--title", + ".captcha-slider-tips", + "#captcha_verify_image", + ".captcha-slider-btn", +] + +SMS_SELECTORS = [ + ".second-verify-panel", + ".uc-ui-verify_sms-input", + ".uc-ui-verify-new_header-title", + "input[placeholder='手机号']", + "input[placeholder='请输入验证码']", +] + +DIALOG_TEXT_CONTAINERS = [ + "[role='dialog']", + ".semi-modal", + ".semi-modal-content", + ".vc-captcha-verify", + ".second-verify-panel", +] + +RISK_MARKERS = ( + "操作频繁", + "访问过于频繁", + "风险验证", + "安全验证", +) + + +@dataclass +class HumanVerificationState: + present: bool + kind: str + message: str + selector: str = "" + text_excerpt: str = "" + + +@dataclass +class HumanVerificationWaitResult: + timed_out: bool + kind: str = "" + code: str = "" + message: str = "" + stage: str = "" + + +def _text_excerpt(text: str) -> str: + cleaned = re.sub(r"\s+", " ", (text or "").strip()) + if len(cleaned) <= TEXT_EXCERPT_MAX: + return cleaned + return cleaned[:TEXT_EXCERPT_MAX] + "..." + + +def _contains_any(text: str, markers: tuple[str, ...]) -> bool: + return any(m in text for m in markers) + + +async def _selector_visible(page, selector: str) -> bool: + try: + loc = page.locator(selector).first + return await loc.is_visible(timeout=500) + except Exception: + return False + + +async def _selector_inner_text(page, selector: str) -> str: + try: + return await page.locator(selector).first.inner_text(timeout=1000) + except Exception: + return "" + + +async def _visible_text_from_first_visible(page, selectors: tuple[str, ...] | list[str]) -> tuple[str, str]: + for sel in selectors: + try: + loc = page.locator(sel).first + if await loc.is_visible(timeout=500): + text = await loc.inner_text(timeout=1000) + return sel, text or "" + except Exception: + continue + return "", "" + + +def _save_login_popup_from_text(text: str) -> bool: + if not text: + return False + if _contains_any(text, SAVE_LOGIN_STRONG_MARKERS): + return True + if "保存" in text and "取消" in text and ("登录" in text or "登录信息" in text): + return True + return False + + +async def detect_save_login_popup(page) -> bool: + sel, text = await _visible_text_from_first_visible(page, SAVE_LOGIN_CONTAINER_SELECTORS) + if sel and _save_login_popup_from_text(text): + return True + return False + + +async def wait_save_login_countdown_if_present(page, timeout_sec: int = 8) -> bool: + """若出现“保存登录信息”倒计时弹窗则等待其消失;未出现则立即返回 False。""" + if not await detect_save_login_popup(page): + return False + + logger.info("save_login_popup_detected") + print("[登录] 检测到“保存登录信息”倒计时弹窗,等待其自动关闭...") + start = time.monotonic() + deadline = start + timeout_sec + while time.monotonic() < deadline: + if not await detect_save_login_popup(page): + elapsed = round(time.monotonic() - start, 2) + logger.info("save_login_popup_gone elapsed_sec=%s", elapsed) + return True + await asyncio.sleep(0.5) + + elapsed = round(time.monotonic() - start, 2) + logger.warning( + "save_login_popup_still_visible elapsed_sec=%s timeout_sec=%s", + elapsed, + timeout_sec, + ) + print( + f"[登录] 警告:保存登录信息弹窗超过 {timeout_sec} 秒仍未消失," + "将继续后续流程(您可能已手工点击保存/取消)。" + ) + return True + + +async def detect_slider_verification(page) -> Optional[HumanVerificationState]: + sel_slide = ".vc-captcha-verify.slide" + if await _selector_visible(page, sel_slide): + text = await _selector_inner_text(page, sel_slide) + return HumanVerificationState( + present=True, + kind="slider", + message="滑块验证码", + selector=sel_slide, + text_excerpt=_text_excerpt(text), + ) + + sel_title = ".captcha_verify_bar--title" + if await _selector_visible(page, sel_title): + title_text = await _selector_inner_text(page, sel_title) + if "请完成下列验证后继续" in title_text: + return HumanVerificationState( + present=True, + kind="slider", + message="滑块验证码", + selector=sel_title, + text_excerpt=_text_excerpt(title_text), + ) + + sel_tips = ".captcha-slider-tips" + if await _selector_visible(page, sel_tips): + tips_text = await _selector_inner_text(page, sel_tips) + if "拖动完成上方拼图" in tips_text: + return HumanVerificationState( + present=True, + kind="slider", + message="滑块验证码", + selector=sel_tips, + text_excerpt=_text_excerpt(tips_text), + ) + + if await _selector_visible(page, "#captcha_verify_image"): + for btn_sel in (".captcha-slider-btn", ".captcha_verify_slide--button", ".captcha-slider"): + if await _selector_visible(page, btn_sel): + return HumanVerificationState( + present=True, + kind="slider", + message="滑块验证码", + selector=f"#captcha_verify_image+{btn_sel}", + text_excerpt="", + ) + + return None + + +async def detect_sms_verification(page) -> Optional[HumanVerificationState]: + sel_panel = ".second-verify-panel" + if await _selector_visible(page, sel_panel): + text = await _selector_inner_text(page, sel_panel) + return HumanVerificationState( + present=True, + kind="sms", + message="短信验证码", + selector=sel_panel, + text_excerpt=_text_excerpt(text), + ) + + sel_sms = ".uc-ui-verify_sms-input" + if await _selector_visible(page, sel_sms): + text = await _selector_inner_text(page, sel_sms) + return HumanVerificationState( + present=True, + kind="sms", + message="短信验证码", + selector=sel_sms, + text_excerpt=_text_excerpt(text), + ) + + sel_hdr = ".uc-ui-verify-new_header-title" + if await _selector_visible(page, sel_hdr): + hdr_text = await _selector_inner_text(page, sel_hdr) + if "短信验证" in hdr_text: + return HumanVerificationState( + present=True, + kind="sms", + message="短信验证码", + selector=sel_hdr, + text_excerpt=_text_excerpt(hdr_text), + ) + + phone_visible = await _selector_visible( + page, + ".second-verify-panel input[placeholder='手机号'], .uc-ui-verify_sms-input input[placeholder='手机号']", + ) + code_visible = await _selector_visible( + page, + ".second-verify-panel input[placeholder='请输入验证码'], .uc-ui-verify_sms-input input[placeholder='请输入验证码']", + ) + if phone_visible and code_visible: + return HumanVerificationState( + present=True, + kind="sms", + message="短信验证码", + selector="sms-phone-and-code-inputs", + text_excerpt="手机号+请输入验证码", + ) + + return None + + +async def detect_human_verification_from_containers(page) -> Optional[HumanVerificationState]: + sel, text = await _visible_text_from_first_visible(page, DIALOG_TEXT_CONTAINERS) + if not sel or not text: + return None + + if _contains_any(text, RISK_MARKERS): + return HumanVerificationState( + present=True, + kind="risk", + message="安全/频控验证", + selector=sel, + text_excerpt=_text_excerpt(text), + ) + + if sel in (".vc-captcha-verify", ".vc-captcha-verify-visibility"): + if "请完成下列验证后继续" in text or "拖动完成上方拼图" in text: + return HumanVerificationState( + present=True, + kind="slider", + message="滑块验证码", + selector=sel, + text_excerpt=_text_excerpt(text), + ) + + if sel in (".second-verify-panel", ".uc-ui-verify_sms-input"): + if "短信验证" in text or "获取验证码" in text: + return HumanVerificationState( + present=True, + kind="sms", + message="短信验证码", + selector=sel, + text_excerpt=_text_excerpt(text), + ) + + return None + + +async def detect_human_verification(page) -> HumanVerificationState: + """主检测入口:仅在验证码/弹窗容器级检测,不扫整页 body。""" + state = await detect_slider_verification(page) + if state is not None: + return state + state = await detect_sms_verification(page) + if state is not None: + return state + state = await detect_human_verification_from_containers(page) + if state is not None: + return state + return HumanVerificationState(present=False, kind="", message="", selector="", text_excerpt="") + + +def detect_human_verification_from_text(text: str) -> HumanVerificationState: + """ + 根据 HTML/容器文本判断验证弹窗(单元测试辅助,不能作为真实页面主检测入口)。 + 只检查验证容器 DOM 标记,不根据正文关键词误判。 + """ + html_lower = (text or "").lower() + has_slider_dom = ( + "vc-captcha-verify" in html_lower + and "slide" in html_lower + and ( + "captcha-slider-tips" in html_lower + or "captcha_verify_bar--title" in html_lower + or "captcha_verify_image" in html_lower + ) + ) + if has_slider_dom: + selector = ".vc-captcha-verify.slide" + if "captcha-slider-tips" in html_lower: + selector = ".captcha-slider-tips" + return HumanVerificationState( + present=True, + kind="slider", + message="滑块验证码", + selector=selector, + text_excerpt="", + ) + + has_sms_dom = "second-verify-panel" in html_lower or "uc-ui-verify_sms-input" in html_lower + if has_sms_dom: + selector = ".second-verify-panel" if "second-verify-panel" in html_lower else ".uc-ui-verify_sms-input" + return HumanVerificationState( + present=True, + kind="sms", + message="短信验证码", + selector=selector, + text_excerpt="", + ) + + return HumanVerificationState(present=False, kind="", message="", selector="", text_excerpt="") + + +def _wait_log_message(state: HumanVerificationState, wait_sec: int) -> str: + if state.kind == "slider": + return f"[人工验证] 检测到滑块验证码,请在浏览器中手工完成,最多等待 {wait_sec} 秒..." + if state.kind == "sms": + return f"[人工验证] 检测到短信验证码,请在浏览器中手工完成,最多等待 {wait_sec} 秒..." + return f"[人工验证] 检测到安全验证,请在浏览器中手工处理,最多等待 {wait_sec} 秒..." + + +def _timeout_error_code(kind: str) -> str: + if kind == "slider": + return "SLIDER_VERIFICATION_TIMEOUT" + if kind == "sms": + return "SMS_VERIFICATION_TIMEOUT" + return "HUMAN_VERIFICATION_TIMEOUT" + + +def _timeout_error_message(kind: str, wait_sec: int) -> str: + if kind == "slider": + return ( + f"滑块验证码未在 {wait_sec} 秒内完成,任务已停止。" + "请重新运行任务并及时在浏览器中完成验证。" + ) + if kind == "sms": + return ( + f"短信验证码未在 {wait_sec} 秒内完成,任务已停止。" + "请重新运行任务并及时完成短信验证。" + ) + return ( + f"安全验证未在 {wait_sec} 秒内完成,任务已停止。" + "请重新运行任务并及时在浏览器中完成验证。" + ) + + +async def wait_human_verification_if_present( + page, + *, + wait_sec: int = 180, + stage: str = "unknown", +) -> Optional[HumanVerificationWaitResult]: + """ + 若存在人工验证则等待用户完成;无验证或已完成返回 None;超时返回 HumanVerificationWaitResult。 + 不自动拖动滑块、不自动填写短信验证码。 + """ + state = await detect_human_verification(page) + if not state.present: + return None + + logger.warning( + "human_verification_detected stage=%s kind=%s selector=%s text=%s", + stage, + state.kind, + state.selector, + state.text_excerpt, + ) + print(_wait_log_message(state, wait_sec)) + + last_state = state + start = time.monotonic() + deadline = start + wait_sec + + while time.monotonic() < deadline: + state = await detect_human_verification(page) + if not state.present: + elapsed = round(time.monotonic() - start, 2) + logger.info( + "human_verification_passed stage=%s kind=%s elapsed_sec=%s", + stage, + last_state.kind, + elapsed, + ) + await asyncio.sleep(random.uniform(1.0, 2.0)) + return None + last_state = state + await asyncio.sleep(2.0) + + logger.warning( + "human_verification_timeout stage=%s kind=%s wait_sec=%s selector=%s", + stage, + last_state.kind, + wait_sec, + last_state.selector, + ) + return HumanVerificationWaitResult( + timed_out=True, + kind=last_state.kind, + code=_timeout_error_code(last_state.kind), + message=_timeout_error_message(last_state.kind, wait_sec), + stage=stage, + ) diff --git a/examples/real_browser_rpa/scripts/service/task_rpa.py b/examples/real_browser_rpa/scripts/service/task_rpa.py new file mode 100644 index 0000000..d7248c6 --- /dev/null +++ b/examples/real_browser_rpa/scripts/service/task_rpa.py @@ -0,0 +1,637 @@ +"""真实网站关键词搜索 RPA 示例(async Playwright)。""" + +from __future__ import annotations + +import logging +import os +import random +import re +import time +from dataclasses import dataclass, field +from typing import Any, Callable, Dict, List, Optional, Tuple + +from jiangchang_skill_core import config +from jiangchang_skill_core.activity import interruptible_sleep +from jiangchang_skill_core.rpa import poll_until + +from service.browser_session import close_browser_context, get_start_url, start_browser_session +from service.human_verification import ( + HumanVerificationWaitResult, + wait_human_verification_if_present, + wait_save_login_countdown_if_present, +) +from util.constants import ( + DEFAULT_MAX_ITEMS, + DEFAULT_MAX_SCROLLS, + DEFAULT_NO_NEW_ROUNDS_LIMIT, + HUMAN_WAIT_TIMEOUT, + LOG_LOGGER_NAME, + RESULT_CONTAINER_SELECTOR, + RESULT_END_TEXT, + RESULT_ITEM_SELECTOR, + SCROLL_CONTAINER_SELECTORS, + SEARCH_BUTTON_SELECTOR, + SEARCH_INPUT_SELECTORS, +) +from util.logging import mask_text, safe_log_value + +logger = logging.getLogger(LOG_LOGGER_NAME) + + +@dataclass +class ScrapeError: + code: str + message: str + browser_started: bool = False + stage: Optional[str] = None + + +@dataclass +class ScrapeRunResult: + success: bool + items: List[Dict[str, Any]] = field(default_factory=list) + stop_reason: Optional[str] = None + error: Optional[ScrapeError] = None + + +StepCallback = Callable[[str], None] + +# 示例登录态 selector — 复制到新 skill 后必须用目标站点 F12/DOM 实测替换,禁止凭经验猜测或跨平台照抄 +LOGGED_IN_SELECTORS = ( + "a[href*='/user/self']", + "[data-user-avatar]", + ".user-avatar", +) +LOGGED_OUT_SELECTOR = "button:has-text('登录')" + + +def format_stop_reason_for_user(stop_reason: str | None, collected_count: int) -> str: + """将内部 stop_reason 枚举翻译为用户可理解的中文说明。""" + if stop_reason == "max_items": + return ( + f"已达到本次默认采集规模({collected_count} 条)。" + "如需更多,可以继续说「继续采集更多」或「尽量多采集」。" + ) + if stop_reason == "end_marker": + return "页面提示没有更多结果,本次采集已完成。" + if stop_reason == "no_new_rounds": + return "连续多次加载没有发现新结果,本次采集已完成。" + if stop_reason == "max_scrolls": + return ( + "已达到本次页面加载保护范围,本次采集已完成。" + "如需更深度采集,可以说明「尽量多采集」。" + ) + if stop_reason == "human_verification_timeout": + return "人工验证未在规定时间内完成,任务已停止。" + return "本次采集已完成。" + + +def _mask_account_ref(account: Dict[str, Any]) -> str: + for key in ("login_id", "phone", "mobile"): + raw = str(account.get(key) or "").strip() + if raw: + return mask_text(raw) + aid = account.get("id") or account.get("account_id") + if aid is not None: + return f"id:{aid}" + return "unknown" + + +def _step_cb(cb: Optional[StepCallback], text: str) -> None: + if cb: + cb(text) + + +async def _random_delay() -> None: + lo = max(0.0, config.get_float("STEP_DELAY_MIN", 1.0)) + hi = max(lo, config.get_float("STEP_DELAY_MAX", 3.0)) + await interruptible_sleep(random.uniform(lo, hi)) + + +async def _scroll_wait() -> None: + await interruptible_sleep(random.uniform(1.2, 3.5)) + + +def _headless() -> bool: + v = (os.getenv("OPENCLAW_BROWSER_HEADLESS") or "0").strip().lower() + return v in ("1", "true", "yes", "on") + + +def _no_new_limit() -> int: + try: + return int(os.getenv("RPA_NO_NEW_ROUNDS_LIMIT") or str(DEFAULT_NO_NEW_ROUNDS_LIMIT)) + except ValueError: + return DEFAULT_NO_NEW_ROUNDS_LIMIT + + +async def _visible(locator) -> bool: + try: + return await locator.count() > 0 and await locator.is_visible(timeout=500) + except Exception: + return False + + +async def _is_logged_out(page) -> bool: + try: + btn = page.locator(LOGGED_OUT_SELECTOR).first + return await _visible(btn) + except Exception: + return False + + +async def _is_logged_in(page) -> bool: + if await _is_logged_out(page): + return False + for sel in LOGGED_IN_SELECTORS: + try: + loc = page.locator(sel).first + if await _visible(loc): + return True + except Exception: + continue + return False + + +async def _open_login_panel_if_needed(page) -> None: + if not await _is_logged_out(page): + return + try: + btn = page.locator(LOGGED_OUT_SELECTOR).first + if await _visible(btn): + await btn.click() + await interruptible_sleep(random.uniform(1.0, 3.0)) + except Exception: + pass + + +async def _ensure_logged_in(page, *, wait_sec: int) -> None: + if await _is_logged_in(page): + return + + await _open_login_panel_if_needed(page) + if await _is_logged_in(page): + return + + print(f"[登录] 请在浏览器中完成登录,最多等待 {wait_sec} 秒...") + deadline = time.monotonic() + wait_sec + while time.monotonic() < deadline: + if await _is_logged_in(page): + print("[登录] 检测到登录成功") + return + await interruptible_sleep(2.0) + + raise RuntimeError( + f"ERROR:LOGIN_TIMEOUT 浏览器已打开,但未完成登录。" + f"请重新运行任务并在浏览器打开后及时完成登录。" + ) + + +async def _find_search_input(page) -> Tuple[Optional[Any], str]: + for sel in SEARCH_INPUT_SELECTORS: + try: + loc = page.locator(sel) + count = await loc.count() + if count == 1 and await _visible(loc): + return loc, sel + if count > 1: + logger.warning("search_input_ambiguous selector=%s count=%s", sel, count) + except Exception: + continue + + try: + ph = page.get_by_placeholder(re.compile("搜索")) + count = await ph.count() + if count == 1 and await ph.is_visible(): + return ph, "placeholder=搜索" + if count > 1: + logger.warning("search_input_ambiguous selector=placeholder_search count=%s", count) + except Exception: + pass + return None, "" + + +async def _find_search_button(page): + try: + btn = page.get_by_role("button", name="搜索") + count = await btn.count() + if count == 1 and await btn.is_visible(): + return btn + if count > 1: + logger.warning("search_button_ambiguous selector=role_button_search count=%s", count) + except Exception: + pass + try: + btn = page.locator(SEARCH_BUTTON_SELECTOR) + count = await btn.count() + if count == 1 and await btn.is_visible(): + return btn + if count > 1: + logger.warning( + "search_button_ambiguous selector=%s count=%s", + SEARCH_BUTTON_SELECTOR, + count, + ) + except Exception: + pass + return None + + +async def _wait_search_results(page, timeout_sec: float = 45.0) -> bool: + """有界等待搜索结果容器出现(kit poll_until;区分加载中与超时未就绪)。""" + + async def _ready() -> bool: + try: + url = page.url or "" + if "/search" in url or "search" in url.lower(): + if await page.locator(RESULT_CONTAINER_SELECTOR).count() > 0: + return True + except Exception: + return False + return False + + result = await poll_until(_ready, timeout_sec=timeout_sec, interval_sec=0.8) + return result.ok + + +async def _has_end_marker(page) -> bool: + try: + container = page.locator(RESULT_CONTAINER_SELECTOR).first + if await container.count() > 0: + text = await container.inner_text(timeout=2000) + if RESULT_END_TEXT in text or "暂时没有更多了" in text: + return True + except Exception: + pass + try: + if await page.locator(f"text={RESULT_END_TEXT}").count() > 0: + return True + except Exception: + pass + return False + + +async def _mouse_wheel_scroll(page) -> Tuple[str, int]: + wheel_delta = random.randint(650, 1200) + for sel in SCROLL_CONTAINER_SELECTORS: + loc = page.locator(sel) + try: + if await loc.count() > 0 and await loc.first.is_visible(): + await loc.first.hover() + await _random_delay() + await page.mouse.wheel(0, wheel_delta) + return sel, wheel_delta + except Exception: + continue + await page.mouse.wheel(0, wheel_delta) + return "page", wheel_delta + + +async def _parse_item_locator(item) -> Dict[str, Any]: + item_id = await item.get_attribute("data-item-id") or await item.get_attribute("id") or "" + title = "" + try: + title = (await item.inner_text(timeout=1000)).strip()[:200] + except Exception: + pass + return { + "item_id": item_id or title[:32] or str(id(item)), + "title": title, + } + + +async def _collect_visible_items(page, seen_ids: set[str]) -> List[Dict[str, Any]]: + new_items: List[Dict[str, Any]] = [] + try: + items = await page.locator(RESULT_ITEM_SELECTOR).all() + except Exception: + items = [] + for item in items: + try: + parsed = await _parse_item_locator(item) + item_id = parsed.get("item_id") + if not item_id or item_id in seen_ids: + continue + seen_ids.add(str(item_id)) + new_items.append(parsed) + except Exception: + continue + return new_items + + +async def _count_visible_items(page) -> int: + try: + return await page.locator(RESULT_ITEM_SELECTOR).count() + except Exception: + return 0 + + +def _fail_result(error: ScrapeError, *, collected: int = 0) -> ScrapeRunResult: + logger.warning( + "rpa_failed code=%s stage=%s message=%s collected=%s", + error.code, + error.stage, + error.message, + collected, + ) + return ScrapeRunResult(success=False, error=error) + + +def _fail_on_verification_timeout( + verification_timeout: HumanVerificationWaitResult, + *, + collected: int = 0, +) -> ScrapeRunResult: + return _fail_result( + ScrapeError( + code=verification_timeout.code, + message=verification_timeout.message, + browser_started=True, + stage=verification_timeout.stage or "unknown", + ), + collected=collected, + ) + + +async def run_keyword_search_async( + account: Dict[str, Any], + keyword: str, + *, + max_items: int = DEFAULT_MAX_ITEMS, + max_scrolls: int = DEFAULT_MAX_SCROLLS, + data_dir: str = "", + batch_id: str = "", + step_callback: Optional[StepCallback] = None, +) -> ScrapeRunResult: + profile_dir = account.get("profile_dir") or "" + account_ref = _mask_account_ref(account) + wait_sec = int(os.getenv("HUMAN_WAIT_TIMEOUT") or str(HUMAN_WAIT_TIMEOUT)) + no_new_limit = _no_new_limit() + + logger.info( + "rpa_start keyword=%s max_items=%s max_scrolls=%s wait_sec=%s batch_id=%s account_ref=%s", + safe_log_value(keyword), + max_items, + max_scrolls, + wait_sec, + batch_id, + account_ref, + ) + + if not profile_dir: + err = ScrapeError( + "ACCOUNT_NOT_FOUND", + "账号缺少 profile_dir。", + browser_started=False, + stage="account_prepare", + ) + return _fail_result(err) + + pw = None + context = None + page = None + browser_started = False + collected_count = 0 + + try: + _step_cb(step_callback, "启动关键词搜索采集任务") + try: + pw, context, page = await start_browser_session(profile_dir, headless=_headless()) + except RuntimeError as exc: + msg = str(exc) + err = ScrapeError( + "MISSING_BROWSER" if "MISSING_BROWSER" in msg else "UNKNOWN_ERROR", + msg.replace("ERROR:MISSING_BROWSER ", "").replace("ERROR:", ""), + browser_started="未能打开目标首页" in msg, + stage="browser_start" if "未能打开目标首页" in msg else "account_prepare", + ) + return _fail_result(err) + browser_started = True + + logger.info( + "browser_ready start_url=%s current_url=%s profile_dir_exists=%s", + get_start_url(), + page.url, + os.path.isdir(profile_dir), + ) + + _step_cb(step_callback, "打开目标站点首页") + await _random_delay() + + verification_timeout = await wait_human_verification_if_present( + page, wait_sec=wait_sec, stage="browser_start" + ) + if verification_timeout: + return _fail_on_verification_timeout(verification_timeout) + + _step_cb(step_callback, "检查账号登录状态") + logger.info("login_check_start current_url=%s", page.url) + try: + await _ensure_logged_in(page, wait_sec=wait_sec) + except RuntimeError as exc: + msg = str(exc) + code = "LOGIN_TIMEOUT" if "LOGIN_TIMEOUT" in msg else "REQUIRE_LOGIN" + err = ScrapeError( + code, + msg.replace("ERROR:LOGIN_TIMEOUT ", "").replace("ERROR:", ""), + browser_started=True, + stage="login_check", + ) + return _fail_result(err) + logger.info("login_check_done current_url=%s", page.url) + + await wait_save_login_countdown_if_present(page) + + verification_timeout = await wait_human_verification_if_present( + page, wait_sec=wait_sec, stage="login_check" + ) + if verification_timeout: + return _fail_on_verification_timeout(verification_timeout) + + _step_cb(step_callback, "定位搜索框") + search_input, input_selector = await _find_search_input(page) + if search_input is None: + err = ScrapeError( + "SEARCH_INPUT_NOT_FOUND", + "未找到搜索输入框。", + browser_started=True, + stage="search", + ) + return _fail_result(err) + logger.info("search_input_found selector=%s", input_selector) + + _step_cb(step_callback, f"输入关键词:{keyword}") + await search_input.click() + await _random_delay() + await page.keyboard.press("Control+A") + await page.keyboard.type(keyword, delay=random.randint(90, 240)) + await _random_delay() + + _step_cb(step_callback, "点击搜索") + search_btn = await _find_search_button(page) + search_method = "button" + if search_btn is not None: + await search_btn.click() + else: + search_method = "enter" + await page.keyboard.press("Enter") + logger.info( + "search_submitted keyword=%s method=%s current_url=%s", + safe_log_value(keyword), + search_method, + page.url, + ) + await _random_delay() + + verification_timeout = await wait_human_verification_if_present( + page, wait_sec=wait_sec, stage="search_verification" + ) + if verification_timeout: + return _fail_on_verification_timeout(verification_timeout) + + _step_cb(step_callback, "等待搜索结果") + if not await _wait_search_results(page): + verification_timeout = await wait_human_verification_if_present( + page, wait_sec=wait_sec, stage="search_results" + ) + if verification_timeout: + return _fail_on_verification_timeout(verification_timeout) + + if not await _wait_search_results(page, timeout_sec=10.0): + err = ScrapeError( + "RESULT_CONTAINER_NOT_FOUND", + "搜索结果未出现,且未检测到需要人工处理的验证码,请检查关键词或页面状态。", + browser_started=True, + stage="search", + ) + return _fail_result(err) + + container_count = await page.locator(RESULT_CONTAINER_SELECTOR).count() + logger.info( + "search_results_ready current_url=%s container_count=%s", + page.url, + container_count, + ) + + try: + await page.locator(RESULT_CONTAINER_SELECTOR).first.wait_for(state="visible", timeout=15_000) + except Exception: + err = ScrapeError( + "RESULT_CONTAINER_NOT_FOUND", + "搜索结果容器未出现。", + browser_started=True, + stage="search", + ) + return _fail_result(err) + + seen_ids: set[str] = set() + collected: List[Dict[str, Any]] = [] + no_new_rounds = 0 + stop_reason = "max_scrolls" + rounds_executed = 0 + + for scroll_round in range(max_scrolls + 1): + rounds_executed = scroll_round + _step_cb(step_callback, "读取当前结果列表") + visible_count = await _count_visible_items(page) + batch = await _collect_visible_items(page, seen_ids) + new_count = len(batch) + if batch: + collected.extend(batch) + no_new_rounds = 0 + else: + no_new_rounds += 1 + + collected_count = len(collected) + end_marker = await _has_end_marker(page) + logger.info( + "collect_round round=%s max_scrolls=%s visible_count=%s new_count=%s " + "collected=%s no_new_rounds=%s no_new_limit=%s end_marker=%s current_url=%s", + scroll_round, + max_scrolls, + visible_count, + new_count, + collected_count, + no_new_rounds, + no_new_limit, + end_marker, + page.url, + ) + + if len(collected) >= max_items: + collected = collected[:max_items] + stop_reason = "max_items" + break + + if end_marker: + stop_reason = "end_marker" + _step_cb(step_callback, "发现结果到底或达到采集数量") + break + + if no_new_rounds >= no_new_limit: + stop_reason = "no_new_rounds" + logger.info( + "collect_no_new_stop round=%s no_new_rounds=%s no_new_limit=%s collected=%s", + scroll_round, + no_new_rounds, + no_new_limit, + collected_count, + ) + break + + if scroll_round >= max_scrolls: + break + + _step_cb(step_callback, "滚动加载更多结果") + scroll_target, wheel_delta = await _mouse_wheel_scroll(page) + logger.info( + "scroll round=%s target=%s wheel_delta=%s", + scroll_round, + scroll_target, + wheel_delta, + ) + await _scroll_wait() + + verification_timeout = await wait_human_verification_if_present( + page, wait_sec=wait_sec, stage="collect" + ) + if verification_timeout: + return _fail_on_verification_timeout( + verification_timeout, + collected=collected_count, + ) + + logger.info( + "collect_stop reason=%s collected=%s rounds=%s no_new_rounds=%s", + stop_reason, + collected_count, + rounds_executed, + no_new_rounds, + ) + + if not collected: + err = ScrapeError( + "NO_RESULTS", + "未采集到任何结果。", + browser_started=True, + stage="collect", + ) + return _fail_result(err, collected=0) + + _step_cb(step_callback, "采集完成") + logger.info( + "rpa_success collected=%s stop_reason=%s", + len(collected[:max_items]), + stop_reason, + ) + return ScrapeRunResult(success=True, items=collected[:max_items], stop_reason=stop_reason) + + except Exception as exc: + err = ScrapeError( + "UNKNOWN_ERROR", + str(exc), + browser_started=browser_started, + stage="rpa", + ) + return _fail_result(err, collected=collected_count) + finally: + await close_browser_context(pw, context) diff --git a/examples/real_browser_rpa/scripts/service/task_service.py b/examples/real_browser_rpa/scripts/service/task_service.py new file mode 100644 index 0000000..e49aff8 --- /dev/null +++ b/examples/real_browser_rpa/scripts/service/task_service.py @@ -0,0 +1,146 @@ +"""轻量任务编排示例(无真实数据库依赖)。""" + +from __future__ import annotations + +import asyncio +import uuid +from typing import Any, Optional + +from service.account_client import ( + AccountManagerError, + pick_web_account, + release_lease, + resolve_account_selectors_from_config, +) +from service.task_rpa import ( + ScrapeRunResult, + format_stop_reason_for_user, + run_keyword_search_async, +) +from util.constants import ( + DEFAULT_MAX_ITEMS, + DEFAULT_MAX_SCROLLS, + SKILL_SLUG, + TARGET_PLATFORM, +) + + +def _error_payload( + code: str, + message: str, + *, + browser_started: Optional[bool] = None, + stage: Optional[str] = None, +) -> dict[str, Any]: + payload: dict[str, Any] = { + "success": False, + "error": {"code": code, "message": message}, + } + if browser_started is not None: + payload["browser_started"] = browser_started + if stage: + payload["stage"] = stage + return payload + + +def _success_payload( + *, + keyword: str, + collected_count: int, + items: list[dict[str, Any]], + stop_reason: Optional[str], +) -> dict[str, Any]: + return { + "success": True, + "skill": SKILL_SLUG, + "keyword": keyword, + "collected_count": collected_count, + "items": items, + "stop_reason": stop_reason, + "stop_reason_message": format_stop_reason_for_user(stop_reason, collected_count), + } + + +async def run_keyword_search_task( + keyword: str, + *, + max_items: int = DEFAULT_MAX_ITEMS, + max_scrolls: int = DEFAULT_MAX_SCROLLS, + account_id: Optional[str] = None, + platform: str = TARGET_PLATFORM, +) -> dict[str, Any]: + """ + 编排一次关键词搜索采集。 + + 真实 skill 中还应: + - 写 SQLite 入库(items 表) + - 使用 RpaVideoSession 录制步骤留痕 + - 失败时 capture_failure 截图 + """ + batch_id = uuid.uuid4().hex[:12] + data_dir = "" # 示例占位;真实 skill 使用 get_skill_data_dir() + lease_token: Optional[str] = None + account: Optional[dict[str, Any]] = None + + try: + cfg_account_id, cfg_login_id = resolve_account_selectors_from_config() + account = pick_web_account( + platform, + account_id=account_id or cfg_account_id, + login_id=cfg_login_id, + ) + lease_token = account.get("lease_token") + + scrape_result: ScrapeRunResult = await run_keyword_search_async( + account, + keyword, + max_items=max_items, + max_scrolls=max_scrolls, + data_dir=data_dir, + batch_id=batch_id, + ) + + if not scrape_result.success: + err = scrape_result.error + return _error_payload( + err.code if err else "UNKNOWN_ERROR", + err.message if err else "未知错误", + browser_started=err.browser_started if err else True, + stage=err.stage if err else "rpa", + ) + + collected_count = len(scrape_result.items) + return _success_payload( + keyword=keyword, + collected_count=collected_count, + items=scrape_result.items[:10], + stop_reason=scrape_result.stop_reason, + ) + except AccountManagerError as exc: + return _error_payload( + exc.code, + exc.message, + browser_started=False, + stage="account_prepare", + ) + finally: + release_lease(lease_token) + + +def run_keyword_search_task_sync( + keyword: str, + *, + max_items: int = DEFAULT_MAX_ITEMS, + max_scrolls: int = DEFAULT_MAX_SCROLLS, + account_id: Optional[str] = None, + platform: str = TARGET_PLATFORM, +) -> dict[str, Any]: + return asyncio.run( + run_keyword_search_task( + keyword, + max_items=max_items, + max_scrolls=max_scrolls, + account_id=account_id, + platform=platform, + ) + ) diff --git a/examples/real_browser_rpa/scripts/util/constants.py b/examples/real_browser_rpa/scripts/util/constants.py new file mode 100644 index 0000000..f90f552 --- /dev/null +++ b/examples/real_browser_rpa/scripts/util/constants.py @@ -0,0 +1,31 @@ +"""示例常量(复制到新 skill 时请替换为目标站点与业务字段)。""" + +SKILL_SLUG = "example-real-browser-rpa" +LOG_LOGGER_NAME = "openclaw.skill.example_real_browser_rpa" + +TARGET_PLATFORM = "target_platform" +LEASE_HOLDER = "example-real-browser-rpa" +LEASE_TTL_SEC = "1800" + +DEFAULT_START_URL = "https://example.com" +DEFAULT_MAX_ITEMS = 100 +DEFAULT_MAX_SCROLLS = 40 +DEFAULT_NO_NEW_ROUNDS_LIMIT = 5 +HUMAN_WAIT_TIMEOUT = 180 + +RESULT_END_TEXT = "没有更多结果了" + +# 示例 selector — 复制到新 skill 时按目标站点 DOM 替换 +SEARCH_INPUT_SELECTORS = ( + 'input[type="search"]', + 'input[name="q"]', + 'input[placeholder*="搜索"]', +) +SEARCH_BUTTON_SELECTOR = 'button:has-text("搜索")' +RESULT_CONTAINER_SELECTOR = "#search-result-container" +RESULT_ITEM_SELECTOR = "[data-result-item]" +SCROLL_CONTAINER_SELECTORS = ( + "#search-result-container", + ".search-result-scroll", + ".route-scroll-container", +) diff --git a/examples/real_browser_rpa/scripts/util/logging.py b/examples/real_browser_rpa/scripts/util/logging.py new file mode 100644 index 0000000..d0a014c --- /dev/null +++ b/examples/real_browser_rpa/scripts/util/logging.py @@ -0,0 +1,34 @@ +"""示例结构化日志工具。""" + +from __future__ import annotations + +import logging +import re +from typing import Any + +from util.constants import LOG_LOGGER_NAME + + +def get_logger(name: str | None = None) -> logging.Logger: + return logging.getLogger(name or LOG_LOGGER_NAME) + + +def mask_text(value: str, *, keep_start: int = 2, keep_end: int = 2) -> str: + s = (value or "").strip() + if len(s) >= 11 and s.isdigit(): + return s[:3] + "****" + s[-4:] + if len(s) > keep_start + keep_end: + return s[:keep_start] + "****" + s[-keep_end:] + return "****" + + +def safe_log_value(value: Any) -> str: + if value is None: + return "" + text = str(value) + if re.fullmatch(r"\d{7,}", text): + return mask_text(text) + if "@" in text and len(text) > 6: + local, _, domain = text.partition("@") + return f"{mask_text(local, keep_start=1, keep_end=1)}@{domain}" + return text diff --git a/examples/real_browser_rpa/tests/conftest.py b/examples/real_browser_rpa/tests/conftest.py new file mode 100644 index 0000000..5b922c9 --- /dev/null +++ b/examples/real_browser_rpa/tests/conftest.py @@ -0,0 +1,10 @@ +"""pytest 路径配置:将 examples/real_browser_rpa/scripts 加入 import 路径。""" + +from __future__ import annotations + +import sys +from pathlib import Path + +SCRIPTS = Path(__file__).resolve().parents[1] / "scripts" +if str(SCRIPTS) not in sys.path: + sys.path.insert(0, str(SCRIPTS)) diff --git a/examples/real_browser_rpa/tests/test_account_client.py b/examples/real_browser_rpa/tests/test_account_client.py new file mode 100644 index 0000000..70c70a8 --- /dev/null +++ b/examples/real_browser_rpa/tests/test_account_client.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import json +import subprocess +import unittest +from unittest.mock import patch + +from service.account_client import AccountManagerError, pick_web_account + + +def _completed(payload: dict) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess( + args=["python", "account-manager"], + returncode=0, + stdout=json.dumps(payload, ensure_ascii=False), + stderr="", + ) + + +class TestExplicitAccountLease(unittest.TestCase): + @patch("service.account_client._run_argv") + def test_explicit_account_id_is_reselected_with_lease(self, run_argv) -> None: + account = { + "id": 42, + "platform_key": "target-platform", + "account_label": "主账号", + "login_id": "user@example.com", + "profile_dir": "D:/data/profile-42", + "status": "active", + } + leased = {**account, "lease_token": "lease-42"} + run_argv.side_effect = [_completed(account), _completed(leased)] + + result = pick_web_account("target-platform", account_id="42") + + self.assertEqual(result["id"], 42) + self.assertEqual(result["lease_token"], "lease-42") + self.assertEqual(run_argv.call_args_list[0].args[0], ["account", "get", "42"]) + pick_args = run_argv.call_args_list[1].args[0] + self.assertIn("pick-web", pick_args) + self.assertIn("--lease", pick_args) + self.assertIn("user@example.com", pick_args) + + @patch("service.account_client._run_argv") + def test_missing_lease_token_is_rejected(self, run_argv) -> None: + account = { + "id": 42, + "platform_key": "target-platform", + "account_label": "主账号", + "login_id": "user@example.com", + "profile_dir": "D:/data/profile-42", + "status": "active", + } + run_argv.side_effect = [_completed(account), _completed(account)] + + with self.assertRaises(AccountManagerError) as raised: + pick_web_account("target-platform", account_id="42") + + self.assertEqual(raised.exception.code, "LEASE_REQUIRED") + + +if __name__ == "__main__": + unittest.main() diff --git a/examples/real_browser_rpa/tests/test_human_verification.py b/examples/real_browser_rpa/tests/test_human_verification.py new file mode 100644 index 0000000..5b27f82 --- /dev/null +++ b/examples/real_browser_rpa/tests/test_human_verification.py @@ -0,0 +1,170 @@ +# -*- coding: utf-8 -*- +"""人工验证检测单元测试(DOM 精准检测,不依赖真实浏览器)。""" +from __future__ import annotations + +import asyncio +import time +import unittest + +from service.human_verification import ( + HumanVerificationWaitResult, + detect_human_verification, + detect_human_verification_from_text, + wait_human_verification_if_present, + wait_save_login_countdown_if_present, +) + + +SLIDER_HTML = """ +<div class="vc-captcha-verify slide"> + <div class="captcha_verify_bar--title">请完成下列验证后继续</div> + <div class="captcha-slider-tips">按住左边按钮拖动完成上方拼图</div> +</div> +""" + +SMS_HTML = """ +<div class="second-verify-panel"> + <div class="uc-ui-verify_sms-input"> + <div class="uc-ui-verify-new_header-title">短信验证</div> + <input placeholder="手机号" /> + <input placeholder="请输入验证码" /> + <button>获取验证码</button> + </div> +</div> +""" + +SEARCH_RESULTS_HTML = """ +<div id="search-result-container"> + <div data-result-item="1">RPA 滑块验证码识别教程</div> + <div data-result-item="2">短信验证码自动化</div> +</div> +""" + + +class MockLocator: + def __init__(self, selector: str, visible: set[str], texts: dict[str, str]) -> None: + self._selector = selector + self._visible = visible + self._texts = texts + + @property + def first(self) -> MockLocator: + return self + + async def is_visible(self, timeout: int = 500) -> bool: + return self._selector in self._visible + + async def inner_text(self, timeout: int = 1000) -> str: + return self._texts.get(self._selector, "") + + async def count(self) -> int: + return 1 if self._selector in self._visible else 0 + + +class MockPage: + def __init__(self, visible: set[str], texts: dict[str, str] | None = None) -> None: + self._visible = visible + self._texts = texts or {} + + def locator(self, selector: str) -> MockLocator: + return MockLocator(selector, self._visible, self._texts) + + +class TestHumanVerificationFromText(unittest.TestCase): + def test_slider_dom_detect(self) -> None: + state = detect_human_verification_from_text(SLIDER_HTML) + self.assertTrue(state.present) + self.assertEqual(state.kind, "slider") + self.assertTrue(state.selector) + + def test_sms_dom_detect(self) -> None: + state = detect_human_verification_from_text(SMS_HTML) + self.assertTrue(state.present) + self.assertEqual(state.kind, "sms") + self.assertTrue(state.selector) + + def test_search_results_not_misclassified(self) -> None: + state = detect_human_verification_from_text(SEARCH_RESULTS_HTML) + self.assertFalse(state.present) + + def test_body_keywords_without_dom_not_misclassified(self) -> None: + text = "RPA 滑块验证码识别教程\n短信验证码自动化" + state = detect_human_verification_from_text(text) + self.assertFalse(state.present) + + +class TestHumanVerificationDomDetect(unittest.TestCase): + def test_slider_dom_visible(self) -> None: + page = MockPage( + {".vc-captcha-verify.slide"}, + {".vc-captcha-verify.slide": "请完成下列验证后继续"}, + ) + state = asyncio.run(detect_human_verification(page)) + self.assertTrue(state.present) + self.assertEqual(state.kind, "slider") + self.assertEqual(state.selector, ".vc-captcha-verify.slide") + + def test_sms_dom_visible(self) -> None: + page = MockPage( + {".second-verify-panel"}, + {".second-verify-panel": "短信验证 获取验证码"}, + ) + state = asyncio.run(detect_human_verification(page)) + self.assertTrue(state.present) + self.assertEqual(state.kind, "sms") + + def test_no_verification_when_empty(self) -> None: + page = MockPage(set()) + state = asyncio.run(detect_human_verification(page)) + self.assertFalse(state.present) + + +class TestSaveLoginCountdown(unittest.TestCase): + def test_wait_not_present_no_delay(self) -> None: + page = MockPage(set()) + start = time.monotonic() + result = asyncio.run(wait_save_login_countdown_if_present(page, timeout_sec=8)) + elapsed = time.monotonic() - start + self.assertFalse(result) + self.assertLess(elapsed, 1.0) + + +class TestHumanVerificationWait(unittest.TestCase): + def test_wait_timeout_slider_dom(self) -> None: + page = MockPage( + {".vc-captcha-verify.slide"}, + {".vc-captcha-verify.slide": "请完成下列验证后继续"}, + ) + result = asyncio.run( + wait_human_verification_if_present(page, wait_sec=1, stage="search_verification") + ) + self.assertIsInstance(result, HumanVerificationWaitResult) + self.assertTrue(result.timed_out) + self.assertEqual(result.code, "SLIDER_VERIFICATION_TIMEOUT") + self.assertIn("滑块验证码未在 1 秒内完成", result.message) + self.assertEqual(result.stage, "search_verification") + + def test_wait_timeout_sms_dom(self) -> None: + page = MockPage( + {".second-verify-panel"}, + {".second-verify-panel": "短信验证 获取验证码"}, + ) + result = asyncio.run( + wait_human_verification_if_present(page, wait_sec=1, stage="login_check") + ) + self.assertIsInstance(result, HumanVerificationWaitResult) + self.assertTrue(result.timed_out) + self.assertEqual(result.code, "SMS_VERIFICATION_TIMEOUT") + self.assertEqual(result.stage, "login_check") + + def test_collect_no_verification_immediate_none(self) -> None: + page = MockPage(set()) + start = time.monotonic() + err = asyncio.run(wait_human_verification_if_present(page, wait_sec=180, stage="collect")) + elapsed = time.monotonic() - start + self.assertIsNone(err) + self.assertLess(elapsed, 1.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/examples/real_browser_rpa/tests/test_stop_reason.py b/examples/real_browser_rpa/tests/test_stop_reason.py new file mode 100644 index 0000000..70110e5 --- /dev/null +++ b/examples/real_browser_rpa/tests/test_stop_reason.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +"""stop_reason 用户友好文案单元测试。""" +from __future__ import annotations + +import unittest + +from service.task_rpa import format_stop_reason_for_user + + +class TestFormatStopReasonForUser(unittest.TestCase): + def test_max_items(self) -> None: + msg = format_stop_reason_for_user("max_items", 100) + self.assertIn("默认采集规模", msg) + self.assertIn("100", msg) + + def test_end_marker(self) -> None: + msg = format_stop_reason_for_user("end_marker", 42) + self.assertIn("没有更多结果", msg) + + def test_no_new_rounds(self) -> None: + msg = format_stop_reason_for_user("no_new_rounds", 30) + self.assertIn("连续多次加载没有发现新结果", msg) + + def test_max_scrolls(self) -> None: + msg = format_stop_reason_for_user("max_scrolls", 80) + self.assertIn("页面加载保护范围", msg) + + def test_none_or_unknown(self) -> None: + self.assertEqual(format_stop_reason_for_user(None, 10), "本次采集已完成。") + self.assertEqual(format_stop_reason_for_user("unknown", 10), "本次采集已完成。") + + +if __name__ == "__main__": + unittest.main() diff --git a/examples/real_desktop_rpa/README.md b/examples/real_desktop_rpa/README.md new file mode 100644 index 0000000..82da4e0 --- /dev/null +++ b/examples/real_desktop_rpa/README.md @@ -0,0 +1,152 @@ +# Real Windows Desktop RPA Example + +这是从已跑通业务技能 `D:\OpenClaw\client-marketing\add-friend-wecom` 提炼的 Windows UIA 参考案例。它用于教技术人员和 AI 编程工具如何从真实拾取结果得到稳定定位器,并完成窗口、控件、动作、等待和诊断闭环。 + +## 状态 + +- **技术路线已真实验证**:Windows UIA + Python `uiautomation` +- **来源业务已真实跑通**:企业微信添加客户(通讯录 → 新的客户 → 添加 → 搜索 → 发送) +- **本目录默认安全模式**:只提供 probe、dry-run、assisted 范式,不默认发送真实邀请 +- **不是完整 skill 仓库**:按 Copy map 选择性复制 + +## 先读 + +1. [`../../development/RPA_ELEMENT_LOCATING.md`](../../development/RPA_ELEMENT_LOCATING.md) +2. [`../../development/RPA_DESKTOP.md`](../../development/RPA_DESKTOP.md)(含 Name/Value、最大化激活、同名叶子) +3. 本 README 的“原始拾取到生产定位器” + +## 成功案例验证了什么 + +```text +枚举目标顶层窗口 +→ 激活并最大化主窗(勿无条件 SW_RESTORE) +→ 从 HWND 获取 UIA root / 业务子 pane +→ 在父容器中定位(Name 与 Value 分开看) +→ 真实点击并校验后置状态 +→ 等待子窗口或业务结果 +→ 处理独立/嵌套弹窗 +→ probe-ui 诊断 +``` + +来源案例还提供 `dry-run / assisted / auto` 三档、频率控制和业务记忆。本 example 只提炼 UIA 元素工程;账号、风控和数据库仍由具体业务技能实现。 + +本目录 `probe()` 是**最小探针**(主窗 + 通讯录入口,可选尝试新的客户行),**不等于**生产技能完整 probe。 + +## 目录结构 + +```text +real_desktop_rpa/ + README.md + assets/ + element-map.sample.yaml + selector-capture.sample.xml + scripts/ + service/ + desktop_session.py + uia_locator.py + wecom_reference_flow.py + util/ + selectors.py + tests/ + test_selector_contract.py +``` + +## 原始拾取到生产定位器 + +### 搜索框(添加客户窗) + +```text +原始:Window(SearchExternalsWnd) → 多层 Layout/index → search_layout → searchedit +提炼:窗口 → search_layout → Name=searchedit +``` + +### 新的客户(易错) + +```text +原始:通讯录 pane → wlist_view → item[2] → status_layout → main_chattext +提炼:wlist_view 内找 子控件 main_chattext.Value=新的客户 的那一行,再点该行 status_layout +禁止:全树第一个 Name=status_layout(常为「群聊」) +禁止:把选择器标题「新的客户」当成 UIA Name 去搜 +``` + +### 添加按钮 + +```text +提炼:search_external → wbtn_container(子文案 Value=添加) +禁止:搜索旁 plus_button 下的同名 wbtn_container +``` + +正确提炼方式: + +1. 保留稳定顶层窗口:进程、Window Class、标题;需要时加上业务子 pane。 +2. 保留最小业务父容器。 +3. 叶子用稳定 Name **和/或** Value / 子控件 Value。 +4. 删除只反映当前布局的深层 index;index 仅作已验证兜底并写清含义。 +5. 验证父容器内候选数量为 `1`。 +6. 重启应用后再次验证。 + +不要把完整 XML 路径或未校验的 `item[0]` 原样变成生产定位器。 + +## Element map + +本目录的 [`assets/element-map.sample.yaml`](assets/element-map.sample.yaml) 只展示来源案例的元素登记方法。复制后必须重新拾取目标软件并全部替换。 + +关键规则: + +- 窗口不唯一时失败,不取 `hwnds[0]`。 +- 控件不唯一时失败,不取第一个匹配。 +- `item`、`status_layout`、`wbtn_container` 等通用 Name 必须放进稳定父容器,并用文案校验。 +- 行顺序不是身份;用业务文案(Value)选择结果行。 +- 主窗依赖完整侧栏时先 maximize;`activate(maximize=False)` 不得冲掉已最大化状态。 +- 只有主定位成功、窗口前台且矩形有效时,才允许从 UIA BoundingRectangle 降级坐标点击。 + +## 三种实施模式 + +| 模式 | 行为 | 用途 | +|---|---|---| +| `probe` | 只检查依赖、窗口和关键控件 | 安装、自检、版本变更排障 | +| `dry-run` | 导航并填写,提交前停止 | 开发和验收默认 | +| `assisted` | 填写后等待用户人工确认 | 不可逆动作或现场联调 | + +业务技能若增加 `auto`,必须实现明确确认策略、频率限制、业务后置状态和 `RESULT_UNCONFIRMED`。 + +## Copy map + +| 来源 | 目标 | 说明 | +|---|---|---| +| `scripts/service/desktop_session.py` | `scripts/service/desktop_session.py` | 顶层窗口枚举、唯一性、激活/最大化 | +| `scripts/service/uia_locator.py` | `scripts/service/uia_locator.py` | 作用域内唯一定位(含 Value)、点击、输入 | +| `scripts/util/selectors.py` | `scripts/util/<platform>_selectors.py` | element spec;必须全部替换 | +| `scripts/service/wecom_reference_flow.py` | `scripts/service/<business>_flow.py` | 只复制流程分层,不复制企微 selector | +| `assets/element-map.sample.yaml` | `assets/<platform>-element-map.yaml` | 真实拾取后填写 | +| `tests/test_selector_contract.py` | `tests/test_selector_contract.py` | 定位器契约测试思路 | + +## 必须补齐的业务能力 + +- 目标应用安装/版本 doctor +- 真实业务元素清单 +- 失败截图、UIA树和顶层窗口快照 +- Activity/RpaVideoSession 步骤 +- 不可逆动作确认 +- 业务成功/失败/结果不明确状态 +- 频率限制、幂等和断点续跑 +- 真实集成测试(默认不进 CI) + +## 禁止照抄 + +- `WXWork`、`WeWorkWindow`、`SearchExternalsWnd` +- `org_op_new`、`searchedit`、`wbtn_container`、`main_chattext` +- 企业微信列表第 0/1/2 行含义或固定 index +- 来源案例的本机路径、账号和业务数据 +- 任何未经目标软件真实拾取验证的 fallback + +## 验收 + +- `probe` 能明确报告依赖、窗口和关键元素(生产技能覆盖关键跳转节点) +- 每个关键元素在目标作用域内唯一 +- 关闭重开软件后仍能定位 +- 多窗口时不会误选 +- UI变化时停止并保存截图/UIA树 +- dry-run 不执行不可逆提交 +- assisted 清楚提示用户确认 +- 提交后仅在业务后置状态成立时报告成功 diff --git a/examples/real_desktop_rpa/assets/element-map.sample.yaml b/examples/real_desktop_rpa/assets/element-map.sample.yaml new file mode 100644 index 0000000..ecebceb --- /dev/null +++ b/examples/real_desktop_rpa/assets/element-map.sample.yaml @@ -0,0 +1,117 @@ +source_case: add-friend-wecom +status: verified_source_redacted_example +surface: windows +driver: uiautomation +notes: + - 选择器显示名 ≠ UIA Name;可见中文常在 Value / 子 Label + - 禁止全树第一个 status_layout / wbtn_container + - 主窗流程先 maximize;activate 勿无条件 SW_RESTORE +elements: + - logical_name: main_window + purpose: 企业微信主窗口 + scope: + process_name: WXWork + primary_locator: + window_class: WeWorkWindow + title: 企业微信 + expected_count: 1 + postcondition: UIA root 可读取;业务需要时已最大化 + replace_in_new_skill: true + + - logical_name: contacts_entry + purpose: 打开通讯录 + scope: + logical_parent: main_window + primary_locator: + name: org_op_new + control_type: TreeItem + value_hint: 通讯录 + expected_count: 1 + postcondition: 出现企业微信-通讯录业务 pane + replace_in_new_skill: true + + - logical_name: contacts_pane + purpose: 通讯录业务子层作用域 + scope: + logical_parent: main_window + primary_locator: + name_contains: 通讯录 + expected_count: 1 + postcondition: 可枚举 wlist_view + replace_in_new_skill: true + + - logical_name: new_customer_row + purpose: 侧栏进入「新的客户」 + scope: + logical_parent: contacts_pane + ancestor_name: wlist_view + primary_locator: + row_leaf_name: status_layout + child_name: main_chattext + child_value: 新的客户 + expected_count: 1 + postcondition: panel_title_label Value=新的客户 或出现 search_external + failure_note: 禁止点全树第一个 status_layout(常为群聊) + replace_in_new_skill: true + + - logical_name: panel_title + purpose: 校验已进入新的客户页 + scope: + logical_parent: contacts_pane + primary_locator: + name: panel_title_label + value: 新的客户 + expected_count: 1 + replace_in_new_skill: true + + - logical_name: add_customer_button + purpose: 打开添加客户窗口 + scope: + logical_parent: contacts_pane + ancestor_name: search_external + primary_locator: + name: wbtn_container + child_name: ww_btn_text + child_value: 添加 + expected_count: 1 + postcondition: 出现 SearchExternalsWnd + failure_note: 禁止点搜索旁 plus_button 下的 wbtn_container + replace_in_new_skill: true + + - logical_name: add_customer_window + purpose: 添加客户窗口 + scope: + process_name: WXWork + primary_locator: + window_class: SearchExternalsWnd + title: 添加客户 + expected_count: 1 + postcondition: search_input 可定位 + replace_in_new_skill: true + + - logical_name: search_input + purpose: 输入待搜索账号 + scope: + logical_parent: add_customer_window + ancestor_name: search_layout + primary_locator: + name: searchedit + expected_count: 1 + ready_condition: + visible: true + enabled: true + postcondition: 结果列表或用户不存在弹窗出现 + replace_in_new_skill: true + + - logical_name: verify_message_input + purpose: 填写添加验证语 + scope: + window_class: InputReasonWnd + title_any: + - 输入认证信息 + - 发送添加邀请 + primary_locator: + name: name_edit + expected_count: 1 + postcondition: 输入值可读取或发送按钮可用 + replace_in_new_skill: true diff --git a/examples/real_desktop_rpa/assets/selector-capture.sample.xml b/examples/real_desktop_rpa/assets/selector-capture.sample.xml new file mode 100644 index 0000000..2414611 --- /dev/null +++ b/examples/real_desktop_rpa/assets/selector-capture.sample.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + 脱敏教学摘录:展示“拾取器长路径”而非可直接复制的生产 selector。 + 生产代码应保留稳定窗口、业务父容器和叶子属性,删除无业务含义的深层 index。 +--> +<selector name="search_input" source="inspector-export"> + <window process="WXWork" class="SearchExternalsWnd" title="添加客户" /> + <uia name="content_container" index="1" /> + <uia name="search_layout" index="0" /> + <uia name="inner_search_layout" index="1" /> + <uia name="searchedit" index="1" /> +</selector> + +<!-- + 新的客户:长路径示意 → 生产应改为 + wlist_view 内 child main_chattext.Value=新的客户 的行,再点该行 status_layout。 + 禁止全树第一个 status_layout。 +--> +<selector name="new_customer_row" source="inspector-export"> + <window process="WXWork" class="WeWorkWindow" title="企业微信" /> + <pane name="企业微信-通讯录" /> + <uia name="wlist_view" /> + <uia name="item" index="2" /> + <uia name="status_layout" /> + <uia name="main_chattext" value="新的客户" /> +</selector> diff --git a/examples/real_desktop_rpa/scripts/service/desktop_session.py b/examples/real_desktop_rpa/scripts/service/desktop_session.py new file mode 100644 index 0000000..1a44a4e --- /dev/null +++ b/examples/real_desktop_rpa/scripts/service/desktop_session.py @@ -0,0 +1,129 @@ +"""Windows 顶层窗口作用域:枚举、唯一性校验和激活。""" + +from __future__ import annotations + +import ctypes +import ntpath +import time +from ctypes import wintypes +from dataclasses import dataclass +from typing import Iterable + +from util.selectors import WindowSpec + + +class DesktopRpaError(RuntimeError): + def __init__(self, code: str, message: str) -> None: + super().__init__(f"ERROR:{code} {message}") + self.code = code + self.message = message + + +@dataclass(frozen=True) +class WindowCandidate: + hwnd: int + pid: int + process_name: str + class_name: str + title: str + visible: bool + + +user32 = ctypes.windll.user32 +kernel32 = ctypes.windll.kernel32 +SW_RESTORE = 9 +SW_MAXIMIZE = 3 +PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 + + +def _process_name(pid: int) -> str: + handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid) + if not handle: + return "" + try: + size = wintypes.DWORD(32768) + buffer = ctypes.create_unicode_buffer(size.value) + if not kernel32.QueryFullProcessImageNameW( + handle, + 0, + buffer, + ctypes.byref(size), + ): + return "" + return ntpath.splitext(ntpath.basename(buffer.value))[0] + finally: + kernel32.CloseHandle(handle) + + +def enumerate_top_windows() -> list[WindowCandidate]: + found: list[WindowCandidate] = [] + + @ctypes.WINFUNCTYPE(ctypes.c_bool, wintypes.HWND, wintypes.LPARAM) + def callback(hwnd, _lparam): + pid = wintypes.DWORD() + user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid)) + cls = ctypes.create_unicode_buffer(256) + user32.GetClassNameW(hwnd, cls, 256) + length = user32.GetWindowTextLengthW(hwnd) + title = ctypes.create_unicode_buffer(length + 1) + user32.GetWindowTextW(hwnd, title, length + 1) + found.append( + WindowCandidate( + hwnd=int(hwnd), + pid=int(pid.value), + process_name=_process_name(int(pid.value)), + class_name=cls.value, + title=title.value, + visible=bool(user32.IsWindowVisible(hwnd)), + ) + ) + return True + + user32.EnumWindows(callback, 0) + return found + + +def resolve_unique_window( + spec: WindowSpec, + *, + candidates: Iterable[WindowCandidate] | None = None, +) -> WindowCandidate: + pool = list(candidates) if candidates is not None else enumerate_top_windows() + matched = [ + item + for item in pool + if item.visible + and item.process_name.casefold() == spec.process_name.casefold() + and item.class_name == spec.class_name + and (spec.title is None or item.title == spec.title) + ] + if not matched: + raise DesktopRpaError("WINDOW_NOT_FOUND", f"{spec.logical_name} 未找到") + if len(matched) != 1: + raise DesktopRpaError( + "ELEMENT_AMBIGUOUS", + f"{spec.logical_name} 匹配 {len(matched)} 个窗口,请按 PID/账号补充作用域", + ) + return matched[0] + + +def activate_window(window: WindowCandidate, *, maximize: bool = False) -> None: + """激活窗口。 + + maximize=True:主窗进入依赖完整侧栏的流程时使用。 + maximize=False:只前置;**禁止**对已最大化窗口无条件 SW_RESTORE。 + """ + hwnd = window.hwnd + iconic = bool(user32.IsIconic(hwnd)) + zoomed = bool(user32.IsZoomed(hwnd)) + if maximize: + if iconic: + user32.ShowWindow(hwnd, SW_RESTORE) + if not zoomed: + user32.ShowWindow(hwnd, SW_MAXIMIZE) + else: + if iconic: + user32.ShowWindow(hwnd, SW_RESTORE) + if not user32.SetForegroundWindow(hwnd): + raise DesktopRpaError("WINDOW_ACTIVATE_FAILED", f"无法激活 hwnd={hwnd}") + time.sleep(0.35 if maximize else 0.2) diff --git a/examples/real_desktop_rpa/scripts/service/uia_locator.py b/examples/real_desktop_rpa/scripts/service/uia_locator.py new file mode 100644 index 0000000..e039dd2 --- /dev/null +++ b/examples/real_desktop_rpa/scripts/service/uia_locator.py @@ -0,0 +1,127 @@ +"""作用域内 UIA 唯一定位、真实点击和逐字输入。""" + +from __future__ import annotations + +import random +from typing import Any, Iterator + +from service.desktop_session import DesktopRpaError, WindowCandidate, activate_window +from util.selectors import ElementSpec + + +def require_uiautomation(): + try: + import uiautomation as auto + except ImportError as exc: + raise DesktopRpaError("UIA_UNAVAILABLE", "缺少 uiautomation 依赖") from exc + return auto + + +def root_from_window(window: WindowCandidate): + auto = require_uiautomation() + return auto.ControlFromHandle(window.hwnd) + + +def control_value(control: Any) -> str: + try: + auto = require_uiautomation() + raw = control.GetPropertyValue(auto.PropertyId.ValueValueProperty) + return str(raw or "").strip() + except Exception: + return "" + + +def _walk(root: Any, *, depth: int = 0, max_depth: int = 25) -> Iterator[Any]: + if depth > max_depth: + return + yield root + if depth == max_depth: + return + try: + children = root.GetChildren() + except Exception: + children = [] + for child in children: + yield from _walk(child, depth=depth + 1, max_depth=max_depth) + + +def _child_has_value(control: Any, child_name: str, expected_value: str, *, max_depth: int = 10) -> bool: + for node in _walk(control, max_depth=max_depth): + if (getattr(node, "Name", None) or "") != child_name: + continue + if control_value(node) == expected_value: + return True + return False + + +def _matches(control: Any, spec: ElementSpec) -> bool: + if spec.name is not None and (getattr(control, "Name", None) or "") != spec.name: + return False + if spec.value is not None and not spec.child_value_name: + if control_value(control) != spec.value: + return False + if spec.automation_id is not None and ( + getattr(control, "AutomationId", None) or "" + ) != spec.automation_id: + return False + if spec.control_type is not None and ( + (getattr(control, "ControlTypeName", None) or "").removesuffix("Control") + != spec.control_type.removesuffix("Control") + ): + return False + if spec.class_name is not None and ( + getattr(control, "ClassName", None) or "" + ) != spec.class_name: + return False + if spec.child_value_name and spec.value: + if not _child_has_value(control, spec.child_value_name, spec.value): + return False + return True + + +def locate_unique(root: Any, spec: ElementSpec, *, max_depth: int = 25): + search_root = root + if spec.ancestor_name: + ancestor_spec = ElementSpec( + logical_name=f"{spec.logical_name}.ancestor", + name=spec.ancestor_name, + ) + search_root = locate_unique(root, ancestor_spec, max_depth=max_depth) + + matched = [ + control for control in _walk(search_root, max_depth=max_depth) if _matches(control, spec) + ] + if not matched: + raise DesktopRpaError("ELEMENT_NOT_FOUND", f"{spec.logical_name} 未找到") + if len(matched) != spec.expected_count: + raise DesktopRpaError( + "ELEMENT_AMBIGUOUS", + f"{spec.logical_name} 期望 {spec.expected_count} 个,实际 {len(matched)} 个", + ) + return matched[0] + + +def click_control( + control: Any, + *, + window: WindowCandidate, + maximize: bool = False, +) -> None: + activate_window(window, maximize=maximize) + rect = control.BoundingRectangle + if rect.width() <= 0 or rect.height() <= 0 or rect.left < -10000: + raise DesktopRpaError("ELEMENT_NOT_READY", "目标元素不可见或边界矩形无效") + control.Click( + x=random.randint(-2, 2), + y=random.randint(-2, 2), + simulateMove=True, + ) + + +def type_text(control: Any, text: str, *, window: WindowCandidate) -> None: + auto = require_uiautomation() + click_control(control, window=window, maximize=False) + auto.SendKeys("{Ctrl}a", waitTime=0.05) + auto.SendKeys("{Delete}", waitTime=0.05) + for char in text: + auto.SendKeys(char, waitTime=random.uniform(0.04, 0.12)) diff --git a/examples/real_desktop_rpa/scripts/service/wecom_reference_flow.py b/examples/real_desktop_rpa/scripts/service/wecom_reference_flow.py new file mode 100644 index 0000000..804a474 --- /dev/null +++ b/examples/real_desktop_rpa/scripts/service/wecom_reference_flow.py @@ -0,0 +1,58 @@ +"""企业微信成功案例的安全提炼:probe 和 dry-run,不默认发送邀请。""" + +from __future__ import annotations + +from dataclasses import asdict +from typing import Any + +from service.desktop_session import activate_window, resolve_unique_window +from service.uia_locator import locate_unique, root_from_window, type_text +from util.selectors import ( + ADD_CUSTOMER_WINDOW, + CONTACTS_ENTRY, + MAIN_WINDOW, + NEW_CUSTOMER_ROW, + SEARCH_INPUT, +) + + +def probe() -> dict[str, Any]: + """最小 probe:主窗 + 通讯录入口。 + + 生产技能应对「新的客户 / 添加」等关键节点做更深探测;本函数不等于生产完整度。 + 调用前会 maximize 主窗,避免侧栏折叠导致 org_op_new 抖动。 + """ + main = resolve_unique_window(MAIN_WINDOW) + activate_window(main, maximize=True) + root = root_from_window(main) + contacts = locate_unique(root, CONTACTS_ENTRY) + payload: dict[str, Any] = { + "success": True, + "minimized_probe": True, + "main_window": asdict(main), + "contacts_entry": { + "name": getattr(contacts, "Name", ""), + "control_type": getattr(contacts, "ControlTypeName", ""), + }, + } + # 可选:若已在通讯录页,尝试按 Value 定位「新的客户」行(失败不视为 probe 失败) + try: + row = locate_unique(root, NEW_CUSTOMER_ROW) + payload["new_customer_row"] = {"name": getattr(row, "Name", "")} + except Exception: + payload["new_customer_row"] = None + return payload + + +def fill_search_dry_run(target: str) -> dict[str, Any]: + """要求操作者已打开“添加客户”窗口;只填写搜索框,不提交。""" + search_window = resolve_unique_window(ADD_CUSTOMER_WINDOW) + activate_window(search_window, maximize=False) + root = root_from_window(search_window) + search_input = locate_unique(root, SEARCH_INPUT) + type_text(search_input, target, window=search_window) + return { + "success": True, + "mode": "dry-run", + "message": "已填写搜索条件,未发送邀请", + } diff --git a/examples/real_desktop_rpa/scripts/util/selectors.py b/examples/real_desktop_rpa/scripts/util/selectors.py new file mode 100644 index 0000000..7fd2994 --- /dev/null +++ b/examples/real_desktop_rpa/scripts/util/selectors.py @@ -0,0 +1,63 @@ +"""脱敏 UIA element specs;复制到新技能后必须用真实拾取结果替换。""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional + + +@dataclass(frozen=True) +class WindowSpec: + logical_name: str + process_name: str + class_name: str + title: Optional[str] = None + + +@dataclass(frozen=True) +class ElementSpec: + logical_name: str + name: Optional[str] = None + """UIA Name(内部 id,未必是界面中文)。""" + value: Optional[str] = None + """UIA Value(可见中文文案常在此)。""" + automation_id: Optional[str] = None + control_type: Optional[str] = None + class_name: Optional[str] = None + ancestor_name: Optional[str] = None + """先定位到该 Name 的祖先,再在其下搜索。""" + child_value_name: Optional[str] = None + """在候选控件子树中查找该 Name 的子控件,并用 value 匹配其 Value。""" + expected_count: int = 1 + + +MAIN_WINDOW = WindowSpec("main_window", "WXWork", "WeWorkWindow", "企业微信") +ADD_CUSTOMER_WINDOW = WindowSpec( + "add_customer_window", + "WXWork", + "SearchExternalsWnd", + "添加客户", +) +CONTACTS_ENTRY = ElementSpec( + "contacts_entry", + name="org_op_new", + control_type="TreeItem", +) +# 示意:用子控件 main_chattext 的 Value 定位「新的客户」行(禁止全树第一个 status_layout) +NEW_CUSTOMER_ROW = ElementSpec( + "new_customer_row", + name="status_layout", + ancestor_name="wlist_view", + child_value_name="main_chattext", + value="新的客户", +) +ADD_BUTTON = ElementSpec( + "add_customer_button", + name="wbtn_container", + ancestor_name="search_external", +) +SEARCH_INPUT = ElementSpec( + "search_input", + name="searchedit", + ancestor_name="search_layout", +) diff --git a/examples/real_desktop_rpa/tests/test_selector_contract.py b/examples/real_desktop_rpa/tests/test_selector_contract.py new file mode 100644 index 0000000..ff6de92 --- /dev/null +++ b/examples/real_desktop_rpa/tests/test_selector_contract.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +from service.desktop_session import ( # noqa: E402 + DesktopRpaError, + WindowCandidate, + activate_window, + resolve_unique_window, +) +from service import uia_locator # noqa: E402 +from util.selectors import ElementSpec, MAIN_WINDOW # noqa: E402 + + +class SelectorContractTest(unittest.TestCase): + def test_unique_window_is_required(self) -> None: + candidate = WindowCandidate(1, 10, "WXWork", "WeWorkWindow", "企业微信", True) + self.assertEqual( + resolve_unique_window(MAIN_WINDOW, candidates=[candidate]), + candidate, + ) + + def test_duplicate_windows_fail_instead_of_taking_first(self) -> None: + candidates = [ + WindowCandidate(1, 10, "WXWork", "WeWorkWindow", "企业微信", True), + WindowCandidate(2, 11, "WXWork", "WeWorkWindow", "企业微信", True), + ] + with self.assertRaises(DesktopRpaError) as ctx: + resolve_unique_window(MAIN_WINDOW, candidates=candidates) + self.assertEqual(ctx.exception.code, "ELEMENT_AMBIGUOUS") + + def test_hidden_window_is_not_a_candidate(self) -> None: + hidden = WindowCandidate(1, 10, "WXWork", "WeWorkWindow", "企业微信", False) + with self.assertRaises(DesktopRpaError) as ctx: + resolve_unique_window(MAIN_WINDOW, candidates=[hidden]) + self.assertEqual(ctx.exception.code, "WINDOW_NOT_FOUND") + + def test_activate_without_maximize_skips_restore_when_zoomed(self) -> None: + window = WindowCandidate(42, 10, "WXWork", "WeWorkWindow", "企业微信", True) + with patch("service.desktop_session.user32") as user32: + user32.IsIconic.return_value = False + user32.IsZoomed.return_value = True + user32.SetForegroundWindow.return_value = True + activate_window(window, maximize=False) + user32.ShowWindow.assert_not_called() + user32.SetForegroundWindow.assert_called_once_with(42) + + def test_activate_maximize_when_not_zoomed(self) -> None: + window = WindowCandidate(42, 10, "WXWork", "WeWorkWindow", "企业微信", True) + with patch("service.desktop_session.user32") as user32: + user32.IsIconic.return_value = False + user32.IsZoomed.return_value = False + user32.SetForegroundWindow.return_value = True + activate_window(window, maximize=True) + user32.ShowWindow.assert_called() + user32.SetForegroundWindow.assert_called_once_with(42) + + def test_locate_unique_matches_child_value(self) -> None: + row = MagicMock() + row.Name = "status_layout" + row.AutomationId = "" + row.ControlTypeName = "TreeControl" + row.ClassName = "" + label = MagicMock() + label.Name = "main_chattext" + label.GetChildren.return_value = [] + row.GetChildren.return_value = [label] + + root = MagicMock() + root.Name = "wlist_view" + root.AutomationId = "" + root.ControlTypeName = "TreeControl" + root.ClassName = "" + root.GetChildren.return_value = [row] + + with patch.object(uia_locator, "control_value", side_effect=lambda c: "新的客户" if c is label else ""): + spec = ElementSpec( + "new_customer_row", + name="status_layout", + ancestor_name="wlist_view", + child_value_name="main_chattext", + value="新的客户", + ) + # ancestor is root itself when searching from root with name wlist_view + found = uia_locator.locate_unique(root, ElementSpec("wlist", name="wlist_view")) + self.assertIs(found, root) + found_row = uia_locator.locate_unique(root, spec) + self.assertIs(found_row, row) + + +if __name__ == "__main__": + unittest.main() diff --git a/examples/simulator_api/README.md b/examples/simulator_api/README.md new file mode 100644 index 0000000..bf8fd6a --- /dev/null +++ b/examples/simulator_api/README.md @@ -0,0 +1,35 @@ +# Simulator API Example + +这个目录用于沉淀「仿真环境 / API」类 skill 的可复制成功案例。 + +## 适用场景(规划) + +- 使用 mock / sandbox / simulator API +- 不需要启动浏览器 +- 适合演示输入校验、API 调用、结果入库、日志、错误契约和测试 + +## 当前状态:规划占位 + +- **本目录当前为规划占位**,仅保留最小结构(`README.md`、`scripts/`、`tests/` 占位)。 +- **尚未沉淀可复制实现**;目录内无参考代码。 +- **AI 编程工具不得**基于空目录自行脑补 mock API 或仿真接口实现。 +- 后续补齐可复制案例后,再按 README copy map 选择性复制到新技能 `scripts/`。 + +## 轻量结构 + +```text +simulator_api/ + README.md + scripts/ + service/ + util/ + tests/ +``` + +不要在本目录添加 `SKILL.md`、市场 README、`references/`、`development/`、`.github/` 等完整 skill 仓库层文件。 + +## 复制边界(待案例补齐后生效) + +- 参考代码将从 `examples/simulator_api/scripts/` 选择性复制到新技能 `scripts/` +- 根 `README.md`、`SKILL.md`、`references/` 仍按模板主目录规则编写 +- 当前阶段:**不可作为实现参考** diff --git a/examples/simulator_api/scripts/service/.gitkeep b/examples/simulator_api/scripts/service/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/examples/simulator_api/scripts/util/.gitkeep b/examples/simulator_api/scripts/util/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/examples/simulator_api/tests/.gitkeep b/examples/simulator_api/tests/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/examples/simulator_browser_rpa/README.md b/examples/simulator_browser_rpa/README.md new file mode 100644 index 0000000..22951ed --- /dev/null +++ b/examples/simulator_browser_rpa/README.md @@ -0,0 +1,163 @@ +# Simulator Browser RPA Example + +这是一个**仿真浏览器 RPA** 的可复制参考实现:在可控 sandbox 页面上,通过 **async Playwright** 完成门户门闩、两步登录、表单填写、PIN 弹窗、结果解析。 + +本示例抽象自已跑通的「工行批量提交类」浏览器自动化 skill 的工程范式,但已**去业务化**——不包含真实银行/真实资金语义,也**不是** Amazon 或任何生产技能的复制。 + +本示例只教学授权范围内的正常界面自动化;禁止把 sandbox 做法扩展为破解、逆向、私有协议、验证码绕过、Hook / 注入、自动换号或规避真实平台限制的实现。 + +## 适合什么场景 + +- 自有 **行业仿真系统** / `sandbox.jc2009.com` / 本地 `sandbox/demo_app.html` +- 需要 **浏览器 RPA** 做端到端演示或回归 +- 表单较复杂:门户 HITL、多步登录、Tab 切换、动态增行、PIN 弹窗、成功页解析 +- 需要 **adapter 分层**:`mock`(CI)与 `simulator_rpa`(半集成)可切换 + +## 不适合什么场景 + +- **真实第三方网站 + 高风控 + 人工验证码** → 优先看 [`../real_browser_rpa/README.md`](../real_browser_rpa/README.md) +- 目标系统有稳定 **官方 API** → 不要硬写浏览器 RPA +- **不要把本示例的业务字段、selector、sandbox URL 原样复制**到你的 skill +- 复制前先读 `development/RPA_ELEMENT_LOCATING.md` 与 `development/RPA_BROWSER.md`;即使 DOM 可控也要定义作用域、唯一性和后置状态 + +## 目录结构 + +``` +simulator_browser_rpa/ +├── README.md +├── sandbox/ +│ └── demo_app.html # 本地仿真页面(门户门闩 + 批量提交教学 DOM) +├── scripts/ +│ ├── service/ +│ │ ├── browser_session.py # async 启动 persistent context(不含 goto) +│ │ ├── account_client.py # account-manager subprocess(对齐 real_browser_rpa) +│ │ ├── simulator_playwright.py # RPA 主流程(厚) +│ │ ├── task_service.py # async 编排层 +│ │ └── adapter/ +│ │ ├── base.py # BatchItem / BatchSubmitResult 契约 +│ │ ├── mock.py # mock/unit 档 +│ │ ├── simulator_rpa.py # 薄 adapter:pick / lease / 委托 RPA +│ │ └── __init__.py # select_adapter +│ └── util/ +│ ├── constants.py +│ └── logging.py +└── tests/ + ├── test_adapter_dispatch.py + ├── test_no_rpa_helpers_import.py + ├── test_simulator_rpa_unit.py + └── test_stop_reason.py +``` + +## 文件职责 + +| 文件 | 职责 | +|---|---| +| `browser_session.py` | async 系统 Chrome/Edge + launch args;**不在此 goto** | +| `account_client.py` | **唯一** account-manager subprocess 封装(`pick_web_account`→ensure-web / `release_lease`) | +| `simulator_playwright.py` | 门户轮询、登录、批量表单、PIN、解析 `batch_id`、截图 | +| `adapter/simulator_rpa.py` | **薄** adapter:pick 账号、lease、委托 RPA、`finally release_lease` | +| `adapter/mock.py` | 不启浏览器,供 unit/mock/CI | +| `task_service.py` | async 校验输入 → 选 adapter → `await submit_batch` | +| `sandbox/demo_app.html` | 可控 DOM;含可选门户门闩 + 原批量提交流程 | + +## 网页 RPA 硬规则(与模板 `development/RPA.md` §0.2 一致) + +- **先 Profile 再开浏览器**:`simulator_rpa` 档必须 `pick_web_account`(ensure-web)拿到 `profile_dir`;拿不到则失败,禁止系统默认用户目录 +- **有头**:联调 / 交付默认 `OPENCLAW_BROWSER_HEADLESS=0` +- **登录按平台约定**:本示例需要仿真登录;其他技能可为 optional / not_needed,但**仍须**走 account-manager + +## 核心流程 + +1. `await run_batch_submit(target, items)` 校验参数 +2. `select_adapter()` 按 `OPENCLAW_TEST_TARGET` 选择 mock 或 simulator RPA +3. `simulator_rpa` 档:`SimulatorBrowserRpaAdapter.submit_batch` 内 `pick_web_account(sim_batch)` → `submit_batch_rpa` → `release_lease` +4. RPA:门户门闩(若可见则 HITL 轮询)→ 登录两步 → 批量页 → 填表 → 提交 → PIN → 解析 `batch_id` +5. 失败截图(有 `artifacts_dir` 时)→ 返回 `BatchSubmitResult` + +## 两档仿真目标 + +| 档位 | 目标 | 说明 | +|---|---|---| +| 本地教学 | `sandbox/demo_app.html`(`file://`) | 结构教学 + mock 单测默认 | +| 线上联调 | `https://sandbox.jc2009.com/...` | 设 `SIMULATOR_BASE_URL` + account-manager `sim_batch` 账号 | + +联调时账号 `url` 用行业根路径,**不要**写 `/login` 子路径;`auth_strategy=per_session_manual`。 + +## account-manager 注册示例(泛化) + +```bash +python .../account-manager/scripts/main.py account add-web \ + --platform sim_batch \ + --login-id demo001 \ + --url file:///.../demo_app.html \ + --environment simulator \ + --auth-strategy per_session_manual +``` + +线上 sandbox 时将 `--url` 换为 `https://sandbox.jc2009.com/...` 占位路径(按团队部署填写)。 + +**PIN/密码演示值**:RPA 从 account 的 `login_id` 取账号;`password` / `token_pin` 允许从 account 字段或环境变量 `SIMULATOR_PASSWORD` / `SIMULATOR_TOKEN_PIN` 回退;验证码固定演示值 `0000`(仅 sandbox 用)。 + +## Copy map(复制到新技能 `scripts/`) + +| 来源(examples) | 目标(新技能) | 说明 | +|---|---|---| +| `browser_session.py` | `scripts/service/browser_session.py` | async;URL 仅 `page.goto`,不进 launch args | +| `account_client.py` | `scripts/service/account_client.py` | subprocess,对齐 `real_browser_rpa` | +| `simulator_playwright.py` | `scripts/service/<platform>_playwright.py` | RPA 主流程(厚) | +| `adapter/` | `scripts/service/<domain>_adapter/` | 薄 `simulator_rpa` + mock | +| `task_service.py` | `scripts/service/task_service.py` | async 编排 | +| `util/constants.py` | `scripts/util/constants.py` | 须替换 `TARGET_PLATFORM` 等业务常量 | +| `tests/` | `tests/` | 可按需复制 dispatch / unit / 禁止 import 守护 | +| `sandbox/demo_app.html` | — | **仅仿真测试**,不进入生产 skill | + +## 明确禁止 + +- **不要** `import account-manager` 内部模块(`rpa_helpers`、`inject_account_manager_scripts_path`、`get_account_credential`) +- **不要** sync Playwright 用于完整 RPA 主路径 +- **不要在** `task_service.py` / RPA 主流程里散落 account-manager subprocess +- **不要在技能侧**执行 `playwright install` / `pip install playwright` +- **不要把 URL 放进** `launch_persistent_context(args=[...])` +- **不要** vendor `scripts/jiangchang_skill_core/` +- **不要**照抄 slug、logger 名、演示账号到生产 skill + +> 旧版 `pick_simulator_account()` / `SimulatorAccount` / `set_credentials()` 已废弃;账号统一由 account-manager 下发。 + +## 联调检查表 + +- [ ] 数据目录 `.env` 中 `OPENCLAW_TEST_TARGET=simulator_rpa`(非 mock) +- [ ] account-manager:`platform ensure` + 账号 `active` + 有 `profile_dir` +- [ ] 目标 sandbox UI 已部署(跨团队) +- [ ] 失败先查 `rpa-artifacts/` 截图,再查 Chrome profile 缓存(可用 `--user-data-dir` 手工打开清站点数据) +- [ ] 默认 `tests/run_tests.py` / 本 example `pytest` 仍全部通过(mock 离线) + +## 依赖说明 + +- Python 3.10+ +- Python 包 `playwright`:由**宿主共享 runtime** 提供;技能侧不要 `pip install playwright` +- 默认使用**系统 Chrome/Edge**;技能侧不要 `playwright install` +- `jiangchang_skill_core.runtime_env.find_chrome_executable`(可选) + +## 环境变量(示例) + +| 变量 | 说明 | +|---|---| +| `OPENCLAW_TEST_TARGET` | `mock`/`unit` → Mock;`simulator_rpa` → 仿真 RPA | +| `SIMULATOR_BASE_URL` | 仿真页 URL;默认本地 `demo_app.html` 的 file URI | +| `SIMULATOR_PASSWORD` / `SIMULATOR_TOKEN_PIN` | 演示密码/PIN 回退(账号字段优先) | +| `PORTAL_LOGIN_WAIT_SEC` | 门户 HITL 超时(默认 120) | +| `OPENCLAW_BROWSER_HEADLESS` | `0` 默认有头;`1` 无头(CI 可用) | + +## 运行测试 + +```bash +cd examples/simulator_browser_rpa +python -m pytest tests/ -v +``` + +测试为 mock/纯函数,**不启动真实浏览器**。 + +## 当前状态 + +- async Playwright + 薄 adapter + `simulator_playwright.py` 工程范式已对齐 `real_browser_rpa`。 +- 未接入完整 CLI、数据库与 entitlement。 diff --git a/examples/simulator_browser_rpa/sandbox/demo_app.html b/examples/simulator_browser_rpa/sandbox/demo_app.html new file mode 100644 index 0000000..52ee286 --- /dev/null +++ b/examples/simulator_browser_rpa/sandbox/demo_app.html @@ -0,0 +1,155 @@ +<!DOCTYPE html> +<html lang="zh-CN"> +<head> + <meta charset="UTF-8" /> + <title>仿真批量提交系统(示例 Sandbox) + + + +

仿真业务系统 · 批量提交(示例)

+ +
+

平台门户登录(示例)

+
+ + + +
+
+ + + + + + + + + + + + + + diff --git a/examples/simulator_browser_rpa/scripts/service/.gitkeep b/examples/simulator_browser_rpa/scripts/service/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/examples/simulator_browser_rpa/scripts/service/account_client.py b/examples/simulator_browser_rpa/scripts/service/account_client.py new file mode 100644 index 0000000..b068608 --- /dev/null +++ b/examples/simulator_browser_rpa/scripts/service/account_client.py @@ -0,0 +1,355 @@ +"""account-manager CLI 集成(仅 subprocess,不 import 兄弟 skill 内部模块)。 + +业务首启默认 ``ensure-web``(有则用、无则 add-web);登记 ≠ 站点已登录。 +""" + +from __future__ import annotations + +import json +import logging +import os +import subprocess +import sys +from typing import Any, Dict, List, Optional, Tuple + +from jiangchang_skill_core import config +from jiangchang_skill_core.runtime_env import get_sibling_skills_root + +from util.constants import LEASE_HOLDER, LEASE_TTL_SEC, TARGET_PLATFORM +from util.logging import mask_text + +logger = logging.getLogger(__name__) + +PLACEHOLDER_PLATFORM = TARGET_PLATFORM + +ACCOUNT_SETUP_MESSAGE = ( + f"未能取得可用的 {PLACEHOLDER_PLATFORM} 账号(ensure-web 失败),所以还没有打开浏览器。" + f"请确认已安装 account-manager,并检查 platform={PLACEHOLDER_PLATFORM} 账号状态后重试。" +) + +LEASE_BUSY_MESSAGE = ( + "该平台下已有账号,但均被其他任务占用。请稍后重试或释放租约;不会自动新建账号。" +) + +DEFAULT_AUTH_STRATEGY = "qr_code_manual" +DEFAULT_ACCOUNT_LABEL = f"{PLACEHOLDER_PLATFORM} 默认" +DEFAULT_START_URL = "https://www.example.com/" + + +class AccountManagerError(Exception): + def __init__(self, code: str, message: str) -> None: + super().__init__(message) + self.code = code + self.message = message + + +def mask_login_id(login_id: str) -> str: + return mask_text(login_id) + + +def format_account_label(account: dict) -> str: + """人读账号标识:优先 login_id / 备注,最后才用数字 id。""" + for key in ("login_id", "account_label", "label"): + v = account.get(key) + if v is not None and str(v).strip(): + return str(v).strip() + for key in ("id", "account_id"): + v = account.get(key) + if v is not None and str(v).strip(): + return str(v).strip() + return "" + + +def _is_placeholder_config(raw: str) -> bool: + s = (raw or "").strip() + if not s: + return True + if s.startswith("#"): + return True + low = s.lower() + if low in ("default-account", "none", "null", "n/a", "-"): + return True + return False + + +def resolve_account_selectors_from_config() -> Tuple[Optional[str], Optional[str]]: + """从配置解析 (account_id, login_id)。 + + - ``DEFAULT_ACCOUNT_ID``:仅纯数字视为账号主键;其它非空非占位当作 login_id + - ``DEFAULT_LOGIN_ID``:手机号/用户名(可覆盖上一项推导的 login_id) + - 两者皆空:返回 (None, None) → 调用方走按平台 ensure-web + """ + account_id: Optional[str] = None + login_id: Optional[str] = None + + aid_raw = (config.get("DEFAULT_ACCOUNT_ID") or "").strip() + if not _is_placeholder_config(aid_raw): + if aid_raw.isdigit(): + account_id = aid_raw + else: + login_id = aid_raw + logger.info("default_account_id_treated_as_login_id value=%s", aid_raw) + + lid_raw = (config.get("DEFAULT_LOGIN_ID") or "").strip() + if not _is_placeholder_config(lid_raw): + login_id = lid_raw + + if account_id: + return account_id, None + return None, login_id + + +def _resolve_account_manager_main() -> str: + """解析 account-manager CLI 入口路径。 + + 优先级:ACCOUNT_MANAGER_ROOT → get_sibling_skills_root → 开发机兜底。 + 末尾 dev 路径仅用于模板/本机调试,复制到真实 skill 后不要依赖个人机器绝对路径。 + """ + env_root = (config.get("ACCOUNT_MANAGER_ROOT") or os.getenv("ACCOUNT_MANAGER_ROOT") or "").strip() + if env_root and os.path.isfile(os.path.join(env_root, "scripts", "main.py")): + return os.path.join(os.path.abspath(env_root), "scripts", "main.py") + + scripts_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + skills_root = get_sibling_skills_root(scripts_dir) + candidate = os.path.join(skills_root, "account-manager", "scripts", "main.py") + if os.path.isfile(candidate): + return candidate + + # 开发环境兜底:仅模板/本机调试;生产依赖宿主注入的路径变量,勿硬编码个人目录 + dev = r"D:\OpenClaw\client-commons\account-manager\scripts\main.py" + if os.path.isfile(dev): + return dev + + raise AccountManagerError( + "ACCOUNT_NOT_FOUND", + "未找到 account-manager,请配置 ACCOUNT_MANAGER_ROOT 或安装 account-manager 技能。", + ) + + +def _parse_last_json(stdout: str) -> Any: + lines = [ln.strip() for ln in (stdout or "").splitlines() if ln.strip()] + for raw in reversed(lines): + if raw.startswith("ERROR:"): + continue + try: + return json.loads(raw) + except json.JSONDecodeError: + continue + raise AccountManagerError("UNKNOWN_ERROR", "stdout 中没有可解析的 JSON。") + + +def _run_argv(argv_suffix: List[str]) -> subprocess.CompletedProcess[str]: + main_py = _resolve_account_manager_main() + env = os.environ.copy() + return subprocess.run( + [sys.executable, main_py, *argv_suffix], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + env=env, + ) + + +def _normalize_error_code(raw: str) -> str: + code = (raw or "").strip() + if code.startswith("ERROR:"): + code = code[6:] + return code + + +def _validate_pick_payload(payload: dict[str, Any]) -> dict[str, Any]: + if not isinstance(payload, dict): + raise AccountManagerError("ACCOUNT_NOT_FOUND", "ensure-web 返回格式异常。") + if payload.get("success") is False: + err = payload.get("error") if isinstance(payload.get("error"), dict) else {} + code = _normalize_error_code(str(err.get("code") or "")) + message = str(err.get("message") or "") + if code == "LEASE_CONFLICT" or "LEASE_CONFLICT" in code: + raise AccountManagerError("LEASE_CONFLICT", message or LEASE_BUSY_MESSAGE) + if code == "AMBIGUOUS_ACCOUNT" or "AMBIGUOUS_ACCOUNT" in code: + raise AccountManagerError( + "AMBIGUOUS_ACCOUNT", + message or "匹配到多条账号,请改用数字 id 或更精确的登录标识。", + ) + if code in ("NO_ACCOUNT", "ACCOUNT_NOT_FOUND") or "NO_ACCOUNT" in code: + raise AccountManagerError("NO_ACCOUNT", message or "没有可用账号。") + raise AccountManagerError(code or "ENSURE_WEB_FAILED", message or "ensure-web 失败。") + + if not payload.get("profile_dir"): + raise AccountManagerError("ACCOUNT_NOT_FOUND", "ensure-web 返回的账号缺少 profile_dir。") + if not payload.get("lease_token"): + raise AccountManagerError("LEASE_REQUIRED", "account-manager 未返回 lease_token,禁止打开浏览器。") + return payload + + +def _ensure_web_with_lease( + platform: str, + *, + login_id: Optional[str] = None, +) -> Dict[str, Any]: + start_url = (config.get("TARGET_BASE_URL") or DEFAULT_START_URL).strip() or DEFAULT_START_URL + lid = (login_id or "").strip() or None + label = f"{platform} {lid}" if lid else DEFAULT_ACCOUNT_LABEL + argv = [ + "account", + "ensure-web", + "--platform", + platform, + "--url", + start_url, + "--auth-strategy", + DEFAULT_AUTH_STRATEGY, + "--label", + label, + "--lease", + "--holder", + LEASE_HOLDER, + "--ttl-sec", + LEASE_TTL_SEC, + "--purpose", + "rpa", + ] + if lid: + argv.extend(["--login-id", lid]) + + proc = _run_argv(argv) + out = proc.stdout or "" + if proc.returncode != 0 and not out.strip(): + raise AccountManagerError( + "ENSURE_WEB_FAILED", + (proc.stderr or "").strip() or "ensure-web 子进程失败", + ) + payload = _parse_last_json(out) + if isinstance(payload, dict) and payload.get("success") is False: + return _validate_pick_payload(payload) + if not isinstance(payload, dict): + raise AccountManagerError("ACCOUNT_NOT_FOUND", "ensure-web 返回格式异常。") + return _validate_pick_payload(payload) + + +def _pick_existing_with_lease( + platform: str, + *, + login_id: Optional[str] = None, + label: Optional[str] = None, +) -> Dict[str, Any]: + argv = [ + "account", + "pick-web", + "--platform", + platform, + "--lease", + "--holder", + LEASE_HOLDER, + "--ttl-sec", + LEASE_TTL_SEC, + "--purpose", + "rpa", + ] + if login_id: + argv.extend(["--login-id", login_id]) + elif label: + argv.extend(["--label", label]) + else: + raise AccountManagerError("AMBIGUOUS_ACCOUNT", "指定账号缺少可用于带租约选择的登录标识或名称。") + + proc = _run_argv(argv) + out = proc.stdout or "" + if proc.returncode != 0 and not out.strip(): + raise AccountManagerError( + "PICK_WEB_FAILED", + (proc.stderr or "").strip() or "pick-web 子进程失败", + ) + payload = _parse_last_json(out) + return _validate_pick_payload(payload) + + +def _pick_by_id_with_lease(platform: str, account_id: int) -> Dict[str, Any]: + proc = _run_argv(["account", "get", str(account_id)]) + out = proc.stdout or "" + if proc.returncode != 0 or out.startswith("ERROR:"): + raise AccountManagerError("ACCOUNT_NOT_FOUND", f"指定账号 {account_id} 不存在或获取失败。") + raw = _parse_last_json(out) + if isinstance(raw, dict) and "data" in raw and isinstance(raw["data"], dict): + data = raw["data"] + elif isinstance(raw, dict): + data = raw + else: + raise AccountManagerError("ACCOUNT_NOT_FOUND", "account get 返回非对象 JSON。") + + if data.get("success") is False: + raise AccountManagerError("ACCOUNT_NOT_FOUND", f"指定账号 {account_id} 不存在或获取失败。") + platform_key = str(data.get("platform_key") or "").lower() + if platform_key != platform.lower(): + raise AccountManagerError( + "ACCOUNT_NOT_FOUND", + f"账号 {account_id} 不是 {platform} 平台账号。", + ) + profile_dir = str(data.get("profile_dir") or "").strip() + if not profile_dir: + raise AccountManagerError("ACCOUNT_NOT_FOUND", f"账号 {account_id} 缺少 profile_dir。") + if str(data.get("status") or "active").lower() != "active": + raise AccountManagerError("ACCOUNT_NOT_FOUND", f"账号 {account_id} 当前不是 active 状态。") + + try: + selected = _pick_existing_with_lease( + platform, + login_id=(str(data.get("login_id") or "").strip() or None), + label=(str(data.get("account_label") or "").strip() or None), + ) + except AccountManagerError as exc: + if exc.code in ("NO_ACCOUNT", "ACCOUNT_NOT_FOUND"): + raise AccountManagerError("LEASE_CONFLICT", LEASE_BUSY_MESSAGE) from exc + raise + if str(selected.get("id")) != str(account_id): + release_lease(str(selected.get("lease_token") or "")) + raise AccountManagerError( + "AMBIGUOUS_ACCOUNT", + f"无法唯一取得指定账号 {account_id} 的租约,请检查重复的登录标识或账号名称。", + ) + return selected + + +def pick_web_account( + platform: str, + account_id: Optional[str] = None, + login_id: Optional[str] = None, +) -> dict: + """获取网页账号(profile_dir + lease_token)。 + + 优先级:数字 account_id → login_id(ensure-web --login-id)→ 按平台 ensure-web。 + """ + platform_key = (platform or PLACEHOLDER_PLATFORM).strip() or PLACEHOLDER_PLATFORM + + aid_raw = (account_id or "").strip() + if aid_raw and not _is_placeholder_config(aid_raw): + if not aid_raw.isdigit(): + if not (login_id or "").strip(): + login_id = aid_raw + aid_raw = "" + else: + return _pick_by_id_with_lease(platform_key, int(aid_raw)) + + lid = (login_id or "").strip() or None + if lid and _is_placeholder_config(lid): + lid = None + + try: + return _ensure_web_with_lease(platform_key, login_id=lid) + except AccountManagerError as exc: + if exc.code in ("NO_ACCOUNT", "ACCOUNT_NOT_FOUND") or "NO_ACCOUNT" in exc.code: + raise AccountManagerError("ACCOUNT_SETUP_REQUIRED", ACCOUNT_SETUP_MESSAGE) from exc + raise + + +def release_lease(lease_token: Optional[str]) -> None: + token = (lease_token or "").strip() + if not token: + return + try: + proc = _run_argv(["lease", "release", token]) + if proc.returncode != 0: + logger.warning("lease_release_failed stdout=%s", (proc.stdout or "").strip()) + except Exception: + logger.warning("lease_release_exception", exc_info=True) diff --git a/examples/simulator_browser_rpa/scripts/service/adapter/__init__.py b/examples/simulator_browser_rpa/scripts/service/adapter/__init__.py new file mode 100644 index 0000000..4884e90 --- /dev/null +++ b/examples/simulator_browser_rpa/scripts/service/adapter/__init__.py @@ -0,0 +1,41 @@ +"""adapter 工厂:按 OPENCLAW_TEST_TARGET 选择实现。""" + +from __future__ import annotations + +import logging +from typing import Optional + +from jiangchang_skill_core import config + +from service.adapter.base import BatchAdapterBase, BatchItem, BatchSubmitResult +from service.adapter.mock import MockBatchAdapter +from service.adapter.simulator_rpa import SimulatorBrowserRpaAdapter + +logger = logging.getLogger(__name__) + +__all__ = [ + "select_adapter", + "BatchAdapterBase", + "BatchItem", + "BatchSubmitResult", + "MockBatchAdapter", + "SimulatorBrowserRpaAdapter", +] + + +def select_adapter(artifacts_dir: Optional[str] = None) -> BatchAdapterBase: + target = (config.get("OPENCLAW_TEST_TARGET") or "").strip().lower() + + if target in ("mock", "unit"): + logger.info("target '%s': MockBatchAdapter", target) + return MockBatchAdapter() + + if target == "real_rpa": + logger.warning("target real_rpa 未实现,回退 MockBatchAdapter") + return MockBatchAdapter() + + if target and target != "simulator_rpa": + logger.warning("未知 target '%s',回退 simulator_rpa", target) + + logger.info("target '%s': SimulatorBrowserRpaAdapter", target or "(unset=default)") + return SimulatorBrowserRpaAdapter(artifacts_dir=artifacts_dir) diff --git a/examples/simulator_browser_rpa/scripts/service/adapter/base.py b/examples/simulator_browser_rpa/scripts/service/adapter/base.py new file mode 100644 index 0000000..3022840 --- /dev/null +++ b/examples/simulator_browser_rpa/scripts/service/adapter/base.py @@ -0,0 +1,32 @@ +"""批量提交 adapter 基类与数据契约。""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + + +@dataclass +class BatchItem: + row_index: int + name: str + account: str + amount: float + note: str = "" + + +@dataclass +class BatchSubmitResult: + ok: bool + batch_id: Optional[str] + submitted_count: int + submitted_amount: float + error_msg: Optional[str] + artifacts: Dict[str, Any] = field(default_factory=dict) + + +class BatchAdapterBase: + name: str = "base" + + async def submit_batch(self, target: str, items: List[BatchItem]) -> BatchSubmitResult: + raise NotImplementedError diff --git a/examples/simulator_browser_rpa/scripts/service/adapter/mock.py b/examples/simulator_browser_rpa/scripts/service/adapter/mock.py new file mode 100644 index 0000000..c613f77 --- /dev/null +++ b/examples/simulator_browser_rpa/scripts/service/adapter/mock.py @@ -0,0 +1,36 @@ +"""mock / unit 档位:不启动浏览器。""" + +from __future__ import annotations + +import time +from typing import List + +from service.adapter.base import BatchAdapterBase, BatchItem, BatchSubmitResult + + +class MockBatchAdapter(BatchAdapterBase): + name = "mock" + + async def submit_batch(self, target: str, items: List[BatchItem]) -> BatchSubmitResult: + del target + if not items: + return BatchSubmitResult( + ok=False, + batch_id=None, + submitted_count=0, + submitted_amount=0.0, + error_msg="items 为空", + artifacts={"adapter": self.name}, + ) + + time.sleep(0.05) + total = sum(it.amount for it in items) + fake_batch_id = f"MOCK-{int(time.time())}" + return BatchSubmitResult( + ok=True, + batch_id=fake_batch_id, + submitted_count=len(items), + submitted_amount=total, + error_msg=None, + artifacts={"adapter": self.name}, + ) diff --git a/examples/simulator_browser_rpa/scripts/service/adapter/simulator_rpa.py b/examples/simulator_browser_rpa/scripts/service/adapter/simulator_rpa.py new file mode 100644 index 0000000..04f6cd1 --- /dev/null +++ b/examples/simulator_browser_rpa/scripts/service/adapter/simulator_rpa.py @@ -0,0 +1,109 @@ +"""仿真浏览器 RPA 薄 adapter:pick 账号、lease、委托 RPA、release lease。""" + +from __future__ import annotations + +import logging +from typing import List, Optional + +from jiangchang_skill_core import config + +from service.account_client import AccountManagerError, pick_web_account, release_lease +from service.adapter.base import BatchAdapterBase, BatchItem, BatchSubmitResult +from service.browser_session import find_chrome_executable +from service.simulator_playwright import submit_batch_rpa +from util.constants import TARGET_PLATFORM, resolve_simulator_base_url + +logger = logging.getLogger(__name__) + + +def _resolve_entry_url(account: dict) -> str: + url = str(account.get("url") or "").strip() + if url: + return url.rstrip("/") + return resolve_simulator_base_url() + + +class SimulatorBrowserRpaAdapter(BatchAdapterBase): + name = "simulator_rpa" + + def __init__( + self, + artifacts_dir: Optional[str] = None, + headless: Optional[bool] = None, + ) -> None: + self.artifacts_dir = artifacts_dir + if headless is None: + self.headless = config.get_bool("OPENCLAW_BROWSER_HEADLESS", default=False) + else: + self.headless = headless + + async def submit_batch(self, target: str, items: List[BatchItem]) -> BatchSubmitResult: + try: + from playwright.async_api import async_playwright # noqa: F401 + except ImportError: + return BatchSubmitResult( + ok=False, + batch_id=None, + submitted_count=0, + submitted_amount=0.0, + error_msg=( + "playwright Python 包不可用。" + "生产/宿主运行由共享 runtime 提供;技能侧不要 pip install playwright。" + ), + artifacts={"adapter": self.name}, + ) + + if not items: + return BatchSubmitResult( + ok=False, + batch_id=None, + submitted_count=0, + submitted_amount=0.0, + error_msg="items 为空", + artifacts={"adapter": self.name}, + ) + + if not find_chrome_executable(): + return BatchSubmitResult( + ok=False, + batch_id=None, + submitted_count=0, + submitted_amount=0.0, + error_msg="未检测到 Chrome 或 Edge 浏览器,请安装系统浏览器后重试。", + artifacts={"adapter": self.name}, + ) + + lease_token: Optional[str] = None + try: + account = pick_web_account(TARGET_PLATFORM) + lease_token = account.get("lease_token") + base_url = _resolve_entry_url(account) + return await submit_batch_rpa( + account, + target=target, + items=items, + base_url=base_url, + artifacts_dir=self.artifacts_dir, + headless=self.headless, + ) + except AccountManagerError as exc: + return BatchSubmitResult( + ok=False, + batch_id=None, + submitted_count=0, + submitted_amount=0.0, + error_msg=f"ERROR:{exc.code} {exc.message}", + artifacts={"adapter": self.name}, + ) + except Exception as exc: + logger.exception("adapter_unexpected: %s", exc) + return BatchSubmitResult( + ok=False, + batch_id=None, + submitted_count=0, + submitted_amount=0.0, + error_msg=f"未预期异常:{type(exc).__name__}: {exc}", + artifacts={"adapter": self.name}, + ) + finally: + release_lease(lease_token) diff --git a/examples/simulator_browser_rpa/scripts/service/browser_session.py b/examples/simulator_browser_rpa/scripts/service/browser_session.py new file mode 100644 index 0000000..87bfa4c --- /dev/null +++ b/examples/simulator_browser_rpa/scripts/service/browser_session.py @@ -0,0 +1,126 @@ +"""浏览器会话启动(系统 Chrome/Edge + async Playwright persistent context)。""" + +from __future__ import annotations + +import logging +import os +from typing import Any, List, Optional, Tuple + +logger = logging.getLogger(__name__) + +DEFAULT_PAGE_TIMEOUT_MS = 15_000 + +CHROME_LAUNCH_ARGS: List[str] = [ + "--start-maximized", + "--disable-blink-features=AutomationControlled", +] + +STEALTH_INIT_SCRIPT = """ +(() => { + try { Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); } catch (e) {} +})(); +""" + + +def chrome_launch_args() -> List[str]: + return list(CHROME_LAUNCH_ARGS) + + +def _headless_from_env() -> bool: + v = (os.getenv("OPENCLAW_BROWSER_HEADLESS") or "0").strip().lower() + return v in ("1", "true", "yes", "on") + + +def find_chrome_executable() -> str | None: + try: + from jiangchang_skill_core.runtime_env import find_chrome_executable as _find + + chrome = _find() + if chrome and os.path.isfile(chrome): + return chrome + except ImportError: + pass + + candidates = [ + os.path.expandvars(r"%ProgramFiles%\Google\Chrome\Application\chrome.exe"), + os.path.expandvars(r"%ProgramFiles(x86)%\Google\Chrome\Application\chrome.exe"), + os.path.expandvars(r"%ProgramFiles%\Microsoft\Edge\Application\msedge.exe"), + os.path.expandvars(r"%ProgramFiles(x86)%\Microsoft\Edge\Application\msedge.exe"), + ] + for path in candidates: + if path and os.path.isfile(path): + return path + return None + + +def _clear_node_options() -> None: + os.environ.pop("NODE_OPTIONS", None) + + +async def start_browser_session( + profile_dir: str, + *, + headless: Optional[bool] = None, +) -> Tuple[Any, Any, Any]: + """ + 启动持久化 Chrome profile,返回 (playwright, context, page)。 + + URL 不在此文件打开;由 simulator_playwright.py 负责 page.goto。 + """ + from playwright.async_api import async_playwright + + _clear_node_options() + hl = headless if headless is not None else _headless_from_env() + chrome = find_chrome_executable() + if not chrome: + raise RuntimeError( + "ERROR:MISSING_BROWSER 未检测到 Chrome 或 Edge 浏览器,请先安装系统浏览器后重试。" + ) + + args = chrome_launch_args() + logger.info( + "browser_launch chrome=%s profile_dir=%s args=%s headless=%s", + chrome, + profile_dir, + args, + hl, + ) + + pw = await async_playwright().start() + context = None + try: + context = await pw.chromium.launch_persistent_context( + user_data_dir=profile_dir, + headless=hl, + executable_path=chrome, + locale="zh-CN", + no_viewport=True, + args=args, + ignore_default_args=["--enable-automation"], + ) + await context.add_init_script(STEALTH_INIT_SCRIPT) + + page = context.pages[0] if context.pages else await context.new_page() + page.set_default_timeout(DEFAULT_PAGE_TIMEOUT_MS) + return pw, context, page + except Exception: + if context is not None: + try: + await context.close() + except Exception: + pass + await pw.stop() + raise + + +async def close_browser_session(playwright: Any, context: Any) -> None: + try: + if context is not None: + await context.close() + except Exception: + pass + try: + if playwright is not None: + await playwright.stop() + except Exception: + pass diff --git a/examples/simulator_browser_rpa/scripts/service/simulator_playwright.py b/examples/simulator_browser_rpa/scripts/service/simulator_playwright.py new file mode 100644 index 0000000..4ba8fd3 --- /dev/null +++ b/examples/simulator_browser_rpa/scripts/service/simulator_playwright.py @@ -0,0 +1,400 @@ +"""仿真批量提交 RPA 主流程(async Playwright)。""" + +from __future__ import annotations + +import asyncio +import logging +import os +import random +import re +import time +from dataclasses import dataclass +from typing import Any, List, Optional + +from jiangchang_skill_core import config + +from service.adapter.base import BatchItem, BatchSubmitResult +from service.browser_session import ( + close_browser_session, + find_chrome_executable, + start_browser_session, +) +from util.constants import ( + DEFAULT_DEMO_CAPTCHA, + DEFAULT_DEMO_LOGIN_ID, + DEFAULT_DEMO_PASSWORD, + DEFAULT_DEMO_TOKEN_PIN, + DEFAULT_TIMEOUT_MS, + ENV_SIMULATOR_PASSWORD, + ENV_SIMULATOR_TOKEN_PIN, + LOGIN_NAVIGATE_TIMEOUT_MS, + SUBMIT_NAVIGATE_TIMEOUT_MS, + portal_login_wait_sec, +) + +logger = logging.getLogger(__name__) + +GOTO_TIMEOUT_MS = 60_000 + +_PAUSE_MIN_MS = 500 +_PAUSE_MAX_MS = 2000 + + +class RpaError(Exception): + def __init__(self, message: str, screenshot_path: Optional[str] = None): + super().__init__(message) + self.screenshot_path = screenshot_path + + +@dataclass +class _Credentials: + login_id: str + password: str + token_pin: str + + +def _resolve_credentials(account: dict[str, Any]) -> _Credentials: + login_id = str( + account.get("login_id") or account.get("username") or DEFAULT_DEMO_LOGIN_ID + ).strip() + password = str( + account.get("password") + or os.getenv(ENV_SIMULATOR_PASSWORD) + or config.get(ENV_SIMULATOR_PASSWORD) + or DEFAULT_DEMO_PASSWORD + ).strip() + token_pin = str( + account.get("token_pin") + or account.get("pin") + or os.getenv(ENV_SIMULATOR_TOKEN_PIN) + or config.get(ENV_SIMULATOR_TOKEN_PIN) + or DEFAULT_DEMO_TOKEN_PIN + ).strip() + return _Credentials(login_id=login_id, password=password, token_pin=token_pin) + + +def _headless() -> bool: + return config.get_bool("OPENCLAW_BROWSER_HEADLESS", default=False) + + +async def _pause(min_ms: int = _PAUSE_MIN_MS, max_ms: int = _PAUSE_MAX_MS) -> None: + await asyncio.sleep(random.uniform(min_ms, max_ms) / 1000.0) + + +async def _visible(locator) -> bool: + try: + return await locator.count() > 0 and await locator.is_visible(timeout=500) + except Exception: + return False + + +async def _wait_portal_gate(page, wait_sec: int) -> None: + portal_user = page.locator("#portal-user") + try: + if not await _visible(portal_user): + return + except Exception: + return + + print(f"[门户] 检测到平台门户登录门闩,请手工完成门户登录,最多等待 {wait_sec} 秒...") + deadline = time.monotonic() + wait_sec + while time.monotonic() < deadline: + try: + portal_section = page.locator("#portal-section") + login_step1 = page.locator('form[name="simLoginStep1"]') + if not await _visible(portal_section) or await _visible(login_step1): + print("[门户] 门户门闩已通过,进入业务系统登录") + return + except Exception: + pass + await asyncio.sleep(2.0) + + raise RpaError("ERROR:LOGIN_TIMEOUT 门户登录超时,请重新运行并及时完成门户登录。") + + +async def _login(page, creds: _Credentials, artifacts_dir: str | None) -> None: + try: + await page.wait_for_selector( + 'form[name="simLoginStep1"]', timeout=LOGIN_NAVIGATE_TIMEOUT_MS + ) + await _pause() + await page.fill('form[name="simLoginStep1"] input[name="loginId"]', creds.login_id) + await _pause() + await page.click('form[name="simLoginStep1"] button[type="submit"]') + except Exception as exc: + sp = await _safe_screenshot(page, artifacts_dir, "login_step1_failed") + raise RpaError(f"登录第一步失败:{exc}", screenshot_path=sp) from exc + + try: + await page.wait_for_selector('form[name="simLoginStep2"]', timeout=DEFAULT_TIMEOUT_MS) + await _pause() + await page.fill('form[name="simLoginStep2"] input[name="password"]', creds.password) + await page.fill('form[name="simLoginStep2"] input[name="captcha"]', DEFAULT_DEMO_CAPTCHA) + await _pause() + await page.click('form[name="simLoginStep2"] button[type="submit"]') + except Exception as exc: + sp = await _safe_screenshot(page, artifacts_dir, "login_step2_failed") + raise RpaError(f"登录第二步失败:{exc}", screenshot_path=sp) from exc + + try: + await page.wait_for_selector('form[name="simBatchForm"]', timeout=LOGIN_NAVIGATE_TIMEOUT_MS) + except Exception as exc: + sp = await _safe_screenshot(page, artifacts_dir, "login_navigate_failed") + raise RpaError(f"登录后未进入批量提交页:{exc}", screenshot_path=sp) from exc + + +async def _goto_batch_page(page, artifacts_dir: str | None) -> None: + try: + await page.wait_for_selector('form[name="simBatchForm"]', timeout=DEFAULT_TIMEOUT_MS) + await _pause() + except Exception as exc: + sp = await _safe_screenshot(page, artifacts_dir, "goto_batch_failed") + raise RpaError(f"进入批量提交页失败:{exc}", screenshot_path=sp) from exc + + +async def _select_target_account(page, target: str) -> None: + sel = page.locator('select[name="fromAccountNo"]') + await sel.wait_for(timeout=DEFAULT_TIMEOUT_MS) + n = await sel.locator("option").count() + matched_value: Optional[str] = None + first_value: Optional[str] = None + for i in range(n): + opt = sel.locator("option").nth(i) + val = (await opt.get_attribute("value")) or "" + if not val.strip(): + continue + label_txt = await opt.inner_text() + if first_value is None: + first_value = val + if target.strip() and target.strip() in label_txt: + matched_value = val + break + pick = matched_value or first_value + if not pick: + raise RpaError("来源账户下拉无可选项") + if not matched_value and target.strip(): + logger.warning("target '%s' 未匹配选项,回退 value=%s", target, pick) + await sel.select_option(value=pick) + await _pause() + + +async def _fill_batch_form( + page, target: str, items: List[BatchItem], artifacts_dir: str | None +) -> None: + try: + await page.get_by_role("button", name=re.compile(r"页面填写")).click() + except Exception as exc: + sp = await _safe_screenshot(page, artifacts_dir, "tab_switch_failed") + raise RpaError(f"切换到页面填写失败:{exc}", screenshot_path=sp) from exc + + try: + await _select_target_account(page, target) + except RpaError: + raise + except Exception as exc: + sp = await _safe_screenshot(page, artifacts_dir, "select_account_failed") + raise RpaError(f"选择来源账户失败:{exc}", screenshot_path=sp) from exc + + try: + existing = await page.locator("tr[data-row-index]").count() + need_more = max(0, len(items) - existing) + add_btn = page.get_by_role("button", name=re.compile(r"新增一行")) + for _ in range(need_more): + await _pause(400, 900) + await add_btn.click() + except Exception as exc: + sp = await _safe_screenshot(page, artifacts_dir, "add_rows_failed") + raise RpaError(f"新增明细行失败:{exc}", screenshot_path=sp) from exc + + for it in items: + try: + row = page.locator(f'tr[data-row-index="{it.row_index}"]') + await row.wait_for(timeout=DEFAULT_TIMEOUT_MS) + await _pause(300, 800) + await row.locator('input[name="name"]').fill(it.name) + await row.locator('input[name="account"]').fill(it.account) + await row.locator('input[name="note"]').fill(it.note or "") + await row.locator('input[name="amount"]').fill(f"{it.amount}") + except Exception as exc: + sp = await _safe_screenshot(page, artifacts_dir, f"fill_row_{it.row_index}_failed") + raise RpaError(f"填第 {it.row_index} 行失败:{exc}", screenshot_path=sp) from exc + + try: + await page.fill('input[name="purpose"]', "仿真批量提交示例") + except Exception as exc: + logger.warning("填写用途失败(非致命):%s", exc) + + +async def _submit_and_get_batch_id(page, token_pin: str, artifacts_dir: str | None) -> str: + try: + await _pause(800, 1500) + await page.locator('form[name="simBatchForm"] button[type="submit"]').click() + except Exception as exc: + sp = await _safe_screenshot(page, artifacts_dir, "click_submit_failed") + raise RpaError(f"点击提交失败:{exc}", screenshot_path=sp) from exc + + dlg_sel = 'div[role="dialog"][aria-modal="true"]' + try: + await page.wait_for_selector(dlg_sel, timeout=DEFAULT_TIMEOUT_MS) + except Exception as exc: + sp = await _safe_screenshot(page, artifacts_dir, "pin_dialog_not_open") + raise RpaError(f"PIN 弹窗未出现:{exc}", screenshot_path=sp) from exc + + try: + pin_input = page.locator(f'{dlg_sel} input[type="password"][inputmode="numeric"]') + await _pause(500, 1200) + await pin_input.fill(token_pin) + except Exception as exc: + sp = await _safe_screenshot(page, artifacts_dir, "fill_pin_failed") + raise RpaError(f"填写 PIN 失败:{exc}", screenshot_path=sp) from exc + + try: + await page.locator(dlg_sel).get_by_role("button", name=re.compile(r"确认提交")).click() + except Exception as exc: + sp = await _safe_screenshot(page, artifacts_dir, "click_confirm_failed") + raise RpaError(f"确认提交失败:{exc}", screenshot_path=sp) from exc + + try: + await page.wait_for_selector('[data-testid="batch-id"]', timeout=SUBMIT_NAVIGATE_TIMEOUT_MS) + except Exception as exc: + sp = await _safe_screenshot(page, artifacts_dir, "success_area_not_visible") + raise RpaError(f"提交后未出现成功区域:{exc}", screenshot_path=sp) from exc + + batch_id = await page.locator('[data-testid="batch-id"]').get_attribute("data-batch-id") or "" + if not batch_id: + batch_id = (await page.locator('[data-testid="batch-id"]').inner_text()).strip() + url = page.url + if not batch_id and "#/batch/" in url: + batch_id = url.split("#/batch/", 1)[1].split("?")[0].split("#")[0].strip("/") + if not batch_id: + sp = await _safe_screenshot(page, artifacts_dir, "batch_id_empty") + raise RpaError(f"无法解析 batch_id,url={url}", screenshot_path=sp) + return batch_id + + +async def _safe_screenshot(page, artifacts_dir: str | None, tag: str) -> Optional[str]: + if not artifacts_dir: + return None + try: + os.makedirs(artifacts_dir, exist_ok=True) + path = os.path.join(artifacts_dir, f"{tag}_{int(time.time())}.png") + await page.screenshot(path=path, full_page=True) + logger.info("screenshot_saved path=%s", path) + return path + except Exception as exc: + logger.warning("screenshot_failed tag=%s err=%s", tag, exc) + return None + + +async def submit_batch_rpa( + account: dict[str, Any], + *, + target: str, + items: list[BatchItem], + base_url: str, + artifacts_dir: str | None, + video_session: Any = None, + headless: bool | None = None, +) -> BatchSubmitResult: + del video_session # 示例占位;真实 skill 可接入 RpaVideoSession + + profile_dir = str(account.get("profile_dir") or "").strip() + if not profile_dir: + return BatchSubmitResult( + ok=False, + batch_id=None, + submitted_count=0, + submitted_amount=0.0, + error_msg="账号缺少 profile_dir", + artifacts={"adapter": "simulator_rpa"}, + ) + + if not items: + return BatchSubmitResult( + ok=False, + batch_id=None, + submitted_count=0, + submitted_amount=0.0, + error_msg="items 为空", + artifacts={"adapter": "simulator_rpa"}, + ) + + if not find_chrome_executable(): + return BatchSubmitResult( + ok=False, + batch_id=None, + submitted_count=0, + submitted_amount=0.0, + error_msg="未检测到 Chrome 或 Edge 浏览器,请安装系统浏览器后重试。", + artifacts={"adapter": "simulator_rpa"}, + ) + + creds = _resolve_credentials(account) + portal_wait = portal_login_wait_sec() + hl = headless if headless is not None else _headless() + + pw = None + context = None + page = None + try: + pw, context, page = await start_browser_session(profile_dir, headless=hl) + logger.info("rpa_goto url=%s", base_url) + await page.goto(base_url, wait_until="domcontentloaded", timeout=GOTO_TIMEOUT_MS) + + await _wait_portal_gate(page, portal_wait) + logger.info("rpa_login_start") + await _login(page, creds, artifacts_dir) + logger.info("rpa_login_done") + + await _goto_batch_page(page, artifacts_dir) + logger.info("rpa_batch_page_ready") + + await _fill_batch_form(page, target, items, artifacts_dir) + logger.info("rpa_batch_form_filled rows=%s", len(items)) + + batch_id = await _submit_and_get_batch_id(page, creds.token_pin, artifacts_dir) + logger.info("rpa_submit_success batch_id=%s", batch_id) + + total = sum(it.amount for it in items) + return BatchSubmitResult( + ok=True, + batch_id=batch_id, + submitted_count=len(items), + submitted_amount=total, + error_msg=None, + artifacts={"adapter": "simulator_rpa"}, + ) + except RpaError as exc: + logger.error("rpa_failed: %s", exc) + arts = {"adapter": "simulator_rpa"} + if exc.screenshot_path: + arts["screenshot"] = exc.screenshot_path + elif page is not None: + sp = await _safe_screenshot(page, artifacts_dir, "rpa_error") + if sp: + arts["screenshot"] = sp + return BatchSubmitResult( + ok=False, + batch_id=None, + submitted_count=0, + submitted_amount=0.0, + error_msg=str(exc), + artifacts=arts, + ) + except Exception as exc: + logger.exception("rpa_unexpected: %s", exc) + arts = {"adapter": "simulator_rpa"} + if page is not None: + sp = await _safe_screenshot(page, artifacts_dir, "unexpected_error") + if sp: + arts["screenshot"] = sp + return BatchSubmitResult( + ok=False, + batch_id=None, + submitted_count=0, + submitted_amount=0.0, + error_msg=f"未预期异常:{type(exc).__name__}: {exc}", + artifacts=arts, + ) + finally: + await close_browser_session(pw, context) diff --git a/examples/simulator_browser_rpa/scripts/service/task_service.py b/examples/simulator_browser_rpa/scripts/service/task_service.py new file mode 100644 index 0000000..e40ebdd --- /dev/null +++ b/examples/simulator_browser_rpa/scripts/service/task_service.py @@ -0,0 +1,100 @@ +"""轻量任务编排:校验输入 → 选 adapter → 提交批次。""" + +from __future__ import annotations + +import os +import tempfile +import uuid +from typing import Any, Dict, List, Optional + +from service.adapter import BatchItem, select_adapter +from util.logging import get_logger + +logger = get_logger(__name__) + + +def _validate_items(items: List[Dict[str, Any]]) -> tuple[List[BatchItem], Optional[str]]: + if not items: + return [], "items 不能为空" + parsed: List[BatchItem] = [] + for idx, raw in enumerate(items, start=1): + name = str(raw.get("name") or "").strip() + account = str(raw.get("account") or "").strip() + note = str(raw.get("note") or "").strip() + try: + amount = float(raw.get("amount")) + except (TypeError, ValueError): + return [], f"第 {idx} 行 amount 无效" + if amount <= 0: + return [], f"第 {idx} 行 amount 必须大于 0" + if not name or not account: + return [], f"第 {idx} 行 name/account 不能为空" + parsed.append( + BatchItem( + row_index=int(raw.get("row_index") or idx), + name=name, + account=account, + amount=amount, + note=note, + ) + ) + return parsed, None + + +async def run_batch_submit( + target: str, + items: List[Dict[str, Any]], + *, + force: bool = False, + artifacts_dir: Optional[str] = None, +) -> Dict[str, Any]: + """ + 最小编排示例。 + + 真实 skill 中还应:写业务表、entitlement、RpaVideoSession 等。 + 账号 pick / lease 在 adapter 内完成,编排层不直接碰 account-manager。 + """ + del force # 示例占位;真实 skill 可用于跳过确认 + + target_key = (target or "").strip() + if not target_key: + return {"success": False, "error": {"code": "INVALID_TARGET", "message": "target 不能为空"}} + + batch_items, err = _validate_items(items) + if err: + return {"success": False, "error": {"code": "INVALID_ITEMS", "message": err}} + + art_dir = artifacts_dir or os.path.join( + tempfile.gettempdir(), + "openclaw-simulator-rpa-artifacts", + uuid.uuid4().hex[:8], + ) + os.makedirs(art_dir, exist_ok=True) + logger.info( + "batch_submit_start target=%s item_count=%s artifacts_dir=%s", + target_key, + len(batch_items), + art_dir, + ) + + adapter = select_adapter(artifacts_dir=art_dir) + result = await adapter.submit_batch(target_key, batch_items) + summary: Dict[str, Any] = { + "success": result.ok, + "target": target_key, + "adapter": result.artifacts.get("adapter") or adapter.name, + "submitted_count": result.submitted_count, + "submitted_amount": result.submitted_amount, + "batch_id": result.batch_id, + "artifacts": result.artifacts, + "artifacts_dir": art_dir, + } + if not result.ok: + summary["error"] = {"code": "BATCH_SUBMIT_FAILED", "message": result.error_msg or "提交失败"} + logger.info( + "batch_submit_done success=%s batch_id=%s adapter=%s", + result.ok, + result.batch_id, + adapter.name, + ) + return summary diff --git a/examples/simulator_browser_rpa/scripts/util/.gitkeep b/examples/simulator_browser_rpa/scripts/util/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/examples/simulator_browser_rpa/scripts/util/constants.py b/examples/simulator_browser_rpa/scripts/util/constants.py new file mode 100644 index 0000000..9da48e0 --- /dev/null +++ b/examples/simulator_browser_rpa/scripts/util/constants.py @@ -0,0 +1,52 @@ +"""仿真浏览器 RPA 示例常量。""" + +from __future__ import annotations + +import os +from pathlib import Path + +from jiangchang_skill_core import config + +SKILL_SLUG = "example-simulator-browser-rpa" +LOG_LOGGER_NAME = "openclaw.skill.example_simulator_browser_rpa" + +TARGET_PLATFORM = "sim_batch" +LEASE_HOLDER = "example-simulator-browser-rpa" +LEASE_TTL_SEC = "1800" + +DEFAULT_TIMEOUT_MS = 15_000 +LOGIN_NAVIGATE_TIMEOUT_MS = 30_000 +SUBMIT_NAVIGATE_TIMEOUT_MS = 60_000 +DEFAULT_PORTAL_LOGIN_WAIT_SEC = 120 + +DEFAULT_DEMO_LOGIN_ID = "demo001" +DEFAULT_DEMO_PASSWORD = "demo-password" +DEFAULT_DEMO_CAPTCHA = "0000" +DEFAULT_DEMO_TOKEN_PIN = "123456" + +ENV_SIMULATOR_BASE_URL = "SIMULATOR_BASE_URL" +ENV_SIMULATOR_PASSWORD = "SIMULATOR_PASSWORD" +ENV_SIMULATOR_TOKEN_PIN = "SIMULATOR_TOKEN_PIN" +ENV_PORTAL_LOGIN_WAIT_SEC = "PORTAL_LOGIN_WAIT_SEC" + +_EXAMPLE_ROOT = Path(__file__).resolve().parents[2] +DEFAULT_DEMO_APP_PATH = _EXAMPLE_ROOT / "sandbox" / "demo_app.html" + + +def portal_login_wait_sec() -> int: + raw = ( + os.getenv(ENV_PORTAL_LOGIN_WAIT_SEC) + or config.get(ENV_PORTAL_LOGIN_WAIT_SEC) + or str(DEFAULT_PORTAL_LOGIN_WAIT_SEC) + ) + try: + return max(1, int(str(raw).strip())) + except (TypeError, ValueError): + return DEFAULT_PORTAL_LOGIN_WAIT_SEC + + +def resolve_simulator_base_url() -> str: + env = (os.getenv(ENV_SIMULATOR_BASE_URL) or "").strip() + if env: + return env.rstrip("/") + return DEFAULT_DEMO_APP_PATH.as_uri() diff --git a/examples/simulator_browser_rpa/scripts/util/logging.py b/examples/simulator_browser_rpa/scripts/util/logging.py new file mode 100644 index 0000000..569bcc9 --- /dev/null +++ b/examples/simulator_browser_rpa/scripts/util/logging.py @@ -0,0 +1,38 @@ +"""示例日志工具。""" + +from __future__ import annotations + +import logging +import re +from typing import Any + +from util.constants import LOG_LOGGER_NAME + + +def get_logger(name: str | None = None) -> logging.Logger: + return logging.getLogger(name or LOG_LOGGER_NAME) + + +def configure_logging(level: int = logging.INFO) -> None: + logging.basicConfig(level=level, format="%(levelname)s %(name)s %(message)s") + + +def mask_text(value: str, *, keep_start: int = 2, keep_end: int = 2) -> str: + s = (value or "").strip() + if len(s) >= 11 and s.isdigit(): + return s[:3] + "****" + s[-4:] + if len(s) > keep_start + keep_end: + return s[:keep_start] + "****" + s[-keep_end:] + return "****" + + +def safe_log_value(value: Any) -> str: + if value is None: + return "" + text = str(value) + if re.fullmatch(r"\d{7,}", text): + return mask_text(text) + if "@" in text and len(text) > 6: + local, _, domain = text.partition("@") + return f"{mask_text(local, keep_start=1, keep_end=1)}@{domain}" + return text diff --git a/examples/simulator_browser_rpa/tests/.gitkeep b/examples/simulator_browser_rpa/tests/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/examples/simulator_browser_rpa/tests/conftest.py b/examples/simulator_browser_rpa/tests/conftest.py new file mode 100644 index 0000000..0ba6eb8 --- /dev/null +++ b/examples/simulator_browser_rpa/tests/conftest.py @@ -0,0 +1,10 @@ +"""pytest 路径:将 examples/simulator_browser_rpa/scripts 加入 import 路径。""" + +from __future__ import annotations + +import sys +from pathlib import Path + +SCRIPTS = Path(__file__).resolve().parents[1] / "scripts" +if str(SCRIPTS) not in sys.path: + sys.path.insert(0, str(SCRIPTS)) diff --git a/examples/simulator_browser_rpa/tests/test_account_client.py b/examples/simulator_browser_rpa/tests/test_account_client.py new file mode 100644 index 0000000..70c70a8 --- /dev/null +++ b/examples/simulator_browser_rpa/tests/test_account_client.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import json +import subprocess +import unittest +from unittest.mock import patch + +from service.account_client import AccountManagerError, pick_web_account + + +def _completed(payload: dict) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess( + args=["python", "account-manager"], + returncode=0, + stdout=json.dumps(payload, ensure_ascii=False), + stderr="", + ) + + +class TestExplicitAccountLease(unittest.TestCase): + @patch("service.account_client._run_argv") + def test_explicit_account_id_is_reselected_with_lease(self, run_argv) -> None: + account = { + "id": 42, + "platform_key": "target-platform", + "account_label": "主账号", + "login_id": "user@example.com", + "profile_dir": "D:/data/profile-42", + "status": "active", + } + leased = {**account, "lease_token": "lease-42"} + run_argv.side_effect = [_completed(account), _completed(leased)] + + result = pick_web_account("target-platform", account_id="42") + + self.assertEqual(result["id"], 42) + self.assertEqual(result["lease_token"], "lease-42") + self.assertEqual(run_argv.call_args_list[0].args[0], ["account", "get", "42"]) + pick_args = run_argv.call_args_list[1].args[0] + self.assertIn("pick-web", pick_args) + self.assertIn("--lease", pick_args) + self.assertIn("user@example.com", pick_args) + + @patch("service.account_client._run_argv") + def test_missing_lease_token_is_rejected(self, run_argv) -> None: + account = { + "id": 42, + "platform_key": "target-platform", + "account_label": "主账号", + "login_id": "user@example.com", + "profile_dir": "D:/data/profile-42", + "status": "active", + } + run_argv.side_effect = [_completed(account), _completed(account)] + + with self.assertRaises(AccountManagerError) as raised: + pick_web_account("target-platform", account_id="42") + + self.assertEqual(raised.exception.code, "LEASE_REQUIRED") + + +if __name__ == "__main__": + unittest.main() diff --git a/examples/simulator_browser_rpa/tests/test_adapter_dispatch.py b/examples/simulator_browser_rpa/tests/test_adapter_dispatch.py new file mode 100644 index 0000000..2ca882c --- /dev/null +++ b/examples/simulator_browser_rpa/tests/test_adapter_dispatch.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +"""adapter 工厂 dispatch 单元测试。""" +from __future__ import annotations + +import os +import unittest +from unittest.mock import patch + +from service.adapter import ( + BatchItem, + MockBatchAdapter, + SimulatorBrowserRpaAdapter, + select_adapter, +) + + +class TestAdapterDispatch(unittest.TestCase): + def test_default_returns_simulator_rpa(self) -> None: + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("OPENCLAW_TEST_TARGET", None) + adapter = select_adapter() + self.assertIsInstance(adapter, SimulatorBrowserRpaAdapter) + + def test_unit_returns_mock(self) -> None: + with patch.dict(os.environ, {"OPENCLAW_TEST_TARGET": "unit"}): + self.assertIsInstance(select_adapter(), MockBatchAdapter) + + def test_mock_returns_mock(self) -> None: + with patch.dict(os.environ, {"OPENCLAW_TEST_TARGET": "mock"}): + self.assertIsInstance(select_adapter(), MockBatchAdapter) + + def test_simulator_rpa_returns_simulator(self) -> None: + with patch.dict(os.environ, {"OPENCLAW_TEST_TARGET": "simulator_rpa"}): + self.assertIsInstance(select_adapter(), SimulatorBrowserRpaAdapter) + + def test_real_rpa_falls_back_to_mock(self) -> None: + with patch.dict(os.environ, {"OPENCLAW_TEST_TARGET": "real_rpa"}): + self.assertIsInstance(select_adapter(), MockBatchAdapter) + + def test_unknown_target_falls_back_simulator_rpa(self) -> None: + with patch.dict(os.environ, {"OPENCLAW_TEST_TARGET": "simulator_api"}): + self.assertIsInstance(select_adapter(), SimulatorBrowserRpaAdapter) + + +class TestMockAdapter(unittest.IsolatedAsyncioTestCase): + async def test_mock_success(self) -> None: + items = [ + BatchItem(row_index=1, name="Alice", account="1001", amount=10.5), + BatchItem(row_index=2, name="Bob", account="1002", amount=20.0), + ] + result = await MockBatchAdapter().submit_batch("acct-demo-001", items) + self.assertTrue(result.ok) + self.assertEqual(result.submitted_count, 2) + self.assertEqual(result.submitted_amount, 30.5) + self.assertTrue((result.batch_id or "").startswith("MOCK-")) + + async def test_mock_empty_items(self) -> None: + result = await MockBatchAdapter().submit_batch("acct-demo-001", []) + self.assertFalse(result.ok) + self.assertEqual(result.error_msg, "items 为空") + + +if __name__ == "__main__": + unittest.main() diff --git a/examples/simulator_browser_rpa/tests/test_no_rpa_helpers_import.py b/examples/simulator_browser_rpa/tests/test_no_rpa_helpers_import.py new file mode 100644 index 0000000..5ca2e16 --- /dev/null +++ b/examples/simulator_browser_rpa/tests/test_no_rpa_helpers_import.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +"""禁止 import account-manager 内部 RPA 辅助模块。""" +from __future__ import annotations + +import os +import re +import unittest +from pathlib import Path + +FORBIDDEN_PATTERNS = ( + re.compile(r"\brpa_helpers\b"), + re.compile(r"\binject_account_manager_scripts_path\b"), + re.compile(r"\bget_account_credential\b"), +) + +SCRIPTS_ROOT = Path(__file__).resolve().parents[1] / "scripts" + + +class TestNoRpaHelpersImport(unittest.TestCase): + def test_scripts_tree_has_no_forbidden_account_manager_imports(self) -> None: + violations: list[str] = [] + for root, _dirs, files in os.walk(SCRIPTS_ROOT): + for name in files: + if not name.endswith(".py"): + continue + path = Path(root) / name + text = path.read_text(encoding="utf-8") + for pattern in FORBIDDEN_PATTERNS: + if pattern.search(text): + violations.append(f"{path.relative_to(SCRIPTS_ROOT)}: {pattern.pattern}") + self.assertEqual( + violations, + [], + msg="Forbidden account-manager internal imports found:\n" + "\n".join(violations), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/examples/simulator_browser_rpa/tests/test_simulator_rpa_unit.py b/examples/simulator_browser_rpa/tests/test_simulator_rpa_unit.py new file mode 100644 index 0000000..a23a8a6 --- /dev/null +++ b/examples/simulator_browser_rpa/tests/test_simulator_rpa_unit.py @@ -0,0 +1,134 @@ +# -*- coding: utf-8 -*- +"""SimulatorBrowserRpaAdapter 单元测试(不启动真实浏览器)。""" +from __future__ import annotations + +import asyncio +import unittest +from unittest.mock import AsyncMock, MagicMock, patch + +from service.adapter import BatchItem, SimulatorBrowserRpaAdapter +from service.browser_session import CHROME_LAUNCH_ARGS +from service.simulator_playwright import RpaError + + +class TestSimulatorRpaUnit(unittest.IsolatedAsyncioTestCase): + async def test_submit_batch_empty_items(self) -> None: + adapter = SimulatorBrowserRpaAdapter() + result = await adapter.submit_batch("acct-demo-001", []) + self.assertFalse(result.ok) + self.assertIn("items", result.error_msg or "") + + @patch("service.adapter.simulator_rpa.find_chrome_executable", return_value=None) + async def test_no_chrome_returns_error_without_launch(self, _chrome: MagicMock) -> None: + adapter = SimulatorBrowserRpaAdapter() + items = [BatchItem(row_index=1, name="A", account="1", amount=1.0)] + result = await adapter.submit_batch("acct-demo-001", items) + self.assertFalse(result.ok) + msg = result.error_msg or "" + self.assertTrue("Chrome" in msg or "Edge" in msg or "浏览器" in msg) + + def test_chrome_launch_args_must_not_contain_url(self) -> None: + for arg in CHROME_LAUNCH_ARGS: + lowered = arg.lower() + self.assertFalse(lowered.startswith("http://")) + self.assertFalse(lowered.startswith("https://")) + self.assertFalse(lowered.startswith("file://")) + + @patch("service.adapter.simulator_rpa.submit_batch_rpa", new_callable=AsyncMock) + @patch("service.adapter.simulator_rpa.pick_web_account") + @patch("service.adapter.simulator_rpa.find_chrome_executable", return_value="C:/Chrome/chrome.exe") + async def test_adapter_delegates_to_playwright_with_account_url( + self, + _chrome: MagicMock, + mock_pick: MagicMock, + mock_rpa: AsyncMock, + ) -> None: + from service.adapter.base import BatchSubmitResult + + mock_pick.return_value = { + "login_id": "demo001", + "profile_dir": "/tmp/profile", + "url": "file:///demo/demo_app.html", + "lease_token": "tok-1", + } + mock_rpa.return_value = BatchSubmitResult( + ok=False, + batch_id=None, + submitted_count=0, + submitted_amount=0.0, + error_msg="login fail", + artifacts={"adapter": "simulator_rpa"}, + ) + + adapter = SimulatorBrowserRpaAdapter(artifacts_dir="/tmp/art") + items = [BatchItem(row_index=1, name="A", account="1", amount=1.0)] + result = await adapter.submit_batch("acct-demo-001", items) + + self.assertFalse(result.ok) + mock_rpa.assert_awaited_once() + call_kwargs = mock_rpa.call_args.kwargs + self.assertEqual(call_kwargs["base_url"], "file:///demo/demo_app.html") + for arg in CHROME_LAUNCH_ARGS: + self.assertNotIn(arg, call_kwargs["base_url"]) + + @patch("service.simulator_playwright.start_browser_session", new_callable=AsyncMock) + async def test_playwright_opens_url_via_goto_not_launch_args(self, mock_start: AsyncMock) -> None: + from service.simulator_playwright import submit_batch_rpa + + page = AsyncMock() + page.goto = AsyncMock() + mock_start.return_value = (MagicMock(), MagicMock(), page) + + account = {"profile_dir": "/tmp/p", "login_id": "demo001"} + items = [BatchItem(row_index=1, name="A", account="1", amount=1.0)] + + with patch( + "service.simulator_playwright._wait_portal_gate", + new_callable=AsyncMock, + return_value=None, + ): + with patch( + "service.simulator_playwright._login", + new_callable=AsyncMock, + side_effect=RpaError("login fail"), + ): + with patch( + "service.simulator_playwright.find_chrome_executable", + return_value="C:/Chrome/chrome.exe", + ): + with patch( + "service.simulator_playwright.close_browser_session", + new_callable=AsyncMock, + ): + result = await submit_batch_rpa( + account, + target="acct-demo-001", + items=items, + base_url="file:///demo/demo_app.html", + artifacts_dir=None, + ) + + self.assertFalse(result.ok) + page.goto.assert_awaited_once() + goto_url = page.goto.call_args[0][0] + self.assertEqual(goto_url, "file:///demo/demo_app.html") + for arg in CHROME_LAUNCH_ARGS: + self.assertNotIn(arg, goto_url) + + async def test_safe_screenshot_without_artifacts_dir(self) -> None: + from service.simulator_playwright import _safe_screenshot + + page = AsyncMock() + self.assertIsNone(await _safe_screenshot(page, None, "tag")) + + +class TestSyncHelpers(unittest.TestCase): + def test_asyncio_run_smoke(self) -> None: + async def _noop() -> bool: + return True + + self.assertTrue(asyncio.run(_noop())) + + +if __name__ == "__main__": + unittest.main() diff --git a/examples/simulator_browser_rpa/tests/test_stop_reason.py b/examples/simulator_browser_rpa/tests/test_stop_reason.py new file mode 100644 index 0000000..65ec6af --- /dev/null +++ b/examples/simulator_browser_rpa/tests/test_stop_reason.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- +"""RPA 失败结果与 artifacts 单元测试。""" +from __future__ import annotations + +import unittest +from unittest.mock import AsyncMock, MagicMock, patch + +from service.adapter import BatchItem, SimulatorBrowserRpaAdapter +from service.adapter.base import BatchSubmitResult + + +class TestBatchSubmitFailureResult(unittest.IsolatedAsyncioTestCase): + @patch("service.adapter.simulator_rpa.release_lease") + @patch("service.adapter.simulator_rpa.submit_batch_rpa", new_callable=AsyncMock) + @patch("service.adapter.simulator_rpa.pick_web_account") + @patch("service.adapter.simulator_rpa.find_chrome_executable", return_value="C:/Chrome/chrome.exe") + async def test_rpa_error_converts_to_failed_result( + self, + _chrome: MagicMock, + mock_pick: MagicMock, + mock_rpa: AsyncMock, + _release: MagicMock, + ) -> None: + mock_pick.return_value = { + "profile_dir": "/tmp/profile", + "lease_token": "tok", + } + mock_rpa.return_value = BatchSubmitResult( + ok=False, + batch_id=None, + submitted_count=0, + submitted_amount=0.0, + error_msg="登录第一步失败:timeout", + artifacts={"adapter": "simulator_rpa", "screenshot": "/tmp/art/x.png"}, + ) + + adapter = SimulatorBrowserRpaAdapter(artifacts_dir="/tmp/art") + items = [BatchItem(row_index=1, name="A", account="1", amount=1.0)] + result = await adapter.submit_batch("acct-demo-001", items) + + self.assertFalse(result.ok) + self.assertIn("登录第一步失败", result.error_msg or "") + self.assertEqual(result.artifacts.get("adapter"), "simulator_rpa") + self.assertEqual(result.artifacts.get("screenshot"), "/tmp/art/x.png") + + @patch("service.adapter.simulator_rpa.release_lease") + @patch("service.adapter.simulator_rpa.submit_batch_rpa", new_callable=AsyncMock) + @patch("service.adapter.simulator_rpa.pick_web_account") + @patch("service.adapter.simulator_rpa.find_chrome_executable", return_value="C:/Chrome/chrome.exe") + async def test_unexpected_error_includes_adapter_name( + self, + _chrome: MagicMock, + mock_pick: MagicMock, + mock_rpa: AsyncMock, + _release: MagicMock, + ) -> None: + mock_pick.return_value = { + "profile_dir": "/tmp/profile", + "lease_token": "tok", + } + mock_rpa.side_effect = RuntimeError("boom") + + adapter = SimulatorBrowserRpaAdapter(artifacts_dir=None) + items = [BatchItem(row_index=1, name="A", account="1", amount=1.0)] + result = await adapter.submit_batch("acct-demo-001", items) + + self.assertFalse(result.ok) + self.assertIn("未预期异常", result.error_msg or "") + self.assertEqual(result.artifacts.get("adapter"), "simulator_rpa") + self.assertNotIn("screenshot", result.artifacts) + + +if __name__ == "__main__": + unittest.main() diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..d4413ff --- /dev/null +++ b/pytest.ini @@ -0,0 +1,8 @@ +[pytest] +testpaths = tests +python_files = test_*.py *_test.py +python_classes = Test* +python_functions = test_* +norecursedirs = .git __pycache__ .pytest_cache integration desktop samples fixtures artifacts diagnostics +filterwarnings = + ignore::DeprecationWarning diff --git a/references/ACTIONS.md b/references/ACTIONS.md new file mode 100644 index 0000000..642230c --- /dev/null +++ b/references/ACTIONS.md @@ -0,0 +1,1158 @@ +# Skill Action Manifest(匠厂宿主) + +本文件说明 `assets/actions.json` 与 `scripts/main.py` CLI 的对应关系,供 **Agent 编排**与宿主 **Skill Action Runtime** 使用。这是 Agent 侧关于 Action / 任务中心的**主契约**;实现细节与开发规范见 [`../development/SKILL_ACTION_RUNTIME.md`](../development/SKILL_ACTION_RUNTIME.md)。 + +**先读产品入口:** 对话 / 数据管理 / 定时任务在宿主上**平级**,何时挂哪个入口见 [`../development/PRODUCT_SURFACES.md`](../development/PRODUCT_SURFACES.md)(本文只讲字段与执行模型)。 + +**边界:** 用户市场四 Tab 见根目录 [`README.md`](../README.md) / [`TUTORIAL.md`](../TUTORIAL.md) / [`DEMO.md`](../DEMO.md) / [`CHANGELOG.md`](../CHANGELOG.md);CLI 参数见 [`CLI.md`](CLI.md)。 + +## Agent 何时用 Action(与 CLI 的分工) + +| 场景 | 做法 | +|------|------| +| 短查询 / 只读 / 秒级 | bash + 共享 Python CLI(见 [`CLI.md`](CLI.md));**不必**强行走 Action | +| 数据管理按钮 / 定时任务 / 技能详情按钮 | **必须**在 `actions.json` 声明对应 Action | +| 长耗时 / RPA / 需要进度与取消 | **必须** `run_skill_action`;对应 Action 一般为 `executionProfile: "async"` | +| 已声明的 `sync` Action | `run_skill_action` 当场返回结果,**不进**任务中心 | +| 已声明的 `async` Action | `run_skill_action` 立即返回 `jobId`,**进入**任务中心 | + +**进不进任务中心只取决于该 Action 的 `executionProfile`,与从数据管理、定时任务还是 Agent 触发无关。** + +禁止对长任务 `exec`/`bash` + `process poll` 干等 CLI。禁止 `uv run python` 跑技能脚本。 + +## async 副作用 Action 最小组合(复制验收 checklist) + +对每个「会开浏览器 / 长耗时 / 需进度或取消」的 Action,声明时建议同时满足: + +1. `executionProfile: "async"`(进入任务中心) +2. 若要让 Agent 触发:`placements` **含** `agent` +3. 有可感知副作用时:提供 `confirmation.message` +4. 需要用户/Agent 入参时:简单 `inputSchema` + entrypoint 中的 `{{fieldName}}` +5. `SKILL.md`「用户意图路由表」与 `CLI.md`「Action 强制黑名单」**出现同一 action id / 命令** +6. 若处理的是**业务队列表中的一行**:遵守 [`WORK_ITEM_STATUS.md`](WORK_ITEM_STATUS.md)——默认只捞**可操作**角色;开始抢占为**进行中**;结束回写成功/失败(失败带原因);确认文案写清仅可操作态可处理 + +默认模板 `assets/actions.json` 仅含诊断类 sync Action,**不要**为通过本 checklist 而塞假业务 Action;业务技能按需添加。完整字段见下文 Phase 1。 + +## 可选:域前缀 CLI(嵌套子命令) + +小技能可继续扁平:`entrypoint.command = "run"`。 +命令变多时,可采用**域前缀**:`command` 为域(如 ``),业务动词与参数放在 `args` 数组(如 `["", "--flag", "{{field}}"]`)。与扁平入口等价,须仍指向同一 domain service。 + +## 模板严格规范 vs 宿主向后兼容 + +| 角色 | 定位 | +|------|------| +| **skill-template** | 新技能推荐的**严格规范**:必填字段、明确类型、`additionalProperties: false` | +| **宿主运行时** | **向后兼容**的历史 manifest 解析器:可能接受部分缺省字段(如 `description`、`placements`、`entrypoint.args`、缺失的 `bind.tables`) | + +**新技能不应依赖宿主缺省行为。** 复制模板后按 [`assets/schemas/skill-actions.schema.json`](../assets/schemas/skill-actions.schema.json) 与本文完整填写 manifest。 + +JSON Schema 使用 `additionalProperties: false` 表达模板严格边界;这与宿主共享类型中的可选字段并不矛盾——宿主为兼容旧技能可能更宽松,模板为防漂移要求更严。 + +## Action 是否必需 + +Action 是否提供、挂哪些 `placements`,由本技能 **入口定位表**决定(见 [`../development/PRODUCT_SURFACES.md`](../development/PRODUCT_SURFACES.md)),不是「可有可无的附属能力」: + +- 定位需要出现在**数据管理按钮**、**技能详情**、**定时任务**或 **Agent 直接调用**中时,**必须**提供 `assets/actions.json` 并声明对应入口。 +- 定位确认无任何宿主结构化入口时,**可以不提供** `actions.json`。 +- **含浏览器 RPA / 长耗时任务**的技能:**必须**提供至少一条 `executionProfile: "async"` 且 `placements` 含 `agent` 的 action(见 `POLICY-SKILL-ACTION-001`)。 + +## 文件位置 + +```text +{skill_root}/assets/actions.json +``` + +宿主扫描路径示例: + +```text +~/.openclaw/skills/your-skill-slug/assets/actions.json +``` + +## 顶层结构 + +| 字段 | 必填 | 说明 | +|------|------|------| +| `schemaVersion` | 是 | 当前固定为 `1` | +| `skill` | 是 | 必须与 `SKILL.md` 的 `metadata.openclaw.slug` 一致 | +| `actions` | 是 | Action 数组,每项 `id` 唯一;**数组相对顺序**决定数据管理 toolbar / row / batch 技能按钮的展示顺序(见下节) | + +模板最小示例见 [`assets/actions.json`](../assets/actions.json);JSON Schema 见 [`assets/schemas/skill-actions.schema.json`](../assets/schemas/skill-actions.schema.json)。可选 Data Policy 见 [`POLICY.md`](POLICY.md) 与 [`assets/schemas/skill-policy-snapshot.schema.json`](../assets/schemas/skill-policy-snapshot.schema.json)。可选单元格呈现见 [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md) 与 [`assets/schemas/skill-cell-presentation.schema.json`](../assets/schemas/skill-cell-presentation.schema.json)。 + +## 六职责正交(必读) + +| 字段 / 层 | 只决定什么 | 不决定什么 | +|-----------|------------|------------| +| **`placements`** | Action **出现在哪里**(toolbar / row / batch / cron / agent / skill-detail;`row`=行级入口,壳由宿主呈现) | 不决定 sync/async,不决定点了是否弹窗,不决定行级用操作列还是右键,不决定谁能点 | +| **`replaces`** | **可选**:接管数据管理默认「新增 / 编辑 / 删除 / **导入**」 | 不强制;无配置时宿主仍直写 SQLite;**import 时 UI 仍用宿主「导入数据」面板** | +| **`bind.tables`** | 数据管理里出现在**哪些业务表** | 不决定业务实现 | +| **`executionProfile`** | 宿主是**同步等待**还是**异步 Job** | 不限制可展示入口,不决定是否弹窗 | +| **`interaction`** | 可点击入口(数据管理 toolbar/row/batch、默认 CRUD 接管、未来 skill-detail)**点了怎么交互** | 不决定 sync/async;**cron 触发与 agent 忽略** | +| **`hoverHint`** | **可选**:数据管理**工具栏悬停**文案 | 不决定短按钮名;不决定点下去弹不弹窗;**不回退** `description` / `confirmation.message`;省略 = 不显示悬停。当前宿主实现为顶栏技能按钮 | +| **`inputSchema.visibleWhen`** | **可选**:Action **参数表单**字段条件显隐(先选模式再出对应参数) | **不是** Data Policy L3 行态 `visibleWhen`;不决定 placements;旧宿主忽略则字段平铺全显 | +| **`hostEffects` / `steps`** | **可选**:数据管理交互跑完前后,宿主要不要刷新表 / 清选区 / 级联删等 | 不决定业务写什么;**省略 = 宿主不额外改 UI**(≥2.8.5 起不再硬编码 mutation 后刷新);cron/agent 忽略 | +| **`cellPresentation`** | **可选**:某表某行某列的语义标色/强调(tone + 说明) | 不替代业务列;不下发 CSS;用途不限(超时、预警、驳回等);见 [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md) | +| **`policy` / Snapshot** | **可选**:当前身份在数据管理下允许哪些 Action / 宿主内置能力 / 行列范围 | 不替代 placements;不替代 domain 写路径鉴权;多数技能不配 = L0 全开 | +| **`entrypoint`** | 实际执行哪个 CLI 命令与参数 | 不写业务逻辑 | +| **domain service** | **真正完成什么业务** | 不感知 toolbar / cron / agent 来源 | + +一个业务能力通常只定义**一个** Action,用 `placements` 同时挂到多个入口。不要拆成 `sync-records-toolbar` / `sync-records-cron` 等副本,除非语义确实不同;即便拆分,也必须调用同一个领域业务内核。需要按用户/角色藏按钮或滤行时,**不要**按角色复制 Action,应启用顶层 `policy` 并由技能算出 Snapshot(见 [`POLICY.md`](POLICY.md))。 + +## Phase 1 当前稳定支持(新技能应只使用这些) + +| 层级 | 稳定字段 / 能力 | +|------|----------------| +| 顶层 | `schemaVersion`、`skill`、`actions`;可选 **`policy`**(Data Policy 启用块);可选 **`cellPresentation`**(单元格呈现启用块) | +| action | `id`、`label`、`description`、`placements`、`entrypoint`、**`executionProfile`(必填)**;可选 `hoverHint`、`inputSchema`、`confirmation`、**`interaction`**、**`interactionByPlacement`**、**`bind`**、**`replaces`**、**`hostEffects`**、**`steps`**、**`authz`**(仅文档提示) | +| entrypoint | `type = cli`、`command`、`args`(**必填** JSON 数组,项为 `string` / `number` / `boolean`) | +| placements | `toolbar`、`row`、`batch`、`skill-detail`、`agent`、`cron`;仅 `replaces` 时可为空数组 | +| **replaces** | 可选 `"create"` \| `"update"` \| `"delete"` \| `"import"` — 接管默认新增/编辑/删除/导入(须 `bind.tables`) | +| **bind** | `{ "tables": ["business_table"] }`;可选 `inputMapping`、`columnMapping`、**`labelColumn`**(行内/批量预填与选择摘要标签,见下节) | +| **policy** | 可选;`enabled` + `entrypoint`(推荐 `policy` CLI);见 POLICY.md。未启用 = L0 | +| **cellPresentation** | 可选;`{ "enabled": true }` 时宿主应加载单元格呈现;见 [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md)。未启用 = P0 | +| inputSchema | 根 `type: object`;properties 使用 `string` / `number` / `boolean`;支持 `enum`、`default`、`required`、`sensitive`、`readOnly`、可选 **`revealInForm`**;可选 `display_type` / `format`(含 `file` / `directory` 等);可选 **`pathRoot`**(file/directory 起始目录,相对技能数据根);可选 **`visibleWhen`**(字段条件显隐,见专节) | +| confirmation | `{ "message": "..." }` — **风险文案**(confirm/form 弹窗黄条;agent 元数据);**不是**「要不要弹窗」的开关 | +| **hoverHint** | 可选字符串。数据管理**工具栏悬停**文案;与 `description` / `confirmation.message` **独立**。省略则不显示悬停,不回退。短名已够懂时不要写。需宿主已支持该字段;当前实现为顶栏技能按钮,不含宿主内置删除/刷新/新增/导入/导出 | +| **visibleWhen** | 可选。写在 **property** 上(非 action 顶层):依赖同表单其它字段值决定是否展示;隐藏字段不提交、不校验 `required`。**勿与** [`POLICY.md`](POLICY.md) L3 行态 `visibleWhen` 混淆;也**勿**用它藏「已选择」已表达的映射主键(见「选择摘要」) | +| **revealInForm** | 可选。写在 **property** 上:有「已选择」时仍强制画出该 `readOnly` 字段;省略 = 映射只读字段默认不占参数区(仍提交) | +| **executionProfile** | `"sync"` \| `"async"`(**必须显式写出**,禁止依赖缺省) | +| **interaction** | `"auto"` \| `"direct"` \| `"confirm"` \| `"form"`(可选;省略≈`auto`) | +| **interactionByPlacement** | 可选;按 placement 覆盖为 `direct`/`confirm`/`form`(不可写 `auto`) | +| **hostEffects** | 可选;`onSuccess` / `onFailure` → `reloadTable` \| `refreshCatalog` \| `clearSelection`(匠厂宿主 ≥2.8.5) | +| **steps** | 可选;有序 `{ when, do, table?, fk? }`,`when`=`before`\|`afterSuccess`\|`afterFailure`;新技能优先(≥2.8.5) | + +## `bind.tables`(表级绑定) + +```json +{ + "bind": { + "tables": ["business_table"] + } +} +``` + +规则: + +- `bind` 为 object,`additionalProperties: false`;当前允许 `tables`(必填键当存在 bind 时)与可选 `inputMapping`、`columnMapping`、**`labelColumn`**。 +- `tables` 非空数组、元素唯一、英文 **snake_case**、不得为空字符串。 +- **`placements` 含 `toolbar` 时必须显式声明非空 `bind.tables`**(模板 Schema `if/then` + 自动测试强制)。 +- `row` / `batch` **建议**同样声明 `bind.tables`,避免在无关表上露出按钮;未声明时宿主可能按兼容逻辑在更多表上展示。 +- 表名须真实存在于技能 SQLite 与 `_jiangchang_tables`。 +- 希望出现在多张表时,显式列出所有表;**不要**依赖「无 bind 则全表展示」——那是宿主对**旧技能**的兼容,不是新技能标准。 +- **`inputMapping`(可选)**:把行/勾选映射到 `inputSchema` 字段,例如 `"leadId": "$row.$pk"`、`"ids": "$selection.ids"`。适合 `row` / `batch`。宿主仍会提交这些字段。映射字段建议标 `readOnly: true`:无「已选择」时只读展示;**有「已选择」时默认不占参数区**(仍提交,见下节)。不要用 `visibleWhen` 去「藏」映射主键。 +- **`columnMapping`(可选)**:把 inputSchema 属性名映射到物理列名,供表单复用列展示名 / lookup。 +- **`labelColumn`(可选)**:物理列名,供数据管理 Action 弹层「已选择」摘要展示 `#id + 标签`。**仅影响展示,不改变 CLI/提交 payload,也不决定业务是否跳过某些状态。** 省略时宿主用常见列名启发(如 `title` / `name` / `nickname` 等);再没有则只显示编号。列名不常见、或启发会选错列时再显式声明。 +- `concurrency` / `locks` 仍未稳定,**不要**写入新技能 manifest。 + +### 数据管理:选择摘要(宿主默认能力) + +当用户在数据管理**勾选行**(或行内打开带行上下文的动作)后打开 `confirm` / `form` 弹层时,宿主会展示通用 **「已选择 · N 条」** 摘要(`#主键` + 可读标签;超过约 3 条可展开查看全部)。这是**宿主 UX**,对所有技能默认生效: + +- **技能零改动即可受益**;不要在 `confirmation.message` 里手写「已选 id 列表」,也不要为了面板去拼业务状态跳过文案。 +- 摘要与执行解耦:勾选了谁由 UI 说明;**实际处理哪些行、是否跳过某些状态,仍由技能 CLI / domain service 决定**。 +- 需要稳定标签列时,在 `bind` 上声明可选 `labelColumn`(见上)。示例:[`assets/examples/selection-label-column.action.example.json`](../assets/examples/selection-label-column.action.example.json)。 + +**与 `confirm` / `form` 的信息架构对齐(映射只读字段):** + +| 交互 | 参数区 | 映射主键(`inputMapping` + `readOnly`) | +|------|--------|----------------------------------------| +| `confirm` | 整区不画 | 不画;预设值仍提交 | +| `form`(有「已选择」) | 只画用户要填/要改的字段 | **默认不画**;预设值仍提交 | +| `form`(无「已选择」) | 正常画字段 | `readOnly` **只读展示**(如工具栏靠 lookup 手选作业) | + +- 需要用户在有「已选择」时仍看见某只读字段:该 property 加 **`revealInForm: true`**(逃生阀;不改变提交)。 +- **禁止**用 `visibleWhen` 藏映射主键:`visibleWhen` 隐藏的字段**不进提交**、不校验 `required`,会弄断 `--id` / `{{job_id}}`。 +- 非映射的只读说明字段(未出现在 `inputMapping`)在有摘要时**仍会显示**;仅映射上下文字段被默认折叠。 + +## `replaces`(可选:接管默认新增 / 编辑 / 删除 / 导入) + +有领域副作用的表(归一化字段、建目录、加密、级联清理)不要依赖宿主直写 SQLite。此时可为该表声明 `replaces`,**用户仍点宿主默认按钮**,由技能 CLI 完成写入。 + +### 怎么选 + +| 场景 | 建议 | +|------|------| +| 纯表字段、无副作用 | 不配 `replaces`,宿主直写即可 | +| 单条新增/编辑/删除有副作用 | `replaces: create` / `update` / `delete` | +| 批量导入也有副作用(推荐) | 另配 `replaces: import`(与 create 可同表并存) | +| 只有 create、暂无 import | 宿主「导入」面板确认后会**逐行调 create**(兼容);正式产品应补 `import` | + +### 导入分流(必读) + +点工具栏「导入」时: + +1. **UI 一律**打开宿主「导入数据」面板(下载模板 → 选文件 → 映射 → 出错规则 → 预览)。 +2. **写库**按表能力分流: + - 有 `replaces: import` → 把选中文件路径 + 出错规则交给该 Action(技能解析并写入) + - 仅有 `replaces: create` → 宿主按行调用 create(兼容) + - 都没有 → 宿主直写 SQLite + +**不要**再做一张技能自己的「开始执行」导入表单来冒充导入体验。`placements` 对 import 保持 `[]`;`description` 写明「由宿主导入面板调用」。 + +可抄文件见 [`assets/examples/`](../assets/examples/):`replaces-*.action.example.json`。 + +### 示例:create + +```json +{ + "id": "item-create", + "label": "新增记录", + "description": "新增业务记录并执行领域副作用(走技能 CLI,非宿主直写库)。", + "replaces": "create", + "placements": [], + "executionProfile": "sync", + "interaction": "form", + "bind": { "tables": ["items"] }, + "hostEffects": { "onSuccess": ["reloadTable"] }, + "inputSchema": { + "type": "object", + "required": ["name"], + "properties": { + "name": { "type": "string", "title": "名称" }, + "note": { "type": "string", "title": "备注" } + } + }, + "entrypoint": { + "type": "cli", + "command": "item", + "args": ["create", "--name", "{{name}}", "--note", "{{note}}"] + } +} +``` + +### 示例:update + +```json +{ + "id": "item-update", + "label": "编辑记录", + "description": "编辑业务记录并重算派生字段。", + "replaces": "update", + "placements": [], + "executionProfile": "sync", + "interaction": "form", + "bind": { + "tables": ["items"], + "inputMapping": { + "id": "$row.$pk", + "name": "$row.name", + "note": "$row.note" + } + }, + "hostEffects": { "onSuccess": ["reloadTable"] }, + "inputSchema": { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { "type": "number", "title": "编号", "readOnly": true }, + "name": { "type": "string", "title": "名称" }, + "note": { "type": "string", "title": "备注" } + } + }, + "entrypoint": { + "type": "cli", + "command": "item", + "args": ["update", "--id", "{{id}}", "--name", "{{name}}", "--note", "{{note}}"] + } +} +``` + +### 示例:delete + +```json +{ + "id": "item-delete", + "label": "删除记录", + "description": "删除业务记录并清理关联副作用。", + "replaces": "delete", + "placements": [], + "executionProfile": "sync", + "interaction": "confirm", + "confirmation": { "message": "确定删除该记录?关联数据将一并清理。" }, + "bind": { + "tables": ["items"], + "inputMapping": { "id": "$row.$pk" } + }, + "hostEffects": { "onSuccess": ["reloadTable", "clearSelection"] }, + "inputSchema": { + "type": "object", + "required": ["id"], + "properties": { + "id": { "type": "number", "title": "编号", "readOnly": true } + } + }, + "entrypoint": { + "type": "cli", + "command": "item", + "args": ["delete", "--id", "{{id}}"] + } +} +``` + +### 示例:import + +```json +{ + "id": "item-import", + "label": "导入记录", + "description": "由宿主「导入数据」面板调用:解析 Excel/CSV 并写入业务表(含领域校验与派生字段)。", + "replaces": "import", + "placements": [], + "executionProfile": "sync", + "interaction": "form", + "bind": { "tables": ["items"] }, + "hostEffects": { "onSuccess": ["reloadTable"] }, + "inputSchema": { + "type": "object", + "required": ["filePath"], + "properties": { + "filePath": { + "type": "string", + "title": "导入文件", + "description": "由宿主导入面板传入的 Excel/CSV 路径。", + "format": "file" + }, + "onError": { + "type": "string", + "title": "导入规则(出错时)", + "enum": ["跳过该行,继续导入", "立即停止,不再导入后续行"], + "default": "跳过该行,继续导入" + } + } + }, + "entrypoint": { + "type": "cli", + "command": "item", + "args": ["import", "--file", "{{filePath}}", "--on-error", "{{onError}}"] + } +} +``` + +规则: + +- **全可选**:无 `replaces` 时,宿主默认按钮仍直写库,行为与以前一致。 +- 取值:`create`(新增)/ `update`(编辑)/ `delete`(删除)/ `import`(导入写库)。 +- **必须**非空 `bind.tables`;同一技能同一表同一 `replaces` 至多一个 Action。 +- 与 `placements` 正交:可只接管默认按钮(`placements: []`),不必再挂 toolbar「第二个新增/导入」。 +- 点默认「新增/编辑/删除」时走该 Action 的 `interaction`(create/update 常用 `form`;delete 常用 `confirm`)。 +- 点默认「导入」时**固定**打开宿主导入面板;确认后若有 `replaces:import` 则调用该 Action(`interaction` 不决定是否再弹技能表单)。 +- `update` / `delete` 可用 `inputMapping`(`$row.$pk`、`$selection.ids`)预填主键。 +- `import` 约定:`filePath`(或 `file`)+ 可选 `onError`(`skip`/`abort` 或上例中文枚举);技能自行解析文件与表头。 +- 整表 `_jiangchang_tables.readonly=1` 时仍隐藏写按钮(优先于 `replaces`)。 +- **列元数据复用(lookup)**:Action 表单(含 toolbar/batch 弹窗与 create/update 抽屉)可将 `inputSchema` 字段对齐业务列,复用中文名与 lookup 下拉: + - 优先让属性名 = 物理列名(如 `platform_key`); + - 或 `bind.columnMapping`:`{ "platform": "platform_key" }`; + - 或字段上写 `"column": "platform_key"`。 + - 当前表没有对应列时,用字段内联 `lookup`(见下文「跨表引用:Lookup」)。 + - **不要**在 actions.json 里写死会过期的全量平台 `enum`,也**不要**让用户手填内部编号。 + +## `interaction`(点击交互方式) + +与 `executionProfile` **正交**:一个 Action 可以是 `async` + `direct`(一点就进任务中心),也可以是 `sync` + `form`(填参后当场返回)。 + +| 值 | 数据管理体验 | 典型场景 | +|----|--------------|----------| +| `direct` | **一点即跑**,无弹窗 | 打开目录、打开浏览器、无参只读、行内主键已映射 | +| `confirm` | 轻量确认弹窗(无参数区;文案优先 `confirmation.message`;有勾选时宿主展示「已选择」摘要;映射主键仍提交) | 处理待发送、删除前确认、有副作用但无可编辑入参 | +| `form` | 完整参数表单(有勾选时同样展示「已选择」摘要;**映射 `readOnly` 默认不占参数区**,见「选择摘要」) | 导入、选文件、查询条件、需用户改写的字段 | +| `auto` / 省略 | 宿主推断:有**可编辑**参数 → `form`;仅有 `confirmation` → `confirm`;否则 → `direct` | 旧技能兼容;新技能建议显式写出 | + +```json +{ + "id": "reveal-profile", + "label": "打开目录", + "description": "打开该账号的浏览器 Profile 目录。", + "placements": ["row"], + "executionProfile": "sync", + "interaction": "direct", + "bind": { + "tables": ["accounts"], + "inputMapping": { "accountId": "$row.$pk" } + }, + "inputSchema": { + "type": "object", + "required": ["accountId"], + "properties": { + "accountId": { "type": "number", "title": "账号 ID", "readOnly": true } + } + }, + "entrypoint": { + "type": "cli", + "command": "account", + "args": ["reveal-profile", "{{accountId}}"] + } +} +``` + +```json +{ + "id": "open-browser", + "label": "打开浏览器", + "description": "用该账号持久化 Profile 打开系统浏览器,仅供肉眼核对。", + "placements": ["row"], + "executionProfile": "sync", + "interaction": "direct", + "bind": { + "tables": ["accounts"], + "inputMapping": { "accountId": "$row.$pk" } + }, + "inputSchema": { + "type": "object", + "required": ["accountId"], + "properties": { + "accountId": { "type": "number", "title": "账号 ID", "readOnly": true } + } + }, + "entrypoint": { + "type": "cli", + "command": "account", + "args": ["open", "{{accountId}}"] + } +} +``` + +可选 `interactionByPlacement`:同一 Action 在不同入口用不同交互(例如 `row: "direct"`、`toolbar: "form"`)。值只能是 `direct`/`confirm`/`form`,不能写 `auto`。 + +**边界:** + +- **cron 配置**:仍展示可编辑参数;`confirmation.message` 可作为配置页风险提示(由宿主决定)。 +- **cron 触发 / agent**:忽略 `interaction`(无人值守 / 模型填参);`confirmation` 对 agent 仍作 `requiresConfirmation` 元数据。 +- 不要用 `interaction: "direct"` 掩盖需要用户填写的可编辑字段——宿主在 `auto` 下会因可编辑参数回落到 `form`;显式 `direct` + 可编辑字段时参数取 default/预填,作者须自觉。 + +## Action 按钮中文名(`label`,参考) + +`label` 是数据管理 **toolbar / row / batch** 以及技能详情直调上用户看见的短按钮名。本节是**参考契约,不是白名单**:行业专有动作(私信、审单、报关登记等)按业务起名。同类通用动作尽量用同一动词,避免每个技能一套叫法。 + +**写法:** 短中文,动词或「动词 + 对象」(约 2~8 字)。同一能力用**一个** Action 换 `placements`,不要为入口复制同义按钮。 + +以后增补:在下表加行,并在模板 `CHANGELOG.md` 记一笔。废弃说法挪到「不要叫」,不要直接删行。 + +### 宿主已有,技能不要再做一套同义按钮 + +数据管理宿主已提供:**查看 / 编辑 / 删除 / 新增 / 导入 / 导出 / 刷新**。 + +技能要用 CLI 接管增删改导入时用 `replaces`(见上文),**不要**再把同义按钮挂到 `placements: ["row"]` 或 `toolbar`(例如「编辑平台」「删除 Key」与底下宿主「编辑」「删除」叠两套)。用户仍点宿主那几个字。 + +### 建议动词(有则用之) + +| 类型 | 建议 `label` | 不要叫 | 说明 | +|------|----------------|--------|------| +| 基础设施 | 运行检查、查看版本、查看配置位置、初始化数据 | 健康检查、版本信息、配置路径、初始化数据库 | 多挂 `skill-detail` | +| 打开 | 打开浏览器、打开目录、打开文件 | 打开文件位置、在资源管理器中打开、用浏览器打开 | 见下节静默成功 | +| 改库 | 新增…、编辑…、删除…、启用/停用、设置默认…、加入待…、重置为待… | 表记录用「添加」;无必要拆成「启用」「停用」两个按钮 | 对象名可带,如「新增账号」 | +| 外源入库 | 同步数据、全量同步、采集…、扫描… | 把宿主「刷新」叫成同步;把导出 CSV 叫成同步 | 默认用「同步数据」;全量才写「全量同步」 | +| 队列与探测 | 处理待…、测试 API、测试网页、抽检本条、抽检所选、批量抽检 | 行 / 勾选 / 工具栏三个入口都叫「抽检」 | 行=本条,勾选=所选,不管勾选的批量=批量 | +| 生成发布 | 生成 HTML、生成…、发布到… | 用「导出」代替宿主表导出 | 固定 id `render-html` 的 label 用「生成 HTML」 | + +### 打开类:一点即开,成功不提示 + +打开目录 / 打开浏览器 / 打开文件:`interaction: "direct"`,`executionProfile` 通常 `"sync"`。 + +- **成功**:不要输出用户可见成功文案。若打印 JSON,令 `"message": ""`(宿主据此不弹「执行成功」)。操作系统已经弹出目录或浏览器,就是反馈。 +- **失败**:仍 `ERROR::`(可加 `HINT:`)。 +- **不要**用宿主「查看」表示打开目录或浏览器——「查看」是只读详情。 + +## `hoverHint`(工具栏悬停,可选) + +与 `label` / `description` / `confirmation.message` **独立**。只决定数据管理**顶栏技能按钮**鼠标悬停时显示什么。 + +- **省略 / 空白** ⇒ 不显示悬停,**不回退**灰色说明或确认黄条。 +- 短按钮名已经够懂时**不要写**,避免噪音。适合:业务黑话、会覆盖本机/远程、不点进去看不出后果。 +- 悬停文案不要写成「确认开始?」;那是点下去之后黄条的口吻。 +- 当前宿主实现为**工具栏技能按钮**;宿主内置删除/刷新/新增/导入/导出不走本字段。 +- 需宿主已支持 `hoverHint`;更旧宿主会忽略未知字段(模板 Schema 仍会校验新技能)。 + +```json +{ + "id": "kb-sync-feishu", + "label": "拉取到本机", + "description": "把飞书知识库覆盖到本机工作区。未发布的本地修改会丢失。", + "hoverHint": "将用飞书覆盖本机知识库,未发布的本地修改会丢失。", + "placements": ["toolbar"], + "executionProfile": "async", + "interaction": "confirm", + "confirmation": { "message": "将用飞书覆盖本机知识库,未发布的本地修改会丢失。确认开始?" }, + "bind": { "tables": ["kb_sources"] } +} +``` + +## `hostEffects` / `steps`(执行前后宿主编排,≥2.8.5) + +与 `placements` / `executionProfile` / `interaction` **正交**。决定的是:数据管理里点完 Action 之后(或之前),宿主要不要**刷新当前表、刷新目录、清空多选、级联删子表**等。 + +- **省略 / 空** ⇒ 宿主**不**额外改 UI(≥2.8.5 起不再对 mutation 硬编码刷新)。本地库若已由技能写新,界面仍可能旧,直到用户点内置「刷新」或本 Action 声明了 `reloadTable`。 +- **仅数据管理交互路径生效**(toolbar / row / batch / skill-detail / `replaces`);**cron 与 agent 忽略**(与 `interaction` 相同)。 +- 需匠厂宿主 **≥ 2.8.5**;更旧宿主会忽略未知字段(若未做严格 Schema 校验)。 +- **新技能优先写 `steps`**;`hostEffects` 仍可作为「只刷 UI」的简写。两者可并存;同一 UI 效果若两边都声明,宿主可能各跑一次。 + +### `hostEffects`(UI 副作用简写) + +```json +{ + "hostEffects": { + "onSuccess": ["reloadTable"], + "onFailure": ["clearSelection"] + } +} +``` + +| 字段 | 触发时机 | +|------|----------| +| `onSuccess` | **sync** 退出码 0;或 **async** 任务已被任务中心**接收**(不是 Job 跑完) | +| `onFailure` | **sync** 失败;或 **async** **启动被拒** | + +闭集 effect id:`reloadTable`(重载当前表 + 常伴随 Data Policy 再拉)、`refreshCatalog`(刷新技能目录)、`clearSelection`(清空多选)。未知 id 丢弃。 + +**async 注意**:`sync-data` 一类长同步若写 `onSuccess: ["reloadTable"]`,会在**入队成功时**就刷表,可能仍是旧数据;更适合 sync 的新建/登记,或等 Job 真正完成后再刷(若宿主后续扩展完成回调)。 + +### `steps`(有序编排,推荐) + +```json +{ + "steps": [ + { "when": "before", "do": "deleteByFk", "table": "child_rows", "fk": "parent_id" }, + { "when": "afterSuccess", "do": "reloadTable" }, + { "when": "afterFailure", "do": "clearSelection" } + ] +} +``` + +| `when` | 含义 | +|--------|------| +| `before` | 调用 CLI **之前** | +| `afterSuccess` | 同 `hostEffects.onSuccess` | +| `afterFailure` | 同 `hostEffects.onFailure` | + +| `do` | 说明 | +|------|------| +| `reloadTable` / `refreshCatalog` / `clearSelection` | 同 hostEffects | +| `deleteByFk` | 按外键批量删子表;须 `table` + `fk`;行按钮会把当前行主键当父键 | +| `deleteRows` | 删指定表行;须 `table` | + +规则:单次最多 **8** 步;未知步骤丢弃;`deleteByFk` / `deleteRows` 仅走 Skill Action steps 路径时可删 `metadata_readonly` 投影表(人工 CRUD 仍守只读)。整库只读 / 视图仍禁止写。 + +典型:业务表「新建 / 登记 / 更新」成功后加 `{ "when": "afterSuccess", "do": "reloadTable" }`,避免用户傻等旧表格。 + +```json +{ + "id": "register-one", + "label": "登记本行", + "description": "更新当前行业务字段。", + "placements": ["row"], + "executionProfile": "sync", + "interaction": "form", + "bind": { + "tables": ["business_records"], + "inputMapping": { "recordId": "$row.$pk" } + }, + "steps": [{ "when": "afterSuccess", "do": "reloadTable" }], + "entrypoint": { + "type": "cli", + "command": "register", + "args": ["--id", "{{recordId}}"] + } +} +``` + +默认模板 `actions.json` 仅含 `health` / `version` / `config-path`,不放在 toolbar/row/batch,故无需 `bind` 示例污染默认 manifest。业务示例: + +```json +{ + "id": "sync-records", + "label": "同步记录", + "description": "从外部来源同步到技能本地数据库,不生成导出文件。", + "placements": ["toolbar", "cron", "agent", "skill-detail"], + "executionProfile": "async", + "bind": { "tables": ["business_records"] }, + "entrypoint": { "type": "cli", "command": "sync-records", "args": [] } +} +``` + +行内示例: + +```json +{ + "id": "retry-one", + "label": "重试本行", + "description": "对当前行执行一次业务重试。", + "placements": ["row"], + "executionProfile": "async", + "bind": { + "tables": ["business_records"], + "inputMapping": { "recordId": "$row.$pk" } + }, + "inputSchema": { + "type": "object", + "required": ["recordId"], + "properties": { + "recordId": { "type": "string", "title": "记录 ID", "readOnly": true } + } + }, + "entrypoint": { + "type": "cli", + "command": "retry", + "args": ["--id", "{{recordId}}"] + } +} +``` + +## 预留能力(当前不可当作已实现能力) + +| 类别 | 预留项 | 说明 | +|------|--------|------| +| action 字段 | `concurrency`、`locks` | 并发与锁控制,未稳定开放 | +| inputSchema | `array`、nested `object`、`oneOf` 等 | 复杂表单,不属于当前推荐范围 | + +`bind`、`row`、`batch` **不是**保留能力;已按匠厂宿主正式支持写入本规范。 +## 执行模型 + +宿主将 action 转为 argv,再用**共享 Python**(用户数据目录下 `python-runtime/.venv`)执行: + +```text +python {skill_root}/scripts/main.py +``` + +- `entrypoint.type` 当前只支持 `cli` +- `entrypoint.command` → CLI 子命令(须真实存在) +- `entrypoint.args` → 子命令参数(**必须为 JSON 数组**) +- `{{fieldName}}` → 来自 `inputSchema` 用户输入的简单模板替换 + +完整子命令定义见 [`scripts/cli/app.py`](../scripts/cli/app.py)。 + +### `executionProfile`(仅 sync / async) + +**心智**:多数 Action 按 **sync**(短调用、不进任务中心);只有长耗时 / RPA / 需进度控制时才标 **async**。匠厂宿主若**省略**该字段,按 **sync** 处理;模板与新技能仍要求**每个 Action 显式写出** `sync` 或 `async`,禁止发明 `auto` / `background` / `deferred`,也禁止赌省略行为。 + +| 值 | 宿主行为 | 任务中心 | +|----|----------|----------| +| `sync`(或省略时的宿主缺省) | 调用方等待结束,直接返回结构化结果或结构化错误;**不**创建后台 Job | 不进 | +| `async`(须显式声明) | 宿主创建后台 Job,立即返回 `jobId`;用 `emit` / `checkpoint` / `finish` 报告进度与终态 | **进入** | + +合法组合(全部允许):`sync|async` × `toolbar|row|batch|cron|agent|skill-detail`。 + +经验建议(**不是** placement 硬限制):耗时不可预测、需要暂停/停止/进度展示的任务,用 `async`;其余默认写 `sync`。 + +直接执行短 CLI 时,终端自然等待进程结束。宿主通过 Action 调用同一 CLI 时,再按 `executionProfile` 决定同步等待还是建 Job。技能**不得**自行实现第二套任务中心或后台队列。 + +### CLI 输出契约 + +技能 CLI 必须: + +- 使用稳定的退出码 +- 成功时输出可解析的结构化结果 +- **例外(打开类)**:打开目录 / 打开浏览器 / 打开文件成功时不要输出用户可见成功文案;若打印 JSON,令 `"message": ""`。失败仍走下一款错误约定 +- 失败时输出稳定错误信息 +- 对可预期错误提供稳定错误码 +- 对用户可处理的问题提供 `HINT` +- 不依赖 Agent 连续对话才能完成 +- 不自行实现宿主任务中心 +- 不在 CLI 中硬编码宿主安装目录 +- 不直接依赖某个具体 UI 页面 + +建议错误输出格式: + +```text +ERROR:: +HINT: +``` + +## placements(入口位置) + +| 值 | 含义 | +|----|------| +| `toolbar` | 数据管理表顶栏技能按钮(须配合 `bind.tables`) | +| `row` | 数据管理**行级入口**(建议 `bind.tables` + `inputMapping`;表需有主键)。技能声明后,由宿主呈现为操作列链接、**行右键菜单**和/或行内「⋯」等壳;**不**新增 `row-context-menu` 等 placement | +| `batch` | 数据管理批量操作(需勾选行;无勾选时宿主禁用按钮) | +| `skill-detail` | 技能详情页直调(与安装/四 Tab 同属技能市场详情;已声明则须自测) | +| `agent` | Agent 可直接调用(`run_skill_action`) | +| `cron` | 定时任务「技能直调」 | + +### 用户可见性与教程准确性 + +`placements` 决定 Action **可以**出现在哪;教程只能写用户在宿主里**实际能点到**的路径。完整用户文档契约见 [`../development/TUTORIAL_SURFACES.md`](../development/TUTORIAL_SURFACES.md)。 + +| 规则 | 说明 | +|------|------| +| **skill-detail ≠ 一定可见** | 声明 `skill-detail` 不保证详情页渲染出按钮;当前宿主详情常只有说明/教程/演示/更新日志四 Tab。**禁止**把「技能详情 → 点某某」写成用户主路径或教程唯一入口 | +| **准备 / 载入 / 导入 / 重置** | 若技能以数据管理为主路径,此类能力优先 `toolbar` + 非空 `bind.tables`(空表也可点的准备动作尤其如此);可同时挂 `agent`;`skill-detail` 仅作可选兼容 | +| **写入后刷新** | 会写入业务表的 Action 建议 `steps: [{ "when": "afterSuccess", "do": "refreshCatalog" }]`,避免用户点完表不更新 | +| **教程按钮名** | 必须等于本文件 `label`;对照表与数据管理顶栏/行内文案一致 | +| **README vs 教程** | 说明 Tab 不写分步;操作步骤只写 `TUTORIAL.md` | + +### 行级入口 `row`(宿主呈现契约) + +- **技能决定有什么**:`placements` 含 `"row"`(且通过 `bind.tables` / policy 等过滤)的 Action,构成该表行的技能菜单项集合;配什么就出现什么。 +- **宿主决定怎么摆**:可用操作列、行右键、行内「⋯」等呈现**同一批** Action;点击均走同一 `launch`(`placement: "row"`),`interaction` / `executionProfile` 与壳无关。 +- **顺序**:各壳内技能项顺序 = 下方「数据管理技能按钮顺序」契约(`actions` 数组相对顺序)。 +- **宿主内置项**:查看 / 编辑 / 删除由宿主提供,**不是**技能 `actions.json` 配置出来的;若出现在行右键,须与技能项分组(例如分隔线后),且不得要求技能再配一份菜单 DSL。 +- **禁止**:为右键单独发明 placement、菜单注册表或第二套 Action 列表。 + +### 数据管理技能按钮顺序(宿主正式契约) + +宿主**没有**单独的 `sortOrder` / `uiOrder` 字段。同一 `placement`(`toolbar` / `row` / `batch`)下,技能按钮的展示顺序 = `actions` 数组中**匹配该项 placement(且通过 `bind.tables` 过滤)的相对先后**。 + +规则: + +1. 想调整顺序 → **调整 `actions` 数组先后**(不必改 `id` / `label`)。 +2. 无关 placement 的 Action(如仅 `skill-detail` 的 `health`)夹在中间**不影响** toolbar/row 的相对顺序;宿主按匹配子集保序渲染。 +3. 同一 Action 可同时挂多个 placement;它在各入口的位置,分别是该入口匹配子集里的相对位置。 +4. **宿主内置按钮不参与本契约、位置固定**: + - 表顶栏右侧:删除(有勾选时)/ 刷新 / 新增 / 导入 / 导出 —— 技能 toolbar/batch 按钮在左侧,不能与右侧内置按钮混排。 + - 行级:技能 `row` 项按本顺序出现在操作列 / 行右键(及可选「⋯」)中;宿主「查看」→(非只读)「编辑」→「删除」为内置项,位置由宿主固定(操作列常在技能项之后;右键中可与技能项分组)。 +5. **表顶栏全部直接展示,无「更多操作」溢出菜单**:匹配到的 toolbar/batch 技能按钮一律渲染(顺序仍按本契约)。**行级**不以「顶栏溢出菜单」模型截断;行右键是 `row` 的正式呈现壳之一,不是 toolbar 式「更多操作」截断。 +6. **表顶栏宽窄布局(宿主行为,技能无需配置)**: + - 够宽:左右各横排一行(左技能按钮 + 右宿主工具)。 + - 不够宽:保持左右分栏,栏内按钮上下竖排(左栏技能、右栏刷新/导出等)。 + +禁止: + +- 臆造 `sortOrder` / `order` / `priority` 等字段指望宿主排序(模板 Schema `additionalProperties: false` 会拒收)。 +- 臆造 `tooltip` / `title` 等字段当悬停(用可选 `hoverHint`;不要把 `confirmation.message` 复用成悬停)。 +- 依赖或宣传表顶栏「更多操作」溢出菜单(宿主已取消该能力)。 +- 把「表格行数据默认排序」与按钮顺序混淆——行排序见 [`SCHEMA.md`](SCHEMA.md) 的 `default_sort_json`。 +- 把「默认展示列」与 `visible=0` 硬隐藏混淆——默认展示列见 [`SCHEMA.md`](SCHEMA.md) 的 `default_visible_columns_json`。 +- 新增 `row-context-menu` 等 placement,或要求技能再维护一份与 `row` 平行的菜单清单。 + +### Action 类型 × placements(常见示例,不是限制) + +下表只表示**常见用法示例**。实际 `placements` 以 manifest 配置为准;**`sync` / `async` 不影响展示入口**。已声明的入口都要在宿主自测,不要只测其中一条。 + +| Action 类型 | 常见 executionProfile | 常见 placements | confirmation | +|-------------|----------------------|-----------------|--------------| +| health / version / config-path | sync | skill-detail, agent | 无 | +| 统计 / 只读查询 | sync | skill-detail, agent | 无 | +| 数据同步(外源 → 本地库) | sync 或 async | toolbar + cron + agent + skill-detail(按需) | 可选 | +| 单行重试 / 行内处理 | sync 或 async | row(可加 agent) | 可选 | +| 批量处理勾选行 | sync 或 async | batch(可加 toolbar/cron) | 可选 | +| 导入 / 重置 | sync 或 async | **优先** toolbar(须 `bind.tables`)+ agent;skill-detail 仅可选 | 可选 | +| 单条副作用 RPA | async(建议) | agent, skill-detail | **建议** | +| 批量顺序 pick RPA | async(建议) | toolbar, cron, agent | **建议** | +## 同步数据 vs 导出当前表 vs 特殊业务报告 vs HTML 页面 + +不要混用「导出」一词: + +| 能力 | 语义 | 谁负责 | +|------|------|--------| +| **同步数据**(sync / refresh / collect) | 外部来源 → 技能本地数据库 | 技能 Action / domain service | +| **导出当前数据表** | 技能本地数据库 → CSV/Excel 等 | 数据管理页**宿主统一导出** | +| **生成特殊业务报告(文件)** | 多表合并、特殊排版或附加计算的 **xlsx/csv/json 等文件** | 仅当系统导出无法满足时,才提供独立 `export-*` Action;文件落 `output/` | +| **HTML 页面报告** | 可在数据管理侧栏预览的整页 HTML | 技能写入 `{skill_data}/html/*.html` + Action id **`render-html`**(见下节) | + +同步型 Action 默认:读外部数据、写技能自己的库、**不**生成 CSV/Excel/JSON 文件、不重复实现宿主表导出。兼容旧 export 命令若需要刷新数据,必须调用同一个 sync 业务内核。通用同步语义见 [`SCHEMA.md`](SCHEMA.md) 与 [`../development/SKILL_ACTION_RUNTIME.md`](../development/SKILL_ACTION_RUNTIME.md)。 + +## HTML 页面报告(数据管理预览) + +> 一句话:**技能负责生成并覆盖 `html/*.html`;宿主负责发现、预览、用 `render-html` 触发重新生成。** + +### 何时用 + +- 需要**可浏览的整页报告**(KPI + 表格 + 自带样式),而不是再导出一张表 +- 与 `export-*` / 宿主表导出**并列**:HTML 走 `html/`;文件型特殊报告仍走 `output/` + `export-*` + +### 目录与发现(宿主契约) + +| 项 | 约定 | +|----|------| +| 路径 | `{skill_data}/html/*.html`,**只扫一层**(子目录忽略) | +| helper | `util.runtime_paths.get_html_dir()`;写入用 `service.html_page_service.write_html_page` | +| 侧栏名 | 取文件内 ``(缺省则用文件名) | +| 预览 | 宿主 iframe 预览;右键:重新生成 / 浏览器打开 / 刷新 | +| 模板资源 | 建议放 `assets/html/*.html`(安装包内),渲染后**写出**到数据目录 `html/` | + +### Action:`render-html`(固定 id) + +宿主「重新生成」**硬编码**查找 Action id `render-html`(见 `service.html_page_service.HOST_HTML_RENDER_ACTION_ID`)。技能未提供该 id → 提示「该技能未提供生成 HTML」。 + +推荐形态: + +| 字段 | 建议 | +|------|------| +| `id` | **必须** `render-html` | +| `executionProfile` | `sync` | +| `interaction` | `direct` | +| `placements` | `toolbar` + `skill-detail`(配合表顶栏与 HTML 右键);可选 `agent` | +| `bind.tables` | 数据**来源表**(含 `toolbar` 时必填);宿主从 HTML 页重新生成时会用 bind 补齐跑表上下文 | +| `steps` | `afterSuccess` → **`refreshCatalog`**(重扫 `htmlPages`;不要只 `reloadTable`) | +| `entrypoint` | CLI 子命令(如 `render-html`)调用同一写入内核 | + +完整 JSON 形状见 [`../assets/examples/render-html.action.example.json`](../assets/examples/render-html.action.example.json)。最小页面壳见 [`../assets/html/example-report.html`](../assets/html/example-report.html)(已含数据管理 iframe 预览的布局基线;复制后可按需扩展 `{{BODY}}` 内结构)。 + +### 宿主预览布局 + +数据管理在 **iframe** 内预览 `html/*.html`;宿主 iframe 已占满右侧预览区,**技能页不应再人为收窄正文**(尤其数据管理窗口通常较窄)。 + +| 级别 | 约定 | +|------|------| +| **必须** | 页面横向**铺满** iframe 可用宽度:`main { width: 100%; min-width: 0; }` | +| **必须** | **禁止** `main`(或等价正文容器)使用 `max-width: Npx` 且**无** `margin: 0 auto`(宽屏会左对齐留白;窄宿主窗浪费横向空间) | +| **必须** | `html` / `body` 设 `overflow-x: hidden`;滚动以**纵向**为主,避免横向滚动条 | +| **必须** | 写入 `{{BODY}}` 的业务数据经 `html.escape`(或等价)转义,防 XSS | +| **应当** | `header` 与 `main` 水平内边距一致(如共用 CSS 变量或 `clamp()`) | +| **应当** | 多列表格:`width: 100%` + 单元格 `word-break` / `overflow-wrap`,长文本在格内换行 | +| **应当** | KPI / 卡片网格用 `repeat(auto-fit, minmax(…, 1fr))` 等流体列,勿死写固定列数 | +| **禁止** | 在 `<td>` 上用 `max-width` 限制「说明列」等业务列(易导致列宽异常或横向溢出) | +| **禁止** | 依赖外链 CSS/JS 或 `networkidle` 式加载(iframe `sandbox` 仅 `allow-scripts`;页面应自包含) | + +长文阅读型报告若确需版心,可对整个 `main` 使用 `max-width` **且** `margin: 0 auto`,并令 `header` 与 `main` 同宽;**数据看板类**默认不设 `max-width`。 + +### 实现要点 + +1. 用 `load_html_template` + `render_html_placeholders` + `write_html_page`(原子 `*.tmp` + `os.replace`)覆盖同名文件;**不要**每次另存带时间戳的副本(除非产品明确要求历史版本)。 +2. 页头时间建议用**写出时刻**(「报告生成于」),不要误用业务表里过期的 `generated_at` 冒充「刚重新生成」。 +3. 可选:数据重算成功后调用静默写入(吞掉错误),保持侧栏报告与库一致。 +4. 权限:按业务决定谁能生成(管理员 / 角色);在 CLI/`cmd_render_html` 内强制校验。 +5. CHANGELOG / 用户说明:模板或样式升级后,提醒「请在数据管理对报告执行一次重新生成」。 + +### 验收(复制后自测) + +- [ ] `html/` 下出现 `.html`,数据管理侧栏能看到报告项,预览正常 +- [ ] 预览区**横向铺满**、**无横向滚动条**;拖窄侧栏后内容自适应(见上节「宿主预览布局」) +- [ ] 表顶栏(若声明 toolbar)或报告右键「重新生成」能跑通;成功后预览刷新 +- [ ] 未登录/无权限用户被拒绝,且有可读错误 +- [ ] 成功路径带 `refreshCatalog`(或等价让侧栏/预览看到更新) + +## inputSchema 与参数表单 + +### 跨表引用:Lookup(宿主已支持;勿让用户填编号) + +当 Action 参数要引用**同库另一张业务表**的一行(话术模板、平台、账号等)时,必须用 Lookup 下拉,**禁止**做成「请填写编号」的数字框(用户记不住内部 id,体验差)。 + +| 场景 | 怎么配 | 说明 | +|------|--------|------| +| 当前表已有同含义列,且列上已是 CRUD lookup | `column` / `bind.columnMapping`,或属性名 = 列名 | toolbar/batch 弹窗与 create/update 抽屉都会复用该列已解析的选项 | +| 当前表没有该列(如在「潜客」表上选「话术模板」) | `display_type: "lookup"` + 字段内联 `lookup` 对象 | 打开表单时宿主按当前技能库解析选项 | + +内联 `lookup` 与列元数据 `options_json` **同构**(见 [`SCHEMA.md`](SCHEMA.md)「跨表引用 lookup」): + +```json +{ + "templateId": { + "type": "number", + "title": "话术模板", + "description": "仅在正文留空时生效;选 -- 表示自动挑选。", + "display_type": "lookup", + "lookup": { + "ref_table": "message_templates", + "value_column": "id", + "label_column": "name", + "extra_column": "channel", + "filter_column": "is_active", + "filter_value": 1 + } + }, + "engineIds": { + "type": "string", + "title": "监测引擎", + "description": "可多选;提交为逗号分隔编号。", + "display_type": "lookup", + "lookup": { + "ref_table": "engines", + "value_column": "id", + "label_column": "display_name", + "filter_column": "enabled", + "filter_value": 1, + "multiple": true + } + } +} +``` + +| 字段 | 必填 | 说明 | +|------|------|------| +| `ref_table` | 是 | 同库被引用表 | +| `value_column` | 是 | 提交给 CLI 的值(业务 id / 编号) | +| `label_column` | 是 | 主文案列 | +| `extra_column` | 否 | 第三展示列(任意列名,不限「备注」) | +| `filter_column` / `filter_value` | 否 | 等值过滤 | +| `multiple` | 否 | `true` = 多选;省略/`false` = 单选(默认) | + +**下拉/选项展示(宿主统一,技能只配列):** 单选为 `{value} · {label}`;有非空 `extra_column` 时为 `{value} · {label}({extra})`。例:`2 · 通用私信-留资`、`2 · 通用私信-留资(dm)`。 + +**多选存值与模板:** Action / CRUD 存 **`TEXT` 逗号分隔 id**(如 `1,2,5`);`{{engineIds}}` 原样替换进 CLI。多选字段在 `inputSchema` 里用 `type: "string"`(逗号串),不要用尚未稳定的 JSON array 当业务控件。 + +**多选控件(宿主策略):** 选项少(建议 ≤6)用勾选列表;选项多用字段内 Chip + 可搜索下拉(不是再开业务弹窗)。 + +**可选「自动」(单选常见):** 字段不要放进 `required`;不要用 `minimum: 1` 卡死留空。宿主会省略空的可选数字参数,避免「提交的数据无效」。 + +**反模式(勿做):** + +- 标题写成「某某编号」,说明里教用户去左侧表抄 id +- 用静态 `enum` 罗列会增长/变更的业务行(会过期) +- 在技能里自己拼假 label 列代替宿主统一格式(难维护) +- 另造 `lookup_multi` / 第二套 display_type(应只加 `multiple: true`) + +形状参考:[`assets/examples/lookup-action.example.json`](../assets/examples/lookup-action.example.json)。 + +### 表单字段类型对照(须与 CRUD 对称) + +Skill Action 弹窗与数据管理「新增 / 编辑」使用**同一套控件语义**。CRUD 侧配置见 [`SCHEMA.md`](SCHEMA.md)「表单与展示:稳定字段类型」;本表只写 Action 怎么配。 + +| 控件语义 | Action `inputSchema` 怎么写 | CRUD 怎么写(对照) | +|----------|----------------------------|---------------------| +| 单行文本 | `type: string` | `display_type: text` 或缺省 | +| 多行文本 | `display_type` 或 `format`: `textarea` | `display_type: textarea` | +| JSON 长文本 | `type: object`/`array`(以 JSON 字符串编辑)或 `textarea` | `display_type: json` / `textarea` | +| 数字 | `type: number` | `display_type: number` 或 INT/REAL/NUMERIC | +| 开关 | `type: boolean` | `display_type: boolean` | +| 枚举下拉 | `enum: [...]` | `options_json` 数组 | +| 关联下拉(Lookup) | 内联 `display_type: lookup` + `lookup`(可选 `multiple`),或绑列 | 同左;多选存 `TEXT` 逗号分隔 id | +| 密码 | `sensitive: true` | 列 `sensitive` | +| 日期(日) | `display_type` 或 `format`: `date` | `display_type: date` → 存 `YYYY-MM-DD` | +| 日期时间 | `display_type` 或 `format`: `datetime_unix_seconds` | 同左;**存/传 Unix 秒** | +| URL(薄层) | `display_type` 或 `format`: `url` | `display_type: url` | +| Email(薄层) | `display_type` 或 `format`: `email` | `display_type: email` | +| **本地文件路径** | `display_type`/`format`: **`file`**;可选 **`pathRoot`** | CRUD 一般不用;见下节 | +| **本地目录路径** | `display_type`/`format`: **`directory`**;可选 **`pathRoot`** | CRUD 一般不用;见下节 | +| 说明灰字 | `description` | `_jiangchang_columns.description` | +| 表格横幅 / 列头「?」 | (无;Action 不用) | `_jiangchang_tables.grid_help` / `_jiangchang_columns.grid_help` | +| 默认值 | `default` | SQLite 列 `DEFAULT` | +| 只读 | `readOnly: true` | 列 `editable: 0` / 宿主只读规则 | +| **条件显隐** | **`visibleWhen`**(见下节) | **本版 CRUD 无对称**;仅 Action / 定时任务参数表单 | +| **列筛选「值」** | **无对称**(Action 无表格筛选) | 见 [`SCHEMA.md`](SCHEMA.md)「数据管理:列筛选与全局搜索」:枚举 / boolean / lookup 在筛选栏用**下拉 Select**(非开关、非分段单选);存库仍用 value,展示与搜索认 label | + +**本版不做(勿宣传):** 浏览器内文件上传控件、复杂 `oneOf` / nested object 专用表单;`visibleWhen` 多条件 AND/OR 树、跨 Action 依赖、CRUD 新增/编辑对称显隐;Action 参数表单的列筛选对称。 +(**Lookup 多选**已支持:同一 `lookup` + `multiple: true`,见上文;不是任意标签数组。) +(**本地路径选择**已支持:`file` / `directory` + 可选 `pathRoot`,见下节;提交值仍是本机绝对路径字符串,不是 multipart 上传。) + +日期时间约定: + +- Action 提交与 CRUD 写库统一用 Unix **秒**整数(与 SCHEMA 标准时间字段一致) +- `date` 只表示日历日,值为 `YYYY-MM-DD` +- 脏/无法归一的旧预设:日期控件可回退为普通文本,避免卡死表单 + +### 本地路径选择与 `pathRoot` + +用于数据管理 / 定时任务 **Action 参数表单**里选本机文件或目录(原生对话框)。与宿主工具栏内置「导入」面板(默认打开本技能 `input/`,见 [`../development/DATA_PATHS.md`](../development/DATA_PATHS.md))是**两条 UI 路径**,不要混为一谈。 + +| 字段 | 含义 | +|------|------| +| `display_type` / `format`: `file` \| `directory` | 渲染「选文件 / 选文件夹」控件;提交值为本机**绝对路径**字符串 | +| **`pathRoot`(可选)** | 对话框**起始目录**,相对**当前技能数据根** `{JIANGCHANG_DATA_ROOT}/{USER_ID}/{slug}/` | + +宿主行为(匠厂已支持): + +| `pathRoot` | 打开对话框时 | +|------------|--------------| +| 省略 / 空 | `{skill_data}/input` | +| `input/库存数据` 等 | `{skill_data}/` + 该相对路径 | +| `output` / `output/…` | 落在技能 `output` 下 | +| 裸 `库存数据` | 视为 `input/库存数据` | +| 绝对路径、盘符、`..` | 非法 → 回退 `{skill_data}/input` | + +硬规则: + +1. **禁止**写用户 ID、盘符、绝对路径;用户与数据根由宿主按当前登录解析。 +2. **`description` 只给人看**,宿主**不会**解析文案来决定起始目录;要默认进子目录必须写 `pathRoot`。 +3. Agent / CLI **忽略** `pathRoot`(仍直接传路径字符串)。 +4. 输入框**已有路径**时,宿主优先用已有值作为起始(便于再次浏览)。 + +```json +{ + "id": "refresh-inventory", + "label": "刷新库存数据", + "description": "从输入目录选择 JSON 快照并更新库存。", + "placements": ["toolbar", "agent"], + "executionProfile": "sync", + "interaction": "form", + "bind": { "tables": ["sku_inventory"] }, + "inputSchema": { + "type": "object", + "required": ["file"], + "properties": { + "file": { + "type": "string", + "title": "库存数据文件", + "description": "请选择 input/库存数据/ 下的 JSON。", + "format": "file", + "display_type": "file", + "pathRoot": "input/库存数据" + } + } + }, + "entrypoint": { + "type": "cli", + "command": "refresh-inventory", + "args": ["--file", "{{file}}"] + } +} +``` + +形状参考:[`assets/examples/path-root-file-action.example.json`](../assets/examples/path-root-file-action.example.json)。 + +### 当前宿主稳定支持(摘要) + +- 根:`object`;属性:`string` / `number` / `boolean`(`object`/`array` 仅作 JSON 多行编辑兼容,新技能慎用) +- `enum` / `default` / `required` / `sensitive` / `readOnly` / 可选 `revealInForm` +- `display_type` / `format`:`textarea` | `date` | `datetime_unix_seconds` | `url` | `email` | **`lookup`** | **`file`** | **`directory`** +- 可选 **`pathRoot`**(仅 `file` / `directory`):相对技能数据根的起始目录;省略默认 `{skill_data}/input` +- 字段内联 `lookup`(与 CRUD `options_json` lookup 对象同构;可选 `extra_column`、`multiple`) +- 属性级 `description`(表单标签下方灰字说明) +- 简单模板替换(`{{fieldName}}`) +- 可选空数字字段提交时由宿主省略,避免 `""` 触发 number 校验失败 +- **字段条件显隐 `visibleWhen`**(可选):依赖同表单其它字段值决定是否展示;隐藏字段不进提交、不校验 `required` +- **选择摘要下的映射只读折叠**:`form` +「已选择」时,`inputMapping` 映射且 `readOnly` 的字段默认不渲染(仍提交);`revealInForm: true` 可强制显示;**勿**用 `visibleWhen` 代替 + +### 字段条件显隐(`visibleWhen`) + +用于「先选模式,再出对应参数」类表单。写在 `inputSchema.properties.<field>` 上(**不是** action 顶层字段)。 + +> **消歧义:** 本文的 `visibleWhen` = **Action 参数表单控件显隐**。 +> [`POLICY.md`](POLICY.md) L3 预留的 `visibleWhen` = **行态/权限策略**(未冻结),**不是**同一能力。勿混用、勿互相替代。 + +```json +"keyword": { + "type": "string", + "title": "关键词", + "visibleWhen": { "field": "collectMode", "equals": "按关键词采集" } +}, +"authorUrl": { + "type": "string", + "title": "作者主页链接", + "visibleWhen": { "collectMode": "按作者主页采集" } +} +``` + +支持形态: + +| 写法 | 含义 | +|------|------| +| `{ "field": "x", "equals": "a" }` | `x === a` 时显示 | +| `{ "field": "x", "in": ["a", "b"] }` | `x` 属于集合时显示 | +| `{ "x": "a" }` / `{ "x": ["a","b"] }` | 单键简写,同上 | + +宿主行为(需已支持该字段的匠厂宿主;更旧版本会忽略未知字段 → 参数平铺全显): + +- 数据管理 / 定时任务参数表单按当前值求值并隐藏 +- 若依赖字段自身因 `visibleWhen` 隐藏,则下游字段一并隐藏(避免切模式后残留) +- 提交与服务端 `validateActionInput` 会剥离隐藏字段;隐藏字段不校验 `required` +- 无 `visibleWhen` 的字段保持始终可见(兼容旧技能) + +形状参考:[`assets/examples/visible-when-action.example.json`](../assets/examples/visible-when-action.example.json)。 + +互斥模式**优先**用 `visibleWhen`,不要只靠 `description` 灰字写「仅某某时填写」。 + +**与数据管理「新增 / 编辑」表单的分工(勿混用配置入口):** + +| 表单 | 短标题 | 说明灰字 | 默认值 | 控件类型 | +|------|--------|----------|--------|----------| +| Skill Action 弹窗 | `inputSchema.title` | `inputSchema.description` | `inputSchema.default` | `type` + `display_type`/`format` | +| 数据管理新增/编辑 | `_jiangchang_columns.display_name` | `_jiangchang_columns.description` | 物理列 SQLite `DEFAULT` | `display_type` + 物理类型推断 | + +数据管理**表格**上方说明与列头「?」用 `_jiangchang_tables.grid_help` / `_jiangchang_columns.grid_help`(空则不渲染),**不要**用 `description`。见 [`SCHEMA.md`](SCHEMA.md)「表格说明 `grid_help`」。 + +CRUD 侧宿主义务与禁止事项见 [`SCHEMA.md`](SCHEMA.md)「表单与展示:稳定字段类型」「新增/编辑表单:字段说明与默认值」。同一业务含义两边文案与控件应对齐,但分别配置。 + +示例: + +```json +{ + "inputSchema": { + "type": "object", + "required": ["query"], + "properties": { + "query": { + "type": "string", + "title": "查询条件", + "description": "支持关键词或完整业务编号" + }, + "limit": { + "type": "number", + "title": "返回数量", + "description": "最多返回多少条;填 0 表示不限制(若业务支持)", + "default": 50 + }, + "dueDate": { + "type": "string", + "title": "截止日期", + "display_type": "date", + "description": "日历日,格式 YYYY-MM-DD" + }, + "publishAt": { + "type": "number", + "title": "发布时间", + "display_type": "datetime_unix_seconds", + "description": "提交值为 Unix 秒" + }, + "homepage": { + "type": "string", + "title": "主页链接", + "display_type": "url" + } + } + } +} +``` + +批量 pick 参数约定(有队列表时): + +```json +{ + "pickCount": { + "type": "number", + "title": "处理数量", + "description": "从队列选取「可操作」状态记录的条数(具体状态名由本技能定义,见 WORK_ITEM_STATUS)" + } +} +``` + +默认**不要**把失败/成功/跳过混进该 pick;重做靠人工改回可操作态。详见 [`WORK_ITEM_STATUS.md`](WORK_ITEM_STATUS.md)。 + +若还需要默认篇数,可在同一 `properties` 里为 `pickCount` 增加 `"default": 1`、`"minimum": 1`。 + +约定: + +- `boolean` 在宿主中渲染为开关 +- `enum` 在宿主中渲染为选择框 +- `type: number` 渲染为数字输入 +- `display_type`/`format: date` 渲染为日期(日);`datetime_unix_seconds` 渲染为日期时间并按 **Unix 秒**提交 +- `display_type`/`format: url` / `email` 为薄层输入类型(校验从宽,勿当强校验网关) +- `required` 字段在提交前校验 +- `sensitive` 字段不得出现在低信任入口 +- `readOnly: true`:数据管理表单中标记为只读;**有「已选择」且该字段来自 `inputMapping`(或无 mapping 时的约定 id 字段)时,宿主默认不渲染该控件,预设值仍随提交传入**(与 `confirm` 无参数区对齐信息架构)。无「已选择」时仍只读展示。需要强制露出时加 `revealInForm: true`。**不要**用 `visibleWhen` 藏映射主键(会不提交)。 +- `placements` 含 `batch` 的动作在数据管理表顶常驻,无勾选时禁用(与表删除一致) +- 需要确认的副作用操作使用 `confirmation.message` +- 短名看不懂或会覆盖数据时,用独立的 `hoverHint`;不要把确认黄条复用成悬停 +- 模板参数使用 `{{fieldName}}`,且必须在 `inputSchema.properties` 中声明 +- `entrypoint.args` 项当前只允许 `string`、`number`、`boolean`;不允许对象或 `null` +- **不要**在当前模板中宣传文件上传、任意 JSON 多选数组、`oneOf`、复杂 nested object 等尚未稳定支持的能力;Lookup 多选请用 `lookup.multiple` +## 风险与入口配置 + +- 查询、诊断、版本检查可以放到 `skill-detail` 或 `agent` +- 低风险、常用动作才考虑放 `toolbar`(须 `bind.tables`;executionProfile 按耗时自选) +- 会修改本地数据、访问外部系统、执行长时间任务的动作必须谨慎配置 +- 有副作用的动作应添加 `confirmation.message` +- 高风险动作不应默认出现在 `toolbar` +- 含 `sensitive` 参数的 action 不应放入 `toolbar` 或 `cron`,除非有明确安全设计 +- 不要为了「按钮多」而暴露所有 CLI 子命令 +- 一个 CLI 命令可以拆成多个语义清晰的 action,但每个 action 必须有明确语义,并复用同一业务内核 +- **Agent 必须用 `run_skill_action`,禁止 `exec` 直跑长任务 CLI**(见 `SKILL_ACTION_RUNTIME.md`) +- **禁止**按 `source=toolbar/cron/agent/skill-detail` 在业务代码中分叉业务行为 + +## 模板示例 action 一览 + +| id | CLI | executionProfile | placements | 说明 | +|----|-----|------------------|------------|------| +| `health` | `health` | sync | skill-detail, agent | 运行环境检查 | +| `version` | `version` | sync | skill-detail | 版本 JSON | +| `config-path` | `config-path` | sync | skill-detail | 配置路径 JSON | + +业务技能复制后,按需增加例如: + +| 模式 | id 示例 | executionProfile | placements | bind | +|------|---------|------------------|------------|------| +| 数据同步 | `sync-records` | async 或 sync | toolbar, cron, agent, skill-detail | 必填 tables | +| 单条 RPA | `your-run` | async(建议) | agent, skill-detail | 按需 | +| 队列 pick | `your-run-pick` | async(建议) | toolbar, cron, agent | 有 toolbar 则必填 | +| 统计 | `your-stats` | sync | skill-detail, agent | 无 | + +不需要宿主入口时可删除整个 `actions.json`(无 RPA 长任务时)。 + +## 与宿主 API(参考) + +| 宿主 API | 用途 | +|----------|------| +| `GET /api/skill-actions/{skillSlug}` | 读取 manifest | +| `POST /api/skill-actions/run` | `{ skillSlug, actionId, input?, source? }` | +| `GET /api/skill-actions/jobs` | 任务中心 | + +具体路径与字段以宿主实现为准;技能侧只需保证 manifest 与 CLI 契约稳定。 diff --git a/references/CELL_PRESENTATION.md b/references/CELL_PRESENTATION.md new file mode 100644 index 0000000..a5c9fca --- /dev/null +++ b/references/CELL_PRESENTATION.md @@ -0,0 +1,210 @@ +# 数据管理单元格呈现(Cell Presentation)契约(v1.0) + +> **状态:正式契约 v1.0(`schemaVersion` 1)。** +> **目标:** 冻结「技能声明某表某行某列的语义呈现 → 宿主映射主题样式」的通用能力,供任意业务场景复用。 +> **非目标:** 超时/SLA 专用字段;技能下发任意 CSS / 任意色值;宿主实现业务规则引擎。 +> **配套 Schema:** [`../assets/schemas/skill-cell-presentation.schema.json`](../assets/schemas/skill-cell-presentation.schema.json)。 + +相关正交轴见 [`ACTIONS.md`](ACTIONS.md)、[`POLICY.md`](POLICY.md)、[`SCHEMA.md`](SCHEMA.md)。 + +--- + +## 0. 一句话 + +技能可以声明:**哪张业务表、哪一行、哪一列**需要特殊标色/强调(语义 `tone` + 可选说明)。 +用途不限:时效超时未填、库存预警、状态异常、必填缺失、审核驳回等。 +**不是**「再加一列超时文字」;强调落在**真正要关注的业务格子**上。 + +--- + +## 0.1 能力档位 + +| 档位 | 技能行为 | 宿主行为 | +|------|----------|----------| +| **P0(默认)** | 不写呈现数据 / 未启用 | 数据管理表格与今日一致,无特殊标色 | +| **P1** | 按规则写入呈现文档或推荐旁路表 | 渲染对应单元格的语义样式;可选汇总 `alert: true` 做进页提醒 | + +多数技能保持 **P0**。需要视觉强调时再启用。 + +--- + +## 1. 职责边界 + +| 角色 | 负责 | 不负责 | +|------|------|--------| +| **技能** | 按**当前业务数据**算出哪些格子要强调;在数据变更后**重算**并覆盖旧结果 | 不写宿主 CSS;不把强调文案污染进业务真值列冒充「变红」 | +| **宿主** | 读取呈现结果,按闭集 `tone` 映射主题样式;可选 Toast/提醒汇总 | 不理解「提前 3 天交单」等业务规则;不为单个技能写死样式 | + +**派生状态(重要):** 呈现是计算结果,不是粘性开关。 +用户补填数据 / 同步刷新后技能重算 → 该项消失 → **样式与提醒自然关闭**。不要单独维护「已关闭提醒」状态机(除非产品另做「忽略此条」能力,本契约不冻结)。 + +--- + +## 2. 文档形状(冻) + +技能产出一份 **Cell Presentation Document**(可落库、可 CLI 打印,形状相同),须符合 Schema。 + +```json +{ + "schemaVersion": 1, + "skill": "your-skill-slug", + "table": "business_records", + "rowKey": "id", + "generatedAt": "2026-08-10T06:00:00Z", + "cells": [ + { + "rowId": "42", + "column": "qty", + "tone": "danger", + "message": "库存低于安全线", + "alert": true + }, + { + "rowId": "42", + "column": "status", + "tone": "warning", + "message": "待审核" + } + ] +} +``` + +| 字段 | 必填 | 说明 | +|------|------|------| +| `schemaVersion` | 是 | 固定 `1` | +| `skill` | 是 | 与 slug 一致 | +| `table` | 是 | 当前文档作用的业务表 | +| `rowKey` | 是 | 行匹配列(`id` / `feishu_record_id` 等) | +| `generatedAt` | 否 | 排障用 | +| `cells` | 是 | 数组;**空数组 = 该表清除全部特殊呈现** | + +### 2.1 `cells[]` 项 + +| 字段 | 必填 | 说明 | +|------|------|------| +| `rowId` | 是 | `rowKey` 列值的字符串形式 | +| `column` | 是 | 要强调的**业务列**(snake_case),须真实存在 | +| `tone` | 是 | 见 §3 | +| `message` | 否 | 短说明(≤500);悬停 / 无障碍 | +| `alert` | 否 | 默认 `false`;`true` 可计入进页/同步提醒汇总 | + +同一 `(rowId, column)` 多条:宿主可**后者覆盖前者**。 +`column` 对用户不可见(policy 打码 / 列隐藏)时:宿主可仍应用样式,或跳过——**推荐跳过不可见列的强调**,避免泄露「有问题」的存在感(技能仍可写,宿主按可见性过滤)。 + +--- + +## 3. `tone` 闭集(冻) + +| tone | 语义(技能怎么选) | 宿主怎么呈现(示例,主题可调) | +|------|-------------------|-------------------------------| +| `danger` | 必须立刻处理 / 已违规 / 严重超时 | 强强调(如浅红底) | +| `warning` | 即将到期 / 需注意 | 中等强调(如浅黄) | +| `info` | 提示性标记 | 轻强调(如浅蓝) | +| `success` | 正向完成态(少用在「待填」场景) | 浅绿等 | +| `muted` | 弱化 / 次要 | 灰底或灰字 | + +**禁止:** 技能下发原始 `#RRGGBB`、任意 CSS class、内联 style。 +以后若扩展色板,只能**追加新 tone token** 并升契约,不能开放任意色值。 + +「技能配置什么就显示什么」在本契约中的含义是:技能选择 **哪一格 + 哪个 tone + 什么说明**;**像素级颜色由宿主主题决定**,以保证多技能 UI 一致与安全。 + +--- + +## 4. 推荐持久化(给宿主实现) + +v1 **推荐**技能将「当前表」的呈现写入用户库旁路表(与业务表分离,**不是**多余业务超时列): + +```sql +CREATE TABLE IF NOT EXISTS _jiangchang_cell_presentation ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + table_name TEXT NOT NULL, + row_key TEXT NOT NULL, + row_id TEXT NOT NULL, + column_name TEXT NOT NULL, + tone TEXT NOT NULL, + message TEXT, + alert INTEGER NOT NULL DEFAULT 0, + generated_at TEXT, + updated_at INTEGER NOT NULL DEFAULT (unixepoch()), + UNIQUE (table_name, row_id, column_name) +); +``` + +约定: + +- 重算某表时:**先删该 `table_name` 全部行,再插入新结果**(或等价事务替换),保证填完后旧 `danger` 不会残留。 +- 宿主打开数据管理某表时:若该技能启用了呈现(见 §5)且存在此表,则按当前页行的 `rowKey` 值匹配并着色。 +- 也允许技能用 CLI 打印整份 Document 供调试;**运行时以库内旁路表为准**(除非后续宿主另定拉取入口)。 + +未创建该旁路表 = P0,宿主忽略。 + +--- + +## 5. 在 `actions.json` 中启用(可选,推荐) + +与 `policy` 类似,顶层可选: + +```json +{ + "schemaVersion": 1, + "skill": "your-skill-slug", + "cellPresentation": { + "enabled": true + }, + "actions": [] +} +``` + +| 字段 | 说明 | +|------|------| +| `cellPresentation` | 省略 = P0 | +| `cellPresentation.enabled` | 仅 `true` 表示宿主应尝试加载呈现;`false`/缺省等同 P0 | + +v1 **不**要求单独 CLI entrypoint(与 policy 不同);技能在 `sync` / 写路径 / `init-db` 后自行维护旁路表即可。若未来需要「仅刷新呈现」Action,用普通 Skill Action 即可,不必扩展本启用块。 + +--- + +## 6. 何时重算(技能必须遵守) + +至少在以下时机用**同一套 domain 规则**重算并覆盖该表呈现: + +1. 全量/增量把外部数据写入本地投影之后(如同步成功) +2. 会改变「是否仍需强调」的写路径成功之后(新建、更新、登记等) +3. (可选)用户显式「刷新呈现」类 Action + +进页 Toast:宿主可在加载表后统计当前页或全表 `alert=1` 的条数;**0 则不打扰**。 +提醒关闭 = 重算后没有对应 cell,而不是用户点「我知道了」写死关闭(除非另做忽略列表,本契约不冻)。 + +--- + +## 7. 与其它契约的关系 + +| 契约 | 关系 | +|------|------| +| **SCHEMA / `_jiangchang_columns`** | 管列是否可见、类型、中文名;**不管**条件标色 | +| **POLICY Snapshot** | 管谁能看哪些 Action/行列;呈现仍建议按可见列过滤 | +| **hostEffects / steps** | 管 Action 跑完后是否 `reloadTable`;重载后宿主应重新读呈现 | +| **业务列真值** | 保持干净;不要把 `【超时】` 写进业务字段冒充变色 | + +--- + +## 8. 反模式 + +- 为标色新增「超时环节」「时效状态」等**业务展示冗余列**当作终态方案 +- 技能下发 CSS / 内联颜色 +- 只在首次超时时打标、填完不重算导致红字残留 +- 宿主写死某个技能 slug 的特殊染色逻辑 +- 把 Cell Presentation 做成仅服务 SLA 的专用协议 + +--- + +## 9. 示例场景(说明用途不限) + +| 场景 | column | tone | message 例 | +|------|--------|------|------------| +| 打单超时未填 | `print_done_at` | `danger` | 打单已超 1 天未填 | +| 库存低于安全线 | `qty` | `warning` | 低于安全库存 | +| 审核驳回 | `status` | `danger` | 已驳回,请修改 | +| 选填备注缺失但不紧急 | `notes` | `muted` | 建议补充备注 | + +完整 JSON 例见 [`../assets/examples/skill-cell-presentation.example.json`](../assets/examples/skill-cell-presentation.example.json)。 diff --git a/references/CLI.md b/references/CLI.md new file mode 100644 index 0000000..af63ef6 --- /dev/null +++ b/references/CLI.md @@ -0,0 +1,88 @@ +# 通用业务技能模板 CLI 标准 + +将 `{baseDir}` 替换为技能根目录(含 `SKILL.md`、`scripts/` 的目录)。 + +## 解释器(必读) + +必须使用宿主**共享 Python runtime**(`{JIANGCHANG_DATA_ROOT}/python-runtime/.venv`,已在 PATH / `VIRTUAL_ENV`)。一条命令直接跑,不要先探系统 Python。 + +```bash +python {baseDir}/scripts/main.py <command> … +``` + +| 场景 | 写法 | +|------|------| +| **推荐(Agent)** | `python {baseDir}/scripts/main.py <command> …` | +| 手工排查(环境未注入时) | `{JIANGCHANG_DATA_ROOT}/python-runtime/.venv/Scripts/python.exe`(Windows)或 `…/.venv/bin/python3`(Unix) | + +**禁止**对技能脚本使用 `uv run python …`(会创建临时环境,加载不到 `jiangchang-platform-kit` / `jiangchang_skill_core`)。 +**禁止** Agent 自行 `pip install` / `uv pip install` 往临时环境塞依赖。 + +## Agent 分流(业务技能复制后必改) + +用本表列出**本技能**哪些子命令可走短 CLI,哪些必须走 Skill Action。默认:短查询/只读写本地可用短 CLI;长耗时 / RPA / 需进度取消进**黑名单**。有 async/RPA 的技能:**黑名单不得为空**,且黑名单命令须映射到 `actions.json` 中的 async Action。 + +| 类型 | 本技能命令(自填) | Agent | +|------|--------------------|-------| +| **短 CLI 白名单** | (例)`health` / `version` / `render-html` / `<your-read-or-prep>` | 可用 bash/exec + 共享 `python` | +| **Action 强制黑名单** | (例)`<your-long-or-rpa-command>` | **禁止** bash + `process poll`;必须 `run_skill_action` | + +细节与 Action 清单见 [`ACTIONS.md`](ACTIONS.md)。若提供 HTML 报告,「重新生成」须有 Action id **`render-html`**(可同时进短 CLI 白名单);见 ACTIONS.md「HTML 页面报告」。 + +## 最小命令 + +```bash +python {baseDir}/scripts/main.py health +python {baseDir}/scripts/main.py config-path +python {baseDir}/scripts/main.py version +python {baseDir}/scripts/main.py init-db +``` + +## 标准行为 + +- **任意 CLI 启动前**执行 `bootstrap_skill_config()`(`main.py` 与 `cli.app.main()` 均触发)。 +- **`health`**:只读 runtime 诊断,**不下载、不修复 media-assets,不执行业务动作**;不输出敏感值。 +- **`config-path`**:输出 JSON,包含 `skill`、`env_path`、`example_path`。 +- **`version`**:输出 JSON(`version`、`skill`)。 +- **`init-db`**:幂等创建/迁移本地 SQLite(含 `_jiangchang_*` 展示元数据)。成功输出一行 JSON(`ok` / `skill` / `db_path`),退出码 `0`。宿主可在技能安装或更新成功后静默调用;不要并入 `health`。 +- **`policy`(可选)**:仅当 `actions.json` 顶层启用 Data Policy 时需要;向 stdout 输出 Snapshot JSON(见 `references/POLICY.md`)。模板默认不实现;多数技能保持 L0 即可。 +- **`run` / RPA / 长任务**:CLI 仍是同一内核;**Agent 禁止**用 bash 干等长任务结束。须走宿主 Skill Action(`run_skill_action`),且该 Action 一般为 `executionProfile: "async"` → **任务中心**。长时间无 stdout **不代表卡死**;进度看任务中心 / Run Journal。 +- **排查顺序**(由快到慢): + 1. 任务中心 / Run Journal `{JIANGCHANG_DATA_ROOT}/.jiangchang/runs/{job_id}.jsonl` + 2. 统一日志文件 `{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/logs/jiangchang.log`(含 `trace_id`、`task_failed` 等) + 3. 业务表(宿主数据管理;有才建) + 4. RPA artifacts / `video_log` / 失败截图(开发规范见 `development/RPA.md`);未开启录屏时可能只有截图或日志 +- **录屏默认关闭**:模板 `.env.example` 为 `OPENCLAW_RECORD_VIDEO=0`;若未开启录屏,任务**可能不会**打印录屏路径。 +- 需要录屏时在用户 `.env` 或进程环境变量中设置 **`OPENCLAW_RECORD_VIDEO=1`**;开启后任务完成时 CLI 应打印录屏路径、录屏日志、视频/音频诊断(见 `task_run_support._print_video_summary`),且 `finish()` 含 video 字段。 + +## config-path(配置路径) + +```json +{ + "skill": "your-skill-slug", + "env_path": "{DATA_ROOT}/{USER_ID}/your-skill-slug/.env", + "example_path": "{skill_root}/.env.example" +} +``` + +## health 标准输出(runtime diagnostics) + +`health` 委托 `jiangchang_skill_core.collect_runtime_diagnostics`,典型字段: + +- `python_executable` — 当前 Python 解释器路径(应落在共享 python-runtime) +- `platform_kit_version` / `platform_kit_min_version` / `platform_kit_version_ok` +- `jiangchang_skill_core_file` — 公共库加载来源(若来自技能目录副本会报 warning) +- `media_assets_root`、`ffmpeg_path`、`ffmpeg_available`、`background_music_mp3_count` +- `runtime_issue[warning|error]` — 非致命/致命问题列表 +- `env_path` / `env_exists` / `example_path` — 用户 `.env` 与仓库模板路径 + +## 通用业务命令骨架 + +```bash +python {baseDir}/scripts/main.py run +python {baseDir}/scripts/main.py run -t <target> -i <input_id> +``` + +| 命令 | 用途 | Agent 注意 | +|------|------|------------| +| `run` | 执行一次任务(常为长任务 / RPA) | **走 `run_skill_action`**,不要 bash poll | diff --git a/references/POLICY.md b/references/POLICY.md new file mode 100644 index 0000000..d0afea3 --- /dev/null +++ b/references/POLICY.md @@ -0,0 +1,383 @@ +# Skill Data Policy 契约(v1.0) + +> **状态:正式契约 v1.0(`schemaVersion` 1)。** +> **目标:** 冻结「启用方式 + Snapshot 最小字段 + L2 scope 白名单」,使多数技能(L0)零配置,需要权限的技能(L1/L2)有稳定扩展面。 +> **非目标:** 宿主实现业务用户/角色库;规定飞书/HTTP/本地表等权限数据源;本期不强制 L3(行态 `visibleWhen` 等)。 +> **注意:** L3 预留的行态 `visibleWhen` **不是** Action 参数表单的 `inputSchema.properties.*.visibleWhen`(表单控件显隐,见 [`ACTIONS.md`](ACTIONS.md)「字段条件显隐」)。二者同名不同义,勿混用。 +> **配套 Schema:** [`../assets/schemas/skill-policy-snapshot.schema.json`](../assets/schemas/skill-policy-snapshot.schema.json);启用块见 [`../assets/schemas/skill-actions.schema.json`](../assets/schemas/skill-actions.schema.json)。 + +相关正交轴见 [`ACTIONS.md`](ACTIONS.md)。本契约增加第六轴:**policy(当前身份允许什么)**。 + +--- + +## 0. 能力档位(必须按此理解) + +| 档位 | 技能配置 | 宿主行为 | +|------|----------|----------| +| **L0** | 无 `policy`,或 `policy.enabled !== true` | **与今日完全一致**:不拉 Snapshot,不按身份过滤按钮/行/列 | +| **L1** | `policy.enabled: true`,Snapshot 至少给出 Action 授权 | 数据管理展示 skill Action 前与 allowlist 求交;可选限制宿主内置能力 | +| **L2** | 在 L1 基础上 Snapshot 带 `data.tables[table]` | 列表查询应用声明式 `rows.scope`;列按 `columns` 投影 | +| **L3** | (预留,本契约不冻结行为) | 如**行态** `visibleWhen`、菜单级藏表、`rows.mode=delegate` 等(≠ Action 表单 `inputSchema.visibleWhen`) | + +**80% 技能停留 L0。** 启用 policy 的技能必须实现约定 CLI,并在写路径自行再鉴权(见 §7)。 + +--- + +## 0.1 已冻结最佳实践(v1.0) + +| 议题 | 决定 | 技能开发者怎么做 | +|------|------|------------------| +| Skill Action 授权模式 | **仅** `allowlist` / `open`;v1 **禁止** `denylist` | 有角色就算出允许的 action id 列表;只控行不控按钮时用 `open` | +| `hostCapabilities` | **省略 = 不限制**宿主内置;写出则按 allowlist(`[]` = 全禁内置) | 只控业务按钮时**不要写**该字段;要限导出/删除时再显式列出 | +| scope 运算符 | 保留小白名单(见 §3.4);**禁止** like/raw/嵌套 | 日常优先 `eq` / `in` / `is_null`;比较符按需 | +| `valueFrom` | v1 **仅** `actor.userId` | 角色只在技能内用来算 Snapshot,**不要**用 `actor.roles` 做行过滤 | + +--- + +## 1. 启用字段(冻在 `assets/actions.json` 顶层) + +`actions.json` 根对象增加**可选**字段 `policy`(与 `schemaVersion` / `skill` / `actions` 同级)。 + +### 1.1 形状(L0/L1 必冻) + +```json +{ + "schemaVersion": 1, + "skill": "your-skill-slug", + "policy": { + "enabled": true, + "entrypoint": { + "type": "cli", + "command": "policy", + "args": ["--table", "{{table}}"] + }, + "appliesTo": ["data-management"] + }, + "actions": [] +} +``` + +| 字段 | 必填 | 冻结说明 | +|------|------|----------| +| `policy` | 否 | 缺省 = L0 | +| `policy.enabled` | 若存在 `policy` 则**必填** | 仅 `true` 启用;`false` 等同 L0 | +| `policy.entrypoint` | `enabled: true` 时必填 | 与 Action `entrypoint` 同构:`type` 目前仅 `"cli"` | +| `policy.entrypoint.command` | 是 | 子命令名;**推荐**固定为 `policy` | +| `policy.entrypoint.args` | 是 | JSON 数组;可含占位符,由宿主替换后再拼接 CLI | +| `policy.appliesTo` | 否 | 缺省 `["data-management"]`。本期宿主**只实现** `data-management`;其它值忽略但不报错 | + +### 1.2 占位符白名单(冻) + +| 占位符 | 含义 | 例 | +|--------|------|----| +| `{{table}}` | 当前数据管理选中的**物理表名** | `customs_timelines` | +| `{{skill}}` | 当前技能 slug | 与 manifest `skill` 一致 | +| `{{userId}}` | 当前宿主用户 id | 与注入的 `JIANGCHANG_USER_ID` 一致 | + +未列出的 `{{…}}`:**禁止**;宿主应视为契约错误(fail-closed,见 §6)。 + +### 1.3 显式不放进启用块的内容(防推翻) + +以下**不得**出现在 `actions.json.policy` 中(属于技能内部或 Snapshot): + +- 角色列表、人员表、飞书 app id、外部 IAM URL +- 具体 allowlist / scope(那是 Snapshot 的事) +- 按角色复制的 Action 定义 + +### 1.4 Action 级可选提示(不冻为运行时权威) + +允许(文档/静态检查用),**宿主不得仅凭此显隐按钮**: + +```json +"authz": { "capability": "optional.skill.defined.string" } +``` + +运行时权威仅为 Snapshot §2 的 `actions`。 + +--- + +## 2. Snapshot 最小字段集(`policy` CLI 标准输出) + +- 编码:UTF-8 JSON 对象,写在 **stdout**(可先打日志到 stderr/文件,但 stdout 必须是单 JSON)。 +- 成功退出码 `0`;业务上「无权限看任何东西」仍是 `0` + 合法 Snapshot(例如空 allowlist),不是靠非 0 表示未授权。 +- 非 0 或非合法 JSON → §6 fail-closed。 + +### 2.1 根对象(L0 不出现;L1 必冻) + +| 字段 | 必填 | 类型 | 说明 | +|------|------|------|------| +| `schemaVersion` | 是 | `integer` | 本期冻结为 **`1`** | +| `skill` | 是 | `string` | 必须等于 manifest `skill` | +| `actor` | 是 | `object` | 见 §2.2 | +| `actions` | 是 | `object` | 见 §2.3 | +| `hostCapabilities` | 否 | `string[]` | 见 §2.4;**省略 = 不限制宿主内置按钮**(兼容) | +| `data` | 否 | `object` | 见 §3;省略 = 不限制行/列(L1-only) | +| `extensions` | 否 | `object` | 技能私有扩展;宿主**必须忽略**未知键 | + +根级未知字段:宿主**忽略**;**不得**依赖未写入本契约的字段做安全决策。 + +### 2.2 `actor`(L1 必冻) + +| 字段 | 必填 | 说明 | +|------|------|------| +| `userId` | 是 | 非空;应与 `JIANGCHANG_USER_ID` / `{{userId}}` 对齐 | +| `displayName` | 否 | 展示用 | +| `roles` | 否 | `string[]`,角色**码**;仅供展示 / 技能自检。**不得**用于 v1 scope 的 `valueFrom` | +| `attributes` | 否 | `object`,仅允许 JSON 标量值;本期 scope **不**解引用 | + +### 2.3 `actions`(L1 必冻)— Skill Action 授权 + +**最佳实践:** 角色 → 技能内计算 → `allowlist` 的 id 列表。宿主不理解角色名。 + +```json +"actions": { + "mode": "allowlist", + "ids": ["whoami", "create-record", "register-submit"] +} +``` + +| 字段 | 必填 | 冻结取值 | +|------|------|----------| +| `mode` | 是 | **仅两值**:`"allowlist"` \| `"open"` | +| `ids` | `mode=allowlist` 时必填 | `string[]`;元素为 **`actions.json` 里的 action.id** | +| `ids` | `mode=open` 时 | 必须省略或 `[]`;宿主视为**不限制** skill Action | + +规则: + +1. 宿主先按现有规则得到候选集:`placements` ∩ `bind.tables`(及 batch 等)。 +2. 若 `mode=allowlist`:候选 ∩ `ids`。漏配 id = 少按钮(安全侧失败)。 +3. `ids` 中的未知 id:**忽略**(不报错)。 +4. `mode=open`:等价「L1 不限制按钮」,可与 L2 行列限制并存。 +5. **`denylist` 不是 v1 合法值**;出现则 §6 fail-closed。 + +`replaces` 接管的默认新增/编辑/删除/导入:视为对应 Action 的 id 是否在 allowlist 中;不在则宿主隐藏该接管按钮(导入按钮是否可用还受 `hostCapabilities` 含 `import` 影响)。`mode=open` 时不按 id 限制 replaces。 + +### 2.4 `hostCapabilities`(L1 可选冻) + +控制数据管理**宿主内置**能力,与 skill `action.id` **命名空间分离**。 + +本期枚举冻结:`refresh` | `export` | `view` | `edit` | `delete` | `import`。 + +| 写法 | 含义 | 何时用 | +|------|------|--------| +| **省略字段** | **不限制**(与今日一致) | **默认推荐**:只控 skill 按钮 / 行数据时 | +| 写出非空数组 | 仅允许列出的内置能力(allowlist) | 要限导出、删除等时 | +| 写出 `[]` | 禁止全部上表内置能力 | 极少见;慎用 | + +未知字符串:忽略。**禁止**把「省略」解释成全禁。 + +--- + +## 3. L2:`data` 与 scope 语法白名单 + +### 3.1 `data` 形状 + +```json +"data": { + "tables": { + "customs_timelines": { + "accessible": true, + "columns": { + "visible": ["id", "contract_no", "status"], + "writable": ["status"] + }, + "rows": { + "mode": "scope", + "scope": { + "all": [ + { "field": "owner_user_id", "op": "eq", "valueFrom": "actor.userId" } + ] + } + } + } + } +} +``` + +| 字段 | 必填 | 说明 | +|------|------|------| +| `data.tables` | 若存在 `data` 则建议有 | `key` = 物理表名 | +| `accessible` | 否 | 缺省 `true`。`false`:宿主可拒绝打开该表 | +| `columns.visible` / `writable` | 否 | 物理列名;省略 = 不额外限制 | +| `rows` | 否 | 见下 | + +`writable` 应 ⊆ `visible`;若违规,宿主按交集降级,不 fail-closed。 + +### 3.2 `rows.mode`(冻) + +| 值 | 本期 | 含义 | +|----|------|------| +| `scope` | **实现目标** | 宿主将 `scope` 编译为安全查询约束 | +| `open` | 允许 | 显式不限制行 | +| `delegate` | **仅占位** | 本期未实现时 fail-closed / 提示;**禁止**当 `open` | + +缺省:无 `rows` = `open`。 + +### 3.3 `scope` 布尔结构(冻) + +```text +Scope = + | { "all": Predicate[] } // AND,长度 ≥ 1 + | { "any": Predicate[] } // OR,长度 ≥ 1 + | Predicate // 单个谓词的语法糖 +``` + +**禁止**:嵌套超过一层。 + +### 3.4 `Predicate` 白名单(冻) + +**运算符(宿主必须实现):** `eq` | `ne` | `in` | `not_in` | `gt` | `gte` | `lt` | `lte` | `is_null` | `is_not_null` + +**推荐日常只用:** `eq` / `in` / `is_null`(及 `is_not_null`)。 + +**明确排除:** `like` / `contains` / `sql` / `raw` / `expr`。 + +**`valueFrom` v1 仅:** `actor.userId`。 +`actor.roles` / `actor.attributes.*` **非法** → fail-closed。 + +`value` 与 `valueFrom` 互斥。标量:`string` | `number` | `boolean` | `null`(与 null 比较优先用 `is_null`)。 + +### 3.5 与宿主原有过滤的关系 + +最终行集 = **原有表查询**(搜索、排序、分页、用户 Filter) **AND** policy `scope`。宿主必须把 scope 下推到查询层。 + +--- + +## 4. 宿主消费算法 + +```text +IF manifest.policy?.enabled !== true: + → L0;END + +调用 entrypoint(替换占位符),解析 Snapshot +IF 失败 / 非法 / skill 不匹配 / mode 非法: + → fail-closed(§6);END + +候选 = filter(placements, bind.tables, …) +IF mode == allowlist: 候选 ∩ ids +IF mode == open: 候选不变 + +渲染 skill 按钮 ← 候选 +渲染宿主内置 ← hostCapabilities(省略则不限制) +应用 data.tables[T](若有) +``` + +拉取时机(建议):进入表、切换表、刷新。短缓存不得跨 `userId`。 + +--- + +## 5. 身份与环境 + +| 项 | 约定 | +|----|------| +| 用户主键 | `JIANGCHANG_USER_ID` | +| 技能解析 | 飞书 / 本地表 / HTTP IAM 等,**契约不规定** | + +--- + +## 6. Fail-closed(启用后必冻) + +当且仅当 `policy.enabled === true`: + +| 情况 | 宿主行为 | +|------|----------| +| CLI 非 0 / 超时 / 无 JSON | 不渲染该表 skill 写操作与(建议)导出;明确错误;**禁止**回退 L0 全开 | +| `schemaVersion` ≠ 1 / `skill` 不匹配 / `actions.mode` 非法 | 同上 | +| `rows.mode=scope` 但 predicate 非法 | 不返回行或整表不可用;**禁止**忽略 scope | +| `rows.mode=delegate`(未实现) | 提示不支持;**禁止**当 `open` | + +--- + +## 7. 安全边界 + +1. Snapshot **只**服务宿主 UI 与查询约束。 +2. 写 Action / 敏感读的 **domain service 必须再次鉴权**。 +3. 禁止「只藏按钮」宣称为完整权限方案。 +4. Cron / Agent **不**自动套用本 Snapshot。 + +--- + +## 8. 最小示例 + +模板仓库另有可复制示例: + +- [`../assets/examples/skill-policy-snapshot.allowlist.example.json`](../assets/examples/skill-policy-snapshot.allowlist.example.json) +- [`../assets/examples/skill-policy-snapshot.scope.example.json`](../assets/examples/skill-policy-snapshot.scope.example.json) + +### 8.1 L0 + +不写 `policy` 字段。 + +### 8.2 L1 allowlist(不碰宿主内置) + +```json +{ + "schemaVersion": 1, + "skill": "your-skill-slug", + "actor": { "userId": "u1", "displayName": "张三", "roles": ["clerk"] }, + "actions": { + "mode": "allowlist", + "ids": ["whoami", "create-record", "register-submit"] + } +} +``` + +### 8.3 L1 + 限制导出 + +```json +{ + "schemaVersion": 1, + "skill": "your-skill-slug", + "actor": { "userId": "u1", "roles": ["clerk"] }, + "actions": { "mode": "allowlist", "ids": ["whoami"] }, + "hostCapabilities": ["refresh", "view"] +} +``` + +### 8.4 L1 open + L2 scope + +```json +{ + "schemaVersion": 1, + "skill": "your-skill-slug", + "actor": { "userId": "u1" }, + "actions": { "mode": "open" }, + "data": { + "tables": { + "orders": { + "rows": { + "mode": "scope", + "scope": { + "all": [ + { "field": "owner_user_id", "op": "eq", "valueFrom": "actor.userId" } + ] + } + } + } + } + } +} +``` + +--- + +## 9. 刻意延后 + +| 项 | 备注 | +|----|------| +| `actions.mode: denylist` | 另开 `schemaVersion` | +| `valueFrom: actor.roles` | **明确不做** | +| `visibleWhen` / 嵌套 scope / `like` | L3 或永不做(此处指**行态**策略字段;Action 表单 `inputSchema.visibleWhen` 见 ACTIONS.md,不属本表) | +| `rows.mode: delegate` 宿主实现 | 字段名已占坑 | +| 平台全局 Role UI / cron·agent 套用 Snapshot | 另议 | + +--- + +## 10. 冻结检查清单 + +- [x] L0 缺省无歧义 +- [x] 启用字段仅 `enabled` + `entrypoint` + 可选 `appliesTo` +- [x] `actions.mode` 仅 `allowlist` / `open` +- [x] `hostCapabilities` 省略 = 不限制 +- [x] scope 小白名单;`valueFrom` 仅 `actor.userId` +- [x] fail-closed;写路径技能自检 diff --git a/references/README.md b/references/README.md new file mode 100644 index 0000000..8619000 --- /dev/null +++ b/references/README.md @@ -0,0 +1,27 @@ +# Agent 参考索引 + +本目录只是 Agent **运行、编排、调用** skill 时可渐进加载的资料索引,**不是**用户市场说明。 + +**边界规则:** + +- 本文**不得**包含 YAML frontmatter,**不得**包含 `description` 字段。 +- 技能市场四 Tab 材料**必须**放在根目录:[`README.md`](../README.md)、[`TUTORIAL.md`](../TUTORIAL.md)、[`DEMO.md`](../DEMO.md)、[`CHANGELOG.md`](../CHANGELOG.md)。 +- **Agent 主读本目录**(`CLI.md` / `ACTIONS.md` / `SCHEMA.md`)与根 [`SKILL.md`](../SKILL.md)。 +- [`../development/`](../development/) 是开发者 / 编码助手深度规范,**不是**对话 Agent 的默认读盘路径。 + +| 文档 | 用途 | +|------|------| +| 根目录 [`README.md`](../README.md) | 市场说明 Tab(`readme_md`) | +| 根目录 [`TUTORIAL.md`](../TUTORIAL.md) | 市场教程 Tab(`tutorial_md`) | +| 根目录 [`DEMO.md`](../DEMO.md) | 市场演示 Tab(`demo_video_url` + `demo_md`) | +| 根目录 [`CHANGELOG.md`](../CHANGELOG.md) | 市场更新日志 Tab(版本小节 → `changelog`) | +| [`SKILL.md`](../SKILL.md) | LLM / OpenClaw 平台技能入口与触发摘要 | +| [`CLI.md`](CLI.md) | 共享 Python、命令行入口、参数与短操作契约 | +| [`ACTIONS.md`](ACTIONS.md) | Skill Action、sync/async、任务中心、多入口、按钮中文名 `label` 参考(按入口定位表挂载;先读 `development/PRODUCT_SURFACES.md`) | +| [`SCHEMA.md`](SCHEMA.md) | 本地 SQLite 与数据管理展示契约 | +| [`POLICY.md`](POLICY.md) | Data Policy:可选身份授权、Snapshot、行 scope(多数技能 L0 不配) | +| [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md) | 数据管理单元格呈现:语义 tone 标色/强调(用途不限;多数技能 P0 不配) | +| [`WORK_ITEM_STATUS.md`](WORK_ITEM_STATUS.md) | 业务行生命周期:处理队列的可操作/进行中/终态角色与重做规则(有处理行 Action 时适用) | +| [`../development/`](../development/) | 开发、测试、RPA 深度规范(给人 / 编码助手) | + +按需阅读:短操作优先 `CLI.md`;Action / 任务中心读 `ACTIONS.md`;结构化字段读 `SCHEMA.md`;权限 opt-in 读 `POLICY.md`;格子标色读 `CELL_PRESENTATION.md`;队列处理状态机读 `WORK_ITEM_STATUS.md`。 diff --git a/references/SCHEMA.md b/references/SCHEMA.md new file mode 100644 index 0000000..186eb32 --- /dev/null +++ b/references/SCHEMA.md @@ -0,0 +1,841 @@ +# 数据存储与数据管理展示规范 + +本文是 skill 本地 SQLite 与匠厂宿主「数据管理」页面展示契约的**权威说明**。 + +**产品定位:** 数据管理既是业务数据与过程的可见面,也是与对话**平级**的 **UI 执行面**(表上 Action 走同一 CLI 闭环)。何时挂执行入口见 [`../development/PRODUCT_SURFACES.md`](../development/PRODUCT_SURFACES.md);本文管表结构与展示元数据。 + +实现上的 DDL 与默认中文元数据种子见 `scripts/db/display_metadata.py`;`scripts/db/connection.py` 的 `init_db()` 会一并初始化。 + +## 职责边界 + +### 技能负责 + +- 定义真实 SQLite 表结构 +- 使用英文 `snake_case` 表名和字段名 +- 使用 `_jiangchang_tables` 提供表级展示元数据 +- 使用 `_jiangchang_columns` 提供字段级展示元数据(含可选 `description` 表单说明、可选 `grid_help` 表格说明) +- 需要「新增表单预填」时,在物理列上声明 SQLite `DEFAULT`(勿把默认值只写在文案里) +- 定义哪些字段可见、可搜索、可创建、可编辑 +- 定义字段展示类型和枚举选项 +- (可选)按业务规则维护单元格呈现旁路数据(语义 tone;见 [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md)) +- 保证元数据与真实数据库结构一致 +- 保证元数据初始化幂等 + +### 宿主负责 + +- 读取 `_jiangchang_*` 元数据 +- 读取 SQLite `PRAGMA table_info` +- **数据管理侧栏/表列表**:当库中存在 `_jiangchang_tables` 时,**仅展示**在该表注册且 `visible=1` 的业务表;孤儿物理表(有表、无注册元数据)**不得**出现在侧栏,直接访问 schema API 应返回「表不存在」。极老库无 `_jiangchang_tables` 时,宿主可保留物理表枚举 fallback(兼容 `_schema_meta` 时代) +- 根据 schema 渲染数据管理页面 +- 根据 `editable` 推导字段是否可创建或编辑 +- 在**新增 / 编辑**表单中,若列有 `_jiangchang_columns.description`,在标签下方展示该说明(灰字;空则不显示) +- 在数据管理**表格**中,若表有 `_jiangchang_tables.grid_help` 则在表上方展示可折叠说明;若列有 `_jiangchang_columns.grid_help` 则在列头显示「?」;空/未写则不渲染。**不**把 `description` 当作表格说明 +- 打开**新增**表单时,对可创建字段用 SQLite 列 `DEFAULT`(`PRAGMA table_info.dflt_value`)预填;无 DEFAULT 则留空 +- 执行通用查询、新增、编辑、删除、导入、导出 +- 对字段名、表名、类型和权限进行最终校验 +- 不猜测技能业务含义 +- 不根据具体技能名称写特殊逻辑 +- 可在技能安装/更新成功后静默调用 `python scripts/main.py init-db` 做幂等 ensure(内部即 `init_db()`);失败不影响安装结果 +- 若技能启用了单元格呈现(见 [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md)),读取旁路呈现数据并为单元格映射语义样式(`tone`);不猜测业务规则 + +## 初始化入口 + +幂等建库/迁移:`python scripts/main.py init-db`。业务仓储也可懒触发 `init_db()`,与 CLI 结果一致。 + +SQLite **没有**可靠的 `COMMENT ON TABLE/COLUMN`;SQL 文件里的 `-- 注释` 也不会成为可查询结构。 +因此中文展示名称必须写入元数据表;字段顺序必须写在 `CREATE TABLE` 的列定义顺序中(`PRAGMA table_info` 的 `cid`)。 + +> **禁止**新技能使用宿主的旧版 `_schema_meta` 表。仅 `_jiangchang_tables` / `_jiangchang_columns` 为正式契约。 + +## 技能名称(SKILL.md) + +```yaml +--- +name: 中文业务名称 +description: 中文技能说明 +metadata: + openclaw: + slug: english-kebab-slug +--- +``` + +- `name`:用户可见的中文业务名称。 +- `description`:能力说明,不是技能名称。 +- `metadata.openclaw.slug`:机器标识,kebab-case 英文;目录名与默认数据库文件名使用 slug。slug 语义规范见 [`../development/NAMING.md`](../development/NAMING.md)。 +- 复制模板后不得将 `your-skill-slug`、`My Skill` 等占位内容作为正式技能发布名称。 + +## 数据库路径与命名 + +```text +{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/{skill-slug}/{skill-slug}.db +``` + +- 数据库文件:英文 slug,例如 `your-skill-slug.db`(默认 `{skill-slug}.db`)。 +- 业务表、字段:**英文 snake_case** 物理名,例如 `demo_items`、`created_at`。 +- **禁止**用中文作为 SQLite 真实表名或字段名。 + +## 外部来源只读与本地数据可写 + +某些技能会从外部系统读取数据并写入技能自己的本地数据库。需要区分: + +```text +外部来源数据库: + 可能只读,不应被修改 + +技能本地数据库: + 可以根据业务语义允许新增、编辑或删除 +``` + +不要把「外部来源只读」错误理解成「技能本地数据库一定只读」。技能通过 `_jiangchang_tables.readonly` 与 `_jiangchang_columns.editable` 声明本地表的实际写权限。 + +若创建/更新/删除/导入带有领域副作用(自动建目录、加密、级联清理、归一化字段),不要依赖宿主默认直写:在 `assets/actions.json` 用可选字段 `replaces: create|update|delete|import` 接管默认按钮。其中 **import** 时用户仍使用宿主「导入数据」面板,确认后由技能写库。详见 [`ACTIONS.md`](ACTIONS.md) 与 `assets/examples/replaces-*.action.example.json`。 + +## 表与表之间:禁止 SQLite 外键 + +业务表之间的「属于哪条父记录 / 引用哪条来源」**只存业务 id**(整数或文本标量),**禁止**在 DDL 中写 SQLite `FOREIGN KEY` / `REFERENCES`,也**不要**指望 `PRAGMA foreign_keys = ON` 替你校验或级联。 + +| 要做的事 | 正确做法 | 禁止 | +|----------|----------|------| +| 子表指向父表 | 子表列如 `case_id INTEGER NOT NULL`;写入前在 repository/service 查父表是否存在 | `FOREIGN KEY (case_id) REFERENCES parent(id)` | +| 父表记「最新一版」 | 父表列如 `latest_draft_id INTEGER NOT NULL DEFAULT 0`;由 service 更新指针 | 同上 | +| 删除 / 覆盖 / 级联清理 | repository/service 内显式 `DELETE` / `UPDATE`;需要时用 Action `steps` 的 `deleteByFk` | 依赖 DB 级联或宿主 CRUD 被 FK 拦住 | +| 数据管理里显示名称 | `_jiangchang_columns.display_type = lookup` + `options_json` 对象(见下文「跨表引用 lookup」) | 把 lookup 当成 DDL 外键 | + +**为什么:** 宿主数据管理对业务表执行通用增删改,会直接写 SQLite。一旦 DDL 带外键,删除、迁移 rename 父表、或父表 id 变更时极易出现「本地数据操作失败」且难排查。关联语义属于技能业务,必须在代码里写清。 + +**迁移注意:** 避免「`ALTER TABLE parent RENAME TO parent_old` → 建新 parent → 删 old」这类会隐性改写子表 FK 指向的套路;优先 `ADD COLUMN` + 拷列,或重建子表且**不带** FK。 + +`display_metadata_validator` 与 `validate_template_database` 会扫描业务表 DDL;含 `FOREIGN KEY` / `REFERENCES` 时报错(见 `development/CHECKLIST.md` A6-6)。 + +## 标准数据同步语义(外源 → 本地库) + +适用于「同步 / refresh / collect」类能力。使用通用名称表述,勿把具体平台表名写进模板实现。 + +| 要求 | 说明 | +|------|------| +| 外部来源默认只读 | 技能不得修改外源 | +| 本地 SQLite 可事务写入 | 写入走 repository + 事务 | +| 幂等 | 同一业务键重复同步结果稳定 | +| 稳定唯一业务键 | 明确 upsert / 匹配字段 | +| 全量或增量 | 在实现与文档中显式声明 | +| 完整快照才提交 | 完整性校验通过后再 commit;调用方**必须显式提供**完整性状态(如 keyword-only 必填的 `snapshot_complete`),**禁止**默认视为完整,**禁止**靠数据条数猜测 | +| 空结果 / 读取失败保护 | 读取失败、分页不完整或不可信空结果(`snapshot_complete=False`)**不得**清库或误删;仅当完整快照**显式确认**为空(`snapshot_complete=True` 且结果为空)时,才可按全量失效策略处理旧数据 | +| 全量失效策略 | 物理删除、`removed`/`is_active` 标记、或保留历史——必须三选一并写清 | +| 部分失败 | 不得伪装成整体成功 | +| 半成品 | 事务失败不得留下可见不一致状态 | + +建议统一返回统计: + +```json +{ + "total": 100, + "inserted": 10, + "updated": 20, + "unchanged": 65, + "removed": 5 +} +``` + +### 同步数据 vs 导出当前数据表 vs 特殊业务报告 vs HTML 页面 + +| 能力 | 语义 | 负责方 | +|------|------|--------| +| **同步数据** | 外部来源 → 技能本地数据库 | 技能 Action / domain service | +| **导出当前数据表** | 本地库 → CSV/Excel 等 | 数据管理页**宿主统一导出** | +| **生成特殊业务报告(文件)** | 多表合并、特殊排版或附加计算的文件 | 仅当系统导出无法满足时,才提供独立 `export-*`;落 `output/` | +| **HTML 页面报告** | 侧栏可预览的整页 HTML | 写入 `html/` + Action id **`render-html`**(见 [`ACTIONS.md`](ACTIONS.md)) | + +同步型 Action 默认只写库、不生成导出文件。兼容旧 export 命令需要刷新时,必须调用同一 sync 内核。可复用测试范式见 `tests/samples/test_sync_contract.py.sample`。 + +## 元数据表(宿主兼容) + +宿主读取字段(与匠厂 `metadata-resolver` 一致): + +### `_jiangchang_tables` 表级标准 + +| 字段 | 必填 | 说明 | +|------|------|------| +| `table_name` | 是 | 真实英文表名 | +| `display_name` | 是 | 用户可见的中文业务名称 | +| `description` | 否 | 表说明(表单/备注用途);**不**用于表格上方横幅 | +| `grid_help` | 否 | 数据管理表格上方说明;空/未写则不出横幅。与 `description` 独立,禁止互拷(见下节) | +| `sort_order` | 否 | 兼容保留字段,不用于替代物理字段顺序 | +| `visible` | 是 | `1` 在宿主数据管理中展示 / `0` 隐藏 | +| `readonly` | 是 | `1` 整张表只读;`0` 允许宿主根据字段权限执行写操作 | +| `default_sort_json` | 否 | 数据管理**默认行排序**(见下节);空则宿主用内置默认 | +| `default_visible_columns_json` | 否 | 数据管理**默认展示列**(见下节);空则全显 | + +#### 表级默认排序 `default_sort_json`(宿主支持) + +技能可声明该表在数据管理中的默认排序;宿主优先采用,未声明或声明无效时回退: + +`updated_at DESC` → `created_at DESC` → 主键 DESC。 + +JSON 数组(可多字段,按顺序): + +```json +[{"column":"sent_at","direction":"desc"},{"column":"id","direction":"desc"}] +``` + +规则: + +- `column` 必须是该表真实列名;敏感列会被宿主忽略 +- `direction` 仅 `asc` / `desc`(缺省按 `desc`) +- 用户在 UI 上手动改排序后,以用户选择为准;声明只影响未选择时的默认 +- 通过 `upsert_table_metadata(..., default_sort_json=...)` 写入;旧库由 `init_display_metadata_tables` 幂等 `ALTER` 补列 + +示例(消息时间线): + +```python +upsert_table_metadata( + cursor, + table_name="messages", + display_name="消息", + readonly=0, + default_sort_json='[{"column":"sent_at","direction":"desc"},{"column":"id","direction":"desc"}]', +) +``` + +#### 表级默认展示列 `default_visible_columns_json`(宿主支持) + +技能可声明该表在数据管理表格中的**默认展示列**(软默认,不是硬隐藏): + +- **已配置**:首次打开(用户尚未改过「列」)只显示名单中的列;其余列仍在「列」面板中,用户可勾回 +- **未配置 / 空 / 无效**:显示全部 schema 可见列(与今日行为一致) +- **用户改过「列」或表头「隐藏此列」后**:以本机偏好为准;「恢复默认列布局」后重新回到技能默认(若有) + +JSON 数组(物理列名;也可写 `{"column":"col"}` 对象,宿主同样接受): + +```json +["stat_type","period","stage","ops_count","timeout_ratio"] +``` + +规则: + +- 列名必须是该表真实列,且 `_jiangchang_columns.visible=1`(硬隐藏列本来就不进表格) +- 与 `visible=0` **不同**:`visible=0` = 列从数据管理 schema /「列」面板移除;本字段只影响默认勾选状态 +- 名单为空或全部无效时,宿主 fail-open:全显 +- 通过 `upsert_table_metadata(..., default_visible_columns_json=...)` 写入;旧库由 `init_display_metadata_tables` 幂等 `ALTER` 补列 + +示例(时效统计:默认不展示编号与时间戳列): + +```python +upsert_table_metadata( + cursor, + table_name="timeliness_stats", + display_name="时效统计", + readonly=1, + default_visible_columns_json=( + '["stat_type","period","stage","ops_count",' + '"timeout_open","timeout_done","timeout_total","timeout_ratio"]' + ), +) +``` + +#### 表格说明 `grid_help`(宿主支持) + +技能可给数据管理**表格**写说明,与新增/编辑表单的 `description` **完全独立**: + +| 写在哪 | 宿主做什么 | 空/未写 | +|--------|------------|---------| +| `_jiangchang_tables.grid_help` | 表格上方可折叠横幅(首行摘要;有后续行才出展开) | 不出横幅 | +| `_jiangchang_columns.grid_help` | 列头「?」,点击查看该列说明 | 不出「?」 | + +规则: + +- **opt-in**:大多数表/列不需要写;不写则行为与今日一致 +- **禁止**把 `description` 拷进 `grid_help`,也禁止把表格规则写进 `description`(`description` 只给表单灰字) +- 多行文本时:第一行是摘要,其余行点「展开」后可见 +- 通过 `upsert_table_metadata(..., grid_help=...)` / `upsert_column_metadata(..., grid_help=...)` 写入;旧库由 `init_display_metadata_tables` 幂等 `ALTER` 补列 +- 需已支持该字段的匠厂宿主;更旧版本会忽略未知列,不写则无影响 + +示例(仅在确有表格规则时写;模板默认业务表不要预填): + +```python +upsert_table_metadata( + cursor, + table_name="timeliness_records", + display_name="时效登记", + readonly=1, + grid_help="超时按环节规则判定。\n交单:预计到港日前 3 天须交单", +) +upsert_column_metadata( + cursor, + table_name="timeliness_records", + column_name="submit_date", + display_name="交单日期", + grid_help="预计到港日前 3 天须交单", + editable=0, +) +``` + +禁止: + +- 使用中文真实表名 +- 使用 `_schema_meta` 作为新的正式契约 +- 只写英文物理名而不写中文 `display_name` +- 让元数据引用不存在的表 +- 用 `visible=0` 假装「默认不展示但用户还能勾出来」(那是硬隐藏,勾不回来) + +### `_jiangchang_columns` 字段级标准 + +| 字段 | 必填 | 说明 | +|------|------|------| +| `table_name` | 是 | 所属物理表 | +| `column_name` | 是 | 真实英文字段名 | +| `display_name` | 是 | 用户可见的中文字段名(表头与表单标签);保持简短 | +| `description` | 否 | 字段说明(给人看的短文案);**宿主在新增/编辑表单标签下方展示**(见下节);不要塞进 `display_name` | +| `grid_help` | 否 | 数据管理表头「?」;空/未写则不出。与表单 `description` 独立,禁止互拷 | +| `display_order` | 否 | 兼容字段,默认保持 `NULL` | +| `visible` | 是 | 是否进入数据管理 schema /「列」面板(`0` = **硬隐藏**,用户无法勾回;默认不展示但可勾回请用表级 `default_visible_columns_json`) | +| `searchable` | 是 | 是否参与**列筛选**与**顶栏全局搜索**(`0` = 两者均不参与);详见下节「数据管理:列筛选与全局搜索」 | +| `editable` | 是 | `1` 业务字段允许宿主用于新增和编辑;`0` 系统/只读字段 | +| `display_type` | 否 | 字段渲染类型;稳定取值见下节「表单与展示:稳定字段类型」 | +| `options_json` | 否 | 枚举选项 JSON | + +这两张表是**数据字典**,不是业务数据;宿主不会把它们当作普通业务表展示。 + +#### 表单与展示:稳定字段类型(宿主支持,须与 Action 对称) + +数据管理「新增 / 编辑」表单与 Skill Action 参数表单应使用**同一套控件语义**(配置入口不同,见 [`ACTIONS.md`](ACTIONS.md))。新技能只使用下表;宿主须两边都实现,避免「Action 有、CRUD 没有」。 + +**本版稳定支持(须齐):** + +| `display_type`(CRUD) | 物理类型建议 | 列表展示 | 新增/编辑控件 | 存库约定 | +|------------------------|--------------|----------|---------------|----------| +| `text`(或缺省) | `TEXT` / 一般标量 | 原文 | 单行文本 | 原文 | +| `textarea` | `TEXT` | 原文(可截断) | 多行文本 | 原文 | +| `json` | `TEXT` | 原文 | 多行文本(JSON 字符串) | 原文;提交时宿主可做基础 JSON 合法性校验 | +| `number` | `INTEGER` / `REAL` / `NUMERIC` | 原文 | **数字输入**(`type=number`) | 数字;无 `display_type` 时,物理类型为 INT/REAL/NUMERIC 也应走数字控件 | +| `boolean` | `INTEGER` 0/1 或 BOOL | 开关语义展示 | 开关 | `0` / `1` | +| (枚举) | 任意 + `options_json` **数组** | 标签映射 | 下拉选择 | 选项 `value` | +| `lookup` | 跨表引用 id;`options_json` **对象**;可选 `multiple` | 单选:标签映射;多选:多标签(如「豆包、元宝」) | 单选下拉;多选见下文 | 单选:业务 id 标量;多选:`TEXT` 逗号分隔 id(非 SQLite 外键) | +| (敏感) | 任意;列 `sensitive=1` | 掩码/隐藏策略由宿主定 | 新建可用密码框;编辑策略与既有宿主一致 | 原文(勿日志明文) | +| `date` | `TEXT` 推荐 `YYYY-MM-DD`,或可安全归一的值 | 日期 | **日期选择**(日) | `YYYY-MM-DD` | +| `datetime_unix_seconds` | `INTEGER` | `YYYY-MM-DD HH:mm:ss` | **日期时间选择**;提交写回 **Unix 秒** | Unix **秒**整数;禁止改成 ISO 字符串 | +| `url` | `TEXT` | 原文(可作链接) | 单行输入 `type=url`(薄层;校验从宽) | 原文 | +| `email` | `TEXT` | 原文 | 单行输入 `type=email`(薄层;校验从宽) | 原文 | + +说明灰字与创建默认值见下一小节;与 Action 的对照写在 [`ACTIONS.md`](ACTIONS.md)「表单字段类型对照」。 + +**明确不在本版稳定范围(勿在新技能宣传为已支持):** + +- 文件 / 图片上传 +- 复杂 `oneOf` / nested object 表单(任意 JSON 数组当业务多选控件) +- 把业务可编辑时间存成毫秒、ISO 文本或 SQLite `datetime` 文本(审计字段规范见「标准时间字段」) + +> **Lookup 多选**已纳入本版契约(见下文 `multiple`),与「任意多选/标签数组」不是同一能力。 + +`datetime_unix_milliseconds`:仅兼容**列表展示**旧数据;**新技能不要用**;可编辑时间一律 `datetime_unix_seconds`。 + +#### 新增/编辑表单:字段说明与默认值(宿主支持) + +数据管理「新增记录 / 编辑记录」与 Skill Action 弹窗是**两套表单**;CRUD 侧约定如下(交互对齐 Action 的 `inputSchema.description` / `default`,但**配置入口不同**)。 + +| 能力 | 技能写什么 | 宿主做什么 | +|------|------------|------------| +| 字段说明 | `_jiangchang_columns.description`(可选) | 新增/编辑表单中,在标签下方以灰字展示;空则不显示 | +| 创建默认值 | 物理列 SQLite `DEFAULT`(如 `limit_n INTEGER NOT NULL DEFAULT 0`) | 打开「新增」时,对可创建字段预填该默认值;无 DEFAULT 则留空 | +| 编辑已有行 | — | **不**用 DEFAULT 覆盖已有行数据 | +| 表格列头标题 | `display_name` | 短名;不要把长说明塞进列头文字 | +| 表格列头「?」 | `_jiangchang_columns.grid_help`(可选) | 有内容才显示「?」;**不**回退 `description` | +| 表格上方说明 | `_jiangchang_tables.grid_help`(可选) | 有内容才显示横幅;**不**回退表级 `description` | + +规则与禁止: + +- **推荐**对易填错、语义特殊的可编辑字段写 `description`(示例链接、`0` 表示不限制等);非强制每个字段都有 +- **禁止**把说明硬塞进 `display_name`(如 `评论上限(0不限)`);表头会变脏,也无法与 Action 说明对齐 +- 业务语义(如「0 = 不限制」)写在 `description` 里;宿主只展示,**不**解读业务含义 +- 第一期**不**新增元数据「表单默认值」列;CRUD 默认值 = DDL `DEFAULT`,与 Action 的 `inputSchema.default` 分开配置 +- 同一业务字段若同时出现在 Action 与新增表单,**文案应对齐**(短标题 + 说明),但分别写在 `actions.json` 与 `_jiangchang_columns` / DDL +- 数字类可编辑列推荐 `display_type: "number"`(或依赖 INT/REAL/NUMERIC 推断)并在 DDL 写清 `DEFAULT`(如条数上限默认 `0`) + +示例(可编辑列种子 + DDL 默认): + +```sql +-- 物理列:创建默认 0 +comment_limit INTEGER NOT NULL DEFAULT 0 +``` + +```python +seed_column_metadata_batch( + cursor, + table_name="videos", + columns=( + { + "column_name": "video_url", + "display_name": "视频链接", + "editable": 1, + "display_type": "url", + "description": "填平台视频页链接;需包含可识别的视频路径片段。", + }, + { + "column_name": "comment_limit", + "display_name": "采集评论条数", + "editable": 1, + "display_type": "number", + "description": "最多采多少条。填 0 = 不限制;填正整数则按该数量封顶。", + }, + ), + valid_column_names=physical, +) +``` + +Action 参数表单的 `title` / `description` / `default` / `display_type` 见 [`ACTIONS.md`](ACTIONS.md);勿与本节 CRUD 配置入口混用。 + +#### 数据管理:列筛选与全局搜索(宿主支持) + +数据管理表格提供两类与 `searchable` 相关的检索能力,技能通过 `_jiangchang_columns.searchable` 声明是否参与: + +| 能力 | 用户入口 | `searchable = 1` 时 | `searchable = 0` 时 | +|------|----------|---------------------|---------------------| +| **列筛选** | 表格「筛选」→ 字段 / 操作符 / 值 | 出现在「字段」下拉,可按类型选值与操作符 | 不出现在列筛选字段列表 | +| **全局搜索** | 顶栏搜索框 | 参与 OR 模糊匹配(宿主对参与列数有上限,如最多 8 列) | 不参与全局搜索 | + +**存库 vs 展示(核心契约)** + +- **列表**:有 `options_json` 时展示 **label**(如「启用」「停用」) +- **筛选 / 查询 / 全局搜索**:比较与匹配 **value**(如 `0`/`1`、`active`、`toutiaohao`) +- 宿主负责 label ↔ value 映射;**禁止**为迁就 UI 把存库改成中文枚举,也**禁止**在 `description` 里写「筛选请填 0/1」 + +**筛选栏控件 ≠ 新增/编辑表单(勿混用)** + +列筛选「值」与表单使用同一套**类型语义**,但控件形态不同:筛选栏须与「字段」「操作符」同一行工具栏对齐(如统一 `h-9` 下拉),**枚举 / boolean / lookup 在筛选栏一律用下拉 Select**,不用新增/编辑表单里的开关或横向分段单选。表单侧仍按上表「新增/编辑控件」配置。 + +| 列类型 | 筛选「值」控件 | 常用操作符(宿主按类型裁剪) | 技能须配置 | +|--------|----------------|------------------------------|------------| +| 文本 / `url` / `email` | 文本框 | 等于 / 包含 / 开头 / 结尾 / 为空… | 通常 `searchable: 1` | +| 数字 | 数字框 | 等于 / 大于 / 小于… | `display_type: number` 或 INT/REAL | +| 枚举(`options_json` **数组**) | 下拉(展示 label) | 等于 / 不等于 / 为空 | **`options_json` 必填** + `searchable: 1` | +| `boolean` | 下拉(有 `options_json` 用其 label;否则宿主默认可选「是/否」) | 等于 / 不等于 / 为空 | `display_type: boolean`;**推荐同时写 `options_json`** 指定中文 label | +| `lookup` 单选 | 下拉 | 等于 / 不等于 / 为空 | `display_type: lookup` + lookup 对象 | +| `lookup` + `multiple: true` | 列筛选 UI 演进中;**全局搜索**已支持 | 等于时按逗号分隔 id 的 token 匹配 | 多选存 `TEXT` 逗号分隔 id | +| `date` / `datetime_unix_seconds` | 日期 / 日期时间 | 比较类 | 时间列通常 `searchable: 0` | +| 敏感列 | **不可筛** | — | 宿主强制排除 | + +**全局搜索扩展(用户可搜中文)** + +宿主在 `searchable = 1` 的列上除原文 `LIKE` 外,还应: + +- 有 `options_json` 枚举:**label 或 value** 均可命中 +- `boolean` 无 options:认「是/否」等常见别名 +- `lookup` 多选:按逗号分隔 id 的 **token 边界** 匹配;选项 label 亦可参与 +- `datetime_unix_seconds`:搜 `YYYY-MM-DD` 形态可匹配当天 Unix 秒区间 + +**技能配置建议** + +推荐: + +- 用户常筛的状态列(启用/停用、待生成/已生成、`status` 等)→ `searchable: 1` + `options_json`(value 与 DDL 存库一致) +- `boolean` 业务列:`display_type: boolean` **且** `options_json` 写中文 label(示例见下) + +禁止: + +- 仅为筛选把存库 value 改成中文 +- 在文档或 `description` 要求用户记内部 magic number / 英文 code + +示例(启用开关 + 中文筛选): + +```python +{ + "column_name": "enabled", + "display_name": "是否启用", + "visible": 1, + "searchable": 1, + "editable": 1, + "display_type": "boolean", + "options_json": json.dumps( + [{"value": 0, "label": "停用"}, {"value": 1, "label": "启用"}], + ensure_ascii=False, + ), +} +``` + +Action 参数表单**无**列筛选对称能力;筛选语义以本节为准,表单对照见 [`ACTIONS.md`](ACTIONS.md)「表单字段类型对照」。 + +## editable 与 createable 的关系 + +技能模板只维护 `editable` 这一份字段权限声明: + +```text +skill metadata: + editable + +host runtime: + createable / editable +``` + +宿主根据以下因素计算字段是否能够出现在新增或编辑表单中: + +- 表级 `readonly` +- 字段 `editable` +- 主键 +- generated 字段 +- sensitive 字段 +- BLOB 类型 +- not-null 约束 +- default value + +通用规则: + +```text +新增表单: + editable = 1 + 且不是主键 + 且不是 generated + 且不是敏感字段 + 且不是 BLOB + +编辑表单: + editable = 1 + 且不是主键 + 且不是 generated + 且不是敏感字段 + 且不是 BLOB +``` + +若一张表: + +```text +readonly = 0 +但所有字段 editable = 0 +``` + +则: + +- 这张表技术上可写,但不适合手工新增或编辑 +- 宿主不应打开空白新增表单 +- 技能应通过自身业务流程产生数据 +- 这不是宿主猜测业务,而是技能通过元数据声明的结果 + +## 系统字段标准 + +通常保持 `editable = 0` 的字段类型包括: + +- 自增主键 +- generated 字段 +- `created_at` / `updated_at` +- 同步时间戳 +- 内容哈希 +- 计算结果 +- 内部状态 +- 删除标记 +- 审计字段 +- 敏感凭据或密钥字段 + +不要把具体业务字段名写死;按字段类型和职责设置 `editable`。 + +## 表结构标准 + +- 业务表和字段使用英文 `snake_case` +- 可写表优先使用单字段主键 +- 自增主键优先放在 `CREATE TABLE` 第一列 +- `created_at` 和 `updated_at` 放在业务字段末尾 +- 字段展示顺序以 `CREATE TABLE` 和 `PRAGMA table_info(cid)` 为准 +- 不依赖 `display_order` 重新排列字段 +- 不要让宿主通过英文字段名推断展示顺序 +- 复杂字段需要通过 `display_type` 或 `visible` 明确处理 + +推荐顺序(可按业务裁剪,但已有字段应遵守相对位置): + +```text +1. 主键 id +2. 关联标识与核心业务字段 +3. 普通业务字段 +4. 状态、结果、错误等辅助字段 +5. created_at(如有) +6. updated_at(如有) +``` + +## 标准时间字段(新技能统一规范) + +新技能业务表若需要时间审计字段,**统一**采用 Unix **秒级**时间戳,不要使用毫秒时间戳、ISO 字符串或 SQLite 文本 datetime。 + +| 项目 | 规范 | +|------|------| +| 物理字段 | `created_at INTEGER`、`updated_at INTEGER` | +| 存储格式 | Unix 秒级整数 | +| 展示元数据 `display_type` | `datetime_unix_seconds` | +| 中文名 | `created_at` → **创建时间**;`updated_at` → **更新时间** | +| 用户编辑 | **审计字段** `editable = 0`(系统维护,列表只展示格式化结果,**不**进可编辑表单) | +| 字段顺序 | 位于业务字段末尾:`… → created_at → updated_at` | +| 排序 | **不使用** `display_order`;顺序仅来自 `PRAGMA table_info` | + +### 业务可编辑时间 vs 审计时间 + +| 场景 | `editable` | 列表 | 新增/编辑表单 | +|------|------------|------|----------------| +| `created_at` / `updated_at` 等审计列 | `0` | 格式化为 `YYYY-MM-DD HH:mm:ss` | 不出现(或只读,由宿主既有规则) | +| 业务时间列(预约、截止、活动开始等) | `1` | 同上格式化 | **日期时间控件**;提交写入 Unix **秒**整数 | + +宿主对 `display_type = datetime_unix_seconds`: + +- **列表**:格式化为 `YYYY-MM-DD HH:mm:ss` 展示,**不**改库内原始整数 +- **可编辑表单**:提供日期时间选择;保存时写回 Unix **秒**(不要写 ISO 字符串、不要写毫秒) + +### 自动生成与维护 + +- **created_at**:`INTEGER NOT NULL DEFAULT (unixepoch())`;插入时可省略,由数据库生成。 +- **updated_at**:`INTEGER NOT NULL DEFAULT (unixepoch())`;插入时同样可省略。 +- **updated_at 更新**:业务表通过 SQLite trigger `{table}_set_updated_at` 在业务字段更新后自动刷新(`init_updated_at_trigger`)。 + +实现参考: + +- DDL 与 trigger:`scripts/db/connection.py`、`scripts/db/timestamp_columns.py` +- 中文元数据种子:`scripts/db/display_metadata.py` +- 自检:`scripts/db/display_metadata_validator.py` + +### 系统自动写入表的只读约定 + +技能内部**系统自动写入**、不供用户在宿主数据管理中手工维护的表,应声明: + +| 层级 | 值 | 说明 | +|------|-----|------| +| 表级 `readonly` | `1` | 宿主数据管理只读展示,不提供新增/编辑/删除表单 | +| 相关业务字段 `editable` | `0` | 元数据权限只约束宿主 UI,不限制技能代码写库 | + +模板**默认不建**此类审计表。过程看 `jiangchang.log`,进度/终态看任务中心,业务结果看业务表。 + +### 业务行生命周期(处理队列) + +若技能对某一业务行做「生成 / 触达 / 私信 / 发布」等有副作用处理,状态字段须遵守 **角色模型**(可操作 / 进行中 / 成功·失败·跳过类终态),**不要**把某一技能的状态名当成全平台唯一枚举。默认只处理可操作态;重做靠人工改回可操作。完整规则见 [`WORK_ITEM_STATUS.md`](WORK_ITEM_STATUS.md)。 + +建议: + +| 项 | 说明 | +|----|------| +| 状态列 | `options_json` 列出本技能全部业务状态(含进行中与各类终态) | +| 失败原因 | 失败类终态应有用户可读的原因字段(勿写密钥) | +| 可编辑 | 终态/进行中建议可改回可操作,或提供等价 Action | +| `grid_help` | 可一句话说明「仅某某状态可处理」 | + +表级 `readonly = 1` 时,宿主数据管理只读展示;技能 repository 仍可写入。 + +### 通用示例:`demo_items`(测试 fixture 参考) + +以下表结构用于模板测试演示数据管理契约,**不是**模板默认业务表: + +```sql +CREATE TABLE demo_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + description TEXT, + status TEXT NOT NULL DEFAULT 'draft', + generated_value TEXT, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) +); +``` + +对应元数据要点: + +| 物理字段 | editable | 说明 | +|----------|----------|------| +| id | 0 | 自增主键 | +| title | 1 | 业务字段 | +| description | 1 | 业务字段 | +| status | 1 | 枚举,`options_json` 演示 | +| generated_value | 0 | 系统/计算字段 | +| created_at | 0 | 系统时间 | +| updated_at | 0 | 系统时间 | + +## 枚举字段 options_json + +`options_json` 支持两种形态: + +### 1)静态枚举(数组) + +每项包含 `value` 与 `label`: + +```json +[ + {"value": "draft", "label": "草稿"}, + {"value": "published", "label": "已发布"}, + {"value": "archived", "label": "已归档"} +] +``` + +宿主据此渲染选择框;技能侧负责保证 JSON 合法且与物理字段取值一致。 + +### 2)跨表引用 lookup(对象,用于关联 id 字段) + +当字段取值依赖同库另一张业务表时,`display_type` 设为 `lookup`,`options_json` 写对象(**不是**数组)。这是**展示与表单选择**契约,**不是** SQLite 外键。 + +**单选(默认,与既有行为一致):** + +```json +{ + "ref_table": "platforms", + "value_column": "platform_key", + "label_column": "display_name", + "extra_column": "category", + "filter_column": "enabled", + "filter_value": 1 +} +``` + +**多选(平行扩展,仅多一个开关):** + +```json +{ + "ref_table": "engines", + "value_column": "id", + "label_column": "display_name", + "filter_column": "enabled", + "filter_value": 1, + "multiple": true +} +``` + +| 字段 | 必填 | 说明 | +|---|---|---| +| `ref_table` | 是 | 同库被引用表名(snake_case 标识符) | +| `value_column` | 是 | 写入当前字段的值列(业务 id / 编号) | +| `label_column` | 是 | 选项主文案列(名称) | +| `extra_column` | 否 | 第三展示列(不必叫「备注」);有值时拼进括号 | +| `filter_column` / `filter_value` | 否 | 等值过滤(如只列出 `enabled=1`) | +| `multiple` | 否 | `true` = 多选 Lookup;省略或 `false` = **单选**(默认) | + +宿主在加载表 schema 时查询 `ref_table`,把结果展开成 `{value,label}` 选项列表。 + +**存库约定:** + +| 模式 | 物理列建议 | 存什么 | +|------|------------|--------| +| 单选 | 整数或文本标量 | 一个业务 id(如 `2`、`douyin`) | +| 多选 | **`TEXT`** | 逗号分隔 id,如 `1,2,5`(与 `$selection.ids` 风格一致);**不要**建联接子表当 UI 多选 | + +**统一展示文案(宿主固定一种,技能只配列):** + +- 单选默认:`{value} · {label}`,例如 `2 · 通用私信-留资`、`douyin · 抖音` +- 配置了 `extra_column` 且该行有非空值:`{value} · {label}({extra})` +- 未配置 `extra_column`、或额外列为空:不加 `()` +- `label` 为空或与 `value` 相同时:只显示 `value`(可再带额外括号) +- **多选列表格**:按 id 拆开后逐个映射为 label,再用顿号/逗号拼接(如 `豆包、元宝`);过长可截断并提示「+N」 + +**控件呈现(宿主策略,非第二套契约):** + +- 单选:下拉 Select(现状) +- 多选且选项较少(建议 ≤6):勾选列表 +- 多选且选项较多:字段内 Chip + 可搜索下拉(**不是**再开一层业务弹窗) + +Action 弹窗内联 Lookup 与上表 **同构**(字段名一致,含 `multiple`),见 [`ACTIONS.md`](ACTIONS.md)「跨表引用:Lookup」。 + +示例:账号表的平台字段 → 平台信息表;凭据表的账号编号 → 账号信息表;私信 Action 选话术 → `message_templates`;抽检 Action 多选引擎 → `engines` + `multiple: true`。 + +## 幂等初始化标准 + +- `_jiangchang_tables` 和 `_jiangchang_columns` 必须通过 upsert 初始化 +- 多次执行初始化不能重复记录 +- 重新初始化可以更新展示名、描述、`grid_help`、`visible`、`searchable`、`editable`、`display_type`、`options_json` +- 元数据不能残留旧字段(删除或重命名字段时必须同步清理) +- 元数据不能引用不存在的表或字段 +- 新增字段或表时必须同步增加元数据 +- 不要直接修改用户真实数据库作为迁移方式 + +实现 helper: + +- `upsert_table_metadata` / `upsert_column_metadata` — 幂等写入 +- `seed_column_metadata_batch` — 批量 upsert;**可选**传入 `valid_column_names` 时在 upsert 后自动 prune +- `prune_stale_column_metadata` — 清理已删除物理字段的元数据 + +### 新增业务表时如何初始化 + +1. 在 `connection.py`(或迁移脚本)中 `CREATE TABLE` 真实物理表。 +2. 用 `PRAGMA table_info` 或 `list_physical_column_names` 取得**真实物理字段列表**(顺序以 `cid` 为准)。 +3. 调用 `upsert_table_metadata` 写入表级元数据。 +4. 调用 `seed_column_metadata_batch`,并传入 `valid_column_names=physical_column_names`: + +```python +from db.display_metadata import list_physical_column_names, seed_column_metadata_batch, upsert_table_metadata + +physical = list_physical_column_names(cursor, "demo_items") +upsert_table_metadata(cursor, table_name="demo_items", display_name="示例记录", readonly=0) +seed_column_metadata_batch( + cursor, + table_name="demo_items", + columns=DEMO_ITEMS_COLUMN_METADATA, + valid_column_names=physical, +) +``` + +传入 `valid_column_names` 时:先 upsert,再删除不在物理表中的陈旧 `_jiangchang_columns` 行。 + +### 字段删除或重命名时如何清理 + +1. 在 DDL / 迁移中完成物理字段删除或重命名。 +2. 重新执行 `seed_column_metadata_batch(..., valid_column_names=当前物理字段列表)`,自动 prune 旧字段元数据。 +3. 或单独调用 `prune_stale_column_metadata(cursor, table_name, valid_column_names)`。 + +**不传入 `valid_column_names` 时不会自动 prune**——模板不会猜测真实物理字段,避免误删;文档与实现均以此为准。 + +不要直接修改用户真实数据库作为迁移方式;元数据初始化必须使用真实物理字段列表。 + +## 表增删改与退役(技能 schema 迁移) + +技能拥有 SQLite 物理 schema;宿主只渲染**已注册**的元数据。增删改表时技能须在 `init_db()` 中做幂等迁移,并与 `_jiangchang_*` 同步。 + +| 操作 | 技能必须做 | +|------|------------| +| **新增**用户可见表 | `CREATE TABLE` + `upsert_table_metadata` + 列元数据种子 | +| **修改**表/列 | 物理迁移(`ALTER` / 重建)+ 更新或 `prune_stale_column_metadata` | +| **临时隐藏**(表仍保留) | 元数据 `visible=0`(表与元数据行都保留) | +| **永久废弃**用户可见表 | `drop_deprecated_user_table(...)`(见下) | + +### 永久废弃用户可见表 + +使用 `scripts/db/display_metadata.py` 的 **`drop_deprecated_user_table`**(幂等): + +1. `DROP TRIGGER`(若有 `{table}_set_updated_at` 等) +2. `DROP TABLE IF EXISTS` +3. `DELETE` 对应 `_jiangchang_columns` / `_jiangchang_tables` 行 + +```python +from db.display_metadata import drop_deprecated_user_table + +def init_db() -> None: + ... + drop_deprecated_user_table(cur, "task_logs") + init_display_metadata(cur) +``` + +**反模式(禁止):** 只删 `_jiangchang_*` 元数据、保留物理表。宿主虽可能不展示孤儿表,但库内残留、自检失败,且违背「元数据与物理结构一致」。 + +**新技能禁止**创建或写入 `task_logs` 一类通用任务审计表。过程 → `jiangchang.log` / 运行日志;进度/终态 → 任务中心;业务结果 → 业务表(见 [`WORK_ITEM_STATUS.md`](WORK_ITEM_STATUS.md)、[`../development/LOGGING.md`](../development/LOGGING.md))。 + +### 旧库迁移测试(废弃表时必做) + +复制 `tests/samples/test_table_deprecation_migration.py.sample`,构造「旧库含废弃表 + 元数据 + 历史行」→ `init_db()` 后断言: + +- `sqlite_master` 无该表 +- `_jiangchang_*` 无残留行 +- `iter_user_tables` 不含该表 + +卸载技能**不删**用户数据目录;重装会复用旧库,迁移必须在 `init_db()` 中完成(见 [`../development/DATA_PATHS.md`](../development/DATA_PATHS.md))。 + +## 中文命名质量 + +- 使用普通用户能理解的业务用语:`error_msg` → **错误信息**,`created_at` → **创建时间**。 +- 禁止:`Task Logs`、`input编号`、`errormsg`、直接把物理名当 `display_name`。 +- 不宜向用户暴露的实现细节(如 `payload_json`、`selector`)应设 `visible = 0` 或给出可理解中文名。 + +## 元数据同步规则 + +1. 新增用户可见业务表 → 同步 `_jiangchang_tables`(`visible=1` 方可在数据管理展示)。 +2. 新增用户可见字段 → 同步 `_jiangchang_columns`。 +3. 删除/重命名表或字段 → 物理迁移 + 同步清理元数据;**废弃表**用 `drop_deprecated_user_table`,禁止只删元数据留物理表。 +4. 初始化脚本必须幂等。 +5. 元数据中的表、字段必须在库中真实存在;可见项不得缺少中文 `display_name`。 +6. `init_db()` 后:`iter_user_tables` 中每个物理业务表都应有对应 `_jiangchang_tables.display_name`,或已被 `drop_deprecated_user_table` 物理删除。 + +## 开发自检 + +- `init_db()` 后运行 `tests/test_display_metadata.py` 与 `tests/test_data_management_contract.py`。 +- 若提供宿主 Action 入口,运行 `tests/test_actions_manifest.py`。 +- 复制为真实技能后,使用 `scripts/db/display_metadata_validator.py` 中的校验函数检查 SKILL 名称、slug 与元数据完整性。 + +## 模板原则 + +- 模板不做复杂历史迁移框架;新 skill 从当前 schema 起步。 +- 元数据 DDL 与种子 helper 以 `scripts/db/display_metadata.py` 为单一权威来源。 +- 模板定义「如何声明能力」;具体技能定义「声明什么业务能力」;宿主负责「如何通用渲染和执行」。 diff --git a/references/WORK_ITEM_STATUS.md b/references/WORK_ITEM_STATUS.md new file mode 100644 index 0000000..4943162 --- /dev/null +++ b/references/WORK_ITEM_STATUS.md @@ -0,0 +1,157 @@ +# 业务行生命周期契约(Work Item Lifecycle) + +> **状态:正式契约 v1.0。** +> **目标:** 冻结「对某一行做有副作用处理」时的**状态角色与操作权**规则,使队列型技能(生成、触达、私信、发布等)行为一致、可验收。 +> **非目标:** 规定某一技能的具体状态码或状态个数;强制所有表都有 status;替代任务中心 / 运行日志;规定 Data Policy 授权。 +> **适用范围:** 有「处理业务行」类 Action 的技能。纯配置表、目录表、只读展示表标 N/A。 + +相关正交轴:[`ACTIONS.md`](ACTIONS.md)(入口与 sync/async)、[`SCHEMA.md`](SCHEMA.md)(表与展示)、[`POLICY.md`](POLICY.md)(谁能点)、任务中心(宿主 Job)。 + +--- + +## 0. 为什么需要本契约 + +若「处理这一行」的按钮对**任意状态**都可点,会出现: + +- 已成功的行被重复执行(重复发信、重复生成、覆盖产物) +- 失败行被默认批量静默重试,运营无法区分「未做」与「做过但失败」 +- 任务中心显示成功,业务表仍停在初始态(结果未回写) + +本契约要求:**默认可自动操作的只有「可操作」角色;其它角色须人工改回可操作后再处理。** + +--- + +## 1. 状态角色(通用,不绑死枚举) + +技能自己定义业务状态字符串与中文标签;契约只认**角色**。一个角色可对应**一个或多个**业务状态。 + +| 角色 | 含义 | 默认可被处理 Action 捞起? | +|------|------|---------------------------| +| **operable(可操作)** | 尚未成功处理、允许自动或行操作启动处理 | **是(默认唯一)** | +| **in_progress(进行中)** | 已抢占,正在处理 | **否**(防双跑) | +| **terminal_success(成功类终态)** | 已按业务定义完成 | **否** | +| **terminal_failure(失败类终态)** | 已尝试且失败;通常带失败原因 | **否** | +| **terminal_skipped(跳过类终态)** | 人工放弃或不处理 | **否** | + +### 1.1 举例(仅对照,不是规范枚举) + +| 角色 | 技能 A(内容队列)可能叫 | 技能 B(线索触达)可能叫 | +|------|--------------------------|--------------------------| +| operable | 待生成、新建 | 新建、私信未发 | +| in_progress | 生成中 | 触达中 | +| terminal_success | 已生成 | 已私信 | +| terminal_failure | 失败 | 触达失败 | +| terminal_skipped | 已跳过 | 已忽略 | + +技能可以有更多业务状态(审核中、待确认、部分成功等):须在需求里写明各自映射到哪一角色,或声明「不属于处理队列、本契约 N/A」。 + +### 1.2 技能必须声明映射 + +复制模板后,在 `development/REQUIREMENTS.md`(或等价需求)中写清: + +```text +处理队列表:<table> +状态字段:<column> +可操作:<status1>, <status2>, … +进行中:<status…> +成功类终态:… +失败类终态:…(失败原因字段:…) +跳过类终态:… +``` + +未声明映射的「处理队列」技能,交活 Checklist 不得标 PASS。 + +--- + +## 2. 硬规则(技能实现必须遵守) + +1. **默认只认 operable** + 行操作 / 批量 / 定时 / Agent 触发的「处理」类 Action,pick 与 claim **默认**只包含可操作状态。 + 不得把成功、失败、跳过、进行中混进默认捞取集合(除非另做**显式命名**的「重试失败」类 Action,且确认文案写清范围——仍推荐不如人工改回可操作简单)。 + +2. **开始即原子抢占 → in_progress** + 在真正产生副作用前,用「仅当当前为 operable 时 UPDATE 为进行中」的方式抢占。抢占失败 → 跳过或返回明确错误(如「当前不是可操作状态」),**禁止**继续副作用。 + +3. **结束必须回写业务表** + - 成功 → 对应成功类终态,并写入产物路径/摘要等业务字段(若有) + - 失败 → 对应失败类终态,并写入**失败原因**(用户可读;勿塞密钥) + - 禁止「任务中心成功、业务行仍停在可操作/进行中」而不回写 + +4. **重做靠人工改回 operable** + 成功 / 失败 / 跳过 / 卡住的进行中,默认不能再点处理。运营在数据管理把状态改回可操作后,方可再次处理。 + +5. **任务中心 ≠ 业务状态** + Job「进行中 / 成功 / 失败」是宿主任务态。业务「进行中」必须写在业务表。进度 `emit` 不替代状态回写。 + +6. **过程与结果分工**(延续既有约定) + 过程 → 运行日志 / 任务中心 Activity;业务结果 → 业务表。**不要**靠新建 `task_logs` 一类审计表充当状态机。 + +--- + +## 3. Action 与文案约定 + +- 处理类 Action 的 `description` / `confirmation.message` 应写明:**仅处理「可操作」状态**(用本技能用户能懂的词,如「仅待生成」「仅新建」)。 +- 对非可操作行触发时:返回明确失败码与中文说明,提示「请先改回 xxx」。 +- 长任务(`async`):除任务中心进度外,业务行应尽快进入进行中,避免界面长时间仍显示可操作导致连点。 + +正交提醒: + +| 轴 | 管什么 | 不管什么 | +|----|--------|----------| +| 本契约 | 什么**状态**能处理 | 谁有权点按钮 | +| Data Policy | 谁有权 | 状态机 | +| executionProfile | sync/async 通道 | 业务终态 | + +--- + +## 4. 数据管理展示建议 + +- 状态列:`options_json` 枚举完整业务状态(含进行中与各类终态)。 +- 失败原因列:失败类终态可见;成功后宜清空或保留末次原因(技能自定,须一致)。 +- 终态与进行中的状态字段建议 **可编辑**(或提供「改回可操作」Action),以便人工重做;**不要**只能靠删行重建。 +- `grid_help` 可一句话写清:「仅某某状态可处理;其它状态请改回后再操作。」 + +详见 [`SCHEMA.md`](SCHEMA.md)。 + +--- + +## 5. 可观测性(与 ACTIONS / 日志约定对齐) + +推荐阶段(名称可自定,须能对应到开始 / 关键节点 / 结束): + +1. 已抢占(业务 → 进行中) +2. 处理中关键步骤(可 `emit`) +3. 回写成功或失败 + +故意失败一条时:业务表应为失败类终态 + 原因;任务中心为失败;日志含 `task_failed` / `error_code=`。 + +--- + +## 6. 非目标与常见误区 + +| 误区 | 正确做法 | +|------|----------| +| 把「新建/成功/失败/跳过」写成全平台唯一枚举 | 只用角色;各技能自定状态名 | +| 默认批量包含失败「方便自动重试」 | 默认不含;重试改回可操作或单独显式 Action | +| 只有任务成功、不改业务状态 | 必须回写业务表 | +| 用进行中替代任务中心 | 两者都要:业务防双跑,任务轨看进度 | +| 所有表都套本契约 | 无「处理行」副作用的表标 N/A | + +--- + +## 7. 验收摘要(完整条目见 CHECKLIST) + +有处理队列时至少验证: + +1. 成功类终态再点处理 → 拒绝或跳过,不产生重复副作用 +2. 可操作行处理中 → 先见进行中,结束后见成功或失败+原因 +3. 失败行改回可操作后 → 可以再次处理 +4. 任务中心有过程(长任务)且业务表状态与结果一致 + +--- + +## 8. 版本 + +| 版本 | 说明 | +|------|------| +| v1.0 | 首版:角色模型 + 硬规则 + 需求映射 + 与 Action/SCHEMA/任务中心边界 | diff --git a/release.ps1 b/release.ps1 new file mode 100644 index 0000000..db77b94 --- /dev/null +++ b/release.ps1 @@ -0,0 +1,424 @@ +<# +.SYNOPSIS + Local git release for skill-template repo. + +.DESCRIPTION + Handles git commit / push / semver tag / push tag only. + Encryption, ZIP packaging, and marketplace sync are done by CI + (.github/workflows/release_skill.yaml → reusable-release-skill.yaml@main). + + POLICY-RELEASE-001: before tagging, requires SKILL.md version and + scripts/util/constants.py SKILL_VERSION to equal the next tag (without prefix). + release.ps1 does not auto-edit those fields. + + Requires: git, PowerShell 5+ + + After fetch, runs default tests against the current working tree (before auto-commit): + python tests/run_tests.py -v + Use -SkipTests only for emergency diagnostics. -DryRun still runs tests. +#> + +[CmdletBinding()] +param( + [string]$Prefix = "v", + [string]$Message, + [switch]$AutoCommit, + [switch]$RequireClean, + [string]$CommitMessage, + [switch]$DryRun, + [switch]$SkipTests +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +# Default tag message is 「正式发布」 from UTF-8 bytes (not a Chinese literal in +# this file). Windows PowerShell 5.1 parses UTF-8 scripts without BOM as the +# system ANSI code page, which would turn a source literal into 「姝e紡鍙戝竷」. +if (-not $PSBoundParameters.ContainsKey("Message") -or [string]::IsNullOrWhiteSpace($Message)) { + $Message = [System.Text.Encoding]::UTF8.GetString([byte[]](0xE6, 0xAD, 0xA3, 0xE5, 0xBC, 0x8F, 0xE5, 0x8F, 0x91, 0xE5, 0xB8, 0x83)) +} + +function Remove-GitNoise { + param([string[]]$Lines) + return @($Lines | Where-Object { + $_ -and ($_ -notmatch '^warning:\s+unable to access ') + }) +} + +function Convert-GitOutputLines { + param($Output) + return @( + @($Output) | ForEach-Object { + if ($_ -is [System.Management.Automation.ErrorRecord]) { $_.ToString() } + else { "$_" } + } + ) +} + +# Call git.exe directly. Never wrap with `cmd /c`: on Chinese Windows, cmd +# re-encodes the command line through the OEM code page. +# Also never pass Chinese via `git -m`: Windows PowerShell 5.1 encodes native +# argv using the console code page, so 「正式发布」 becomes 「姝e紡鍙戝竷」 +# in the annotated tag. Commit/tag messages go through a UTF-8 file + `-F`. +function Invoke-GitRaw { + param([Parameter(Mandatory = $true)][string[]]$GitArgs) + $prev = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + $output = & git @GitArgs 2>&1 + $code = $LASTEXITCODE + } finally { + $ErrorActionPreference = $prev + } + return [pscustomobject]@{ + ExitCode = $code + Lines = @(Remove-GitNoise -Lines @(Convert-GitOutputLines -Output $output)) + } +} + +function Invoke-Git { + param([Parameter(Mandatory = $true)][string[]]$GitArgs) + Write-Host (">> git " + ($GitArgs -join " ")) -ForegroundColor DarkGray + $r = Invoke-GitRaw -GitArgs $GitArgs + if ($r.ExitCode -ne 0) { + throw "git command failed: git $($GitArgs -join ' ')" + } +} + +function Invoke-GitWithMessageFile { + param( + [Parameter(Mandatory = $true)][string[]]$GitArgsPrefix, + [Parameter(Mandatory = $true)][string]$Message + ) + $msgFile = Join-Path ([System.IO.Path]::GetTempPath()) ("jiangchang-git-msg-" + [guid]::NewGuid().ToString("N") + ".txt") + $utf8NoBom = New-Object System.Text.UTF8Encoding $false + [System.IO.File]::WriteAllText($msgFile, ($Message.TrimEnd() + "`n"), $utf8NoBom) + try { + Invoke-Git -GitArgs (@("-c", "i18n.commitEncoding=utf-8") + $GitArgsPrefix + @("-F", $msgFile)) + } finally { + Remove-Item -LiteralPath $msgFile -Force -ErrorAction SilentlyContinue + } +} + +function Get-GitOutput { + param([Parameter(Mandatory = $true)][string[]]$GitArgs) + $r = Invoke-GitRaw -GitArgs $GitArgs + if ($r.ExitCode -ne 0) { + throw "git command failed: git $($GitArgs -join ' ')" + } + return @($r.Lines) +} + +function Test-Repo { + $r = Invoke-GitRaw -GitArgs @("rev-parse", "--is-inside-work-tree") + if ($r.ExitCode -ne 0) { + return $false + } + return (($r.Lines | Select-Object -First 1) -eq "true") +} + +function Get-CurrentBranch { + $b = (Get-GitOutput -GitArgs @("branch", "--show-current") | Select-Object -First 1).Trim() + return $b +} + +function Get-StatusPorcelain { + $lines = @(Get-GitOutput -GitArgs @("status", "--porcelain")) + return $lines +} + +function Parse-SemVerTag { + param( + [string]$Tag, + [string]$TagPrefix + ) + $escaped = [regex]::Escape($TagPrefix) + $m = [regex]::Match($Tag, "^${escaped}(\d+)\.(\d+)\.(\d+)$") + if (-not $m.Success) { return $null } + + return [pscustomobject]@{ + Raw = $Tag + Major = [int]$m.Groups[1].Value + Minor = [int]$m.Groups[2].Value + Patch = [int]$m.Groups[3].Value + } +} + +function Get-NextTag { + param([string]$TagPrefix) + + $tags = Get-GitOutput -GitArgs @("tag", "--list") + $parsed = @() + + foreach ($t in $tags) { + $t = $t.Trim() + if (-not $t) { continue } + $obj = Parse-SemVerTag -Tag $t -TagPrefix $TagPrefix + if ($null -ne $obj) { $parsed += $obj } + } + + if ($parsed.Count -eq 0) { + return "${TagPrefix}1.0.1" + } + + $latest = $parsed | Sort-Object Major, Minor, Patch | Select-Object -Last 1 + return "$TagPrefix$($latest.Major).$($latest.Minor).$([int]$latest.Patch + 1)" +} + +function Read-SkillMdVersion { + param([Parameter(Mandatory = $true)][string]$SkillRoot) + + $path = Join-Path $SkillRoot "SKILL.md" + if (-not (Test-Path -LiteralPath $path)) { + return $null + } + $text = Get-Content -LiteralPath $path -Raw -Encoding UTF8 -ErrorAction SilentlyContinue + if ([string]::IsNullOrWhiteSpace($text)) { + return $null + } + $m = [regex]::Match($text, '(?m)^version:\s*["'']?([0-9]+\.[0-9]+\.[0-9]+)["'']?\s*$') + if (-not $m.Success) { + return $null + } + return $m.Groups[1].Value.Trim() +} + +function Read-SkillConstantsVersion { + param([Parameter(Mandatory = $true)][string]$SkillRoot) + + $path = Join-Path $SkillRoot "scripts\util\constants.py" + if (-not (Test-Path -LiteralPath $path)) { + return $null + } + $text = Get-Content -LiteralPath $path -Raw -Encoding UTF8 -ErrorAction SilentlyContinue + if ([string]::IsNullOrWhiteSpace($text)) { + return $null + } + $m = [regex]::Match($text, '(?m)^SKILL_VERSION\s*=\s*["'']([0-9]+\.[0-9]+\.[0-9]+)["'']') + if (-not $m.Success) { + return $null + } + return $m.Groups[1].Value.Trim() +} + +# POLICY-RELEASE-001: marketplace latest (git tag) must equal package manifest versions. +function Assert-SkillReleaseVersionAlignment { + param( + [Parameter(Mandatory = $true)][string]$SkillRoot, + [Parameter(Mandatory = $true)][string]$NextTag, + [Parameter(Mandatory = $true)][string]$TagPrefix + ) + + $skillMdPath = Join-Path $SkillRoot "SKILL.md" + if (-not (Test-Path -LiteralPath $skillMdPath)) { + return + } + + $expected = $NextTag + if ($TagPrefix -and $expected.StartsWith($TagPrefix)) { + $expected = $expected.Substring($TagPrefix.Length) + } + $expected = $expected.Trim() + if ($expected -notmatch '^\d+\.\d+\.\d+$') { + throw "Release check failed (POLICY-RELEASE-001): cannot derive semver from Next tag '$NextTag'." + } + + $mdVersion = Read-SkillMdVersion -SkillRoot $SkillRoot + $constVersion = Read-SkillConstantsVersion -SkillRoot $SkillRoot + + $problems = @() + if ([string]::IsNullOrWhiteSpace($mdVersion)) { + $problems += "SKILL.md missing top-level version: X.Y.Z" + } + elseif ($mdVersion -ne $expected) { + $problems += "SKILL.md version=$mdVersion (want $expected)" + } + if ([string]::IsNullOrWhiteSpace($constVersion)) { + $problems += "scripts/util/constants.py missing SKILL_VERSION = `"X.Y.Z`"" + } + elseif ($constVersion -ne $expected) { + $problems += "SKILL_VERSION=$constVersion (want $expected)" + } + + if ($problems.Count -gt 0) { + throw ( + "Release aborted (POLICY-RELEASE-001): Next tag=$NextTag requires manifest version $expected, but " + + ($problems -join "; ") + + ". Bump SKILL.md version and scripts/util/constants.py SKILL_VERSION to $expected (and CHANGELOG), then re-run release.ps1. " + + "release.ps1 does not auto-edit these fields." + ) + } + + Write-Host "Version alignment OK: tag=$NextTag == SKILL.md=$mdVersion == SKILL_VERSION=$constVersion" -ForegroundColor DarkGray +} + +function Assert-SkillReleasePackagingSources { + param([Parameter(Mandatory = $true)][string]$SkillRoot) + + $scriptsDir = Join-Path $SkillRoot "scripts" + $mainPy = Join-Path $scriptsDir "main.py" + if (-not (Test-Path -LiteralPath $mainPy)) { + return + } + + $text = Get-Content -LiteralPath $mainPy -Raw -Encoding UTF8 -ErrorAction SilentlyContinue + if ([string]::IsNullOrWhiteSpace($text)) { + return + } + + $need = @() + if ($text -match '(?m)^\s*from\s+cli\.') { $need += 'cli' } + if ($text -match '(?m)^\s*import\s+cli\b') { $need += 'cli' } + if ($text -match '(?m)^\s*from\s+service\.') { $need += 'service' } + if ($text -match '(?m)^\s*import\s+service\b') { $need += 'service' } + if ($text -match '(?m)^\s*from\s+db\.') { $need += 'db' } + if ($text -match '(?m)^\s*import\s+db\b') { $need += 'db' } + if ($text -match '(?m)^\s*from\s+util\.') { $need += 'util' } + if ($text -match '(?m)^\s*import\s+util\b') { $need += 'util' } + + foreach ($p in ($need | Select-Object -Unique)) { + $folder = Join-Path $scriptsDir $p + if (-not (Test-Path -LiteralPath $folder)) { + throw "Release check failed: scripts/main.py imports from '$p' but folder is missing: $folder" + } + } + + $pyFiles = @(Get-ChildItem -LiteralPath $scriptsDir -Filter *.py -Recurse -File -ErrorAction SilentlyContinue) + Write-Host "Packaging check: $($pyFiles.Count) Python file(s) under scripts/ (CI will obfuscate all recursively)." -ForegroundColor DarkGray +} + +function Invoke-DefaultTests { + param( + [Parameter(Mandatory = $true)][string]$SkillRoot, + [switch]$Skip + ) + + if ($Skip) { + Write-Host "WARNING: Skipping tests before release. This should only be used for emergency diagnostics." -ForegroundColor Yellow + return + } + + $runTests = Join-Path $SkillRoot "tests\run_tests.py" + if (-not (Test-Path -LiteralPath $runTests)) { + throw "Release check failed: tests/run_tests.py not found." + } + + Write-Host ">> python tests/run_tests.py -v" -ForegroundColor DarkGray + & python $runTests -v + if ($LASTEXITCODE -ne 0) { + throw "Release aborted: default tests failed (python tests/run_tests.py -v)." + } +} + +function Ensure-CleanOrAutoCommit { + param( + [switch]$DoAutoCommit, + [switch]$NeedClean, + [switch]$IsDryRun, + [string]$Msg + ) + + $status = @(Get-StatusPorcelain) + if ($status.Length -eq 0) { return } + + if ($NeedClean) { + Write-Host "Working tree is not clean and -RequireClean is enabled." -ForegroundColor Yellow + (Invoke-GitRaw -GitArgs @("status", "--short")).Lines | ForEach-Object { Write-Host $_ } + throw "Abort: dirty working tree." + } + + $commitMsg = $Msg + if ([string]::IsNullOrWhiteSpace($commitMsg)) { + $commitMsg = "chore: auto release commit ($(Get-Date -Format 'yyyy-MM-dd HH:mm:ss'))" + } + + if (-not $DoAutoCommit) { + Write-Host "Detected uncommitted changes, auto-committing before release..." -ForegroundColor Yellow + } + + if ($IsDryRun) { + Write-Host "[DryRun] Would run: git add -A" -ForegroundColor Yellow + Write-Host "[DryRun] Would run: git commit -m `"$commitMsg`"" -ForegroundColor Yellow + return + } + + Invoke-Git -GitArgs @("add", "-A") + Invoke-GitWithMessageFile -GitArgsPrefix @("commit") -Message $commitMsg +} + +try { + Write-Host "=== Release Script Start ===" -ForegroundColor Cyan + + if (-not (Test-Repo)) { + throw "Current directory is not a git repository." + } + + $branch = Get-CurrentBranch + if ([string]::IsNullOrWhiteSpace($branch)) { + throw "Unable to determine current branch." + } + + if ($branch -notin @("main", "master")) { + throw "Current branch is '$branch'. Release is only allowed from main/master." + } + + if ($DryRun) { + Write-Host "[DryRun] Would run: git fetch --tags --prune origin" -ForegroundColor Yellow + } else { + Invoke-Git -GitArgs @("fetch", "--tags", "--prune", "origin") + } + + $skillRoot = (Get-GitOutput -GitArgs @("rev-parse", "--show-toplevel") | Select-Object -First 1).Trim() + if (Test-Path -LiteralPath (Join-Path $skillRoot "SKILL.md")) { + Assert-SkillReleasePackagingSources -SkillRoot $skillRoot + } + + $nextTag = Get-NextTag -TagPrefix $Prefix + Write-Host "Next tag: $nextTag" -ForegroundColor Green + Assert-SkillReleaseVersionAlignment -SkillRoot $skillRoot -NextTag $nextTag -TagPrefix $Prefix + + $existing = @(Get-GitOutput -GitArgs @("tag", "--list", $nextTag)) + if ($existing.Length -gt 0) { + throw "Tag already exists: $nextTag" + } + + Invoke-DefaultTests -SkillRoot $skillRoot -Skip:$SkipTests + + Ensure-CleanOrAutoCommit -DoAutoCommit:$AutoCommit -NeedClean:$RequireClean -IsDryRun:$DryRun -Msg $CommitMessage + + $upstream = Invoke-GitRaw -GitArgs @("rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}") + $hasUpstream = ($upstream.ExitCode -eq 0) + + if ($DryRun) { + if ($hasUpstream) { + Write-Host "[DryRun] Would run: git push" -ForegroundColor Yellow + } else { + Write-Host "[DryRun] Would run: git push -u origin $branch" -ForegroundColor Yellow + } + } else { + if ($hasUpstream) { + Invoke-Git -GitArgs @("push") + } else { + Invoke-Git -GitArgs @("push", "-u", "origin", $branch) + } + } + + if ($DryRun) { + Write-Host "[DryRun] Would run: git tag -a $nextTag -F <utf8-msg-file> ($Message)" -ForegroundColor Yellow + Write-Host "[DryRun] Would run: git push origin $nextTag" -ForegroundColor Yellow + Write-Host "=== DryRun Complete ===" -ForegroundColor Cyan + exit 0 + } + + Invoke-GitWithMessageFile -GitArgsPrefix @("tag", "-a", $nextTag) -Message $Message + Invoke-Git -GitArgs @("push", "origin", $nextTag) + + Write-Host "Release success: $nextTag" -ForegroundColor Green + Write-Host "=== Release Script Done ===" -ForegroundColor Cyan + exit 0 +} +catch { + Write-Host "Release failed: $($_.Exception.Message)" -ForegroundColor Red + exit 1 +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7380078 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +# 公共依赖由宿主共享 runtime 提供;这里只声明技能特有 Python 依赖。 +# Word 初稿生成(python-docx 为纯 Python 包 + lxml,宿主安装技能时写入共享 venv) +python-docx>=1.1.0,<2 diff --git a/scripts/cli/app.py b/scripts/cli/app.py new file mode 100644 index 0000000..e89c06d --- /dev/null +++ b/scripts/cli/app.py @@ -0,0 +1,240 @@ +"""CLI:argparse 与分发(结题报告初稿助手)。 + +只负责参数解析并转交 service;业务内核在 draft_service / ledger_service / docx_service, +勿在此按入口复制逻辑。 +""" + +from __future__ import annotations + +import argparse +import sys +from typing import List, Optional + +from service.task_service import ( + cmd_add_achievement, + cmd_add_expense, + cmd_add_project, + cmd_config_path, + cmd_draft_batch_pending, + cmd_draft_report, + cmd_health, + cmd_import, + cmd_init_db, + cmd_list, + cmd_list_drafts, + cmd_render_html, + cmd_run, + cmd_show_draft, + cmd_stats, + cmd_version, +) +from util.config_bootstrap import bootstrap_skill_config +from util.argparse_zh import ZhArgumentParser +from util.constants import LOG_LOGGER_NAME, SKILL_SLUG +from util.logging_config import get_skill_logger, setup_skill_logging + + +def _cli_str_or_none(raw: Optional[str]) -> Optional[str]: + if raw is None: + return None + v = str(raw).strip() + return v or None + + +def _cli_float_or_none(raw: Optional[str]) -> Optional[float]: + raw = _cli_str_or_none(raw) + if raw is None: + return None + try: + return float(raw) + except ValueError: + return None + + +def _handle_run(args: argparse.Namespace) -> int: + tail = [str(x).strip() for x in (args.run_tail or []) if str(x).strip()] + if len(tail) > 2: + print("参数过多。") + print("用法:python main.py run [target [input_id]] | run [-t target] [-i input_id]") + return 1 + t_target: Optional[str] = None + t_input: Optional[str] = None + if len(tail) == 2: + t_target, t_input = tail[0], tail[1] + elif len(tail) == 1: + if tail[0].isdigit(): + t_input = tail[0] + else: + t_target = tail[0] + target = _cli_str_or_none(getattr(args, "target", None)) or t_target + input_id = _cli_str_or_none(getattr(args, "input_id", None)) or t_input + return cmd_run(target=target, input_id=input_id) + + +def _handle_add_project(args: argparse.Namespace) -> int: + return cmd_add_project( + project_no=str(args.project_no or "").strip(), + name=_cli_str_or_none(args.name) or "", + pi=_cli_str_or_none(args.pi) or "", + department=_cli_str_or_none(args.department) or "", + program=_cli_str_or_none(args.program) or "", + start=_cli_str_or_none(args.start) or "", + end=_cli_str_or_none(args.end) or "", + expected_json=_cli_str_or_none(args.expected_json) or "", + excerpt=_cli_str_or_none(args.excerpt) or "", + status=_cli_str_or_none(args.status) or "", + ) + + +def _handle_import(args: argparse.Namespace) -> int: + return cmd_import( + projects_json=_cli_str_or_none(args.projects_json) or "", + achievements_json=_cli_str_or_none(args.achievements_json) or "", + expenses_json=_cli_str_or_none(args.expenses_json) or "", + ) + + +def _handle_list(args: argparse.Namespace) -> int: + return cmd_list( + kind=_cli_str_or_none(args.kind) or "projects", + project_no=_cli_str_or_none(args.project_no) or "", + status=_cli_str_or_none(args.status) or "", + ) + + +def _handle_render_html(args: argparse.Namespace) -> int: + report_id = getattr(args, "report_id", None) + return cmd_render_html(report_id=int(report_id) if report_id else None) + + +def _print_full_usage() -> None: + print("结题报告初稿助手(main.py)可用命令:") + print(" python main.py add-project --project-no X --name Y [--pi 张三] [--program 国自然青年] [--start D] [--end D]") + print(" [--expected-json '[{\"type\":\"论文\",\"target\":3,\"unit\":\"篇\"}]'] [--excerpt 摘句] [--status 待结题]") + print(" python main.py add-achievement --project-no X --type 论文 --title T [--venue 期刊] [--date D] [--representative 1]") + print(" python main.py add-expense --project-no X --category 差旅费 --amount 23000 [--date D] [--description 摘要]") + print(" python main.py import --projects-json F [--achievements-json F] [--expenses-json F]") + print(" python main.py list [projects|achievements|expenses] [--project-no X] [--status 待结题]") + print(" python main.py draft-report --project-no X # 主命令:生成 Word 初稿 + HTML 预览") + print(" python main.py draft-batch-pending # 批量生成全部待结题初稿") + print(" python main.py list-drafts [--limit N] | show-draft --id N | render-html [--report-id N]") + print(" python main.py stats") + print(" python main.py run [target [input_id]] [-t target] [-i input_id]") + print(" python main.py health | config-path | version | init-db") + + +def build_parser() -> ZhArgumentParser: + p = ZhArgumentParser( + prog="main.py", + description="结题报告初稿助手:对比预期指标与实际成果生成完成情况章节,决算明细转规范描述,组装可修改的 Word 版《结题报告(初稿)》。", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + sub = p.add_subparsers(dest="cmd", required=True, parser_class=ZhArgumentParser) + + sp = sub.add_parser("add-project", help="录入/更新结题项目(按项目编号幂等)") + sp.add_argument("--project-no", required=True, metavar="项目编号", dest="project_no") + sp.add_argument("--name", default=None, metavar="项目名称") + sp.add_argument("--pi", default=None, metavar="负责人") + sp.add_argument("--department", default=None, metavar="所属学院") + sp.add_argument("--program", default=None, metavar="项目类型") + sp.add_argument("--start", default=None, metavar="执行期开始") + sp.add_argument("--end", default=None, metavar="执行期结束") + sp.add_argument("--expected-json", default=None, metavar="预期指标JSON", dest="expected_json") + sp.add_argument("--excerpt", default=None, metavar="年度报告摘句") + sp.add_argument("--status", default=None, metavar="状态(待结题/初稿已生成/已提交)") + sp.set_defaults(handler=_handle_add_project) + + sp = sub.add_parser("add-achievement", help="录入成果") + sp.add_argument("--project-no", required=True, metavar="项目编号", dest="project_no") + sp.add_argument("--type", required=True, metavar="类型(论文/专利/人才培养/其他)", dest="ach_type") + sp.add_argument("--title", required=True, metavar="成果名称") + sp.add_argument("--venue", default=None, metavar="期刊/专利号") + sp.add_argument("--date", default=None, metavar="日期") + sp.add_argument("--representative", default=None, metavar="代表性(1/0)") + sp.add_argument("--note", default=None, metavar="备注") + sp.set_defaults( + handler=lambda a: cmd_add_achievement( + project_no=str(a.project_no), ach_type=str(a.ach_type), title=str(a.title), + venue=_cli_str_or_none(a.venue) or "", date=_cli_str_or_none(a.date) or "", + representative=_cli_str_or_none(a.representative) or "", note=_cli_str_or_none(a.note) or "", + ) + ) + + sp = sub.add_parser("add-expense", help="录入决算明细") + sp.add_argument("--project-no", required=True, metavar="项目编号", dest="project_no") + sp.add_argument("--category", required=True, metavar="科目") + sp.add_argument("--amount", required=True, type=float, metavar="金额(元)") + sp.add_argument("--date", default=None, metavar="发生日期") + sp.add_argument("--description", default=None, metavar="原始摘要") + sp.set_defaults( + handler=lambda a: cmd_add_expense( + project_no=str(a.project_no), category=str(a.category), amount=float(a.amount), + date=_cli_str_or_none(a.date) or "", description=_cli_str_or_none(a.description) or "", + ) + ) + + sp = sub.add_parser("import", help="批量导入(projects/achievements/expenses JSON)") + sp.add_argument("--projects-json", default=None, metavar="项目JSON(file|-)", dest="projects_json") + sp.add_argument("--achievements-json", default=None, metavar="成果JSON(file|-)", dest="achievements_json") + sp.add_argument("--expenses-json", default=None, metavar="决算JSON(file|-)", dest="expenses_json") + sp.set_defaults(handler=_handle_import) + + sp = sub.add_parser("list", help="查询台账(projects/achievements/expenses)") + sp.add_argument("--kind", default="projects", metavar="projects|achievements|expenses", dest="kind") + sp.add_argument("--project-no", default=None, metavar="项目编号", dest="project_no") + sp.add_argument("--status", default=None, metavar="状态筛选") + sp.set_defaults(handler=_handle_list) + + sp = sub.add_parser("draft-report", help="生成结题报告初稿(主命令)") + sp.add_argument("--project-no", required=True, metavar="项目编号", dest="project_no") + sp.set_defaults(handler=lambda a: cmd_draft_report(project_no=str(a.project_no))) + + sp = sub.add_parser("draft-batch-pending", help="批量生成全部待结题项目初稿") + sp.set_defaults(handler=lambda _a: cmd_draft_batch_pending()) + + sp = sub.add_parser("list-drafts", help="查询初稿记录") + sp.add_argument("--limit", default=20, metavar="条数") + sp.set_defaults(handler=lambda a: cmd_list_drafts(limit=int(a.limit or 20))) + + sp = sub.add_parser("show-draft", help="查看初稿记录详情") + sp.add_argument("--id", required=True, type=int, metavar="记录编号", dest="draft_id") + sp.set_defaults(handler=lambda a: cmd_show_draft(draft_id=int(a.draft_id))) + + sp = sub.add_parser("render-html", help="重建 HTML 预览(缺省最近 10 份)") + sp.add_argument("--report-id", default=None, metavar="记录编号", dest="report_id") + sp.set_defaults(handler=_handle_render_html) + + sp = sub.add_parser("stats", help="统计") + sp.set_defaults(handler=lambda _a: cmd_stats()) + + sp = sub.add_parser("run", help="任务执行命令骨架") + sp.add_argument("--target", "-t", default=None, metavar="目标") + sp.add_argument("--input-id", "-i", default=None, metavar="输入id", dest="input_id") + sp.add_argument("run_tail", nargs="*", metavar="位置参数") + sp.set_defaults(handler=_handle_run) + + sp = sub.add_parser("health", help="健康检查") + sp.set_defaults(handler=lambda _a: cmd_health()) + + sp = sub.add_parser("config-path", help="输出用户 .env 与模板路径") + sp.set_defaults(handler=lambda _a: cmd_config_path()) + + sp = sub.add_parser("version", help="版本信息(JSON)") + sp.set_defaults(handler=lambda _a: cmd_version()) + + sp = sub.add_parser("init-db", help="幂等初始化本地数据库与数据管理元数据") + sp.set_defaults(handler=lambda _a: cmd_init_db()) + return p + + +def main(argv: Optional[List[str]] = None) -> int: + argv = argv if argv is not None else sys.argv[1:] + bootstrap_skill_config() + setup_skill_logging(SKILL_SLUG, LOG_LOGGER_NAME) + get_skill_logger().info("cli_start argv=%s", sys.argv) + if not argv: + _print_full_usage() + return 1 + parser = build_parser() + args = parser.parse_args(argv) + return int(args.handler(args)) diff --git a/scripts/db/connection.py b/scripts/db/connection.py new file mode 100644 index 0000000..58306b3 --- /dev/null +++ b/scripts/db/connection.py @@ -0,0 +1,262 @@ +"""SQLite 连接与幂等初始化(结题报告初稿助手)。 + +业务表 4 张: +- closing_projects(结题项目,含预期指标,可编辑) +- achievements(成果清单,可编辑) +- expense_items(决算明细,可编辑) +- report_drafts(初稿记录,只读,生成自动写入) + +表间禁止 SQLite 外键,仅存业务 id(见 references/SCHEMA.md)。 +""" + +from __future__ import annotations + +import sqlite3 + +from db.display_metadata import ( + init_display_metadata, + seed_column_metadata_batch, + upsert_table_metadata, +) +from db.timestamp_columns import init_updated_at_trigger +from util.runtime_paths import get_db_path + + +def get_conn() -> sqlite3.Connection: + return sqlite3.connect(get_db_path()) + + +_CLOSING_PROJECTS_DDL = """ +CREATE TABLE IF NOT EXISTS closing_projects ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_no TEXT NOT NULL UNIQUE, + project_name TEXT, + pi_name TEXT, + department TEXT, + program TEXT, + start_date TEXT, + end_date TEXT, + expected_json TEXT, + annual_report_excerpt TEXT, + status TEXT NOT NULL DEFAULT '待结题', + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) +) +""" + +_ACHIEVEMENTS_DDL = """ +CREATE TABLE IF NOT EXISTS achievements ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_no TEXT NOT NULL, + achievement_type TEXT NOT NULL, + title TEXT, + venue_or_no TEXT, + ach_date TEXT, + is_representative INTEGER NOT NULL DEFAULT 0, + note TEXT, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) +) +""" + +_EXPENSE_ITEMS_DDL = """ +CREATE TABLE IF NOT EXISTS expense_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_no TEXT NOT NULL, + category TEXT NOT NULL, + amount REAL NOT NULL, + occurred_date TEXT, + description TEXT, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) +) +""" + +_REPORT_DRAFTS_DDL = """ +CREATE TABLE IF NOT EXISTS report_drafts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + batch_no TEXT NOT NULL, + project_no TEXT, + project_name TEXT, + completion_json TEXT, + narrative_points_json TEXT, + draft_file TEXT, + preview_file TEXT, + created_at INTEGER NOT NULL DEFAULT (unixepoch()) +) +""" + +_REPORT_DRAFTS_INDEX = """ +CREATE INDEX IF NOT EXISTS idx_report_drafts_batch +ON report_drafts(batch_no) +""" + +_TS_COLUMN_NAMES = {"created_at", "updated_at"} + + +def _col(column_name: str, display_name: str, description: str, **extra: object) -> dict[str, object]: + base: dict[str, object] = { + "column_name": column_name, + "display_name": display_name, + "description": description, + "visible": 1, + "searchable": 0, + "editable": 0, + } + base.update(extra) + if column_name in _TS_COLUMN_NAMES: + base["display_type"] = "datetime_unix_seconds" + return base + + +_CLOSING_PROJECTS_COLUMNS: tuple[dict[str, object], ...] = ( + _col("id", "编号", "主键", searchable=1), + _col("project_no", "项目编号", "项目唯一编号。", searchable=1), + _col("project_name", "项目名称", "项目全称。", searchable=1, editable=1), + _col("pi_name", "负责人", "项目负责人姓名。", searchable=1, editable=1), + _col("department", "所属学院", "二级学院/单位。", searchable=1, editable=1), + _col("program", "项目类型", "如 国家自然科学基金(青年/面上)。", searchable=1, editable=1), + _col("start_date", "执行期开始", "YYYY-MM-DD。", searchable=1, editable=1), + _col("end_date", "执行期结束", "YYYY-MM-DD。", searchable=1, editable=1), + _col("expected_json", "预期成果指标", "如 [{\"type\":\"论文\",\"target\":3,\"unit\":\"篇\"}]。", visible=0, display_type="textarea", editable=1), + _col("annual_report_excerpt", "年度报告摘句", "年度/中期报告关键句(分号分隔),供叙事素材。", visible=0, display_type="textarea", editable=1), + _col("status", "状态", "待结题 / 初稿已生成 / 已提交。", searchable=1, editable=1, options_json='[{"value":"待结题","label":"待结题"},{"value":"初稿已生成","label":"初稿已生成"},{"value":"已提交","label":"已提交"}]'), + _col("created_at", "创建时间", "记录创建时间(Unix 秒级,自动生成)。"), + _col("updated_at", "更新时间", "记录最后更新时间(Unix 秒级,自动维护)。"), +) + +_ACHIEVEMENTS_COLUMNS: tuple[dict[str, object], ...] = ( + _col("id", "编号", "主键", searchable=1), + _col("project_no", "项目编号", "所属项目编号。", searchable=1), + _col("achievement_type", "成果类型", "论文 / 专利 / 人才培养 / 其他。", searchable=1, editable=1, options_json='[{"value":"论文","label":"论文"},{"value":"专利","label":"专利"},{"value":"人才培养","label":"人才培养"},{"value":"其他","label":"其他"}]'), + _col("title", "成果名称", "论文标题/专利名/成果描述。", searchable=1, editable=1), + _col("venue_or_no", "期刊/专利号", "发表期刊或专利号。", editable=1), + _col("ach_date", "日期", "发表/授权日期(YYYY-MM-DD)。", editable=1), + _col("is_representative", "代表性成果", "1=代表本项目的标志性成果。", searchable=1, editable=1), + _col("note", "备注", "说明。", editable=1), + _col("created_at", "创建时间", "记录创建时间(Unix 秒级,自动生成)。"), + _col("updated_at", "更新时间", "记录最后更新时间(Unix 秒级,自动维护)。"), +) + +_EXPENSE_ITEMS_COLUMNS: tuple[dict[str, object], ...] = ( + _col("id", "编号", "主键", searchable=1), + _col("project_no", "项目编号", "所属项目编号。", searchable=1), + _col("category", "科目", "决算科目,如 差旅费、设备费。", searchable=1, editable=1), + _col("amount", "金额", "支出金额(元)。", searchable=1, editable=1, display_type="number"), + _col("occurred_date", "发生日期", "YYYY-MM-DD。", editable=1), + _col("description", "原始摘要", "流水描述,供语义化转换参考。", visible=0, display_type="textarea", editable=1), + _col("created_at", "创建时间", "记录创建时间(Unix 秒级,自动生成)。"), + _col("updated_at", "更新时间", "记录最后更新时间(Unix 秒级,自动维护)。"), +) + +_REPORT_DRAFTS_COLUMNS: tuple[dict[str, object], ...] = ( + _col("id", "记录编号", "主键,初稿记录编号。", searchable=1), + _col("batch_no", "生成批次号", "同一次生成请求的批次号。", searchable=1), + _col("project_no", "项目编号", "初稿对应项目。", searchable=1), + _col("project_name", "项目名称", "初稿对应项目名。", searchable=1), + _col("completion_json", "指标对比", "预期 vs 实际对比(JSON)。", visible=0, display_type="textarea"), + _col("narrative_points_json", "叙事素材要点", "从成果与年度报告提取的要点(JSON)。", visible=0, display_type="textarea"), + _col("draft_file", "Word 初稿", "生成的 docx 文件路径。", visible=0), + _col("preview_file", "预览文件", "HTML 预览文件路径。", visible=0), + _col("created_at", "创建时间", "记录创建时间(Unix 秒级,自动生成)。"), +) + + +def _init_closing_projects_metadata(cursor) -> None: + cursor.execute(_CLOSING_PROJECTS_DDL) + init_updated_at_trigger(cursor, "closing_projects") + upsert_table_metadata( + cursor, + table_name="closing_projects", + display_name="结题项目", + description="待结题项目与预期成果指标;初稿生成的数据源。", + grid_help="预期指标为 JSON 数组(type/target/unit);顶栏按钮可录入,行内可编辑。", + visible=1, + readonly=0, + default_sort_json='[{"column":"id","direction":"desc"}]', + default_visible_columns_json='["project_no","project_name","pi_name","program","end_date","status"]', + ) + seed_column_metadata_batch( + cursor, + table_name="closing_projects", + columns=_CLOSING_PROJECTS_COLUMNS, + valid_column_names=[str(c["column_name"]) for c in _CLOSING_PROJECTS_COLUMNS], + ) + + +def _init_achievements_metadata(cursor) -> None: + cursor.execute(_ACHIEVEMENTS_DDL) + init_updated_at_trigger(cursor, "achievements") + upsert_table_metadata( + cursor, + table_name="achievements", + display_name="成果清单", + description="实际取得的论文/专利/人才培养等成果;完成情况章节的数据源。", + grid_help="按项目编号关联;代表性成果会优先进入初稿素材。", + visible=1, + readonly=0, + ) + seed_column_metadata_batch( + cursor, + table_name="achievements", + columns=_ACHIEVEMENTS_COLUMNS, + valid_column_names=[str(c["column_name"]) for c in _ACHIEVEMENTS_COLUMNS], + ) + + +def _init_expense_items_metadata(cursor) -> None: + cursor.execute(_EXPENSE_ITEMS_DDL) + init_updated_at_trigger(cursor, "expense_items") + upsert_table_metadata( + cursor, + table_name="expense_items", + display_name="决算明细", + description="经费流水明细;决算章节的数据源。", + grid_help="金额单位元;按科目汇总后自动生成规范描述句。", + visible=1, + readonly=0, + ) + seed_column_metadata_batch( + cursor, + table_name="expense_items", + columns=_EXPENSE_ITEMS_COLUMNS, + valid_column_names=[str(c["column_name"]) for c in _EXPENSE_ITEMS_COLUMNS], + ) + + +def _init_report_drafts_metadata(cursor) -> None: + cursor.execute(_REPORT_DRAFTS_DDL) + cursor.execute(_REPORT_DRAFTS_INDEX) + upsert_table_metadata( + cursor, + table_name="report_drafts", + display_name="初稿记录", + description="每次生成的初稿记录:指标对比、素材要点与文件路径。", + grid_help="Word 文件在 output 目录可下载;HTML 预览在侧栏查看。", + visible=1, + readonly=1, + default_sort_json='[{"column":"id","direction":"desc"}]', + default_visible_columns_json='["id","project_no","project_name","created_at"]', + ) + seed_column_metadata_batch( + cursor, + table_name="report_drafts", + columns=_REPORT_DRAFTS_COLUMNS, + valid_column_names=[str(c["column_name"]) for c in _REPORT_DRAFTS_COLUMNS], + ) + + +def init_db() -> dict[str, int]: + """幂等创建/迁移本地 SQLite 与数据管理元数据。""" + conn = get_conn() + try: + cur = conn.cursor() + init_display_metadata(cur) + _init_closing_projects_metadata(cur) + _init_achievements_metadata(cur) + _init_expense_items_metadata(cur) + _init_report_drafts_metadata(cur) + conn.commit() + return {} + finally: + conn.close() diff --git a/scripts/db/display_metadata.py b/scripts/db/display_metadata.py new file mode 100644 index 0000000..6fe3b3a --- /dev/null +++ b/scripts/db/display_metadata.py @@ -0,0 +1,324 @@ +"""匠厂数据管理展示元数据(_jiangchang_tables / _jiangchang_columns)。 + +SQLite 无原生表/字段 COMMENT;中文展示名称通过这两张元数据表提供给宿主「数据管理」页面。 +字段在界面上的顺序由 CREATE TABLE / PRAGMA table_info(cid) 决定,**不**使用 display_order 排序。 +""" + +from __future__ import annotations + +from typing import Iterable + +# 宿主会隐藏这些表,不作为业务表展示(见 jiangchang electron/data-management/types.ts) +INTERNAL_METADATA_TABLES = frozenset({"_jiangchang_tables", "_jiangchang_columns"}) +LEGACY_METADATA_TABLES = frozenset({"_schema_meta"}) +METADATA_TABLES = INTERNAL_METADATA_TABLES | LEGACY_METADATA_TABLES + +DATETIME_UNIX_SECONDS = "datetime_unix_seconds" +STANDARD_TIMESTAMP_COLUMNS = frozenset({"created_at", "updated_at"}) + +_JIANGCHANG_TABLES_DDL = """ +CREATE TABLE IF NOT EXISTS _jiangchang_tables ( + table_name TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + description TEXT, + grid_help TEXT, + sort_order INTEGER, + visible INTEGER NOT NULL DEFAULT 1, + readonly INTEGER NOT NULL DEFAULT 0, + default_sort_json TEXT, + default_visible_columns_json TEXT +) +""" + +_JIANGCHANG_COLUMNS_DDL = """ +CREATE TABLE IF NOT EXISTS _jiangchang_columns ( + table_name TEXT NOT NULL, + column_name TEXT NOT NULL, + display_name TEXT NOT NULL, + description TEXT, + grid_help TEXT, + display_order INTEGER, + visible INTEGER NOT NULL DEFAULT 1, + searchable INTEGER NOT NULL DEFAULT 1, + editable INTEGER NOT NULL DEFAULT 1, + display_type TEXT, + options_json TEXT, + PRIMARY KEY (table_name, column_name) +) +""" + +def _pragma_column_names(cursor, table_name: str) -> set[str]: + cursor.execute(f"PRAGMA table_info({table_name!r})") + return {str(row[1]) for row in cursor.fetchall()} + + +def _ensure_jiangchang_tables_columns(cursor) -> None: + """旧库补列:CREATE IF NOT EXISTS 不会给已有表加新字段。""" + cols = _pragma_column_names(cursor, "_jiangchang_tables") + if "default_sort_json" not in cols: + cursor.execute("ALTER TABLE _jiangchang_tables ADD COLUMN default_sort_json TEXT") + if "default_visible_columns_json" not in cols: + cursor.execute( + "ALTER TABLE _jiangchang_tables ADD COLUMN default_visible_columns_json TEXT" + ) + if "grid_help" not in cols: + cursor.execute("ALTER TABLE _jiangchang_tables ADD COLUMN grid_help TEXT") + + +def _ensure_jiangchang_columns_columns(cursor) -> None: + cols = _pragma_column_names(cursor, "_jiangchang_columns") + if "grid_help" not in cols: + cursor.execute("ALTER TABLE _jiangchang_columns ADD COLUMN grid_help TEXT") + + +def init_display_metadata_tables(cursor) -> None: + cursor.execute(_JIANGCHANG_TABLES_DDL) + cursor.execute(_JIANGCHANG_COLUMNS_DDL) + _ensure_jiangchang_tables_columns(cursor) + _ensure_jiangchang_columns_columns(cursor) + + +def upsert_table_metadata( + cursor, + *, + table_name: str, + display_name: str, + description: str | None = None, + grid_help: str | None = None, + visible: int = 1, + readonly: int = 0, + default_sort_json: str | None = None, + default_visible_columns_json: str | None = None, +) -> None: + """写入表级元数据。 + + default_sort_json: 宿主数据管理默认排序,JSON 数组,例如 + '[{"column":"sent_at","direction":"desc"},{"column":"id","direction":"desc"}]' + 为空则宿主回退到 updated_at → created_at → 主键。 + + default_visible_columns_json: 宿主数据管理默认展示列,JSON 字符串数组,例如 + '["stat_type","period","stage"]' + 为空则全显;用户改过「列」后以本机偏好为准(软默认,区别于 visible=0 硬隐藏)。 + + grid_help: 数据管理表格上方说明;空则不出横幅。与表单用的 description 独立,禁止互拷。 + """ + cursor.execute( + """ + INSERT INTO _jiangchang_tables ( + table_name, display_name, description, grid_help, visible, readonly, + default_sort_json, default_visible_columns_json + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(table_name) DO UPDATE SET + display_name = excluded.display_name, + description = excluded.description, + grid_help = excluded.grid_help, + visible = excluded.visible, + readonly = excluded.readonly, + default_sort_json = excluded.default_sort_json, + default_visible_columns_json = excluded.default_visible_columns_json + """, + ( + table_name, + display_name, + description, + grid_help, + visible, + readonly, + default_sort_json, + default_visible_columns_json, + ), + ) + + +def upsert_column_metadata( + cursor, + *, + table_name: str, + column_name: str, + display_name: str, + description: str | None = None, + grid_help: str | None = None, + visible: int = 1, + searchable: int = 1, + editable: int = 1, + display_type: str | None = None, + options_json: str | None = None, +) -> None: + # display_order 保留为 NULL:宿主按 PRAGMA table_info cid 展示字段,不读 display_order。 + # grid_help: 表头「?」;空则不出。与表单用的 description 独立,禁止互拷。 + cursor.execute( + """ + INSERT INTO _jiangchang_columns ( + table_name, column_name, display_name, description, grid_help, + display_order, visible, searchable, editable, display_type, options_json + ) + VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?) + ON CONFLICT(table_name, column_name) DO UPDATE SET + display_name = excluded.display_name, + description = excluded.description, + grid_help = excluded.grid_help, + display_order = NULL, + visible = excluded.visible, + searchable = excluded.searchable, + editable = excluded.editable, + display_type = excluded.display_type, + options_json = excluded.options_json + """, + ( + table_name, + column_name, + display_name, + description, + grid_help, + visible, + searchable, + editable, + display_type, + options_json, + ), + ) + + +def seed_column_metadata_batch( + cursor, + *, + table_name: str, + columns: Iterable[dict[str, object]], + valid_column_names: Iterable[str] | None = None, +) -> None: + """批量 upsert 字段元数据;传入 valid_column_names 时会在 upsert 后清理陈旧字段元数据。""" + for column in columns: + upsert_column_metadata( + cursor, + table_name=table_name, + column_name=str(column["column_name"]), + display_name=str(column["display_name"]), + description=str(column["description"]) if column.get("description") else None, + grid_help=str(column["grid_help"]) if column.get("grid_help") else None, + visible=int(column.get("visible", 1)), + searchable=int(column.get("searchable", 1)), + editable=int(column.get("editable", 1)), + display_type=str(column["display_type"]) if column.get("display_type") else None, + options_json=str(column["options_json"]) if column.get("options_json") else None, + ) + if valid_column_names is not None: + prune_stale_column_metadata(cursor, table_name, valid_column_names) + + +def prune_stale_column_metadata(cursor, table_name: str, valid_column_names: Iterable[str]) -> None: + """删除表中已不存在物理字段的元数据行(幂等初始化后不得残留旧字段)。""" + valid = set(valid_column_names) + cursor.execute( + "SELECT column_name FROM _jiangchang_columns WHERE table_name = ?", + (table_name,), + ) + for (column_name,) in cursor.fetchall(): + if str(column_name) not in valid: + cursor.execute( + "DELETE FROM _jiangchang_columns WHERE table_name = ? AND column_name = ?", + (table_name, column_name), + ) + + +def get_table_readonly(cursor, table_name: str) -> int | None: + cursor.execute( + "SELECT readonly FROM _jiangchang_tables WHERE table_name = ?", + (table_name,), + ) + row = cursor.fetchone() + if not row: + return None + return int(row[0] or 0) + + +def get_column_editable(cursor, table_name: str, column_name: str) -> int | None: + cursor.execute( + """ + SELECT editable FROM _jiangchang_columns + WHERE table_name = ? AND column_name = ? + """, + (table_name, column_name), + ) + row = cursor.fetchone() + if not row: + return None + return int(row[0] or 0) + + +def list_editable_business_columns(cursor, table_name: str) -> list[str]: + """返回 editable=1 的业务字段名(不含宿主侧主键/generated 等二次过滤)。""" + physical = list_physical_column_names(cursor, table_name) + cursor.execute( + """ + SELECT column_name, editable + FROM _jiangchang_columns + WHERE table_name = ? + """, + (table_name,), + ) + editable_set = { + str(column_name) + for column_name, editable in cursor.fetchall() + if editable is not None and int(editable) == 1 + } + return [name for name in physical if name in editable_set] + + +def table_allows_manual_create(cursor, table_name: str) -> bool: + """表级 readonly=0 且至少有一个 editable=1 字段时,宿主才应提供空白新增表单。""" + readonly = get_table_readonly(cursor, table_name) + if readonly is None or int(readonly) != 0: + return False + return bool(list_editable_business_columns(cursor, table_name)) + + +def init_display_metadata(cursor) -> None: + init_display_metadata_tables(cursor) + + +def drop_deprecated_user_table( + cursor, + table_name: str, + *, + triggers: Iterable[str] | None = None, +) -> None: + """永久退役用户可见表:删 trigger、DROP 物理表、清理展示元数据(幂等)。 + + 用于废弃 task_logs 等旧表。禁止只删元数据而保留物理表。 + 见 references/SCHEMA.md「表增删改与退役」。 + """ + for trigger_name in triggers or (): + cursor.execute(f"DROP TRIGGER IF EXISTS {trigger_name}") + default_updated_at = f"{table_name}_set_updated_at" + cursor.execute(f"DROP TRIGGER IF EXISTS {default_updated_at}") + cursor.execute(f"DROP TABLE IF EXISTS {_quote_identifier(table_name)}") + cursor.execute( + "DELETE FROM _jiangchang_columns WHERE table_name = ?", + (table_name,), + ) + cursor.execute( + "DELETE FROM _jiangchang_tables WHERE table_name = ?", + (table_name,), + ) + + +def iter_user_tables(cursor) -> Iterable[str]: + cursor.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name" + ) + for (name,) in cursor.fetchall(): + if name in METADATA_TABLES: + continue + yield str(name) + + +def _quote_identifier(name: str) -> str: + return '"' + name.replace('"', '""') + '"' + + +def list_physical_column_names(cursor, table_name: str) -> list[str]: + cursor.execute(f"PRAGMA table_info({_quote_identifier(table_name)})") + rows = cursor.fetchall() + rows.sort(key=lambda row: int(row[0])) + return [str(row[1]) for row in rows] diff --git a/scripts/db/display_metadata_validator.py b/scripts/db/display_metadata_validator.py new file mode 100644 index 0000000..42db9d1 --- /dev/null +++ b/scripts/db/display_metadata_validator.py @@ -0,0 +1,592 @@ +"""数据管理展示元数据校验(模板开发自检,不依赖宿主)。""" + +from __future__ import annotations + +import json +import os +import re +import sqlite3 +from dataclasses import dataclass, field + +from db.display_metadata import ( + DATETIME_UNIX_SECONDS, + METADATA_TABLES, + STANDARD_TIMESTAMP_COLUMNS, + iter_user_tables, + list_editable_business_columns, + list_physical_column_names, + table_allows_manual_create, +) +from db.timestamp_columns import has_updated_at_trigger +from util.constants import SKILL_SLUG +from util.slug_naming import LEGACY_EXEMPT_SLUGS, validate_slug_semantics + +_TEMPLATE_PLACEHOLDER_SLUGS = frozenset({"your-skill-slug", "your_skill_slug"}) +_ENGLISH_PLACEHOLDER_NAME_PATTERNS = ( + re.compile(r"^my\s+skill$", re.I), + re.compile(r"^your[-_\s]?skill", re.I), + re.compile(r"^skill[-_\s]?template$", re.I), + re.compile(r"^[a-z0-9_-]+$"), +) +_KEBAB_SLUG = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") +_CJK_RE = re.compile(r"[\u4e00-\u9fff]") +_FK_DDL_PATTERN = re.compile(r"\bFOREIGN\s+KEY\b|\bREFERENCES\b", re.I) + + +@dataclass +class ValidationReport: + errors: list[str] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + + @property + def ok(self) -> bool: + return not self.errors + + +def _has_cjk(text: str) -> bool: + return bool(_CJK_RE.search(text)) + + +def _looks_like_raw_physical_name(display_name: str, physical_name: str) -> bool: + if not display_name.strip(): + return True + if display_name.strip().lower() == physical_name.strip().lower(): + return True + compact = display_name.replace(" ", "").replace("_", "").lower() + if compact == physical_name.replace("_", "").lower(): + return True + return False + + +def _parse_frontmatter_field(text: str, field_name: str) -> str: + parts = text.split("---", 2) + if len(parts) < 2: + return "" + for line in parts[1].splitlines(): + match = re.match(rf"^{re.escape(field_name)}:\s*(.+)\s*$", line.strip()) + if match: + return match.group(1).strip().strip("\"'") + return "" + + +def _parse_openclaw_slug(text: str) -> str: + parts = text.split("---", 2) + if len(parts) < 2: + return "" + lines = parts[1].splitlines() + for index, line in enumerate(lines): + if re.match(r"^\s+openclaw:\s*$", line): + for inner in lines[index + 1 :]: + if inner.strip() and inner[0] not in (" ", "\t"): + break + match = re.match(r"^(\s+)slug:\s*(.+)\s*$", inner) + if match: + return match.group(2).strip().strip("\"'") + return "" + + +def parse_skill_frontmatter(skill_root: str) -> dict[str, str]: + path = os.path.join(skill_root, "SKILL.md") + with open(path, encoding="utf-8") as f: + text = f.read() + return { + "name": _parse_frontmatter_field(text, "name"), + "slug": _parse_openclaw_slug(text), + } + + +def validate_skill_frontmatter(skill_root: str, *, allow_template_placeholders: bool = True) -> ValidationReport: + report = ValidationReport() + data = parse_skill_frontmatter(skill_root) + name = str(data.get("name") or "").strip() + slug = str(data.get("slug") or "").strip() + + if not name: + report.errors.append("SKILL.md frontmatter.name 缺失") + elif not _has_cjk(name): + if allow_template_placeholders and slug in _TEMPLATE_PLACEHOLDER_SLUGS: + report.warnings.append("SKILL.md frontmatter.name 建议使用清晰的中文业务名称(模板占位可暂保留)") + else: + report.errors.append("SKILL.md frontmatter.name 应使用用户可见的中文业务名称") + else: + for pattern in _ENGLISH_PLACEHOLDER_NAME_PATTERNS: + if pattern.fullmatch(name): + if allow_template_placeholders and slug in _TEMPLATE_PLACEHOLDER_SLUGS: + report.warnings.append(f"SKILL.md frontmatter.name 仍为占位风格:{name!r}") + else: + report.errors.append(f"SKILL.md frontmatter.name 不应为英文占位名:{name!r}") + break + + if not slug: + report.errors.append("SKILL.md metadata.openclaw.slug 缺失") + elif not _KEBAB_SLUG.fullmatch(slug): + report.errors.append(f"metadata.openclaw.slug 必须为 kebab-case:{slug!r}") + elif slug in _TEMPLATE_PLACEHOLDER_SLUGS and not allow_template_placeholders: + report.errors.append(f"metadata.openclaw.slug 仍为模板占位:{slug!r}") + + constants_slug = SKILL_SLUG.strip() + if slug and constants_slug and slug != constants_slug: + report.warnings.append( + f"SKILL.md slug ({slug!r}) 与 util.constants.SKILL_SLUG ({constants_slug!r}) 不一致" + ) + + if slug and slug not in LEGACY_EXEMPT_SLUGS and _KEBAB_SLUG.fullmatch(slug): + naming_errors, naming_warnings = validate_slug_semantics( + slug, + strict=not allow_template_placeholders, + ) + report.errors.extend(naming_errors) + report.warnings.extend(naming_warnings) + + return report + + +def _quote_identifier(name: str) -> str: + return '"' + name.replace('"', '""') + '"' + + +def _table_column_info(cursor, table_name: str) -> dict[str, dict[str, object]]: + cursor.execute(f"PRAGMA table_info({_quote_identifier(table_name)})") + info: dict[str, dict[str, object]] = {} + for row in cursor.fetchall(): + info[str(row[1])] = { + "cid": int(row[0]), + "name": str(row[1]), + "type": str(row[2] or ""), + "notnull": int(row[3] or 0), + "dflt_value": row[4], + "pk": int(row[5] or 0), + } + return info + + +def _table_column_xinfo(cursor, table_name: str) -> dict[str, dict[str, object]]: + """PRAGMA table_xinfo 可识别 generated/hidden 字段(SQLite 3.31+)。""" + cursor.execute(f"PRAGMA table_xinfo({_quote_identifier(table_name)})") + info: dict[str, dict[str, object]] = {} + for row in cursor.fetchall(): + hidden = int(row[6] or 0) if len(row) > 6 else 0 + info[str(row[1])] = { + "cid": int(row[0]), + "name": str(row[1]), + "type": str(row[2] or ""), + "notnull": int(row[3] or 0), + "dflt_value": row[4], + "pk": int(row[5] or 0), + "hidden": hidden, + } + return info + + +def _is_generated_column(xinfo: dict[str, object]) -> bool: + hidden = int(xinfo.get("hidden") or 0) + return hidden in (2, 3) + + +def _is_blob_column(column_info: dict[str, object]) -> bool: + return "BLOB" in str(column_info.get("type") or "").upper() + + +def _default_uses_unixepoch(default_value: object) -> bool: + if default_value is None: + return False + text = str(default_value).strip().lower() + return "unixepoch" in text + + +def validate_options_json(options_json: str | None) -> tuple[list[str], list[str]]: + """校验 options_json:静态枚举数组,或 lookup 对象。""" + errors: list[str] = [] + warnings: list[str] = [] + if options_json is None or str(options_json).strip() == "": + return errors, warnings + try: + parsed = json.loads(str(options_json)) + except json.JSONDecodeError as exc: + errors.append(f"options_json 不是合法 JSON:{exc}") + return errors, warnings + + if isinstance(parsed, dict): + for key in ("ref_table", "value_column", "label_column"): + if not str(parsed.get(key) or "").strip(): + errors.append(f"options_json lookup 必须包含非空 {key}") + ident = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$") + for key in ("ref_table", "value_column", "label_column", "filter_column"): + raw = parsed.get(key) + if raw is None or raw == "": + continue + if not ident.match(str(raw)): + errors.append(f"options_json.{key} 必须是安全标识符,当前为 {raw!r}") + if parsed.get("filter_column") not in (None, "") and "filter_value" not in parsed: + warnings.append("options_json lookup 声明了 filter_column 但未提供 filter_value") + if "multiple" in parsed and not isinstance(parsed.get("multiple"), bool): + errors.append("options_json lookup.multiple 必须是布尔值") + return errors, warnings + + if not isinstance(parsed, list): + errors.append("options_json 必须是 JSON 数组(静态枚举)或对象(lookup)") + return errors, warnings + for index, item in enumerate(parsed): + if not isinstance(item, dict): + errors.append(f"options_json[{index}] 必须是对象") + continue + if "value" not in item or "label" not in item: + errors.append(f"options_json[{index}] 必须包含 value 与 label") + return errors, warnings + + +def validate_filter_search_metadata( + cursor, + table_name: str, +) -> tuple[list[str], list[str]]: + """对可见但未开启 searchable 的枚举 / boolean 列给出软建议。""" + warnings: list[str] = [] + cursor.execute( + """ + SELECT column_name, visible, searchable, display_type, options_json + FROM _jiangchang_columns + WHERE table_name = ? + """, + (table_name,), + ) + for column_name, visible, searchable, display_type, options_json in cursor.fetchall(): + if visible is not None and int(visible) == 0: + continue + if searchable is not None and int(searchable) != 0: + continue + + display_type_norm = str(display_type or "").strip() + is_boolean = display_type_norm == "boolean" + + has_enum = False + if options_json is not None and str(options_json).strip(): + opt_errors, _ = validate_options_json(options_json) + if not opt_errors: + try: + parsed = json.loads(str(options_json)) + except json.JSONDecodeError: + parsed = None + has_enum = isinstance(parsed, list) and len(parsed) > 0 + + if not has_enum and not is_boolean: + continue + + warnings.append( + f"{table_name}.{column_name}: 枚举/状态类列建议 searchable=1," + "以便宿主列筛选与按中文 label 全局搜索(见 references/SCHEMA.md)" + ) + + return [], warnings + + +def validate_column_permissions( + cursor, + table_name: str, + *, + physical_columns: list[str] | None = None, +) -> tuple[list[str], list[str]]: + """校验常见系统字段 editable=0,并识别技术上可写但不适合手工新增的表。""" + errors: list[str] = [] + warnings: list[str] = [] + ordered = physical_columns or list_physical_column_names(cursor, table_name) + column_info = _table_column_info(cursor, table_name) + column_xinfo = _table_column_xinfo(cursor, table_name) + + cursor.execute( + """ + SELECT column_name, editable, options_json + FROM _jiangchang_columns + WHERE table_name = ? + """, + (table_name,), + ) + meta_by_column = { + str(row[0]): {"editable": row[1], "options_json": row[2]} + for row in cursor.fetchall() + } + + for column_name, meta in meta_by_column.items(): + physical = column_info.get(column_name) or {} + xinfo = column_xinfo.get(column_name) or physical + if not physical and column_name not in column_xinfo: + continue + + pk = int(physical.get("pk") or xinfo.get("pk") or 0) + editable = meta["editable"] + if pk and editable is not None and int(editable) != 0: + errors.append(f"{table_name}.{column_name} 是主键,editable 应为 0") + + if column_name == "id" and editable is not None and int(editable) != 0: + errors.append(f"{table_name}.id 的 editable 应为 0(自增主键)") + + if _is_generated_column(xinfo) and editable is not None and int(editable) != 0: + errors.append(f"{table_name}.{column_name} 是 generated 字段,editable 应为 0") + + if _is_blob_column(physical or xinfo) and editable is not None and int(editable) != 0: + errors.append(f"{table_name}.{column_name} 是 BLOB 字段,editable 应为 0") + + opt_errors, _ = validate_options_json(meta.get("options_json")) + for item in opt_errors: + errors.append(f"{table_name}.{column_name}: {item}") + + cursor.execute( + "SELECT readonly FROM _jiangchang_tables WHERE table_name = ?", + (table_name,), + ) + readonly_row = cursor.fetchone() + readonly = int(readonly_row[0]) if readonly_row else None + editable_columns = list_editable_business_columns(cursor, table_name) + if readonly == 0 and not editable_columns: + warnings.append( + f"{table_name}: 表级 readonly=0 但所有字段 editable=0," + "宿主不应打开空白新增表单;数据应由技能业务流程产生" + ) + if readonly == 1 and editable_columns: + warnings.append( + f"{table_name}: 表级 readonly=1 但存在 editable=1 字段," + "宿主应以表级只读为准" + ) + + return errors, warnings + + +def _expected_timestamp_display_name(column_name: str) -> str: + if column_name == "created_at": + return "创建时间" + if column_name == "updated_at": + return "更新时间" + return "" + + +def validate_standard_timestamp_columns( + cursor, + table_name: str, + *, + physical_columns: list[str] | None = None, +) -> tuple[list[str], list[str]]: + errors: list[str] = [] + warnings: list[str] = [] + ordered = physical_columns or list_physical_column_names(cursor, table_name) + column_info = _table_column_info(cursor, table_name) + + for column_name in STANDARD_TIMESTAMP_COLUMNS: + if column_name not in ordered: + continue + + physical = column_info.get(column_name) + if not physical: + errors.append(f"{table_name}.{column_name} 缺少物理字段定义") + continue + + if "INT" not in str(physical["type"]).upper(): + errors.append( + f"{table_name}.{column_name} 字段类型应为 INTEGER(Unix 秒级时间戳),当前为 {physical['type']!r}" + ) + + if column_name == "created_at" and not _default_uses_unixepoch(physical["dflt_value"]): + errors.append( + f"{table_name}.created_at 应设置数据库默认值 DEFAULT (unixepoch()),当前默认值为 {physical['dflt_value']!r}" + ) + if column_name == "updated_at" and not _default_uses_unixepoch(physical["dflt_value"]): + errors.append( + f"{table_name}.updated_at 应设置数据库初始默认值 DEFAULT (unixepoch()),当前默认值为 {physical['dflt_value']!r}" + ) + + cursor.execute( + """ + SELECT display_name, display_type, editable, searchable, display_order + FROM _jiangchang_columns + WHERE table_name = ? AND column_name = ? + """, + (table_name, column_name), + ) + meta_row = cursor.fetchone() + if not meta_row: + errors.append(f"{table_name}.{column_name} 缺少 _jiangchang_columns 元数据") + continue + + display_name, display_type, editable, searchable, display_order = meta_row + expected_name = _expected_timestamp_display_name(column_name) + if str(display_name or "").strip() != expected_name: + errors.append( + f"{table_name}.{column_name} 的 display_name 应为 {expected_name!r},当前为 {display_name!r}" + ) + if str(display_type or "").strip() != DATETIME_UNIX_SECONDS: + errors.append( + f"{table_name}.{column_name} 的 display_type 应为 {DATETIME_UNIX_SECONDS!r},当前为 {display_type!r}" + ) + if editable is None or int(editable) != 0: + errors.append(f"{table_name}.{column_name} 的 editable 应为 0(系统时间字段不可编辑)") + if display_order is not None: + errors.append(f"{table_name}.{column_name} 的 display_order 应为 NULL") + if searchable is not None and int(searchable) != 0: + warnings.append(f"{table_name}.{column_name} 的时间字段通常不必开启 searchable") + + if "created_at" in ordered and "updated_at" in ordered: + if ordered.index("updated_at") < ordered.index("created_at"): + warnings.append(f"{table_name}: updated_at 不应出现在 created_at 之前") + + if "updated_at" in ordered and not has_updated_at_trigger(cursor, table_name): + errors.append( + f"{table_name}.updated_at 缺少自动维护 trigger(期望 {table_name}_set_updated_at)" + ) + + return errors, warnings + + +def column_order_warnings(table_name: str, ordered_names: list[str]) -> list[str]: + warnings: list[str] = [] + if "id" in ordered_names and ordered_names.index("id") != 0: + warnings.append(f"{table_name}: 字段 id 通常应定义在 CREATE TABLE 的第一列") + if "created_at" in ordered_names: + idx = ordered_names.index("created_at") + trailing = ordered_names[idx + 1 :] + if trailing and trailing != ["updated_at"]: + warnings.append( + f"{table_name}: created_at 之后通常只应跟 updated_at,不应再放置其他业务字段" + ) + if "updated_at" in ordered_names and "created_at" in ordered_names: + if ordered_names.index("updated_at") < ordered_names.index("created_at"): + warnings.append(f"{table_name}: updated_at 不应出现在 created_at 之前") + return warnings + + +def _table_exists(cursor, table_name: str) -> bool: + cursor.execute( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1", + (table_name,), + ) + return cursor.fetchone() is not None + + +def validate_no_sqlite_foreign_keys(cursor) -> tuple[list[str], list[str]]: + """业务表 DDL 不得含 FOREIGN KEY / REFERENCES(关联走 repository/service)。""" + errors: list[str] = [] + cursor.execute( + """ + SELECT name, sql FROM sqlite_master + WHERE type = 'table' AND name NOT LIKE 'sqlite_%' + """ + ) + for table_name, sql in cursor.fetchall(): + if table_name in METADATA_TABLES: + continue + ddl = str(sql or "") + if ddl and _FK_DDL_PATTERN.search(ddl): + errors.append( + f"业务表 {table_name!r} 的 DDL 含 SQLite FOREIGN KEY/REFERENCES;" + "请改为业务 id 列 + repository/service 逻辑关联(见 references/SCHEMA.md)" + ) + return errors, [] + + +def validate_display_metadata(conn: sqlite3.Connection, *, check_column_order: bool = True) -> ValidationReport: + report = ValidationReport() + cur = conn.cursor() + + has_tables_meta = _table_exists(cur, "_jiangchang_tables") + has_columns_meta = _table_exists(cur, "_jiangchang_columns") + if not has_tables_meta: + report.errors.append("缺少数据管理元数据表 _jiangchang_tables") + if not has_columns_meta: + report.errors.append("缺少数据管理元数据表 _jiangchang_columns") + if not has_tables_meta or not has_columns_meta: + return report + + fk_errors, _ = validate_no_sqlite_foreign_keys(cur) + report.errors.extend(fk_errors) + + for table_name in iter_user_tables(cur): + cur.execute( + "SELECT display_name FROM _jiangchang_tables WHERE table_name = ?", + (table_name,), + ) + row = cur.fetchone() + if not row or not str(row[0] or "").strip(): + report.errors.append(f"业务表 {table_name!r} 缺少 _jiangchang_tables.display_name") + elif not _has_cjk(str(row[0])): + report.errors.append(f"业务表 {table_name!r} 的 display_name 应使用中文业务名称") + + physical_columns = list_physical_column_names(cur, table_name) + if check_column_order: + report.warnings.extend(column_order_warnings(table_name, physical_columns)) + + for column_name in physical_columns: + cur.execute( + """ + SELECT display_name, visible + FROM _jiangchang_columns + WHERE table_name = ? AND column_name = ? + """, + (table_name, column_name), + ) + col_row = cur.fetchone() + if not col_row: + report.errors.append( + f"字段 {table_name}.{column_name} 缺少 _jiangchang_columns 元数据" + ) + continue + display_name = str(col_row[0] or "").strip() + visible = col_row[1] is None or int(col_row[1]) != 0 + if visible and not display_name: + report.errors.append(f"字段 {table_name}.{column_name} 的 display_name 为空") + elif visible and _looks_like_raw_physical_name(display_name, column_name): + report.errors.append( + f"字段 {table_name}.{column_name} 的 display_name 不应直接等于英文物理名:{display_name!r}" + ) + elif visible and not _has_cjk(display_name): + report.errors.append( + f"字段 {table_name}.{column_name} 的 display_name 应使用中文:{display_name!r}" + ) + + ts_errors, ts_warnings = validate_standard_timestamp_columns( + cur, + table_name, + physical_columns=physical_columns, + ) + report.errors.extend(ts_errors) + report.warnings.extend(ts_warnings) + + perm_errors, perm_warnings = validate_column_permissions( + cur, + table_name, + physical_columns=physical_columns, + ) + report.errors.extend(perm_errors) + report.warnings.extend(perm_warnings) + + _, filter_warnings = validate_filter_search_metadata(cur, table_name) + report.warnings.extend(filter_warnings) + + cur.execute("SELECT table_name, column_name FROM _jiangchang_columns") + for table_name, column_name in cur.fetchall(): + if table_name in METADATA_TABLES: + continue + cur.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name = ?", + (table_name,), + ) + if not cur.fetchone(): + report.errors.append(f"元数据引用了不存在的表:{table_name!r}") + continue + physical = list_physical_column_names(cur, str(table_name)) + if str(column_name) not in physical: + report.errors.append(f"元数据引用了不存在的字段:{table_name}.{column_name}") + + cur.execute("SELECT table_name FROM _jiangchang_tables") + for (table_name,) in cur.fetchall(): + if table_name in METADATA_TABLES: + continue + cur.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name = ?", + (table_name,), + ) + if not cur.fetchone(): + report.errors.append(f"元数据引用了不存在的表:{table_name!r}") + + return report + + +def validate_template_database(conn: sqlite3.Connection) -> ValidationReport: + """模板自检入口:校验展示元数据完整性,并扫描业务表 DDL 是否含 SQLite 外键。""" + return validate_display_metadata(conn) diff --git a/scripts/db/timestamp_columns.py b/scripts/db/timestamp_columns.py new file mode 100644 index 0000000..95245ab --- /dev/null +++ b/scripts/db/timestamp_columns.py @@ -0,0 +1,50 @@ +"""标准时间字段:Unix 秒级时间戳、默认值与 updated_at 自动维护。 + +业务表若包含 created_at / updated_at,在 CREATE TABLE 后调用 +``init_updated_at_trigger(cursor, table_name)``。模板默认不建业务表。 +""" + +from __future__ import annotations + +STANDARD_TIMESTAMP_COLUMNS = frozenset({"created_at", "updated_at"}) +DATETIME_UNIX_SECONDS = "datetime_unix_seconds" + + +def updated_at_trigger_name(table_name: str) -> str: + return f"{table_name}_set_updated_at" + + +def _quote_identifier(name: str) -> str: + return '"' + name.replace('"', '""') + '"' + + +def init_updated_at_trigger(cursor, table_name: str) -> None: + """幂等创建 {table}_set_updated_at:业务字段 UPDATE 后刷新 updated_at。""" + trigger = updated_at_trigger_name(table_name) + quoted = _quote_identifier(table_name) + quoted_trigger = _quote_identifier(trigger) + cursor.execute( + f""" +CREATE TRIGGER IF NOT EXISTS {quoted_trigger} +AFTER UPDATE ON {quoted} +FOR EACH ROW +BEGIN + UPDATE {quoted} + SET updated_at = unixepoch() + WHERE id = NEW.id AND updated_at = OLD.updated_at; +END +""" + ) + + +def has_updated_at_trigger(cursor, table_name: str) -> bool: + trigger_name = updated_at_trigger_name(table_name) + cursor.execute( + """ + SELECT 1 FROM sqlite_master + WHERE type = 'trigger' AND name = ? AND tbl_name = ? + LIMIT 1 + """, + (trigger_name, table_name), + ) + return cursor.fetchone() is not None diff --git a/scripts/main.py b/scripts/main.py new file mode 100644 index 0000000..4084c53 --- /dev/null +++ b/scripts/main.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +skill-template CLI 入口。 + +复制为新技能后,请修改注释与常量,但保留当前分层结构: +cli(argv)→ service(业务编排)→ db(持久化)→ util(通用工具)。 + +jiangchang_skill_core 来自宿主共享 Python runtime 安装的 jiangchang-platform-kit, +不在技能仓库内 vendored。 +""" +from __future__ import annotations + +import os +import sys + +if sys.platform == "win32": + import io + + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") + sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace") + +_scripts_dir = os.path.dirname(os.path.abspath(__file__)) +if _scripts_dir not in sys.path: + sys.path.insert(0, _scripts_dir) + +from jiangchang_skill_core.runtime_env import apply_cli_local_defaults + +apply_cli_local_defaults() + +from util.config_bootstrap import bootstrap_skill_config + +bootstrap_skill_config() + +from cli.app import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/service/docx_service.py b/scripts/service/docx_service.py new file mode 100644 index 0000000..5b0698d --- /dev/null +++ b/scripts/service/docx_service.py @@ -0,0 +1,86 @@ +"""Word 初稿渲染(python-docx)。 + +初稿结构:封面信息 → 目录骨架 → 六章(3/4/5 章自动成文,1/2/6 章素材要点 + [待补充] 占位)。 +写入技能 output/ 目录(用户可取)。python-docx 为技能特有依赖(requirements.txt 声明)。 +""" + +from __future__ import annotations + +from typing import Any + +from util.logging_config import get_skill_logger +from util.runtime_paths import get_output_dir + + +def _log(): + try: + return get_skill_logger() + except RuntimeError: + return None + + +def _check_docx_available() -> None: + try: + import docx # noqa: F401 + except ImportError as exc: + raise RuntimeError( + "python-docx 未安装(技能依赖安装异常);请重新安装技能,或先用 HTML 预览查看初稿内容。" + ) from exc + + +def write_docx(project: dict[str, Any], sections: dict[str, list[str]], batch_no: str) -> str: + """生成《结题报告(初稿)》docx,返回文件路径。""" + _check_docx_available() + import docx + from docx.shared import Pt + + log = _log() + log.info("docx_render_start project_no=%s stage=docx", project.get("project_no")) + + document = docx.Document() + + title = document.add_heading("结题报告(初稿)", level=0) + title.alignment = 1 + + info_table = document.add_table(rows=0, cols=2) + info_table.style = "Table Grid" + for label, value in ( + ("项目名称", project.get("project_name")), + ("项目编号", project.get("project_no")), + ("项目负责人", project.get("pi_name")), + ("所属学院", project.get("department")), + ("项目类型", project.get("program")), + ("执行周期", f"{project.get('start_date') or '—'} ~ {project.get('end_date') or '—'}"), + ("生成批次", batch_no), + ): + row = info_table.add_row().cells + row[0].text = str(label) + row[1].text = str(value if value not in (None, "") else "—") + + document.add_heading("目录(骨架,终稿请按本单位模板调整)", level=1) + for section_title in sections: + document.add_paragraph(section_title, style="List Number") + + for section_title, paragraphs in sections.items(): + document.add_heading(section_title, level=1) + for paragraph in paragraphs: + text = str(paragraph) + p = document.add_paragraph() + run = p.add_run(text) + if text.startswith("[待补充"): + run.font.color.rgb = None # 占位文本以提示语区分,不依赖颜色 + run.italic = True + + notice = document.add_paragraph() + notice_run = notice.add_run( + "说明:本初稿由结题报告初稿助手按台账数据自动生成;数字章节(完成情况/决算/成果清单)请逐项核对," + "叙事章节仅提供素材要点,须由项目负责人本人撰写终稿;严禁引入台账之外的成果或结论。" + ) + notice_run.font.size = Pt(9) + + project_no = str(project.get("project_no") or "未编号") + filename = f"结题报告初稿-{project_no}.docx" + path = get_output_dir() + "/" + filename + document.save(path) + log.info("docx_render_done project_no=%s path=%s stage=docx", project_no, path) + return path diff --git a/scripts/service/draft_service.py b/scripts/service/draft_service.py new file mode 100644 index 0000000..2ec5aff --- /dev/null +++ b/scripts/service/draft_service.py @@ -0,0 +1,354 @@ +"""结题报告初稿生成内核(唯一业务内核)。 + +五步组装(对齐《核心建设内容:各部门"Skill"深度需求规格书》科研处 Skill 002): + C1 指标对比:按成果类型汇总实际数量 vs 预期指标 → 超额完成/持平/未达标/数据待补 + C2 完成情况章节:逐指标成文 + 成果明细(来源标注) + C3 决算转换:按科目汇总金额与占比,生成规范描述句(金额不改写) + C4 素材要点:代表性成果与年度报告摘句逐条提取(标注来源) + C5 骨架组装:通用结题结构,叙事章节只放素材要点 + [待补充] 指引 + W1 数据完整性:缺数据生成占位,不阻塞整稿 + +红线:初稿中每个数字都来自台账;严禁编造数据与虚构论文结论。 +""" + +from __future__ import annotations + +import json +import uuid +from datetime import date +from typing import Any, Optional + +from db.connection import get_conn, init_db +from util.logging_config import get_skill_logger +from util.timeutil import now_unix + +_PLACEHOLDER = "[待补充:请结合素材要点与原始报告撰写,注意引用本人成果]" + +_EXPENSE_SENTENCE_TEMPLATES = { + "设备费": "购置/使用科研设备", + "材料费": "采购实验材料", + "测试化验加工费": "开展测试化验与加工", + "燃料动力费": "支付燃料动力费用", + "差旅费": "赴外开展调研与学术交流", + "会议费": "组织/参加学术会议", + "国际合作与交流费": "开展国际合作与交流", + "出版/文献/信息传播/知识产权事务费": "支付出版与知识产权事务费用", + "劳务费": "支付参与项目的研究生与临时聘用人员劳务", + "专家咨询费": "支付专家咨询费用", + "其他": "其他与项目研究直接相关的支出", +} + + +def _log(): + try: + return get_skill_logger() + except RuntimeError: + return None + + +def _wan(value: Optional[float]) -> str: + if value is None: + return "—" + return f"{value / 10000:.2f} 万元" + + +def _camel(name: str) -> str: + parts = str(name).split("_") + if len(parts) <= 1: + return str(name) + head, *rest = parts + return head + "".join(p[:1].upper() + p[1:] for p in rest if p) + + +def _snake(name: str) -> str: + return "".join("_" + ch.lower() if ch.isupper() else ch for ch in str(name)).lstrip("_") + + +def _norm_project(raw: dict[str, Any]) -> dict[str, Any]: + aliases = { + "projectNo": ("project_no", "项目编号"), + "projectName": ("project_name", "项目名称"), + "piName": ("pi_name", "负责人"), + "department": ("department", "学院", "所属学院"), + "program": ("program", "项目类型", "资助渠道"), + "startDate": ("start_date", "执行期开始", "开始日期"), + "endDate": ("end_date", "执行期结束", "结束日期"), + "expected": ("expected_json", "预期成果指标", "预期指标"), + "annualReportExcerpt": ("annual_report_excerpt", "年度报告摘句", "年度报告"), + "status": ("status", "状态"), + } + out: dict[str, Any] = {} + for key, value in raw.items(): + out.setdefault(str(key), value) + for canonical, names in aliases.items(): + if str(key) in names or _camel(str(key)) == canonical: + out.setdefault(canonical, value) + return out + + +def _norm_achievement(raw: dict[str, Any]) -> dict[str, Any]: + aliases = { + "projectNo": ("project_no", "项目编号"), + "achievementType": ("achievement_type", "成果类型", "类型"), + "title": ("title", "成果名称", "标题"), + "venueOrNo": ("venue_or_no", "期刊/专利号", "期刊", "专利号"), + "achDate": ("ach_date", "日期", "发表日期"), + "isRepresentative": ("is_representative", "代表性成果"), + "note": ("note", "备注"), + } + out: dict[str, Any] = {} + for key, value in raw.items(): + out.setdefault(str(key), value) + for canonical, names in aliases.items(): + if str(key) in names or _camel(str(key)) == canonical: + out.setdefault(canonical, value) + return out + + +def _norm_expense(raw: dict[str, Any]) -> dict[str, Any]: + aliases = { + "projectNo": ("project_no", "项目编号"), + "category": ("category", "科目"), + "amount": ("amount", "金额"), + "occurredDate": ("occurred_date", "发生日期"), + "description": ("description", "原始摘要", "描述"), + } + out: dict[str, Any] = {} + for key, value in raw.items(): + out.setdefault(str(key), value) + for canonical, names in aliases.items(): + if str(key) in names or _camel(str(key)) == canonical: + out.setdefault(canonical, value) + return out + + +def _c1_compare(expected: list[dict[str, Any]], achievements: list[dict[str, Any]]) -> list[dict[str, Any]]: + """C1:按类型对比预期指标与实际成果。""" + counts: dict[str, int] = {} + for ach in achievements: + ach_type = str(ach.get("achievement_type") or "").strip() + counts[ach_type] = counts.get(ach_type, 0) + 1 + rows = [] + for item in expected: + item_type = str(item.get("type") or "").strip() + target = item.get("target") + try: + target_v = int(float(target)) + except (TypeError, ValueError): + target_v = None + unit = str(item.get("unit") or "项") + actual = counts.get(item_type) + if target_v is None: + verdict, detail = "数据待补", f"预期指标未配置数值(类型:{item_type})" + elif actual is None: + verdict, detail = "未达标", f"预期 {target_v} {unit},台账中未录入该类成果" + else: + if actual > target_v: + verdict = "超额完成" + elif actual == target_v: + verdict = "持平" + else: + verdict = "未达标" + detail = f"预期 {target_v} {unit},实际 {actual} {unit},{verdict}" + rows.append({"type": item_type, "target": target_v, "unit": unit, "actual": actual, "verdict": verdict, "detail": detail}) + return rows + + +def _c3_expense_sentences(expenses: list[dict[str, Any]], total_approved: Optional[float]) -> tuple[list[dict[str, Any]], str]: + """C3:按科目汇总并生成规范描述句。返回 (rows, 汇总段落)。""" + by_category: dict[str, float] = {} + for item in expenses: + try: + amount = float(item.get("amount") or 0) + except (TypeError, ValueError): + amount = 0.0 + category = str(item.get("category") or "其他").strip() or "其他" + by_category[category] = by_category.get(category, 0.0) + amount + total = round(sum(by_category.values()), 2) + rows = [] + sentences = [] + for category, amount in sorted(by_category.items(), key=lambda kv: -kv[1]): + ratio = round(amount / total, 4) if total > 0 else 0.0 + action = _EXPENSE_SENTENCE_TEMPLATES.get(category, f"开展{category}相关支出") + sentence = f"{action},支出 {_wan(amount)},占比 {ratio * 100:.1f}%" + rows.append({"category": category, "amount": round(amount, 2), "ratio": ratio, "sentence": sentence}) + sentences.append(sentence) + if not rows: + return [], "经费决算明细尚未录入,请在补录后重新生成初稿(W1)。" + tail = f"。项目决算合计支出 {_wan(total)}。" + if total_approved is not None: + tail += "支出总额控制在立项经费范围内。" if total <= total_approved else "支出总额超出立项经费,请补充说明。" + return rows, "本项目经费支出按科目如下:" + ";".join(sentences) + "。" + tail + + +def _c4_narrative_points(achievements: list[dict[str, Any]], excerpt: str) -> list[dict[str, str]]: + """C4:从代表性成果与年度报告摘句提取叙事素材要点。""" + points = [] + for ach in achievements: + if ach.get("is_representative"): + title = str(ach.get("title") or "").strip() + venue = str(ach.get("venue_or_no") or "").strip() + points.append({"source": "成果清单", "point": f"代表性成果:{title}" + (f"({venue})" if venue else "")}) + for sentence in str(excerpt or "").replace(";", "。").split("。"): + sentence = sentence.strip() + if sentence: + points.append({"source": "年度报告", "point": sentence}) + return points + + +def build_draft_sections( + project: dict[str, Any], + achievements: list[dict[str, Any]], + expenses: list[dict[str, Any]], +) -> dict[str, Any]: + """组装初稿各章节数据(纯计算,不落库不出文件)。""" + import json as _json + + project = _norm_project(project) + raw_expected = project.get("expected_json") + if raw_expected in (None, ""): + raw_expected = project.get("expected") or project.get("expectedJson") or [] + if isinstance(raw_expected, str): + try: + expected = _json.loads(raw_expected) + except (TypeError, ValueError): + expected = [] + elif isinstance(raw_expected, list): + expected = raw_expected + else: + expected = [] + if isinstance(expected, dict): + expected = expected.get("indicators") or [] + + achievements = [{_snake(k): v for k, v in _norm_achievement(a).items()} for a in achievements] + expenses = [{_snake(k): v for k, v in _norm_expense(e).items()} for e in expenses] + + completion = _c1_compare(expected, achievements) + expense_rows, expense_paragraph = _c3_expense_sentences(expenses, None) + excerpt = project.get("annual_report_excerpt") or project.get("annualReportExcerpt") or "" + narrative_points = _c4_narrative_points(achievements, str(excerpt)) + + achievement_lines = [] + for ach in achievements: + title = str(ach.get("title") or "").strip() + venue = str(ach.get("venue_or_no") or "").strip() + ach_date = str(ach.get("ach_date") or "").strip() + line = f"{ach.get('achievement_type')}:{title}" + (f"({venue},{ach_date})" if venue or ach_date else "") + achievement_lines.append(line + "。来源:成果清单") + + sections = { + "1. 研究背景与目标": [_PLACEHOLDER], + "2. 研究内容与进展": ( + [f"· {p['point']}(来源:{p['source']})" for p in narrative_points] or [f"· {_PLACEHOLDER}"] + ) + [_PLACEHOLDER], + "3. 预期成果完成情况": [f"{row['type']}:{row['detail']}。来源:预期指标与成果清单" for row in completion] + or ["预期成果指标未配置,请在项目台账补录后重新生成(W1)。"], + "4. 经费决算说明": [expense_paragraph, "明细:"] + [f"· {row['sentence']}" for row in expense_rows], + "5. 成果清单": achievement_lines or ["成果清单尚未录入,请在补录后重新生成初稿(W1)。"], + "6. 存在问题与展望": [_PLACEHOLDER], + } + return { + "completion": completion, + "expense_rows": expense_rows, + "expense_paragraph": expense_paragraph, + "narrative_points": narrative_points, + "achievement_lines": achievement_lines, + "sections": sections, + } + + +def run_draft(project_raw: dict[str, Any], achievements: list[dict[str, Any]], expenses: list[dict[str, Any]], *, batch_no: str = "") -> dict[str, Any]: + """初稿内核:三步生成 → 写 docx + HTML 预览 → 落库。""" + from service.docx_service import write_docx + from service.report_service import write_preview_for_kernel + + log = _log() + init_db() + project = _norm_project(project_raw) + project_no = str(project.get("project_no") or "").strip() + if not project_no: + raise ValueError("项目编号(project_no)不能为空") + batch_no = batch_no or now_unix().to_bytes(5, "big").hex() + uuid.uuid4().hex[:6] + + if log: + log.info("draft_kernel_start batch_no=%s project_no=%s stage=kernel", batch_no, project_no) + + sections_data = build_draft_sections(project, achievements, expenses) + sections = sections_data["sections"] + + draft_file = write_docx(project, sections, batch_no) + + draft = { + "project_no": project_no, + "project_name": str(project.get("project_name") or ""), + "batch_no": batch_no, + "sections": sections, + "completion": sections_data["completion"], + "narrative_points": sections_data["narrative_points"], + } + preview_file = write_preview_for_kernel(draft=draft) + + conn = get_conn() + try: + cur = conn.cursor() + cur.execute( + """ + INSERT INTO report_drafts + (batch_no, project_no, project_name, completion_json, narrative_points_json, draft_file, preview_file) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + batch_no, + project_no, + str(project.get("project_name") or ""), + json.dumps(sections_data["completion"], ensure_ascii=False), + json.dumps(sections_data["narrative_points"], ensure_ascii=False), + draft_file, + preview_file, + ), + ) + draft_id = int(cur.lastrowid) + cur.execute( + "UPDATE closing_projects SET status = '初稿已生成', updated_at = ? WHERE project_no = ?", + (now_unix(), project_no), + ) + conn.commit() + finally: + conn.close() + + payload = { + "ok": True, + "batch_no": batch_no, + "draft_id": draft_id, + "project_no": project_no, + "draft_file": draft_file, + "preview_file": preview_file, + "completion": sections_data["completion"], + "narrative_points_count": len(sections_data["narrative_points"]), + "disclaimer": "初稿由台账数据与模板生成;数据章节请核对,叙事章节须本人终审,严禁引入台账之外的成果。", + } + if log: + log.info("draft_kernel_done batch_no=%s project_no=%s draft_id=%s stage=kernel", batch_no, project_no, draft_id) + return payload + + +def query_pending_projects(project_no: str = "") -> list[dict[str, Any]]: + """读取待结题项目(可按项目编号筛选),附成果与决算数量。""" + conn = get_conn() + try: + cur = conn.cursor() + if project_no: + cur.execute("SELECT * FROM closing_projects WHERE project_no = ?", (project_no,)) + else: + cur.execute("SELECT * FROM closing_projects WHERE status = '待结题' ORDER BY id") + columns = [d[0] for d in cur.description] + projects = [dict(zip(columns, row)) for row in cur.fetchall()] + for project in projects: + pno = project.get("project_no") + cur.execute("SELECT COUNT(*) FROM achievements WHERE project_no = ?", (pno,)) + project["achievement_count"] = int(cur.fetchone()[0]) + cur.execute("SELECT COUNT(*) FROM expense_items WHERE project_no = ?", (pno,)) + project["expense_count"] = int(cur.fetchone()[0]) + finally: + conn.close() + return projects diff --git a/scripts/service/entitlement_service.py b/scripts/service/entitlement_service.py new file mode 100644 index 0000000..176ea11 --- /dev/null +++ b/scripts/service/entitlement_service.py @@ -0,0 +1,46 @@ +"""在线鉴权(可选模板)。""" + +from __future__ import annotations + +from typing import Tuple + +import requests +from jiangchang_skill_core import config + + +def check_entitlement(skill_slug: str) -> Tuple[bool, str]: + auth_base = (config.get("JIANGCHANG_AUTH_BASE_URL") or "").strip().rstrip("/") + if not auth_base: + return True, "" + user_id = (config.get("JIANGCHANG_USER_ID") or "").strip() + if not user_id: + return False, "鉴权失败:缺少用户身份(JIANGCHANG_USER_ID)" + + auth_api_key = (config.get("JIANGCHANG_AUTH_API_KEY") or "").strip() + timeout = int((config.get("JIANGCHANG_AUTH_TIMEOUT_SECONDS") or "5").strip()) + headers = {"Content-Type": "application/json"} + if auth_api_key: + headers["Authorization"] = f"Bearer {auth_api_key}" + payload = { + "user_id": user_id, + "skill_slug": skill_slug, + "trace_id": (config.get("JIANGCHANG_TRACE_ID") or "").strip(), + "context": {"entry": "main.py"}, + } + try: + res = requests.post(f"{auth_base}/api/entitlements/check", json=payload, headers=headers, timeout=timeout) + except requests.RequestException as exc: + return False, f"鉴权请求失败:{exc}" + if res.status_code != 200: + return False, f"鉴权服务异常:HTTP {res.status_code}" + try: + body = res.json() + except ValueError: + return False, "鉴权服务异常:返回非 JSON" + code = body.get("code") + data = body.get("data") or {} + if code != 200: + return False, str(body.get("msg") or "鉴权失败") + if not data.get("allow", False): + return False, str(data.get("reason") or "未购买或已过期") + return True, "" diff --git a/scripts/service/html_page_service.py b/scripts/service/html_page_service.py new file mode 100644 index 0000000..4123b6e --- /dev/null +++ b/scripts/service/html_page_service.py @@ -0,0 +1,78 @@ +"""技能 HTML 报告页:写入 `{skill_data}/html/*.html`(宿主扫描预览)。 + +契约要点(权威说明见 `references/ACTIONS.md`「HTML 页面报告」): + +- 目录:仅一层 `html/*.html`(非递归) +- 重新生成 Action id 必须为 `render-html`(宿主硬编码) +- 页面布局底线见 `references/ACTIONS.md`「宿主预览布局」;模板壳 `assets/html/example-report.html` +- 成功后建议 `steps: refreshCatalog` 以重扫侧栏 +- 本模块只提供路径/模板/原子写;业务 KPI/表格由各技能自行填充 +""" + +from __future__ import annotations + +import os +import re +from typing import Mapping + +from util.logging_config import get_skill_logger +from util.runtime_paths import get_html_dir, get_skill_root + +# Host Data Management looks up this exact Skill Action id for「重新生成」. +HOST_HTML_RENDER_ACTION_ID = "render-html" + +_UNSAFE_NAME = re.compile(r"[\\/]|^\.\.|\.\.$") + + +def _log(): + try: + return get_skill_logger() + except RuntimeError: + return None + + +def assert_html_filename(filename: str) -> str: + name = (filename or "").strip() + if not name.lower().endswith(".html"): + raise ValueError(f"html filename must end with .html: {filename!r}") + if _UNSAFE_NAME.search(name) or "/" in name or "\\" in name: + raise ValueError(f"html filename must be a basename only: {filename!r}") + if len(name) > 200: + raise ValueError(f"html filename too long: {filename!r}") + return name + + +def html_page_path(filename: str, *, create_dir: bool = True) -> str: + return os.path.join(get_html_dir(create=create_dir), assert_html_filename(filename)) + + +def load_html_template(relative_under_skill_root: str) -> str: + """Load UTF-8 template from skill root (e.g. ``assets/html/example-report.html``).""" + rel = (relative_under_skill_root or "").strip().replace("\\", "/") + if not rel or rel.startswith("/") or ".." in rel.split("/"): + raise ValueError(f"invalid template path: {relative_under_skill_root!r}") + path = os.path.join(get_skill_root(), *rel.split("/")) + with open(path, encoding="utf-8") as f: + return f.read() + + +def render_html_placeholders(template: str, mapping: Mapping[str, str]) -> str: + """Replace ``{{KEY}}`` tokens. Unknown keys are left as-is.""" + page = template + for key, value in mapping.items(): + page = page.replace("{{" + str(key) + "}}", str(value)) + return page + + +def write_html_page(filename: str, html: str) -> str: + """Atomically write UTF-8 HTML under ``html/``. Returns absolute path.""" + dest = html_page_path(filename, create_dir=True) + tmp = dest + ".tmp" + text = html if isinstance(html, str) else str(html) + with open(tmp, "w", encoding="utf-8", newline="\n") as f: + f.write(text) + os.replace(tmp, dest) + log = _log() + if log: + log.info("html_page_written path=%s bytes=%s", dest, len(text.encode("utf-8"))) + return dest diff --git a/scripts/service/ledger_service.py b/scripts/service/ledger_service.py new file mode 100644 index 0000000..8d48d45 --- /dev/null +++ b/scripts/service/ledger_service.py @@ -0,0 +1,313 @@ +"""结题项目 / 成果清单 / 决算明细 / 初稿记录 的维护与查询。""" + +from __future__ import annotations + +import json +from typing import Any, Optional + +from db.connection import get_conn, init_db +from service.draft_service import _norm_achievement, _norm_expense, _norm_project +from util.logging_config import get_skill_logger +from util.timeutil import now_unix + +_PROJECT_TEXT_FIELDS = ("project_name", "pi_name", "department", "program", "start_date", "end_date", "annual_report_excerpt", "status") +_KNOWN_STATUS = ("待结题", "初稿已生成", "已提交") +_KNOWN_ACH_TYPES = ("论文", "专利", "人才培养", "其他") + + +def _log(): + try: + return get_skill_logger() + except RuntimeError: + return None + + +def _snake(name: str) -> str: + return "".join("_" + ch.lower() if ch.isupper() else ch for ch in str(name)).lstrip("_") + + +def _to_float(value: Any) -> Optional[float]: + if value is None or value == "": + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def upsert_project(fields: dict[str, Any]) -> dict[str, Any]: + fields = {_snake(k): v for k, v in _norm_project(fields).items()} + project_no = str(fields.get("project_no") or "").strip() + if not project_no: + raise ValueError("项目编号(project_no)不能为空") + values: dict[str, Any] = {"project_no": project_no} + for key in _PROJECT_TEXT_FIELDS: + if fields.get(key) not in (None, ""): + values[key] = str(fields[key]).strip() + if values.get("status") and values["status"] not in _KNOWN_STATUS: + raise ValueError(f"status 只接受 {'、'.join(_KNOWN_STATUS)}:{values['status']}") + if fields.get("expected_json") not in (None, ""): + raw = fields["expected_json"] + try: + expected = json.loads(raw) if isinstance(raw, str) else raw + except (TypeError, ValueError) as exc: + raise ValueError(f"预期指标 JSON 解析失败:{exc}") from exc + if not isinstance(expected, list): + raise ValueError("预期指标应为数组,如 [{\"type\":\"论文\",\"target\":3,\"unit\":\"篇\"}]") + values["expected_json"] = json.dumps(expected, ensure_ascii=False) + + init_db() + conn = get_conn() + try: + cur = conn.cursor() + cur.execute("SELECT id FROM closing_projects WHERE project_no = ?", (project_no,)) + row = cur.fetchone() + if row: + project_id = int(row[0]) + set_clause = ", ".join(f"{k} = ?" for k in values if k != "project_no") + cur.execute( + f"UPDATE closing_projects SET {set_clause}, updated_at = ? WHERE id = ?", + [values[k] for k in values if k != "project_no"] + [now_unix(), project_id], + ) + action = "updated" + else: + columns = ", ".join(values.keys()) + placeholders = ", ".join("?" for _ in values) + cur.execute( + f"INSERT INTO closing_projects ({columns}, created_at, updated_at) VALUES ({placeholders}, ?, ?)", + list(values.values()) + [now_unix(), now_unix()], + ) + project_id = int(cur.lastrowid) + action = "created" + conn.commit() + finally: + conn.close() + log = _log() + if log: + log.info("closing_project_upserted project_no=%s action=%s stage=ledger", project_no, action) + return {"ok": True, "project_no": project_no, "id": project_id, "action": action} + + +def upsert_achievement(fields: dict[str, Any]) -> dict[str, Any]: + achievement = {_snake(k): v for k, v in _norm_achievement(fields).items()} + project_no = str(achievement.get("project_no") or "").strip() + title = str(achievement.get("title") or "").strip() + ach_type = str(achievement.get("achievement_type") or "").strip() + if not project_no: + raise ValueError("项目编号(project_no)不能为空") + if not ach_type: + raise ValueError("成果类型(achievement_type)不能为空") + if ach_type not in _KNOWN_ACH_TYPES: + raise ValueError(f"成果类型只接受 {'、'.join(_KNOWN_ACH_TYPES)}:{ach_type}") + if not title: + raise ValueError("成果名称(title)不能为空") + + values = { + "project_no": project_no, + "achievement_type": ach_type, + "title": title, + "venue_or_no": str(achievement.get("venue_or_no") or "").strip(), + "ach_date": str(achievement.get("ach_date") or "").strip(), + "is_representative": 1 if str(achievement.get("is_representative") or "").strip() in ("1", "true", "True", "是") else 0, + "note": str(achievement.get("note") or "").strip(), + } + init_db() + conn = get_conn() + try: + cur = conn.cursor() + cur.execute("SELECT id FROM closing_projects WHERE project_no = ?", (project_no,)) + if not cur.fetchone(): + raise ValueError(f"项目不存在,请先录入结题项目:{project_no}") + cur.execute( + """ + INSERT INTO achievements + (project_no, achievement_type, title, venue_or_no, ach_date, is_representative, note, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + list(values.values()) + [now_unix(), now_unix()], + ) + achievement_id = int(cur.lastrowid) + conn.commit() + finally: + conn.close() + log = _log() + if log: + log.info("achievement_added project_no=%s type=%s stage=ledger", project_no, ach_type) + return {"ok": True, "id": achievement_id, "project_no": project_no, "title": title} + + +def upsert_expense(fields: dict[str, Any]) -> dict[str, Any]: + expense = {_snake(k): v for k, v in _norm_expense(fields).items()} + project_no = str(expense.get("project_no") or "").strip() + category = str(expense.get("category") or "").strip() + amount = _to_float(expense.get("amount")) + if not project_no: + raise ValueError("项目编号(project_no)不能为空") + if not category: + raise ValueError("科目(category)不能为空") + if amount is None or amount < 0: + raise ValueError(f"金额必须是非负数字:{fields.get('amount')}") + + values = { + "project_no": project_no, + "category": category, + "amount": round(amount, 2), + "occurred_date": str(expense.get("occurred_date") or "").strip(), + "description": str(expense.get("description") or "").strip(), + } + init_db() + conn = get_conn() + try: + cur = conn.cursor() + cur.execute("SELECT id FROM closing_projects WHERE project_no = ?", (project_no,)) + if not cur.fetchone(): + raise ValueError(f"项目不存在,请先录入结题项目:{project_no}") + cur.execute( + """ + INSERT INTO expense_items + (project_no, category, amount, occurred_date, description, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + list(values.values()) + [now_unix(), now_unix()], + ) + expense_id = int(cur.lastrowid) + conn.commit() + finally: + conn.close() + log = _log() + if log: + log.info("expense_added project_no=%s category=%s stage=ledger", project_no, category) + return {"ok": True, "id": expense_id, "project_no": project_no, "category": category, "amount": values["amount"]} + + +def _import_generic(rows: Any, upsert, label: str) -> dict[str, Any]: + if not isinstance(rows, list) or not rows: + raise ValueError(f"导入内容应为非空 JSON 数组({label})") + if len(rows) > 500: + raise ValueError(f"单次导入条数过多({len(rows)}),上限 500 条") + results = [] + for idx, raw in enumerate(rows): + if not isinstance(raw, dict): + raise ValueError(f"第 {idx + 1} 条不是 JSON 对象") + results.append(upsert(raw)) + return { + "ok": True, + "count": len(results), + "created": sum(1 for r in results if r["action"] == "created"), + "updated": sum(1 for r in results if r["action"] == "updated"), + "items": results, + } + + +def import_projects(rows: list[dict[str, Any]]) -> dict[str, Any]: + return _import_generic(rows, lambda row: upsert_project({_snake(k): v for k, v in _norm_project(row).items()}), "结题项目") + + +def import_achievements(rows: list[dict[str, Any]]) -> dict[str, Any]: + return _import_generic(rows, lambda row: upsert_achievement({_snake(k): v for k, v in _norm_achievement(row).items()}), "成果清单") + + +def import_expenses(rows: list[dict[str, Any]]) -> dict[str, Any]: + return _import_generic(rows, lambda row: upsert_expense({_snake(k): v for k, v in _norm_expense(row).items()}), "决算明细") + + +def list_projects(status: str = "") -> list[dict[str, Any]]: + init_db() + conn = get_conn() + try: + cur = conn.cursor() + if status: + cur.execute("SELECT * FROM closing_projects WHERE status = ? ORDER BY id", (status,)) + else: + cur.execute("SELECT * FROM closing_projects ORDER BY id") + columns = [d[0] for d in cur.description] + rows = [dict(zip(columns, row)) for row in cur.fetchall()] + finally: + conn.close() + return rows + + +def list_achievements(project_no: str) -> list[dict[str, Any]]: + conn = get_conn() + try: + cur = conn.cursor() + cur.execute("SELECT * FROM achievements WHERE project_no = ? ORDER BY id", (project_no,)) + columns = [d[0] for d in cur.description] + rows = [dict(zip(columns, row)) for row in cur.fetchall()] + finally: + conn.close() + return rows + + +def list_expenses(project_no: str) -> list[dict[str, Any]]: + conn = get_conn() + try: + cur = conn.cursor() + cur.execute("SELECT * FROM expense_items WHERE project_no = ? ORDER BY id", (project_no,)) + columns = [d[0] for d in cur.description] + rows = [dict(zip(columns, row)) for row in cur.fetchall()] + finally: + conn.close() + return rows + + +def list_drafts(limit: int = 20) -> list[dict[str, Any]]: + init_db() + limit = max(1, min(int(limit), 200)) + conn = get_conn() + try: + cur = conn.cursor() + cur.execute( + "SELECT id, batch_no, project_no, project_name, draft_file, preview_file, created_at FROM report_drafts ORDER BY id DESC LIMIT ?", + (limit,), + ) + columns = [d[0] for d in cur.description] + rows = [dict(zip(columns, row)) for row in cur.fetchall()] + finally: + conn.close() + return rows + + +def get_draft(draft_id: int) -> dict[str, Any]: + init_db() + conn = get_conn() + try: + cur = conn.cursor() + cur.execute("SELECT * FROM report_drafts WHERE id = ?", (draft_id,)) + row = cur.fetchone() + if not row: + raise ValueError(f"初稿记录不存在:{draft_id}") + columns = [d[0] for d in cur.description] + draft = dict(zip(columns, row)) + finally: + conn.close() + for key in ("completion_json", "narrative_points_json"): + try: + draft[key[:-5]] = json.loads(draft.get(key) or "[]") + except (TypeError, ValueError): + draft[key[:-5]] = [] + return draft + + +def stats() -> dict[str, Any]: + init_db() + conn = get_conn() + try: + cur = conn.cursor() + cur.execute("SELECT status, COUNT(*) FROM closing_projects GROUP BY status") + by_status = {str(r[0]): int(r[1]) for r in cur.fetchall()} + cur.execute("SELECT COUNT(*) FROM achievements") + achievement_total = int(cur.fetchone()[0]) + cur.execute("SELECT COUNT(*) FROM expense_items") + expense_total = int(cur.fetchone()[0]) + cur.execute("SELECT COUNT(*) FROM report_drafts") + draft_total = int(cur.fetchone()[0]) + finally: + conn.close() + return { + "ok": True, + "projects_by_status": by_status, + "achievement_total": achievement_total, + "expense_total": expense_total, + "draft_total": draft_total, + } diff --git a/scripts/service/report_service.py b/scripts/service/report_service.py new file mode 100644 index 0000000..08aaf06 --- /dev/null +++ b/scripts/service/report_service.py @@ -0,0 +1,104 @@ +"""初稿 HTML 预览组装。 + +与 Word 初稿同构内容写入 `{skill_data}/html/draft-{id}.html`,宿主侧栏可预览; +重建走固定 Action id `render-html`。 +""" + +from __future__ import annotations + +import json +from typing import Any + +from service.html_page_service import load_html_template, render_html_placeholders, write_html_page +from util.logging_config import get_skill_logger + +_TEMPLATE_REL = "assets/html/draft-preview.html" + + +def _esc(value: Any) -> str: + import html + + return html.escape(str(value if value is not None else "—"), quote=True) + + +def _sections_html(sections: dict[str, list[str]]) -> str: + parts = [] + for title, paragraphs in (sections or {}).items(): + items = "".join(f"<p class='para{' placeholder' if p.startswith('[待补充') else ''}'>{_esc(p)}</p>" for p in paragraphs) + parts.append(f"<div class='section'><h2>{_esc(title)}</h2>{items}</div>") + return "".join(parts) + + +def build_preview_html(draft: dict[str, Any]) -> str: + body = f""" + <div class="verdict-line"> + <span class="tag tier-info">初稿</span> + <span class="muted">项目 {_esc(draft.get("project_no"))} · 批次 {_esc(draft.get("batch_no"))}</span> + </div> + <div class="section"><h2>生成说明</h2> + <p class="para">数据章节(完成情况/决算/成果清单)由台账自动生成,请逐项核对;叙事章节仅提供素材要点,须由项目负责人本人终审。</p> + </div> + {_sections_html(draft.get("sections") or {})} + <footer>本预览与 Word 初稿同构;严禁引入台账之外的成果或结论。以本单位结题报告模板为准。</footer> + """ + template = load_html_template(_TEMPLATE_REL) + return render_html_placeholders( + template, + { + "TITLE": f"结题报告初稿预览 · {draft.get('project_name') or draft.get('project_no') or ''}", + "GENERATED_AT": "", + "BODY": body, + }, + ) + + +def write_preview_for_kernel(*, draft: dict[str, Any]) -> str: + html_text = build_preview_html(draft) + log = get_skill_logger() + path = write_html_page("draft-preview.html", html_text) + log.info("draft_preview_written project_no=%s path=%s stage=report", draft.get("project_no"), path) + return path + + +def build_preview_for_draft_id(draft_id: int) -> str: + """按初稿记录 id 重建 HTML 预览(重建入口用)。""" + from db.connection import get_conn + + log = get_skill_logger() + log.info("preview_render_start draft_id=%s stage=render", draft_id) + conn = get_conn() + try: + cur = conn.cursor() + cur.execute("SELECT * FROM report_drafts WHERE id = ?", (draft_id,)) + row = cur.fetchone() + if not row: + raise ValueError(f"初稿记录不存在:{draft_id}") + columns = [d[0] for d in cur.description] + record = dict(zip(columns, row)) + finally: + conn.close() + + try: + completion = json.loads(record.get("completion_json") or "[]") + except (TypeError, ValueError): + completion = [] + try: + narrative_points = json.loads(record.get("narrative_points_json") or "[]") + except (TypeError, ValueError): + narrative_points = [] + + sections: dict[str, list[str]] = { + "完成情况(自动生成)": [f"{row.get('type')}:{row.get('detail')}" for row in completion] or ["指标未配置(W1)。"], + "叙事素材要点": [f"· {p.get('point')}(来源:{p.get('source')})" for p in narrative_points] or ["无素材,请补录成果/年度报告摘句。"], + "Word 初稿": [f"文件:{record.get('draft_file') or '—'}(output 目录可取)"], + } + draft = { + "project_no": record.get("project_no"), + "project_name": record.get("project_name"), + "batch_no": record.get("batch_no"), + "sections": sections, + } + html_text = build_preview_html(draft) + path = write_html_page(f"draft-{draft_id}.html", html_text) + log.info("preview_render_done draft_id=%s path=%s stage=render", draft_id, path) + return path diff --git a/scripts/service/sibling_bridge.py b/scripts/service/sibling_bridge.py new file mode 100644 index 0000000..521daae --- /dev/null +++ b/scripts/service/sibling_bridge.py @@ -0,0 +1,57 @@ +"""兄弟技能 CLI 调用工具。 + +通过子进程调用同级其他 skill 的 main.py,并解析 JSON 输出。 +具体调用哪些兄弟技能,由复制后的业务 skill 在 service 层按需决定。 +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from typing import Any, Dict, List, Optional + +from util.logging_config import subprocess_env_with_trace +from util.runtime_paths import get_skills_root + + +def call_sibling_json(skill_slug: str, args: List[str]) -> Optional[Dict[str, Any]]: + """通用兄弟技能调用器。 + + Args: + skill_slug: 兄弟技能的 slug(即同级目录名)。 + args: 传给 main.py 的命令行参数列表。 + + Returns: + 若兄弟技能输出可解析的 JSON 对象则返回 dict;否则返回 None。 + """ + script_path = os.path.join(get_skills_root(), skill_slug, "scripts", "main.py") + if not os.path.isfile(script_path): + return None + proc = subprocess.run( + [sys.executable, script_path, *args], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + env=subprocess_env_with_trace(), + ) + raw = (proc.stdout or "").strip() + if not raw or raw.startswith("ERROR"): + return None + try: + data = json.loads(raw) + except json.JSONDecodeError: + return None + return data if isinstance(data, dict) else None + + +def get_sibling_main_path(skill_slug: str) -> str: + """获取兄弟技能的 main.py 绝对路径。 + + 使用示例(在 task_service.py 中): + from service.sibling_bridge import get_sibling_main_path, call_sibling_json + result = call_sibling_json("account-manager", ["list", "--limit", "10"]) + """ + return os.path.join(get_skills_root(), skill_slug, "scripts", "main.py") diff --git a/scripts/service/task_run_support.py b/scripts/service/task_run_support.py new file mode 100644 index 0000000..f3c2ee1 --- /dev/null +++ b/scripts/service/task_run_support.py @@ -0,0 +1,84 @@ +"""任务运行辅助:录屏信息组装与 CLI 输出(通用模板,无业务逻辑)。""" + +from __future__ import annotations + +from typing import Any, Dict, Optional + + +def build_video_info( + video_summary: dict[str, Any], + compose_context_before: Optional[dict[str, Any]] = None, + compose_context_after: Optional[dict[str, Any]] = None, +) -> dict[str, Any]: + """将 RpaVideoSession.summary() 转为 finish() / CLI 可用的 video 块。""" + return { + "enabled": video_summary.get("enabled", False), + "path": video_summary.get("path"), + "capture_path": video_summary.get("capture_path"), + "record_log_path": video_summary.get("record_log_path"), + "warnings": list(video_summary.get("warnings") or []), + "voiceover_path": video_summary.get("voiceover_path"), + "music_path": video_summary.get("music_path"), + "audio_warnings": list(video_summary.get("audio_warnings") or []), + "compose_context": { + **(compose_context_before or {}), + "after_compose": compose_context_after or {}, + }, + } + + +def merge_video_into_result_summary( + payload: dict[str, Any], + video_info: Optional[dict[str, Any]], +) -> dict[str, Any]: + """把 video artifact 字段写入 finish() 结果对象(video / video_path 等)。""" + if not video_info: + return payload + payload = dict(payload) + payload["video"] = video_info + payload["video_path"] = video_info.get("path") + payload["raw_video"] = video_info.get("capture_path") + payload["video_log"] = video_info.get("record_log_path") + payload["video_warnings"] = list(video_info.get("warnings") or []) + payload["voiceover_path"] = video_info.get("voiceover_path") + payload["music_path"] = video_info.get("music_path") + payload["audio_warnings"] = list(video_info.get("audio_warnings") or []) + return payload + + +def _print_video_summary(video_block: Optional[dict[str, Any]]) -> None: + """任务完成后在 CLI 打印录屏路径、日志与诊断信息。""" + if not isinstance(video_block, dict) or not video_block.get("enabled"): + return + record_log = video_block.get("record_log_path") + if record_log: + print(f"录屏日志:{record_log}") + path = video_block.get("path") + if path: + print(f"录屏路径:{path}") + else: + print("录屏路径:未生成") + warnings = video_block.get("warnings") or [] + if warnings: + print(f"视频诊断:{'; '.join(str(w) for w in warnings)}") + capture = video_block.get("capture_path") + if capture: + print(f"原始录屏路径:{capture}") + voiceover = video_block.get("voiceover_path") + if voiceover: + print(f"旁白路径:{voiceover}") + music = video_block.get("music_path") + if music: + print(f"背景音乐:{music}") + audio_warnings = video_block.get("audio_warnings") or [] + if audio_warnings: + print(f"音频诊断:{'; '.join(str(w) for w in audio_warnings)}") + compose_ctx = video_block.get("compose_context") + if isinstance(compose_ctx, dict): + print( + "视频合成上下文:" + f"media_assets_root={compose_ctx.get('media_assets_root', '')}; " + f"ffmpeg_path={compose_ctx.get('ffmpeg_path', '')}; " + f"background_music_issue={compose_ctx.get('background_music_issue') or ''}; " + f"background_music_sample_path={compose_ctx.get('background_music_sample_path', '')}" + ) diff --git a/scripts/service/task_service.py b/scripts/service/task_service.py new file mode 100644 index 0000000..db05471 --- /dev/null +++ b/scripts/service/task_service.py @@ -0,0 +1,625 @@ +"""任务编排层:job_context / emit / finish、鉴权与错误转换。 + +领域业务在 ``service/draft_service.py``(初稿内核)、 +``service/ledger_service.py``(台账/记录)、``service/docx_service.py``(Word 渲染)、 +``service/report_service.py``(HTML 简报),由本模块调用; +多入口(CLI / Action / 对话 / cron)复用同一内核,禁止按入口分叉业务。 +""" + +from __future__ import annotations + +import asyncio +import json +import os +import uuid +from typing import Any, Optional + +from jiangchang_skill_core import collect_runtime_diagnostics, config, format_runtime_health_lines +from jiangchang_skill_core.activity import JobStopped, emit, finish, job_context + +from jiangchang_skill_core.rpa.video_session import RpaVideoSession +from service.entitlement_service import check_entitlement +from service.task_run_support import ( + _print_video_summary, + build_video_info, + merge_video_into_result_summary, +) +from util.constants import LOG_LOGGER_NAME, PLATFORM_KIT_MIN_VERSION, SKILL_SLUG, SKILL_VERSION +from util.logging_config import get_skill_logger, setup_skill_logging +from util.runtime_paths import get_db_path, get_skill_root +from db.connection import init_db + + +def _get_task_logger(): + try: + return get_skill_logger() + except RuntimeError: + setup_skill_logging(SKILL_SLUG, LOG_LOGGER_NAME) + return get_skill_logger() + + +def _print_json(payload: dict[str, Any]) -> None: + print(json.dumps(payload, ensure_ascii=False)) + + +# --------------------------------------------------------------------------- +# 模板演示入口(保留:满足 POLICY-RPA-003 录屏集成与 CLI run 契约;不承载本技能业务) +# --------------------------------------------------------------------------- + +async def _run_template_demo(target: Optional[str], input_id: Optional[str]) -> tuple[int, dict[str, Any]]: + """最小 RpaVideoSession 示范:不启动浏览器、不执行业务,仅演示录屏包裹范式。""" + batch_id = uuid.uuid4().hex[:12] + data_dir = get_skill_root() + + async with RpaVideoSession( + skill_slug=SKILL_SLUG, + skill_data_dir=data_dir, + batch_id=batch_id, + title="开始执行示例任务", + closing_title="示例任务执行完成", + ) as video: + video.add_step("准备执行示例任务") + if target: + video.add_step(f"接收目标参数:{target}") + if input_id: + video.add_step(f"接收输入标识:{input_id}") + video.add_step("示例任务执行完成") + + video_summary = video.summary() + video_info = build_video_info(video_summary, {}, {}) + return 1, video_info + + +def cmd_run(target: Optional[str] = None, input_id: Optional[str] = None) -> int: + """模板演示入口:本技能主能力请使用 broadcast 等业务命令。""" + log = _get_task_logger() + task_type = "demo" + + with job_context(skill=SKILL_SLUG): + log.info( + "task_start task_type=%s target_id=%s input_id=%s stage=run", + task_type, + target, + input_id, + ) + emit("开始处理任务", skill=SKILL_SLUG, stage="run") + try: + ok, reason = check_entitlement(SKILL_SLUG) + if not ok: + log.warning( + "entitlement_failed task_type=%s stage=auth error_code=ENTITLEMENT_DENIED", + task_type, + ) + emit("鉴权失败,已记录诊断信息", type="warn", skill=SKILL_SLUG, stage="auth") + finish( + status="failed", + message=reason, + skill=SKILL_SLUG, + error_code="ENTITLEMENT_DENIED", + stage="auth", + ) + print(f"ERROR:ENTITLEMENT_DENIED {reason}") + return 1 + + log.info("entitlement_ok task_type=%s stage=auth", task_type) + log.info("rpa_start stage=demo_prepare task_type=%s", task_type) + log.info("task_run_demo_start target_id=%s input_id=%s stage=demo_prepare", target, input_id) + rc, video_info = asyncio.run(_run_template_demo(target, input_id)) + log.info("rpa_done stage=demo_prepare status=ok") + log.info( + "task_run_demo_done target_id=%s input_id=%s stage=demo_finish status=failed", + target, + input_id, + ) + _print_video_summary(video_info) + + summary_payload = merge_video_into_result_summary( + { + "template_demo": True, + "target": target, + "input_id": input_id, + }, + video_info, + ) + + log.warning( + "task_failed task_type=%s stage=demo_finish error_code=TEMPLATE_STUB", + task_type, + ) + finish( + status="failed", + message="模板演示入口未承载本技能业务", + skill=SKILL_SLUG, + error_code="TEMPLATE_STUB", + **summary_payload, + ) + print("ERROR:TEMPLATE_STUB 这是模板演示入口,请使用 draft-report 命令生成结题报告初稿。") + return rc + except JobStopped: + raise + except Exception: + log.exception( + "task_failed task_type=%s target_id=%s input_id=%s", + task_type, + target, + input_id, + ) + emit("任务失败,已记录诊断信息", type="warn", skill=SKILL_SLUG, stage="run") + finish( + status="failed", + message="任务执行异常,详见统一日志", + skill=SKILL_SLUG, + stage="run", + ) + return 1 + + +# --------------------------------------------------------------------------- +# 基础命令 +# --------------------------------------------------------------------------- + +def cmd_config_path() -> int: + example_path = os.path.join(get_skill_root(), ".env.example") + env_path = config.get_env_file_path() or "" + _print_json( + { + "skill": SKILL_SLUG, + "env_path": env_path, + "example_path": os.path.abspath(example_path), + } + ) + return 0 + + +def cmd_health() -> int: + runtime = collect_runtime_diagnostics( + skill_slug=SKILL_SLUG, + platform_kit_min_version=PLATFORM_KIT_MIN_VERSION, + skill_root=get_skill_root(), + ) + example_path = os.path.join(get_skill_root(), ".env.example") + env_path = config.get_env_file_path() or "" + env_exists = bool(env_path and os.path.isfile(env_path)) + + health_status = "failed" if runtime.has_fatal_issues else "ok" + lines = [ + f"{SKILL_SLUG} health: {health_status}", + *format_runtime_health_lines(runtime), + f"env_path: {env_path}", + f"env_exists: {env_exists}", + f"example_path: {os.path.abspath(example_path)}", + ] + for line in lines: + print(line) + return 1 if runtime.has_fatal_issues else 0 + + +def cmd_version() -> int: + _print_json({"version": SKILL_VERSION, "skill": SKILL_SLUG}) + return 0 + + +def cmd_init_db() -> int: + """幂等创建/迁移本地 SQLite 与数据管理元数据(宿主 install/upgrade 可调用)。""" + log = _get_task_logger() + try: + init_db() + except Exception as exc: # noqa: BLE001 — CLI 边界,统一成可机读失败 + print( + json.dumps( + {"ok": False, "skill": SKILL_SLUG, "error": str(exc)}, + ensure_ascii=False, + ) + ) + return 1 + _print_json({"ok": True, "skill": SKILL_SLUG, "db_path": get_db_path()}) + return 0 + + +# --------------------------------------------------------------------------- +# 台账维护 +# --------------------------------------------------------------------------- + +def _parse_json_arg(raw: str, label: str) -> list[dict[str, Any]]: + import sys + + text = (raw or "").strip() + if not text: + raise ValueError(f"缺少导入 JSON({label})") + if text == "-": + text = sys.stdin.read().strip() + elif text.startswith(("[", "{")): + pass + elif os.path.isfile(text): + with open(text, encoding="utf-8") as f: + text = f.read().strip() + else: + raise ValueError(f"{label} 不是有效文件路径或 JSON:{text[:80]}") + try: + payload = json.loads(text) + except json.JSONDecodeError as exc: + raise ValueError(f"导入 JSON 解析失败:{exc}") from exc + if isinstance(payload, dict): + for key in ("projects", "achievements", "expenses", "items"): + if isinstance(payload.get(key), list): + payload = payload[key] + break + else: + payload = [payload] + if not isinstance(payload, list): + raise ValueError(f"{label} 应为对象数组") + return payload + + +def cmd_add_project( + *, + project_no: str, + name: str = "", + pi: str = "", + department: str = "", + program: str = "", + start: str = "", + end: str = "", + expected_json: str = "", + excerpt: str = "", + status: str = "", +) -> int: + log = _get_task_logger() + from service.ledger_service import upsert_project + + with job_context(skill=SKILL_SLUG): + log.info("task_start task_type=add_project project_no=%s stage=ledger", project_no) + emit("开始写入结题项目", skill=SKILL_SLUG, stage="ledger") + try: + ok, reason = check_entitlement(SKILL_SLUG) + if not ok: + log.warning("entitlement_failed task_type=add_project stage=auth") + finish(status="failed", message=reason, skill=SKILL_SLUG, error_code="ENTITLEMENT_DENIED", stage="auth") + print(f"ERROR:ENTITLEMENT_DENIED {reason}") + return 1 + fields: dict[str, Any] = {"project_no": project_no} + for key, value in (("project_name", name), ("pi_name", pi), ("department", department), ("program", program), ("start_date", start), ("end_date", end), ("expected_json", expected_json), ("annual_report_excerpt", excerpt), ("status", status)): + if value: + fields[key] = value + payload = upsert_project(fields) + _print_json(payload) + finish(status="success", message=f"项目 {project_no} 已{('更新' if payload['action'] == 'updated' else '录入')}", skill=SKILL_SLUG, stage="ledger") + return 0 + except ValueError as exc: + log.warning("task_failed task_type=add_project error_code=PROJECT_INVALID detail=%s", exc) + finish(status="failed", message=str(exc), skill=SKILL_SLUG, error_code="PROJECT_INVALID", stage="ledger") + print(f"ERROR:PROJECT_INVALID {exc}") + return 1 + except JobStopped: + raise + except Exception: + log.exception("task_failed task_type=add_project stage=ledger") + finish(status="failed", message="项目写入异常,详见统一日志", skill=SKILL_SLUG, stage="ledger") + return 1 + + +def cmd_add_achievement( + *, + project_no: str, + ach_type: str, + title: str, + venue: str = "", + date: str = "", + representative: str = "", + note: str = "", +) -> int: + log = _get_task_logger() + from service.ledger_service import upsert_achievement + + with job_context(skill=SKILL_SLUG): + log.info("task_start task_type=add_achievement project_no=%s stage=ledger", project_no) + emit("开始写入成果", skill=SKILL_SLUG, stage="ledger") + try: + ok, reason = check_entitlement(SKILL_SLUG) + if not ok: + log.warning("entitlement_failed task_type=add_achievement stage=auth") + finish(status="failed", message=reason, skill=SKILL_SLUG, error_code="ENTITLEMENT_DENIED", stage="auth") + print(f"ERROR:ENTITLEMENT_DENIED {reason}") + return 1 + payload = upsert_achievement( + {"project_no": project_no, "achievement_type": ach_type, "title": title, "venue_or_no": venue, "ach_date": date, "is_representative": representative, "note": note} + ) + _print_json(payload) + finish(status="success", message=f"成果已录入:{title[:20]}", skill=SKILL_SLUG, stage="ledger") + return 0 + except ValueError as exc: + log.warning("task_failed task_type=add_achievement error_code=PROJECT_INVALID detail=%s", exc) + finish(status="failed", message=str(exc), skill=SKILL_SLUG, error_code="PROJECT_INVALID", stage="ledger") + print(f"ERROR:PROJECT_INVALID {exc}") + return 1 + except JobStopped: + raise + except Exception: + log.exception("task_failed task_type=add_achievement stage=ledger") + finish(status="failed", message="成果写入异常,详见统一日志", skill=SKILL_SLUG, stage="ledger") + return 1 + + +def cmd_add_expense(*, project_no: str, category: str, amount: float, date: str = "", description: str = "") -> int: + log = _get_task_logger() + from service.ledger_service import upsert_expense + + with job_context(skill=SKILL_SLUG): + log.info("task_start task_type=add_expense project_no=%s stage=ledger", project_no) + emit("开始写入决算明细", skill=SKILL_SLUG, stage="ledger") + try: + ok, reason = check_entitlement(SKILL_SLUG) + if not ok: + log.warning("entitlement_failed task_type=add_expense stage=auth") + finish(status="failed", message=reason, skill=SKILL_SLUG, error_code="ENTITLEMENT_DENIED", stage="auth") + print(f"ERROR:ENTITLEMENT_DENIED {reason}") + return 1 + payload = upsert_expense({"project_no": project_no, "category": category, "amount": amount, "occurred_date": date, "description": description}) + _print_json(payload) + finish(status="success", message=f"决算明细已录入:{category} {payload['amount']} 元", skill=SKILL_SLUG, stage="ledger") + return 0 + except ValueError as exc: + log.warning("task_failed task_type=add_expense error_code=PROJECT_INVALID detail=%s", exc) + finish(status="failed", message=str(exc), skill=SKILL_SLUG, error_code="PROJECT_INVALID", stage="ledger") + print(f"ERROR:PROJECT_INVALID {exc}") + return 1 + except JobStopped: + raise + except Exception: + log.exception("task_failed task_type=add_expense stage=ledger") + finish(status="failed", message="决算写入异常,详见统一日志", skill=SKILL_SLUG, stage="ledger") + return 1 + + +def cmd_import(projects_json: str = "", achievements_json: str = "", expenses_json: str = "") -> int: + """统一导入入口:projects/achievements/expenses 三类 JSON(至少提供一个)。""" + log = _get_task_logger() + from service.ledger_service import import_achievements, import_expenses, import_projects + + with job_context(skill=SKILL_SLUG): + log.info("task_start task_type=import stage=ledger") + emit("开始批量导入结题数据", skill=SKILL_SLUG, stage="ledger") + try: + ok, reason = check_entitlement(SKILL_SLUG) + if not ok: + log.warning("entitlement_failed task_type=import stage=auth") + finish(status="failed", message=reason, skill=SKILL_SLUG, error_code="ENTITLEMENT_DENIED", stage="auth") + print(f"ERROR:ENTITLEMENT_DENIED {reason}") + return 1 + summary: dict[str, Any] = {"ok": True} + if projects_json: + summary["projects"] = import_projects(_parse_json_arg(projects_json, "--projects-json")) + if achievements_json: + summary["achievements"] = import_achievements(_parse_json_arg(achievements_json, "--achievements-json")) + if expenses_json: + summary["expenses"] = import_expenses(_parse_json_arg(expenses_json, "--expenses-json")) + if len(summary) == 1: + raise ValueError("至少提供一个导入 JSON(--projects-json / --achievements-json / --expenses-json)") + _print_json(summary) + finish(status="success", message="批量导入完成", skill=SKILL_SLUG, stage="ledger") + return 0 + except ValueError as exc: + log.warning("task_failed task_type=import error_code=PROJECT_INVALID detail=%s", exc) + finish(status="failed", message=str(exc), skill=SKILL_SLUG, error_code="PROJECT_INVALID", stage="ledger") + print(f"ERROR:PROJECT_INVALID {exc}") + return 1 + except JobStopped: + raise + except Exception: + log.exception("task_failed task_type=import stage=ledger") + finish(status="failed", message="批量导入异常,详见统一日志", skill=SKILL_SLUG, stage="ledger") + return 1 + + +def cmd_list(kind: str = "projects", project_no: str = "", status: str = "") -> int: + log = _get_task_logger() + from service.ledger_service import list_achievements, list_expenses, list_projects + + with job_context(skill=SKILL_SLUG): + log.info("task_start task_type=list kind=%s project_no=%s", kind, project_no) + try: + if kind == "achievements": + rows = list_achievements(project_no) + elif kind == "expenses": + rows = list_expenses(project_no) + else: + rows = list_projects(status=status) + _print_json({"ok": True, "count": len(rows), "rows": rows}) + return 0 + except Exception: + log.exception("task_failed task_type=list") + print("ERROR:QUERY_FAILED 台账查询异常,详见统一日志") + return 1 + + +# --------------------------------------------------------------------------- +# 初稿生成 +# --------------------------------------------------------------------------- + +def cmd_draft_report(*, project_no: str) -> int: + """结题报告初稿(主命令,同步):指定项目生成 Word 初稿 + HTML 预览。""" + log = _get_task_logger() + from service.draft_service import query_pending_projects, run_draft + + with job_context(skill=SKILL_SLUG): + log.info("task_start task_type=draft_report project_no=%s stage=draft", project_no) + emit("开始生成结题报告初稿", skill=SKILL_SLUG, stage="draft") + try: + ok, reason = check_entitlement(SKILL_SLUG) + if not ok: + log.warning("entitlement_failed task_type=draft_report stage=auth") + finish(status="failed", message=reason, skill=SKILL_SLUG, error_code="ENTITLEMENT_DENIED", stage="auth") + print(f"ERROR:ENTITLEMENT_DENIED {reason}") + return 1 + + projects = query_pending_projects(project_no=project_no) + if not projects: + message = f"未找到该项目(编号 {project_no})或项目状态不是“待结题”" + log.warning("task_failed task_type=draft_report error_code=PROJECT_NOT_FOUND stage=draft") + finish(status="failed", message=message, skill=SKILL_SLUG, error_code="PROJECT_NOT_FOUND", stage="draft") + print(f"ERROR:PROJECT_NOT_FOUND {message}") + return 1 + project = projects[0] + achievements = [] + expenses = [] + from service.ledger_service import list_achievements, list_expenses + + achievements = list_achievements(project_no) + expenses = list_expenses(project_no) + + result = run_draft(project, achievements, expenses) + _print_json(result) + finish( + status="success", + message=f"初稿已生成:{result['draft_file']}", + skill=SKILL_SLUG, + stage="draft", + batch_no=result["batch_no"], + ) + return 0 + except JobStopped: + raise + except ValueError as exc: + log.warning("task_failed task_type=draft_report error_code=PROJECT_INVALID detail=%s", exc) + finish(status="failed", message=str(exc), skill=SKILL_SLUG, error_code="PROJECT_INVALID", stage="draft") + print(f"ERROR:PROJECT_INVALID {exc}") + return 1 + except Exception: + log.exception("task_failed task_type=draft_report stage=draft") + emit("初稿生成失败,已记录诊断信息", type="warn", skill=SKILL_SLUG, stage="draft") + finish(status="failed", message="初稿生成异常,详见统一日志", skill=SKILL_SLUG, stage="draft") + return 1 + + +def cmd_draft_batch_pending() -> int: + """批量初稿(async Action 入口):为全部“待结题”项目生成初稿,任务中心可见进度。""" + log = _get_task_logger() + from service.draft_service import query_pending_projects, run_draft + from service.ledger_service import list_achievements, list_expenses + + with job_context(skill=SKILL_SLUG): + log.info("task_start task_type=draft_batch stage=batch") + emit("开始批量生成结题初稿", skill=SKILL_SLUG, stage="batch") + try: + ok, reason = check_entitlement(SKILL_SLUG) + if not ok: + log.warning("entitlement_failed task_type=draft_batch stage=auth") + finish(status="failed", message=reason, skill=SKILL_SLUG, error_code="ENTITLEMENT_DENIED", stage="auth") + print(f"ERROR:ENTITLEMENT_DENIED {reason}") + return 1 + + projects = query_pending_projects() + if not projects: + message = "没有“待结题”状态的项目,批量生成跳过" + log.warning("task_failed task_type=draft_batch error_code=PROJECT_NOT_FOUND stage=batch") + finish(status="failed", message=message, skill=SKILL_SLUG, error_code="PROJECT_NOT_FOUND", stage="batch") + print(f"ERROR:PROJECT_NOT_FOUND {message}") + return 1 + + drafts = [] + for index, project in enumerate(projects, start=1): + emit(f"[{index}/{len(projects)}] {project.get('project_name') or project.get('project_no')}", skill=SKILL_SLUG, stage="batch") + try: + result = run_draft(project, list_achievements(project["project_no"]), list_expenses(project["project_no"])) + drafts.append({"draft_id": result["draft_id"], "project_no": result["project_no"], "draft_file": result["draft_file"]}) + except Exception: + log.exception("draft_batch_item_failed project_no=%s", project.get("project_no")) + _print_json({"ok": True, "count": len(drafts), "drafts": drafts}) + finish(status="success", message=f"批量初稿完成 {len(drafts)}/{len(projects)} 个项目", skill=SKILL_SLUG, stage="batch") + return 0 + except JobStopped: + raise + except Exception: + log.exception("task_failed task_type=draft_batch stage=batch") + emit("批量初稿失败,已记录诊断信息", type="warn", skill=SKILL_SLUG, stage="batch") + finish(status="failed", message="批量初稿执行异常,详见统一日志", skill=SKILL_SLUG, stage="batch") + return 1 + + +# --------------------------------------------------------------------------- +# 记录与统计 +# --------------------------------------------------------------------------- + +def cmd_list_drafts(limit: int = 20) -> int: + log = _get_task_logger() + from service.ledger_service import list_drafts + + with job_context(skill=SKILL_SLUG): + log.info("task_start task_type=list_drafts limit=%s", limit) + try: + drafts = list_drafts(limit=limit) + _print_json({"ok": True, "count": len(drafts), "drafts": drafts}) + return 0 + except Exception: + log.exception("task_failed task_type=list_drafts") + print("ERROR:QUERY_FAILED 初稿记录查询异常,详见统一日志") + return 1 + + +def cmd_show_draft(draft_id: int) -> int: + log = _get_task_logger() + from service.ledger_service import get_draft + + with job_context(skill=SKILL_SLUG): + log.info("task_start task_type=show_draft draft_id=%s", draft_id) + try: + draft = get_draft(int(draft_id)) + _print_json({"ok": True, "draft": draft}) + return 0 + except ValueError as exc: + log.warning("task_failed task_type=show_draft error_code=DRAFT_NOT_FOUND detail=%s", exc) + print(f"ERROR:DRAFT_NOT_FOUND {exc}") + return 1 + except Exception: + log.exception("task_failed task_type=show_draft") + print("ERROR:QUERY_FAILED 初稿记录查询异常,详见统一日志") + return 1 + + +def cmd_render_html(report_id: Optional[int] = None) -> int: + log = _get_task_logger() + from service.ledger_service import list_drafts + from service.report_service import build_preview_for_draft_id + + with job_context(skill=SKILL_SLUG): + log.info("task_start task_type=render_html report_id=%s stage=render", report_id) + try: + if report_id: + targets = [int(report_id)] + else: + targets = [d["id"] for d in list_drafts(limit=10)] + if not targets: + log.warning("task_failed task_type=render_html error_code=DRAFT_NOT_FOUND stage=render") + finish(status="failed", message="暂无可重建的初稿记录", skill=SKILL_SLUG, error_code="DRAFT_NOT_FOUND", stage="render") + print("ERROR:DRAFT_NOT_FOUND 暂无可重建的初稿记录") + return 1 + written = [{"draft_id": did, "path": build_preview_for_draft_id(did)} for did in targets] + _print_json({"ok": True, "count": len(written), "previews": written}) + finish(status="success", message=f"已重建 {len(written)} 份预览", skill=SKILL_SLUG, stage="render") + return 0 + except JobStopped: + raise + except ValueError as exc: + log.warning("task_failed task_type=render_html error_code=DRAFT_NOT_FOUND detail=%s", exc) + finish(status="failed", message=str(exc), skill=SKILL_SLUG, error_code="DRAFT_NOT_FOUND", stage="render") + print(f"ERROR:DRAFT_NOT_FOUND {exc}") + return 1 + except Exception: + log.exception("task_failed task_type=render_html stage=render") + finish(status="failed", message="预览重建异常,详见统一日志", skill=SKILL_SLUG, stage="render") + return 1 + + +def cmd_stats() -> int: + log = _get_task_logger() + from service.ledger_service import stats + + with job_context(skill=SKILL_SLUG): + log.info("task_start task_type=stats") + try: + _print_json(stats()) + return 0 + except Exception: + log.exception("task_failed task_type=stats") + print("ERROR:QUERY_FAILED 统计查询异常,详见统一日志") + return 1 diff --git a/scripts/util/argparse_zh.py b/scripts/util/argparse_zh.py new file mode 100644 index 0000000..8ef2a0b --- /dev/null +++ b/scripts/util/argparse_zh.py @@ -0,0 +1,12 @@ +"""argparse 中文错误说明。""" + +from __future__ import annotations + +import argparse +import sys + + +class ZhArgumentParser(argparse.ArgumentParser): + def error(self, message: str) -> None: + print(f"参数错误:{message}\n请执行:python main.py -h 查看帮助", file=sys.stderr) + self.exit(2) diff --git a/scripts/util/config_bootstrap.py b/scripts/util/config_bootstrap.py new file mode 100644 index 0000000..475aeeb --- /dev/null +++ b/scripts/util/config_bootstrap.py @@ -0,0 +1,19 @@ +"""技能配置初始化:.env.example 落盘与用户 .env 缺失项合并。""" + +from __future__ import annotations + +import os + +from jiangchang_skill_core import config + +from util.constants import SKILL_SLUG, SKILL_VERSION +from util.runtime_paths import get_skill_root + + +def bootstrap_skill_config() -> str: + """确保用户数据目录 .env 存在,并追加 .env.example 中的新配置项。""" + example_path = os.path.join(get_skill_root(), ".env.example") + env_path = config.ensure_env_file(SKILL_SLUG, example_path) + comment = f"{SKILL_SLUG} v{SKILL_VERSION}" if SKILL_VERSION else None + config.merge_missing_env_keys(example_path, env_path, comment_skill=comment) + return env_path diff --git a/scripts/util/constants.py b/scripts/util/constants.py new file mode 100644 index 0000000..36fa6cf --- /dev/null +++ b/scripts/util/constants.py @@ -0,0 +1,6 @@ +"""技能标识、版本与平台公共库约束(结题报告初稿助手)。""" + +SKILL_SLUG = "draft-closing-report-default" +SKILL_VERSION = "1.0.1" +LOG_LOGGER_NAME = "openclaw.skill.draft_closing_report_default" +PLATFORM_KIT_MIN_VERSION = "1.2.11" diff --git a/scripts/util/logging_config.py b/scripts/util/logging_config.py new file mode 100644 index 0000000..3328400 --- /dev/null +++ b/scripts/util/logging_config.py @@ -0,0 +1,21 @@ +"""Re-export unified logging (implementation: jiangchang_skill_core.unified_logging).""" + +from jiangchang_skill_core.unified_logging import ( + attach_unified_file_handler, + ensure_trace_for_process, + get_skill_log_file_path, + get_skill_logger, + get_unified_logs_dir, + setup_skill_logging, + subprocess_env_with_trace, +) + +__all__ = [ + "attach_unified_file_handler", + "ensure_trace_for_process", + "get_skill_log_file_path", + "get_skill_logger", + "get_unified_logs_dir", + "setup_skill_logging", + "subprocess_env_with_trace", +] diff --git a/scripts/util/runtime_paths.py b/scripts/util/runtime_paths.py new file mode 100644 index 0000000..f38a631 --- /dev/null +++ b/scripts/util/runtime_paths.py @@ -0,0 +1,155 @@ +"""数据根、技能目录、兄弟技能根路径与用户数据子目录解析。""" + +from __future__ import annotations + +import os + +from jiangchang_skill_core.runtime_env import get_data_root, get_sibling_skills_root, get_user_id + +from util.constants import SKILL_SLUG + +_SCRIPTS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +# 用户可见的上行/下行目录(数据管理「输入/输出」文件浏览器) +STANDARD_SUBDIR_INPUT = "input" +STANDARD_SUBDIR_OUTPUT = "output" + +# 宿主扫描的 HTML 报告页(数据管理侧栏 htmlPages,非 input/output 文件浏览器) +STANDARD_SUBDIR_HTML = "html" + +# 内部/可选目录(不上侧栏) +STANDARD_SUBDIR_UPLOADS = "uploads" +STANDARD_SUBDIR_CACHE = "cache" +STANDARD_SUBDIR_TEMP = "temp" +STANDARD_SUBDIR_RPA_ARTIFACTS = "rpa-artifacts" +STANDARD_SUBDIR_VIDEOS = "videos" + +CONFIG_KEY_INPUT_DIR = "SKILL_INPUT_DIR" +CONFIG_KEY_OUTPUT_DIR = "SKILL_OUTPUT_DIR" +CONFIG_KEY_UPLOAD_DIR = "SKILL_UPLOAD_DIR" +CONFIG_KEY_CACHE_DIR = "SKILL_CACHE_DIR" +CONFIG_KEY_TEMP_DIR = "SKILL_TEMP_DIR" + + +def get_skill_root() -> str: + return os.path.dirname(_SCRIPTS_DIR) + + +def get_openclaw_root() -> str: + return get_sibling_skills_root(_SCRIPTS_DIR) + + +def get_skills_root() -> str: + return get_sibling_skills_root(_SCRIPTS_DIR) + + +def get_skill_data_dir() -> str: + path = os.path.join(get_data_root(), get_user_id(), SKILL_SLUG) + os.makedirs(path, exist_ok=True) + return path + + +def get_db_path(filename: str | None = None) -> str: + name = filename or f"{SKILL_SLUG}.db" + return os.path.join(get_skill_data_dir(), name) + + +def _normalize_subdir(value: str) -> str: + cleaned = value.strip().strip("/\\") + if not cleaned or cleaned.startswith(".."): + raise ValueError(f"invalid skill data subdir: {value!r}") + return cleaned.replace("/", os.sep).replace("\\", os.sep) + + +def resolve_data_path( + config_key: str | None, + default_subdir: str, + *, + create: bool = True, +) -> str: + """解析技能拥有路径:空配置 → 数据目录下 default_subdir;相对 → 相对数据目录;绝对 → 原样。""" + from jiangchang_skill_core import config + + base = get_skill_data_dir() + raw = (config.get(config_key) or "").strip() if config_key else "" + if not raw: + path = os.path.join(base, _normalize_subdir(default_subdir)) + elif os.path.isabs(raw): + path = os.path.abspath(os.path.expanduser(raw)) + else: + path = os.path.join(base, _normalize_subdir(raw)) + + if create: + os.makedirs(path, exist_ok=True) + return path + + +def resolve_input_path(raw_path: str, *, create_parent: bool = False) -> str: + """解析 CLI 输入路径:绝对路径原样;相对路径相对 {skill_data_dir}/input/。""" + text = (raw_path or "").strip() + if not text: + return text + expanded = os.path.expanduser(text) + if os.path.isabs(expanded): + return os.path.abspath(expanded) + input_dir = get_input_dir(create=create_parent) + return os.path.abspath(os.path.join(input_dir, os.path.normpath(expanded))) + + +def get_input_dir(*, create: bool = True) -> str: + """上行:用户/Agent 放入的待处理文件(清单、CSV、产品表等)。""" + return resolve_data_path(CONFIG_KEY_INPUT_DIR, STANDARD_SUBDIR_INPUT, create=create) + + +def get_output_dir(*, create: bool = True) -> str: + """下行:技能/宿主交给用户拿走的文件(视频、报表、导出 CSV 等)。""" + return resolve_data_path(CONFIG_KEY_OUTPUT_DIR, STANDARD_SUBDIR_OUTPUT, create=create) + + +def get_uploads_dir(*, create: bool = True) -> str: + return resolve_data_path(CONFIG_KEY_UPLOAD_DIR, STANDARD_SUBDIR_UPLOADS, create=create) + + +def get_cache_dir(*, create: bool = True) -> str: + return resolve_data_path(CONFIG_KEY_CACHE_DIR, STANDARD_SUBDIR_CACHE, create=create) + + +def get_temp_dir(*, create: bool = True) -> str: + return resolve_data_path(CONFIG_KEY_TEMP_DIR, STANDARD_SUBDIR_TEMP, create=create) + + +def get_rpa_artifacts_dir(batch_id: str, *, create: bool = True) -> str: + batch = (batch_id or "").strip() or "default" + path = os.path.join(get_skill_data_dir(), STANDARD_SUBDIR_RPA_ARTIFACTS, batch) + if create: + os.makedirs(path, exist_ok=True) + return path + + +def get_videos_dir(*, create: bool = True) -> str: + path = os.path.join(get_skill_data_dir(), STANDARD_SUBDIR_VIDEOS) + if create: + os.makedirs(path, exist_ok=True) + return path + + +def get_html_dir(*, create: bool = True) -> str: + """HTML 报告页目录:宿主扫描 `{skill_data}/html/*.html`(一层、非递归)。""" + path = os.path.join(get_skill_data_dir(), STANDARD_SUBDIR_HTML) + if create: + os.makedirs(path, exist_ok=True) + return path + + +def list_resolved_data_paths() -> dict[str, str]: + """供 health / config-path 输出已解析的数据子目录(不创建目录)。""" + return { + "skill_data_dir": get_skill_data_dir(), + "input_dir": get_input_dir(create=False), + "output_dir": get_output_dir(create=False), + "html_dir": get_html_dir(create=False), + "uploads_dir": get_uploads_dir(create=False), + "cache_dir": get_cache_dir(create=False), + "temp_dir": get_temp_dir(create=False), + "videos_dir": get_videos_dir(create=False), + } diff --git a/scripts/util/slug_naming.py b/scripts/util/slug_naming.py new file mode 100644 index 0000000..0dda7cd --- /dev/null +++ b/scripts/util/slug_naming.py @@ -0,0 +1,227 @@ +"""技能 slug 语义校验(verb-noun-platform 规范,见 development/NAMING.md)。""" + +from __future__ import annotations + +import os +import re + +_KEBAB_SLUG = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") +_MAX_SLUG_LENGTH = 48 +_MIN_SEGMENTS = 3 +_MAX_SEGMENTS = 5 +_MAX_NOUN_SEGMENTS = 3 + +_SKILL_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +_NAMING_DIR = os.path.join(_SKILL_ROOT, "assets", "naming") + +_BUILTIN_VERBS = frozenset( + { + "scrape", + "receive", + "download", + "export", + "add", + "fill", + "enter", + "bind", + "withdraw", + "reconcile", + "verify", + "submit", + "query", + "process", + "reply", + "generate", + "draft", + "review", + "confirm", + "track", + "upload", + "match", + "triage", + "ship", + "reprice", + } +) +_BUILTIN_PLATFORMS = frozenset( + { + "amazon", + "shopee", + "xinghang", + "alibaba", + "kingdee", + "icbc", + "pingpong", + "worldfirst", + "paypal", + "lianlian", + "single-window", + "export-rebate", + "etax", + } +) +_BUILTIN_SCOPES = frozenset({"all", "batch", "multi", "default"}) + +LEGACY_EXEMPT_SLUGS: frozenset[str] = frozenset( + { + "account-manager", + "skill-template", + "your-skill-slug", + "your_skill_slug", + } +) + + +def load_wordlist(filename: str) -> frozenset[str]: + """从 assets/naming/{filename} 加载词表;文件不存在时 fallback 内置集合。""" + fallbacks = { + "verbs.txt": _BUILTIN_VERBS, + "platforms.txt": _BUILTIN_PLATFORMS, + "scopes.txt": _BUILTIN_SCOPES, + } + fallback = fallbacks.get(filename, frozenset()) + path = os.path.join(_NAMING_DIR, filename) + if not os.path.isfile(path): + return fallback + words: set[str] = set() + with open(path, encoding="utf-8") as f: + for line in f: + word = line.strip() + if word and not word.startswith("#"): + words.add(word) + return frozenset(words) if words else fallback + + +def parse_slug_segments(slug: str) -> list[str]: + """按 '-' 分割;非法字符由上层 kebab 校验处理。""" + slug = slug.strip() + if not slug: + return [] + return slug.split("-") + + +def _match_platform_suffix( + segments: list[str], + platforms: frozenset[str], +) -> tuple[str, int] | None: + """返回 (platform_name, platform_part_count) 或 None。""" + for platform in sorted(platforms, key=lambda item: (-item.count("-"), item)): + parts = platform.split("-") + part_count = len(parts) + if len(segments) < part_count + 2: + continue + if segments[-part_count:] == parts: + return platform, part_count + return None + + +def classify_slug_form(segments: list[str], *, slug: str = "") -> str: + """ + 返回: 'legacy_exempt' | 'standard' | 'scope' | 'invalid' + + - legacy_exempt: slug in LEGACY_EXEMPT_SLUGS + - standard: 末段匹配 platforms, 首段 in verbs, 3<=len<=5 + - scope: 末段 in scopes, 首段 in verbs, 3<=len<=5 + - invalid: 其他 + """ + if slug in LEGACY_EXEMPT_SLUGS: + return "legacy_exempt" + + if not segments or not (_MIN_SEGMENTS <= len(segments) <= _MAX_SEGMENTS): + return "invalid" + + verbs = load_wordlist("verbs.txt") + platforms = load_wordlist("platforms.txt") + scopes = load_wordlist("scopes.txt") + + if segments[0] not in verbs: + return "invalid" + + if segments[-1] in scopes: + return "scope" + + if _match_platform_suffix(segments, platforms) is not None: + return "standard" + + return "invalid" + + +def validate_slug_semantics( + slug: str, + *, + strict: bool = True, +) -> tuple[list[str], list[str]]: + """返回 (errors, warnings)。""" + slug = slug.strip() + if slug in LEGACY_EXEMPT_SLUGS: + return [], [] + + errors: list[str] = [] + warnings: list[str] = [] + + if not _KEBAB_SLUG.fullmatch(slug): + msg = f"slug 必须为 kebab-case(小写字母、数字、连字符):{slug!r}" + (errors if strict else warnings).append(msg) + return errors, warnings + + if len(slug) > _MAX_SLUG_LENGTH: + msg = f"slug 长度不得超过 {_MAX_SLUG_LENGTH} 字符(当前 {len(slug)}):{slug!r}" + (errors if strict else warnings).append(msg) + + segments = parse_slug_segments(slug) + segment_count = len(segments) + + if segment_count < _MIN_SEGMENTS: + msg = ( + f"slug 段数应为 {_MIN_SEGMENTS}~{_MAX_SEGMENTS}(verb-noun-platform)," + f"当前为 {segment_count} 段:{slug!r};新技能禁止 2 段无平台形态(如 reconcile-finance)" + ) + (errors if strict else warnings).append(msg) + return errors, warnings + + if segment_count > _MAX_SEGMENTS: + msg = f"slug 段数不得超过 {_MAX_SEGMENTS}(当前 {segment_count}):{slug!r}" + (errors if strict else warnings).append(msg) + + verbs = load_wordlist("verbs.txt") + platforms = load_wordlist("platforms.txt") + scopes = load_wordlist("scopes.txt") + + if segments[0] in platforms and segments[0] not in verbs: + msg = f"平台名不应放在 slug 最前:{slug!r}(应为 verb-noun-platform)" + (errors if strict else warnings).append(msg) + + if segments[0] not in verbs: + msg = f"slug 首段应为动词白名单中的词:{segments[0]!r}(slug={slug!r})" + (errors if strict else warnings).append(msg) + + form = classify_slug_form(segments, slug=slug) + if form == "scope": + if segments[-1] not in scopes: + msg = f"slug 末段应为 scope 白名单中的词:{segments[-1]!r}(slug={slug!r})" + (errors if strict else warnings).append(msg) + noun_count = segment_count - 2 + elif form == "standard": + platform_match = _match_platform_suffix(segments, platforms) + if platform_match is None: + msg = f"slug 末段应匹配平台白名单:{slug!r}" + (errors if strict else warnings).append(msg) + noun_count = 0 + else: + _platform_name, platform_part_count = platform_match + noun_count = segment_count - 1 - platform_part_count + else: + if segment_count >= _MIN_SEGMENTS: + if segments[-1] not in scopes: + platform_match = _match_platform_suffix(segments, platforms) + if platform_match is None: + msg = f"slug 末段应匹配平台或 scope 白名单:{slug!r}" + (errors if strict else warnings).append(msg) + noun_count = 0 + + if noun_count > _MAX_NOUN_SEGMENTS: + warnings.append( + f"slug 中间名词段建议不超过 {_MAX_NOUN_SEGMENTS} 个词(当前 {noun_count}):{slug!r}" + ) + + return errors, warnings diff --git a/scripts/util/timeutil.py b/scripts/util/timeutil.py new file mode 100644 index 0000000..318191f --- /dev/null +++ b/scripts/util/timeutil.py @@ -0,0 +1,20 @@ +"""时间戳与 ISO 展示。""" + +from __future__ import annotations + +import time +from datetime import datetime +from typing import Optional + + +def now_unix() -> int: + return int(time.time()) + + +def unix_to_iso(ts: Optional[int]) -> Optional[str]: + if ts is None: + return None + try: + return datetime.fromtimestamp(int(ts)).isoformat(timespec="seconds") + except (ValueError, OSError, OverflowError): + return None diff --git a/tests/.gitignore b/tests/.gitignore new file mode 100644 index 0000000..e6fce5d --- /dev/null +++ b/tests/.gitignore @@ -0,0 +1,10 @@ +# 测试运行产物与本地密钥,勿提交 +__pycache__/ +*.py[cod] +.pytest_cache/ +desktop/artifacts/ +integration/artifacts/ +*.log +.env +.env.* +!.env.sample diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..cd67d4a --- /dev/null +++ b/tests/README.md @@ -0,0 +1,174 @@ +# skill-template 测试说明(企业数字员工技能) + +本目录提供面向 **ERP / TMS / WMS / 船司 / 报关 / 邮件 / 浏览器 / LLM / RPA** 等外联场景的**分层测试骨架**:默认仅内存与本地 SQLite;**真实 API / 真实 RPA** 必须显式设置 ``OPENCLAW_TEST_TARGET``,且仅以 ``*.sample`` 或复制后的文件提供范式。 + +复制为新技能后,请保留隔离数据根与 profile 策略,再按域扩展用例。 + +--- + +## 我该把测试写在哪里? + +人类开发者与 Cursor 等 AI 工具在加用例前,先对照下表决定**文件落点**与**是否默认执行**: + +| 场景 | 放在哪里 | 默认是否运行 | 说明 | +|------|----------|--------------|------| +| CLI / health / version / init-db / metadata / runtime 路径 | `tests/test_*.py` | 是 | 默认必跑,保持轻量、无外联 | +| 纯函数 / 业务规则 | `tests/test_*.py` | 是 | 不依赖真实外部系统 | +| service 层契约 | 从 `tests/samples/test_service_contract.py.sample` 复制到 `tests/test_service_contract.py` | 是,复制后 | 用 `FakeAdapter` / stub 注入外部依赖 | +| 外源同步契约 | 从 `tests/samples/test_sync_contract.py.sample` 复制 | 是,复制后 | 幂等 / 事务 / 空结果保护 / 不导出文件 | +| 单内核多入口 | 从 `tests/samples/test_action_core_contract.py.sample` 复制 | 是,复制后 | CLI/task/兼容命令复用同一 domain service | +| golden fixture 回归 | 从 `tests/samples/test_golden_cases.py.sample` 复制到 `tests/test_golden_cases.py` | 是,复制后 | 输入样例 + 期望输出,适合解析、计算、校验类技能 | +| 仿真 API | `tests/integration/test_simulator_api_contract.py.sample` | 否 | 复制并设置 `OPENCLAW_TEST_TARGET=simulator_api` 后按需运行 | +| 仿真 RPA / 页面操作 | `tests/integration/test_simulator_rpa_contract.py.sample` | 否 | 用 localhost / 仿真页面,不碰真实系统 | +| 真实 API | `tests/integration/test_real_api_contract.py.sample` | 否 | 必须 `OPENCLAW_TEST_TARGET=real_api` | +| 真实 RPA | `tests/integration/test_real_rpa_contract.py.sample` | 否 | 最高风险,只能手动触发 | +| 桌面宿主 E2E | `tests/desktop/test_desktop_smoke.py.sample` | 否 | 需要 pytest + `jiangchang_desktop_sdk` | + +说明:`python tests/run_tests.py` **只**收集 `tests/` **根目录**下的 `test_*.py`;子目录里的 `*.sample` 与 desktop 用例**不会**被默认发现。 + +--- + +## 新技能最小测试清单 + +从本模板复制出新技能仓库后,建议至少逐项确认: + +- [ ] `python tests/run_tests.py -v` 能通过(含 platform-kit 导入与无 vendored core 守护)。 +- [ ] `python scripts/main.py health` 能通过(输出 runtime diagnostics;warning 不导致非零退出)。 +- [ ] `python scripts/main.py version` 输出 JSON,且 `skill` 与目录名 / `SKILL.md` / `constants.SKILL_SLUG` 一致。 +- [ ] `python scripts/main.py init-db` 幂等创建本地库并输出 JSON(`ok` / `skill` / `db_path`)。 +- [ ] 所有 DB / 文件写入都在 `IsolatedDataRoot`(或等价隔离)下测试,不写真实数据目录。 +- [ ] 外部系统默认使用 mock / `FakeAdapter`,不访问真实 API,不打开真实 RPA。 +- [ ] 至少有 1 个成功路径测试。 +- [ ] 至少有 1 个缺必填字段 / 非法输入测试。 +- [ ] 如果有 adapter,至少覆盖 timeout / unauthorized / invalid response(可用 fake 模拟)。 +- [ ] 如果有解析 / 计算 / 校验类业务,至少保留 1 组 golden fixture(脱敏)。 +- [ ] 如果技能有**特有** Python 三方依赖,`requirements.txt` 已声明且版本约束合理(尽量收窄范围);公共依赖(platform-kit、playwright)由宿主共享 runtime 提供,**不要**写入技能 requirements。 +- [ ] `health` 能在依赖缺失或原生运行库未就绪时给出清晰、可操作的错误(非裸 traceback)。 +- [ ] 真实 API / 真实 RPA 测试只放 `tests/integration/`,并且默认 `.sample`,不默认运行。 + +--- + +## 1. 默认必跑(unittest) + +在**技能仓库根目录**执行: + +```powershell +$env:PYTHONDONTWRITEBYTECODE = "1" +python tests/run_tests.py +python tests/run_tests.py -v +python tests/run_tests.py cli_smoke +python tests/run_tests.py test_cli_smoke +``` + +### 1.1 收集范围 + +- ``run_tests.py`` **只**发现 ``tests/`` **根目录**下 ``test_*.py``。 +- **不递归**:``tests/samples/``、``tests/integration/``、``tests/desktop/``(其中文件为 ``*.sample`` 或需 pytest 单独跑)。 +- 启动时把 ``scripts/`` 与 ``tests/`` 加入 ``sys.path``。 +- Windows 下将 **stdout/stderr** 包装为 **UTF-8**。 + +### 1.2 默认套件覆盖 + +| 能力 | 文件 | +|------|------| +| CLI 冒烟(import ``cli.app.main``) | ``test_cli_smoke.py`` | +| **subprocess 真实入口** ``python scripts/main.py`` | ``test_entrypoint_subprocess.py`` | +| 运行路径与 **JIANGCHANG_*** 隔离 | ``test_runtime_paths.py`` | +| ``SKILL.md`` slug 与 ``constants.SKILL_SLUG`` | ``test_skill_metadata.py`` | +| DB / 元数据骨架冒烟 | ``test_db_smoke.py`` | +| 数据管理元数据 / 字段顺序 | ``test_display_metadata.py`` | +| 标准时间字段(Unix 秒 / trigger / 元数据) | ``test_timestamp_columns.py`` | +| **adapter profile / 外呼授权策略** | ``test_adapter_profile_policy.py`` + ``adapter_test_utils.py``(非 ``test_`` 前缀,不自动当用例收集) | +| **无 vendored ``jiangchang_skill_core``** | ``test_platform_import.py`` | +| **runtime diagnostics 架构守护** | ``test_runtime_diagnostics_integration.py`` | +| **文档/runtime 标准守护** | ``test_template_runtime_standard.py`` | +| **配置 bootstrap / config-path** | ``test_config_bootstrap.py`` | +| **health runtime diagnostics** | ``test_health_runtime.py`` | + +复制为新技能后**不得删除**上述架构守护测试,除非同步更新模板标准并理解影响。 + +### 1.3 数据隔离(``_support.IsolatedDataRoot``) + +进入同一临时目录 / 用户: + +| 变量 | 值 | +|------|-----| +| ``JIANGCHANG_DATA_ROOT`` | ``tempfile.mkdtemp(prefix="skill-test-")`` | +| ``JIANGCHANG_USER_ID`` | ``_test`` | + +退出时两个键均恢复(原不存在则 ``pop``),并删除临时目录。 + +### 1.4 产物与密钥 + +- ``tests/.gitignore`` 忽略 ``__pycache__``、``.pytest_cache``、``*.pyc``、``desktop/artifacts/``、``integration/artifacts/``、``.env`` 等。 +- 推荐运行前设置 ``PYTHONDONTWRITEBYTECODE=1`` 减少 ``.pyc``。 + +--- + +## 2. 可选:Service / Golden(``tests/samples/*.sample``) + +- 展示 **service 契约** 与 **fixture 回归** 写法;**默认不执行**。 +- 启用:复制 ``tests/samples/test_service_contract.py.sample`` 等为 ``tests/test_*.py``(去掉 ``.sample``),按业务修改后再 ``python tests/run_tests.py``。 +- 脱敏样例数据见 ``tests/fixtures/README.md`` 与 ``sample_request.json`` / ``expected_response.json``;profile 结构见 ``adapter_profiles.sample.yaml``。 + +--- + +## 3. 可选:Integration(``tests/integration/*.sample``) + +- **默认不跑**;文件均为 ``*.py.sample``。 +- 说明与开关见 **`tests/integration/README.md`**。 +- **禁止**在样例中硬编码真实 token、密码、生产 URL;凭证须来自密钥库或受控环境变量。 + +--- + +## 4. 可选:Desktop E2E(pytest) + +见 **`tests/desktop/README.md`**。示例为 `test_desktop_smoke.py.sample`,需复制为 `test_desktop_smoke.py` 后用 pytest 运行。 + +--- + +## 5. 环境变量:测试档位 + +### 5.1 测试档位 + +- ``OPENCLAW_TEST_TARGET`` + +**合法取值**(非法值将使 ``get_test_target()`` 抛 ``ValueError``): + +| 取值 | 含义 | +|------|------| +| ``unit`` | **默认**:单元 / 内存 / mock,不跑仿真与真实外联 | +| ``mock`` | 与 ``unit`` 类似的安全档位(显式语义) | +| ``simulator_api`` | 仅允许 **仿真 HTTP** 类集成(如 localhost) | +| ``simulator_rpa`` | 仅允许 **仿真页面 / 录播 RPA** | +| ``real_api`` | 真实 API(显式设 ``OPENCLAW_TEST_TARGET=real_api``) | +| ``real_rpa`` | 真实 RPA(显式设 ``OPENCLAW_TEST_TARGET=real_rpa``) | + +未设置时等价 ``unit``。 + +### 5.2 默认安全策略(``adapter_test_utils``) + +- 默认 ``unit``:不访问真实 API、不跑真实 RPA、不做写操作、不读取真实密钥、不写真实数据根。 +- ``assert_no_real_network_env()`` 断言默认未误设 ``OPENCLAW_TEST_TARGET`` 为 ``real_api`` / ``real_rpa``(用于必跑用例自检)。 + +--- + +## AI 编程工具注意事项(Cursor 等) + +以下约束适用于为本仓库或复制出的技能编写/修改测试时,**避免误伤业务与生产**: + +- **不要为了**让测试通过去改业务代码,除非用户明确要求修改实现。 +- **不要**把真实外部系统调用写进默认的 `tests/test_*.py`(默认套件须无外联、无真实浏览器)。 +- **不要**把 `*.sample` 改名为可执行测试或移入根目录并去掉 `.sample`,除非用户明确要求启用 integration / 真实联调。 +- **不要**硬编码真实凭证、token、cookie、生产 URL;占位请用明显虚构域名(如 `example.invalid`)或文档约定的 `credential_ref`。 +- 默认测试只能使用 **mock、stub、`FakeAdapter`、fixtures、隔离 SQLite** 等安全手段。 +- 业务逻辑应优先在 **service 层** 编写与测试,不要只断言 CLI 文案。 +- 异常路径应返回**结构化错误**(如统一错误码/字段),避免裸异常直接穿透到 CLI。 +- 有真实系统联调需求时,测试应写在 `tests/integration/`,并显式设置 ``OPENCLAW_TEST_TARGET`` 为 ``real_api`` / ``real_rpa``;不得默认开启。 + +--- + +## 6. 与 content-manager 的关系 + +- **未**复制 article / media / prompt / NotebookLM 等业务测试。 +- 本骨架面向**通用外联技能**;业务契约请放在各自技能的 ``tests/samples`` / ``tests/integration`` 复制件中实现。 diff --git a/tests/_support.py b/tests/_support.py new file mode 100644 index 0000000..ec9b7fb --- /dev/null +++ b/tests/_support.py @@ -0,0 +1,141 @@ +# -*- coding: utf-8 -*- +""" +测试公共支撑。 + +1. 将 scripts/ 加入 sys.path,便于 `from cli…` / `from db…` / `from util…` 等导入。 +2. `IsolatedDataRoot`:设置 `JIANGCHANG_DATA_ROOT` 与 `JIANGCHANG_USER_ID`, + 退出时恢复原值并删除临时目录,避免污染真实用户数据目录。 +""" +from __future__ import annotations + +import os +import shutil +import sys +import tempfile +from typing import Optional + +_TESTS_DIR = os.path.dirname(os.path.abspath(__file__)) +_SKILL_ROOT = os.path.abspath(os.path.join(_TESTS_DIR, "..")) +_SCRIPTS_DIR = os.path.join(_SKILL_ROOT, "scripts") + +if _SCRIPTS_DIR not in sys.path: + sys.path.insert(0, _SCRIPTS_DIR) + +_ISOLATION_KEYS = ( + "JIANGCHANG_DATA_ROOT", + "JIANGCHANG_USER_ID", +) + + +def get_scripts_dir() -> str: + return _SCRIPTS_DIR + + +def get_skill_root() -> str: + return _SKILL_ROOT + + +def platform_kit_version_patch(version: str = "1.2.11"): + """Mock installed jiangchang-platform-kit version for health/diagnostics tests.""" + from unittest.mock import patch + + return patch( + "jiangchang_skill_core.runtime_diagnostics._platform_kit_version", + return_value=version, + ) + + +class IsolatedDataRoot: + """ + 在独立临时目录下模拟数据根,避免污染本机默认数据目录。 + + 进入上下文时: + - JIANGCHANG_DATA_ROOT -> tempfile.mkdtemp(prefix='skill-test-') + - JIANGCHANG_USER_ID -> _test(与业务默认用户区分) + + 退出时:两个变量均恢复为进入前状态(若原先未设置则 pop),并删除临时目录。 + """ + + def __init__(self, user_id: str = "_test") -> None: + self._tmp: Optional[str] = None + self._old: dict[str, Optional[str]] = {} + self._user_id = user_id + + def __enter__(self) -> str: + self._tmp = tempfile.mkdtemp(prefix="skill-test-") + for k in _ISOLATION_KEYS: + self._old[k] = os.environ.get(k) + os.environ["JIANGCHANG_DATA_ROOT"] = self._tmp + os.environ["JIANGCHANG_USER_ID"] = self._user_id + return self._tmp + + def __exit__(self, *exc_info: object) -> None: + for k in _ISOLATION_KEYS: + v = self._old.get(k) + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + if self._tmp and os.path.isdir(self._tmp): + shutil.rmtree(self._tmp, ignore_errors=True) + self._tmp = None + + +DEMO_ITEMS_TABLE = "demo_items" + +DEMO_ITEMS_DDL = """ +CREATE TABLE IF NOT EXISTS demo_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'draft', + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) +) +""" + +DEMO_ITEMS_COLUMN_METADATA: tuple[dict[str, object], ...] = ( + {"column_name": "id", "display_name": "编号", "description": "主键", "visible": 1, "searchable": 1, "editable": 0}, + {"column_name": "title", "display_name": "标题", "description": "标题", "visible": 1, "searchable": 1, "editable": 1}, + {"column_name": "status", "display_name": "状态", "description": "状态", "visible": 1, "searchable": 1, "editable": 1}, + { + "column_name": "created_at", + "display_name": "创建时间", + "description": "记录创建时间(Unix 秒级时间戳,数据库自动生成)", + "visible": 1, + "searchable": 0, + "editable": 0, + "display_type": "datetime_unix_seconds", + }, + { + "column_name": "updated_at", + "display_name": "更新时间", + "description": "记录最后更新时间(Unix 秒级时间戳,自动维护)", + "visible": 1, + "searchable": 0, + "editable": 0, + "display_type": "datetime_unix_seconds", + }, +) + + +def seed_demo_items(cursor, *, readonly: int = 0) -> None: + """测试夹具:创建 demo_items 物理表、时间戳 trigger 与展示元数据。""" + from db.display_metadata import seed_column_metadata_batch, upsert_table_metadata + from db.timestamp_columns import init_updated_at_trigger + + cursor.execute(DEMO_ITEMS_DDL) + init_updated_at_trigger(cursor, DEMO_ITEMS_TABLE) + upsert_table_metadata( + cursor, + table_name=DEMO_ITEMS_TABLE, + display_name="示例记录", + description="模板测试用业务表示例,不是默认交付表", + visible=1, + readonly=readonly, + ) + seed_column_metadata_batch( + cursor, + table_name=DEMO_ITEMS_TABLE, + columns=DEMO_ITEMS_COLUMN_METADATA, + valid_column_names=[str(column["column_name"]) for column in DEMO_ITEMS_COLUMN_METADATA], + ) diff --git a/tests/adapter_test_utils.py b/tests/adapter_test_utils.py new file mode 100644 index 0000000..042775e --- /dev/null +++ b/tests/adapter_test_utils.py @@ -0,0 +1,118 @@ +# -*- coding: utf-8 -*- +""" +企业技能对接外部系统(ERP / TMS / WMS / 船司 / 报关 / 邮件 / 浏览器 / LLM / RPA)时, +测试侧共用的 **profile / 禁止真实外呼** 工具。 + +- 仅标准库 + jiangchang_skill_core.config;不发起 HTTP;不启动浏览器。 +- 真实 API / RPA 契约测试请放在 ``tests/integration/*.sample``,并显式设置 ``OPENCLAW_TEST_TARGET``。 +""" +from __future__ import annotations + +from typing import Any, Final + +from jiangchang_skill_core import config + +ALLOWED_TEST_TARGETS: Final[frozenset[str]] = frozenset( + { + "unit", + "mock", + "simulator_api", + "simulator_rpa", + "real_api", + "real_rpa", + } +) + + +def _raw_test_target_env() -> str: + return (config.get("OPENCLAW_TEST_TARGET") or "").strip() + + +def get_test_target() -> str: + """ + 读取 ``OPENCLAW_TEST_TARGET``(经 ``config.get`` 三级合并)。 + + 未设置时返回 ``unit``。非法取值抛出 ``ValueError``,便于尽早发现拼写错误。 + """ + raw = _raw_test_target_env() + if not raw: + return "unit" + if raw not in ALLOWED_TEST_TARGETS: + raise ValueError( + f"Invalid test target {raw!r}. Allowed: {sorted(ALLOWED_TEST_TARGETS)}" + ) + return raw + + +def real_access_allowed(profile: str) -> bool: + """ + 判断当前 ``OPENCLAW_TEST_TARGET`` 是否允许运行 ``profile`` 档位测试。 + """ + target = get_test_target() + if profile in ("mock", "simulator_api", "simulator_rpa"): + return True + if profile == "real_api": + return target == "real_api" + if profile == "real_rpa": + return target == "real_rpa" + raise ValueError(f"unknown profile for real_access_allowed: {profile!r}") + + +def should_skip_profile(profile: str, write: bool = False) -> tuple[bool, str]: + """ + 返回 ``(should_skip, reason)``。 + + 策略摘要: + - ``mock``:永不跳过(内存 / FakeAdapter,无外呼)。 + - ``simulator_api`` / ``simulator_rpa``:仅当 ``get_test_target()`` 与 profile 一致时不跳过。 + - ``real_api`` / ``real_rpa``:仅当 ``get_test_target()`` 与 profile 一致时不跳过。 + - ``write`` 参数保留以兼容 integration 样例签名;档位一致即允许写操作测试。 + """ + _ = write + target = get_test_target() + + if profile == "mock": + return (False, "") + + if profile in ("simulator_api", "simulator_rpa", "real_api", "real_rpa"): + if target != profile: + return (True, f"OPENCLAW_TEST_TARGET must be {profile} (got {target!r})") + return (False, "") + + raise ValueError(f"unknown profile: {profile!r}") + + +class FakeAdapter: + """ + 模拟外部系统 adapter,用于单元测试中的超时 / 鉴权 / 畸形响应等分支。 + + ``mode``:``success`` | ``timeout`` | ``invalid_response`` | ``unauthorized`` + """ + + def __init__(self, mode: str = "success") -> None: + if mode not in ("success", "timeout", "invalid_response", "unauthorized"): + raise ValueError(f"unsupported FakeAdapter mode: {mode!r}") + self._mode = mode + + def call(self, payload: Any) -> Any: + if self._mode == "success": + return {"success": True, "data": {"echo": payload}} + if self._mode == "timeout": + raise TimeoutError("simulated upstream timeout") + if self._mode == "invalid_response": + return object() # 非 dict,模拟无法解析的响应体 + if self._mode == "unauthorized": + raise PermissionError("simulated 401/403") + raise RuntimeError("unreachable") + + +def assert_no_real_network_env() -> None: + """ + 断言默认必跑套件未误设真实外联档位(``real_api`` / ``real_rpa``)。 + + 若需在受控环境跑真实集成,请显式设置 ``OPENCLAW_TEST_TARGET`` 并使用 ``tests/integration/*.sample``。 + """ + target = get_test_target() + assert target not in ("real_api", "real_rpa"), ( + f"OPENCLAW_TEST_TARGET must not be {target!r} in default unit tests" + ) diff --git a/tests/desktop/README.md b/tests/desktop/README.md new file mode 100644 index 0000000..a8ecf3e --- /dev/null +++ b/tests/desktop/README.md @@ -0,0 +1,63 @@ +# 可选桌面 E2E(pytest) + +本目录用于**匠厂桌面 + jiangchang_desktop_sdk** 的端到端冒烟,**不是** `python tests/run_tests.py` 默认路径的一部分。 + +## 依赖 + +- `pytest` +- `jiangchang_desktop_sdk`(由 `conftest.py` 按多级路径尝试加入 `sys.path`,见该文件说明) +- 匠厂桌面已安装或开发态可启动,且 `jiangchang://` 协议可用 + +## 为什么不默认跑 + +- 需要真实宿主环境,无法在纯 CI/无头环境稳定复现。 +- `unittest` 的 `tests/run_tests.py` **只发现** `tests/` 根目录下 `test_*.py`,**不递归**本目录。 +- 示例文件为 **`test_desktop_smoke.py.sample`**,避免被误收集。 + +## 如何启用 + +1. 将 `test_desktop_smoke.py.sample` **复制或重命名**为 `test_desktop_smoke.py`。 +2. 按你的技能名称、路由关键词、期望输出修改 `ask` 与断言。 +3. 运行: + +```powershell +pytest tests/desktop/test_desktop_smoke.py -v -s +``` + +失败时,`conftest.py` 中的 `failure_snapshot_dir` fixture 会将现场目录指向 `tests/desktop/artifacts/`(需自行避免将大文件提交进仓库)。 + +## 与后端测试的关系 + +- **后端 / CLI / DB**:请使用仓库根目录下 `python tests/run_tests.py`(见 `tests/README.md`)。 +- **桌面 E2E**:仅在本目录用 **pytest** 维护,二者互补、互不替代。 + +若仅需验证 **service 业务逻辑**、契约与 golden 回归,**不要**写 desktop E2E;desktop 只用于验证**宿主 UI、路由、`jiangchang://` 协议、真实用户交互链路**等与桌面宿主强相关的行为。 + +## 模板选择指南 + +| 场景 | 用哪个模板 | +|---|---| +| 纯问答 skill(不上传附件、不跑长流程) | `test_desktop_smoke.py.sample` | +| 需要上传附件 / RPA 长流程 / Agent 多轮工具调用 | `test_desktop_smoke_with_attachment.py.sample` | + +录屏入口都用 `record_screencast.py.sample`,里面 `TEST_FILE_NAME` 常量切换测试文件。 + +## 已知 SDK 坑(来自 disburse-payroll-icbc 实战) + +1. **`client.send_file()` 在 v2.0.17+ 抛 NotImplementedError**——附件场景必须用 + `jiangchang_desktop_sdk.e2e_helpers.drop_file_into_composer`(DataTransfer 拖拽)。 +2. **`client.ask()` 内部 `user_msg_index<0` 死循环**——如果匠厂启动慢 / gateway 握手有 + 延迟,导致 15s 内没抓到 user 节点,ask() 会锁死后续 900s 等待。建议长流程 / 附件场景 + 绕开 ask(),用 `jiangchang_desktop_sdk.e2e_helpers` 里的 `send_prompt_via_composer` + + `wait_for_user_message` + `wait_for_agent_complete` 自己控时。 +3. **字幕关键词建议用 ASCII**——虽然 platform-kit 已修了 subprocess 编码乱码, + 保险起见字幕脚本里关键词用 `step: 1/` 这种 ASCII 锚而非中文短语。 +4. **`client.bring_to_front()` 已升级**——v?? 起会 ctypes ShowWindow(SW_MAXIMIZE) + + SetForegroundWindow,能真正铺满桌面。dev 模式下也能用(不依赖 jiangchang:// 协议)。 + +## 录屏新能力(platform-kit v??) + +- `run_screencast(..., activate_window=True)` 自动激活+最大化匠厂窗口 +- `run_screencast(..., monitor_index=1)` 多显示器场景显式录主屏 +- 字幕脚本支持三元组 `(keyword, text, duration)` 给个别字幕指定独立时长, + 覆盖长等待场景中间没日志输出的"字幕真空" diff --git a/tests/desktop/conftest.py b/tests/desktop/conftest.py new file mode 100644 index 0000000..09811f4 --- /dev/null +++ b/tests/desktop/conftest.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +""" +可选桌面 E2E:pytest 收集时自动加载,负责把 jiangchang_desktop_sdk 加入 sys.path。 + +查找顺序: +1. 已 pip install 则直接 import; +2. 环境变量 JIANGCHANG_KIT_ROOT → sdk/jiangchang-desktop-sdk/src(旧版布局); +3. 环境变量 JIANGCHANG_KIT_ROOT → src(新版 platform-kit 布局); +4. 自本文件上溯:skill 根 → workspace 根 → OpenClaw 根,拼路径; +5. 兜底 D:\\AI\\jiangchang-platform-kit\\src(若存在); +6. 都找不到则 pytest.exit 给出可操作的排障说明。 +""" +from __future__ import annotations + +import importlib.util +import os +import sys + +import pytest + + +def _bootstrap_jiangchang_desktop_sdk() -> None: + if importlib.util.find_spec("jiangchang_desktop_sdk") is not None: + return + + env_kit_root = (os.environ.get("JIANGCHANG_KIT_ROOT") or "").strip() + + here = os.path.dirname(os.path.abspath(__file__)) + skill_root = os.path.abspath(os.path.join(here, "..", "..")) + workspace_root = os.path.abspath(os.path.join(skill_root, "..")) + open_claw_root = os.path.abspath(os.path.join(workspace_root, "..")) + + candidates = [] + if env_kit_root: + candidates.append(os.path.join(env_kit_root, "sdk", "jiangchang-desktop-sdk", "src")) + candidates.append(os.path.join(env_kit_root, "src")) + candidates.append( + os.path.join(workspace_root, "jiangchang-platform-kit", "sdk", "jiangchang-desktop-sdk", "src") + ) + candidates.append( + os.path.join(open_claw_root, "jiangchang-platform-kit", "sdk", "jiangchang-desktop-sdk", "src") + ) + candidates.append( + os.path.join(workspace_root, "jiangchang-platform-kit", "src") + ) + candidates.append( + os.path.join(open_claw_root, "jiangchang-platform-kit", "src") + ) + default_kit_src = r"D:\AI\jiangchang-platform-kit\src" + if os.path.isdir(default_kit_src): + candidates.append(default_kit_src) + + tried = [] + for cand in candidates: + cand_abs = os.path.abspath(cand) + tried.append(cand_abs) + if os.path.isdir(cand_abs) and os.path.isdir(os.path.join(cand_abs, "jiangchang_desktop_sdk")): + if cand_abs not in sys.path: + sys.path.insert(0, cand_abs) + return + + pytest.exit( + "无法定位 jiangchang_desktop_sdk。请任选一种方式解决:\n" + " 1) pip install -e <path-to>/jiangchang-platform-kit\n" + " 2) 设置环境变量 JIANGCHANG_KIT_ROOT=<path-to>/jiangchang-platform-kit\n" + " (SDK 新版位置:<root>\\src;旧版:<root>\\sdk\\jiangchang-desktop-sdk\\src)\n" + " 3) 将 jiangchang-platform-kit 与本仓库放在同一 workspace 父级可推断的位置\n" + "已尝试的候选路径:\n - " + "\n - ".join(tried), + returncode=4, + ) + + +_bootstrap_jiangchang_desktop_sdk() + +_ARTIFACT_DIR = os.path.join(os.path.dirname(__file__), "artifacts") + + +@pytest.hookimpl(tryfirst=True, hookwrapper=True) +def pytest_runtest_makereport(item, call): + outcome = yield + rep = outcome.get_result() + setattr(item, f"rep_{rep.when}", rep) + + +@pytest.fixture +def failure_snapshot_dir() -> str: + os.makedirs(_ARTIFACT_DIR, exist_ok=True) + return _ARTIFACT_DIR diff --git a/tests/desktop/record_screencast.py.sample b/tests/desktop/record_screencast.py.sample new file mode 100644 index 0000000..7607db3 --- /dev/null +++ b/tests/desktop/record_screencast.py.sample @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- +""" +Screencast 录制入口(模板)。 + +启用方式: + 1. 复制为 record_screencast.py + 2. 修改 SKILL_SLUG 和 SUBTITLE_SCRIPT + 3. 决定 test_file 指向哪个测试(默认 test_desktop_smoke.py; + 有附件场景的用 test_desktop_smoke_with_attachment.py) + 4. 运行:python tests/desktop/record_screencast.py + +依赖: + 1. pip install mss + 2. 系统已安装 ffmpeg 并在 PATH 中 + 3. jiangchang-platform-kit 可推断路径(见 _bootstrap 逻辑) + 4. MEDIA_ASSETS_ROOT 环境变量指向 media-assets 仓库(或使用默认路径) + +平台 kit v?? 之后 run_screencast 自动激活并最大化匠厂窗口、支持指定显示器、 +字幕支持每条独立时长。本模板已经利用这些能力,新 skill 复制后无需再内嵌 +ctypes 激活代码。 +""" +from __future__ import annotations + +import os +import sys +from pathlib import Path + + +def _bootstrap_screencast() -> None: + """将 jiangchang-platform-kit/tools 加入 sys.path,使 screencast 包可导入。""" + here = Path(__file__).resolve() + skill_root = here.parents[2] + workspace_root = skill_root.parent + open_claw_root = workspace_root.parent + + candidates = [ + workspace_root / "jiangchang-platform-kit" / "tools", + open_claw_root / "jiangchang-platform-kit" / "tools", + Path(r"D:\AI\jiangchang-platform-kit") / "tools", + ] + env_kit = os.environ.get("JIANGCHANG_KIT_ROOT", "") + if env_kit: + candidates.insert(0, Path(env_kit) / "tools") + + for cand in candidates: + if (cand / "screencast" / "__init__.py").exists(): + if str(cand) not in sys.path: + sys.path.insert(0, str(cand)) + return + + raise RuntimeError( + "找不到 jiangchang-platform-kit/tools/screencast。\n" + "请设置 JIANGCHANG_KIT_ROOT 环境变量,或将 platform-kit 放在 workspace " + "同级目录。" + ) + + +_bootstrap_screencast() + +from screencast import run_screencast # noqa: E402 + +# ============================================================ +# ↓↓↓ 复制后只需修改下面这些 ↓↓↓ +# ============================================================ + +SKILL_SLUG = "draft-closing-report-default" # 替换为本技能 slug + +# 字幕脚本设计要点(来自 disburse-payroll-icbc 实战经验): +# 1. **关键词全用 ASCII**:保险起见绕开 Windows cp936/UTF-8 编码偶发问题。 +# 虽然 platform-kit 已修了 subprocess 编码,ASCII 关键词仍是最稳的。 +# 2. **用 "step: N/X" 模式作触发锚**:在测试代码里加 _logger.info("step: 1/5 ...") +# 日志作为字幕触发点,能精确控制字幕出现时机,比依赖 SDK 内部日志更可靠。 +# 3. **关键词每条只触发一次**,所以"step: 1/" 这种唯一前缀比"准备数据"更稳。 +# 4. **三元组支持每条独立时长**:(关键词, 文案, 显示秒数); +# long wait 场景可以让某条字幕停留更久(如 30s)覆盖中间无日志输出的等待期。 +SUBTITLE_SCRIPT = [ + # (keyword, text) 二元组用默认时长(5s) + ("ensure_app_running", "启动匠厂智能体平台"), + ("wait_gateway_ready", "网关就绪"), + ("step: 1/", "准备测试数据"), + ("step: 2/", "执行技能操作"), + # (keyword, text, duration) 三元组指定独立时长 + ("step: 3/", "技能在后端持续执行,请稍候...", 30.0), + ("step: 4/", "结果汇总返回"), + ("PASSED", "测试通过 ✓"), +] + +# 测试文件选择(按需切换): +# - test_desktop_smoke.py: 纯问答 skill 用这个 +# - test_desktop_smoke_with_attachment.py: 需要上传附件的 skill 用这个 +TEST_FILE_NAME = "test_desktop_smoke.py" + +# 音乐子目录(按 skill 风格选): +# - "music/corporate": 商务严肃(金融、办公场景) +# - "music/calm": 舒缓(流程演示、配置类) +# - "music/upbeat": 活力(成功汇报、营销视频) +# - "music"(默认): 三个子目录混选 +MUSIC_SUBDIR = "music/corporate" + +# ============================================================ + +if __name__ == "__main__": + _here = Path(__file__).resolve() + test_file = str(_here.parent / TEST_FILE_NAME) + output_dir = str(_here.parent / "artifacts" / "recordings") + + run_screencast( + skill_slug=SKILL_SLUG, + subtitle_script=SUBTITLE_SCRIPT, + pytest_args=[test_file, "-v", "-s"], + output_dir=output_dir, + music_subdir=MUSIC_SUBDIR, + # platform-kit 自动激活+最大化窗口(Windows ctypes / 其他系统协议), + # 想关掉传 activate_window=False + # monitor_index=1 显式录主屏(多显示器场景推荐) + ) diff --git a/tests/desktop/test_desktop_smoke.py.sample b/tests/desktop/test_desktop_smoke.py.sample new file mode 100644 index 0000000..e099851 --- /dev/null +++ b/tests/desktop/test_desktop_smoke.py.sample @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +""" +可选桌面 E2E 示例(默认不执行:文件名后缀为 .sample)—— **简单问答场景**。 + +启用方式: + 复制本文件为 test_desktop_smoke.py,按目标技能修改提问文案与断言。 + 运行:pytest tests/desktop/test_desktop_smoke.py -v -s + +依赖:pytest、jiangchang_desktop_sdk、匠厂桌面宿主已就绪。 + +【何时用此模板】 +- 技能只需要发一句话给 Main Agent,等回复,做断言(纯问答) +- 不涉及上传附件 / 不涉及 RPA 长流程 + +【何时不要用此模板,改用 test_desktop_smoke_with_attachment.py.sample】 +- 需要上传文件(Excel / PDF / 图片)作为附件 +- 技能内部跑 RPA / 长流程(> 2 分钟),需要精细等待 + +【已知 SDK 限制(来自 disburse-payroll-icbc 实战)】 +- client.ask() 内部对"找到 user 消息节点"只给 15 秒窗口,找不到就锁死 + user_msg_index=-1,后续等待 assistant 永远 return None。如果你的场景里 + 匠厂启动较慢 / gateway 握手有延迟,client.ask() 可能假死到 timeout。 + 此模板仍用 ask(),因为它对**简单场景**够用。复杂场景请用 _with_attachment.py.sample + 里的"绕开 ask()"模式。 +- client.send_file() 在 v2.0.17+ 抛 NotImplementedError,**绝不要调用它**。 + 需要附件请用 _with_attachment.py.sample 里的拖拽方案。 +""" +from __future__ import annotations + +import logging +import os + +import pytest + +from jiangchang_desktop_sdk import AskOptions, JiangchangDesktopClient + +_logger = logging.getLogger("skill-template-desktop-e2e-sample") +if not _logger.handlers: + _logger.setLevel(logging.INFO) + _h = logging.StreamHandler() + _h.setFormatter(logging.Formatter("[E2E-sample] %(message)s")) + _logger.addHandler(_h) + +_ARTIFACT_DIR = os.path.join(os.path.dirname(__file__), "artifacts") + + +class TestDesktopSmokeSample: + """最小 smoke:拉起桌面、等待网关、发一条与技能路由相关的问句并做宽松断言。""" + + @pytest.fixture(autouse=True) + def connect_and_teardown(self, request): + client = JiangchangDesktopClient() + _logger.info("ensure_app_running") + client.ensure_app_running(wait_timeout_s=30) + client.bring_to_front() + client.wait_gateway_ready(timeout_ms=30000) + self.client = client + yield + try: + rep = getattr(request.node, "rep_call", None) + if rep is not None and rep.failed: + os.makedirs(_ARTIFACT_DIR, exist_ok=True) + paths = client.snapshot(_ARTIFACT_DIR, tag=request.node.name) + _logger.error("snapshot: %s", paths) + finally: + client.disconnect() + + def test_gateway_and_simple_ask(self) -> None: + """请将 SKILL 名与路由关键词改成你复制后的真实技能。""" + answer = self.client.ask( + "请使用当前已安装技能执行一次 health 检查并返回 OK 或等价结果。", + AskOptions(timeout=120000, new_task=True), + ) + assert answer + assert "OK" in answer or "ok" in answer.lower() or "健康" in answer diff --git a/tests/desktop/test_desktop_smoke_with_attachment.py.sample b/tests/desktop/test_desktop_smoke_with_attachment.py.sample new file mode 100644 index 0000000..de3fcb0 --- /dev/null +++ b/tests/desktop/test_desktop_smoke_with_attachment.py.sample @@ -0,0 +1,137 @@ +# -*- coding: utf-8 -*- +""" +可选桌面 E2E 示例(默认不执行:文件名后缀为 .sample)—— **附件上传 + 长流程场景**。 + +启用方式: + 1. 复制为 test_desktop_smoke_with_attachment.py + 2. 修改 SKILL_SLUG / PROMPT / 断言为你技能的真实业务 + 3. 准备样例附件,或动态生成(参考 _prepare_sample_attachment) + 4. 运行:pytest tests/desktop/test_desktop_smoke_with_attachment.py -v -s + +依赖: + - pytest / jiangchang_desktop_sdk / 匠厂桌面宿主已就绪 + - 通过 conftest.py 自动 bootstrap,jiangchang_desktop_sdk.e2e_helpers 模块在 sys.path + +【为什么用此模板而不是 test_desktop_smoke.py.sample】 +- client.send_file() 在 v2.0.17+ 抛 NotImplementedError,必须用拖拽方式 +- client.ask() 在 user_idx<0 时会锁死(已知 SDK 缺陷),本模板绕开 ask(), + 在测试侧自己用 jiangchang_desktop_sdk.e2e_helpers 做等待 + +【字幕配合】 +- 关键节点都打了 _logger.info("step: N/7 ..."),对应 record_screencast.py 的 + SUBTITLE_SCRIPT 用 step: 关键词触发字幕 +""" +from __future__ import annotations + +import logging +import os +from pathlib import Path + +import pytest + +from jiangchang_desktop_sdk import JiangchangDesktopClient +from jiangchang_desktop_sdk.e2e_helpers import ( + drop_file_into_composer, + send_prompt_via_composer, + wait_for_agent_complete, + wait_for_attachment_ready, + wait_for_composer_enabled, + wait_for_user_message, +) + +# ============================================================ +# ↓↓↓ 复制后修改这些 ↓↓↓ +# ============================================================ + +SKILL_SLUG = "draft-closing-report-default" + +PROMPT = ( + "请使用 /draft-closing-report-default 处理我刚才上传的文件。" + "完成后请用清单方式汇总:[改成你技能要求的字段]。" +) + +# 业务断言:根据你的技能输出修改 +EXPECTED_KEYWORDS_IN_REPLY = ["关键词1", "关键词2"] + +# ============================================================ + +_logger = logging.getLogger("desktop-e2e-with-attachment-sample") +if not _logger.handlers: + _logger.setLevel(logging.INFO) + _h = logging.StreamHandler() + _h.setFormatter(logging.Formatter("[E2E] %(message)s")) + _logger.addHandler(_h) + +_ARTIFACT_DIR = os.path.join(os.path.dirname(__file__), "artifacts") + + +def _prepare_sample_attachment(tmp_path: Path) -> Path: + """在 tmp_path 下生成一个样例附件(按 skill 真实需要修改)。 + + 示例:生成一个 5 行的 xlsx。你的 skill 可能需要 CSV / PDF / 图片等其他格式。 + """ + from openpyxl import Workbook # noqa: WPS433 + xlsx = tmp_path / "sample.xlsx" + wb = Workbook() + ws = wb.active + ws.append(["列1", "列2", "列3"]) + for i in range(1, 6): + ws.append([f"值{i}A", f"值{i}B", i * 100]) + wb.save(str(xlsx)) + return xlsx + + +@pytest.fixture +def client(request): + c = JiangchangDesktopClient() + _logger.info("ensure_app_running") + c.ensure_app_running(wait_timeout_s=45) + c.bring_to_front() + c.wait_gateway_ready(timeout_ms=30000) + yield c + try: + rep = getattr(request.node, "rep_call", None) + if rep is not None and rep.failed: + os.makedirs(_ARTIFACT_DIR, exist_ok=True) + paths = c.snapshot(_ARTIFACT_DIR, tag=request.node.name) + _logger.error("failure snapshot: %s", paths) + finally: + c.disconnect() + + +def test_desktop_with_attachment(client: JiangchangDesktopClient, tmp_path: Path) -> None: + _logger.info("step: 1/7 准备样例附件") + attachment_path = _prepare_sample_attachment(tmp_path) + + client.new_task() + page = client.get_page() + client.bring_to_front() + + _logger.info("step: 2/7 拖拽附件到聊天框") + drop_file_into_composer(page, attachment_path) + + _logger.info("step: 3/7 等附件 stage-buffer 完成") + wait_for_attachment_ready(page, attachment_path.name) + + wait_for_composer_enabled(page) + client.bring_to_front() + _logger.info("step: 4/7 键入并发送指令") + send_prompt_via_composer(page, PROMPT) + + _logger.info("step: 5/7 Main Agent 接收,等待路由到技能") + user_idx = wait_for_user_message(page, PROMPT, timeout_s=60.0) + + _logger.info("step: 6/7 技能在后端执行(可能 2-3 分钟)") + client.bring_to_front() + reply = wait_for_agent_complete( + page, user_idx, + overall_timeout_s=1200.0, + stable_seconds=5.0, + ) + + _logger.info("step: 7/7 校验回复内容") + _logger.info("reply length=%d", len(reply)) + + assert reply, "assistant 回复为空" + for kw in EXPECTED_KEYWORDS_IN_REPLY: + assert kw in reply, f"期望关键词 {kw!r} 未出现在回复中;片段:{reply[:400]!r}" diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md new file mode 100644 index 0000000..34b29ac --- /dev/null +++ b/tests/fixtures/README.md @@ -0,0 +1,27 @@ +# fixtures(脱敏样例) + +本目录存放 **golden case / contract test** 用的输入、期望输出与 **adapter profile 样例**。 + +## 规则 + +- 只能放**虚构、脱敏、可公开**的数据(演示租户、演示 request_id 等)。 +- **禁止**:真实客户名、真实订单/提单号、真实报关单、真实 token/cookie、真实内网 URL、生产环境凭证。 +- `adapter_profiles.sample.yaml` 中的 URL 须使用 `localhost`、`example.invalid` 等明显占位;`credential_ref` 仅表达「从密钥库引用」的约定,**不得**内嵌密钥。 + +## 与本仓库测试的对应关系 + +| 文件 | 用途 | +|------|------| +| `sample_request.json` | 通用请求体样例,供 ``tests/samples/test_golden_cases.py.sample`` 演示读取。 | +| `expected_response.json` | 与上配套的期望摘要字段(业务复制后按域替换)。 | +| `adapter_profiles.sample.yaml` | 五类运行方式(mock / simulator_api / simulator_rpa / real_api / real_rpa)的配置表达样例。 | + +--- + +## 给 AI 工具的替换规则 + +- 可以按业务**重命名字段**、增减键名,但必须保持**虚构、脱敏**;不得把真实客户标识原样搬进仓库。 +- 可以**新增**更多 fixture 文件,例如 `case_valid_001.json`、`case_missing_required_001.json`,并在测试中通过相对路径读取。 +- **禁止**将真实企业数据、真实单证截图中的文字、真实报关要素未经处理直接复制进 `fixtures/`。 +- 若用例来自真实客户样本,须先**脱敏、泛化、替换编号与地名**,再作为模板数据提交。 + diff --git a/tests/fixtures/adapter_profiles.sample.yaml b/tests/fixtures/adapter_profiles.sample.yaml new file mode 100644 index 0000000..9a30db2 --- /dev/null +++ b/tests/fixtures/adapter_profiles.sample.yaml @@ -0,0 +1,40 @@ +# 样例:五类 adapter 运行方式(复制到业务仓后按环境改名;勿提交真实凭证) +runtime: + profile: mock + +adapters: + demo_external_system: + interface: demo + profile: mock + + mock: + target: mock + channel: none + + simulator_api: + target: simulator + channel: api + base_url: http://localhost:5180/api/demo + timeout_seconds: 10 + + simulator_rpa: + target: simulator + channel: rpa + entry_url: http://localhost:5180/select + timeout_seconds: 30 + + real_api: + target: real + channel: api + base_url: https://example.invalid/api + credential_ref: vault://tenant/demo/external-system/api + write_enabled: false + timeout_seconds: 20 + + real_rpa: + target: real + channel: rpa + entry_url: https://example.invalid/portal + credential_ref: vault://tenant/demo/external-system/rpa + write_enabled: false + timeout_seconds: 60 diff --git a/tests/fixtures/expected_response.json b/tests/fixtures/expected_response.json new file mode 100644 index 0000000..d031ce4 --- /dev/null +++ b/tests/fixtures/expected_response.json @@ -0,0 +1,6 @@ +{ + "success": true, + "data": { + "status": "ok" + } +} diff --git a/tests/fixtures/sample_request.json b/tests/fixtures/sample_request.json new file mode 100644 index 0000000..96ad266 --- /dev/null +++ b/tests/fixtures/sample_request.json @@ -0,0 +1,11 @@ +{ + "request_id": "req_test_001", + "tenant_id": "tenant_demo", + "actor": "tester", + "parameters": { + "input_text": "demo input", + "options": { + "dry_run": true + } + } +} diff --git a/tests/integration/README.md b/tests/integration/README.md new file mode 100644 index 0000000..2c0939f --- /dev/null +++ b/tests/integration/README.md @@ -0,0 +1,48 @@ +# integration(可选集成 / 真实外联) + +本目录**默认不执行**: + +- ``python tests/run_tests.py`` 只收集 ``tests/`` 根目录下 ``test_*.py``,**不会**进入本目录。 +- 本目录下文件一律为 ``*.py.sample``,pytest / unittest **默认也不会**按普通 ``test_*.py`` 收集。 + +## 与 samples 的区别 + +| 维度 | `tests/samples/` | `tests/integration/` | +|------|------------------|------------------------| +| 目的 | 默认可**升级**为根目录 `test_*.py` 的 **业务单元 / service 契约 / golden** 范式 | **外联联调**范式:网络、浏览器、仿真或真实系统 | +| 默认执行 | `.sample` 不执行;复制到根目录并改名后才进入默认套件 | **永远不**作为默认 unittest 的一部分;复制后仍需显式环境变量与手动/CI 任务触发 | +| 典型内容 | `FakeAdapter`、fixture、隔离 DB | `localhost` 仿真、真实 API、真实 RPA(最高风险) | + +**原则**:只要会访问**网络**、**浏览器**、**真实或仿真外部系统**,应优先放在 `integration`(并保持 `.sample` 直至团队同意启用),而不是写进默认 `tests/test_*.py`。 + +## 何时使用 + +| 场景 | 建议 ``OPENCLAW_TEST_TARGET`` | 其它条件 | +|------|------------------------------|----------| +| 本地 / CI 对接**仿真 HTTP** | ``simulator_api`` | 仿真服务如 ``http://localhost:5180`` | +| **仿真页面** / 录播 RPA | ``simulator_rpa`` | 本地页面或沙箱浏览器 profile | +| **真实 API** | ``real_api`` | **仅手动触发** | +| **真实 RPA**(最高风险) | ``real_rpa`` | **仅手动触发** | + +## 环境变量(与 ``adapter_test_utils`` 一致) + +- **目标档位**:``OPENCLAW_TEST_TARGET`` + 取值:``unit`` | ``mock`` | ``simulator_api`` | ``simulator_rpa`` | ``real_api`` | ``real_rpa`` + 默认未设置等价于 ``unit``。 + +## 安全约束 + +- **禁止**在样例或复制后的测试代码中硬编码真实账号、密码、token、cookie、生产 URL。 +- 凭证应来自**平台密钥库**、受控环境变量或本机安全配置(``.env`` 已列入 ``tests/.gitignore``,勿提交)。 +- 真实 RPA 可能对 ERP/TMS/WMS 等产生真实操作;**务必**沙箱账号、只读权限,并由人工触发 CI job。 + +## 如何运行某个样例 + +1. 将目标 ``*.sample`` 复制为 ``test_*.py``(去掉 ``.sample``)。 +2. 按技能修改其中的占位 URL / 路由 / 断言。 +3. 使用 **pytest** 指向该文件(或整目录),并导出上表所需环境变量。 + +```powershell +$env:OPENCLAW_TEST_TARGET = "simulator_api" +pytest tests/integration/test_simulator_api_contract.py -v +``` diff --git a/tests/integration/test_real_api_contract.py.sample b/tests/integration/test_real_api_contract.py.sample new file mode 100644 index 0000000..6eec96a --- /dev/null +++ b/tests/integration/test_real_api_contract.py.sample @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# 样例:复制为 test_real_api_contract.py;需 OPENCLAW_TEST_TARGET=real_api。 +"""真实 HTTP API(只读优先;默认跳过)。""" +from __future__ import annotations + +import unittest + +from adapter_test_utils import should_skip_profile + + +class TestRealApiContractSample(unittest.TestCase): + def test_skip_by_default(self) -> None: + skip, reason = should_skip_profile("real_api", write=False) + if skip: + raise unittest.SkipTest(reason) + # 写操作示例(复制后使用): + # skip_w, _ = should_skip_profile("real_api", write=True) + # 禁止在此文件写入真实 token;从 vault / 环境注入读取 endpoint。 + self.assertFalse(skip) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/integration/test_real_rpa_contract.py.sample b/tests/integration/test_real_rpa_contract.py.sample new file mode 100644 index 0000000..4db607e --- /dev/null +++ b/tests/integration/test_real_rpa_contract.py.sample @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# 样例:复制为 test_real_rpa_contract.py;需 OPENCLAW_TEST_TARGET=real_rpa。 +""" +真实 RPA / 桌面自动化(**最高风险**,仅手动触发)。 + +默认只读思路示例:打开沙箱门户、读取标题、**不提交表单**。 +写操作测试同样须显式设 ``OPENCLAW_TEST_TARGET=real_rpa`` 后手动触发。 +""" +from __future__ import annotations + +import unittest + +from adapter_test_utils import should_skip_profile + + +class TestRealRpaContractSample(unittest.TestCase): + def test_skip_by_default(self) -> None: + skip, reason = should_skip_profile("real_rpa", write=False) + if skip: + raise unittest.SkipTest(reason) + # 真实技能中:连接企业沙箱门户,执行只读断言;禁止默认操作生产系统。 + self.assertFalse(skip) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/integration/test_simulator_api_contract.py.sample b/tests/integration/test_simulator_api_contract.py.sample new file mode 100644 index 0000000..f512e36 --- /dev/null +++ b/tests/integration/test_simulator_api_contract.py.sample @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# 样例:复制为 test_simulator_api_contract.py 后,在 OPENCLAW_TEST_TARGET=simulator_api 下用 pytest 或 unittest 运行。 +"""仿真 HTTP API 契约(默认跳过;不引入 requests 运行时依赖)。""" +from __future__ import annotations + +import unittest + +from adapter_test_utils import should_skip_profile + + +class TestSimulatorApiContractSample(unittest.TestCase): + """演示:仅当 ``OPENCLAW_TEST_TARGET=simulator_api`` 时不跳过。""" + + def test_skip_unless_simulator_api_target(self) -> None: + skip, reason = should_skip_profile("simulator_api") + if skip: + raise unittest.SkipTest(reason) + # 真实技能中:在此使用 urllib 访问 localhost 仿真,或通过 adapter factory 注入 stub。 + # 禁止默认连接生产 ERP/TMS。 + self.assertTrue(True) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/integration/test_simulator_rpa_contract.py.sample b/tests/integration/test_simulator_rpa_contract.py.sample new file mode 100644 index 0000000..1815483 --- /dev/null +++ b/tests/integration/test_simulator_rpa_contract.py.sample @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# 样例:复制为 test_simulator_rpa_contract.py 后按需运行;默认跳过。 +"""仿真页面 / RPA 契约(不默认启动浏览器)。""" +from __future__ import annotations + +import unittest + +from adapter_test_utils import should_skip_profile + + +class TestSimulatorRpaContractSample(unittest.TestCase): + def test_skip_unless_simulator_rpa_target(self) -> None: + skip, reason = should_skip_profile("simulator_rpa") + if skip: + raise unittest.SkipTest(reason) + # 真实技能中:可替换为 Playwright 连接 http://localhost:5180/select 等仿真入口。 + self.assertFalse(skip) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/run_tests.py b/tests/run_tests.py new file mode 100644 index 0000000..0bfb501 --- /dev/null +++ b/tests/run_tests.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +"""后端 / CLI / DB 单元测试入口(unittest,不强制 pytest)。 + +用法: + python tests/run_tests.py # 发现 tests/ 根目录下全部 test_*.py + python tests/run_tests.py -v # 详细输出 + python tests/run_tests.py cli_smoke # 仅匹配 test_cli_smoke.py + python tests/run_tests.py test_cli_smoke + +说明: + 只发现本目录(tests/)下一层的 test_*.py;不递归 ``samples/``、``integration/``、 + ``desktop/``(后三者提供 ``*.sample`` 或 pytest 专用用例,见 ``tests/README.md``)。 +""" +from __future__ import annotations + +import os +import sys +import unittest + +_TESTS_DIR = os.path.dirname(os.path.abspath(__file__)) +_SKILL_ROOT = os.path.abspath(os.path.join(_TESTS_DIR, "..")) +_SCRIPTS_DIR = os.path.abspath(os.path.join(_SKILL_ROOT, "scripts")) + +for _p in (_SCRIPTS_DIR, _TESTS_DIR): + if _p not in sys.path: + sys.path.insert(0, _p) + +# Windows 下统一 stdout/stderr 为 UTF-8,避免 GBK 控制台中文/emoji 报错 +if sys.platform == "win32": + import io + + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") + sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace") + + +def _pattern_from_argv(argv: list[str]) -> str: + for a in argv[1:]: + if a.startswith("-"): + continue + s = a if a.startswith("test_") else f"test_{a}" + if not s.endswith(".py"): + s = f"{s}.py" + return s + return "test_*.py" + + +def _verbosity_from_argv(argv: list[str]) -> int: + return 2 if ("-v" in argv or "--verbose" in argv) else 1 + + +def main() -> int: + pattern = _pattern_from_argv(sys.argv) + verbosity = _verbosity_from_argv(sys.argv) + loader = unittest.TestLoader() + suite = loader.discover(_TESTS_DIR, pattern=pattern) + runner = unittest.TextTestRunner(verbosity=verbosity) + result = runner.run(suite) + return 0 if result.wasSuccessful() else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/samples/README.md b/tests/samples/README.md new file mode 100644 index 0000000..5880813 --- /dev/null +++ b/tests/samples/README.md @@ -0,0 +1,128 @@ +# samples(业务测试样例,默认不执行) + +本目录存放**可复制到根目录的业务测试范式**,面向「企业数字员工技能」中 **ERP、TMS、WMS、船司、报关、邮件、LLM、RPA** 等外联场景下的 **service / golden** 测法说明。 + +--- + +## 作用 + +- 这里的文件是 **`.py.sample`**:`python tests/run_tests.py` **不会**收集本目录(`run_tests.py` 只发现 `tests/` 根目录下的 `test_*.py`)。 +- 人类或 AI 需要启用范式时:**复制**到 `tests/test_<名称>.py`(去掉 `.sample`),再按业务改 import、断言与 fixture 路径。 +- **禁止**把真实联调、真实网络、真实 RPA 写进复制后的默认用例;此类内容应放在 `tests/integration/*.sample` 并配合环境变量开关。 + +--- + +## 样例文件 + +### 1. `test_service_contract.py.sample` + +**适合:** + +- 技能有清晰的 **service 层入口**,例如 `run(...)`、`handle(...)`、`execute(...)`。 +- 需要验证:**成功路径**、**参数/校验错误**、**adapter 异常被转换为业务可读错误**。 +- 需要用 **`FakeAdapter`**(见 `tests/adapter_test_utils.py`)或自建 **stub** 替代 ERP、TMS、WMS、船司、报关、LLM、RPA 等,**不**发真实 HTTP、**不**开真实浏览器。 + +### 2. `test_sync_contract.py.sample` + +**适合:** 外源 → 本地库的同步 / refresh / collect 能力。 + +至少应覆盖:首次插入、第二次幂等、更新、失效数据处理、不可信空结果保护 vs 完整空快照失效、遗漏 `snapshot_complete` 立即失败、部分写入后故障注入与回滚(`fail_after_writes`)、统计字段 `inserted/updated/unchanged/removed`、同步路径不调用 CSV/Excel 导出器。`snapshot_complete` 为调用方**必须显式传入**的 keyword-only 参数(无默认值),禁止默认把结果当成完整快照,也禁止靠条数猜测完整性。使用通用表名(如 `business_key`),不要写具体平台业务名。 + +### 3. `test_action_core_contract.py.sample` + +**适合:** 校验「单业务内核、多入口」——task/CLI/兼容命令调用同一 domain service;不按 toolbar/cron/agent 来源改变业务结果。 + +### 4. `test_golden_cases.py.sample` + +**适合:** + +- **解析类**:邮件/单证/PDF/Excel 等结构化输出。 +- **计算类**:费用、税费、分摊、账单字段。 +- **校验类**:合规、字段一致性、状态机规则。 + +推荐配合根目录下的 **`tests/fixtures/sample_request.json`** 与 **`tests/fixtures/expected_response.json`**(复制技能后按域替换字段,保持脱敏)。 + +### 5. `test_logging_contract.py.sample` + +**适合:** 长任务 / RPA 技能,需断言统一文件日志含 `task_start`、`task_failed|task_success`、`error_code=` 等事件词(见 [`development/LOGGING.md`](../../development/LOGGING.md) §3)。复制后用 `IsolatedDataRoot` + mock 域 RPA,**不要**依赖真实企微/浏览器。 + +--- + +## 如何启用 service contract 样例 + +1. **复制文件** + `tests/samples/test_service_contract.py.sample` + → `tests/test_service_contract.py`(或 `tests/test_<domain>_service_contract.py`,须符合根目录 `test_*.py` 命名以便 `run_tests.py` 收集)。 + +2. **替换示例 import** + 把注释中的示意: + `# from service.your_service import run` + 改为真实模块,例如: + `from service.quote_service import run_quote` + (具体路径以技能代码为准。) + +3. **注入假外部依赖** + - **不要**在默认用例里直接 `requests` 访问 ERP/TMS/WMS 等。 + - **不要**打开真实网页或驱动真实 RPA。 + - **不要**调用真实 LLM;可 stub 返回固定 JSON。 + - 对 **timeout / unauthorized / invalid_response** 等分支,用 `FakeAdapter` 或自建 fake 模拟(与 `adapter_test_utils` 思路一致)。 + +4. **至少保留三类断言习惯** + - **成功路径**:返回 `success: true` 或与技能约定的成功结构。 + - **参数错误**:缺必填字段时返回**结构化** error(字段/码),而非未捕获裸异常。 + - **外部异常**:adapter 超时、鉴权失败、脏响应等被转换为**可读、可重试标识明确**的错误形态(按产品约定)。 + +5. **给 AI 的提示** + 复制后全文搜索 `your_service`、`TODO`、`伪代码` 等占位符并替换;不要留下指向真实客户的示例数据。 + +--- + +## 如何启用 golden case 样例 + +1. **复制文件** + `tests/samples/test_golden_cases.py.sample` + → `tests/test_golden_cases.py`(或 `tests/test_<domain>_golden.py`)。 + +2. **按业务修改 fixtures**(仍须脱敏) + - `tests/fixtures/sample_request.json`:请求或输入快照。 + - `tests/fixtures/expected_response.json`:期望摘要或关键字段。 + 可新增多组文件,如 `case_valid_001.json`,并在测试中显式读取路径。 + +3. **断言建议(避免脆弱用例)** + - 比较**关键字段**,避免对整段 JSON 做机械全文 diff。 + - **忽略**时间戳、随机 id、`trace_id` 等非确定字段。 + - 对列表:断言**长度**、**关键元素**、**排序规则**(若业务保证顺序)。 + - 对金额/数量:断言 Decimal 字符串、或允许文档约定的小范围误差。 + +4. **与 service 的关系** + golden 用例仍应通过 **service 层**(或纯函数)得到输出,再与 fixture 对比,而不是从 CLI 字符串 scrape。 + +--- + +## 不应该写在 samples 复制件里的内容 + +以下若出现,应移到 **`tests/integration/*.sample`** 或 **`tests/desktop/*.sample`**,并加环境变量授权说明: + +- 访问**真实** API 或内网服务。 +- 打开**真实** RPA 页面或生产门户。 +- 读取**真实**客户文件、生产数据库。 +- 在测试中写入**真实**数据根或共享盘。 +- 任何真实 **token / cookie / 密码** 字面量。 +- 耗时极长、强依赖宿主 UI 抖动、无法在 CI 稳定复现的用例(不要塞进默认 `tests/test_*.py`)。 + +--- + +## 推荐命名(复制到根目录后) + +| 用途 | 示例文件名 | +|------|------------| +| Service 契约 | `tests/test_service_contract.py` | +| 同步契约 | `tests/test_sync_contract.py` | +| 单内核多入口 | `tests/test_action_core_contract.py` | +| Golden / fixture 回归 | `tests/test_golden_cases.py` | +| 领域规则 | `tests/test_<domain>_rules.py` | +| 校验逻辑 | `tests/test_<domain>_validation.py` | + +命名需满足:`tests/` 根目录下 `test_*.py`,以便 `python tests/run_tests.py` 默认发现。 + +更多分层说明见 **`tests/README.md`**(「我该把测试写在哪里?」与「新技能最小测试清单」)。 diff --git a/tests/samples/test_action_core_contract.py.sample b/tests/samples/test_action_core_contract.py.sample new file mode 100644 index 0000000..5bcc8c0 --- /dev/null +++ b/tests/samples/test_action_core_contract.py.sample @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- +# 样例:复制到 ``tests/test_action_core_contract.py`` 后按实际模块改名运行。 +# 默认不会被 ``python tests/run_tests.py`` 收集(扩展名为 .sample)。 +""" +单业务内核、多入口契约范式(通用)。 + +覆盖: +- task / command adapter 调用唯一 domain service +- 兼容命令复用同一个核心函数 +- CLI handler 不复制业务逻辑 +- 不根据 toolbar / cron / agent 来源改变业务处理 + +复制后把示意函数换成真实 import;禁止按入口维护多套实现。 +""" +from __future__ import annotations + +import unittest +from typing import Any, Callable + + +class DomainService: + """唯一业务内核示意。""" + + def __init__(self) -> None: + self.calls: list[dict[str, Any]] = [] + + def sync_records(self, *, limit: int = 100) -> dict[str, Any]: + self.calls.append({"op": "sync_records", "limit": limit}) + return {"ok": True, "limit": limit, "inserted": 1} + + +def cmd_sync_records(domain: DomainService, *, limit: int = 100) -> dict[str, Any]: + """task_service 编排层:可加 job_context,但不按 source 分叉业务。""" + return domain.sync_records(limit=limit) + + +def cmd_export_compat_refresh(domain: DomainService, *, limit: int = 100) -> dict[str, Any]: + """兼容旧 export:刷新必须复用同一 sync 内核,禁止另写采集逻辑。""" + sync_result = domain.sync_records(limit=limit) + return {"export": "delegated-to-host-or-report", "sync": sync_result} + + +def cli_handle_sync(domain: DomainService, argv_limit: int) -> dict[str, Any]: + """CLI handler:只解析参数后转发,不内嵌业务。""" + return cmd_sync_records(domain, limit=argv_limit) + + +def run_from_source( + domain: DomainService, + *, + source: str, + limit: int = 10, +) -> dict[str, Any]: + """ + 宿主可能传入 source=toolbar|cron|agent|skill-detail。 + 正确实现:忽略 source,业务结果一致。 + """ + _ = source # 不得用于分支业务语义 + return cmd_sync_records(domain, limit=limit) + + +class TestActionCoreContractSample(unittest.TestCase): + def test_task_adapter_calls_single_domain_service(self) -> None: + domain = DomainService() + out = cmd_sync_records(domain, limit=5) + self.assertTrue(out["ok"]) + self.assertEqual(domain.calls, [{"op": "sync_records", "limit": 5}]) + + def test_compat_command_reuses_same_core(self) -> None: + domain = DomainService() + cmd_export_compat_refresh(domain, limit=3) + self.assertEqual(len(domain.calls), 1) + self.assertEqual(domain.calls[0]["op"], "sync_records") + + def test_cli_handler_does_not_duplicate_business_logic(self) -> None: + domain = DomainService() + # CLI 与 task 编排应落到同一函数对象(示意) + self.assertIs(cli_handle_sync.__wrapped__ if hasattr(cli_handle_sync, "__wrapped__") else None, None) + out = cli_handle_sync(domain, 7) + self.assertEqual(out["limit"], 7) + self.assertEqual(domain.calls[-1], {"op": "sync_records", "limit": 7}) + + # 强化:CLI 路径与 cmd 路径结果一致 + domain2 = DomainService() + a = cli_handle_sync(domain2, 2) + domain3 = DomainService() + b = cmd_sync_records(domain3, limit=2) + self.assertEqual(a, b) + + def test_business_result_independent_of_entry_source(self) -> None: + domain = DomainService() + results = [ + run_from_source(domain, source=src, limit=4) + for src in ("toolbar", "cron", "agent", "skill-detail") + ] + self.assertEqual(results[0], results[1]) + self.assertEqual(results[1], results[2]) + self.assertEqual(results[2], results[3]) + self.assertEqual(len(domain.calls), 4) + self.assertTrue(all(c["op"] == "sync_records" and c["limit"] == 4 for c in domain.calls)) + + def test_entrypoint_wiring_points_to_same_callable(self) -> None: + """示意:manifest 多 placements 应对应同一 Python 入口函数。""" + domain = DomainService() + handlers: dict[str, Callable[..., dict[str, Any]]] = { + "toolbar": lambda: cmd_sync_records(domain, limit=1), + "cron": lambda: cmd_sync_records(domain, limit=1), + "agent": lambda: cmd_sync_records(domain, limit=1), + } + outs = [handlers[k]() for k in handlers] + self.assertEqual(outs[0], outs[1]) + self.assertEqual(outs[1], outs[2]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/samples/test_golden_cases.py.sample b/tests/samples/test_golden_cases.py.sample new file mode 100644 index 0000000..6a35750 --- /dev/null +++ b/tests/samples/test_golden_cases.py.sample @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# 样例:复制为 ``tests/test_golden_cases.py`` 后运行;默认不收集。 +""" +Golden fixture 回归(示意)。 + +- 从 ``tests/fixtures/`` 读取脱敏 JSON。 +- 调用 service 层(复制后替换为真实 import)。 +- 断言关键字段,而非全文 diff(外部系统常带时间戳/非确定性字段)。 +""" +from __future__ import annotations + +import json +import os +import unittest + + +def _fixtures_dir() -> str: + return os.path.join(os.path.dirname(__file__), "..", "fixtures") + + +class TestGoldenCasesSample(unittest.TestCase): + def test_sample_request_matches_expected_shape(self) -> None: + req_path = os.path.join(_fixtures_dir(), "sample_request.json") + exp_path = os.path.join(_fixtures_dir(), "expected_response.json") + with open(req_path, encoding="utf-8") as f: + req = json.load(f) + with open(exp_path, encoding="utf-8") as f: + exp = json.load(f) + self.assertEqual(req["request_id"], "req_test_001") + self.assertTrue(req["parameters"]["options"]["dry_run"]) + # 示意:out = run(req); 然后 assert out["success"] == exp["success"] 等关键字段 + self.assertTrue(exp["success"]) + self.assertEqual(exp["data"]["status"], "ok") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/samples/test_logging_contract.py.sample b/tests/samples/test_logging_contract.py.sample new file mode 100644 index 0000000..f3d1bee --- /dev/null +++ b/tests/samples/test_logging_contract.py.sample @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +"""日志契约样例:复制为 tests/test_logging_contract.py 后按业务改入口。 + +验证主任务 mock/离线路径写入统一日志事件词(见 development/LOGGING.md §3)。 +默认套件不收集本文件(.sample)。 +""" +from __future__ import annotations + +import io +import logging +import unittest +from contextlib import redirect_stderr, redirect_stdout +from unittest.mock import patch + +# 复制到 tests/ 根目录后取消下一行注释,并按技能改 import +# from _support import IsolatedDataRoot + + +class TestLoggingContractSample(unittest.TestCase): + """示范:捕获 skill logger,断言关键事件词出现。""" + + def test_main_path_emits_task_start_and_terminal_event(self) -> None: + self.skipTest( + "复制本文件为 tests/test_logging_contract.py 后:" + "用 IsolatedDataRoot + patch 域 RPA,调用 cmd_run/cmd_add_one," + "对 logging.Handler 断言消息含 task_start 与 task_success|task_failed" + ) + # 示例骨架(复制后实现): + # with IsolatedDataRoot(): + # log = logging.getLogger("openclaw.skill.draft_closing_report_default") + # records: list[str] = [] + # class _H(logging.Handler): + # def emit(self, record): + # records.append(record.getMessage()) + # h = _H() + # log.addHandler(h) + # try: + # with patch(...), redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()): + # rc = cmd_add_one(...) # 或 cmd_run + # finally: + # log.removeHandler(h) + # blob = "\n".join(records) + # self.assertIn("task_start", blob) + # self.assertTrue("task_failed" in blob or "task_success" in blob) + # if "task_failed" in blob: + # self.assertIn("error_code=", blob) + + def test_failure_path_logs_error_code_before_print(self) -> None: + self.skipTest( + "复制后:注入业务错误(如 ELEMENT_NOT_FOUND),断言 logger 含 " + "task_failed error_code=...,且 stdout 含 ERROR:CODE" + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/samples/test_service_contract.py.sample b/tests/samples/test_service_contract.py.sample new file mode 100644 index 0000000..258b8cf --- /dev/null +++ b/tests/samples/test_service_contract.py.sample @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# 本文件为样例:复制到 ``tests/test_service_contract.py``(或业务仓 tests 根)后按实际 service 改名运行。 +# 默认 **不会** 被 ``python tests/run_tests.py`` 收集(扩展名为 .sample)。 +""" +Service 层契约测试范式(示意)。 + +复制为真实技能后: +1. 将 ``from service.your_service import run`` 替换为实际模块与入口函数。 +2. 用 ``FakeAdapter`` / 内存 stub 替代真实 ERP/TMS/WMS 等外呼。 +3. 需要真实外网或真实 RPA 时,勿在本文件直接写死凭证;改用 ``tests/integration/*.sample`` + 环境开关。 +""" +from __future__ import annotations + +import unittest + +from adapter_test_utils import FakeAdapter + + +# from service.your_service import run # noqa: ERA001 — 复制后取消注释并实现 + + +class TestServiceContractSample(unittest.TestCase): + """三类常见契约:成功路径、结构化错误、上游超时转可重试错误。""" + + def test_success_path_returns_success_true(self) -> None: + # 示意:run(payload, adapter=FakeAdapter("success")) -> {"success": True, ...} + ad = FakeAdapter("success") + out = ad.call({"op": "ping"}) + self.assertTrue(out.get("success")) + + def test_missing_required_field_returns_structured_error_not_bare_exception(self) -> None: + # 示意:业务层应对必填字段做校验,返回 {"success": False, "error": {"code": "...", "message": "..."}} + err = {"success": False, "error": {"code": "VALIDATION", "message": "missing shipment_id"}} + self.assertFalse(err["success"]) + self.assertIn("code", err["error"]) + + def test_adapter_timeout_maps_to_retriable_error(self) -> None: + # 示意:捕获 TimeoutError 后映射为业务可重试错误码,而不是让裸异常穿透 CLI + ad = FakeAdapter("timeout") + with self.assertRaises(TimeoutError): + ad.call({}) + # 真实实现中:except TimeoutError: return {"success": False, "error": {"code": "UPSTREAM_TIMEOUT", "retriable": True}} + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/samples/test_sync_contract.py.sample b/tests/samples/test_sync_contract.py.sample new file mode 100644 index 0000000..806f164 --- /dev/null +++ b/tests/samples/test_sync_contract.py.sample @@ -0,0 +1,274 @@ +# -*- coding: utf-8 -*- +# 样例:复制到 ``tests/test_sync_contract.py`` 后按实际 domain service 改名运行。 +# 默认不会被 ``python tests/run_tests.py`` 收集(扩展名为 .sample)。 +""" +外源 → 本地库同步契约范式(通用,无具体平台业务名)。 + +覆盖: +- 首次同步插入 +- 第二次同步幂等 +- 更新已有数据 +- 处理失效数据 +- 不可信空结果保护 vs 完整空快照失效(``snapshot_complete`` 必填,无默认值) +- 遗漏 ``snapshot_complete`` 立即失败(TypeError) +- 事务在部分写入后失败并回滚 +- 返回 inserted/updated/unchanged/removed +- sync Action 不调用 CSV/Excel 文件导出器 + +复制后把内存仓库与 stub 外源换成真实 repository / reader;禁止访问真实网络或用户数据目录。 +""" +from __future__ import annotations + +import unittest +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class SyncStats: + total: int = 0 + inserted: int = 0 + updated: int = 0 + unchanged: int = 0 + removed: int = 0 + + def as_dict(self) -> dict[str, int]: + return { + "total": self.total, + "inserted": self.inserted, + "updated": self.updated, + "unchanged": self.unchanged, + "removed": self.removed, + } + + +@dataclass +class MemoryRecordStore: + """内存库:演示幂等 upsert + 全量失效(is_active 标记)。""" + + rows: dict[str, dict[str, Any]] = field(default_factory=dict) + in_transaction: bool = False + _tx_snapshot: dict[str, dict[str, Any]] | None = None + + def begin(self) -> None: + self._tx_snapshot = {k: dict(v) for k, v in self.rows.items()} + self.in_transaction = True + + def commit(self) -> None: + self._tx_snapshot = None + self.in_transaction = False + + def rollback(self) -> None: + if self._tx_snapshot is not None: + self.rows = {k: dict(v) for k, v in self._tx_snapshot.items()} + self._tx_snapshot = None + self.in_transaction = False + + def upsert_snapshot( + self, + records: list[dict[str, Any]], + *, + fail_after_writes: int | None = None, + ) -> SyncStats: + """全量快照:按 business_key 对齐;缺失标 is_active=0。 + + ``fail_after_writes``:在成功完成该次数的 insert/update 之后抛错,用于验证回滚。 + """ + self.begin() + try: + stats = SyncStats(total=len(records)) + seen: set[str] = set() + write_count = 0 + for rec in records: + key = str(rec["business_key"]) + seen.add(key) + existing = self.rows.get(key) + payload = { + "business_key": key, + "title": rec["title"], + "is_active": 1, + } + if existing is None: + self.rows[key] = payload + stats.inserted += 1 + write_count += 1 + elif existing.get("title") == payload["title"] and existing.get("is_active") == 1: + stats.unchanged += 1 + else: + self.rows[key] = payload + stats.updated += 1 + write_count += 1 + if fail_after_writes is not None and write_count >= fail_after_writes: + raise RuntimeError( + f"simulated write failure after {write_count} insert/update write(s)" + ) + for key, existing in list(self.rows.items()): + if key not in seen and existing.get("is_active") == 1: + existing["is_active"] = 0 + stats.removed += 1 + self.commit() + return stats + except Exception: + self.rollback() + raise + + +class ExportSpy: + """若 sync 路径误调导出器,测试应失败。""" + + def __init__(self) -> None: + self.calls: list[str] = [] + + def write_csv(self, *_a: Any, **_k: Any) -> None: + self.calls.append("csv") + + def write_excel(self, *_a: Any, **_k: Any) -> None: + self.calls.append("excel") + + +def sync_records( + store: MemoryRecordStore, + external_rows: list[dict[str, Any]], + *, + snapshot_complete: bool, + exporter: ExportSpy | None = None, + fail_after_writes: int | None = None, +) -> dict[str, int]: + """领域同步内核示意:只写库,不导出文件。 + + ``snapshot_complete`` 为 keyword-only **必填**参数(无默认值): + - False:读取失败/分页不完整/不可信结果 → 不得清库或失效旧数据 + - True:外源完整快照(可为空)→ 允许按全量策略 upsert / 标记失效 + 调用方必须显式传入,禁止默认当作完整快照,也禁止靠条数猜测完整性。 + """ + if external_rows is None: + raise ValueError("external_rows required") + if not snapshot_complete: + active = sum(1 for r in store.rows.values() if r.get("is_active") == 1) + return SyncStats(total=0, unchanged=active).as_dict() + stats = store.upsert_snapshot(external_rows, fail_after_writes=fail_after_writes) + if exporter is not None: + # 正确实现不得调用;本示意刻意不调用,供测试断言 + pass + return stats.as_dict() + + +class TestSyncContractSample(unittest.TestCase): + def test_first_sync_inserts(self) -> None: + store = MemoryRecordStore() + stats = sync_records( + store, + [{"business_key": "a", "title": "A"}, {"business_key": "b", "title": "B"}], + snapshot_complete=True, + ) + self.assertEqual(stats["inserted"], 2) + self.assertEqual(stats["total"], 2) + self.assertEqual(len(store.rows), 2) + + def test_second_sync_is_idempotent(self) -> None: + store = MemoryRecordStore() + rows = [{"business_key": "a", "title": "A"}] + sync_records(store, rows, snapshot_complete=True) + stats = sync_records(store, rows, snapshot_complete=True) + self.assertEqual(stats["unchanged"], 1) + self.assertEqual(stats["inserted"], 0) + self.assertEqual(stats["updated"], 0) + + def test_updates_existing_row(self) -> None: + store = MemoryRecordStore() + sync_records(store, [{"business_key": "a", "title": "A"}], snapshot_complete=True) + stats = sync_records( + store, [{"business_key": "a", "title": "A2"}], snapshot_complete=True + ) + self.assertEqual(stats["updated"], 1) + self.assertEqual(store.rows["a"]["title"], "A2") + + def test_marks_removed_inactive_on_full_sync(self) -> None: + store = MemoryRecordStore() + sync_records( + store, + [{"business_key": "a", "title": "A"}, {"business_key": "b", "title": "B"}], + snapshot_complete=True, + ) + stats = sync_records( + store, [{"business_key": "a", "title": "A"}], snapshot_complete=True + ) + self.assertEqual(stats["removed"], 1) + self.assertEqual(store.rows["b"]["is_active"], 0) + self.assertEqual(store.rows["a"]["is_active"], 1) + + def test_incomplete_empty_snapshot_does_not_wipe_existing(self) -> None: + store = MemoryRecordStore() + sync_records(store, [{"business_key": "a", "title": "A"}], snapshot_complete=True) + stats = sync_records(store, [], snapshot_complete=False) + self.assertEqual(store.rows["a"]["is_active"], 1) + self.assertEqual(stats["removed"], 0) + self.assertEqual(stats["unchanged"], 1) + + def test_complete_empty_snapshot_marks_existing_inactive(self) -> None: + store = MemoryRecordStore() + sync_records( + store, + [{"business_key": "a", "title": "A"}, {"business_key": "b", "title": "B"}], + snapshot_complete=True, + ) + stats = sync_records(store, [], snapshot_complete=True) + self.assertEqual(stats["removed"], 2) + self.assertEqual(store.rows["a"]["is_active"], 0) + self.assertEqual(store.rows["b"]["is_active"], 0) + + def test_omitting_snapshot_complete_raises_type_error(self) -> None: + store = MemoryRecordStore() + with self.assertRaises(TypeError): + sync_records(store, [{"business_key": "a", "title": "A"}]) # type: ignore[call-arg] + + def test_transaction_failure_after_partial_writes_rolls_back(self) -> None: + store = MemoryRecordStore() + sync_records(store, [{"business_key": "a", "title": "A"}], snapshot_complete=True) + with self.assertRaises(RuntimeError) as ctx: + sync_records( + store, + [ + {"business_key": "a", "title": "A2"}, + {"business_key": "b", "title": "B"}, + ], + snapshot_complete=True, + fail_after_writes=1, + ) + self.assertIn("after 1", str(ctx.exception)) + self.assertEqual(store.rows["a"]["title"], "A") + self.assertNotIn("b", store.rows) + self.assertFalse(store.in_transaction) + self.assertIsNone(store._tx_snapshot) + + def test_stats_keys_cover_inserted_updated_unchanged_removed(self) -> None: + store = MemoryRecordStore() + sync_records( + store, + [{"business_key": "a", "title": "A"}, {"business_key": "b", "title": "B"}], + snapshot_complete=True, + ) + stats = sync_records( + store, + [{"business_key": "a", "title": "A2"}], + snapshot_complete=True, + ) + for key in ("total", "inserted", "updated", "unchanged", "removed"): + self.assertIn(key, stats) + self.assertEqual(stats["updated"], 1) + self.assertEqual(stats["removed"], 1) + + def test_sync_does_not_call_file_exporters(self) -> None: + store = MemoryRecordStore() + spy = ExportSpy() + sync_records( + store, + [{"business_key": "a", "title": "A"}], + snapshot_complete=True, + exporter=spy, + ) + self.assertEqual(spy.calls, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/samples/test_table_deprecation_migration.py.sample b/tests/samples/test_table_deprecation_migration.py.sample new file mode 100644 index 0000000..9702626 --- /dev/null +++ b/tests/samples/test_table_deprecation_migration.py.sample @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +"""旧库废弃表迁移样例(复制到 tests/ 并按技能改写)。 + +验收:给定旧库含废弃物理表 + 元数据 + 历史行 → init_db() 后表与元数据均清除。 +见 references/SCHEMA.md「表增删改与退役」。 +""" +from __future__ import annotations + +import unittest + +from _support import IsolatedDataRoot + + +class TestTableDeprecationMigration(unittest.TestCase): + def test_init_db_drops_legacy_task_logs(self) -> None: + with IsolatedDataRoot(): + from db.connection import get_conn, init_db + from db.display_metadata import iter_user_tables + + # 1) 模拟升级前的旧库 + init_db() + conn = get_conn() + try: + cur = conn.cursor() + cur.execute( + """ + CREATE TABLE task_logs ( + id INTEGER PRIMARY KEY, + task_type TEXT, + status TEXT + ) + """ + ) + cur.execute( + """ + INSERT INTO _jiangchang_tables + (table_name, display_name, visible, readonly) + VALUES ('task_logs', '任务日志', 1, 0) + """ + ) + cur.execute("INSERT INTO task_logs (task_type, status) VALUES ('run', 'ok')") + conn.commit() + finally: + conn.close() + + # 2) 新代码 init_db() 应幂等清除(在 connection.init_db 中调用 drop_deprecated_user_table) + init_db() + + conn = get_conn() + try: + cur = conn.cursor() + cur.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='task_logs'" + ) + self.assertIsNone(cur.fetchone()) + cur.execute( + "SELECT COUNT(*) FROM _jiangchang_tables WHERE table_name='task_logs'" + ) + self.assertEqual(int(cur.fetchone()[0]), 0) + self.assertNotIn("task_logs", list(iter_user_tables(cur))) + finally: + conn.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_actions_manifest.py b/tests/test_actions_manifest.py new file mode 100644 index 0000000..52b1905 --- /dev/null +++ b/tests/test_actions_manifest.py @@ -0,0 +1,669 @@ +# -*- coding: utf-8 -*- +"""assets/actions.json 结构与 CLI 映射守护(通用模板,不含具体业务技能名称)。""" +from __future__ import annotations + +import json +import os +import re +import sqlite3 +import unittest + +from _support import IsolatedDataRoot, get_skill_root + +from cli.app import build_parser +from util.constants import SKILL_SLUG + +_TEMPLATE_PLACEHOLDER_SLUG = "your-skill-slug" +_ALLOWED_PLACEMENTS = frozenset({"toolbar", "row", "batch", "cron", "agent", "skill-detail"}) +_RESERVED_ACTION_FIELDS = frozenset({"concurrency", "locks"}) +_ALLOWED_EXECUTION_PROFILES = frozenset({"sync", "async"}) +_REQUIRED_MANIFEST_KEYS = frozenset({"schemaVersion", "skill", "actions"}) +_REQUIRED_ACTION_KEYS = frozenset( + {"id", "label", "description", "placements", "entrypoint", "executionProfile"} +) +_TABLE_NAME_PATTERN = re.compile(r"^[a-z][a-z0-9_]*$") +_FORBIDDEN_BUSINESS_TERMS = ( + "wechat", + "微信", + "contacts", + "wxid", + "account_wxid", + "contact.db", + "scrape-contacts", + "douyin", + "抖音", + "xiaohongshu", + "小红书", + "公众号", + "logistics", + "物流", +) + +_TEMPLATE_ARG_PATTERN = re.compile(r"\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}") + + +def _load_actions_manifest() -> dict: + path = os.path.join(get_skill_root(), "assets", "actions.json") + with open(path, encoding="utf-8") as f: + return json.load(f) + + +def _cli_subcommands() -> set[str]: + parser = build_parser() + subs = parser._subparsers._group_actions[0].choices # type: ignore[attr-defined] + return set(subs.keys()) + + +def _collect_template_vars(args: list) -> set[str]: + found: set[str] = set() + for item in args: + if not isinstance(item, str): + continue + for match in _TEMPLATE_ARG_PATTERN.finditer(item): + found.add(match.group(1)) + return found + + +def _load_actions_schema() -> dict: + path = os.path.join(get_skill_root(), "assets", "schemas", "skill-actions.schema.json") + with open(path, encoding="utf-8") as f: + return json.load(f) + + +def _validate_bind_tables(action_id: str, bind: object) -> list[str]: + errors: list[str] = [] + if not isinstance(bind, dict): + return [f"{action_id}: bind must be object"] + allowed = {"tables", "inputMapping", "columnMapping", "labelColumn"} + unknown = set(bind.keys()) - allowed + if unknown: + errors.append(f"{action_id}: bind only allows {sorted(allowed)}, got {sorted(bind.keys())}") + tables = bind.get("tables") + if not isinstance(tables, list) or len(tables) < 1: + errors.append(f"{action_id}: bind.tables must be non-empty array") + return errors + seen: set[str] = set() + for table in tables: + if not isinstance(table, str) or not table.strip(): + errors.append(f"{action_id}: bind.tables entries must be non-empty strings") + continue + if table != table.strip() or not _TABLE_NAME_PATTERN.fullmatch(table): + errors.append(f"{action_id}: bind.tables entry {table!r} must be snake_case") + if table in seen: + errors.append(f"{action_id}: bind.tables duplicate {table!r}") + seen.add(table) + label_column = bind.get("labelColumn") + if label_column is not None: + if not isinstance(label_column, str) or not label_column.strip(): + errors.append(f"{action_id}: bind.labelColumn must be non-empty string") + elif not _TABLE_NAME_PATTERN.fullmatch(label_column): + errors.append(f"{action_id}: bind.labelColumn {label_column!r} must be snake_case") + return errors + + +def _load_bindable_business_tables() -> set[str]: + """在隔离临时数据根下 init_db,返回可绑定到 toolbar 的业务表集合。 + + 表必须同时满足: + - SQLite 物理表存在(排除 sqlite_* 与 _jiangchang_* 元数据表) + - ``_jiangchang_tables`` 中有对应行且 ``visible=1`` + + 模板默认无业务表,允许返回空集。失败时抛出 RuntimeError(带诊断)。 + """ + from db.connection import init_db + from db.display_metadata import METADATA_TABLES + from util.runtime_paths import get_db_path + + physical: set[str] = set() + visible_meta: set[str] = set() + try: + with IsolatedDataRoot() as data_root: + init_db() + db_path = get_db_path() + if not os.path.isfile(db_path): + raise RuntimeError( + f"init_db did not create database file under isolated root " + f"{data_root!r}: expected {db_path}" + ) + conn = sqlite3.connect(db_path) + try: + cur = conn.cursor() + cur.execute( + "SELECT name FROM sqlite_master " + "WHERE type='table' AND name NOT LIKE 'sqlite_%'" + ) + physical = { + str(row[0]) + for row in cur.fetchall() + if str(row[0]) not in METADATA_TABLES + } + cur.execute( + "SELECT table_name FROM _jiangchang_tables WHERE visible = 1" + ) + visible_meta = {str(row[0]) for row in cur.fetchall()} + finally: + conn.close() + bindable = physical & visible_meta + return bindable + except RuntimeError: + raise + except Exception as exc: + raise RuntimeError( + "failed to load bindable business tables via isolated init_db: " + f"{type(exc).__name__}: {exc}" + ) from exc + + +class TestActionsManifest(unittest.TestCase): + def test_manifest_exists_and_schema(self) -> None: + manifest = _load_actions_manifest() + self.assertEqual(manifest.get("schemaVersion"), 1) + skill = manifest.get("skill") + self.assertIn(skill, (SKILL_SLUG, _TEMPLATE_PLACEHOLDER_SLUG)) + self.assertIsInstance(manifest.get("actions"), list) + self.assertGreater(len(manifest["actions"]), 0) + + def test_action_ids_unique_and_cli_valid(self) -> None: + manifest = _load_actions_manifest() + subs = _cli_subcommands() + seen: set[str] = set() + for action in manifest["actions"]: + action_id = action["id"] + self.assertNotIn(action_id, seen, msg=f"duplicate action id: {action_id}") + seen.add(action_id) + + self.assertTrue(str(action.get("label", "")).strip(), msg=f"{action_id} label empty") + self.assertTrue(str(action.get("description", "")).strip(), msg=f"{action_id} description empty") + + entry = action["entrypoint"] + self.assertEqual(entry["type"], "cli") + self.assertIn(entry["command"], subs, msg=f"{action_id} command not in CLI") + + args = entry.get("args") + self.assertIsInstance(args, list, msg=f"{action_id} args must be array") + for item in args: + self.assertIsInstance( + item, + (str, int, float, bool), + msg=f"{action_id} arg item must be scalar", + ) + self.assertNotIsInstance(item, dict, msg=f"{action_id} arg must not be object") + self.assertIsNotNone(item, msg=f"{action_id} arg must not be null") + + placements = action.get("placements") + self.assertIsInstance(placements, list, msg=f"{action_id} placements must be array") + replaces = action.get("replaces") + if replaces is not None: + self.assertIn( + replaces, + {"create", "update", "delete", "import"}, + msg=f"{action_id} invalid replaces {replaces!r}", + ) + bind = action.get("bind") or {} + tables = bind.get("tables") or [] + self.assertTrue( + isinstance(tables, list) and len(tables) > 0, + msg=f"{action_id} replaces requires non-empty bind.tables", + ) + else: + self.assertGreater(len(placements), 0, msg=f"{action_id} placements empty") + for placement in placements: + self.assertIn(placement, _ALLOWED_PLACEMENTS, msg=f"{action_id} invalid placement {placement!r}") + + def test_every_action_declares_execution_profile(self) -> None: + manifest = _load_actions_manifest() + for action in manifest["actions"]: + profile = action.get("executionProfile") + self.assertIn( + profile, + _ALLOWED_EXECUTION_PROFILES, + msg=f"{action['id']} executionProfile must be sync|async, got {profile!r}", + ) + + def test_schema_requires_execution_profile_and_defines_bind(self) -> None: + schema = _load_actions_schema() + action_def = schema["$defs"]["action"] + self.assertIn("executionProfile", action_def.get("required") or []) + profile = action_def["properties"]["executionProfile"] + self.assertEqual(set(profile.get("enum") or []), {"sync", "async"}) + self.assertIn("bind", schema["$defs"]) + self.assertIn("bind", action_def["properties"]) + bind_def = schema["$defs"]["bind"] + self.assertEqual(bind_def.get("required"), ["tables"]) + self.assertIs(bind_def.get("additionalProperties"), False) + + def test_schema_defines_interaction_fields(self) -> None: + schema = _load_actions_schema() + action_def = schema["$defs"]["action"] + props = action_def["properties"] + self.assertIn("interaction", props) + self.assertEqual( + set(props["interaction"].get("enum") or []), + {"auto", "direct", "confirm", "form"}, + ) + self.assertIn("interactionByPlacement", props) + by_placement = props["interactionByPlacement"] + self.assertIs(by_placement.get("additionalProperties"), False) + for key in ("toolbar", "row", "batch", "cron", "agent", "skill-detail", "create", "update", "delete", "import"): + self.assertIn(key, by_placement.get("properties") or {}) + self.assertEqual( + set((by_placement["properties"][key].get("enum") or [])), + {"direct", "confirm", "form"}, + ) + self.assertIn("replaces", props) + self.assertEqual(props["replaces"].get("$ref"), "#/$defs/mutationKind") + self.assertEqual( + set(schema["$defs"]["mutationKind"].get("enum") or []), + {"create", "update", "delete", "import"}, + ) + + def test_schema_defines_hover_hint(self) -> None: + schema = _load_actions_schema() + action_def = schema["$defs"]["action"] + props = action_def["properties"] + self.assertIn("hoverHint", props) + self.assertNotIn("hoverHint", action_def.get("required") or []) + self.assertEqual(props["hoverHint"].get("type"), "string") + + def test_schema_defines_visible_when_on_input_property(self) -> None: + schema = _load_actions_schema() + prop_def = schema["$defs"]["inputProperty"] + props = prop_def["properties"] + self.assertIn("visibleWhen", props) + self.assertNotIn("visibleWhen", prop_def.get("required") or []) + # Must live on input property, not action top-level (≠ Policy L3). + action_props = schema["$defs"]["action"]["properties"] + self.assertNotIn("visibleWhen", action_props) + + def test_default_manifest_omits_visible_when(self) -> None: + for action in _load_actions_manifest()["actions"]: + properties = ((action.get("inputSchema") or {}).get("properties")) or {} + for name, prop in properties.items(): + self.assertNotIn( + "visibleWhen", + prop if isinstance(prop, dict) else {}, + msg=f"{action['id']}.{name} default template should omit visibleWhen", + ) + + def test_visible_when_example_exists_and_uses_field_rules(self) -> None: + path = os.path.join( + get_skill_root(), "assets", "examples", "visible-when-action.example.json" + ) + self.assertTrue(os.path.isfile(path), msg="visible-when example must exist") + with open(path, encoding="utf-8") as f: + example = json.load(f) + props = example["actions"][0]["inputSchema"]["properties"] + self.assertIn("visibleWhen", props["keyword"]) + self.assertIn("visibleWhen", props["authorUrl"]) + self.assertNotIn("visibleWhen", props["collectMode"]) + + def test_default_manifest_omits_hover_hint(self) -> None: + for action in _load_actions_manifest()["actions"]: + self.assertNotIn( + "hoverHint", + action, + msg=f"{action['id']} default template action should omit hoverHint", + ) + + def test_schema_defines_host_effects_and_steps(self) -> None: + schema = _load_actions_schema() + action_def = schema["$defs"]["action"] + props = action_def["properties"] + self.assertIn("hostEffects", props) + self.assertEqual(props["hostEffects"].get("$ref"), "#/$defs/hostEffects") + self.assertIn("steps", props) + effects = schema["$defs"]["hostEffects"] + self.assertIs(effects.get("additionalProperties"), False) + for key in ("onSuccess", "onFailure"): + self.assertIn(key, effects.get("properties") or {}) + effect_ids = set(schema["$defs"]["hostEffectId"].get("enum") or []) + self.assertEqual(effect_ids, {"reloadTable", "refreshCatalog", "clearSelection"}) + step = schema["$defs"]["actionStep"] + self.assertEqual( + set(step["properties"]["when"].get("enum") or []), + {"before", "afterSuccess", "afterFailure"}, + ) + self.assertEqual( + set(step["properties"]["do"].get("enum") or []), + {"deleteByFk", "deleteRows", "reloadTable", "refreshCatalog", "clearSelection"}, + ) + self.assertEqual(props["steps"].get("maxItems"), 8) + + def test_manifest_validates_against_json_schema(self) -> None: + try: + import jsonschema + except ImportError: + self.skipTest("jsonschema not installed") + schema = _load_actions_schema() + manifest = _load_actions_manifest() + jsonschema.validate(instance=manifest, schema=schema) + + def test_bind_structure_when_present(self) -> None: + manifest = _load_actions_manifest() + errors: list[str] = [] + for action in manifest["actions"]: + if "bind" not in action: + continue + errors.extend(_validate_bind_tables(action["id"], action["bind"])) + self.assertEqual(errors, [], msg="\n".join(errors)) + + def test_toolbar_actions_require_bind_tables(self) -> None: + manifest = _load_actions_manifest() + errors: list[str] = [] + for action in manifest["actions"]: + placements = action.get("placements") or [] + if "toolbar" not in placements: + continue + bind = action.get("bind") + if bind is None: + errors.append(f"{action['id']}: toolbar placement requires bind.tables") + continue + errors.extend(_validate_bind_tables(action["id"], bind)) + self.assertEqual(errors, [], msg="\n".join(errors)) + + def test_bindable_business_tables_helper_loads_template_db(self) -> None: + """helper 本身必须可用:本技能有 4 张业务表,隔离库应可读且不含元数据表。""" + bindable = _load_bindable_business_tables() + self.assertEqual( + bindable, + {"closing_projects", "achievements", "expense_items", "report_drafts"}, + ) + self.assertNotIn("_jiangchang_tables", bindable) + self.assertNotIn("_jiangchang_columns", bindable) + + def test_bind_tables_exist_in_physical_and_visible_metadata(self) -> None: + bindable = _load_bindable_business_tables() + manifest = _load_actions_manifest() + missing: list[str] = [] + for action in manifest["actions"]: + bind = action.get("bind") + if not isinstance(bind, dict): + continue + for table in bind.get("tables") or []: + if table not in bindable: + missing.append( + f"{action['id']}: bind.tables entry {table!r} must exist as " + f"physical table AND _jiangchang_tables.visible=1 " + f"(bindable={sorted(bindable)})" + ) + self.assertEqual(missing, [], msg="\n".join(missing)) + + def test_schema_allows_all_placement_execution_profile_combinations(self) -> None: + """POLICY-SKILL-ACTION-004:6 placements × 2 profiles 必须全部通过 Schema。""" + try: + import jsonschema + except ImportError: + self.fail("jsonschema is required to validate placement×executionProfile orthogonality") + schema = _load_actions_schema() + placements = ("toolbar", "row", "batch", "cron", "agent", "skill-detail") + profiles = ("sync", "async") + for placement in placements: + for profile in profiles: + action: dict = { + "id": "probe-action", + "label": "正交探测", + "description": "验证 placements 与 executionProfile 无条件耦合", + "placements": [placement], + "executionProfile": profile, + "entrypoint": {"type": "cli", "command": "health", "args": []}, + } + if placement == "toolbar": + action["bind"] = {"tables": ["demo_items"]} + manifest = { + "schemaVersion": 1, + "skill": "your-skill-slug", + "actions": [action], + } + with self.subTest(placement=placement, executionProfile=profile): + jsonschema.validate(instance=manifest, schema=schema) + if placement != "toolbar": + self.assertNotIn("bind", action) + + def test_schema_rejects_toolbar_without_bind(self) -> None: + try: + import jsonschema + from jsonschema import ValidationError + except ImportError: + self.fail("jsonschema is required") + schema = _load_actions_schema() + manifest = { + "schemaVersion": 1, + "skill": "your-skill-slug", + "actions": [ + { + "id": "probe-toolbar", + "label": "缺 bind 探测", + "description": "toolbar 无 bind 应被 Schema 拒绝", + "placements": ["toolbar"], + "executionProfile": "sync", + "entrypoint": {"type": "cli", "command": "health", "args": []}, + } + ], + } + with self.assertRaises(ValidationError): + jsonschema.validate(instance=manifest, schema=schema) + + def test_schema_allows_bind_label_column(self) -> None: + try: + import jsonschema + except ImportError: + self.fail("jsonschema is required") + schema = _load_actions_schema() + bind_props = schema["$defs"]["bind"]["properties"] + self.assertIn("labelColumn", bind_props) + manifest = { + "schemaVersion": 1, + "skill": "your-skill-slug", + "actions": [ + { + "id": "probe-label-column", + "label": "处理勾选", + "description": "验证 bind.labelColumn 可通过 Schema", + "placements": ["toolbar", "batch"], + "executionProfile": "sync", + "interaction": "confirm", + "bind": { + "tables": ["demo_items"], + "inputMapping": {"ids": "$selection.ids"}, + "labelColumn": "display_name", + }, + "entrypoint": {"type": "cli", "command": "health", "args": []}, + } + ], + } + jsonschema.validate(instance=manifest, schema=schema) + + def test_no_execution_profile_placement_coupling_in_tests(self) -> None: + """守护:本文件不得引入入口位置与 sync/async 的错误硬耦合断言。""" + path = os.path.join(get_skill_root(), "tests", "test_actions_manifest.py") + with open(path, encoding="utf-8") as f: + text = f.read() + # 拆开拼接,避免本测试源码自身触发字符串扫描。 + forbidden_snippets = ( + "toolbar " + "只能 async", + "toolbar only " + "async", + 'toolbar"' + " and profile != " + '"async"', + 'toolbar")' + " and profile != " + '"async"', + "toolbar must be " + "async", + "executionProfile" + " == " + '"async"' + " and " + '"toolbar"', + ) + for snippet in forbidden_snippets: + self.assertNotIn(snippet, text) + + def test_template_variables_have_input_schema(self) -> None: + manifest = _load_actions_manifest() + for action in manifest["actions"]: + args = action.get("entrypoint", {}).get("args") or [] + template_vars = _collect_template_vars(args) + if not template_vars: + continue + schema = action.get("inputSchema") or {} + properties = schema.get("properties") or {} + for var in template_vars: + self.assertIn( + var, + properties, + msg=f"{action['id']} uses {{{{{var}}}}} but inputSchema.properties missing it", + ) + + def test_required_fields_exist_in_properties(self) -> None: + manifest = _load_actions_manifest() + for action in manifest["actions"]: + schema = action.get("inputSchema") or {} + required = schema.get("required") or [] + properties = schema.get("properties") or {} + for field in required: + self.assertIn( + field, + properties, + msg=f"{action['id']} required field {field!r} missing from properties", + ) + + def test_sensitive_params_not_on_toolbar(self) -> None: + manifest = _load_actions_manifest() + for action in manifest["actions"]: + props = (action.get("inputSchema") or {}).get("properties") or {} + has_sensitive = any(p.get("sensitive") for p in props.values()) + if has_sensitive and "toolbar" in (action.get("placements") or []): + self.fail(f"{action['id']} has sensitive input but is on toolbar") + + def test_confirmation_message_structure(self) -> None: + manifest = _load_actions_manifest() + for action in manifest["actions"]: + confirmation = action.get("confirmation") + if confirmation is None: + continue + self.assertIsInstance(confirmation, dict) + message = confirmation.get("message") + self.assertTrue(str(message or "").strip(), msg=f"{action['id']} confirmation.message empty") + + def test_manifest_has_no_specific_business_skill_names(self) -> None: + path = os.path.join(get_skill_root(), "assets", "actions.json") + with open(path, encoding="utf-8") as f: + raw = f.read().lower() + for term in _FORBIDDEN_BUSINESS_TERMS: + self.assertNotIn(term.lower(), raw, msg=f"actions.json must not contain {term!r}") + + def test_actions_md_exists_and_links_manifest(self) -> None: + path = os.path.join(get_skill_root(), "references", "ACTIONS.md") + self.assertTrue(os.path.isfile(path)) + with open(path, encoding="utf-8") as f: + text = f.read() + self.assertIn("actions.json", text) + self.assertIn("schemaVersion", text) + for term in _FORBIDDEN_BUSINESS_TERMS: + self.assertNotIn(term.lower(), text.lower(), msg=f"ACTIONS.md must not contain {term!r}") + + def test_skill_actions_schema_file_exists(self) -> None: + path = os.path.join(get_skill_root(), "assets", "schemas", "skill-actions.schema.json") + self.assertTrue(os.path.isfile(path)) + with open(path, encoding="utf-8") as f: + schema = json.load(f) + self.assertEqual(schema.get("title"), "SkillActionManifest") + self.assertIn("actions", schema.get("properties", {})) + + def test_skill_actions_schema_json_parses(self) -> None: + schema = _load_actions_schema() + self.assertEqual(schema["properties"]["schemaVersion"]["const"], 1) + self.assertTrue(schema.get("additionalProperties") is False) + self.assertIn("action", schema.get("$defs", {})) + + def test_template_manifest_matches_schema_required_keys(self) -> None: + manifest = _load_actions_manifest() + schema = _load_actions_schema() + for key in _REQUIRED_MANIFEST_KEYS: + self.assertIn(key, manifest) + self.assertIn(key, schema.get("properties", {})) + for action in manifest["actions"]: + for key in _REQUIRED_ACTION_KEYS: + self.assertIn(key, action) + + def test_template_example_does_not_use_reserved_action_fields(self) -> None: + manifest = _load_actions_manifest() + for action in manifest["actions"]: + overlap = set(action.keys()) & _RESERVED_ACTION_FIELDS + self.assertFalse( + overlap, + msg=f"{action['id']} uses reserved action fields not supported in template Phase 1: {overlap}", + ) + + def test_default_manifest_does_not_place_health_on_toolbar(self) -> None: + """脚手架默认健康类 Action 不应出现在数据管理 toolbar。""" + manifest = _load_actions_manifest() + for action in manifest["actions"]: + if action["id"] in {"health", "version", "config-path"}: + self.assertNotIn( + "toolbar", + action.get("placements") or [], + msg=f"{action['id']} must not be auto-placed on toolbar", + ) + + def test_actions_md_documents_strict_vs_host_boundary(self) -> None: + path = os.path.join(get_skill_root(), "references", "ACTIONS.md") + with open(path, encoding="utf-8") as f: + text = f.read() + for marker in ( + "严格规范", + "向后兼容", + "Phase 1", + "bind.tables", + "concurrency", + "locks", + "row", + "batch", + "additionalProperties", + "placements", + "executionProfile", + "interaction", + "confirmation", + "数据管理技能按钮顺序", + "数组相对顺序", + "全部直接展示", + "左右分栏", + "行级入口", + "行右键", + "六职责正交", + "POLICY.md", + "hostEffects", + "steps", + "hoverHint", + "visibleWhen", + "字段条件显隐", + "reloadTable", + "afterSuccess", + "afterFailure", + "cellPresentation", + "CELL_PRESENTATION.md", + "labelColumn", + "已选择", + "revealInForm", + "映射只读", + ): + self.assertIn(marker, text, msg=f"ACTIONS.md missing {marker!r}") + self.assertNotIn( + "其余进「更多操作」", + text, + msg="ACTIONS.md must not document removed overflow more-menu truncation", + ) + self.assertIn( + "新增 `row-context-menu` 等 placement", + text, + msg="ACTIONS.md must forbid inventing a separate row-context-menu placement", + ) + placement_enum = _load_actions_schema().get("$defs", {}).get("placement", {}).get("enum") or [] + self.assertNotIn( + "row-context-menu", + placement_enum, + msg="skill-actions.schema must not add row-context-menu to placements enum", + ) + for forbidden in ( + "数据管理 toolbar " + "只挂", + "toolbar " + "只放 async", + "toolbar)→ " + "只挂", + "只挂 **" + "async**", + ): + self.assertNotIn(forbidden, text, msg=f"ACTIONS.md must not contain deprecated coupling: {forbidden!r}") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_adapter_profile_policy.py b/tests/test_adapter_profile_policy.py new file mode 100644 index 0000000..a7bc799 --- /dev/null +++ b/tests/test_adapter_profile_policy.py @@ -0,0 +1,129 @@ +# -*- coding: utf-8 -*- +"""adapter profile / 环境开关策略:默认不触碰真实 API 与真实 RPA。""" +from __future__ import annotations + +import os +import unittest + +from jiangchang_skill_core import config + +from adapter_test_utils import ( + ALLOWED_TEST_TARGETS, + FakeAdapter, + assert_no_real_network_env, + get_test_target, + should_skip_profile, +) + + +def _save_env(keys: tuple[str, ...]) -> dict[str, str | None]: + return {k: os.environ.get(k) for k in keys} + + +def _restore_env(saved: dict[str, str | None]) -> None: + for k, v in saved.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + config.reset_cache() + + +_ENV_KEYS = ("OPENCLAW_TEST_TARGET",) + + +class TestAdapterProfilePolicy(unittest.TestCase): + def tearDown(self) -> None: + config.reset_cache() + + def test_get_test_target_defaults_to_unit(self) -> None: + saved = _save_env(_ENV_KEYS) + try: + for k in _ENV_KEYS: + os.environ.pop(k, None) + config.reset_cache() + self.assertEqual(get_test_target(), "unit") + finally: + _restore_env(saved) + + def test_get_test_target_invalid_raises(self) -> None: + saved = _save_env(_ENV_KEYS) + try: + for k in _ENV_KEYS: + os.environ.pop(k, None) + os.environ["OPENCLAW_TEST_TARGET"] = "not_a_valid_target" + config.reset_cache() + with self.assertRaises(ValueError): + get_test_target() + finally: + _restore_env(saved) + + def test_allowed_targets_contains_expected(self) -> None: + self.assertTrue({"unit", "mock", "simulator_api", "real_api"}.issubset(ALLOWED_TEST_TARGETS)) + + def test_simulator_api_runs_when_target_matches(self) -> None: + saved = _save_env(_ENV_KEYS) + try: + os.environ["OPENCLAW_TEST_TARGET"] = "simulator_api" + config.reset_cache() + skip, _ = should_skip_profile("simulator_api") + self.assertFalse(skip) + finally: + _restore_env(saved) + + def test_unit_target_skips_simulator_and_real_profiles(self) -> None: + saved = _save_env(_ENV_KEYS) + try: + for k in _ENV_KEYS: + os.environ.pop(k, None) + config.reset_cache() + self.assertEqual(get_test_target(), "unit") + for prof in ("simulator_api", "simulator_rpa", "real_api", "real_rpa"): + skip, reason = should_skip_profile(prof) + self.assertTrue(skip, msg=f"{prof}: {reason}") + skip_mock, _ = should_skip_profile("mock") + self.assertFalse(skip_mock) + finally: + _restore_env(saved) + + def test_real_api_runs_when_target_matches(self) -> None: + saved = _save_env(_ENV_KEYS) + try: + os.environ["OPENCLAW_TEST_TARGET"] = "real_api" + config.reset_cache() + skip, _ = should_skip_profile("real_api", write=True) + self.assertFalse(skip) + finally: + _restore_env(saved) + + def test_real_rpa_runs_when_target_matches(self) -> None: + saved = _save_env(_ENV_KEYS) + try: + os.environ["OPENCLAW_TEST_TARGET"] = "real_rpa" + config.reset_cache() + skip, _ = should_skip_profile("real_rpa", write=True) + self.assertFalse(skip) + finally: + _restore_env(saved) + + def test_fake_adapter_modes(self) -> None: + self.assertEqual(FakeAdapter("success").call({"a": 1})["success"], True) + with self.assertRaises(TimeoutError): + FakeAdapter("timeout").call({}) + bad = FakeAdapter("invalid_response").call({}) + self.assertNotIsInstance(bad, dict) + with self.assertRaises(PermissionError): + FakeAdapter("unauthorized").call({}) + + def test_assert_no_real_network_env(self) -> None: + saved = _save_env(_ENV_KEYS) + try: + os.environ.pop("OPENCLAW_TEST_TARGET", None) + config.reset_cache() + assert_no_real_network_env() + finally: + _restore_env(saved) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cell_presentation_contract.py b/tests/test_cell_presentation_contract.py new file mode 100644 index 0000000..e600d81 --- /dev/null +++ b/tests/test_cell_presentation_contract.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- +"""Cell Presentation 契约:schema + 文档 + 示例。""" +from __future__ import annotations + +import json +import os +import unittest + +from util.runtime_paths import get_skill_root + + +def _load_schema() -> dict: + path = os.path.join( + get_skill_root(), + "assets", + "schemas", + "skill-cell-presentation.schema.json", + ) + with open(path, encoding="utf-8") as f: + return json.load(f) + + +def _load_example() -> dict: + path = os.path.join( + get_skill_root(), + "assets", + "examples", + "skill-cell-presentation.example.json", + ) + with open(path, encoding="utf-8") as f: + return json.load(f) + + +class TestCellPresentationContract(unittest.TestCase): + def test_schema_file_exists_and_parses(self) -> None: + schema = _load_schema() + self.assertEqual(schema.get("title"), "SkillCellPresentationDocument") + self.assertIs(schema.get("additionalProperties"), False) + self.assertEqual(schema["properties"]["schemaVersion"].get("const"), 1) + tones = set(schema["$defs"]["tone"].get("enum") or []) + self.assertEqual(tones, {"danger", "warning", "info", "success", "muted"}) + + def test_example_validates_against_schema(self) -> None: + try: + import jsonschema + except ImportError: + self.skipTest("jsonschema not installed") + jsonschema.validate(instance=_load_example(), schema=_load_schema()) + + def test_actions_schema_defines_cell_presentation_enable_block(self) -> None: + path = os.path.join( + get_skill_root(), "assets", "schemas", "skill-actions.schema.json" + ) + with open(path, encoding="utf-8") as f: + actions_schema = json.load(f) + self.assertIn("cellPresentation", actions_schema["properties"]) + self.assertIn("cellPresentationDeclaration", actions_schema["$defs"]) + + def test_docs_and_example_exist(self) -> None: + root = get_skill_root() + self.assertTrue(os.path.isfile(os.path.join(root, "references", "CELL_PRESENTATION.md"))) + self.assertTrue( + os.path.isfile( + os.path.join(root, "assets", "schemas", "skill-cell-presentation.schema.json") + ) + ) + self.assertTrue( + os.path.isfile( + os.path.join(root, "assets", "examples", "skill-cell-presentation.example.json") + ) + ) + + def test_cell_presentation_md_covers_contract(self) -> None: + path = os.path.join(get_skill_root(), "references", "CELL_PRESENTATION.md") + with open(path, encoding="utf-8") as f: + text = f.read() + for marker in ( + "tone", + "danger", + "warning", + "alert", + "_jiangchang_cell_presentation", + "cellPresentation", + "派生", + "任意 CSS", + "用途不限", + "schemaVersion", + ): + self.assertIn(marker, text, msg=f"CELL_PRESENTATION.md missing {marker!r}") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cli_smoke.py b/tests/test_cli_smoke.py new file mode 100644 index 0000000..b4ff8d0 --- /dev/null +++ b/tests/test_cli_smoke.py @@ -0,0 +1,113 @@ +# -*- coding: utf-8 -*- +"""通用 CLI 冒烟:用法、health、version、init-db(不触网、不深测 run 占位)。""" +from __future__ import annotations + +import io +import json +import os +import unittest +from contextlib import redirect_stderr, redirect_stdout + +from _support import IsolatedDataRoot, platform_kit_version_patch + +from jiangchang_skill_core import config + +# scripts/ 已由 _support 注入 sys.path +from cli.app import main +from util.constants import SKILL_SLUG, SKILL_VERSION + + +class TestCliSmoke(unittest.TestCase): + def test_main_empty_argv_shows_usage_and_nonzero(self) -> None: + buf = io.StringIO() + err = io.StringIO() + with redirect_stdout(buf), redirect_stderr(err): + rc = main([]) + self.assertEqual(rc, 1) + out = buf.getvalue() + self.assertIn("结题报告初稿助手", out) + self.assertIn("health", out) + self.assertIn("init-db", out) + + def test_health_zero(self) -> None: + old_record = os.environ.get("OPENCLAW_RECORD_VIDEO") + os.environ["OPENCLAW_RECORD_VIDEO"] = "0" + try: + buf = io.StringIO() + with platform_kit_version_patch(), redirect_stdout(buf), redirect_stderr(io.StringIO()): + rc = main(["health"]) + self.assertEqual(rc, 0) + out = buf.getvalue() + self.assertIn("health:", out) + self.assertIn("python_executable:", out) + self.assertIn("platform_kit_version:", out) + self.assertIn("jiangchang_skill_core_file:", out) + finally: + if old_record is None: + os.environ.pop("OPENCLAW_RECORD_VIDEO", None) + else: + os.environ["OPENCLAW_RECORD_VIDEO"] = old_record + + def test_version_json_and_matches_constants_slug(self) -> None: + buf = io.StringIO() + with redirect_stdout(buf), redirect_stderr(io.StringIO()): + rc = main(["version"]) + self.assertEqual(rc, 0) + payload = json.loads(buf.getvalue().strip()) + self.assertIn("version", payload) + self.assertIn("skill", payload) + self.assertEqual(payload["skill"], SKILL_SLUG) + self.assertEqual(payload["version"], SKILL_VERSION) + + def test_init_db_creates_database_and_is_idempotent(self) -> None: + with IsolatedDataRoot(user_id="_cli_init_db"): + config.reset_cache() + buf = io.StringIO() + with redirect_stdout(buf), redirect_stderr(io.StringIO()): + rc = main(["init-db"]) + self.assertEqual(rc, 0) + payload = json.loads(buf.getvalue().strip()) + self.assertTrue(payload["ok"]) + self.assertEqual(payload["skill"], SKILL_SLUG) + self.assertTrue(os.path.isfile(payload["db_path"])) + + buf2 = io.StringIO() + with redirect_stdout(buf2), redirect_stderr(io.StringIO()): + rc2 = main(["init-db"]) + self.assertEqual(rc2, 0) + payload2 = json.loads(buf2.getvalue().strip()) + self.assertTrue(payload2["ok"]) + self.assertEqual(payload2["db_path"], payload["db_path"]) + + def test_unknown_logs_subcommand_is_rejected(self) -> None: + buf = io.StringIO() + err = io.StringIO() + with redirect_stdout(buf), redirect_stderr(err): + with self.assertRaises(SystemExit) as caught: + main(["logs"]) + self.assertNotEqual(caught.exception.code, 0) + + def test_run_placeholder_returns_nonzero_without_network(self) -> None: + """占位 run:RpaVideoSession 示范 + 模板提示;OPENCLAW_RECORD_VIDEO=0 时不启 ffmpeg。""" + old_auth = os.environ.pop("JIANGCHANG_AUTH_BASE_URL", None) + old_record = os.environ.get("OPENCLAW_RECORD_VIDEO") + os.environ["OPENCLAW_RECORD_VIDEO"] = "0" + try: + with IsolatedDataRoot(user_id="_cli_run"): + config.reset_cache() + buf = io.StringIO() + with redirect_stdout(buf), redirect_stderr(io.StringIO()): + rc = main(["run"]) + self.assertNotEqual(rc, 0) + self.assertIn("模板", buf.getvalue()) + finally: + if old_auth is not None: + os.environ["JIANGCHANG_AUTH_BASE_URL"] = old_auth + if old_record is None: + os.environ.pop("OPENCLAW_RECORD_VIDEO", None) + else: + os.environ["OPENCLAW_RECORD_VIDEO"] = old_record + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_config_bootstrap.py b/tests/test_config_bootstrap.py new file mode 100644 index 0000000..abf7139 --- /dev/null +++ b/tests/test_config_bootstrap.py @@ -0,0 +1,122 @@ +# -*- coding: utf-8 -*- +"""配置落盘、合并与读取优先级。""" +from __future__ import annotations + +import io +import json +import os +import tempfile +import unittest +from contextlib import redirect_stderr, redirect_stdout + +from _support import IsolatedDataRoot, get_skill_root, platform_kit_version_patch + +from jiangchang_skill_core import config +from cli.app import main +from util.config_bootstrap import bootstrap_skill_config +from util.constants import SKILL_SLUG + +_TEST_TARGET_KEY = "OPENCLAW_" + "TEST_TARGET" + + +def _env_line(key: str, value: str) -> str: + return f"{key}={value}\n" + + +class TestConfigBootstrap(unittest.TestCase): + def test_first_run_creates_user_env(self) -> None: + with IsolatedDataRoot(user_id="_cfg_test"): + config.reset_cache() + path = bootstrap_skill_config() + self.assertTrue(os.path.isfile(path)) + self.assertTrue(path.replace("\\", "/").endswith(f"/{SKILL_SLUG}/.env")) + + def test_existing_env_not_overwritten(self) -> None: + with IsolatedDataRoot(user_id="_cfg_keep"): + config.reset_cache() + path = bootstrap_skill_config() + with open(path, "w", encoding="utf-8") as f: + f.write(_env_line(_TEST_TARGET_KEY, "simulator_rpa")) + with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False, suffix=".example") as tmp: + tmp.write(_env_line(_TEST_TARGET_KEY, "mock") + "HUMAN_WAIT_TIMEOUT=60\n") + tmp_path = tmp.name + try: + config.merge_missing_env_keys(tmp_path, path, comment_skill="test") + with open(path, encoding="utf-8") as f: + content = f.read() + self.assertIn(_env_line(_TEST_TARGET_KEY, "simulator_rpa").strip(), content) + self.assertNotIn(_env_line(_TEST_TARGET_KEY, "mock").strip(), content) + finally: + os.unlink(tmp_path) + config.reset_cache() + + def test_merge_appends_missing_keys(self) -> None: + with IsolatedDataRoot(user_id="_cfg_merge"): + config.reset_cache() + path = bootstrap_skill_config() + with open(path, "w", encoding="utf-8") as f: + f.write(_env_line(_TEST_TARGET_KEY, "simulator_rpa")) + example = os.path.join(get_skill_root(), ".env.example") + added = config.merge_missing_env_keys(example, path, comment_skill="test") + self.assertIn("HUMAN_WAIT_TIMEOUT", added) + with open(path, encoding="utf-8") as f: + content = f.read() + self.assertIn("HUMAN_WAIT_TIMEOUT=180", content) + + def test_config_priority_process_user_example(self) -> None: + with IsolatedDataRoot(user_id="_cfg_prio"): + config.reset_cache() + example = os.path.join(get_skill_root(), ".env.example") + path = config.ensure_env_file(SKILL_SLUG, example) + with open(path, "w", encoding="utf-8") as f: + f.write("PRIORITY_TEST_KEY=from_user\n") + config.reset_cache() + + os.environ["PRIORITY_TEST_KEY"] = "from_process" + self.assertEqual(config.get("PRIORITY_TEST_KEY"), "from_process") + + del os.environ["PRIORITY_TEST_KEY"] + config.reset_cache() + self.assertEqual(config.get("PRIORITY_TEST_KEY"), "from_user") + + with open(path, "w", encoding="utf-8") as f: + f.write("# empty user\n") + config.reset_cache() + with open(example, encoding="utf-8") as f: + example_text = f.read() + self.assertIn(_env_line(_TEST_TARGET_KEY, "real_" + "rpa").strip(), example_text) + self.assertEqual(config.get(_TEST_TARGET_KEY), "real_" + "rpa") + + def test_config_path_outputs_json(self) -> None: + with IsolatedDataRoot(user_id="_cfg_path"): + config.reset_cache() + bootstrap_skill_config() + buf = io.StringIO() + with redirect_stdout(buf), redirect_stderr(io.StringIO()): + rc = main(["config-path"]) + self.assertEqual(rc, 0) + payload = json.loads(buf.getvalue().strip()) + self.assertEqual(payload["skill"], SKILL_SLUG) + self.assertTrue(payload["env_path"]) + self.assertTrue(payload["example_path"].endswith(".env.example")) + + def test_health_does_not_print_sensitive_plaintext(self) -> None: + with IsolatedDataRoot(user_id="_cfg_health"): + os.environ["OPENCLAW_RECORD_VIDEO"] = "0" + config.reset_cache() + path = bootstrap_skill_config() + with open(path, "a", encoding="utf-8") as f: + f.write("FAKE_SECRET_TOKEN=supersecret12345\n") + config.reset_cache() + buf = io.StringIO() + with platform_kit_version_patch(), redirect_stdout(buf), redirect_stderr(io.StringIO()): + rc = main(["health"]) + self.assertEqual(rc, 0) + out = buf.getvalue() + self.assertNotIn("supersecret12345", out) + self.assertIn("env_path:", out) + self.assertIn("env_exists: True", out) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_data_management_contract.py b/tests/test_data_management_contract.py new file mode 100644 index 0000000..07d135f --- /dev/null +++ b/tests/test_data_management_contract.py @@ -0,0 +1,479 @@ +# -*- coding: utf-8 -*- +"""通用数据管理契约测试(demo_items fixture,不含具体业务技能内容)。""" +from __future__ import annotations + +import json +import sqlite3 +import unittest + +from _support import IsolatedDataRoot + +DEMO_ITEMS_DDL = """ +CREATE TABLE IF NOT EXISTS demo_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + description TEXT, + status TEXT NOT NULL DEFAULT 'draft', + generated_value TEXT, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) +); +CREATE TRIGGER IF NOT EXISTS demo_items_set_updated_at +AFTER UPDATE ON demo_items +FOR EACH ROW +BEGIN + UPDATE demo_items + SET updated_at = unixepoch() + WHERE id = NEW.id AND updated_at = OLD.updated_at; +END; +""" + +DEMO_ITEMS_TABLE_METADATA = { + "table_name": "demo_items", + "display_name": "示例记录", + "description": "通用数据管理契约演示表", + "visible": 1, + "readonly": 0, +} + +DEMO_ITEMS_COLUMN_METADATA: tuple[dict[str, object], ...] = ( + {"column_name": "id", "display_name": "编号", "visible": 1, "searchable": 1, "editable": 0}, + {"column_name": "title", "display_name": "标题", "visible": 1, "searchable": 1, "editable": 1}, + {"column_name": "description", "display_name": "描述", "visible": 1, "searchable": 1, "editable": 1}, + { + "column_name": "status", + "display_name": "状态", + "visible": 1, + "searchable": 1, + "editable": 1, + "options_json": json.dumps( + [ + {"value": "draft", "label": "草稿"}, + {"value": "published", "label": "已发布"}, + {"value": "archived", "label": "已归档"}, + ], + ensure_ascii=False, + ), + }, + {"column_name": "generated_value", "display_name": "生成值", "visible": 1, "searchable": 0, "editable": 0}, + { + "column_name": "created_at", + "display_name": "创建时间", + "visible": 1, + "searchable": 0, + "editable": 0, + "display_type": "datetime_unix_seconds", + }, + { + "column_name": "updated_at", + "display_name": "更新时间", + "visible": 1, + "searchable": 0, + "editable": 0, + "display_type": "datetime_unix_seconds", + }, +) + + +def _init_demo_items_fixture(conn: sqlite3.Connection, *, prune: bool = True) -> None: + from db.display_metadata import ( + init_display_metadata_tables, + list_physical_column_names, + seed_column_metadata_batch, + upsert_table_metadata, + ) + + cur = conn.cursor() + init_display_metadata_tables(cur) + cur.executescript(DEMO_ITEMS_DDL) + meta = DEMO_ITEMS_TABLE_METADATA + upsert_table_metadata( + cur, + table_name=str(meta["table_name"]), + display_name=str(meta["display_name"]), + description=str(meta["description"]), + visible=int(meta["visible"]), + readonly=int(meta["readonly"]), + ) + physical = list_physical_column_names(cur, "demo_items") + seed_column_metadata_batch( + cur, + table_name="demo_items", + columns=DEMO_ITEMS_COLUMN_METADATA, + valid_column_names=physical if prune else None, + ) + conn.commit() + + +class TestDataManagementContract(unittest.TestCase): + def test_demo_items_metadata_permissions(self) -> None: + conn = sqlite3.connect(":memory:") + try: + _init_demo_items_fixture(conn) + from db.display_metadata import ( + get_column_editable, + get_table_readonly, + list_editable_business_columns, + table_allows_manual_create, + ) + + cur = conn.cursor() + self.assertEqual(get_table_readonly(cur, "demo_items"), 0) + self.assertEqual(get_column_editable(cur, "demo_items", "id"), 0) + self.assertEqual(get_column_editable(cur, "demo_items", "title"), 1) + self.assertEqual(get_column_editable(cur, "demo_items", "generated_value"), 0) + self.assertEqual(get_column_editable(cur, "demo_items", "created_at"), 0) + self.assertEqual(get_column_editable(cur, "demo_items", "updated_at"), 0) + + editable_cols = list_editable_business_columns(cur, "demo_items") + self.assertEqual(editable_cols, ["title", "description", "status"]) + self.assertTrue(table_allows_manual_create(cur, "demo_items")) + finally: + conn.close() + + def test_demo_items_init_idempotent_no_duplicates(self) -> None: + conn = sqlite3.connect(":memory:") + try: + _init_demo_items_fixture(conn) + _init_demo_items_fixture(conn) + cur = conn.cursor() + cur.execute("SELECT COUNT(*) FROM _jiangchang_tables WHERE table_name = 'demo_items'") + self.assertEqual(int(cur.fetchone()[0]), 1) + cur.execute("SELECT COUNT(*) FROM _jiangchang_columns WHERE table_name = 'demo_items'") + self.assertEqual(int(cur.fetchone()[0]), 7) + finally: + conn.close() + + def test_reinit_updates_stale_editable_value(self) -> None: + conn = sqlite3.connect(":memory:") + try: + _init_demo_items_fixture(conn) + conn.execute( + "UPDATE _jiangchang_columns SET editable = 0 WHERE table_name = 'demo_items' AND column_name = 'title'" + ) + conn.commit() + _init_demo_items_fixture(conn) + from db.display_metadata import get_column_editable + + self.assertEqual(get_column_editable(conn.cursor(), "demo_items", "title"), 1) + finally: + conn.close() + + def test_missing_table_metadata_detected(self) -> None: + conn = sqlite3.connect(":memory:") + try: + _init_demo_items_fixture(conn) + conn.execute("DELETE FROM _jiangchang_tables WHERE table_name = 'demo_items'") + conn.commit() + from db.display_metadata_validator import validate_display_metadata + + report = validate_display_metadata(conn) + self.assertFalse(report.ok) + self.assertTrue(any("demo_items" in err for err in report.errors)) + finally: + conn.close() + + def test_missing_column_metadata_detected(self) -> None: + conn = sqlite3.connect(":memory:") + try: + _init_demo_items_fixture(conn) + conn.execute( + "DELETE FROM _jiangchang_columns WHERE table_name = 'demo_items' AND column_name = 'title'" + ) + conn.commit() + from db.display_metadata_validator import validate_display_metadata + + report = validate_display_metadata(conn) + self.assertFalse(report.ok) + self.assertTrue(any("demo_items.title" in err for err in report.errors)) + finally: + conn.close() + + def test_metadata_reference_to_missing_column_detected(self) -> None: + conn = sqlite3.connect(":memory:") + try: + _init_demo_items_fixture(conn) + conn.execute( + """ + INSERT INTO _jiangchang_columns + (table_name, column_name, display_name, visible, searchable, editable) + VALUES ('demo_items', 'not_exists', '不存在', 1, 1, 0) + """ + ) + conn.commit() + from db.display_metadata_validator import validate_display_metadata + + report = validate_display_metadata(conn) + self.assertFalse(report.ok) + self.assertTrue(any("not_exists" in err for err in report.errors)) + finally: + conn.close() + + def test_empty_display_name_detected_for_visible_column(self) -> None: + conn = sqlite3.connect(":memory:") + try: + _init_demo_items_fixture(conn) + conn.execute( + "UPDATE _jiangchang_columns SET display_name = '' WHERE table_name = 'demo_items' AND column_name = 'title'" + ) + conn.commit() + from db.display_metadata_validator import validate_display_metadata + + report = validate_display_metadata(conn) + self.assertFalse(report.ok) + self.assertTrue(any("display_name 为空" in err for err in report.errors)) + finally: + conn.close() + + def test_visible_columns_use_chinese_display_name(self) -> None: + conn = sqlite3.connect(":memory:") + try: + _init_demo_items_fixture(conn) + cur = conn.cursor() + cur.execute( + """ + SELECT column_name, display_name + FROM _jiangchang_columns + WHERE table_name = 'demo_items' AND visible = 1 + """ + ) + for column_name, display_name in cur.fetchall(): + self.assertTrue( + any("\u4e00" <= ch <= "\u9fff" for ch in str(display_name)), + msg=f"{column_name} display_name should be Chinese: {display_name!r}", + ) + finally: + conn.close() + + def test_timestamp_fields_type_default_and_editable(self) -> None: + conn = sqlite3.connect(":memory:") + try: + _init_demo_items_fixture(conn) + cur = conn.cursor() + for column_name in ("created_at", "updated_at"): + cur.execute(f"PRAGMA table_info('demo_items')") + info = {row[1]: row for row in cur.fetchall()} + row = info[column_name] + self.assertIn("INT", str(row[2]).upper()) + self.assertIn("unixepoch", str(row[4]).lower()) + cur.execute( + """ + SELECT editable, display_type FROM _jiangchang_columns + WHERE table_name = 'demo_items' AND column_name = ? + """, + (column_name,), + ) + editable, display_type = cur.fetchone() + self.assertEqual(int(editable), 0) + self.assertEqual(display_type, "datetime_unix_seconds") + finally: + conn.close() + + def test_physical_order_matches_pragma_cid(self) -> None: + conn = sqlite3.connect(":memory:") + try: + _init_demo_items_fixture(conn) + from db.display_metadata import list_physical_column_names + + cur = conn.cursor() + expected = [str(item["column_name"]) for item in DEMO_ITEMS_COLUMN_METADATA] + physical = list_physical_column_names(cur, "demo_items") + self.assertEqual(physical, expected) + cur.execute("PRAGMA table_info('demo_items')") + cid_order = [row[1] for row in sorted(cur.fetchall(), key=lambda row: int(row[0]))] + self.assertEqual(cid_order, expected) + finally: + conn.close() + + def test_options_json_parses_for_enum_field(self) -> None: + conn = sqlite3.connect(":memory:") + try: + _init_demo_items_fixture(conn) + cur = conn.cursor() + cur.execute( + """ + SELECT options_json FROM _jiangchang_columns + WHERE table_name = 'demo_items' AND column_name = 'status' + """ + ) + raw = cur.fetchone()[0] + from db.display_metadata_validator import validate_options_json + + errors, _ = validate_options_json(raw) + self.assertEqual(errors, []) + parsed = json.loads(raw) + self.assertEqual(len(parsed), 3) + self.assertEqual(parsed[0]["value"], "draft") + finally: + conn.close() + + def test_system_table_no_editable_fields_not_manual_create(self) -> None: + conn = sqlite3.connect(":memory:") + try: + from db.display_metadata import ( + init_display_metadata_tables, + seed_column_metadata_batch, + table_allows_manual_create, + upsert_table_metadata, + ) + + cur = conn.cursor() + init_display_metadata_tables(cur) + cur.execute( + """ + CREATE TABLE source_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + item_id TEXT NOT NULL, + synced_at INTEGER NOT NULL DEFAULT (unixepoch()) + ) + """ + ) + upsert_table_metadata( + cur, + table_name="source_records", + display_name="来源记录", + description="只由同步流程写入", + visible=1, + readonly=0, + ) + seed_column_metadata_batch( + cur, + table_name="source_records", + columns=( + {"column_name": "id", "display_name": "编号", "editable": 0}, + {"column_name": "item_id", "display_name": "记录编号", "editable": 0}, + {"column_name": "synced_at", "display_name": "同步时间", "editable": 0, "display_type": "datetime_unix_seconds"}, + ), + ) + conn.commit() + self.assertFalse(table_allows_manual_create(cur, "source_records")) + finally: + conn.close() + + def test_table_readonly_blocks_manual_create_even_with_editable_fields(self) -> None: + conn = sqlite3.connect(":memory:") + try: + _init_demo_items_fixture(conn) + conn.execute("UPDATE _jiangchang_tables SET readonly = 1 WHERE table_name = 'demo_items'") + conn.commit() + from db.display_metadata import table_allows_manual_create + + self.assertFalse(table_allows_manual_create(conn.cursor(), "demo_items")) + finally: + conn.close() + + def test_prune_stale_column_metadata(self) -> None: + conn = sqlite3.connect(":memory:") + try: + _init_demo_items_fixture(conn) + conn.execute( + """ + INSERT INTO _jiangchang_columns + (table_name, column_name, display_name, visible, searchable, editable) + VALUES ('demo_items', 'removed_field', '已删除', 0, 0, 0) + """ + ) + conn.commit() + from db.display_metadata import list_physical_column_names, seed_column_metadata_batch + + cur = conn.cursor() + physical = list_physical_column_names(cur, "demo_items") + seed_column_metadata_batch( + cur, + table_name="demo_items", + columns=DEMO_ITEMS_COLUMN_METADATA, + valid_column_names=physical, + ) + conn.commit() + cur.execute( + "SELECT 1 FROM _jiangchang_columns WHERE table_name = 'demo_items' AND column_name = 'removed_field'" + ) + self.assertIsNone(cur.fetchone()) + finally: + conn.close() + + def test_seed_batch_without_valid_column_names_does_not_prune(self) -> None: + conn = sqlite3.connect(":memory:") + try: + _init_demo_items_fixture(conn, prune=False) + conn.execute( + """ + INSERT INTO _jiangchang_columns + (table_name, column_name, display_name, visible, searchable, editable) + VALUES ('demo_items', 'removed_field', '已删除', 0, 0, 0) + """ + ) + conn.commit() + from db.display_metadata import seed_column_metadata_batch + + seed_column_metadata_batch( + conn.cursor(), + table_name="demo_items", + columns=DEMO_ITEMS_COLUMN_METADATA, + ) + conn.commit() + cur = conn.cursor() + cur.execute( + "SELECT 1 FROM _jiangchang_columns WHERE table_name = 'demo_items' AND column_name = 'removed_field'" + ) + self.assertIsNotNone(cur.fetchone()) + finally: + conn.close() + + def test_seed_batch_accepts_list_columns(self) -> None: + conn = sqlite3.connect(":memory:") + try: + _init_demo_items_fixture(conn, prune=False) + from db.display_metadata import get_column_editable, seed_column_metadata_batch + + columns = list(DEMO_ITEMS_COLUMN_METADATA) + columns[1] = {**columns[1], "display_name": "标题(列表)"} + seed_column_metadata_batch( + conn.cursor(), + table_name="demo_items", + columns=columns, + valid_column_names=[str(item["column_name"]) for item in DEMO_ITEMS_COLUMN_METADATA], + ) + conn.commit() + cur = conn.cursor() + cur.execute( + "SELECT display_name FROM _jiangchang_columns WHERE table_name = 'demo_items' AND column_name = 'title'" + ) + self.assertEqual(cur.fetchone()[0], "标题(列表)") + self.assertEqual(get_column_editable(cur, "demo_items", "title"), 1) + finally: + conn.close() + + def test_prune_does_not_remove_existing_column_metadata(self) -> None: + conn = sqlite3.connect(":memory:") + try: + _init_demo_items_fixture(conn) + from db.display_metadata import get_column_editable, seed_column_metadata_batch + + cur = conn.cursor() + before = get_column_editable(cur, "demo_items", "title") + seed_column_metadata_batch( + cur, + table_name="demo_items", + columns=DEMO_ITEMS_COLUMN_METADATA, + valid_column_names=[str(item["column_name"]) for item in DEMO_ITEMS_COLUMN_METADATA], + ) + conn.commit() + self.assertEqual(get_column_editable(cur, "demo_items", "title"), before) + finally: + conn.close() + + def test_demo_items_validation_passes(self) -> None: + conn = sqlite3.connect(":memory:") + try: + _init_demo_items_fixture(conn) + from db.display_metadata_validator import validate_display_metadata + + report = validate_display_metadata(conn) + self.assertTrue(report.ok, msg="; ".join(report.errors)) + finally: + conn.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_db_smoke.py b/tests/test_db_smoke.py new file mode 100644 index 0000000..260118c --- /dev/null +++ b/tests/test_db_smoke.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +"""init_db:幂等创建元数据表与本技能业务表。""" +from __future__ import annotations + +import unittest + +from _support import IsolatedDataRoot + +_BUSINESS_TABLES = {"closing_projects", "achievements", "expense_items", "report_drafts"} + + +class TestDbSmoke(unittest.TestCase): + def test_init_db_idempotent_metadata_only(self) -> None: + with IsolatedDataRoot(): + from db.connection import get_conn, init_db + from db.display_metadata import METADATA_TABLES, iter_user_tables + + init_db() + init_db() + + conn = get_conn() + try: + cur = conn.cursor() + cur.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'" + ) + tables = {str(row[0]) for row in cur.fetchall()} + self.assertIn("_jiangchang_tables", tables) + self.assertIn("_jiangchang_columns", tables) + self.assertNotIn("task_logs", tables) + self.assertEqual( + sorted(iter_user_tables(cur)), + sorted(_BUSINESS_TABLES), + ) + leftover = tables - METADATA_TABLES - _BUSINESS_TABLES + self.assertEqual(leftover, set()) + finally: + conn.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_development_policy_guard.py b/tests/test_development_policy_guard.py new file mode 100644 index 0000000..1e0d4d1 --- /dev/null +++ b/tests/test_development_policy_guard.py @@ -0,0 +1,1598 @@ +# -*- coding: utf-8 -*- +"""开发规范 hard policy 守护(索引:development/POLICY_MATRIX.md)。""" +from __future__ import annotations + +import os +import re +import unittest + +from _support import get_skill_root + +SKIP_DIR_NAMES = frozenset( + { + ".git", + "__pycache__", + ".pytest_cache", + "rpa-artifacts", + "videos", + } +) + +SKIP_REL_PREFIXES = ("tests/artifacts", "tests/diagnostics") + +POLICY_IDS = ( + "POLICY-STRUCTURE-001", + "POLICY-RUNTIME-001", + "POLICY-RUNTIME-002", + "POLICY-RUNTIME-003", + "POLICY-INSTALL-001", + "POLICY-CONFIG-001", + "POLICY-CONFIG-002", + "POLICY-CONFIG-003", + "POLICY-CONFIG-004", + "POLICY-TESTING-001", + "POLICY-TESTING-002", + "POLICY-RPA-001", + "POLICY-RPA-002", + "POLICY-RPA-003", + "POLICY-RPA-004", + "POLICY-RPA-005", + "POLICY-RPA-006", + "POLICY-PACKAGING-001", + "POLICY-PACKAGING-002", + "POLICY-RELEASE-001", + "POLICY-DOCS-001", + "POLICY-LOGGING-001", + "POLICY-LOGGING-002", + "POLICY-LOGGING-003", + "POLICY-LOGGING-004", + "POLICY-LOGGING-005", + "POLICY-LOGGING-006", + "POLICY-LOGGING-007", + "POLICY-LOGGING-008", + "POLICY-CONTROL-001", + "POLICY-CONTROL-002", + "POLICY-CONTROL-003", + "POLICY-DATA-PATH-001", + "POLICY-SKILL-ACTION-001", + "POLICY-SKILL-ACTION-002", + "POLICY-SKILL-ACTION-003", + "POLICY-SKILL-ACTION-004", + "POLICY-TUTORIAL-001", + "POLICY-ARCH-CORE-001", + "POLICY-DATA-SYNC-001", +) + +STRUCTURE_PATHS = ( + "scripts/main.py", + "scripts/cli", + "scripts/service", + "scripts/db", + "scripts/util", +) + +CONFIG_ENV_BOUNDARY_FILES = frozenset( + { + "scripts/main.py", + "scripts/util/config_bootstrap.py", + "scripts/util/runtime_paths.py", + "scripts/util/logging_config.py", + "scripts/util/constants.py", + } +) + +INSTALL_FORBIDDEN_PATTERNS = ( + re.compile(r"\bpip\s+install\b", re.IGNORECASE), + re.compile(r"\bpython\s+-m\s+pip\s+install\b", re.IGNORECASE), + re.compile(r"\buv\s+pip\s+install\b", re.IGNORECASE), + re.compile(r"\bplaywright\s+install\b", re.IGNORECASE), +) + +CONFIG_ENV_FORBIDDEN_PATTERNS = ( + re.compile(r"\bos\.environ\b"), + re.compile(r"\bos\.getenv\b"), + re.compile(r"\bos\.putenv\b"), + re.compile(r"\benviron\.get\b"), +) + +CONFIG_004_SOURCE = "development/CONFIG.md §.env.example 注释 = 配置管理用户文案" +CONFIG_004_ENV_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +CONFIG_004_CJK_RE = re.compile(r"[\u4e00-\u9fff]") +# 注释中禁止出现的开发向指向 / 黑话(大小写不敏感子串) +CONFIG_004_FORBIDDEN_COMMENT_SUBSTRINGS = ( + "development/", + "adapter.md", + "rpa.md", + "data_paths.md", + "config.md", + "account-manager", + "见 development", + "adapter 档位", + "adapter档位", +) + +TESTING_REAL_TARGET_ALLOWED_ROOT_TESTS = frozenset({"test_adapter_profile_policy.py"}) + +TESTING_001_FORBIDDEN_SUBSTRINGS = ( + "real_" + "api", + "real_" + "rpa", + "OPENCLAW_" + "TEST_TARGET", +) + +TESTING_001_SKIP_ROOT_TESTS = frozenset({"test_development_policy_guard.py"}) + +RPA_001_FORBIDDEN_PATTERNS = ( + re.compile(r"\brpa_helpers\b"), + re.compile(r"\binject_account_manager_scripts_path\b"), + re.compile(r"\bget_account_credential\b"), +) + +RPA_002_FFMPEG_PATTERNS = ( + re.compile( + r"\bsubprocess\.(run|call|Popen|check_output|check_call)\s*\([^)]*ffmpeg", + re.IGNORECASE | re.DOTALL, + ), + re.compile( + r"\bsubprocess\.(run|call|Popen|check_output|check_call)\(\s*['\"][^'\"]*ffmpeg", + re.IGNORECASE, + ), + re.compile(r"\bos\.system\s*\([^)]*ffmpeg", re.IGNORECASE | re.DOTALL), + re.compile(r"\bos\.popen\s*\([^)]*ffmpeg", re.IGNORECASE | re.DOTALL), + re.compile( + r"\bsubprocess\.(run|call|Popen|check_output|check_call)\([^)]*shell\s*=\s*True[^)]*ffmpeg", + re.IGNORECASE | re.DOTALL, + ), +) + +RPA_002_EXEMPT_REL = "scripts/service/task_run_support.py" + +RPA_003_SOURCE = ( + "development/RPA.md §5.3; development/TESTING.md §12; development/POLICY_MATRIX.md" +) + +RPA_004_SOURCE = ( + "development/RPA.md §0.2; development/ADAPTER.md §兄弟依赖; development/POLICY_MATRIX.md" +) +RPA_004_HEADLESS_KEY_RE = re.compile( + r"(?m)^\s*OPENCLAW_BROWSER_HEADLESS\s*=\s*([^\s#]+)" +) +RPA_004_LAUNCH_MARKERS = ( + "launch_persistent_context", + "launch_persistent_browser", +) +RPA_004_PICK_MARKER = "pick_web_account" + +RPA_005_SOURCE = ( + "development/AI_RPA.md §4–§6; development/RPA.md §0.0/§0.3; " + "development/POLICY_MATRIX.md" +) +RPA_005_REQUIRED_DOC_MARKERS = ( + "AI Agent 是用户入口与业务编排者", + "RPA 是确定性执行器", + "破解", + "逆向", + "私有协议", + "验证码", + "人工处理", + "1~3 秒", + "状态等待", +) +RPA_005_STEP_DELAY_KEY_RE = re.compile( + r"(?m)^\s*(STEP_DELAY_MIN|STEP_DELAY_MAX)\s*=\s*([^\s#]+)" +) + +RPA_006_SOURCE = ( + "development/ACCOUNT_MANAGER.md; development/ADAPTER.md §兄弟依赖; " + "development/RPA.md §0.2; development/POLICY_MATRIX.md" +) +RPA_006_REQUIRED_DOC_MARKERS = ( + "一账号一", + "profile_dir", + "lease_token", + "数据管理页面", + "account-manager.db", + "account list", + "lease list", + "关闭浏览器并释放租约", + "两个以上账号", +) +RPA_006_EXAMPLE_CLIENTS = ( + "examples/real_browser_rpa/scripts/service/account_client.py", + "examples/simulator_browser_rpa/scripts/service/account_client.py", +) + +LOGGING_001_SOURCE = "development/LOGGING.md; development/RUNTIME.md" +LOGGING_002_SOURCE = "development/LOGGING.md; development/DEVELOPMENT.md" +LOGGING_003_SOURCE = "development/LOGGING.md" +LOGGING_004_SOURCE = "development/LOGGING.md; development/RPA.md" +LOGGING_005_SOURCE = "development/LOGGING.md; development/CONFIG.md" +LOGGING_006_SOURCE = "development/LOGGING.md §0、§3" +LOGGING_007_SOURCE = "development/LOGGING.md §3.1–§3.2" +LOGGING_008_SOURCE = "development/LOGGING.md §0 铁律 3" + +LOGGING_006_EXEMPT_BASENAMES = frozenset( + { + "__init__.py", + "task_run_support.py", + "entitlement_service.py", + "sibling_bridge.py", + } +) + +LOGGING_RPA_MARKERS = ( + "RpaVideoSession", + "uiautomation", + "playwright", + "@rpa_step", + "rpa_step(", +) + +LOGGING_CALL_PATTERN = re.compile( + r"\b(log|logger|get_skill_logger\s*\(\s*\))\s*\.\s*(info|warning|error|debug|exception|critical)\s*\(", + re.IGNORECASE, +) +LOGGING_CALL_GET_LOGGER_PATTERN = re.compile( + r"get_skill_logger\s*\(\s*\)\s*\.\s*(info|warning|error|debug|exception|critical)\s*\(", + re.IGNORECASE, +) +SENSITIVE_LOG_ASSIGNMENT_PATTERN = re.compile( + r"(password|token|cookie|secret|api_key|authorization)\s*=", + re.IGNORECASE, +) + + +def _policy_msg(policy_id: str, source: str, detail: str) -> str: + return f"{policy_id} [{source}]: {detail}" + + +def _strip_env_quotes(value: str) -> str: + trimmed = value.strip() + if ( + (trimmed.startswith('"') and trimmed.endswith('"')) + or (trimmed.startswith("'") and trimmed.endswith("'")) + ): + return trimmed[1:-1] + return trimmed + + +def _iter_env_example_active_entries(path: str): + """Yield (lineno, key, value, header_comments, inline_comment) for active KEY= lines.""" + pending: list[str] = [] + with open(path, encoding="utf-8") as handle: + for lineno, line in enumerate(handle, 1): + stripped = line.strip() + if not stripped: + pending = [] + continue + if stripped.startswith("#"): + pending.append(stripped.lstrip("#").strip()) + continue + eq = stripped.find("=") + if eq <= 0: + pending = [] + continue + key = stripped[:eq].strip() + if not CONFIG_004_ENV_KEY_RE.match(key): + pending = [] + continue + rest = stripped[eq + 1 :] + inline: str | None = None + if not rest.startswith('"') and not rest.startswith("'"): + hash_at = rest.find(" #") + if hash_at >= 0: + inline = rest[hash_at + 2 :].strip() + rest = rest[:hash_at] + value = _strip_env_quotes(rest) + yield lineno, key, value, list(pending), inline + pending = [] + + +def _config_004_comment_offenders(path: str) -> list[str]: + offenders: list[str] = [] + for lineno, key, _value, headers, inline in _iter_env_example_active_entries(path): + if not headers: + offenders.append( + f".env.example:{lineno}: {key}= 缺少行头注释(须用中文说明「是什么」)" + ) + elif not any(CONFIG_004_CJK_RE.search(part) for part in headers): + offenders.append( + f".env.example:{lineno}: {key}= 行头注释须含中文(说明「是什么」)" + ) + if not inline: + offenders.append( + f".env.example:{lineno}: {key}= 缺少行尾注释(须用中文说明「怎么填」)" + ) + elif not CONFIG_004_CJK_RE.search(inline): + offenders.append( + f".env.example:{lineno}: {key}= 行尾注释须含中文(说明「怎么填」)" + ) + combined = "\n".join([*headers, inline or ""]).lower() + for token in CONFIG_004_FORBIDDEN_COMMENT_SUBSTRINGS: + if token.lower() in combined: + offenders.append( + f".env.example:{lineno}: {key}= 注释禁止含 {token!r}(面向用户,勿写开发文档指向/黑话)" + ) + break + return offenders + + +def _rel(skill_root: str, path: str) -> str: + return os.path.relpath(path, skill_root).replace("\\", "/") + + +def _should_skip_dirname(dirname: str) -> bool: + return dirname in SKIP_DIR_NAMES + + +def _should_skip_rel_path(skill_root: str, abs_path: str) -> bool: + rel = _rel(skill_root, abs_path).replace("\\", "/") + for prefix in SKIP_REL_PREFIXES: + normalized = prefix.rstrip("/") + if rel == normalized or rel.startswith(normalized + "/"): + return True + return False + + +def _walk_files(skill_root: str, rel_root: str, *, suffix: str | None = None) -> list[str]: + base = os.path.join(skill_root, rel_root) + if not os.path.exists(base): + return [] + found: list[str] = [] + for root, dirnames, filenames in os.walk(base): + kept: list[str] = [] + for d in dirnames: + if _should_skip_dirname(d): + continue + sub = os.path.join(root, d) + if _should_skip_rel_path(skill_root, sub): + continue + kept.append(d) + dirnames[:] = kept + for name in filenames: + full = os.path.join(root, name) + if _should_skip_rel_path(skill_root, full): + continue + if suffix and not name.endswith(suffix): + continue + found.append(full) + return sorted(found) + + +def _read_text(path: str) -> str: + with open(path, encoding="utf-8") as f: + return f.read() + + +def _requirement_dependency_lines(req_text: str) -> list[str]: + return [ + line.strip() + for line in req_text.splitlines() + if line.strip() and not line.strip().startswith("#") + ] + + +def _scan_install_delivery_files(skill_root: str) -> list[str]: + offenders: list[str] = [] + scan_specs: list[tuple[str, str | None]] = [ + ("scripts", ".py"), + (".", ".ps1"), + (".", ".sh"), + (".", None), + (".github/workflows", ".yml"), + (".github/workflows", ".yaml"), + ] + seen: set[str] = set() + for rel_root, suffix in scan_specs: + if rel_root == "." and suffix is None: + req = os.path.join(skill_root, "requirements.txt") + if os.path.isfile(req): + seen.add(req) + text = _read_text(req) + for pattern in INSTALL_FORBIDDEN_PATTERNS: + if pattern.search(text): + offenders.append( + _policy_msg( + "POLICY-INSTALL-001", + "development/RUNTIME.md; development/RPA.md §1.4; development/DEVELOPMENT.md §3.1", + f"{_rel(skill_root, req)} matches {pattern.pattern}", + ) + ) + continue + for path in _walk_files(skill_root, rel_root, suffix=suffix): + if path in seen: + continue + seen.add(path) + if suffix == ".ps1" and not path.endswith(".ps1"): + continue + if suffix == ".sh" and not path.endswith(".sh"): + continue + text = _read_text(path) + for pattern in INSTALL_FORBIDDEN_PATTERNS: + if pattern.search(text): + offenders.append( + _policy_msg( + "POLICY-INSTALL-001", + "development/RUNTIME.md; development/RPA.md §1.4; development/DEVELOPMENT.md §3.1", + f"{_rel(skill_root, path)} matches {pattern.pattern}", + ) + ) + return offenders + + +def _gitignore_has_pattern(lines: list[str], pattern: str) -> bool: + normalized = [ln.strip() for ln in lines if ln.strip() and not ln.strip().startswith("#")] + return pattern in normalized + + +class TestPolicyStructure001(unittest.TestCase): + def test_standard_scripts_layout_exists(self) -> None: + skill_root = get_skill_root() + missing: list[str] = [] + for rel in STRUCTURE_PATHS: + path = os.path.join(skill_root, *rel.split("/")) + if not os.path.exists(path): + missing.append(rel) + self.assertEqual( + missing, + [], + msg=_policy_msg( + "POLICY-STRUCTURE-001", + "development/DEVELOPMENT.md §2; development/RUNTIME.md §目录结构", + "missing: " + ", ".join(missing), + ), + ) + + +class TestPolicyRuntime001(unittest.TestCase): + def test_no_vendored_jiangchang_skill_core(self) -> None: + skill_root = get_skill_root() + vendored = os.path.join(skill_root, "scripts", "jiangchang_skill_core") + self.assertFalse( + os.path.isdir(vendored), + msg=_policy_msg( + "POLICY-RUNTIME-001", + "development/RUNTIME.md §共享 Python Runtime", + _rel(skill_root, vendored), + ), + ) + + +class TestPolicyRuntime002(unittest.TestCase): + def test_requirements_txt_excludes_shared_runtime_deps(self) -> None: + skill_root = get_skill_root() + req_path = os.path.join(skill_root, "requirements.txt") + self.assertTrue(os.path.isfile(req_path), msg="requirements.txt missing") + text = _read_text(req_path) + dep_lines = _requirement_dependency_lines(text) + offenders: list[str] = [] + for line in dep_lines: + lower = line.lower() + if "jiangchang-platform-kit" in lower: + offenders.append(line) + if re.search(r"(?<![\w.-])playwright(?![\w.-])", lower): + offenders.append(line) + self.assertEqual( + offenders, + [], + msg=_policy_msg( + "POLICY-RUNTIME-002", + "development/RUNTIME.md; development/DEVELOPMENT.md §3.1", + "forbidden dependency lines: " + "; ".join(offenders), + ), + ) + + +class TestPolicyInstall001(unittest.TestCase): + def test_delivery_files_have_no_auto_install_commands(self) -> None: + offenders = _scan_install_delivery_files(get_skill_root()) + self.assertEqual(offenders, [], msg="\n".join(offenders)) + + +class TestPolicyConfig001(unittest.TestCase): + def test_env_example_exists(self) -> None: + skill_root = get_skill_root() + path = os.path.join(skill_root, ".env.example") + self.assertTrue( + os.path.isfile(path), + msg=_policy_msg( + "POLICY-CONFIG-001", + "development/CONFIG.md", + ".env.example missing at repo root", + ), + ) + + +class TestPolicyConfig002(unittest.TestCase): + def test_gitignore_ignores_env_files(self) -> None: + skill_root = get_skill_root() + path = os.path.join(skill_root, ".gitignore") + self.assertTrue(os.path.isfile(path), msg=".gitignore missing") + lines = _read_text(path).splitlines() + missing: list[str] = [] + if not _gitignore_has_pattern(lines, ".env"): + missing.append(".env") + if not _gitignore_has_pattern(lines, "*.env.local"): + missing.append("*.env.local") + self.assertEqual( + missing, + [], + msg=_policy_msg( + "POLICY-CONFIG-002", + "development/CONFIG.md §红线:敏感信息不进 .env", + "missing gitignore entries: " + ", ".join(missing), + ), + ) + + +class TestPolicyConfig003(unittest.TestCase): + def test_business_scripts_do_not_read_os_environ_directly(self) -> None: + skill_root = get_skill_root() + offenders: list[str] = [] + for path in _walk_files(skill_root, "scripts", suffix=".py"): + rel = _rel(skill_root, path) + if rel.replace("\\", "/") in CONFIG_ENV_BOUNDARY_FILES: + continue + text = _read_text(path) + for lineno, line in enumerate(text.splitlines(), 1): + if line.strip().startswith("#"): + continue + for pattern in CONFIG_ENV_FORBIDDEN_PATTERNS: + if pattern.search(line): + offenders.append( + f"{rel}:{lineno}: {line.strip()} — use jiangchang_skill_core.config.get*" + ) + self.assertEqual( + offenders, + [], + msg=_policy_msg( + "POLICY-CONFIG-003", + "development/CONFIG.md; development/ADAPTER.md", + "\n".join(offenders), + ), + ) + + +class TestPolicyConfig004(unittest.TestCase): + def test_env_example_comments_are_user_facing(self) -> None: + path = os.path.join(get_skill_root(), ".env.example") + self.assertTrue(os.path.isfile(path), msg=".env.example missing") + offenders = _config_004_comment_offenders(path) + self.assertEqual( + offenders, + [], + msg=_policy_msg( + "POLICY-CONFIG-004", + CONFIG_004_SOURCE, + "\n".join(offenders) + or "every active KEY in .env.example needs Chinese header+inline comments", + ), + ) + + +class TestPolicyTesting001(unittest.TestCase): + def test_root_tests_do_not_hardcode_real_targets(self) -> None: + skill_root = get_skill_root() + tests_dir = os.path.join(skill_root, "tests") + offenders: list[str] = [] + for name in sorted(os.listdir(tests_dir)): + if not (name.startswith("test_") and name.endswith(".py")): + continue + if name in TESTING_001_SKIP_ROOT_TESTS: + continue + if name in TESTING_REAL_TARGET_ALLOWED_ROOT_TESTS: + continue + path = os.path.join(tests_dir, name) + text = _read_text(path) + for lineno, line in enumerate(text.splitlines(), 1): + stripped = line.strip() + if stripped.startswith("#"): + continue + for token in TESTING_001_FORBIDDEN_SUBSTRINGS: + if token in line: + offenders.append(f"{name}:{lineno}: {stripped}") + break + self.assertEqual( + offenders, + [], + msg=_policy_msg( + "POLICY-TESTING-001", + "development/TESTING.md §默认必跑测试要做什么", + "\n".join(offenders), + ), + ) + + +class TestPolicyTesting002(unittest.TestCase): + def test_integration_python_tests_are_sample_only(self) -> None: + skill_root = get_skill_root() + integration_dir = os.path.join(skill_root, "tests", "integration") + if not os.path.isdir(integration_dir): + return + offenders: list[str] = [] + for name in sorted(os.listdir(integration_dir)): + if not name.endswith(".py"): + continue + if name.endswith(".sample"): + continue + offenders.append(f"tests/integration/{name}") + self.assertEqual( + offenders, + [], + msg=_policy_msg( + "POLICY-TESTING-002", + "development/TESTING.md §7 真实联调测试的安全约束", + "non-sample integration tests: " + ", ".join(offenders), + ), + ) + + +class TestPolicyRpa001(unittest.TestCase): + def test_scripts_have_no_account_manager_internal_imports(self) -> None: + skill_root = get_skill_root() + offenders: list[str] = [] + for path in _walk_files(skill_root, "scripts", suffix=".py"): + rel = _rel(skill_root, path) + text = _read_text(path) + for pattern in RPA_001_FORBIDDEN_PATTERNS: + if pattern.search(text): + offenders.append(f"{rel}: {pattern.pattern}") + self.assertEqual( + offenders, + [], + msg=_policy_msg( + "POLICY-RPA-001", + "development/ADAPTER.md §兄弟依赖; development/RPA.md §1.7", + "\n".join(offenders), + ), + ) + + +class TestPolicyRpa002(unittest.TestCase): + def test_scripts_do_not_invoke_ffmpeg_directly(self) -> None: + skill_root = get_skill_root() + offenders: list[str] = [] + for path in _walk_files(skill_root, "scripts", suffix=".py"): + rel = _rel(skill_root, path) + if rel.replace("\\", "/") == RPA_002_EXEMPT_REL: + continue + text = _read_text(path) + for pattern in RPA_002_FFMPEG_PATTERNS: + if pattern.search(text): + offenders.append(f"{rel}: {pattern.pattern}") + self.assertEqual( + offenders, + [], + msg=_policy_msg( + "POLICY-RPA-002", + "development/RPA.md §5.3 录屏成片标准", + "\n".join(offenders), + ), + ) + + +class TestPolicyRpa003(unittest.TestCase): + def test_service_layer_retains_video_session_integration(self) -> None: + skill_root = get_skill_root() + service_files = _walk_files(skill_root, "scripts/service", suffix=".py") + self.assertTrue(service_files, msg="scripts/service/*.py not found") + combined = "\n".join(_read_text(path) for path in service_files) + missing: list[str] = [] + if "RpaVideoSession" not in combined: + missing.append("RpaVideoSession") + if "video.add_step" not in combined and ".add_step(" not in combined: + missing.append("video.add_step or .add_step(") + if "merge_video_into_result_summary" not in combined: + missing.append("merge_video_into_result_summary") + self.assertEqual( + missing, + [], + msg=_policy_msg( + "POLICY-RPA-003", + RPA_003_SOURCE, + "scripts/service/*.py missing: " + ", ".join(missing), + ), + ) + + +class TestPolicyRpa004(unittest.TestCase): + def test_env_example_browser_headless_defaults_to_headed(self) -> None: + skill_root = get_skill_root() + env_path = os.path.join(skill_root, ".env.example") + self.assertTrue(os.path.isfile(env_path), msg=".env.example missing") + text = _read_text(env_path) + match = RPA_004_HEADLESS_KEY_RE.search(text) + if match is None: + return + value = match.group(1).strip().strip("\"'") + self.assertEqual( + value, + "0", + msg=_policy_msg( + "POLICY-RPA-004", + RPA_004_SOURCE, + f".env.example OPENCLAW_BROWSER_HEADLESS must default to 0 (headed), got {value!r}", + ), + ) + + def test_launch_persistent_requires_pick_web_account(self) -> None: + skill_root = get_skill_root() + script_texts = [ + _read_text(path) for path in _walk_files(skill_root, "scripts", suffix=".py") + ] + combined = "\n".join(script_texts) + uses_launch = any(marker in combined for marker in RPA_004_LAUNCH_MARKERS) + if not uses_launch: + return + self.assertIn( + RPA_004_PICK_MARKER, + combined, + msg=_policy_msg( + "POLICY-RPA-004", + RPA_004_SOURCE, + "scripts/ uses launch_persistent_* but missing pick_web_account " + "(must obtain profile_dir before opening browser)", + ), + ) + + +class TestPolicyRpa005(unittest.TestCase): + def test_ai_rpa_governance_doc_has_required_boundaries(self) -> None: + skill_root = get_skill_root() + path = os.path.join(skill_root, "development", "AI_RPA.md") + self.assertTrue( + os.path.isfile(path), + msg=_policy_msg("POLICY-RPA-005", RPA_005_SOURCE, "development/AI_RPA.md missing"), + ) + text = _read_text(path) + missing = [marker for marker in RPA_005_REQUIRED_DOC_MARKERS if marker not in text] + self.assertEqual( + missing, + [], + msg=_policy_msg( + "POLICY-RPA-005", + RPA_005_SOURCE, + "AI_RPA.md missing markers: " + ", ".join(missing), + ), + ) + + def test_real_rpa_default_step_delay_is_one_to_three_seconds(self) -> None: + skill_root = get_skill_root() + env_path = os.path.join(skill_root, ".env.example") + values = { + key: value.strip().strip("\"'") + for key, value in RPA_005_STEP_DELAY_KEY_RE.findall(_read_text(env_path)) + } + self.assertEqual( + values.get("STEP_DELAY_MIN"), + "1.0", + msg=_policy_msg( + "POLICY-RPA-005", + RPA_005_SOURCE, + f"STEP_DELAY_MIN must default to 1.0, got {values.get('STEP_DELAY_MIN')!r}", + ), + ) + self.assertEqual( + values.get("STEP_DELAY_MAX"), + "3.0", + msg=_policy_msg( + "POLICY-RPA-005", + RPA_005_SOURCE, + f"STEP_DELAY_MAX must default to 3.0, got {values.get('STEP_DELAY_MAX')!r}", + ), + ) + + +class TestPolicyRpa006(unittest.TestCase): + def test_account_manager_lifecycle_doc_has_required_markers(self) -> None: + skill_root = get_skill_root() + path = os.path.join(skill_root, "development", "ACCOUNT_MANAGER.md") + self.assertTrue( + os.path.isfile(path), + msg=_policy_msg("POLICY-RPA-006", RPA_006_SOURCE, "development/ACCOUNT_MANAGER.md missing"), + ) + text = _read_text(path) + missing = [marker for marker in RPA_006_REQUIRED_DOC_MARKERS if marker not in text] + self.assertEqual( + missing, + [], + msg=_policy_msg( + "POLICY-RPA-006", + RPA_006_SOURCE, + "ACCOUNT_MANAGER.md missing markers: " + ", ".join(missing), + ), + ) + + def test_example_account_id_selection_keeps_lease(self) -> None: + skill_root = get_skill_root() + for rel in RPA_006_EXAMPLE_CLIENTS: + text = _read_text(os.path.join(skill_root, *rel.split("/"))) + self.assertIn("_pick_by_id_with_lease", text, msg=f"{rel} must lease explicit account ids") + self.assertIn('"account",\n "pick-web"', text, msg=f"{rel} must use pick-web for lease") + self.assertIn("lease_token", text, msg=f"{rel} must validate lease_token") + self.assertNotIn( + 'data["lease_token"] = ""', + text, + msg=f"{rel} must not return an explicit account without a lease", + ) + + +def _scan_sensitive_logging_assignments(skill_root: str) -> list[str]: + offenders: list[str] = [] + for path in _walk_files(skill_root, "scripts", suffix=".py"): + rel = _rel(skill_root, path) + for lineno, line in enumerate(_read_text(path).splitlines(), 1): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + if not LOGGING_CALL_PATTERN.search(line): + continue + if SENSITIVE_LOG_ASSIGNMENT_PATTERN.search(line): + offenders.append(f"{rel}:{lineno}: {stripped}") + return offenders + + +class TestPolicyLogging001(unittest.TestCase): + def test_cli_app_sets_up_unified_logging(self) -> None: + skill_root = get_skill_root() + path = os.path.join(skill_root, "scripts", "cli", "app.py") + text = _read_text(path) + rel = _rel(skill_root, path) + self.assertIn( + "setup_skill_logging", + text, + msg=_policy_msg( + "POLICY-LOGGING-001", + LOGGING_001_SOURCE, + f"{rel} missing setup_skill_logging", + ), + ) + has_logger_info = bool( + LOGGING_CALL_GET_LOGGER_PATTERN.search(text) + or re.search(r"\bget_skill_logger\s*\(\s*\)\s*\.\s*info\s*\(", text) + ) + self.assertTrue( + has_logger_info, + msg=_policy_msg( + "POLICY-LOGGING-001", + LOGGING_001_SOURCE, + f"{rel} missing get_skill_logger().info (or equivalent)", + ), + ) + + +class TestPolicyLogging002(unittest.TestCase): + def test_task_service_uses_get_skill_logger(self) -> None: + skill_root = get_skill_root() + path = os.path.join(skill_root, "scripts", "service", "task_service.py") + text = _read_text(path) + rel = _rel(skill_root, path) + self.assertRegex( + text, + r"get_skill_logger", + msg=_policy_msg( + "POLICY-LOGGING-002", + LOGGING_002_SOURCE, + f"{rel} must import/use get_skill_logger", + ), + ) + + +class TestPolicyLogging003(unittest.TestCase): + def test_task_service_logs_start_and_failure(self) -> None: + skill_root = get_skill_root() + path = os.path.join(skill_root, "scripts", "service", "task_service.py") + text = _read_text(path) + rel = _rel(skill_root, path) + missing: list[str] = [] + if not re.search(r"\b(log|logger)\.info\s*\(", text): + missing.append("log.info or logger.info") + if not re.search(r"\b(log|logger)\.exception\s*\(", text): + missing.append("log.exception or logger.exception") + self.assertEqual( + missing, + [], + msg=_policy_msg( + "POLICY-LOGGING-003", + LOGGING_003_SOURCE, + f"{rel} missing: " + ", ".join(missing), + ), + ) + + +class TestPolicyLogging004(unittest.TestCase): + def test_task_service_emits_progress_for_long_tasks(self) -> None: + skill_root = get_skill_root() + path = os.path.join(skill_root, "scripts", "service", "task_service.py") + text = _read_text(path) + rel = _rel(skill_root, path) + has_progress = bool( + re.search(r"\bemit\s*\(", text) + or re.search(r"\bactivity\.emit\s*\(", text) + or re.search(r"\bvideo\.add_step\s*\(", text) + ) + self.assertTrue( + has_progress, + msg=_policy_msg( + "POLICY-LOGGING-004", + LOGGING_004_SOURCE, + f"{rel} must use emit( / activity.emit / video.add_step", + ), + ) + + +class TestPolicyLogging005(unittest.TestCase): + def test_scripts_do_not_log_sensitive_assignments(self) -> None: + offenders = _scan_sensitive_logging_assignments(get_skill_root()) + self.assertEqual( + offenders, + [], + msg=_policy_msg( + "POLICY-LOGGING-005", + LOGGING_005_SOURCE, + "\n".join(offenders), + ), + ) + + +def _iter_service_py_files(skill_root: str) -> list[str]: + service_dir = os.path.join(skill_root, "scripts", "service") + if not os.path.isdir(service_dir): + return [] + return list(_walk_files(skill_root, os.path.join("scripts", "service"), suffix=".py")) + + +def _service_layer_combined_text(skill_root: str) -> str: + parts: list[str] = [] + for path in _iter_service_py_files(skill_root): + parts.append(_read_text(path)) + return "\n".join(parts) + + +_LOGGING_DOMAIN_CALL_RE = re.compile( + r"\b(log|logger|_logger)\s*\.\s*(info|warning|error|exception)\s*\(", + re.IGNORECASE, +) +_PRINT_ERROR_RE = re.compile(r"\bprint\s*\(.*ERROR:", re.IGNORECASE | re.DOTALL) +_FUNC_DEF_RE = re.compile(r"(?m)^(async\s+)?def\s+\w+\s*\(") + + +def _scan_print_error_without_logger(skill_root: str, path: str, text: str) -> list[str]: + """同一函数体内 print(...ERROR:) 须伴随 warning/error/exception 日志。""" + offenders: list[str] = [] + matches = list(_FUNC_DEF_RE.finditer(text)) + if not matches: + return offenders + for i, m in enumerate(matches): + start = m.start() + end = matches[i + 1].start() if i + 1 < len(matches) else len(text) + body = text[start:end] + if not _PRINT_ERROR_RE.search(body): + continue + if re.search( + r"\b(log|logger|_logger)\s*\.\s*(warning|error|exception)\s*\(", + body, + re.IGNORECASE, + ): + continue + lineno = text[:start].count("\n") + 1 + offenders.append( + f"{_rel(skill_root, path)}:{lineno}: print(ERROR:) without log.warning|error|exception in same function" + ) + return offenders + + +class TestPolicyLogging006(unittest.TestCase): + def test_service_domain_modules_have_file_logging(self) -> None: + skill_root = get_skill_root() + offenders: list[str] = [] + for path in _iter_service_py_files(skill_root): + base = os.path.basename(path) + if base in LOGGING_006_EXEMPT_BASENAMES: + continue + text = _read_text(path) + if not _LOGGING_DOMAIN_CALL_RE.search(text): + offenders.append(_rel(skill_root, path)) + self.assertEqual( + offenders, + [], + msg=_policy_msg( + "POLICY-LOGGING-006", + LOGGING_006_SOURCE, + "service domain modules missing log/logger/_logger.(info|warning|error|exception):\n" + + "\n".join(offenders), + ), + ) + + +class TestPolicyLogging007(unittest.TestCase): + def test_rpa_service_layer_has_event_tokens(self) -> None: + skill_root = get_skill_root() + combined = _service_layer_combined_text(skill_root) + if not any(marker in combined for marker in LOGGING_RPA_MARKERS): + self.skipTest("no RPA markers in scripts/service; LOGGING-007 N/A") + missing: list[str] = [] + if "task_start" not in combined: + missing.append("task_start") + if "task_failed" not in combined and "rpa_failed" not in combined: + missing.append("task_failed|rpa_failed") + if "stage=" not in combined: + missing.append("stage=") + # entitlement tokens strongly recommended; require at least one + if "entitlement_ok" not in combined and "entitlement_failed" not in combined: + missing.append("entitlement_ok|entitlement_failed") + self.assertEqual( + missing, + [], + msg=_policy_msg( + "POLICY-LOGGING-007", + LOGGING_007_SOURCE, + "RPA service layer missing event tokens: " + ", ".join(missing), + ), + ) + + +class TestPolicyLogging008(unittest.TestCase): + def test_print_error_paired_with_logger(self) -> None: + skill_root = get_skill_root() + offenders: list[str] = [] + for path in _iter_service_py_files(skill_root): + text = _read_text(path) + offenders.extend(_scan_print_error_without_logger(skill_root, path, text)) + self.assertEqual( + offenders, + [], + msg=_policy_msg( + "POLICY-LOGGING-008", + LOGGING_008_SOURCE, + "\n".join(offenders) or "print(ERROR:) must share function with log.warning|error|exception", + ), + ) + + +class TestPolicyControl001(unittest.TestCase): + def test_no_util_progress_module(self) -> None: + skill_root = get_skill_root() + path = os.path.join(skill_root, "scripts", "util", "progress.py") + self.assertFalse( + os.path.isfile(path), + msg=_policy_msg( + "POLICY-CONTROL-001", + "development/LOGGING.md §2.5; development/RPA.md §0.1", + "scripts/util/progress.py must not exist", + ), + ) + + +class TestPolicyControl002(unittest.TestCase): + def test_task_service_uses_job_context_and_finish(self) -> None: + skill_root = get_skill_root() + path = os.path.join(skill_root, "scripts", "service", "task_service.py") + text = _read_text(path) + rel = _rel(skill_root, path) + missing: list[str] = [] + if "job_context(" not in text: + missing.append("job_context(") + if "finish(" not in text: + missing.append("finish(") + self.assertEqual( + missing, + [], + msg=_policy_msg( + "POLICY-CONTROL-002", + "development/LOGGING.md §2.5、§7", + f"{rel} missing: " + ", ".join(missing), + ), + ) + + +class TestPolicyControl003(unittest.TestCase): + def test_service_layer_has_no_bare_asyncio_sleep(self) -> None: + skill_root = get_skill_root() + offenders: list[str] = [] + for path in _walk_files(skill_root, "scripts/service", suffix=".py"): + rel = _rel(skill_root, path) + for lineno, line in enumerate(_read_text(path).splitlines(), 1): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + if "asyncio.sleep" in stripped: + offenders.append(f"{rel}:{lineno}: {stripped}") + self.assertEqual( + offenders, + [], + msg=_policy_msg( + "POLICY-CONTROL-003", + "development/RPA.md §0.1", + "use interruptible_sleep instead:\n" + "\n".join(offenders), + ), + ) + + +class TestPolicyDataPath001(unittest.TestCase): + def test_env_example_has_no_cwd_relative_paths(self) -> None: + path = os.path.join(get_skill_root(), ".env.example") + offenders: list[str] = [] + with open(path, encoding="utf-8") as f: + for lineno, line in enumerate(f, 1): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + active = stripped.split("#", 1)[0].strip() + if re.search(r"=\s*\./", active): + offenders.append(f".env.example:{lineno}: {active}") + self.assertEqual( + offenders, + [], + msg=_policy_msg( + "POLICY-DATA-PATH-001", + "development/DATA_PATHS.md", + "\n".join(offenders) or "CWD-relative ./ paths in .env.example", + ), + ) + + def test_runtime_paths_exposes_resolve_data_path(self) -> None: + import util.runtime_paths as rp + + self.assertTrue(callable(getattr(rp, "resolve_data_path", None))) + self.assertTrue(callable(getattr(rp, "list_resolved_data_paths", None))) + + def test_business_scripts_do_not_abspath_config_get(self) -> None: + skill_root = get_skill_root() + pattern = re.compile(r"os\.path\.abspath\s*\(\s*config\.get\b") + offenders: list[str] = [] + for rel in _walk_files(skill_root, "scripts", suffix=".py"): + if rel.replace("\\", "/") == "scripts/util/runtime_paths.py": + continue + text = _read_text(os.path.join(skill_root, rel)) + for lineno, line in enumerate(text.splitlines(), 1): + if line.strip().startswith("#"): + continue + if pattern.search(line): + offenders.append(f"{rel}:{lineno}: {line.strip()}") + self.assertEqual( + offenders, + [], + msg=_policy_msg( + "POLICY-DATA-PATH-001", + "development/DATA_PATHS.md", + "\n".join(offenders), + ), + ) + + +_TEMPLATE_PLACEHOLDER_SLUG = "your-skill-slug" +_RPA_SESSION_TOKENS = ("RpaVideoSession",) + + +def _service_has_rpa_video_session(skill_root: str) -> bool: + service_dir = os.path.join(skill_root, "scripts", "service") + if not os.path.isdir(service_dir): + return False + for rel in _walk_files(skill_root, "scripts/service", suffix=".py"): + text = _read_text(os.path.join(skill_root, rel)) + if any(token in text for token in _RPA_SESSION_TOKENS): + return True + return False + + +def _load_actions_json(skill_root: str) -> dict | None: + path = os.path.join(skill_root, "assets", "actions.json") + if not os.path.isfile(path): + return None + import json + + with open(path, encoding="utf-8") as f: + return json.load(f) + + +class TestPolicySkillAction001(unittest.TestCase): + def test_skill_action_runtime_doc_exists(self) -> None: + path = os.path.join(get_skill_root(), "development", "SKILL_ACTION_RUNTIME.md") + self.assertTrue( + os.path.isfile(path), + msg=_policy_msg( + "POLICY-SKILL-ACTION-001", + "development/SKILL_ACTION_RUNTIME.md", + "file missing", + ), + ) + text = _read_text(path) + for marker in ("executionProfile", "async", "任务中心", "run_skill_action"): + self.assertIn( + marker, + text, + msg=_policy_msg( + "POLICY-SKILL-ACTION-001", + "development/SKILL_ACTION_RUNTIME.md", + f"missing marker {marker!r}", + ), + ) + + def test_schema_defines_execution_profile(self) -> None: + import json + + path = os.path.join(get_skill_root(), "assets", "schemas", "skill-actions.schema.json") + self.assertTrue(os.path.isfile(path)) + with open(path, encoding="utf-8") as f: + schema = json.load(f) + action_props = schema.get("$defs", {}).get("action", {}).get("properties", {}) + profile = action_props.get("executionProfile") + self.assertIsInstance( + profile, + dict, + msg=_policy_msg( + "POLICY-SKILL-ACTION-001", + "assets/schemas/skill-actions.schema.json", + "action.properties.executionProfile missing", + ), + ) + self.assertEqual(set(profile.get("enum") or []), {"sync", "async"}) + + def test_actions_md_documents_execution_profile(self) -> None: + path = os.path.join(get_skill_root(), "references", "ACTIONS.md") + text = _read_text(path) + for marker in ("executionProfile", "async", "任务中心", "SKILL_ACTION_RUNTIME"): + self.assertIn( + marker, + text, + msg=_policy_msg( + "POLICY-SKILL-ACTION-001", + "references/ACTIONS.md", + f"missing marker {marker!r}", + ), + ) + + def test_actions_json_actions_declare_execution_profile(self) -> None: + skill_root = get_skill_root() + manifest = _load_actions_json(skill_root) + if manifest is None: + return + offenders: list[str] = [] + for action in manifest.get("actions") or []: + action_id = action.get("id", "?") + profile = action.get("executionProfile") + if profile not in ("sync", "async"): + offenders.append(f"{action_id}: executionProfile={profile!r}") + self.assertEqual( + offenders, + [], + msg=_policy_msg( + "POLICY-SKILL-ACTION-001", + "assets/actions.json", + "every action must set executionProfile to sync|async:\n" + "\n".join(offenders), + ), + ) + + def test_business_rpa_skill_has_async_agent_action(self) -> None: + from util.constants import SKILL_SLUG + + skill_root = get_skill_root() + if SKILL_SLUG == _TEMPLATE_PLACEHOLDER_SLUG: + self.skipTest("template placeholder slug skips business RPA action check") + if not _service_has_rpa_video_session(skill_root): + self.skipTest("no RpaVideoSession in scripts/service") + manifest = _load_actions_json(skill_root) + self.assertIsNotNone( + manifest, + msg=_policy_msg( + "POLICY-SKILL-ACTION-001", + "assets/actions.json", + "RPA skill must provide assets/actions.json", + ), + ) + found = False + for action in manifest.get("actions") or []: + placements = set(action.get("placements") or []) + if action.get("executionProfile") == "async" and "agent" in placements: + found = True + break + self.assertTrue( + found, + msg=_policy_msg( + "POLICY-SKILL-ACTION-001", + "assets/actions.json", + "RPA business skill needs at least one action with executionProfile=async and placements containing agent", + ), + ) + + +class TestPolicySkillAction002(unittest.TestCase): + def test_schema_requires_execution_profile(self) -> None: + import json + + path = os.path.join(get_skill_root(), "assets", "schemas", "skill-actions.schema.json") + with open(path, encoding="utf-8") as f: + schema = json.load(f) + action = schema["$defs"]["action"] + self.assertIn( + "executionProfile", + action.get("required") or [], + msg=_policy_msg( + "POLICY-SKILL-ACTION-002", + "assets/schemas/skill-actions.schema.json", + "executionProfile must be in action.required", + ), + ) + self.assertEqual(set(action["properties"]["executionProfile"].get("enum") or []), {"sync", "async"}) + + def test_manifest_actions_declare_execution_profile(self) -> None: + manifest = _load_actions_json(get_skill_root()) + if manifest is None: + return + offenders: list[str] = [] + for action in manifest.get("actions") or []: + profile = action.get("executionProfile") + if profile not in ("sync", "async"): + offenders.append(f"{action.get('id', '?')}: {profile!r}") + self.assertEqual( + offenders, + [], + msg=_policy_msg( + "POLICY-SKILL-ACTION-002", + "assets/actions.json", + "every action must set executionProfile to sync|async:\n" + "\n".join(offenders), + ), + ) + + +class TestPolicySkillAction003(unittest.TestCase): + def test_schema_defines_bind_and_toolbar_requires_it(self) -> None: + import json + + path = os.path.join(get_skill_root(), "assets", "schemas", "skill-actions.schema.json") + with open(path, encoding="utf-8") as f: + schema = json.load(f) + self.assertIn( + "bind", + schema.get("$defs", {}), + msg=_policy_msg( + "POLICY-SKILL-ACTION-003", + "assets/schemas/skill-actions.schema.json", + "$defs.bind missing", + ), + ) + action = schema["$defs"]["action"] + self.assertIn("bind", action.get("properties", {})) + then_required = (action.get("then") or {}).get("required") or [] + self.assertIn( + "bind", + then_required, + msg=_policy_msg( + "POLICY-SKILL-ACTION-003", + "assets/schemas/skill-actions.schema.json", + "action if/then must require bind when placements contains toolbar", + ), + ) + + def test_toolbar_actions_have_bind_tables(self) -> None: + import re + + table_re = re.compile(r"^[a-z][a-z0-9_]*$") + manifest = _load_actions_json(get_skill_root()) + if manifest is None: + return + offenders: list[str] = [] + for action in manifest.get("actions") or []: + if "toolbar" not in (action.get("placements") or []): + continue + bind = action.get("bind") + tables = bind.get("tables") if isinstance(bind, dict) else None + if not isinstance(tables, list) or not tables: + offenders.append(f"{action.get('id')}: missing bind.tables") + continue + if len(tables) != len(set(tables)): + offenders.append(f"{action.get('id')}: duplicate bind.tables") + for table in tables: + if not isinstance(table, str) or not table_re.fullmatch(table): + offenders.append(f"{action.get('id')}: invalid table {table!r}") + self.assertEqual( + offenders, + [], + msg=_policy_msg( + "POLICY-SKILL-ACTION-003", + "assets/actions.json", + "toolbar actions need legal bind.tables:\n" + "\n".join(offenders), + ), + ) + + +class TestPolicySkillAction004(unittest.TestCase): + def _forbidden_coupling_phrases(self) -> tuple[str, ...]: + # 拆开拼接,避免本测试源码或废止说明原文触发误报。 + return ( + "数据管理 toolbar " + "只挂", + "toolbar " + "只放 async", + "只挂 **" + "async** action", + "toolbar " + "只能 async", + "toolbar only " + "async", + ) + + def test_docs_do_not_couple_toolbar_to_async(self) -> None: + skill_root = get_skill_root() + paths = ( + os.path.join(skill_root, "references", "ACTIONS.md"), + os.path.join(skill_root, "development", "SKILL_ACTION_RUNTIME.md"), + os.path.join(skill_root, "SKILL.md"), + ) + hits: list[str] = [] + for path in paths: + text = _read_text(path) + for phrase in self._forbidden_coupling_phrases(): + if phrase in text: + hits.append(f"{os.path.relpath(path, skill_root)}: {phrase!r}") + self.assertEqual( + hits, + [], + msg=_policy_msg( + "POLICY-SKILL-ACTION-004", + "docs", + "placements must stay orthogonal to executionProfile:\n" + "\n".join(hits), + ), + ) + + def test_runtime_doc_states_orthogonality(self) -> None: + text = _read_text(os.path.join(get_skill_root(), "development", "SKILL_ACTION_RUNTIME.md")) + for marker in ("正交", "sync|async", "toolbar"): + self.assertIn( + marker, + text, + msg=_policy_msg( + "POLICY-SKILL-ACTION-004", + "development/SKILL_ACTION_RUNTIME.md", + f"missing marker {marker!r}", + ), + ) + + def test_schema_then_only_requires_bind_not_execution_profile(self) -> None: + """toolbar→bind 的 if/then 不得顺带约束 executionProfile。""" + import json + + path = os.path.join(get_skill_root(), "assets", "schemas", "skill-actions.schema.json") + with open(path, encoding="utf-8") as f: + schema = json.load(f) + action = schema["$defs"]["action"] + then = action.get("then") or {} + self.assertEqual( + then.get("required"), + ["bind"], + msg=_policy_msg( + "POLICY-SKILL-ACTION-004", + "assets/schemas/skill-actions.schema.json", + "action.then must only require bind", + ), + ) + self.assertNotIn("properties", then) + self.assertNotIn("else", action) + matrix_test = os.path.join(get_skill_root(), "tests", "test_actions_manifest.py") + text = _read_text(matrix_test) + self.assertIn( + "test_schema_allows_all_placement_execution_profile_combinations", + text, + msg=_policy_msg( + "POLICY-SKILL-ACTION-004", + "tests/test_actions_manifest.py", + "missing Schema 6×2 orthogonality matrix test", + ), + ) + + +class TestPolicyArchCore001(unittest.TestCase): + def test_docs_declare_single_core_multi_entry(self) -> None: + skill_root = get_skill_root() + text = ( + _read_text(os.path.join(skill_root, "development", "DEVELOPMENT.md")) + + "\n" + + _read_text(os.path.join(skill_root, "development", "SKILL_ACTION_RUNTIME.md")) + + "\n" + + _read_text(os.path.join(skill_root, "SKILL.md")) + ) + for marker in ("单业务内核", "多入口", "domain service"): + self.assertIn( + marker, + text, + msg=_policy_msg( + "POLICY-ARCH-CORE-001", + "development docs / SKILL.md", + f"missing architecture marker {marker!r}", + ), + ) + sample = os.path.join(skill_root, "tests", "samples", "test_action_core_contract.py.sample") + self.assertTrue( + os.path.isfile(sample), + msg=_policy_msg( + "POLICY-ARCH-CORE-001", + "tests/samples/test_action_core_contract.py.sample", + "sample contract missing", + ), + ) + + +class TestPolicyDataSync001(unittest.TestCase): + def test_schema_md_declares_sync_semantics(self) -> None: + text = _read_text(os.path.join(get_skill_root(), "references", "SCHEMA.md")) + for marker in ("幂等", "空结果", "事务", "inserted", "导出当前数据表"): + self.assertIn( + marker, + text, + msg=_policy_msg( + "POLICY-DATA-SYNC-001", + "references/SCHEMA.md", + f"missing sync marker {marker!r}", + ), + ) + sample = os.path.join(get_skill_root(), "tests", "samples", "test_sync_contract.py.sample") + self.assertTrue( + os.path.isfile(sample), + msg=_policy_msg( + "POLICY-DATA-SYNC-001", + "tests/samples/test_sync_contract.py.sample", + "sample contract missing", + ), + ) + + +class TestPolicyRelease001(unittest.TestCase): + def test_release_ps1_enforces_version_alignment(self) -> None: + skill_root = get_skill_root() + path = os.path.join(skill_root, "release.ps1") + self.assertTrue( + os.path.isfile(path), + msg=_policy_msg("POLICY-RELEASE-001", "release.ps1", "file missing"), + ) + text = _read_text(path) + self.assertIn( + "Assert-SkillReleaseVersionAlignment", + text, + msg=_policy_msg( + "POLICY-RELEASE-001", + "release.ps1", + "must define Assert-SkillReleaseVersionAlignment", + ), + ) + self.assertIn( + "POLICY-RELEASE-001", + text, + msg=_policy_msg( + "POLICY-RELEASE-001", + "release.ps1", + "must reference POLICY-RELEASE-001", + ), + ) + self.assertRegex( + text, + r"Assert-SkillReleaseVersionAlignment\s+-SkillRoot", + msg=_policy_msg( + "POLICY-RELEASE-001", + "release.ps1", + "must invoke Assert-SkillReleaseVersionAlignment before tagging", + ), + ) + + +class TestPolicyDocs001(unittest.TestCase): + def test_policy_matrix_exists_and_lists_policy_ids(self) -> None: + skill_root = get_skill_root() + matrix_path = os.path.join(skill_root, "development", "POLICY_MATRIX.md") + self.assertTrue( + os.path.isfile(matrix_path), + msg=_policy_msg( + "POLICY-DOCS-001", + "development/POLICY_MATRIX.md", + "file missing", + ), + ) + text = _read_text(matrix_path) + missing = [pid for pid in POLICY_IDS if pid not in text] + self.assertEqual( + missing, + [], + msg=_policy_msg( + "POLICY-DOCS-001", + "development/POLICY_MATRIX.md", + "missing policy_id entries: " + ", ".join(missing), + ), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_display_metadata.py b/tests/test_display_metadata.py new file mode 100644 index 0000000..0dab7cb --- /dev/null +++ b/tests/test_display_metadata.py @@ -0,0 +1,815 @@ +# -*- coding: utf-8 -*- +"""数据管理展示元数据:幂等初始化、中文名称、PRAGMA 字段顺序、校验规则。""" +from __future__ import annotations + +import os +import sqlite3 +import unittest + +from _support import ( + DEMO_ITEMS_COLUMN_METADATA, + DEMO_ITEMS_TABLE, + IsolatedDataRoot, + get_skill_root, + seed_demo_items, +) + + +class TestDisplayMetadata(unittest.TestCase): + def test_init_db_metadata_idempotent(self) -> None: + with IsolatedDataRoot(): + from db.connection import get_conn, init_db + + init_db() + init_db() + + conn = get_conn() + try: + cur = conn.cursor() + cur.execute("SELECT COUNT(*) FROM _jiangchang_tables") + # 本技能建 4 张业务表 + self.assertEqual(int(cur.fetchone()[0]), 4) + cur.execute("SELECT COUNT(*) FROM _jiangchang_columns") + self.assertGreaterEqual(int(cur.fetchone()[0]), 35) + cur.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='task_logs'" + ) + self.assertIsNone(cur.fetchone()) + finally: + conn.close() + + def test_demo_items_fixture_has_chinese_names(self) -> None: + with IsolatedDataRoot(): + from db.connection import get_conn, init_db + + init_db() + conn = get_conn() + try: + cur = conn.cursor() + seed_demo_items(cur) + conn.commit() + cur.execute( + "SELECT display_name, readonly FROM _jiangchang_tables WHERE table_name = ?", + (DEMO_ITEMS_TABLE,), + ) + display_name, readonly = cur.fetchone() + self.assertEqual(display_name, "示例记录") + self.assertEqual(int(readonly), 0) + + cur.execute( + """ + SELECT column_name, display_name, editable + FROM _jiangchang_columns + WHERE table_name = ? + ORDER BY column_name + """, + (DEMO_ITEMS_TABLE,), + ) + by_name = {row[0]: (row[1], row[2]) for row in cur.fetchall()} + self.assertEqual(by_name["status"][0], "状态") + self.assertEqual(int(by_name["status"][1]), 1) + self.assertEqual(by_name["title"][0], "标题") + self.assertEqual(by_name["created_at"][0], "创建时间") + self.assertEqual(int(by_name["created_at"][1]), 0) + finally: + conn.close() + + def test_readonly_table_not_manual_create_in_host_ui(self) -> None: + with IsolatedDataRoot(): + from db.connection import get_conn, init_db + from db.display_metadata import table_allows_manual_create + + init_db() + conn = get_conn() + try: + cur = conn.cursor() + seed_demo_items(cur, readonly=1) + conn.commit() + self.assertFalse(table_allows_manual_create(cur, DEMO_ITEMS_TABLE)) + finally: + conn.close() + + def test_demo_items_physical_order_matches_pragma_cid(self) -> None: + with IsolatedDataRoot(): + from db.connection import get_conn, init_db + from db.display_metadata import list_physical_column_names + + init_db() + conn = get_conn() + try: + cur = conn.cursor() + seed_demo_items(cur) + conn.commit() + physical = list_physical_column_names(cur, DEMO_ITEMS_TABLE) + expected = [str(item["column_name"]) for item in DEMO_ITEMS_COLUMN_METADATA] + self.assertEqual(physical, expected) + cur.execute("PRAGMA table_info('demo_items')") + cid_order = [row[1] for row in sorted(cur.fetchall(), key=lambda row: int(row[0]))] + self.assertEqual(cid_order, expected) + finally: + conn.close() + + def test_seed_sets_display_order_null(self) -> None: + with IsolatedDataRoot(): + from db.connection import get_conn, init_db + + init_db() + conn = get_conn() + try: + cur = conn.cursor() + seed_demo_items(cur) + conn.commit() + cur.execute( + """ + SELECT column_name, display_order + FROM _jiangchang_columns + WHERE table_name = ? + """, + (DEMO_ITEMS_TABLE,), + ) + for column_name, display_order in cur.fetchall(): + self.assertIsNone( + display_order, + msg=f"{column_name} display_order should be NULL", + ) + finally: + conn.close() + + def test_reseed_clears_stale_display_order(self) -> None: + with IsolatedDataRoot(): + from db.connection import get_conn, init_db + + init_db() + conn = get_conn() + try: + cur = conn.cursor() + seed_demo_items(cur) + conn.execute( + """ + UPDATE _jiangchang_columns + SET display_order = 99 + WHERE table_name = ? AND column_name = 'id' + """, + (DEMO_ITEMS_TABLE,), + ) + conn.commit() + seed_demo_items(cur) + conn.commit() + cur.execute( + """ + SELECT display_order FROM _jiangchang_columns + WHERE table_name = ? AND column_name = 'id' + """, + (DEMO_ITEMS_TABLE,), + ) + self.assertIsNone(cur.fetchone()[0]) + finally: + conn.close() + + def test_legacy_display_order_does_not_change_pragma_physical_order(self) -> None: + with IsolatedDataRoot(): + from db.connection import get_conn, init_db + from db.display_metadata import list_physical_column_names + + init_db() + conn = get_conn() + try: + cur = conn.cursor() + seed_demo_items(cur) + conn.commit() + cur.execute( + """ + UPDATE _jiangchang_columns + SET display_order = 1 + WHERE table_name = ? AND column_name = 'updated_at' + """, + (DEMO_ITEMS_TABLE,), + ) + cur.execute( + """ + UPDATE _jiangchang_columns + SET display_order = 99 + WHERE table_name = ? AND column_name = 'id' + """, + (DEMO_ITEMS_TABLE,), + ) + conn.commit() + physical = list_physical_column_names(cur, DEMO_ITEMS_TABLE) + self.assertEqual(physical[0], "id") + self.assertEqual(physical[-1], "updated_at") + finally: + conn.close() + + def test_metadata_tables_are_not_user_tables(self) -> None: + with IsolatedDataRoot(): + from db.connection import get_conn, init_db + from db.display_metadata import iter_user_tables + + init_db() + conn = get_conn() + try: + tables = list(iter_user_tables(conn.cursor())) + # 本技能有 4 张业务表 + self.assertEqual( + sorted(tables), + ["achievements", "closing_projects", "expense_items", "report_drafts"], + ) + finally: + conn.close() + + def test_template_database_validation_passes(self) -> None: + with IsolatedDataRoot(): + from db.connection import get_conn, init_db + from db.display_metadata_validator import validate_template_database + + init_db() + conn = get_conn() + try: + report = validate_template_database(conn) + self.assertTrue(report.ok, msg="; ".join(report.errors)) + finally: + conn.close() + + def test_validate_rejects_sqlite_foreign_keys_on_business_tables(self) -> None: + from db.display_metadata import ( + _JIANGCHANG_COLUMNS_DDL, + _JIANGCHANG_TABLES_DDL, + ) + from db.display_metadata_validator import validate_display_metadata + + conn = sqlite3.connect(":memory:") + try: + conn.execute(_JIANGCHANG_TABLES_DDL) + conn.execute(_JIANGCHANG_COLUMNS_DDL) + conn.execute("CREATE TABLE parent_rows (id INTEGER PRIMARY KEY, title TEXT)") + conn.execute( + """ + CREATE TABLE child_rows ( + id INTEGER PRIMARY KEY, + parent_id INTEGER NOT NULL, + FOREIGN KEY (parent_id) REFERENCES parent_rows(id) + ) + """ + ) + report = validate_display_metadata(conn) + self.assertFalse(report.ok) + self.assertTrue( + any("FOREIGN KEY" in err or "REFERENCES" in err for err in report.errors), + msg=f"expected FK error, got: {report.errors}", + ) + self.assertTrue(any("child_rows" in err for err in report.errors)) + finally: + conn.close() + + def test_validate_missing_both_metadata_tables(self) -> None: + conn = sqlite3.connect(":memory:") + try: + conn.execute("CREATE TABLE demo_items (id INTEGER PRIMARY KEY, title TEXT)") + from db.display_metadata_validator import validate_display_metadata + + report = validate_display_metadata(conn) + self.assertFalse(report.ok) + self.assertIn("缺少数据管理元数据表 _jiangchang_tables", report.errors) + self.assertIn("缺少数据管理元数据表 _jiangchang_columns", report.errors) + finally: + conn.close() + + def test_validate_missing_jiangchang_tables_only(self) -> None: + from db.display_metadata import _JIANGCHANG_COLUMNS_DDL + from db.display_metadata_validator import validate_display_metadata + + conn = sqlite3.connect(":memory:") + try: + conn.execute(_JIANGCHANG_COLUMNS_DDL) + report = validate_display_metadata(conn) + self.assertFalse(report.ok) + self.assertIn("缺少数据管理元数据表 _jiangchang_tables", report.errors) + self.assertNotIn("缺少数据管理元数据表 _jiangchang_columns", report.errors) + finally: + conn.close() + + def test_validate_missing_jiangchang_columns_only(self) -> None: + from db.display_metadata import _JIANGCHANG_TABLES_DDL + from db.display_metadata_validator import validate_display_metadata + + conn = sqlite3.connect(":memory:") + try: + conn.execute(_JIANGCHANG_TABLES_DDL) + report = validate_display_metadata(conn) + self.assertFalse(report.ok) + self.assertIn("缺少数据管理元数据表 _jiangchang_columns", report.errors) + self.assertNotIn("缺少数据管理元数据表 _jiangchang_tables", report.errors) + finally: + conn.close() + + def test_missing_table_metadata_is_reported(self) -> None: + with IsolatedDataRoot(): + from db.connection import get_conn, init_db + from db.display_metadata_validator import validate_display_metadata + + init_db() + conn = get_conn() + try: + conn.execute("CREATE TABLE demo_items (id INTEGER PRIMARY KEY, title TEXT)") + conn.execute("DELETE FROM _jiangchang_tables WHERE table_name = 'demo_items'") + conn.commit() + report = validate_display_metadata(conn) + self.assertFalse(report.ok) + self.assertTrue(any("demo_items" in err for err in report.errors)) + finally: + conn.close() + + def test_missing_column_metadata_is_reported(self) -> None: + with IsolatedDataRoot(): + from db.connection import get_conn, init_db + from db.display_metadata_validator import validate_display_metadata + + init_db() + conn = get_conn() + try: + seed_demo_items(conn.cursor()) + conn.execute( + "DELETE FROM _jiangchang_columns WHERE table_name = ? AND column_name = 'status'", + (DEMO_ITEMS_TABLE,), + ) + conn.commit() + report = validate_display_metadata(conn) + self.assertFalse(report.ok) + self.assertTrue(any("demo_items.status" in err for err in report.errors)) + finally: + conn.close() + + def test_metadata_reference_to_missing_column_is_reported(self) -> None: + with IsolatedDataRoot(): + from db.connection import get_conn, init_db + from db.display_metadata_validator import validate_display_metadata + + init_db() + conn = get_conn() + try: + seed_demo_items(conn.cursor()) + conn.execute( + """ + INSERT INTO _jiangchang_columns + (table_name, column_name, display_name, visible, searchable, editable) + VALUES (?, 'not_exists', '不存在', 1, 1, 1) + """, + (DEMO_ITEMS_TABLE,), + ) + conn.commit() + report = validate_display_metadata(conn) + self.assertFalse(report.ok) + self.assertTrue(any("not_exists" in err for err in report.errors)) + finally: + conn.close() + + def test_ordering_test_table_not_alphabetized(self) -> None: + conn = sqlite3.connect(":memory:") + try: + conn.executescript( + """ + CREATE TABLE ordering_test ( + z_field TEXT, + id INTEGER PRIMARY KEY, + a_field TEXT, + created_at INTEGER, + updated_at INTEGER + ); + """ + ) + from db.display_metadata import list_physical_column_names + + physical = list_physical_column_names(conn.cursor(), "ordering_test") + self.assertEqual(physical, ["z_field", "id", "a_field", "created_at", "updated_at"]) + finally: + conn.close() + + def test_skill_frontmatter_validation_for_template(self) -> None: + from db.display_metadata_validator import validate_skill_frontmatter + + report = validate_skill_frontmatter(get_skill_root(), allow_template_placeholders=True) + self.assertTrue(report.ok, msg="; ".join(report.errors)) + + def test_skill_frontmatter_rejects_english_placeholder_name(self) -> None: + import os + import tempfile + + from db.display_metadata_validator import validate_skill_frontmatter + + with tempfile.TemporaryDirectory() as tmp: + skill_md = """--- +name: My Skill +description: demo +metadata: + openclaw: + slug: query-demo-info +--- +""" + with open(os.path.join(tmp, "SKILL.md"), "w", encoding="utf-8") as f: + f.write(skill_md) + report = validate_skill_frontmatter(tmp, allow_template_placeholders=False) + self.assertFalse(report.ok) + self.assertTrue(any("name" in err for err in report.errors)) + + def test_column_order_warning_for_created_at_in_middle(self) -> None: + from db.display_metadata_validator import column_order_warnings + + warnings = column_order_warnings( + "bad_order", + ["created_at", "id", "title", "updated_at"], + ) + self.assertTrue(any("id" in item for item in warnings)) + + def test_generated_column_editable_must_be_zero(self) -> None: + conn = sqlite3.connect(":memory:") + try: + conn.executescript( + """ + CREATE TABLE gen_demo ( + id INTEGER PRIMARY KEY, + base_value INTEGER NOT NULL, + generated_value INTEGER GENERATED ALWAYS AS (base_value * 2) STORED + ); + """ + ) + from db.display_metadata import init_display_metadata_tables, upsert_column_metadata + from db.display_metadata_validator import validate_column_permissions + + cur = conn.cursor() + init_display_metadata_tables(cur) + upsert_column_metadata( + cur, + table_name="gen_demo", + column_name="generated_value", + display_name="生成值", + editable=1, + ) + conn.commit() + errors, _ = validate_column_permissions(cur, "gen_demo") + self.assertTrue(any("generated" in err for err in errors)) + finally: + conn.close() + + def test_blob_column_editable_must_be_zero(self) -> None: + conn = sqlite3.connect(":memory:") + try: + conn.execute( + "CREATE TABLE blob_demo (id INTEGER PRIMARY KEY, payload BLOB NOT NULL)" + ) + from db.display_metadata import init_display_metadata_tables, upsert_column_metadata + from db.display_metadata_validator import validate_column_permissions + + cur = conn.cursor() + init_display_metadata_tables(cur) + upsert_column_metadata( + cur, + table_name="blob_demo", + column_name="payload", + display_name="载荷", + editable=1, + ) + conn.commit() + errors, _ = validate_column_permissions(cur, "blob_demo") + self.assertTrue(any("BLOB" in err for err in errors)) + finally: + conn.close() + + def test_primary_key_editable_must_be_zero(self) -> None: + conn = sqlite3.connect(":memory:") + try: + conn.execute("CREATE TABLE pk_demo (id INTEGER PRIMARY KEY, title TEXT)") + from db.display_metadata import init_display_metadata_tables, upsert_column_metadata + from db.display_metadata_validator import validate_column_permissions + + cur = conn.cursor() + init_display_metadata_tables(cur) + upsert_column_metadata( + cur, + table_name="pk_demo", + column_name="id", + display_name="编号", + editable=1, + ) + conn.commit() + errors, _ = validate_column_permissions(cur, "pk_demo") + self.assertTrue(any("主键" in err for err in errors)) + finally: + conn.close() + + def test_readonly_table_with_editable_fields_warns(self) -> None: + conn = sqlite3.connect(":memory:") + try: + conn.executescript( + """ + CREATE TABLE warn_demo (id INTEGER PRIMARY KEY, title TEXT); + """ + ) + from db.display_metadata import ( + init_display_metadata_tables, + upsert_column_metadata, + upsert_table_metadata, + ) + from db.display_metadata_validator import validate_column_permissions + + cur = conn.cursor() + init_display_metadata_tables(cur) + upsert_table_metadata(cur, table_name="warn_demo", display_name="警告演示", readonly=1) + upsert_column_metadata( + cur, + table_name="warn_demo", + column_name="title", + display_name="标题", + editable=1, + ) + conn.commit() + _, warnings = validate_column_permissions(cur, "warn_demo") + self.assertTrue(any("readonly=1" in item for item in warnings)) + finally: + conn.close() + + def test_writable_table_without_editable_fields_warns(self) -> None: + conn = sqlite3.connect(":memory:") + try: + conn.executescript( + """ + CREATE TABLE warn_demo2 (id INTEGER PRIMARY KEY, title TEXT); + """ + ) + from db.display_metadata import ( + init_display_metadata_tables, + upsert_column_metadata, + upsert_table_metadata, + ) + from db.display_metadata_validator import validate_column_permissions + + cur = conn.cursor() + init_display_metadata_tables(cur) + upsert_table_metadata(cur, table_name="warn_demo2", display_name="警告演示二", readonly=0) + upsert_column_metadata( + cur, + table_name="warn_demo2", + column_name="title", + display_name="标题", + editable=0, + ) + conn.commit() + _, warnings = validate_column_permissions(cur, "warn_demo2") + self.assertTrue(any("readonly=0" in item and "editable=0" in item for item in warnings)) + finally: + conn.close() + + def test_enum_column_without_searchable_warns(self) -> None: + conn = sqlite3.connect(":memory:") + try: + conn.executescript( + """ + CREATE TABLE filter_demo (id INTEGER PRIMARY KEY, enabled INTEGER NOT NULL DEFAULT 1); + """ + ) + from db.display_metadata import ( + init_display_metadata_tables, + upsert_column_metadata, + upsert_table_metadata, + ) + from db.display_metadata_validator import validate_filter_search_metadata + + cur = conn.cursor() + init_display_metadata_tables(cur) + upsert_table_metadata(cur, table_name="filter_demo", display_name="筛选演示", readonly=0) + upsert_column_metadata( + cur, + table_name="filter_demo", + column_name="enabled", + display_name="是否启用", + visible=1, + searchable=0, + editable=1, + display_type="boolean", + options_json='[{"value":0,"label":"停用"},{"value":1,"label":"启用"}]', + ) + conn.commit() + _, warnings = validate_filter_search_metadata(cur, "filter_demo") + self.assertTrue(any("searchable=1" in item for item in warnings)) + finally: + conn.close() + + def test_schema_md_documents_readonly_and_seed_helpers(self) -> None: + path = os.path.join(get_skill_root(), "references", "SCHEMA.md") + with open(path, encoding="utf-8") as f: + text = f.read() + self.assertIn("readonly = 1", text) + self.assertIn("seed_column_metadata_batch", text) + self.assertIn("valid_column_names", text) + + def test_default_visible_columns_json_upsert_and_migrate(self) -> None: + conn = sqlite3.connect(":memory:") + try: + from db.display_metadata import ( + init_display_metadata_tables, + upsert_table_metadata, + ) + + cur = conn.cursor() + # Simulate pre-1.0.79 table without the new column. + cur.execute( + """ + CREATE TABLE _jiangchang_tables ( + table_name TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + description TEXT, + sort_order INTEGER, + visible INTEGER NOT NULL DEFAULT 1, + readonly INTEGER NOT NULL DEFAULT 0, + default_sort_json TEXT + ) + """ + ) + init_display_metadata_tables(cur) + cur.execute("PRAGMA table_info('_jiangchang_tables')") + cols = {str(row[1]) for row in cur.fetchall()} + self.assertIn("default_visible_columns_json", cols) + + payload = '["stat_type","period","stage"]' + upsert_table_metadata( + cur, + table_name="timeliness_stats", + display_name="时效统计", + readonly=1, + default_visible_columns_json=payload, + ) + conn.commit() + cur.execute( + """ + SELECT default_visible_columns_json + FROM _jiangchang_tables + WHERE table_name = 'timeliness_stats' + """ + ) + self.assertEqual(cur.fetchone()[0], payload) + finally: + conn.close() + + def test_grid_help_upsert_and_migrate_from_legacy_schema(self) -> None: + conn = sqlite3.connect(":memory:") + try: + from db.display_metadata import ( + init_display_metadata_tables, + upsert_column_metadata, + upsert_table_metadata, + ) + + cur = conn.cursor() + cur.execute( + """ + CREATE TABLE _jiangchang_tables ( + table_name TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + description TEXT, + sort_order INTEGER, + visible INTEGER NOT NULL DEFAULT 1, + readonly INTEGER NOT NULL DEFAULT 0, + default_sort_json TEXT, + default_visible_columns_json TEXT + ) + """ + ) + cur.execute( + """ + CREATE TABLE _jiangchang_columns ( + table_name TEXT NOT NULL, + column_name TEXT NOT NULL, + display_name TEXT NOT NULL, + description TEXT, + display_order INTEGER, + visible INTEGER NOT NULL DEFAULT 1, + searchable INTEGER NOT NULL DEFAULT 1, + editable INTEGER NOT NULL DEFAULT 1, + display_type TEXT, + options_json TEXT, + PRIMARY KEY (table_name, column_name) + ) + """ + ) + init_display_metadata_tables(cur) + cur.execute("PRAGMA table_info('_jiangchang_tables')") + self.assertIn("grid_help", {str(row[1]) for row in cur.fetchall()}) + cur.execute("PRAGMA table_info('_jiangchang_columns')") + self.assertIn("grid_help", {str(row[1]) for row in cur.fetchall()}) + + upsert_table_metadata( + cur, + table_name="demo_items", + display_name="示例记录", + description="表单备注,不应出现在表格横幅", + grid_help="表格横幅说明", + ) + upsert_column_metadata( + cur, + table_name="demo_items", + column_name="title", + display_name="标题", + description="填标题", + grid_help="列头问号说明", + ) + upsert_table_metadata( + cur, + table_name="empty_help", + display_name="无表格说明", + ) + conn.commit() + cur.execute( + """ + SELECT description, grid_help + FROM _jiangchang_tables + WHERE table_name = 'demo_items' + """ + ) + self.assertEqual(cur.fetchone(), ("表单备注,不应出现在表格横幅", "表格横幅说明")) + cur.execute( + """ + SELECT description, grid_help + FROM _jiangchang_columns + WHERE table_name = 'demo_items' AND column_name = 'title' + """ + ) + self.assertEqual(cur.fetchone(), ("填标题", "列头问号说明")) + cur.execute( + """ + SELECT grid_help FROM _jiangchang_tables + WHERE table_name = 'empty_help' + """ + ) + self.assertTrue(not cur.fetchone()[0]) + finally: + conn.close() + + def test_drop_deprecated_user_table_removes_physical_and_metadata(self) -> None: + conn = sqlite3.connect(":memory:") + try: + cur = conn.cursor() + cur.execute( + """ + CREATE TABLE _jiangchang_tables ( + table_name TEXT PRIMARY KEY, + display_name TEXT, + description TEXT, + sort_order INTEGER, + visible INTEGER, + readonly INTEGER + ) + """ + ) + cur.execute( + """ + CREATE TABLE _jiangchang_columns ( + table_name TEXT, + column_name TEXT, + display_name TEXT, + description TEXT, + display_order INTEGER, + visible INTEGER, + searchable INTEGER, + editable INTEGER, + display_type TEXT, + options_json TEXT, + PRIMARY KEY (table_name, column_name) + ) + """ + ) + cur.execute( + """ + CREATE TABLE task_logs ( + id INTEGER PRIMARY KEY, + status TEXT, + created_at INTEGER, + updated_at INTEGER + ) + """ + ) + cur.execute( + "INSERT INTO _jiangchang_tables VALUES ('task_logs', '任务日志', NULL, 1, 1, 0)" + ) + cur.execute( + "INSERT INTO _jiangchang_columns VALUES " + "('task_logs', 'id', '编号', NULL, 1, 1, 1, 0, 'text', NULL)" + ) + cur.execute("INSERT INTO task_logs (status) VALUES ('success')") + + from db.display_metadata import drop_deprecated_user_table, iter_user_tables + + drop_deprecated_user_table(cur, "task_logs") + cur.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='task_logs'" + ) + self.assertIsNone(cur.fetchone()) + cur.execute( + "SELECT COUNT(*) FROM _jiangchang_tables WHERE table_name='task_logs'" + ) + self.assertEqual(int(cur.fetchone()[0]), 0) + self.assertNotIn("task_logs", list(iter_user_tables(cur))) + finally: + conn.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_docs_standards.py b/tests/test_docs_standards.py new file mode 100644 index 0000000..e8070fe --- /dev/null +++ b/tests/test_docs_standards.py @@ -0,0 +1,737 @@ +# -*- coding: utf-8 -*- +"""文档与 pytest 配置守护:防止模板标准退化。""" +from __future__ import annotations + +import configparser +import os +import re +import unittest + +from _support import get_skill_root + + +FORBIDDEN_README_DESCRIPTION_TERMS_EN = ( + "cli", + "schema", + "references", + "development", + "playwright", + "python", + "dom", + "rpa", +) + +FORBIDDEN_README_DESCRIPTION_TERMS_ZH = ( + "Agent 参考索引", + "契约", + "数据结构", + "开发规范", +) + + +def _description_has_forbidden_term(description: str) -> str | None: + lowered = description.lower() + for term in FORBIDDEN_README_DESCRIPTION_TERMS_EN: + if term in lowered: + return term + for term in FORBIDDEN_README_DESCRIPTION_TERMS_ZH: + if term in description: + return term + return None + + +def _extract_workflow_uses_line(text: str) -> str | None: + for line in text.splitlines(): + stripped = line.strip() + if stripped.startswith("uses:"): + return stripped + return None + + +class TestDocsStandards(unittest.TestCase): + def _read(self, rel_path: str) -> str: + path = os.path.join(get_skill_root(), rel_path) + with open(path, encoding="utf-8") as f: + return f.read() + + def _parse_frontmatter(self, text: str) -> str: + parts = text.split("---", 2) + if len(parts) < 2: + return "" + return parts[1] + + def test_root_readme_has_yaml_frontmatter(self) -> None: + text = self._read("README.md") + self.assertTrue(text.startswith("---"), "README.md must start with YAML frontmatter") + parts = text.split("---", 2) + self.assertGreaterEqual(len(parts), 3, "README.md frontmatter must be closed") + + def test_root_readme_frontmatter_has_description(self) -> None: + frontmatter = self._parse_frontmatter(self._read("README.md")) + self.assertIn("description:", frontmatter, "README.md frontmatter must include description") + + def test_root_readme_description_avoids_technical_index_terms(self) -> None: + frontmatter = self._parse_frontmatter(self._read("README.md")) + match = re.search(r"description:\s*(.+)", frontmatter) + self.assertIsNotNone(match, "README.md description field not found") + description = match.group(1).strip().strip("\"'") + forbidden = _description_has_forbidden_term(description) + self.assertIsNone( + forbidden, + msg=f"README.md description must not contain technical index term {forbidden!r}", + ) + + def test_description_forbidden_terms_are_case_insensitive_for_english(self) -> None: + fail_cases = ( + "CLI 契约说明", + "cli 工具入口", + "SCHEMA 定义", + "Schema 映射", + "schema 字段", + "Playwright 自动化", + "python 脚本", + "RPA 流程", + ) + pass_cases = ( + "用用户能理解的方式说明这个技能能做什么、适合什么场景,以及如何开始使用。", + "帮你自动整理订单并生成处理报告", + "适合每天需要重复核对的业务场景", + ) + for description in fail_cases: + with self.subTest(description=description): + self.assertIsNotNone( + _description_has_forbidden_term(description), + msg=f"expected forbidden term in {description!r}", + ) + for description in pass_cases: + with self.subTest(description=description): + self.assertIsNone( + _description_has_forbidden_term(description), + msg=f"expected no forbidden term in {description!r}", + ) + + def test_references_readme_has_no_yaml_frontmatter(self) -> None: + text = self._read("references/README.md") + self.assertFalse( + text.lstrip().startswith("---"), + "references/README.md must not contain YAML frontmatter", + ) + + def test_references_readme_has_no_description_field(self) -> None: + text = self._read("references/README.md") + self.assertNotRegex( + text, + r"(?m)^description\s*:", + msg="references/README.md must not contain description field", + ) + + def test_skill_md_requires_read_readme_first_rule(self) -> None: + text = self._read("SKILL.md") + self.assertIn("README.md", text) + self.assertTrue( + "优先读取" in text or "优先读" in text, + msg="SKILL.md must require reading README.md first for user-facing questions", + ) + + def test_skill_md_states_readme_is_for_users(self) -> None: + text = self._read("SKILL.md") + self.assertIn("README.md", text) + self.assertTrue( + any(marker in text for marker in ("面向用户", "普通用户", "用户市场")), + msg="SKILL.md must state README.md is user-facing documentation", + ) + + def test_market_tab_docs_exist(self) -> None: + root = get_skill_root() + for name in ("README.md", "TUTORIAL.md", "DEMO.md", "CHANGELOG.md"): + path = os.path.join(root, name) + self.assertTrue(os.path.isfile(path), msg=f"{name} must exist at skill root") + + def test_demo_md_frontmatter_has_demo_video_url(self) -> None: + text = self._read("DEMO.md") + self.assertTrue(text.startswith("---"), "DEMO.md must start with YAML frontmatter") + frontmatter = self._parse_frontmatter(text) + self.assertIn( + "demo_video_url:", + frontmatter, + "DEMO.md frontmatter must include demo_video_url", + ) + + def test_changelog_has_section_for_skill_version(self) -> None: + skill_fm = self._parse_frontmatter(self._read("SKILL.md")) + match = re.search(r"(?m)^version:\s*[\"']?([0-9]+\.[0-9]+\.[0-9]+)", skill_fm) + self.assertIsNotNone(match, "SKILL.md must declare semver version") + version = match.group(1) + changelog = self._read("CHANGELOG.md") + self.assertRegex( + changelog, + rf"(?m)^##\s*\[?v?{re.escape(version)}\]?", + msg=f"CHANGELOG.md must include a ## section for version {version}", + ) + + def test_skill_md_documents_market_four_tabs(self) -> None: + text = self._read("SKILL.md") + for name in ("TUTORIAL.md", "DEMO.md", "CHANGELOG.md", "readme_md", "tutorial_md"): + self.assertIn(name, text, msg=f"SKILL.md must document market material {name}") + + def test_development_md_documents_market_four_tabs(self) -> None: + text = self._read("development/DEVELOPMENT.md") + self.assertIn("TUTORIAL.md", text) + self.assertIn("DEMO.md", text) + self.assertIn("CHANGELOG.md", text) + self.assertIn("demo_video_url", text) + self.assertIn("tutorial_md", text) + self.assertIn("TUTORIAL_SURFACES.md", text) + + def test_tutorial_surfaces_contract_doc_and_checklist(self) -> None: + surfaces = self._read("development/TUTORIAL_SURFACES.md") + self.assertIn("说明", surfaces) + self.assertIn("教程", surfaces) + self.assertIn("skill-detail", surfaces) + checklist = self._read("development/CHECKLIST.md") + self.assertIn("A2-7", checklist) + self.assertIn("A2-8", checklist) + self.assertIn("A2-12", checklist) + self.assertIn("TUTORIAL_SURFACES.md", checklist) + + def test_naming_md_exists_and_covers_standards(self) -> None: + text = self._read("development/NAMING.md") + self.assertIn("verb-noun-platform", text) + self.assertIn("48", text) + self.assertTrue("LEGACY" in text or "历史" in text) + self.assertTrue("verbs.txt" in text or "动词白名单" in text) + self.assertTrue("platforms.txt" in text or "平台白名单" in text) + self.assertTrue("仅适用于新创建" in text or "新技能" in text) + + def test_release_workflow_uses_main_channel(self) -> None: + text = self._read(".github/workflows/release_skill.yaml") + uses_line = _extract_workflow_uses_line(text) + self.assertIsNotNone(uses_line, "release_skill.yaml must contain a uses: line") + self.assertIn( + "client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-release-skill.yaml", + uses_line, + ) + self.assertRegex( + uses_line, + r"reusable-release-skill\.yaml@main\b", + msg="workflow uses must reference reusable-release-skill.yaml@main", + ) + + def test_rpa_md_covers_video_and_playwright_standards(self) -> None: + text = self._read("development/RPA.md") + self.assertIn("title", text) + self.assertIn("closing_title", text) + self.assertIn("中文", text) + self.assertIn("--no-sandbox", text) + self.assertIn("--disable-blink-features=AutomationControlled", text) + self.assertIn("RpaVideoSession", text) + self.assertIn("1.2.0", text) + + def test_rpa_md_forbids_rpa_helpers_import(self) -> None: + text = self._read("development/RPA.md") + self.assertIn("rpa_helpers", text) + self.assertTrue( + "不要 import" in text or "不要" in text and "import" in text, + msg="RPA.md must document forbidding rpa_helpers import", + ) + + def test_rpa_md_covers_simulator_playwright_layering(self) -> None: + text = self._read("development/RPA.md") + self.assertTrue( + "simulator_playwright" in text + or ("薄 adapter" in text and "playwright.py" in text), + msg="RPA.md must describe thin adapter + *_playwright.py layering", + ) + + def test_rpa_md_covers_industry_simulator_portal(self) -> None: + text = self._read("development/RPA.md") + self.assertTrue( + any(marker in text for marker in ("jc2009", "行业仿真", "门户")), + msg="RPA.md must mention jc2009 / industry simulator / portal gate", + ) + + def test_rpa_md_covers_web_profile_headed_and_login_split(self) -> None: + text = self._read("development/RPA.md") + self.assertIn("0.2", text) + self.assertIn("profile_dir", text) + self.assertIn("POLICY-RPA-004", text) + self.assertIn("有头", text) + self.assertTrue( + "登录不全局强制" in text or "登录 ≠ Profile" in text or "登录≠Profile" in text, + msg="RPA.md §0.2 must separate site-login from Profile requirement", + ) + + def test_account_manager_standard_is_discoverable(self) -> None: + account_text = self._read("development/ACCOUNT_MANAGER.md") + for marker in ( + "一账号一", + "profile_dir", + "lease_token", + "数据管理", + "account-manager.db", + "关闭浏览器并释放租约", + "两个以上账号", + ): + self.assertIn(marker, account_text) + for rel in ("SKILL.md", "development/README.md", "development/RPA.md"): + self.assertIn("ACCOUNT_MANAGER.md", self._read(rel), msg=f"{rel} must link account standard") + + def test_simulator_example_readme_covers_async_and_account_manager(self) -> None: + text = self._read("examples/simulator_browser_rpa/README.md") + self.assertIn("async", text.lower()) + self.assertTrue( + "account-manager" in text.lower() or "pick-web" in text or "pick_web_account" in text, + msg="simulator_browser_rpa README must mention async and account-manager", + ) + + def test_development_md_documents_scaffold_and_git_cleanup(self) -> None: + text = self._read("development/DEVELOPMENT.md") + self.assertTrue( + "scaffold_skill" in text or "scaffold_skill.ps1" in text, + msg="DEVELOPMENT.md must document scaffold_skill", + ) + self.assertTrue( + ("Remove-Item" in text and ".git" in text) + or ("删除" in text and ".git" in text), + msg="DEVELOPMENT.md must document deleting .git", + ) + + def test_naming_md_copy_flow_documents_scaffold_or_git_cleanup(self) -> None: + text = self._read("development/NAMING.md") + self.assertTrue( + "scaffold" in text.lower() or ("清理" in text and ".git" in text), + msg="NAMING.md §9 must mention scaffold or Git cleanup", + ) + + def test_scaffold_ps1_exists(self) -> None: + path = os.path.join(get_skill_root(), "tools", "scaffold_skill.ps1") + self.assertTrue(os.path.isfile(path), msg="tools/scaffold_skill.ps1 must exist") + + def test_actions_manifest_and_docs_exist(self) -> None: + root = get_skill_root() + self.assertTrue(os.path.isfile(os.path.join(root, "assets", "actions.json"))) + self.assertTrue(os.path.isfile(os.path.join(root, "references", "ACTIONS.md"))) + self.assertTrue(os.path.isfile(os.path.join(root, "references", "POLICY.md"))) + self.assertTrue(os.path.isfile(os.path.join(root, "references", "CELL_PRESENTATION.md"))) + self.assertTrue(os.path.isfile(os.path.join(root, "references", "WORK_ITEM_STATUS.md"))) + self.assertTrue( + os.path.isfile(os.path.join(root, "assets", "schemas", "skill-actions.schema.json")) + ) + self.assertTrue( + os.path.isfile( + os.path.join(root, "assets", "schemas", "skill-policy-snapshot.schema.json") + ) + ) + self.assertTrue( + os.path.isfile( + os.path.join(root, "assets", "schemas", "skill-cell-presentation.schema.json") + ) + ) + self.assertTrue(os.path.isfile(os.path.join(root, "tests", "test_actions_manifest.py"))) + self.assertTrue(os.path.isfile(os.path.join(root, "tests", "test_data_management_contract.py"))) + + def test_tools_readme_documents_actions_scaffold(self) -> None: + text = self._read("tools/README.md") + self.assertIn("actions.json", text) + self.assertIn("your-skill-slug", text) + self.assertIn("your_skill_slug", text) + + def test_development_md_documents_both_slug_placeholders(self) -> None: + text = self._read("development/DEVELOPMENT.md") + self.assertIn("your-skill-slug", text) + self.assertIn("your_skill_slug", text) + + def test_schema_md_documents_data_management_contract(self) -> None: + text = self._read("references/SCHEMA.md") + for marker in ( + "_jiangchang_tables", + "_jiangchang_columns", + "editable", + "options_json", + "demo_items", + "readonly", + "新增/编辑表单:字段说明与默认值(宿主支持)", + "表单与展示:稳定字段类型(宿主支持,须与 Action 对称)", + "datetime_unix_seconds", + "display_type: \"number\"", + "extra_column", + "{value} · {label}", + "DEFAULT", + "description", + "文件 / 图片上传", + "default_visible_columns_json", + "默认展示列", + "grid_help", + "表格说明 `grid_help`", + "表与表之间:禁止 SQLite 外键", + "禁止 SQLite 外键", + "跨表引用 lookup", + "\"multiple\": true", + "Lookup 多选", + "数据管理:列筛选与全局搜索", + "列筛选与全局搜索", + "searchable: 1", + "筛选栏控件", + "表增删改与退役", + "drop_deprecated_user_table", + "孤儿物理表", + ): + self.assertIn(marker, text, msg=f"SCHEMA.md missing {marker!r}") + + def test_actions_md_documents_form_schema_split(self) -> None: + text = self._read("references/ACTIONS.md") + for marker in ( + "inputSchema 与参数表单", + "表单字段类型对照(须与 CRUD 对称)", + "跨表引用:Lookup", + "display_type: \"lookup\"", + "extra_column", + "multiple", + "数据管理新增/编辑", + "_jiangchang_columns.description", + "datetime_unix_seconds", + "SCHEMA.md", + "字段条件显隐", + "visibleWhen", + "grid_help", + "列筛选", + ): + self.assertIn(marker, text, msg=f"ACTIONS.md missing {marker!r}") + + def test_actions_md_disambiguates_visible_when_from_policy(self) -> None: + actions = self._read("references/ACTIONS.md") + policy = self._read("references/POLICY.md") + self.assertIn("字段条件显隐", actions) + self.assertIn("inputSchema.properties", actions) + self.assertTrue( + "POLICY.md" in actions and "L3" in actions, + msg="ACTIONS.md must point readers away from Policy L3 visibleWhen", + ) + self.assertTrue( + "inputSchema" in policy and "ACTIONS.md" in policy, + msg="POLICY.md must disambiguate L3 visibleWhen from Action form visibleWhen", + ) + + def test_visible_when_example_is_listed(self) -> None: + text = self._read("assets/examples/README.md") + self.assertIn("visible-when-action.example.json", text) + checklist = self._read("development/CHECKLIST.md") + self.assertIn("A4-7", checklist) + self.assertIn("visibleWhen", checklist) + self.assertIn("A4-8", checklist) + self.assertIn("revealInForm", checklist) + runtime = self._read("development/SKILL_ACTION_RUNTIME.md") + self.assertIn("visibleWhen", runtime) + self.assertIn("revealInForm", runtime) + + def test_guarded_docs_do_not_reference_amazon_implementation(self) -> None: + forbidden = ( + "download-settlement-report-amazon", + "amazon_sim", + "#amz-email", + ) + guarded_docs = ( + "development/RPA.md", + "development/ADAPTER.md", + "development/DEVELOPMENT.md", + "development/CONFIG.md", + "development/TESTING.md", + "references/SCHEMA.md", + "references/ACTIONS.md", + "tools/README.md", + "examples/README.md", + "examples/simulator_browser_rpa/README.md", + ) + for rel in guarded_docs: + text = self._read(rel) + for fragment in forbidden: + self.assertNotIn( + fragment, + text, + msg=f"{rel} must not reference Amazon implementation {fragment!r}", + ) + + def test_adapter_md_covers_four_tiers_and_config_get(self) -> None: + text = self._read("development/ADAPTER.md") + for marker in ( + "mock", + "simulator_rpa", + "real_" + "api", + "real_" + "rpa", + "config.get", + "sibling_bridge", + ): + self.assertIn(marker, text, msg=f"ADAPTER.md missing {marker!r}") + self.assertNotIn("ALLOW_" + "REAL_" + "API", text) + self.assertNotIn("ALLOW_WRITE_ACTIONS", text) + + def test_cli_md_requires_shared_python_not_uv_run(self) -> None: + text = self._read("references/CLI.md") + self.assertIn("python-runtime", text) + self.assertIn("python {baseDir}/scripts/main.py", text) + self.assertNotIn("JIANGCHANG_PYTHON_EXE", text) + self.assertIn("禁止", text) + self.assertIn("uv run python", text) + self.assertNotRegex( + text, + r"(?m)^(?!\*\*禁止\*\*).*\*\*建议\*\*.*uv run python", + msg="CLI.md must not recommend uv run for skill scripts", + ) + + def test_cli_md_has_agent_routing_tables(self) -> None: + text = self._read("references/CLI.md") + self.assertIn("短 CLI 白名单", text) + self.assertIn("Action 强制黑名单", text) + self.assertIn("run_skill_action", text) + + def test_skill_md_requires_shared_python_not_uv_run(self) -> None: + text = self._read("SKILL.md") + self.assertIn("python {baseDir}/scripts/main.py", text) + self.assertNotIn("JIANGCHANG_PYTHON_EXE", text) + self.assertIn("uv run python", text) + self.assertIn("禁止", text) + self.assertIn("run_skill_action", text) + self.assertIn("executionProfile", text) + self.assertTrue( + "任务中心" in text and "无关" in text, + msg="SKILL.md must state Task Center is independent of entry point", + ) + + def test_skill_md_has_intent_routing_table(self) -> None: + text = self._read("SKILL.md") + self.assertIn("用户意图路由表", text) + self.assertIn("用户意图", text) + self.assertIn("准备 vs 执行", text) + + def test_skill_md_how_to_forbids_cli_paste(self) -> None: + text = self._read("SKILL.md") + self.assertIn("回答「怎么用", text) + self.assertIn("禁止", text) + self.assertTrue( + "粘贴" in text and "python" in text, + msg="SKILL.md must forbid pasting python CLI when answering how-to", + ) + self.assertTrue( + "dir" in text or "列" in text, + msg="SKILL.md must discourage directory probing for how-to answers", + ) + + def test_actions_md_has_async_side_effect_checklist(self) -> None: + text = self._read("references/ACTIONS.md") + self.assertIn("最小组合", text) + self.assertIn("confirmation.message", text) + self.assertIn("hoverHint", text) + self.assertIn("用户意图路由表", text) + + def test_actions_md_states_profile_not_entry_for_task_center(self) -> None: + text = self._read("references/ACTIONS.md") + self.assertIn("executionProfile", text) + self.assertIn("任务中心", text) + self.assertTrue( + "无关" in text or "orthogonal" in text.lower() or "正交" in text, + msg="ACTIONS.md must keep placements orthogonal to Task Center", + ) + + def test_testing_md_mentions_pytest_txt_collection_guard(self) -> None: + text = self._read("development/TESTING.md") + self.assertIn(".txt", text) + self.assertIn("pytest.ini", text) + + def test_docs_do_not_claim_1_0_11_as_current_standard(self) -> None: + for rel in ( + "SKILL.md", + "development/RUNTIME.md", + "development/RPA.md", + "development/CONFIG.md", + "development/TESTING.md", + ): + text = self._read(rel) + self.assertNotIn( + ">=1.0.11", + text.replace(" ", ""), + msg=f"{rel} still claims 1.0.11 as current minimum", + ) + + def test_scripts_has_no_legacy_example_artifacts(self) -> None: + scripts_dir = os.path.join(get_skill_root(), "scripts") + service_dir = os.path.join(scripts_dir, "service") + self.assertFalse( + os.path.isfile(os.path.join(service_dir, "example_browser_rpa.py")), + "scripts/service/example_browser_rpa.py must not exist", + ) + self.assertFalse( + os.path.isdir(os.path.join(service_dir, "example_adapter")), + "scripts/service/example_adapter/ must not exist", + ) + + def test_gitignore_covers_python_runtime_artifacts(self) -> None: + text = self._read(".gitignore") + self.assertIn("__pycache__", text) + self.assertIn(".pytest_cache", text) + self.assertTrue( + "py[cod]" in text or ".pyc" in text, + msg=".gitignore must ignore Python bytecode (e.g. *.py[cod])", + ) + + def test_scripts_runtime_artifacts_not_tracked_by_git(self) -> None: + import subprocess + + try: + out = subprocess.run( + ["git", "ls-files", "scripts"], + cwd=get_skill_root(), + capture_output=True, + text=True, + check=True, + ) + except (FileNotFoundError, subprocess.CalledProcessError): + self.skipTest("git not available or not a git repo") + tracked = out.stdout.splitlines() + for rel in tracked: + lowered = rel.replace("\\", "/").lower() + self.assertNotIn("__pycache__", lowered, msg=f"tracked cache path: {rel}") + self.assertNotIn(".pytest_cache", lowered, msg=f"tracked cache path: {rel}") + self.assertFalse(lowered.endswith(".pyc"), msg=f"tracked .pyc file: {rel}") + + def test_examples_top_level_modes_present(self) -> None: + examples_dir = os.path.join(get_skill_root(), "examples") + for mode in ( + "real_" + "api", + "real_browser_rpa", + "simulator_api", + "simulator_browser_rpa", + "real_desktop_rpa", + "mobile_rpa", + ): + self.assertTrue( + os.path.isdir(os.path.join(examples_dir, mode)), + msg=f"examples/{mode}/ must exist", + ) + + def test_three_surface_locator_standards_are_present(self) -> None: + required = { + "development/RPA_ELEMENT_LOCATING.md": ( + "元素清单", + "expected_count", + "ERROR:RESULT_UNCONFIRMED", + "AI 编程约束", + ), + "development/RPA_BROWSER.md": ( + "Pick Locator", + "iframe", + "Shadow DOM", + "禁止模式", + ), + "development/RPA_DESKTOP.md": ( + "uiautomation", + "add-friend-wecom", + "AutomationId", + "probe-ui", + ), + "development/RPA_MOBILE.md": ( + "Appium Inspector", + "resourceId", + "Native/WebView", + "尚无团队真实成功案例", + ), + } + for rel, markers in required.items(): + text = self._read(rel) + for marker in markers: + self.assertIn(marker, text, msg=f"{rel} missing {marker!r}") + + def test_desktop_and_mobile_examples_state_validation_level(self) -> None: + desktop = self._read("examples/real_desktop_rpa/README.md") + self.assertIn("技术路线已真实验证", desktop) + self.assertIn("add-friend-wecom", desktop) + self.assertIn("dry-run", desktop) + mobile = self._read("examples/mobile_rpa/README.md") + self.assertIn("不是可复制实现", mobile) + self.assertIn("不得宣称已跑通", mobile) + + def test_examples_are_not_full_skill_repos(self) -> None: + examples_dir = os.path.join(get_skill_root(), "examples") + for mode in os.listdir(examples_dir): + mode_path = os.path.join(examples_dir, mode) + if not os.path.isdir(mode_path): + continue + self.assertFalse( + os.path.isfile(os.path.join(mode_path, "SKILL.md")), + msg=f"examples/{mode}/SKILL.md must not exist", + ) + self.assertFalse( + os.path.isdir(os.path.join(mode_path, "references")), + msg=f"examples/{mode}/references/ must not exist", + ) + self.assertFalse( + os.path.isdir(os.path.join(mode_path, "development")), + msg=f"examples/{mode}/development/ must not exist", + ) + self.assertFalse( + os.path.isdir(os.path.join(mode_path, ".github")), + msg=f"examples/{mode}/.github/ must not exist", + ) + + def test_examples_readme_states_copy_boundary(self) -> None: + text = self._read("examples/README.md") + self.assertIn("不是完整 skill 仓库", text) + self.assertIn("核心实现案例", text) + self.assertTrue( + "不要整包照搬" in text or "不要整包" in text, + msg="examples/README.md must warn against copying whole example tree", + ) + self.assertTrue( + "scripts" in text and ("复制" in text or "copy map" in text.lower()), + msg="examples/README.md must describe copying into scripts/", + ) + + def test_placeholder_api_examples_are_not_implementation_references(self) -> None: + for rel in (f"examples/{'real_' + 'api'}/README.md", "examples/simulator_api/README.md"): + text = self._read(rel) + self.assertTrue( + "占位" in text or "规划" in text, + msg=f"{rel} must state placeholder/planned status", + ) + self.assertTrue( + "尚未沉淀" in text or "不可作为实现参考" in text or "不可" in text, + msg=f"{rel} must warn not to use as implementation reference", + ) + + def test_docs_do_not_reference_scripts_legacy_examples(self) -> None: + forbidden_fragments = ( + "scripts/service/example_browser_rpa", + "scripts/service/example_adapter", + "example_browser_rpa.py", + "example_adapter/", + ) + doc_paths = [] + for root, _dirs, files in os.walk(get_skill_root()): + rel_root = os.path.relpath(root, get_skill_root()) + if rel_root.startswith(".git"): + continue + for name in files: + if name.endswith(".md"): + doc_paths.append(os.path.join(rel_root, name).replace("\\", "/")) + for rel in doc_paths: + text = self._read(rel) + for fragment in forbidden_fragments: + self.assertNotIn( + fragment, + text, + msg=f"{rel} must not reference legacy scripts example path {fragment!r}", + ) + + +class TestPytestIniCollection(unittest.TestCase): + def test_pytest_ini_exists_and_limits_python_files(self) -> None: + ini_path = os.path.join(get_skill_root(), "pytest.ini") + self.assertTrue(os.path.isfile(ini_path), "pytest.ini must exist at repo root") + parser = configparser.ConfigParser() + parser.read(ini_path, encoding="utf-8") + python_files = parser.get("pytest", "python_files", fallback="") + self.assertIn("test_*.py", python_files) + self.assertIn("*_test.py", python_files) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_draft_rules.py b/tests/test_draft_rules.py new file mode 100644 index 0000000..b9de159 --- /dev/null +++ b/tests/test_draft_rules.py @@ -0,0 +1,196 @@ +# -*- coding: utf-8 -*- +"""结题初稿内核金标准用例(离线、确定性;docx 用 zipfile 断言内容)。""" +from __future__ import annotations + +import json +import unittest +import zipfile + +from _support import IsolatedDataRoot + + +def _project(**overrides) -> dict: + base = { + "projectNo": "NSFC2026003", + "projectName": "面向能源转化的材料设计", + "piName": "王教授", + "department": "化材学院", + "program": "国家自然科学基金青年项目", + "startDate": "2024-01-01", + "endDate": "2026-12-31", + "expected": [ + {"type": "论文", "target": 3, "unit": "篇"}, + {"type": "专利", "target": 1, "unit": "项"}, + {"type": "人才培养", "target": 2, "unit": "名"}, + ], + "annualReportExcerpt": "完成界面改性工艺探索;发表高水平论文两篇。", + "status": "待结题", + } + base.update(overrides) + return base + + +def _achievements() -> list[dict]: + return [ + {"projectNo": "NSFC2026003", "achievementType": "论文", "title": "界面调控研究A", "venueOrNo": "Applied Energy", "achDate": "2025-06-01", "isRepresentative": "1"}, + {"projectNo": "NSFC2026003", "achievementType": "论文", "title": "界面调控研究B", "venueOrNo": "J. Mater. Chem. A", "achDate": "2025-11-01", "isRepresentative": "1"}, + {"projectNo": "NSFC2026003", "achievementType": "论文", "title": "界面调控研究C", "venueOrNo": "", "achDate": "2026-03-01", "isRepresentative": "0"}, + {"projectNo": "NSFC2026003", "achievementType": "论文", "title": "界面调控研究D", "venueOrNo": "", "achDate": "2026-05-01", "isRepresentative": "0"}, + {"projectNo": "NSFC2026003", "achievementType": "专利", "title": "一种界面改性方法", "venueOrNo": "ZL2025XXXX", "achDate": "2026-01-01", "isRepresentative": "0"}, + {"projectNo": "NSFC2026003", "achievementType": "人才培养", "title": "硕士研究生1名毕业", "venueOrNo": "", "achDate": "2026-06-30", "isRepresentative": "0"}, + ] + + +def _expenses() -> list[dict]: + return [ + {"projectNo": "NSFC2026003", "category": "差旅费", "amount": 23000.0, "occurredDate": "2025-04-10", "description": "赴外地调研"}, + {"projectNo": "NSFC2026003", "category": "差旅费", "amount": 10000.0, "occurredDate": "2025-09-01", "description": "学术会议"}, + {"projectNo": "NSFC2026003", "category": "设备费", "amount": 200000.0, "occurredDate": "2024-12-01", "description": "测试设备"}, + {"projectNo": "NSFC2026003", "category": "劳务费", "amount": 40000.0, "occurredDate": "2026-06-01", "description": "研究生劳务"}, + ] + + +class DraftKernelTestBase(unittest.TestCase): + def setUp(self) -> None: + self._root = IsolatedDataRoot() + self._root.__enter__() + from db.connection import init_db + + init_db() + + def tearDown(self) -> None: + self._root.__exit__(None, None, None) + + def _kernel(self, expected=None, achievements=None, expenses=None): + from service.draft_service import build_draft_sections + + project = _project() + if expected is not None: + project["expected_json"] = json.dumps(expected, ensure_ascii=False) + return build_draft_sections(project, achievements if achievements is not None else _achievements(), expenses if expenses is not None else _expenses()) + + +class TestC1Completion(DraftKernelTestBase): + def test_over_and_equal(self) -> None: + result = self._kernel() + by_type = {row["type"]: row for row in result["completion"]} + self.assertEqual(by_type["论文"]["verdict"], "超额完成") # 4 > 3 + self.assertEqual(by_type["专利"]["verdict"], "持平") # 1 == 1 + + def test_below_target(self) -> None: + result = self._kernel() + by_type = {row["type"]: row for row in result["completion"]} + self.assertEqual(by_type["人才培养"]["verdict"], "未达标") # 1 < 2 + self.assertIn("预期 2 名,实际 1 名", by_type["人才培养"]["detail"]) + + def test_missing_type_is_not_met_with_zero(self) -> None: + result = self._kernel() + by_type = {row["type"]: row for row in result["completion"]} + self.assertEqual(by_type["专利"]["actual"], 1) + + def test_missing_target_is_pending(self) -> None: + result = self._kernel(expected=[{"type": "论文", "unit": "篇"}]) + self.assertEqual(result["completion"][0]["verdict"], "数据待补") + + +class TestC3Expenses(DraftKernelTestBase): + def test_ratio_sums_to_one_and_sentences(self) -> None: + result = self._kernel() + total_ratio = round(sum(row["ratio"] for row in result["expense_rows"]), 4) + self.assertEqual(total_ratio, 1.0) + top = result["expense_rows"][0] + self.assertEqual(top["category"], "设备费") # 金额最大排前 + self.assertIn("购置/使用科研设备", top["sentence"]) + + def test_empty_expenses_placeholder(self) -> None: + result = self._kernel(expenses=[]) + self.assertIn("尚未录入", result["expense_paragraph"]) + + +class TestC4AndC5(DraftKernelTestBase): + def test_narrative_points_with_source(self) -> None: + result = self._kernel() + sources = {p["source"] for p in result["narrative_points"]} + self.assertIn("成果清单", sources) + self.assertIn("年度报告", sources) + + def test_sections_order_and_placeholders(self) -> None: + result = self._kernel() + titles = list(result["sections"].keys()) + self.assertEqual(titles[:3], ["1. 研究背景与目标", "2. 研究内容与进展", "3. 预期成果完成情况"]) + self.assertIn("来源:成果清单", "\n".join(result["sections"]["5. 成果清单"])) + self.assertTrue(any(p.startswith("[待补充") for p in result["sections"]["1. 研究背景与目标"])) + + def test_w1_empty_ledger_does_not_block(self) -> None: + result = self._kernel(achievements=[], expenses=[]) + self.assertIn("尚未录入", "\n".join(result["sections"]["5. 成果清单"])) + self.assertIn("尚未录入", result["expense_paragraph"]) + + +class TestDraftGeneration(unittest.TestCase): + def setUp(self) -> None: + self._root = IsolatedDataRoot() + self._root.__enter__() + from db.connection import init_db + from service.ledger_service import import_expenses, import_achievements, import_projects, upsert_project + + init_db() + upsert_project(_project()) + for ach in _achievements(): + from service.ledger_service import upsert_achievement + + upsert_achievement(ach) + for expense in _expenses(): + from service.ledger_service import upsert_expense + + upsert_expense(expense) + + def tearDown(self) -> None: + self._root.__exit__(None, None, None) + + def _draft(self, project_no="NSFC2026003"): + from service.draft_service import query_pending_projects, run_draft + from service.ledger_service import list_achievements, list_expenses + + projects = query_pending_projects(project_no=project_no) + assert projects, "project should exist" + return run_draft(projects[0], list_achievements(project_no), list_expenses(project_no)) + + def test_docx_generated_with_sections(self) -> None: + result = self._draft() + self.assertTrue(result["draft_file"].endswith(".docx")) + with zipfile.ZipFile(result["draft_file"]) as zf: + xml = zf.read("word/document.xml").decode("utf-8") + self.assertIn("结题报告(初稿)", xml) + self.assertIn("研究背景与目标", xml) + self.assertIn("经费决算说明", xml) + self.assertIn("来源:成果清单", xml) + self.assertIn("待补充", xml) + + def test_preview_and_status_update(self) -> None: + result = self._draft() + self.assertTrue(result["preview_file"].endswith(".html")) + from service.ledger_service import list_projects + + row = next(p for p in list_projects() if p["project_no"] == "NSFC2026003") + self.assertEqual(row["status"], "初稿已生成") + + def test_batch_pending(self) -> None: + from service.draft_service import query_pending_projects, run_draft + + for project in query_pending_projects(): + run_draft(project, [], []) + from service.ledger_service import list_drafts, stats + + payload = stats() + self.assertEqual(payload["draft_total"], 1) + self.assertEqual(len(list_drafts()), 1) + + def test_project_not_found(self) -> None: + from service.draft_service import query_pending_projects + + self.assertEqual(query_pending_projects(project_no="NOPE0001"), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_entrypoint_subprocess.py b/tests/test_entrypoint_subprocess.py new file mode 100644 index 0000000..eb54057 --- /dev/null +++ b/tests/test_entrypoint_subprocess.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +"""通过 subprocess 调用 ``python scripts/main.py``,验证真实入口(非仅 import main)。""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +import unittest + +from _support import IsolatedDataRoot, get_skill_root + +import importlib.metadata as metadata + +from jiangchang_skill_core import version_ge +from util.constants import PLATFORM_KIT_MIN_VERSION + + +def _platform_kit_meets_template_minimum() -> bool: + try: + installed = metadata.version("jiangchang-platform-kit") + except metadata.PackageNotFoundError: + return False + return version_ge(installed, PLATFORM_KIT_MIN_VERSION) + + +def _run_main(args: list[str], timeout: int = 15) -> subprocess.CompletedProcess[str]: + root = get_skill_root() + main_py = os.path.join(root, "scripts", "main.py") + env = os.environ.copy() + env.setdefault("PYTHONIOENCODING", "utf-8") + env["PYTHONDONTWRITEBYTECODE"] = "1" + return subprocess.run( + [sys.executable, main_py, *args], + cwd=root, + env=env, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=timeout, + ) + + +class TestEntrypointSubprocess(unittest.TestCase): + @unittest.skipUnless( + _platform_kit_meets_template_minimum(), + f"installed jiangchang-platform-kit below template minimum {PLATFORM_KIT_MIN_VERSION}", + ) + def test_health_exit_zero(self) -> None: + with IsolatedDataRoot(): + cp = _run_main(["health"]) + self.assertEqual(cp.returncode, 0, msg=cp.stderr + cp.stdout) + + def test_version_json_and_skill_slug(self) -> None: + with IsolatedDataRoot(): + cp = _run_main(["version"]) + self.assertEqual(cp.returncode, 0, msg=cp.stderr + cp.stdout) + payload = json.loads(cp.stdout.strip()) + self.assertIn("version", payload) + self.assertIn("skill", payload) + from util.constants import SKILL_SLUG + + self.assertEqual(payload["skill"], SKILL_SLUG) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_health_runtime.py b/tests/test_health_runtime.py new file mode 100644 index 0000000..ceae90b --- /dev/null +++ b/tests/test_health_runtime.py @@ -0,0 +1,178 @@ +# -*- coding: utf-8 -*- +"""health / runtime 诊断:验证委托 platform-kit 公共 API。""" +from __future__ import annotations + +import io +import os +import unittest +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path +from unittest.mock import patch + +from _support import IsolatedDataRoot, get_skill_root, platform_kit_version_patch + +import jiangchang_skill_core +from cli.app import main +from jiangchang_skill_core import ( + collect_runtime_diagnostics, + format_runtime_health_lines, + is_jiangchang_skill_core_from_skill_tree, + runtime_diagnostics_dict, +) +from util.config_bootstrap import bootstrap_skill_config +from util.constants import PLATFORM_KIT_MIN_VERSION, SKILL_SLUG +from util.runtime_paths import get_skill_root as skill_root_path + + +def _collect_diag(): + return collect_runtime_diagnostics( + skill_slug=SKILL_SLUG, + platform_kit_min_version=PLATFORM_KIT_MIN_VERSION, + skill_root=skill_root_path(), + ) + + +class TestHealthRuntimeDiagnostics(unittest.TestCase): + def test_collect_runtime_diagnostics_fields(self) -> None: + with IsolatedDataRoot(user_id="_health_rt"): + os.environ["OPENCLAW_RECORD_VIDEO"] = "0" + diag = _collect_diag() + payload = runtime_diagnostics_dict(diag) + for key in ( + "skill_slug", + "python_executable", + "platform_kit_version", + "jiangchang_skill_core_file", + "resolved_data_root", + "media_assets_root", + "ffmpeg_available", + "background_music_mp3_count", + "runtime_issues", + ): + self.assertIn(key, payload) + self.assertEqual(diag.skill_slug, SKILL_SLUG) + self.assertEqual(diag.platform_kit_min_version, PLATFORM_KIT_MIN_VERSION) + + def test_format_runtime_health_lines_contains_core_fields(self) -> None: + diag = _collect_diag() + text = "\n".join(format_runtime_health_lines(diag)) + self.assertIn("skill_slug:", text) + self.assertIn("python_executable:", text) + self.assertIn("platform_kit_version:", text) + self.assertIn("jiangchang_skill_core_file:", text) + self.assertIn("media_assets_root:", text) + self.assertIn("background_music_mp3_count:", text) + + def test_health_cli_prints_runtime_diagnostics(self) -> None: + with IsolatedDataRoot(user_id="_health_cli"): + os.environ["OPENCLAW_RECORD_VIDEO"] = "0" + bootstrap_skill_config() + buf = io.StringIO() + with platform_kit_version_patch(), redirect_stdout(buf), redirect_stderr(io.StringIO()): + rc = main(["health"]) + out = buf.getvalue() + self.assertEqual(rc, 0, msg=out) + self.assertIn("python_executable:", out) + self.assertIn("platform_kit_version:", out) + self.assertIn("jiangchang_skill_core_file:", out) + self.assertIn("media_assets_root:", out) + self.assertIn("background_music_mp3_count:", out) + self.assertIn("env_path:", out) + self.assertIn("example_path:", out) + + def test_health_fails_when_platform_kit_missing(self) -> None: + with IsolatedDataRoot(user_id="_health_no_pkg"): + os.environ["OPENCLAW_RECORD_VIDEO"] = "0" + bootstrap_skill_config() + with patch( + "jiangchang_skill_core.runtime_diagnostics._platform_kit_version", + return_value=None, + ): + buf = io.StringIO() + with redirect_stdout(buf), redirect_stderr(io.StringIO()): + rc = main(["health"]) + self.assertEqual(rc, 1) + self.assertIn("platform_kit_not_installed", buf.getvalue()) + + def test_background_music_issue_is_warning_not_fatal(self) -> None: + from jiangchang_skill_core.media_assets import MediaAssetsStatus + + with IsolatedDataRoot(user_id="_health_music_warn"): + os.environ["OPENCLAW_RECORD_VIDEO"] = "1" + media_root = Path(os.environ["JIANGCHANG_DATA_ROOT"]) / "shared" / "media-assets" + ffmpeg_path = media_root / "tools" / "ffmpeg.exe" + ffmpeg_path.parent.mkdir(parents=True, exist_ok=True) + ffmpeg_path.write_bytes(b"x" * 256) + status = MediaAssetsStatus( + root=media_root, + exists=True, + ready=False, + source="local", + warnings=["music_dir_missing"], + ffmpeg_path=ffmpeg_path, + music_dir=None, + ) + music_probe = { + "music_root": None, + "mp3_count": 0, + "usable_count": 0, + "sample_path": None, + "issue": "music_dir_missing", + } + with patch( + "jiangchang_skill_core.runtime_diagnostics.probe_media_assets", + return_value=status, + ), patch( + "jiangchang_skill_core.runtime_diagnostics.probe_background_music", + return_value=music_probe, + ), platform_kit_version_patch(): + diag = _collect_diag() + music_issues = [i for i in diag.issues if i.code == "background_music_unavailable"] + self.assertTrue(music_issues) + self.assertEqual(music_issues[0].severity, "warning") + self.assertFalse(diag.has_fatal_issues) + + def test_skill_tree_core_load_detected_as_issue(self) -> None: + skill_root = get_skill_root() + fake_core = os.path.join( + skill_root, "scripts", "fake_pkg", "jiangchang_skill_core", "__init__.py" + ) + self.assertTrue( + is_jiangchang_skill_core_from_skill_tree( + skill_root=skill_root, core_file=fake_core + ) + ) + self.assertFalse( + is_jiangchang_skill_core_from_skill_tree( + skill_root=skill_root, + core_file=jiangchang_skill_core.__file__, + ) + ) + + def test_skill_tree_core_load_issue_in_diagnostics(self) -> None: + skill_root = get_skill_root() + fake_core = os.path.join(skill_root, "scripts", "jiangchang_skill_core", "__init__.py") + with patch.object(jiangchang_skill_core, "__file__", fake_core): + diag = _collect_diag() + codes = diag.issue_codes() + self.assertIn("jiangchang_skill_core_loaded_from_skill_tree", codes) + messages = [issue.message for issue in diag.issues] + self.assertTrue(any("skill tree" in msg.lower() for msg in messages)) + + +class TestHealthRuntimeWithFakeMedia(unittest.TestCase): + def test_probe_counts_mp3_without_download(self) -> None: + with IsolatedDataRoot(user_id="_health_media"): + media_root = Path(os.environ["JIANGCHANG_DATA_ROOT"]) / "shared" / "media-assets" + music_dir = media_root / "music" + music_dir.mkdir(parents=True) + (music_dir / "demo.mp3").write_bytes(b"\x00" * 1024) + os.environ["OPENCLAW_RECORD_VIDEO"] = "0" + diag = _collect_diag() + self.assertGreaterEqual(diag.background_music_mp3_count, 1) + self.assertGreaterEqual(diag.background_music_usable_count, 1) + self.assertIsNone(diag.background_music_issue) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_html_page_service.py b/tests/test_html_page_service.py new file mode 100644 index 0000000..06100d3 --- /dev/null +++ b/tests/test_html_page_service.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +"""HTML 报告页薄写入 helper。""" + +from __future__ import annotations + +import os +import unittest + +from _support import IsolatedDataRoot + +from service.html_page_service import ( + HOST_HTML_RENDER_ACTION_ID, + load_html_template, + render_html_placeholders, + write_html_page, +) + + +class TestHtmlPageService(unittest.TestCase): + def test_host_action_id_is_stable(self) -> None: + self.assertEqual(HOST_HTML_RENDER_ACTION_ID, "render-html") + + def test_write_html_page_atomic_under_html_dir(self) -> None: + with IsolatedDataRoot() as tmp: + path = write_html_page( + "example-report.html", + "<!DOCTYPE html><html><head><title>示例ok", + ) + self.assertTrue(os.path.isfile(path)) + norm = path.replace("\\", "/") + self.assertIn("/html/example-report.html", norm) + self.assertTrue(os.path.normcase(os.path.normpath(path)).startswith( + os.path.normcase(os.path.normpath(tmp)), + )) + self.assertFalse(os.path.isfile(path + ".tmp")) + with open(path, encoding="utf-8") as f: + self.assertIn("示例", f.read()) + + def test_load_and_fill_example_template(self) -> None: + with IsolatedDataRoot(): + template = load_html_template("assets/html/example-report.html") + page = render_html_placeholders( + template, + { + "TITLE": "示例报告", + "GENERATED_AT": "2026-08-14 15:49:00", + "BODY": "

hello

", + }, + ) + self.assertIn("示例报告", page) + self.assertIn("报告生成于 · 2026-08-14 15:49:00", page) + self.assertIn("

hello

", page) + self.assertNotIn("{{TITLE}}", page) + dest = write_html_page("example-report.html", page) + self.assertTrue(os.path.isfile(dest)) + + def test_rejects_unsafe_filename(self) -> None: + with IsolatedDataRoot(): + with self.assertRaises(ValueError): + write_html_page("../x.html", "") + with self.assertRaises(ValueError): + write_html_page("note.txt", "") + + def test_example_report_shell_embedded_preview_layout(self) -> None: + """守护 example-report.html:数据管理 iframe 预览布局基线不退化。""" + template = load_html_template("assets/html/example-report.html") + self.assertIn("overflow-x: hidden", template) + self.assertIn("min-width: 0", template) + self.assertIn("table-layout: fixed", template) + self.assertIn("overflow-wrap: anywhere", template) + self.assertNotIn("max-width:", template) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_no_rpa_helpers_import.py b/tests/test_no_rpa_helpers_import.py new file mode 100644 index 0000000..3707bf4 --- /dev/null +++ b/tests/test_no_rpa_helpers_import.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +"""禁止在模板主骨架 scripts/ 中 import account-manager 内部 RPA 辅助模块。""" +from __future__ import annotations + +import os +import re +import unittest + +from _support import get_skill_root + +FORBIDDEN_PATTERNS = ( + re.compile(r"\brpa_helpers\b"), + re.compile(r"\binject_account_manager_scripts_path\b"), + re.compile(r"\bget_account_credential\b"), +) + + +class TestNoRpaHelpersImport(unittest.TestCase): + def test_scripts_tree_has_no_forbidden_account_manager_imports(self) -> None: + scripts_dir = os.path.join(get_skill_root(), "scripts") + violations: list[str] = [] + for root, _dirs, files in os.walk(scripts_dir): + for name in files: + if not name.endswith(".py"): + continue + rel = os.path.relpath(os.path.join(root, name), scripts_dir) + text = open(os.path.join(root, name), encoding="utf-8").read() + for pattern in FORBIDDEN_PATTERNS: + if pattern.search(text): + violations.append(f"{rel}: {pattern.pattern}") + self.assertEqual( + violations, + [], + msg="Forbidden account-manager internal imports in scripts/:\n" + "\n".join(violations), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_platform_import.py b/tests/test_platform_import.py new file mode 100644 index 0000000..05a3203 --- /dev/null +++ b/tests/test_platform_import.py @@ -0,0 +1,136 @@ +# -*- coding: utf-8 -*- +"""防回归:公共能力必须从共享 platform-kit 导入,而非技能 vendored 副本。""" +from __future__ import annotations + +import importlib.metadata as metadata +import os +import re +import sys +import unittest + +from _support import get_scripts_dir, get_skill_root + + +def _parse_platform_kit_min_version(skill_md_text: str) -> str: + parts = skill_md_text.split("---", 2) + if len(parts) < 2: + raise ValueError("missing frontmatter") + m = re.search( + r"platform_kit_min_version:\s*[\"']?([^\"'\s]+)[\"']?", + parts[1], + ) + if not m: + raise ValueError("platform_kit_min_version not found") + return m.group(1).strip() + + +def _requirement_dependency_lines(req_text: str) -> list[str]: + return [ + line.strip() + for line in req_text.splitlines() + if line.strip() and not line.strip().startswith("#") + ] + + +class TestPlatformImportSource(unittest.TestCase): + def test_jiangchang_skill_core_not_from_skill_scripts(self) -> None: + scripts_dir = os.path.abspath(get_scripts_dir()) + skill_root = os.path.abspath(get_skill_root()) + vendored = os.path.join(scripts_dir, "jiangchang_skill_core") + + self.assertFalse( + os.path.isdir(vendored), + f"vendored package must not exist: {vendored}", + ) + + import jiangchang_skill_core + import jiangchang_skill_core.rpa.video_session as video_session + + core_file = os.path.abspath(jiangchang_skill_core.__file__) + video_file = os.path.abspath(video_session.__file__) + + self.assertNotIn( + scripts_dir.lower(), + core_file.lower(), + msg=f"jiangchang_skill_core loaded from skill scripts: {core_file}", + ) + self.assertNotIn( + skill_root.lower(), + core_file.lower(), + msg=f"jiangchang_skill_core loaded from skill tree: {core_file}", + ) + self.assertNotIn( + scripts_dir.lower(), + video_file.lower(), + msg=f"video_session loaded from skill scripts: {video_file}", + ) + + from jiangchang_skill_core.rpa.video_session import RpaVideoSession + + self.assertTrue(callable(RpaVideoSession)) + + def test_skill_tree_load_issue_helper(self) -> None: + from jiangchang_skill_core import is_jiangchang_skill_core_from_skill_tree + + skill_root = os.path.abspath(get_skill_root()) + fake = os.path.join(skill_root, "scripts", "jiangchang_skill_core", "__init__.py") + self.assertTrue( + is_jiangchang_skill_core_from_skill_tree(skill_root=skill_root, core_file=fake) + ) + self.assertFalse( + is_jiangchang_skill_core_from_skill_tree( + skill_root=skill_root, core_file=sys.executable + ) + ) + + def test_platform_kit_min_version_is_at_least_declared(self) -> None: + from jiangchang_skill_core import version_ge + from util.constants import PLATFORM_KIT_MIN_VERSION + + self.assertEqual(PLATFORM_KIT_MIN_VERSION, "1.2.11") + + md_path = os.path.join(get_skill_root(), "SKILL.md") + with open(md_path, encoding="utf-8") as f: + md = f.read() + self.assertEqual(_parse_platform_kit_min_version(md), "1.2.11") + + req_path = os.path.join(get_skill_root(), "requirements.txt") + with open(req_path, encoding="utf-8") as f: + req = f.read() + dep_lines = _requirement_dependency_lines(req) + dep_body = "\n".join(dep_lines).lower() + self.assertNotIn("jiangchang-platform-kit", dep_body) + self.assertNotIn("playwright", dep_body) + + try: + installed = metadata.version("jiangchang-platform-kit") + except metadata.PackageNotFoundError: + self.skipTest("jiangchang-platform-kit not installed in current interpreter") + if not version_ge(installed, PLATFORM_KIT_MIN_VERSION): + self.skipTest( + f"installed jiangchang-platform-kit {installed!r} < required " + f"{PLATFORM_KIT_MIN_VERSION!r}; upgrade shared runtime to enforce this check" + ) + self.assertTrue( + version_ge(installed, PLATFORM_KIT_MIN_VERSION), + msg=f"installed {installed!r} < required {PLATFORM_KIT_MIN_VERSION!r}", + ) + + def test_collect_runtime_diagnostics_importable(self) -> None: + from jiangchang_skill_core import collect_runtime_diagnostics + + self.assertTrue(callable(collect_runtime_diagnostics)) + + def test_main_import_smoke(self) -> None: + scripts_dir = get_scripts_dir() + if scripts_dir not in sys.path: + sys.path.insert(0, scripts_dir) + + from cli.app import build_parser + + parser = build_parser() + self.assertIsNotNone(parser) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_release_packaging_constraints.py b/tests/test_release_packaging_constraints.py new file mode 100644 index 0000000..93c4a4d --- /dev/null +++ b/tests/test_release_packaging_constraints.py @@ -0,0 +1,165 @@ +# -*- coding: utf-8 -*- +"""发布打包守护:PyArmor 单文件行数与 UTF-8 without BOM。""" +from __future__ import annotations + +import os +import unittest + +from _support import get_skill_root + +SCRIPTS_MAX_LINES = 1000 +SCRIPTS_RECOMMENDED_MAX_LINES = 900 + +TEXT_EXTENSIONS = frozenset( + { + ".py", + ".md", + ".yml", + ".yaml", + ".json", + ".toml", + ".ini", + ".txt", + ".ps1", + ".sh", + } +) + +SPECIAL_TEXT_FILES = frozenset({".env.example", ".gitignore"}) + +SKIP_DIR_NAMES = frozenset( + { + ".git", + "__pycache__", + ".pytest_cache", + "rpa-artifacts", + "videos", + } +) + +BINARY_EXTENSIONS = frozenset( + { + ".db", + ".sqlite", + ".png", + ".jpg", + ".jpeg", + ".gif", + ".webp", + ".mp4", + ".zip", + ".tar", + ".gz", + } +) + + +def _is_text_candidate(path: str) -> bool: + basename = os.path.basename(path) + if basename in SPECIAL_TEXT_FILES: + return True + ext = os.path.splitext(basename)[1].lower() + if ext in BINARY_EXTENSIONS: + return False + return ext in TEXT_EXTENSIONS + + +def _iter_text_files(skill_root: str) -> list[str]: + found: list[str] = [] + for root, dirnames, filenames in os.walk(skill_root): + dirnames[:] = [d for d in dirnames if d not in SKIP_DIR_NAMES] + for name in filenames: + full = os.path.join(root, name) + if _is_text_candidate(full): + found.append(full) + return sorted(found) + + +def _iter_scripts_py_files(skill_root: str) -> list[str]: + scripts_dir = os.path.join(skill_root, "scripts") + found: list[str] = [] + if not os.path.isdir(scripts_dir): + return found + for root, dirnames, filenames in os.walk(scripts_dir): + dirnames[:] = [d for d in dirnames if d not in SKIP_DIR_NAMES] + for name in filenames: + if name.endswith(".py"): + found.append(os.path.join(root, name)) + return sorted(found) + + +class TestReleasePackagingConstraints(unittest.TestCase): + def test_scripts_py_files_under_pyarmor_line_limit(self) -> None: + skill_root = get_skill_root() + offenders: list[str] = [] + for path in _iter_scripts_py_files(skill_root): + with open(path, encoding="utf-8") as f: + line_count = sum(1 for _ in f) + if line_count >= SCRIPTS_MAX_LINES: + rel = os.path.relpath(path, skill_root).replace("\\", "/") + offenders.append( + f"{rel}: {line_count} lines (limit < {SCRIPTS_MAX_LINES})" + ) + self.assertEqual( + offenders, + [], + msg=( + "PyArmor trial/free mode may fail when a single Python file reaches " + f"{SCRIPTS_MAX_LINES} lines; split this file into smaller modules. " + f"Recommended keep each file < {SCRIPTS_RECOMMENDED_MAX_LINES} lines. " + f"Offenders: {offenders}" + ), + ) + + def test_text_files_are_utf8_without_bom(self) -> None: + skill_root = get_skill_root() + bom_failures: list[str] = [] + decode_failures: list[str] = [] + feff_failures: list[str] = [] + + for path in _iter_text_files(skill_root): + rel = os.path.relpath(path, skill_root).replace("\\", "/") + with open(path, "rb") as f: + raw = f.read() + + if raw.startswith(b"\xef\xbb\xbf"): + bom_failures.append(rel) + continue + + try: + text = raw.decode("utf-8") + except UnicodeDecodeError as exc: + decode_failures.append(f"{rel}: {exc}") + continue + + if "\ufeff" in text: + feff_failures.append(rel) + + self.assertEqual( + bom_failures, + [], + msg=( + "Files must be saved as UTF-8 without BOM (remove UTF-8 BOM prefix). " + f"Offenders: {bom_failures}" + ), + ) + self.assertEqual( + decode_failures, + [], + msg=( + "Files must be valid UTF-8 without BOM. " + f"Offenders: {decode_failures}" + ), + ) + self.assertEqual( + feff_failures, + [], + msg=( + "Files must not contain U+FEFF; save as UTF-8 without BOM. " + f"Offenders: {feff_failures}" + ), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_runtime_diagnostics_integration.py b/tests/test_runtime_diagnostics_integration.py new file mode 100644 index 0000000..1804760 --- /dev/null +++ b/tests/test_runtime_diagnostics_integration.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +"""Architecture guard: runtime diagnostics must come from platform-kit.""" +from __future__ import annotations + +import os +import unittest + +from _support import get_scripts_dir, get_skill_root + +_FORBIDDEN_DEFINITIONS = ( + "class RuntimeDiagnostics", + "class RuntimeIssue", + "def collect_runtime_diagnostics", + "def format_runtime_health_lines", + "def runtime_diagnostics_dict", + "def _is_git_lfs_pointer", + "def _is_usable_audio_file", + "def _probe_background_music", +) + + +class TestRuntimeDiagnosticsIntegration(unittest.TestCase): + def test_no_vendored_jiangchang_skill_core(self) -> None: + vendored = os.path.join(get_scripts_dir(), "jiangchang_skill_core") + self.assertFalse(os.path.isdir(vendored), vendored) + + def test_runtime_diagnostics_module_removed(self) -> None: + legacy = os.path.join(get_scripts_dir(), "util", "runtime_diagnostics.py") + self.assertFalse(os.path.isfile(legacy), legacy) + + def test_util_py_files_do_not_reimplement_runtime_diagnostics(self) -> None: + util_dir = os.path.join(get_scripts_dir(), "util") + for name in os.listdir(util_dir): + if not name.endswith(".py"): + continue + path = os.path.join(util_dir, name) + with open(path, encoding="utf-8") as f: + text = f.read() + for forbidden in _FORBIDDEN_DEFINITIONS: + self.assertNotIn( + forbidden, + text, + msg=f"{name} must not define {forbidden}", + ) + + def test_task_service_imports_platform_kit_diagnostics(self) -> None: + path = os.path.join(get_scripts_dir(), "service", "task_service.py") + with open(path, encoding="utf-8") as f: + text = f.read() + self.assertIn("from jiangchang_skill_core import", text) + self.assertIn("collect_runtime_diagnostics", text) + self.assertIn("format_runtime_health_lines", text) + self.assertIn("PLATFORM_KIT_MIN_VERSION", text) + self.assertNotIn("from util.runtime_diagnostics import", text) + + def test_collect_runtime_diagnostics_callable_from_platform_kit(self) -> None: + from jiangchang_skill_core import collect_runtime_diagnostics + + from util.constants import PLATFORM_KIT_MIN_VERSION, SKILL_SLUG + from util.runtime_paths import get_skill_root + + diag = collect_runtime_diagnostics( + skill_slug=SKILL_SLUG, + platform_kit_min_version=PLATFORM_KIT_MIN_VERSION, + skill_root=get_skill_root(), + ) + self.assertEqual(diag.skill_slug, SKILL_SLUG) + self.assertEqual(diag.platform_kit_min_version, PLATFORM_KIT_MIN_VERSION) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_runtime_paths.py b/tests/test_runtime_paths.py new file mode 100644 index 0000000..fdea9b8 --- /dev/null +++ b/tests/test_runtime_paths.py @@ -0,0 +1,169 @@ +# -*- coding: utf-8 -*- +"""util.runtime_paths:技能根、DB 路径、数据目录均落在隔离数据根下。""" +from __future__ import annotations + +import os +import unittest + +from _support import IsolatedDataRoot, get_skill_root + +_FAKE_REAL_ROOT = r"D:\jiangchang-data" + + +def _legacy_env_key(suffix: str) -> str: + return "CL" + "AW_" + suffix + + +class TestRuntimePaths(unittest.TestCase): + def test_paths_under_isolated_root(self) -> None: + win_default = r"D:\jiangchang-data" + with IsolatedDataRoot() as tmp: + import importlib + + import util.runtime_paths as rp + + importlib.reload(rp) + + skill_root = rp.get_skill_root() + self.assertTrue(os.path.isdir(skill_root)) + self.assertTrue(os.path.isfile(os.path.join(skill_root, "SKILL.md"))) + + db_path = rp.get_db_path() + self.assertTrue(os.path.normpath(db_path).startswith(os.path.normpath(tmp))) + self.assertNotIn(win_default, db_path) + + data_dir = rp.get_skill_data_dir() + self.assertTrue(os.path.normpath(data_dir).startswith(os.path.normpath(tmp))) + self.assertIn("_test", data_dir.replace("\\", "/")) + self.assertNotIn(win_default, data_dir) + + self.assertEqual(get_skill_root(), skill_root) + + def test_isolation_overrides_prefixed_real_data_root(self) -> None: + """先设假数据根,再进入隔离;get_db_path 必须落在 tempdir。""" + saved = { + k: os.environ.get(k) + for k in ("JIANGCHANG_DATA_ROOT", "JIANGCHANG_USER_ID") + } + try: + os.environ["JIANGCHANG_DATA_ROOT"] = _FAKE_REAL_ROOT + with IsolatedDataRoot() as tmp: + import importlib + + import util.runtime_paths as rp + + importlib.reload(rp) + db_path = rp.get_db_path() + norm_db = os.path.normcase(os.path.normpath(db_path)) + norm_tmp = os.path.normcase(os.path.normpath(tmp)) + self.assertTrue(norm_db.startswith(norm_tmp), msg=f"db_path={db_path!r} tmp={tmp!r}") + norm_fake = os.path.normcase(os.path.normpath(_FAKE_REAL_ROOT)) + self.assertFalse( + norm_db.startswith(norm_fake), + msg=f"db_path must not be under fake root: {db_path!r}", + ) + finally: + for k, v in saved.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + + def test_legacy_env_vars_do_not_override_canonical(self) -> None: + with IsolatedDataRoot() as tmp: + os.environ[_legacy_env_key("DATA_ROOT")] = _FAKE_REAL_ROOT + os.environ[_legacy_env_key("USER_ID")] = "legacy-user" + import importlib + + import util.runtime_paths as rp + + importlib.reload(rp) + data_dir = rp.get_skill_data_dir() + self.assertTrue(os.path.normpath(data_dir).startswith(os.path.normpath(tmp))) + self.assertIn("_test", data_dir.replace("\\", "/")) + + def test_resolve_data_path_defaults_under_skill_data_dir(self) -> None: + with IsolatedDataRoot() as tmp: + import importlib + + import util.runtime_paths as rp + + importlib.reload(rp) + path = rp.resolve_data_path(None, "output", create=True) + norm = os.path.normcase(os.path.normpath(path)) + norm_tmp = os.path.normcase(os.path.normpath(tmp)) + self.assertTrue(norm.startswith(norm_tmp)) + self.assertTrue(norm.endswith(os.path.normcase(os.path.join("output")))) + + def test_resolve_data_path_relative_uses_data_dir_not_cwd(self) -> None: + with IsolatedDataRoot() as tmp: + import importlib + + import util.runtime_paths as rp + + importlib.reload(rp) + os.environ["SKILL_OUTPUT_DIR"] = "output/videos" + from jiangchang_skill_core import config + + config.reset_cache() + importlib.reload(rp) + path = rp.resolve_data_path("SKILL_OUTPUT_DIR", "output", create=False) + norm = os.path.normcase(os.path.normpath(path)) + self.assertIn(os.path.normcase("output"), norm) + self.assertIn(os.path.normcase("videos"), norm) + self.assertTrue(norm.startswith(os.path.normcase(os.path.normpath(tmp)))) + os.environ.pop("SKILL_OUTPUT_DIR", None) + config.reset_cache() + + def test_resolve_input_path_relative_under_input(self) -> None: + with IsolatedDataRoot() as tmp: + import importlib + + import util.runtime_paths as rp + + importlib.reload(rp) + resolved = rp.resolve_input_path("urls.txt", create_parent=True) + norm = os.path.normcase(os.path.normpath(resolved)) + self.assertIn(os.path.normcase("input"), norm) + self.assertTrue(norm.startswith(os.path.normcase(os.path.normpath(tmp)))) + + def test_get_input_and_output_dirs(self) -> None: + with IsolatedDataRoot() as tmp: + import importlib + + import util.runtime_paths as rp + + importlib.reload(rp) + input_dir = rp.get_input_dir(create=True) + output_dir = rp.get_output_dir(create=True) + html_dir = rp.get_html_dir(create=True) + norm_tmp = os.path.normcase(os.path.normpath(tmp)) + self.assertTrue(os.path.normcase(os.path.normpath(input_dir)).startswith(norm_tmp)) + self.assertTrue(os.path.normcase(os.path.normpath(output_dir)).startswith(norm_tmp)) + self.assertTrue(os.path.normcase(os.path.normpath(html_dir)).startswith(norm_tmp)) + self.assertTrue(input_dir.replace("\\", "/").endswith("/input")) + self.assertTrue(output_dir.replace("\\", "/").endswith("/output")) + self.assertTrue(html_dir.replace("\\", "/").endswith("/html")) + + def test_list_resolved_data_paths_keys(self) -> None: + with IsolatedDataRoot(): + import importlib + + import util.runtime_paths as rp + + importlib.reload(rp) + paths = rp.list_resolved_data_paths() + for key in ( + "skill_data_dir", + "input_dir", + "output_dir", + "html_dir", + ): + self.assertIn(key, paths) + self.assertTrue(paths[key]) + self.assertNotIn("downloads_dir", paths) + self.assertNotIn("imports_dir", paths) + self.assertNotIn("exports_dir", paths) + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_scaffold_guard.py b/tests/test_scaffold_guard.py new file mode 100644 index 0000000..0722e43 --- /dev/null +++ b/tests/test_scaffold_guard.py @@ -0,0 +1,145 @@ +# -*- coding: utf-8 -*- +"""skill-template 脚手架与 Git 防串库守护。""" +from __future__ import annotations + +import json +import os +import tempfile +import unittest + +from _support import get_skill_root + +TEMPLATE_MARKER = ".openclaw-skill-template" +TEMPLATE_SLUG_KEBAB = "your-skill-slug" +TEMPLATE_SLUG_UNDERSCORE = "your_skill_slug" +SCAFFOLD_TEST_SLUG = "query-demo-records" +SCAFFOLD_TEST_SLUG_UNDERSCORE = "query_demo_records" + + +def _read_skill_slug() -> str: + constants_path = os.path.join(get_skill_root(), "scripts", "util", "constants.py") + with open(constants_path, encoding="utf-8") as f: + for line in f: + if line.strip().startswith("SKILL_SLUG"): + _, _, value = line.partition("=") + return value.strip().strip('"').strip("'") + return "" + + +class TestScaffoldGuard(unittest.TestCase): + def test_template_repo_has_marker(self) -> None: + slug = _read_skill_slug() + if slug != TEMPLATE_SLUG_KEBAB: + self.skipTest(f"当前 SKILL_SLUG={slug!r},非模板占位,跳过 template marker 断言") + marker = os.path.join(get_skill_root(), TEMPLATE_MARKER) + self.assertTrue( + os.path.isfile(marker), + msg=f"skill-template 源仓库根目录必须存在 {TEMPLATE_MARKER}", + ) + + def test_business_skill_must_not_keep_template_marker(self) -> None: + slug = _read_skill_slug() + if slug == TEMPLATE_SLUG_KEBAB: + self.skipTest("当前仍为模板占位 slug,由 test_template_repo_has_marker 守护") + marker = os.path.join(get_skill_root(), TEMPLATE_MARKER) + self.assertFalse( + os.path.isfile(marker), + msg=( + "业务技能不得保留 .openclaw-skill-template;" + "请使用 tools/scaffold_skill 或删除标记文件并检查是否误复制了模板 .git" + ), + ) + + def test_development_md_documents_git_cleanup(self) -> None: + path = os.path.join(get_skill_root(), "development", "DEVELOPMENT.md") + with open(path, encoding="utf-8") as f: + text = f.read() + self.assertIn(".git", text) + self.assertTrue( + "scaffold_skill" in text or "scaffold" in text.lower(), + msg="DEVELOPMENT.md must document scaffold_skill", + ) + self.assertTrue( + "remote" in text.lower() or "git remote" in text.lower(), + msg="DEVELOPMENT.md must mention git remote self-check", + ) + + def test_scaffold_replaces_slug_and_copies_actions_manifest(self) -> None: + slug = _read_skill_slug() + if slug != TEMPLATE_SLUG_KEBAB: + self.skipTest("仅在 skill-template 源仓库运行 scaffold 集成测试") + + import subprocess + import sys + + repo_root = get_skill_root() + scaffold_py = os.path.join(repo_root, "tools", "scaffold_skill.py") + with tempfile.TemporaryDirectory() as tmp: + dest = os.path.join(tmp, SCAFFOLD_TEST_SLUG) + proc = subprocess.run( + [ + sys.executable, + scaffold_py, + "--slug", + SCAFFOLD_TEST_SLUG, + "--destination", + dest, + ], + cwd=repo_root, + capture_output=True, + text=True, + ) + self.assertEqual(proc.returncode, 0, msg=proc.stderr or proc.stdout) + + actions_path = os.path.join(dest, "assets", "actions.json") + self.assertTrue(os.path.isfile(actions_path), msg="scaffold must copy assets/actions.json") + with open(actions_path, encoding="utf-8") as f: + manifest = json.load(f) + self.assertEqual(manifest.get("skill"), SCAFFOLD_TEST_SLUG) + manifest_raw = json.dumps(manifest) + self.assertNotIn(TEMPLATE_SLUG_KEBAB, manifest_raw) + self.assertNotIn(TEMPLATE_SLUG_UNDERSCORE, manifest_raw) + + actions_md = os.path.join(dest, "references", "ACTIONS.md") + self.assertTrue(os.path.isfile(actions_md), msg="scaffold must copy references/ACTIONS.md") + + schema_path = os.path.join(dest, "assets", "schemas", "skill-actions.schema.json") + self.assertTrue(os.path.isfile(schema_path)) + + marker = os.path.join(dest, TEMPLATE_MARKER) + self.assertFalse(os.path.isfile(marker)) + self.assertFalse(os.path.isdir(os.path.join(dest, ".git"))) + + constants_path = os.path.join(dest, "scripts", "util", "constants.py") + with open(constants_path, encoding="utf-8") as f: + constants_text = f.read() + self.assertNotIn(TEMPLATE_SLUG_KEBAB, constants_text) + self.assertNotIn(TEMPLATE_SLUG_UNDERSCORE, constants_text) + self.assertIn(f'SKILL_SLUG = "{SCAFFOLD_TEST_SLUG}"', constants_text) + self.assertIn( + f'LOG_LOGGER_NAME = "openclaw.skill.{SCAFFOLD_TEST_SLUG_UNDERSCORE}"', + constants_text, + ) + + skill_md_path = os.path.join(dest, "SKILL.md") + with open(skill_md_path, encoding="utf-8") as f: + skill_md = f.read() + self.assertIn(f"slug: {SCAFFOLD_TEST_SLUG}", skill_md) + self.assertNotIn(TEMPLATE_SLUG_KEBAB, skill_md) + self.assertNotIn(TEMPLATE_SLUG_UNDERSCORE, skill_md) + + def test_scaffold_ps1_matches_python_placeholder_rules(self) -> None: + slug = _read_skill_slug() + if slug != TEMPLATE_SLUG_KEBAB: + self.skipTest("仅在 skill-template 源仓库运行 scaffold PS1 文案测试") + + ps1_path = os.path.join(get_skill_root(), "tools", "scaffold_skill.ps1") + with open(ps1_path, encoding="utf-8") as f: + text = f.read() + self.assertIn("your-skill-slug", text) + self.assertIn("your_skill_slug", text) + self.assertIn('.Replace("-", "_")', text) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_skill_metadata.py b/tests/test_skill_metadata.py new file mode 100644 index 0000000..532b463 --- /dev/null +++ b/tests/test_skill_metadata.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +"""SKILL.md 与 util.constants.SKILL_SLUG 一致性(防复制后漏改 constants)。""" +from __future__ import annotations + +import os +import re +import unittest + +from _support import get_skill_root + + +def _parse_openclaw_slug(skill_md_text: str) -> str: + """从 YAML frontmatter 中解析 metadata.openclaw.slug(无 PyYAML 依赖)。""" + parts = skill_md_text.split("---", 2) + if len(parts) < 2: + raise ValueError("missing frontmatter") + fm = parts[1] + lines = fm.splitlines() + for i, line in enumerate(lines): + if re.match(r"^\s+openclaw:\s*$", line): + for j in range(i + 1, len(lines)): + inner = lines[j] + if inner.strip() and inner[0] not in (" ", "\t"): + break + m = re.match(r"^(\s+)slug:\s*(.+)\s*$", inner) + if m: + return m.group(2).strip().strip("\"'") + break + raise ValueError("slug not found under openclaw") + + +class TestSkillMetadata(unittest.TestCase): + def test_skill_slug_alignment(self) -> None: + root = get_skill_root() + skill_dir = os.path.basename(root) + md_path = os.path.join(root, "SKILL.md") + with open(md_path, encoding="utf-8") as f: + md = f.read() + slug_md = _parse_openclaw_slug(md) + + from util.constants import SKILL_SLUG + + self.assertEqual(slug_md, SKILL_SLUG, "SKILL.md slug 必须与 constants.SKILL_SLUG 一致") + + if skill_dir == "skill-template": + # 模板仓库本体:允许占位 slug + self.assertEqual(SKILL_SLUG, "your-skill-slug") + else: + self.assertNotEqual(SKILL_SLUG, "your-skill-slug", "复制为真实技能后不得保留占位 slug") + self.assertEqual(SKILL_SLUG, skill_dir) + self.assertEqual(slug_md, skill_dir) + + def test_skill_version_alignment(self) -> None: + """SKILL.md version 必须与 constants.SKILL_VERSION 一致(POLICY-RELEASE-001 日常侧)。""" + root = get_skill_root() + md_path = os.path.join(root, "SKILL.md") + with open(md_path, encoding="utf-8") as f: + md = f.read() + match = re.search(r"(?m)^version:\s*[\"']?([0-9]+\.[0-9]+\.[0-9]+)[\"']?\s*$", md) + self.assertIsNotNone(match, "SKILL.md must declare top-level semver version") + assert match is not None + md_version = match.group(1) + + from util.constants import SKILL_VERSION + + self.assertEqual( + md_version, + SKILL_VERSION, + "SKILL.md version 必须与 constants.SKILL_VERSION 一致(发布前再与 git tag 对齐)", + ) + self.assertRegex(SKILL_VERSION, r"^\d+\.\d+\.\d+$") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_slug_naming.py b/tests/test_slug_naming.py new file mode 100644 index 0000000..791d177 --- /dev/null +++ b/tests/test_slug_naming.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- +"""slug 语义校验(verb-noun-platform)。""" +from __future__ import annotations + +import unittest + +from util.slug_naming import ( + LEGACY_EXEMPT_SLUGS, + classify_slug_form, + parse_slug_segments, + validate_slug_semantics, +) + + +class TestSlugNaming(unittest.TestCase): + def test_parse_slug_segments(self) -> None: + self.assertEqual(parse_slug_segments("download-settlement-report-amazon"), [ + "download", + "settlement", + "report", + "amazon", + ]) + + def test_classify_standard(self) -> None: + segments = parse_slug_segments("download-settlement-report-amazon") + self.assertEqual(classify_slug_form(segments, slug="download-settlement-report-amazon"), "standard") + + def test_classify_scope(self) -> None: + segments = parse_slug_segments("generate-article-all") + self.assertEqual(classify_slug_form(segments, slug="generate-article-all"), "scope") + + def test_classify_legacy_exempt(self) -> None: + segments = parse_slug_segments("your-skill-slug") + self.assertEqual(classify_slug_form(segments, slug="your-skill-slug"), "legacy_exempt") + + def test_valid_hyphenated_platform_slug(self) -> None: + slug = "fill-export-declaration-single-window" + segments = parse_slug_segments(slug) + self.assertEqual(classify_slug_form(segments, slug=slug), "standard") + errors, warnings = validate_slug_semantics(slug) + self.assertEqual(errors, [], msg=f"errors={errors!r} warnings={warnings!r}") + + def test_valid_standard_slugs(self) -> None: + for slug in ( + "download-settlement-report-amazon", + "add-product-shopee", + "generate-article-all", + "your-skill-slug", + "account-manager", + ): + with self.subTest(slug=slug): + errors, warnings = validate_slug_semantics(slug) + self.assertEqual(errors, [], msg=f"errors={errors!r} warnings={warnings!r}") + + def test_invalid_slugs(self) -> None: + cases = ( + "amazon-download-settlement-report", + "enter-cross-border-sales-receipt-voucher-kingdee", + "reconcile-finance", + "download-settlement-report-unknownplat", + "foobar-settlement-report-amazon", + ) + for slug in cases: + with self.subTest(slug=slug): + errors, _warnings = validate_slug_semantics(slug) + self.assertTrue(errors, msg=f"expected errors for {slug!r}") + + def test_length_boundary_48_passes(self) -> None: + slug = "query-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-amazon" + self.assertEqual(len(slug), 48) + errors, _warnings = validate_slug_semantics(slug) + self.assertEqual(errors, []) + + def test_length_boundary_49_fails(self) -> None: + slug = "query-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-amazon" + self.assertEqual(len(slug), 49) + errors, _warnings = validate_slug_semantics(slug) + self.assertTrue(errors) + self.assertTrue(any("48" in err for err in errors)) + + def test_legacy_exempt_set(self) -> None: + self.assertIn("your-skill-slug", LEGACY_EXEMPT_SLUGS) + self.assertIn("account-manager", LEGACY_EXEMPT_SLUGS) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_template_runtime_standard.py b/tests/test_template_runtime_standard.py new file mode 100644 index 0000000..71fe1a5 --- /dev/null +++ b/tests/test_template_runtime_standard.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +"""Docs guard: template must promote shared runtime, not vendored jiangchang_skill_core.""" +from __future__ import annotations + +import os +import unittest + +from _support import get_skill_root + +DOC_PATHS = ( + "README.md", + "SKILL.md", + "development/RUNTIME.md", + "references/CLI.md", + "development/DEVELOPMENT.md", + "development/RPA.md", + "development/CONFIG.md", +) + +RUNTIME_DOC_PATHS = tuple(p for p in DOC_PATHS if p != "README.md") + +FORBIDDEN_PHRASES = ( + "scripts/jiangchang_skill_core/:运行时", + "运行时与统一日志副本", + "vendor 源码在 scripts/jiangchang_skill_core", + "模板通过 vendor 拷贝引用", + "vendor 自 platform-kit", + "模板 vendor 拷贝在 scripts/jiangchang_skill_core", + "模板默认声明 `jiangchang-platform-kit", + "模板默认包含 `jiangchang-platform-kit", + "默认已含 `jiangchang-platform-kit", + "保留仅含 `jiangchang-platform-kit", +) + +POSITIVE_MARKERS = ( + "jiangchang-platform-kit", + "1.2.11", + "共享 runtime", +) + + +class TestTemplateRuntimeStandard(unittest.TestCase): + def _read(self, rel_path: str) -> str: + path = os.path.join(get_skill_root(), rel_path) + with open(path, encoding="utf-8") as f: + return f.read() + + def test_guarded_docs_avoid_vendored_core_guidance(self) -> None: + for rel_path in DOC_PATHS: + text = self._read(rel_path) + for phrase in FORBIDDEN_PHRASES: + self.assertNotIn( + phrase, + text, + msg=f"{rel_path} must not contain {phrase!r}", + ) + + def test_guarded_docs_promote_shared_runtime_standard(self) -> None: + combined = "\n".join(self._read(p) for p in RUNTIME_DOC_PATHS) + for marker in POSITIVE_MARKERS: + self.assertIn( + marker, + combined, + msg=f"docs should mention {marker!r}", + ) + self.assertTrue( + "不得 vendored" in combined or "不要 vendor" in combined, + msg="docs should state skills must not vendor jiangchang_skill_core", + ) + + def test_runtime_md_mentions_collect_runtime_diagnostics(self) -> None: + text = self._read("development/RUNTIME.md") + self.assertIn("collect_runtime_diagnostics", text) + + def test_cli_md_mentions_health_runtime_diagnostics(self) -> None: + text = self._read("references/CLI.md") + self.assertIn("python_executable", text) + self.assertIn("platform_kit_version", text) + self.assertIn("jiangchang_skill_core_file", text) + self.assertIn("python-runtime", text) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_timestamp_columns.py b/tests/test_timestamp_columns.py new file mode 100644 index 0000000..6302a1a --- /dev/null +++ b/tests/test_timestamp_columns.py @@ -0,0 +1,358 @@ +# -*- coding: utf-8 -*- +"""标准时间字段:Unix 秒级时间戳、默认值、trigger 与元数据。""" +from __future__ import annotations + +import sqlite3 +import time +import unittest + +from _support import DEMO_ITEMS_TABLE, IsolatedDataRoot, seed_demo_items + + +class TestTimestampColumns(unittest.TestCase): + def test_seed_creates_integer_timestamp_columns_with_defaults(self) -> None: + with IsolatedDataRoot(): + from db.connection import get_conn, init_db + from db.display_metadata import list_physical_column_names + from db.display_metadata_validator import _table_column_info + + init_db() + conn = get_conn() + try: + cur = conn.cursor() + seed_demo_items(cur) + conn.commit() + physical = list_physical_column_names(cur, DEMO_ITEMS_TABLE) + self.assertEqual(physical[-2:], ["created_at", "updated_at"]) + + info = _table_column_info(cur, DEMO_ITEMS_TABLE) + for column_name in ("created_at", "updated_at"): + column = info[column_name] + self.assertIn("INT", str(column["type"]).upper()) + self.assertTrue(str(column["dflt_value"]).lower().find("unixepoch") >= 0) + finally: + conn.close() + + def test_insert_without_timestamps_auto_generates_unix_seconds(self) -> None: + with IsolatedDataRoot(): + from db.connection import get_conn, init_db + + init_db() + conn = get_conn() + try: + cur = conn.cursor() + seed_demo_items(cur) + before = int(time.time()) + cur.execute( + "INSERT INTO demo_items (title, status) VALUES ('auto-ts', 'draft')" + ) + conn.commit() + cur.execute( + "SELECT created_at, updated_at FROM demo_items ORDER BY id DESC LIMIT 1" + ) + created_at, updated_at = cur.fetchone() + after = int(time.time()) + self.assertIsInstance(created_at, int) + self.assertIsInstance(updated_at, int) + self.assertGreaterEqual(created_at, before) + self.assertLessEqual(created_at, after) + self.assertEqual(created_at, updated_at) + finally: + conn.close() + + def test_update_business_field_keeps_created_at_and_refreshes_updated_at(self) -> None: + with IsolatedDataRoot(): + from db.connection import get_conn, init_db + + init_db() + conn = get_conn() + try: + cur = conn.cursor() + seed_demo_items(cur) + cur.execute( + """ + INSERT INTO demo_items (title, status, created_at, updated_at) + VALUES ('update-ts', 'draft', 1000, 1000) + """ + ) + row_id = int(cur.lastrowid) + conn.commit() + + cur.execute("UPDATE demo_items SET status = 'published' WHERE id = ?", (row_id,)) + conn.commit() + + cur.execute( + "SELECT created_at, updated_at FROM demo_items WHERE id = ?", + (row_id,), + ) + created_at, updated_at = cur.fetchone() + self.assertEqual(created_at, 1000) + self.assertGreater(updated_at, 1000) + finally: + conn.close() + + def test_reinit_does_not_rewrite_existing_timestamps(self) -> None: + with IsolatedDataRoot(): + from db.connection import get_conn, init_db + + init_db() + conn = get_conn() + try: + cur = conn.cursor() + seed_demo_items(cur) + cur.execute( + """ + INSERT INTO demo_items (title, status, created_at, updated_at) + VALUES ('stable-ts', 'draft', 1234567890, 1234567891) + """ + ) + conn.commit() + finally: + conn.close() + + init_db() + conn = get_conn() + try: + cur = conn.cursor() + cur.execute( + "SELECT created_at, updated_at FROM demo_items WHERE title = 'stable-ts'" + ) + self.assertEqual(cur.fetchone(), (1234567890, 1234567891)) + finally: + conn.close() + + def test_timestamp_metadata_contract(self) -> None: + with IsolatedDataRoot(): + from db.connection import get_conn, init_db + from db.display_metadata import DATETIME_UNIX_SECONDS + + init_db() + conn = get_conn() + try: + cur = conn.cursor() + seed_demo_items(cur) + conn.commit() + cur.execute( + """ + SELECT column_name, display_name, display_type, editable, display_order + FROM _jiangchang_columns + WHERE table_name = ? + AND column_name IN ('created_at', 'updated_at') + """, + (DEMO_ITEMS_TABLE,), + ) + by_name = { + name: (display_name, display_type, editable, display_order) + for name, display_name, display_type, editable, display_order in cur.fetchall() + } + self.assertEqual(by_name["created_at"][0], "创建时间") + self.assertEqual(by_name["updated_at"][0], "更新时间") + self.assertEqual(by_name["created_at"][1], DATETIME_UNIX_SECONDS) + self.assertEqual(by_name["updated_at"][1], DATETIME_UNIX_SECONDS) + self.assertEqual(int(by_name["created_at"][2]), 0) + self.assertEqual(int(by_name["updated_at"][2]), 0) + self.assertIsNone(by_name["created_at"][3]) + self.assertIsNone(by_name["updated_at"][3]) + finally: + conn.close() + + def test_metadata_upsert_keeps_timestamp_display_type(self) -> None: + with IsolatedDataRoot(): + from db.connection import get_conn, init_db + from db.display_metadata import DATETIME_UNIX_SECONDS + + init_db() + conn = get_conn() + try: + cur = conn.cursor() + seed_demo_items(cur) + seed_demo_items(cur) + conn.commit() + cur.execute( + """ + SELECT display_type FROM _jiangchang_columns + WHERE table_name = ? AND column_name = 'created_at' + """, + (DEMO_ITEMS_TABLE,), + ) + self.assertEqual(cur.fetchone()[0], DATETIME_UNIX_SECONDS) + finally: + conn.close() + + def test_insert_uses_database_timestamps(self) -> None: + with IsolatedDataRoot(): + from db.connection import get_conn, init_db + + init_db() + conn = get_conn() + try: + cur = conn.cursor() + seed_demo_items(cur) + before = int(time.time()) + cur.execute( + "INSERT INTO demo_items (title, status) VALUES ('repo-ts', 'draft')" + ) + row_id = int(cur.lastrowid) + conn.commit() + after = int(time.time()) + cur.execute( + "SELECT created_at, updated_at FROM demo_items WHERE id = ?", + (row_id,), + ) + created_at, updated_at = cur.fetchone() + self.assertGreaterEqual(int(created_at), before) + self.assertLessEqual(int(created_at), after) + self.assertEqual(created_at, updated_at) + finally: + conn.close() + + def test_validator_rejects_missing_display_type(self) -> None: + with IsolatedDataRoot(): + from db.connection import get_conn, init_db + from db.display_metadata_validator import validate_template_database + + init_db() + conn = get_conn() + try: + seed_demo_items(conn.cursor()) + conn.execute( + "UPDATE _jiangchang_columns SET display_type = NULL " + "WHERE table_name = ? AND column_name = 'created_at'", + (DEMO_ITEMS_TABLE,), + ) + conn.commit() + report = validate_template_database(conn) + self.assertFalse(report.ok) + self.assertTrue(any("display_type" in err for err in report.errors)) + finally: + conn.close() + + def test_validator_rejects_wrong_timestamp_type(self) -> None: + conn = sqlite3.connect(":memory:") + try: + from db.display_metadata import init_display_metadata + from db.display_metadata_validator import validate_display_metadata + + conn.executescript( + """ + CREATE TABLE demo_items ( + id INTEGER PRIMARY KEY, + title TEXT, + created_at TEXT + ); + """ + ) + init_display_metadata(conn.cursor()) + conn.execute( + """ + INSERT INTO _jiangchang_tables (table_name, display_name, visible, readonly) + VALUES ('demo_items', '演示', 1, 0) + """ + ) + for column_name, display_name in ( + ("id", "编号"), + ("title", "标题"), + ("created_at", "创建时间"), + ): + conn.execute( + """ + INSERT INTO _jiangchang_columns + (table_name, column_name, display_name, visible, searchable, editable, display_type) + VALUES ('demo_items', ?, ?, 1, 0, 0, 'datetime_unix_seconds') + """, + (column_name, display_name), + ) + conn.commit() + report = validate_display_metadata(conn) + self.assertFalse(report.ok) + self.assertTrue(any("created_at 字段类型应为 INTEGER" in err for err in report.errors)) + finally: + conn.close() + + def test_validator_rejects_editable_timestamp(self) -> None: + with IsolatedDataRoot(): + from db.connection import get_conn, init_db + from db.display_metadata_validator import validate_template_database + + init_db() + conn = get_conn() + try: + seed_demo_items(conn.cursor()) + conn.execute( + "UPDATE _jiangchang_columns SET editable = 1 " + "WHERE table_name = ? AND column_name = 'updated_at'", + (DEMO_ITEMS_TABLE,), + ) + conn.commit() + report = validate_template_database(conn) + self.assertFalse(report.ok) + self.assertTrue(any("editable 应为 0" in err for err in report.errors)) + finally: + conn.close() + + def test_validator_does_not_flag_amount_as_timestamp(self) -> None: + conn = sqlite3.connect(":memory:") + try: + from db.display_metadata import init_display_metadata + from db.display_metadata_validator import validate_display_metadata + + conn.executescript( + """ + CREATE TABLE demo_items ( + id INTEGER PRIMARY KEY, + amount INTEGER, + count INTEGER + ); + """ + ) + init_display_metadata(conn.cursor()) + conn.execute( + """ + INSERT INTO _jiangchang_tables (table_name, display_name, visible, readonly) + VALUES ('demo_items', '演示', 1, 0) + """ + ) + for column_name, display_name in ( + ("id", "编号"), + ("amount", "数量"), + ("count", "计数"), + ): + conn.execute( + """ + INSERT INTO _jiangchang_columns + (table_name, column_name, display_name, visible, searchable, editable) + VALUES ('demo_items', ?, ?, 1, 1, 1) + """, + (column_name, display_name), + ) + conn.commit() + report = validate_display_metadata(conn) + self.assertFalse(any("amount" in err and "display_type" in err for err in report.errors)) + self.assertFalse(any("count" in err and "display_type" in err for err in report.errors)) + finally: + conn.close() + + def test_trigger_creation_is_idempotent(self) -> None: + with IsolatedDataRoot(): + from db.connection import get_conn, init_db + from db.timestamp_columns import has_updated_at_trigger, updated_at_trigger_name + + init_db() + conn = get_conn() + try: + cur = conn.cursor() + seed_demo_items(cur) + seed_demo_items(cur) + conn.commit() + self.assertTrue(has_updated_at_trigger(cur, DEMO_ITEMS_TABLE)) + cur.execute( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'trigger' AND name = ?", + (updated_at_trigger_name(DEMO_ITEMS_TABLE),), + ) + self.assertEqual(int(cur.fetchone()[0]), 1) + finally: + conn.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_tutorial_surfaces.py b/tests/test_tutorial_surfaces.py new file mode 100644 index 0000000..e8db72c --- /dev/null +++ b/tests/test_tutorial_surfaces.py @@ -0,0 +1,221 @@ +# -*- coding: utf-8 -*- +"""用户市场文档契约:README≠教程、TUTORIAL 三入口结构、与 actions.json 对齐。 + +契约全文:development/TUTORIAL_SURFACES.md +- 模板占位 slug(your-skill-slug):只检查骨架与契约文档存在 +- 业务技能:按 actions.json placements 硬校验教程章节与 README 反模式 +""" +from __future__ import annotations + +import json +import os +import re +import unittest + +from _support import get_skill_root +from util.constants import SKILL_SLUG + +TEMPLATE_PLACEHOLDER_SLUG = "your-skill-slug" + +_SECTION_DATA_MGMT = re.compile(r"(?m)^##\s*.*数据管理") +_SECTION_DIALOG = re.compile(r"(?m)^##\s*.*(对话任务|对话)") +_SECTION_CRON = re.compile(r"(?m)^##\s*.*定时任务") +_SECTION_COMPARE = re.compile(r"(?m)^##\s*.*(对照表|主能力)") +_SECTION_STUCK = re.compile(r"(?m)^##\s*.*(常见卡住|卡住怎么办)") +_SECTION_WHAT = re.compile(r"(?m)^##\s*.*(本技能做什么|做什么)") +_NO_CRON_HINT = re.compile(r"不需要定时|未提供定时|不提供定时|不适用.*定时|定时.*不适用") + +# README 反模式:分步操作 / Agent 话术专章(说明 Tab 禁止) +_README_HOWTO_AGENT = re.compile(r"(?m)^##\s*.*告诉\s*Agent") +_README_ENTRY_CHAPTER = re.compile(r"(?m)^##\s*入口[一二三]") +_README_NUMBERED_DM_PATH = re.compile( + r"(?m)^\d+[\.、]\s*.*(数据管理|选品任务|表顶).{0,40}(点击|点\s*「)" +) + + +def _read(rel: str) -> str: + path = os.path.join(get_skill_root(), rel) + with open(path, encoding="utf-8") as f: + return f.read() + + +def _load_actions() -> list[dict]: + path = os.path.join(get_skill_root(), "assets", "actions.json") + if not os.path.isfile(path): + return [] + with open(path, encoding="utf-8") as f: + data = json.load(f) + return list(data.get("actions") or []) + + +def _placement_sets(actions: list[dict]) -> tuple[bool, bool, bool]: + has_dm = False + has_agent = False + has_cron = False + dm_tokens = frozenset({"toolbar", "row", "batch"}) + for action in actions: + placements = set(action.get("placements") or []) + if placements & dm_tokens: + has_dm = True + if "agent" in placements and str(action.get("id") or "") not in ( + "health", + "version", + "config-path", + ): + has_agent = True + if "cron" in placements: + has_cron = True + return has_dm, has_agent, has_cron + + +class TestTutorialSurfacesContract(unittest.TestCase): + def test_tutorial_surfaces_doc_exists(self) -> None: + path = os.path.join(get_skill_root(), "development", "TUTORIAL_SURFACES.md") + self.assertTrue( + os.path.isfile(path), + msg="development/TUTORIAL_SURFACES.md must exist (user market doc contract)", + ) + text = _read("development/TUTORIAL_SURFACES.md") + for marker in ( + "README.md", + "TUTORIAL.md", + "数据管理", + "对话", + "定时", + "skill-detail", + "对照表", + "正文不要用", + "禁止", + ): + self.assertIn(marker, text, msg=f"TUTORIAL_SURFACES.md missing {marker!r}") + + def test_market_docs_forbid_redundant_page_h1(self) -> None: + """宿主已有技能名与 Tab 标题:说明/教程正文禁止再用 # 一级标题。""" + + def strip_fences(src: str) -> str: + return re.sub(r"```.*?```", "", src, flags=re.S) + + for rel in ("README.md", "TUTORIAL.md"): + body = strip_fences(_read(rel)) + # YAML frontmatter 里不应出现 ATX H1;正文亦禁止 # + self.assertIsNone( + re.search(r"(?m)^#\s+\S", body), + msg=( + f"{rel} must not use ATX H1 (# …); host already shows skill name / tab. " + "Use ## for sections (see TUTORIAL_SURFACES.md §0.1)." + ), + ) + self.assertIsNone( + re.search(r"(?m)^#\s+.·\s*使用教程\s*$", body), + msg=f"{rel} must not start sections with '# … · 使用教程'", + ) + + def test_development_md_points_to_tutorial_surfaces(self) -> None: + text = _read("development/DEVELOPMENT.md") + self.assertIn( + "TUTORIAL_SURFACES.md", + text, + msg="DEVELOPMENT.md must reference TUTORIAL_SURFACES.md for market docs", + ) + + def test_template_or_business_tutorial_has_required_skeleton(self) -> None: + tutorial = _read("TUTORIAL.md") + self.assertRegex( + tutorial, + _SECTION_WHAT, + msg="TUTORIAL.md must have a '本技能做什么' (or equivalent) section", + ) + self.assertRegex( + tutorial, + _SECTION_DATA_MGMT, + msg="TUTORIAL.md must have a 数据管理 section (or 不适用 placeholder)", + ) + self.assertRegex( + tutorial, + _SECTION_DIALOG, + msg="TUTORIAL.md must have a 对话 section (or 不适用 placeholder)", + ) + self.assertRegex( + tutorial, + _SECTION_CRON, + msg="TUTORIAL.md must have a 定时任务 section (teach or N/A)", + ) + self.assertRegex( + tutorial, + _SECTION_COMPARE, + msg="TUTORIAL.md must have a 主能力对照表 section", + ) + self.assertRegex( + tutorial, + _SECTION_STUCK, + msg="TUTORIAL.md must have a 常见卡住 section", + ) + # 禁止旧「对话单线」作为唯一主结构标题 + self.assertNotRegex( + tutorial, + r"(?m)^##\s*第一步:打开技能并说清目标\s*$", + msg="TUTORIAL.md must not use legacy dialogue-only first step as sole structure", + ) + + def test_readme_has_no_legacy_agent_howto_chapter(self) -> None: + readme = _read("README.md") + self.assertIsNone( + _README_HOWTO_AGENT.search(readme), + msg="README.md must not have '告诉 Agent' howto chapter (belongs in TUTORIAL)", + ) + self.assertIsNone( + _README_ENTRY_CHAPTER.search(readme), + msg="README.md must not have 入口一/二/三 chapters (belongs in TUTORIAL)", + ) + + def test_business_skill_tutorial_aligns_with_actions(self) -> None: + if SKILL_SLUG == TEMPLATE_PLACEHOLDER_SLUG: + self.skipTest("template placeholder slug: skip actions alignment hard checks") + + actions = _load_actions() + has_dm, has_agent, has_cron = _placement_sets(actions) + tutorial = _read("TUTORIAL.md") + readme = _read("README.md") + + if has_dm: + self.assertRegex( + tutorial, + _SECTION_DATA_MGMT, + msg="business skill with data-management placements must document 数据管理 in TUTORIAL", + ) + # 准备类若挂了 toolbar,教程不应把 skill-detail 当唯一主路径 + self.assertNotRegex( + tutorial, + r"(?m)^\d+[\.、]\s*.*技能详情.{0,30}点击", + msg="TUTORIAL must not use 技能详情点击 as primary numbered path when DM exists", + ) + + if has_agent: + self.assertRegex( + tutorial, + _SECTION_DIALOG, + msg="business skill with agent placements must document 对话 in TUTORIAL", + ) + + if has_cron: + self.assertRegex( + tutorial, + _SECTION_CRON, + msg="with cron placements, TUTORIAL must have 定时任务 section", + ) + else: + self.assertRegex( + tutorial, + _NO_CRON_HINT, + msg="without cron placements, TUTORIAL must declare 不需要/未提供定时", + ) + + # README 不应出现「数据管理 → … → 点击」式编号步骤 + self.assertIsNone( + _README_NUMBERED_DM_PATH.search(readme), + msg="README.md must not contain numbered data-management click steps", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_video_service.py b/tests/test_video_service.py new file mode 100644 index 0000000..18b75d0 --- /dev/null +++ b/tests/test_video_service.py @@ -0,0 +1,314 @@ +# -*- coding: utf-8 -*- +"""RpaVideoSession 模板示范与 finish() video 字段 / CLI 输出测试。""" +from __future__ import annotations + +import io +import os +import re +import unittest +from contextlib import redirect_stderr, redirect_stdout +from unittest.mock import AsyncMock, MagicMock, patch + +from _support import IsolatedDataRoot, get_skill_root + +from jiangchang_skill_core import config + + +def _active_env_value(text: str, key: str) -> str | None: + """Return the active (non-comment) KEY=value line from .env-style text.""" + prefix = f"{key}=" + for line in text.splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + active = stripped.split("#", 1)[0].strip() + if active.startswith(prefix): + return active + return None + + +def _combined_service_source(skill_root: str | None = None) -> str: + """Merge scripts/service/*.py source (excludes __pycache__).""" + root = skill_root or get_skill_root() + service_dir = os.path.join(root, "scripts", "service") + parts: list[str] = [] + for dirpath, dirnames, filenames in os.walk(service_dir): + dirnames[:] = [d for d in dirnames if d != "__pycache__"] + for name in sorted(filenames): + if not name.endswith(".py"): + continue + path = os.path.join(dirpath, name) + with open(path, encoding="utf-8") as f: + parts.append(f.read()) + return "\n".join(parts) + + +def _missing_video_integration_tokens(service_text: str) -> list[str]: + missing: list[str] = [] + if "RpaVideoSession" not in service_text: + missing.append("RpaVideoSession") + if "video.add_step" not in service_text and ".add_step(" not in service_text: + missing.append("video.add_step or .add_step(") + if "merge_video_into_result_summary" not in service_text: + missing.append("merge_video_into_result_summary") + return missing + + +class TestEnvExampleVideoDefaults(unittest.TestCase): + def test_env_example_contains_required_keys(self) -> None: + path = os.path.join(get_skill_root(), ".env.example") + with open(path, encoding="utf-8") as f: + text = f.read() + for key in ( + "OPENCLAW_" + "TEST_TARGET=real_" + "rpa", + "OPENCLAW_RECORD_VIDEO=0", + "OPENCLAW_ARTIFACTS_ON_FAILURE=1", + "OPENCLAW_BROWSER_HEADLESS=0", + "OPENCLAW_PLAYWRIGHT_STEALTH=1", + ): + self.assertIn(key, text, msg=f"missing {key!r} in .env.example") + + def test_env_example_default_off_but_template_keeps_video_integration(self) -> None: + path = os.path.join(get_skill_root(), ".env.example") + with open(path, encoding="utf-8") as f: + text = f.read() + self.assertEqual( + _active_env_value(text, "OPENCLAW_RECORD_VIDEO"), + "OPENCLAW_RECORD_VIDEO=0", + ) + + service_text = _combined_service_source() + missing = _missing_video_integration_tokens(service_text) + self.assertEqual( + missing, + [], + msg=( + "default OPENCLAW_RECORD_VIDEO=0 does not allow removing video integration; " + f"scripts/service/*.py missing: {', '.join(missing)}" + ), + ) + + def test_env_example_allows_record_video_one_in_comments_only(self) -> None: + sample = "\n".join( + [ + "# example: OPENCLAW_RECORD_VIDEO=1 for production", + "OPENCLAW_RECORD_VIDEO=0", + ] + ) + self.assertEqual( + _active_env_value(sample, "OPENCLAW_RECORD_VIDEO"), + "OPENCLAW_RECORD_VIDEO=0", + ) + + +class TestPrintVideoSummary(unittest.TestCase): + def test_cli_prints_video_path_when_enabled(self) -> None: + from service.task_run_support import _print_video_summary + + buf = io.StringIO() + with redirect_stdout(buf), redirect_stderr(io.StringIO()): + _print_video_summary( + { + "enabled": True, + "path": r"D:\data\videos\draft-closing-report-default_demo.mp4", + "record_log_path": r"D:\data\logs\ffmpeg-record.log", + "warnings": [], + } + ) + out = buf.getvalue() + self.assertIn("录屏路径", out) + self.assertIn("draft-closing-report-default_demo.mp4", out) + self.assertIn("录屏日志", out) + + def test_disabled_video_prints_nothing(self) -> None: + from service.task_run_support import _print_video_summary + + buf = io.StringIO() + with redirect_stdout(buf), redirect_stderr(io.StringIO()): + _print_video_summary({"enabled": False, "path": r"D:\tmp\x.mp4"}) + self.assertEqual(buf.getvalue(), "") + + +class TestMergeVideoIntoResultSummary(unittest.TestCase): + def test_finish_payload_includes_video_fields(self) -> None: + from service.task_run_support import merge_video_into_result_summary + + video_info = { + "enabled": True, + "path": r"D:\data\videos\demo.mp4", + "capture_path": r"D:\data\capture.mp4", + "record_log_path": r"D:\data\logs\ffmpeg-record.log", + "warnings": ["test_warning"], + "voiceover_path": r"D:\data\voiceover.wav", + "music_path": r"D:\data\music.mp3", + "audio_warnings": ["tts_skipped"], + } + payload = merge_video_into_result_summary({"demo": True}, video_info) + self.assertIn("video", payload) + self.assertEqual(payload["video_path"], video_info["path"]) + self.assertEqual(payload["raw_video"], video_info["capture_path"]) + self.assertEqual(payload["video_log"], video_info["record_log_path"]) + self.assertEqual(payload["video_warnings"], ["test_warning"]) + self.assertEqual(payload["voiceover_path"], video_info["voiceover_path"]) + self.assertEqual(payload["music_path"], video_info["music_path"]) + self.assertEqual(payload["audio_warnings"], ["tts_skipped"]) + + +class TestTemplateRunVideoSession(unittest.TestCase): + def _fake_video_session(self, mock_video: MagicMock): + class _FakeVideoSession: + def __init__(self, **kwargs): + self.kwargs = kwargs + + async def __aenter__(self): + return mock_video + + async def __aexit__(self, *args): + return None + + return _FakeVideoSession + + def test_cmd_run_creates_video_session_with_chinese_titles(self) -> None: + with IsolatedDataRoot(user_id="_video_run"): + os.environ["OPENCLAW_RECORD_VIDEO"] = "0" + config.reset_cache() + + from service import task_service + + mock_video = MagicMock() + mock_video.add_step = MagicMock() + mock_video.summary.return_value = { + "enabled": False, + "path": None, + "capture_path": None, + "record_log_path": None, + "warnings": [], + "voiceover_path": None, + "music_path": None, + "audio_warnings": [], + } + + created: list[dict] = [] + + class _CapturingFakeSession: + def __init__(self, **kwargs): + created.append(kwargs) + + async def __aenter__(self): + return mock_video + + async def __aexit__(self, *args): + return None + + with patch.object(task_service, "RpaVideoSession", _CapturingFakeSession): + finish_kwargs: list[dict] = [] + original_finish = task_service.finish + + def _capture_finish(*args, **kwargs): + finish_kwargs.append(kwargs) + return original_finish(*args, **kwargs) + + buf = io.StringIO() + with patch.object(task_service, "finish", side_effect=_capture_finish): + with redirect_stdout(buf), redirect_stderr(io.StringIO()): + rc = task_service.cmd_run(target="demo-target", input_id="42") + + self.assertEqual(rc, 1) + self.assertTrue(created, "RpaVideoSession should be constructed") + kwargs = created[0] + self.assertEqual(kwargs.get("title"), "开始执行示例任务") + self.assertEqual(kwargs.get("closing_title"), "示例任务执行完成") + self.assertTrue(re.search(r"[\u4e00-\u9fff]", kwargs.get("title", ""))) + self.assertTrue(re.search(r"[\u4e00-\u9fff]", kwargs.get("closing_title", ""))) + mock_video.add_step.assert_any_call("准备执行示例任务") + mock_video.add_step.assert_any_call("示例任务执行完成") + mock_video.summary.assert_called() + + out = buf.getvalue() + self.assertIn("模板", out) + + self.assertTrue(finish_kwargs, "finish() should be called") + summary = finish_kwargs[-1] + self.assertIn("video", summary) + self.assertIn("video_path", summary) + + +class TestCmdRunFinishCoverage(unittest.TestCase): + def test_cmd_run_entitlement_failure_calls_finish(self) -> None: + with IsolatedDataRoot(user_id="_entitlement_fail"): + from service import task_service + + finish_kwargs: list[dict] = [] + original_finish = task_service.finish + + def _capture_finish(*args, **kwargs): + finish_kwargs.append(kwargs) + return original_finish(*args, **kwargs) + + with patch.object(task_service, "check_entitlement", return_value=(False, "mock denied")): + with patch.object(task_service, "finish", side_effect=_capture_finish): + buf = io.StringIO() + with redirect_stdout(buf), redirect_stderr(io.StringIO()): + rc = task_service.cmd_run(target="t1", input_id="99") + + self.assertEqual(rc, 1) + self.assertTrue(finish_kwargs) + payload = finish_kwargs[-1] + self.assertEqual(payload.get("status"), "failed") + self.assertEqual(payload.get("error_code"), "ENTITLEMENT_DENIED") + self.assertEqual(payload.get("stage"), "auth") + self.assertIn("mock denied", payload.get("message") or "") + + def test_cmd_run_exception_calls_finish(self) -> None: + with IsolatedDataRoot(user_id="_exception_run"): + os.environ["OPENCLAW_RECORD_VIDEO"] = "0" + config.reset_cache() + + from service import task_service + + async def _boom(*_args, **_kwargs): + raise RuntimeError("boom") + + finish_kwargs: list[dict] = [] + original_finish = task_service.finish + + def _capture_finish(*args, **kwargs): + finish_kwargs.append(kwargs) + return original_finish(*args, **kwargs) + + with patch.object(task_service, "check_entitlement", return_value=(True, "")): + with patch.object(task_service, "_run_template_demo", side_effect=_boom): + with patch.object(task_service, "finish", side_effect=_capture_finish): + buf = io.StringIO() + with redirect_stdout(buf), redirect_stderr(io.StringIO()): + rc = task_service.cmd_run(target="t2", input_id="88") + + self.assertEqual(rc, 1) + self.assertTrue(finish_kwargs) + payload = finish_kwargs[-1] + self.assertEqual(payload.get("status"), "failed") + self.assertEqual(payload.get("stage"), "run") + self.assertIn("任务执行异常", payload.get("message") or "") + + def test_get_task_logger_fallback_when_not_initialized(self) -> None: + from service import task_service + + mock_logger = MagicMock() + responses = [RuntimeError("logging not initialized"), mock_logger] + + def _fake_get_skill_logger(): + item = responses.pop(0) + if isinstance(item, Exception): + raise item + return item + + with patch.object(task_service, "get_skill_logger", side_effect=_fake_get_skill_logger): + with patch.object(task_service, "setup_skill_logging") as mock_setup: + logger = task_service._get_task_logger() + + mock_setup.assert_called_once_with(task_service.SKILL_SLUG, task_service.LOG_LOGGER_NAME) + self.assertIs(logger, mock_logger) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/README.md b/tools/README.md new file mode 100644 index 0000000..08cce15 --- /dev/null +++ b/tools/README.md @@ -0,0 +1,87 @@ +# skill-template 工具 + +## UI 拾取(开发期) + +`tools/ui_inspector` 已迁出本模板仓。请使用独立产品仓: + +- Gitea:https://git.jc2009.com/client-jiangchang/jiangchang-ui-recorder +- 本机常见路径:`D:\OpenClaw\client-jiangchang\jiangchang-ui-recorder` +- 启动:`python run.py` + +模板仓历史中仍保留删除前的归档提交(可按需从 Git 恢复旧副本);日常开发与运行以独立仓为准。 + +## 业务仓也可以直接 clone(来源 A) + +若项目经理已在**公司自建 Gitea**([https://git.jc2009.com/](https://git.jc2009.com/),基于开源 Gitea 自建,**不是** Gitea 官网)开好业务技能仓库并授权,技术人员应直接: + +```powershell +git clone https://git.jc2009.com//.git +``` + +完整说明见 [`../development/DEVELOPMENT.md`](../development/DEVELOPMENT.md) §4「来源 A」。公开公共仓(含本模板、account-manager、platform-kit)登记见 [`../development/SHARED_REPOS.md`](../development/SHARED_REPOS.md)。下面脚手架用于**本地已有 skill-template、要新建尚未灌仓目录**的场景(来源 B)。 + +## 推荐:脚手架创建新技能 + +**不要**用资源管理器整文件夹复制 `skill-template`(会带走隐藏 `.git`,导致 push 串库)。 + +Windows 首选: + +```powershell +cd D:\OpenClaw\client-commons\skill-template +.\tools\scaffold_skill.ps1 -Slug my-new-skill -Destination D:\OpenClaw\client-gdcm\my-new-skill +``` + +跨平台(Python 3.10+): + +```bash +python tools/scaffold_skill.py --slug my-new-skill --destination /path/to/my-new-skill +``` + +脚本行为: + +- 复制模板到 `-Destination`(目录末段须与 `-Slug` 一致) +- **排除**:`.git`、`.pytest_cache`、`__pycache__`、`.env`、`.env.local`、`*.pyc` +- 删除目标中的 `.openclaw-skill-template` 标记 +- 将文本文件中的模板占位 slug 替换为新 slug: + - `your-skill-slug` → 新技能 kebab-case slug(如 `scrape-demo-records`) + - `your_skill_slug` → 连字符替换为下划线(如 `scrape_demo_records`,用于 `LOG_LOGGER_NAME` 等) + - 覆盖 `assets/actions.json`、`SKILL.md`、`scripts/util/constants.py` 等生成文件;文档中的路径示例可保留占位,但生成后的技能文件不得残留占位符 +- **不**自动修改 `actions.json` 中的 CLI 业务命令;技能作者自行增删 action +- **不**移除 Schema 中的 `bind.tables` 支持,**不**把 `bind` 当模板残留清理 +- **不**自动生成业务表名,**不**给 `health` / `version` / `config-path` 自动加 `toolbar` +- **不**生成任何具体平台业务 Action +- **不**自动 `git init`;打印后续 Git 与测试命令 + +目标目录若已存在且非空,需加 `-Force` / `--force`(且目标**不得**含 `.git`)。 + +## 脚手架复制后必须保留 / 修改 / 可删除的文件 + +| 文件 | 说明 | +|------|------| +| `assets/actions.json` | 默认复制;不需要宿主 Action 入口时可删除 | +| `references/ACTIONS.md` | 契约文档;无 `actions.json` 时可保留作参考或按需精简 | +| `assets/schemas/skill-actions.schema.json` | manifest 规范;无 action 时可保留 | +| `tests/test_actions_manifest.py` | 若删除 `actions.json`,应同步删除或改写此测试 | +| `tests/test_data_management_contract.py` | 通用契约测试;建议保留,按需扩展本技能表结构测试 | + +`demo_items` 等示例表仅存在于模板测试中,**不会**作为 scaffold 后的默认业务表复制进新技能。 + +## 手工复制后的补救 + +若已整目录复制,必须先清理再初始化: + +```powershell +cd <新技能目录> +Remove-Item -Recurse -Force .git +Remove-Item -Recurse -Force .pytest_cache -ErrorAction SilentlyContinue +Remove-Item -Force .openclaw-skill-template -ErrorAction SilentlyContinue +git init +git remote add origin <新技能仓库 URL> +git remote -v +``` + +确认 `origin` **不是** skill-template 的远端 URL。 + +## 模板标记 + +源仓库根目录有 `.openclaw-skill-template`(仅 template 保留)。业务技能不得保留该文件;`tests/test_scaffold_guard.py` 会守护。 diff --git a/tools/highlight_answers.py b/tools/highlight_answers.py new file mode 100644 index 0000000..f45a814 --- /dev/null +++ b/tools/highlight_answers.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +"""Highlight correct answer options in Excel quiz file.""" + +import re +import sys +from pathlib import Path + +import openpyxl +from openpyxl.cell.rich_text import CellRichText, InlineFont, TextBlock + +# Option letter to index mapping +LETTER_TO_IDX = {"A": 0, "B": 1, "C": 2, "D": 3} +IDX_TO_LETTER = {0: "A", 1: "B", 2: "C", 3: "D"} + +# Regex to find option boundaries: A/B/C/D followed by separator +OPTION_MARKER_RE = re.compile( + r"(?=[A-D](?:[\..、::\))]|\s))" +) + +NORMAL_FONT = InlineFont(b=False, color="000000") +RED_BOLD_FONT = InlineFont(b=True, color="FF0000") + + +def parse_answer(answer_raw) -> set[int]: + """Parse answer column into set of option indices (0=A, 1=B, ...).""" + if answer_raw is None: + return set() + s = str(answer_raw).strip() + if not s: + return set() + + indices = set() + + # Pure digits like "13" -> 1 and 3 (not 13) + if re.fullmatch(r"[1-4]+", s): + for ch in s: + indices.add(int(ch) - 1) + return indices + + # Split by common separators + parts = re.split(r"[,,;;\s|]+", s) + for part in parts: + part = part.strip().upper() + if not part: + continue + if part in LETTER_TO_IDX: + indices.add(LETTER_TO_IDX[part]) + elif re.fullmatch(r"[1-4]", part): + indices.add(int(part) - 1) + + return indices + + +def find_option_spans(text: str) -> list[tuple[int, int, str]]: + """Find spans for each A/B/C/D option in the options text.""" + if not text or not isinstance(text, str): + return [] + + markers = [] + for m in OPTION_MARKER_RE.finditer(text): + letter = text[m.start()] + markers.append((m.start(), letter)) + + if not markers: + return [] + + spans = [] + for i, (start, letter) in enumerate(markers): + end = markers[i + 1][0] if i + 1 < len(markers) else len(text) + spans.append((start, end, letter)) + return spans + + +def build_rich_text(options_text: str, answer_indices: set[int]) -> CellRichText | str: + """Build rich text with correct options in red bold.""" + spans = find_option_spans(options_text) + if not spans: + return options_text + + parts = [] + pos = 0 + for start, end, letter in spans: + if start > pos: + parts.append(TextBlock(NORMAL_FONT, options_text[pos:start])) + idx = LETTER_TO_IDX.get(letter) + font = RED_BOLD_FONT if idx is not None and idx in answer_indices else NORMAL_FONT + parts.append(TextBlock(font, options_text[start:end])) + pos = end + + if pos < len(options_text): + parts.append(TextBlock(NORMAL_FONT, options_text[pos:])) + + return CellRichText(*parts) + + +def load_workbook_from_xls(xls_path: Path) -> openpyxl.Workbook: + """Load .xls file, converting via pandas/xlrd if needed.""" + try: + import pandas as pd + + xls_data = pd.read_excel(xls_path, sheet_name=None, header=None, dtype=str) + wb = openpyxl.Workbook() + # Remove default sheet + wb.remove(wb.active) + + for sheet_name, df in xls_data.items(): + ws = wb.create_sheet(title=str(sheet_name)) + for r_idx, row in enumerate(df.itertuples(index=False), start=1): + for c_idx, val in enumerate(row, start=1): + if val is not None and str(val) != "nan": + ws.cell(row=r_idx, column=c_idx, value=str(val)) + return wb + except Exception as e: + raise RuntimeError(f"Failed to read .xls file: {e}") from e + + +def find_header_columns(ws) -> tuple[int, int, int] | None: + """Find header row and column indices for 题目选项 and 题目答案.""" + for row_idx in range(1, min(ws.max_row + 1, 20)): + options_col = None + answer_col = None + for col_idx in range(1, ws.max_column + 1): + val = ws.cell(row=row_idx, column=col_idx).value + if val is None: + continue + s = str(val).strip() + if s == "题目选项": + options_col = col_idx + elif s == "题目答案": + answer_col = col_idx + if options_col is not None and answer_col is not None: + return row_idx, options_col, answer_col + return None + + +def process_sheet(ws, stats: dict) -> bool: + """Process a single worksheet. Returns True if processed.""" + header_info = find_header_columns(ws) + if header_info is None: + return False + + header_row, options_col, answer_col = header_info + stats["sheets"] += 1 + sheet_rows = 0 + sheet_matches = 0 + + for row_idx in range(header_row + 1, ws.max_row + 1): + options_cell = ws.cell(row=row_idx, column=options_col) + answer_cell = ws.cell(row=row_idx, column=answer_col) + + options_text = options_cell.value + if options_text is None: + continue + options_text = str(options_text).strip() + if not options_text: + continue + + answer_indices = parse_answer(answer_cell.value) + if not answer_indices: + continue + + sheet_rows += 1 + sheet_matches += len(answer_indices) + + rich = build_rich_text(options_text, answer_indices) + options_cell.value = rich + + stats["rows"] += sheet_rows + stats["matches"] += sheet_matches + return True + + +def main(): + src = Path(r"D:\下载\安全知识竞赛题库(1).xls") + dst = Path(r"D:\下载\安全知识竞赛题库_答案标红.xlsx") + + if not src.exists(): + print(f"ERROR: Source file not found: {src}") + sys.exit(1) + + print(f"Reading: {src}") + wb = load_workbook_from_xls(src) + + stats = {"sheets": 0, "rows": 0, "matches": 0} + for ws in wb.worksheets: + process_sheet(ws, stats) + + wb.save(dst) + print(f"Saved: {dst}") + print(f"处理结果: {stats['sheets']} 个工作表, {stats['rows']} 行, {stats['matches']} 个匹配选项") + + +if __name__ == "__main__": + main() diff --git a/tools/scaffold_skill.ps1 b/tools/scaffold_skill.ps1 new file mode 100644 index 0000000..dcd9b65 --- /dev/null +++ b/tools/scaffold_skill.ps1 @@ -0,0 +1,140 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Scaffold a new skill directory from skill-template (excludes .git). + +.EXAMPLE + .\tools\scaffold_skill.ps1 -Slug my-new-skill -Destination D:\OpenClaw\client-gdcm\my-new-skill +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$Slug, + + [Parameter(Mandatory = $true)] + [string]$Destination, + + [switch]$Force +) + +$ErrorActionPreference = "Stop" + +function Test-KebabSlug([string]$Value) { + return $Value -match '^[a-z0-9]+(-[a-z0-9]+)*$' +} + +function Write-ScaffoldNextSteps([string]$TargetPath) { + Write-Host "" + Write-Host "=== 脚手架复制完成 ===" -ForegroundColor Green + Write-Host ("目标目录: " + $TargetPath) + Write-Host "" + Write-Host "请在本机继续执行(未自动 git init,避免误操作):" -ForegroundColor Yellow + Write-Host (' cd "' + $TargetPath + '"') + Write-Host " Remove-Item -Recurse -Force .git -ErrorAction SilentlyContinue" + Write-Host " git init" + Write-Host " git remote add origin <你的新技能仓库 URL>" + Write-Host " git remote -v" + Write-Host " # 确认 assets/actions.json 中 skill 已替换为新 slug;不需要 Action 时可删除该文件" + Write-Host " python tests/run_tests.py -v" + Write-Host "" +} + +$TemplateRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +$TargetPath = $Destination + +if (-not (Test-KebabSlug $Slug)) { + throw "Slug must be kebab-case. Got: $Slug" +} + +$leaf = Split-Path -Leaf ($TargetPath.TrimEnd('\', '/')) +if ($leaf -ne $Slug) { + throw "Destination leaf '$leaf' must match -Slug '$Slug'." +} + +$parent = Split-Path -Parent $TargetPath +if (-not $parent -or -not (Test-Path -LiteralPath $parent)) { + throw "Parent directory does not exist: $parent" +} + +$gitPath = Join-Path $TargetPath ".git" +if (Test-Path -LiteralPath $gitPath) { + throw "Target already has .git at $gitPath . Remove it first or use an empty destination." +} + +$targetExists = Test-Path -LiteralPath $TargetPath +if ($targetExists) { + $entries = @(Get-ChildItem -LiteralPath $TargetPath -Force -ErrorAction SilentlyContinue) + if ($entries.Count -gt 0 -and -not $Force) { + throw "Target exists and is not empty: $TargetPath . Use -Force or pick an empty directory." + } +} + +$excludeDirs = @(".git", ".pytest_cache", "__pycache__") +$excludeFiles = @(".env", ".env.local") +$templatePlaceholderSlugKebab = "your-skill-slug" +$templatePlaceholderSlugUnderscore = "your_skill_slug" +$textFileSuffixes = @(".md", ".py", ".json", ".yaml", ".yml", ".txt", ".ini", ".ps1", ".sample") + +function Copy-TemplateTree { + param( + [string]$Source, + [string]$Dest + ) + if (-not (Test-Path -LiteralPath $Dest)) { + New-Item -ItemType Directory -Path $Dest -Force | Out-Null + } + Get-ChildItem -LiteralPath $Source -Force | ForEach-Object { + $name = $_.Name + if ($excludeDirs -contains $name) { return } + if ($excludeFiles -contains $name) { return } + if ($name -like "*.pyc") { return } + + $srcPath = $_.FullName + $dstPath = Join-Path $Dest $name + if ($_.PSIsContainer) { + Copy-TemplateTree -Source $srcPath -Dest $dstPath + } + else { + Copy-Item -LiteralPath $srcPath -Destination $dstPath -Force + } + } +} + +function Replace-TemplatePlaceholders { + param( + [string]$Root, + [string]$Slug + ) + $underscoreSlug = $Slug.Replace("-", "_") + Get-ChildItem -LiteralPath $Root -Recurse -File -Force | ForEach-Object { + $name = $_.Name + if ($excludeFiles -contains $name) { return } + $ext = $_.Extension.ToLowerInvariant() + if ($textFileSuffixes -notcontains $ext -and $name -ne ".env.example") { return } + try { + $text = [System.IO.File]::ReadAllText($_.FullName) + } + catch { + return + } + if ( + $text -notlike "*$templatePlaceholderSlugKebab*" -and + $text -notlike "*$templatePlaceholderSlugUnderscore*" + ) { return } + $updated = $text.Replace($templatePlaceholderSlugKebab, $Slug) + $updated = $updated.Replace($templatePlaceholderSlugUnderscore, $underscoreSlug) + [System.IO.File]::WriteAllText($_.FullName, $updated) + } +} + +Write-Host "Copy template: $TemplateRoot -> $TargetPath" -ForegroundColor Cyan +Copy-TemplateTree -Source $TemplateRoot -Dest $TargetPath + +$marker = Join-Path $TargetPath ".openclaw-skill-template" +if (Test-Path -LiteralPath $marker) { + Remove-Item -LiteralPath $marker -Force +} + +Replace-TemplatePlaceholders -Root $TargetPath -Slug $Slug + +Write-ScaffoldNextSteps -TargetPath (Resolve-Path -LiteralPath $TargetPath).Path diff --git a/tools/scaffold_skill.py b/tools/scaffold_skill.py new file mode 100644 index 0000000..6af5367 --- /dev/null +++ b/tools/scaffold_skill.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""跨平台 skill-template 脚手架(与 scaffold_skill.ps1 行为一致)。""" +from __future__ import annotations + +import argparse +import re +import shutil +import sys +from pathlib import Path + +EXCLUDE_DIR_NAMES = {".git", ".pytest_cache", "__pycache__"} +EXCLUDE_FILE_NAMES = {".env", ".env.local"} +KEBAB_SLUG = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") +TEMPLATE_PLACEHOLDER_SLUG_KEBAB = "your-skill-slug" +TEMPLATE_PLACEHOLDER_SLUG_UNDERSCORE = "your_skill_slug" +TEXT_FILE_SUFFIXES = { + ".md", + ".py", + ".json", + ".yaml", + ".yml", + ".txt", + ".ini", + ".ps1", + ".sample", + ".env.example", +} + + +def _ignore_copy(_dir: str, names: list[str]) -> set[str]: + ignored: set[str] = set() + for name in names: + if name in EXCLUDE_DIR_NAMES or name in EXCLUDE_FILE_NAMES: + ignored.add(name) + elif name.endswith(".pyc"): + ignored.add(name) + return ignored + + +def _should_replace_placeholders(path: Path) -> bool: + if path.name in EXCLUDE_FILE_NAMES: + return False + if path.suffix.lower() in TEXT_FILE_SUFFIXES: + return True + if path.name == ".env.example": + return True + return False + + +def _slug_to_underscore(slug: str) -> str: + return slug.replace("-", "_") + + +def _replace_template_placeholders(target: Path, slug: str) -> None: + """将模板占位 slug 替换为新技能 slug(不修改 action 业务命令结构)。""" + underscore_slug = _slug_to_underscore(slug) + for path in target.rglob("*"): + if not path.is_file(): + continue + if path.name in EXCLUDE_FILE_NAMES: + continue + if path.suffix.lower() not in TEXT_FILE_SUFFIXES and path.name != ".env.example": + continue + try: + text = path.read_text(encoding="utf-8") + except UnicodeDecodeError: + continue + if ( + TEMPLATE_PLACEHOLDER_SLUG_KEBAB not in text + and TEMPLATE_PLACEHOLDER_SLUG_UNDERSCORE not in text + ): + continue + updated = text.replace(TEMPLATE_PLACEHOLDER_SLUG_KEBAB, slug) + updated = updated.replace(TEMPLATE_PLACEHOLDER_SLUG_UNDERSCORE, underscore_slug) + path.write_text(updated, encoding="utf-8") + + +def _print_next_steps(target: Path) -> None: + print() + print("=== 脚手架复制完成 ===") + print(f"目标目录: {target}") + print() + print("请在本机继续执行(未自动 git init,避免误操作):") + print(f' cd "{target}"') + print(" Remove-Item -Recurse -Force .git -ErrorAction SilentlyContinue # 若曾手工复制漏删") + print(" git init") + print(" git remote add origin <你的新技能仓库 URL>") + print(" git remote -v # 确认 remote 不是 skill-template") + print(" # 确认 assets/actions.json 中 skill 已替换为新 slug;不需要 Action 时可删除该文件") + print(" python tests/run_tests.py -v") + print() + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="从 skill-template 脚手架复制新技能目录") + parser.add_argument("--slug", required=True, help="kebab-case slug,须与目标目录名一致") + parser.add_argument("--destination", required=True, help="新技能完整目标路径") + parser.add_argument("--force", action="store_true", help="目标非空时允许覆盖(目标不得含 .git)") + args = parser.parse_args(argv) + + slug = args.slug.strip() + if not KEBAB_SLUG.match(slug): + print(f"ERROR: Slug 必须是 kebab-case,当前: {slug}", file=sys.stderr) + return 1 + + target = Path(args.destination).resolve() + if target.name != slug: + print( + f"ERROR: 目标目录末段 '{target.name}' 与 slug '{slug}' 不一致。", + file=sys.stderr, + ) + return 1 + + parent = target.parent + if not parent.is_dir(): + print(f"ERROR: 父目录不存在: {parent}", file=sys.stderr) + return 1 + + if (target / ".git").exists(): + print( + f"ERROR: 目标已存在 .git,请先删除: {target / '.git'}", + file=sys.stderr, + ) + return 1 + + template_root = Path(__file__).resolve().parent.parent + if target.exists(): + entries = list(target.iterdir()) + if entries and not args.force: + print( + f"ERROR: 目标目录已存在且非空: {target}\n请加 --force 或换空目录。", + file=sys.stderr, + ) + return 1 + + target.mkdir(parents=True, exist_ok=True) + print(f"复制模板: {template_root} -> {target}") + for item in template_root.iterdir(): + name = item.name + if name in EXCLUDE_DIR_NAMES or name in EXCLUDE_FILE_NAMES: + continue + if name.endswith(".pyc"): + continue + dest = target / name + if item.is_dir(): + shutil.copytree(item, dest, ignore=_ignore_copy, dirs_exist_ok=True) + else: + shutil.copy2(item, dest) + + marker = target / ".openclaw-skill-template" + if marker.is_file(): + marker.unlink() + + _replace_template_placeholders(target, slug) + + _print_next_steps(target) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/verify_highlight.py b/tools/verify_highlight.py new file mode 100644 index 0000000..eee9a84 --- /dev/null +++ b/tools/verify_highlight.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Verify highlighted answer output file.""" + +from pathlib import Path + +import openpyxl + +dst = Path(r"D:\下载\安全知识竞赛题库_答案标红.xlsx") +src = Path(r"D:\下载\安全知识竞赛题库(1).xls") + +print(f"Source exists: {src.exists()}") +print(f"Output exists: {dst.exists()}") + +wb = openpyxl.load_workbook(dst, rich_text=True) +ws = wb.active +print(f"Sheet: {ws.title}, rows={ws.max_row}, cols={ws.max_column}") + +# Find header columns +header_row = None +options_col = None +answer_col = None +for row_idx in range(1, 20): + for col_idx in range(1, ws.max_column + 1): + val = ws.cell(row=row_idx, column=col_idx).value + if val and str(val).strip() == "题目选项": + options_col = col_idx + if val and str(val).strip() == "题目答案": + answer_col = col_idx + if options_col and answer_col: + header_row = row_idx + break + +print(f"Header row={header_row}, options_col={options_col}, answer_col={answer_col}") + +# Sample verification for first 5 rows with answers 1,2,3 +samples = [] +for row_idx in range(header_row + 1, ws.max_row + 1): + answer = ws.cell(row=row_idx, column=answer_col).value + if answer in ("1", "2", "3", 1, 2, 3): + cell = ws.cell(row=row_idx, column=options_col) + val = cell.value + runs = [] + if hasattr(val, "__iter__") and not isinstance(val, str): + for block in val: + font = block.font + bold = font.b if font else None + color = font.color if font else None + runs.append((block.text, bold, color)) + samples.append((row_idx, answer, runs)) + if len(samples) >= 5: + break + +for row_idx, answer, runs in samples: + print(f"\nRow {row_idx}, answer={answer}:") + for text, bold, color in runs: + marker = "RED_BOLD" if bold and color == "FF0000" else "normal" + print(f" [{marker}] {repr(text[:50])}")