diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..13d18b9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +* text=auto eol=lf +*.png binary +*.jpg binary +*.jpeg binary +*.webp binary diff --git a/.github/workflows/skill-eval.yml b/.github/workflows/skill-eval.yml index 79369ec..cb89e26 100644 --- a/.github/workflows/skill-eval.yml +++ b/.github/workflows/skill-eval.yml @@ -62,6 +62,11 @@ jobs: python tools/corpus.py --selftest python tools/fix_stats.py --selftest python tools/atomize.py --selftest + python tools/mcp_server.py --selftest + python tools/vlm_review.py --selftest + python evals/real_trigger.py --selftest + - name: 插件清单与资源存在性 + run: python tools/check_plugin.py - name: promptfoo 配置结构 run: | npx --yes promptfoo@$PROMPTFOO_VERSION validate config \ diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..3e4a270 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,9 @@ +{ + "mcpServers": { + "ecom-prompt-studio": { + "command": "python", + "args": ["${CLAUDE_PLUGIN_ROOT}/tools/mcp_server.py"], + "env": {} + } + } +} diff --git a/.zcode-plugin/plugin.json b/.zcode-plugin/plugin.json new file mode 100644 index 0000000..e23b881 --- /dev/null +++ b/.zcode-plugin/plugin.json @@ -0,0 +1,17 @@ +{ + "name": "ecom-prompt-studio", + "version": "2.1.0", + "description": "电商视觉五工作室提示词 SOP:34 个原子库、平台/品类/场景判据引擎、出图后平台机检与语料回流。", + "author": { + "name": "ADC1920" + }, + "license": "MIT", + "skills": [ + "./ecom-prompt-sop", + "./ecom-main-image-prompts", + "./ecom-detail-page-prompts" + ], + "commands": "./commands", + "hooks": "./hooks/hooks.json", + "mcpServers": "./.mcp.json" +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0c3e937 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,35 @@ +# 变更记录 + +本文件记录对外可见的形态变更。内部重构细节见 git log。 + +## 2.1.0 — 2026-11-12 + +装上就能用,不再是一半能力。 + +### 新增 + +- **ZCode 插件形态**:`.zcode-plugin/plugin.json` 声明三个技能、命令、hooks 与 MCP server,一次装齐。此前三个技能各自拷贝,脚本不随包走。 +- **MCP server**(`tools/mcp_server.py`):四个工具,`query_atom` 定位原子、`get_template` 取模板与规则、`validate_prompt` 跑判据校验、`check_output` 平台机检。零依赖,标准库实现 JSON-RPC。 +- **出图后自动机检**(`hooks/check_after_image.py`):`PostToolUse` 命中出图调用即跑平台机检,不过则把结果回注给模型。「机检不过不交付」从文档要求变成运行时强制。 +- **斜杠命令**:`/ecom:main`、`/ecom:detail`、`/ecom:sku`、`/ecom:reverse` 四条,不用记五工位术语。 +- **品牌锁规范**(`references/_brand-lock.md`):十个必锁视觉字段与 prepend 写法,解跨图漂移。 +- **AIGC 标注义务库**(`references/_rules/aigc-disclosure.json`):FTC、加州 SB 942、纽约 SB 8420-A、EU AI Act Art.50、国内标识办法五地判据,含图类型四分类与四条红线。 +- **视觉复核单生成器**(`tools/vlm_review.py`):产出结构化复核清单,启发式分只提示,结论由人给。 + +### 修复 + +- `fix_stats.py` 的提示仍指向已删除的 `--generate` 闭环。改为说明 manifest 需由外部通道产出并给出字段格式。 + +### 变更 + +- 出图通道维持不内置(2026-11-10 移除),机检与视觉验收改为对任意通道产出做后置校验。 + +## 2.0.0 — 2026-10-13 + +- 产品锁定改为「参考图为主 + 保留/改动清单」,负面词改为输出边界句。 +- 新增套图风格锁、证据账本四级、AI 合规节。 +- 新增静态检查 `lint_skills.py` 与触发用例。 + +## 1.0.0 — 2026-10-04 + +首个公开版本:34 篇 SOP、五个工位、三个技能。 diff --git a/README.en.md b/README.en.md new file mode 100644 index 0000000..4a0cb9f --- /dev/null +++ b/README.en.md @@ -0,0 +1,99 @@ +# Ecom-Prompt-Studio + +Prompt engineering for e-commerce visuals: turning "how to write a prompt" into a searchable, checkable, regression-tested production process. + +Models can already write prompts. What's missing is three things — **criteria, examples, feedback** — and this repo is built around exactly those: + +- **Criteria**: the whole SOP decomposed into 197 prompt templates, 571 rule clauses, 181 checklist items, and 122 placeholders. Every one is machine-readable. +- **Examples**: a positive-sample corpus, retrieved by similarity, so you can borrow wording that has already produced good images. +- **Feedback**: platform machine checks after generation. What fails doesn't ship; the fix sentence goes back into the prompt and the loop runs again. + +Each subdirectory is a complete skill: copy it into your local skills directory and it works. + +## Quick start + +### As a ZCode plugin (recommended) + +The repo root is a plugin. Add the repo directory as a local marketplace, then install: + +```bash +git clone https://github.com/ADC1920/Ecom-Prompt-Studio.git +``` + +In ZCode: **Plugin Marketplace → Add → Add Plugin Marketplace**, paste the cloned directory, then install `ecom-prompt-studio` from **Personal**. + +This installs all three skills, four slash commands, the post-generation check hook, and the MCP server in one step. + +### As plain skills + +```bash +cp -r Ecom-Prompt-Studio/ecom-prompt-sop ~/.agents/skills/ +cp -r Ecom-Prompt-Studio/ecom-main-image-prompts ~/.agents/skills/ +cp -r Ecom-Prompt-Studio/ecom-detail-page-prompts ~/.agents/skills/ +``` + +Note: copying only a skill directory ships the SOP text but not `tools/`. For machine checks and the MCP server, use the plugin install. + +## The five studios + +The SOP library is organized by studio. Decide the studio first, then locate exactly one atom. + +| Studio | Takes | Produces | Gate | +|---|---|---|---| +| **Main image** | full main-image set, single main image, buyer show, scene shot, white background | main-image plan + per-shot prompts + compliance check | retention list reused in every shot|claims sourced|first-image rules verified|output boundary sentence | +| **Detail page** | full detail page set, long image, page-by-page planning | page plan + per-page prompts | product lock before design|adjacent pages differ|anti-templating self-check | +| **SKU batch & cross-platform** | multi-color/multi-pack batches, multi-product swaps, 3:4 to 1:1, in-image text translation | variant plan + per-image prompts + conversion results | variant ownership stated|only count/color/layout change|no cropping after conversion|text translated not rewritten | +| **Model & video** | model head/clothing swaps, multi-pose shots, e-commerce video generation | model set or video prompts with timeline shots | faces don't drift|garment stays consistent|face-exposure rules met|video has a timeline | +| **Reverse engineering** | competitor images and videos, old-image refresh, SKU layout replication | breakdown table + replaced prompts + do-not-inherit list | brand names replaced|product traits not inherited|own product locked|video always gets a storyboard | + +Studio priority: reverse/refresh → reverse studio; batch variants → SKU studio; real people or video → model studio; detail-page output → detail studio; everything else → main-image studio. Buyer shows stay in the main-image studio despite the word "batch". + +## Three skills + +| Skill | Takes | Size | +|---|---|---| +| `ecom-prompt-sop` | five-studio SOP atom library: main image, detail page, SKU batch, model & video, reverse engineering | 1 `SKILL.md` + atom library | +| `ecom-main-image-prompts` | main-image director: bare main-image requests with no category cue, plans the whole set around the buyer decision path | 1 `SKILL.md` + 2 reference files | +| `ecom-detail-page-prompts` | detail-page director: bare detail-page requests with no category cue, plans page structure and per-page prompts by category | 1 `SKILL.md` + 1 reference file | + +The only routing rule: **category, action, or scenario cue present → SOP atom library; bare main image or bare detail page → hand off to the director skill.** This rule lives in `ecom-prompt-sop/SKILL.md` and the eval suite watches it. + +## Tooling + +| Script | What it does | +|---|---| +| `tools/atomize.py` | decompose SOPs into atoms, keep them self-contained, export readable text | +| `tools/compose_prompt.py` | five-slot prompt assembly, adds platform constraints and checks required fields | +| `tools/slop_check.py` | catches SD-era filler words, absolute claims, fake promotions, efficacy claims, AI tells | +| `tools/rules_engine.py` | validates slots against platform, category, and scenario rule files | +| `tools/check_image.py` | platform machine check after generation: white background, subject occupancy, ratio, resolution, file size, alpha channel, in-image text | +| `tools/visual_audit.py` | consistency and AI-feel heuristics — hints only, never a verdict | +| `tools/vlm_review.py` | generates a structured human review sheet; conclusions are always human | +| `tools/fix_stats.py` | repair-sentence effectiveness from manifests | +| `tools/corpus.py` | positive-sample corpus: add, similarity search, compile few-shot blocks | +| `tools/lint_skills.py` | full static check, the first CI gate | +| `tools/mcp_server.py` | MCP server exposing the atom library and checks as four tools | + +Machine checks run against images from **any** channel. This repo deliberately does not bundle an image-generation CLI: bring your own endpoint, then verify the output here. + +## MCP tools + +| Tool | Input | Output | +|---|---|---| +| `query_atom` | category, output, action | matching atom ids with evidence | +| `get_template` | atom id, optional index | template text plus that atom's rules, checklists, placeholders | +| `validate_prompt` | prompt, optional platform/scenario/category | slop check and rule-engine results | +| `check_output` | image path, platform | platform machine-check results | + +## Evals and CI + +- `evals/cases.json` — 30 routing cases covering out-of-scope, prompt injection, and same-family disambiguation +- `evals/router.js` — builds the routing prompt from the three live `SKILL.md` files +- `evals/check_cases.py` — keeps the machine-readable cases and the human-readable view in sync +- `.github/workflows/skill-eval.yml` — static checks, eight script self-tests, routing regression on push/PR, weekly quality sampling + +Routing regression feeds only the descriptions and routing table to a model and asserts the returned skill and atom. It catches "wrong skill" and "wrong atom" regressions; it does not judge prompt quality — that's what the quality sampling is for. + +## License + +MIT diff --git a/README.md b/README.md index 700614d..2d8b5ad 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,19 @@ ## 快速开始 -两种运行方式,装法相同,都是把技能目录整体复制过去,保持 `<技能目录>/<技能名>/SKILL.md` 的层级: +### 推荐:作为 ZCode 插件安装 + +仓库根就是插件,三个技能、四条斜杠命令、出图后自动机检、MCP server 一次装齐: + +```bash +git clone https://github.com/ADC1920/Ecom-Prompt-Studio.git +``` + +在 ZCode 里打开 **插件市场 → 添加 → 添加插件市场**,粘贴克隆下来的目录,然后在**个人**里安装 `ecom-prompt-studio`。 + +只拷单个技能目录也能用,但那会丢掉 `tools/`:机检、规则引擎、语料检索、MCP 工具都不在技能目录里,SKILL.md 提到脚本的地方只能人工代劳。要完整能力就走插件。 + +### 手动:只拷技能 ```bash git clone https://github.com/ADC1920/Ecom-Prompt-Studio.git @@ -104,20 +116,37 @@ atomize compose slop check visual fix_stats | `tools/visual_audit.py` | 视觉验收:主体一致性(感知哈希 + 颜色直方图)与 AI 感启发式,只提示不判定 | | `tools/fix_stats.py` | 从 manifest 统计每条修复句的真实生效率,用来判断措辞要不要改 | | `tools/corpus.py` | 正样本语料:录入、相似度检索、编译成 few-shot 范例段 | +| `tools/vlm_review.py` | 生成视觉复核单:启发式分只提示,结论由人给 | +| `tools/mcp_server.py` | MCP server:把原子库与判据暴露成四个可调用工具 | | `tools/lint_skills.py` | 静态检查全集,CI 的第一道门 | +| `tools/check_plugin.py` | 插件清单自校验:字段、资源存在性、路径越界、事件名合法性 | + +除 `check_image.py`、`visual_audit.py`、`vlm_review.py` 用到 Pillow 外全是标准库;带 `--selftest` 的都能离线验,不依赖密钥与网络。 -八个工具带 `--selftest`,离线可验,不依赖密钥与网络。除 `check_image.py` 与 `visual_audit.py` 用到 Pillow 外全是标准库。 +## 装机后自带的能力 + +装成插件之后,下面这些不靠模型自觉,是运行时机制: + +| 机制 | 触发 | 效果 | +|---|---|---| +| 出图后自动机检 | 任何工具调用在 `out/` 下落了图,且命令里带 `--platform` | 跑平台机检,不通过就把结果回注给模型,提示修正重出 | +| MCP 四个工具 | 模型自行调用 | `query_atom` 定位原子、`get_template` 取模板、`validate_prompt` 校验、`check_output` 机检 | +| 斜杠命令 | 用户输入 `/ecom:main` 等 | 不用记五工位术语,直接进对应工位流程 | + +MCP 工具的意义在于把「读文档 → 判工位 → 读原子 → 挑模板」四步压成一次调用。不接 MCP 时这四步全靠模型自己走。 `check_image.py` 的 OCR 会自己找 tesseract:不在 PATH 时扫常见安装路径,默认语言目录缺中文时去找用户级 tessdata。缺依赖就标 `skipped`,不会假装通过。 ## 判据规则与语料 -SOP 给的是「方法」,下面两样给的是「判据与范例」: +SOP 给的是「方法」,下面几样给的是「判据与范例」: - `references/_rules/`:平台(12)、品类(9)、场景(11)三份 JSON,把硬判据变成可校验规则——白底与占比阈值、禁用宣称、必填槽位、画面禁项、AI 标注义务 +- `references/_rules/aigc-disclosure.json`:AIGC 标注义务与法律依据,五地法条、图类型四分类、四条红线 +- `references/_brand-lock.md`:品牌锁十个必锁字段与 prepend 写法,解跨图漂移 - `references/_corpus/`:验证过并出过好图的提示词样本,按相似度检索、编译成 few-shot -规则管「不能写什么」,语料管「写成什么样才算够」。 +规则管「不能写什么」,语料管「写成什么样才算够」,品牌锁管「一套图看着像不像同一家」。 ## 出图与授权 @@ -131,10 +160,12 @@ SOP 给的是「方法」,下面两样给的是「判据与范例」: 技能选错是用户最容易感知的问题,所以触发回归是评测主线。CI 分三层: -1. **静态检查**(每次 push / PR):`lint_skills.py` + `check_cases.py` + `atomize.py check` + 九个工具自检 + promptfoo 配置结构校验,不需要密钥 +1. **静态检查**(每次 push / PR):`lint_skills.py` + `check_cases.py` + `atomize.py check` + `check_plugin.py` + 十一个工具自检 + promptfoo 配置结构校验,不需要密钥 2. **触发回归**(每次 push / PR):promptfoo 跑 30 条提示,断言命中的技能名与**原子编号**。评测输入直接读三个 `SKILL.md`,路由表一改立刻生效 3. **文本质量抽检**(每周一 + 手动):按原子编号读 `source_text` 生成提示词,用 `llm-rubric` 按该 SOP 的硬规则评分 +三层之外还有一套**真实触发评测**(`evals/real_trigger.py`),只在里程碑手动跑:把技能目录交给真实引擎,看它是否自行加载并走到正确原子。前两层抓不到「装了但不触发」,这一层能,代价是慢且可能计费。 + 三个技能各带一份 `EVAL.md` 答案键(能力边界题 / 生产决策题 / 应用任务 + 评分口径)。答案键不链接进 `SKILL.md`,`lint_skills.py` 会校验它存在且未被入口引用——避免被评的 agent 直接读到答案。 模型经 DeepSeek 官方 provider 接入,密钥走仓库 Secret `DEEPSEEK_API_KEY`,不落任何文件。未配置时触发回归直接失败并报错,不会出现「没跑测试却全绿」。 @@ -143,7 +174,9 @@ SOP 给的是「方法」,下面两样给的是「判据与范例」: ```bash python tools/lint_skills.py +python tools/check_plugin.py python evals/check_cases.py +python evals/real_trigger.py --dry-run # 看真实评测会跑哪些用例 npx promptfoo@0.124.1 eval -c evals/promptfooconfig.yaml npx promptfoo@0.124.1 eval -c evals/promptfooconfig.quality.yaml ``` @@ -154,19 +187,28 @@ npx promptfoo@0.124.1 eval -c evals/promptfooconfig.quality.yaml ```text . +├─ .zcode-plugin/plugin.json # 插件清单:技能 + 命令 + hooks + MCP +├─ .mcp.json # MCP server 声明(四个工具) +├─ hooks/ +│ ├─ hooks.json # PostToolUse 匹配出图调用 +│ └─ check_after_image.py # 出图后自动机检并回注结果 +├─ commands/ # /ecom:main /detail /sku /reverse ├─ ecom-prompt-sop/ │ ├─ SKILL.md # 五工作室入口:判工位 → 定位原子 → 取模板与规则 │ ├─ EVAL.md # 评测答案键(评测者用,不被 SKILL.md 引用) │ └─ references/ │ ├─ _atoms/ # 原子库 + 索引 + 语义层 + 机械层 -│ ├─ _rules/ # 平台 / 品类 / 场景判据 +│ ├─ _rules/ # 平台 / 品类 / 场景判据 + AIGC 标注义务 │ ├─ _corpus/ # 正样本语料 │ ├─ _index.json # 篇目索引(槽位映射) +│ ├─ _brand-lock.md # 品牌锁十个字段 │ └─ _prompt-slots.md # 五槽位规范 ├─ ecom-main-image-prompts/ ├─ ecom-detail-page-prompts/ -├─ tools/ # 拆解、组装、机检、闭环、验收、统计 -├─ evals/ # promptfoo 配置、用例与断言脚本 +├─ tools/ # 拆解、组装、机检、验收、统计、MCP、插件校验 +├─ evals/ # promptfoo 配置、用例、断言脚本、真实触发评测 +├─ CHANGELOG.md +├─ README.en.md └─ .github/workflows/skill-eval.yml # CI(三个 job:静态 / 触发回归 / 质量抽检) ``` @@ -175,6 +217,8 @@ npx promptfoo@0.124.1 eval -c evals/promptfooconfig.quality.yaml ## 已知限制 - **AI 感阈值是经验值**:`visual_audit.py` 的饱和度、平滑度、死白、边缘密度这些判据,还没用真实的好图 / AI 图批次标定过,当提示用,不当结论 -- **语料库只有 2 条种子**:检索质量取决于样本量,现在属于「能用但不够准」 +- **语料库只有 2 条种子**:检索质量取决于样本量,现在属于「能用但不够准」。它靠真实出图积累,不能人工编造,所以这一步只能等实际使用 +- **VLM 复核不自动判图**:`vlm_review.py` 只生成复核单,不给结论。VLM 在「保留约束是否满足」这类细粒度判断上会失准,把它的分数当通过依据会把错误固化 - **OCR 需要本地装 tesseract**:缺 pytesseract 或 tesseract 时这项走 skipped,不会误报通过;CI 环境没装,所以云端不检查图内文字 - **模板抽取有少量噪声**:少数没有围栏代码块的篇目走裸段落规则,个别片段可能偏宽,未逐篇人工复核 +- **真实触发评测需自备引擎**:`evals/real_trigger.py` 只提供框架与判定逻辑,引擎命令与凭据由使用者提供,CI 里只跑它的离线自检 diff --git a/commands/ecom-detail.md b/commands/ecom-detail.md new file mode 100644 index 0000000..c9fb075 --- /dev/null +++ b/commands/ecom-detail.md @@ -0,0 +1,22 @@ +--- +description: 出一套详情页提示词(详情页台) +--- + +# /ecom:detail + +按五工位详情页台出逐页方案。 + +参数(都可选,缺了先问,不要猜):`$ARGUMENTS` + +执行步骤: + +1. 用 `query_atom` 定位原子,产物填详情页。 +2. 用 `get_template` 取模板、规则与清单。 +3. 先出**页面规划**:每页的关键词与职责,相邻页主题不得重复。 +4. 再出**逐页提示词**:每页先写产品锁定约束,再写设计画面。 +5. 每页写清构图、景别、镜头、产品位置、信息布局、文字排版、背景材质、光影,不得只写「放标题」。 +6. 定稿前用 `validate_prompt` 自查。 + +防模板化自检:整套关键词与信息模块换个产品也能用,就重写。 + +没有品类线索的裸详情页方案,转 `ecom-detail-page-prompts` 技能。 diff --git a/commands/ecom-main.md b/commands/ecom-main.md new file mode 100644 index 0000000..c542e34 --- /dev/null +++ b/commands/ecom-main.md @@ -0,0 +1,19 @@ +--- +description: 出一套主图提示词(主图台) +--- + +# /ecom:main + +按五工位主图台出主图方案。 + +参数(都可选,缺了先问,不要猜):`$ARGUMENTS` + +执行步骤: + +1. 用 `query_atom` 工具定位原子,参数取用户的品类、产物(主图)、动作。 +2. 读命中原子:`get_template` 取模板与规则。 +3. 先出**主图规划表**(每张的职责、比例、背景、是否模特、文字规则、核心),再出**逐张提示词**。 +4. 每条提示词定稿前用 `validate_prompt` 自查,带上 platform 与 scenario。 +5. 按主图台门禁交付:产品保留清单在每张里完整复用;卖点分级只说已确认与有依据的;首图符合目标平台规范;每张以输出边界句收尾。 + +没有品类线索的裸主图方案,转 `ecom-main-image-prompts` 技能,不要在本命令内硬出。 diff --git a/commands/ecom-reverse.md b/commands/ecom-reverse.md new file mode 100644 index 0000000..8f36ea2 --- /dev/null +++ b/commands/ecom-reverse.md @@ -0,0 +1,20 @@ +--- +description: 反推对标图或对标视频(反推台) +--- + +# /ecom:reverse + +按五工位反推台把别人的图或视频拆成自己的提示词。 + +参数(都可选,缺了先问,不要猜):`$ARGUMENTS` + +执行步骤: + +1. 用 `query_atom` 定位原子,动作填反推或翻新。 +2. `get_template` 取模板与规则。 +3. 先出**拆解表**:画面结构、镜头、光影、信息层级,逐项拆开。 +4. 再出**替换后完整提示词**:换成自有产品,品牌名替换,商品特性不继承。 +5. 附**不继承清单**:明确哪些是对方的、不跟着抄。 +6. 视频反推必出分镜(时间轴镜头),不得只给静态描述。 + +反推的是别人的素材,不得复制其品牌元素与独占设计。 diff --git a/commands/ecom-sku.md b/commands/ecom-sku.md new file mode 100644 index 0000000..60c5508 --- /dev/null +++ b/commands/ecom-sku.md @@ -0,0 +1,19 @@ +--- +description: SKU 批量变体与跨平台转换(SKU 台) +--- + +# /ecom:sku + +按五工位 SKU 台做批量变体、比例转换或图内文字翻译。 + +参数(都可选,缺了先问,不要猜):`$ARGUMENTS` + +执行步骤: + +1. 用 `query_atom` 定位原子,动作填批量或翻译。 +2. `get_template` 取模板与规则。 +3. 先出**SKU 变体规划**:每个变体归属哪个 SKU、改什么(数量、颜色、版式)、不改什么。 +4. 再出逐张提示词。主体只改数量、颜色、版式;转换后主体不裁切;文字只译不改。 +5. 定稿前 `validate_prompt` 自查。 + +比例转换(3:4 转 1:1)必须写明不裁切主体,改用补背景或移位。 diff --git a/ecom-prompt-sop/SKILL.md b/ecom-prompt-sop/SKILL.md index e3c61a1..ef2d8ff 100644 --- a/ecom-prompt-sop/SKILL.md +++ b/ecom-prompt-sop/SKILL.md @@ -215,3 +215,6 @@ description: "电商视觉五工作室(AI 主图台 / AI 详情页台 / SKU - 原子内互相引用的编号(如「见第 X 步」)以该原子自身为准。 - **反 AI 感**:提示词不堆 8k / masterpiece / hyperrealistic / 极致 / 完美 这类空洞词,改用材质、光位、镜头与物理关系描述;规避过度光滑、塑料感高光、完美对称、悬浮无投影、不可能反射等 AI 痕迹。定稿后可用 `tools/slop_check.py` 自查(单独安装技能目录时不带脚本,按本段清单人工过)。 - **生成后机检**:出图后按目标平台跑 `tools/check_image.py`(白底纯度 / 主体占比 / 比例 / 分辨率 / 文件大小 / OCR 文字),机检不过的图不交付、不上架。机检只覆盖可机检的子集,模特造型、AI 标注义务与货不对板仍需人工核对。 +- **套图一致性**:出一套图(多张主图、多页详情页、多 SKU 变体)时,按 `references/_brand-lock.md` 的十个字段写品牌锁,锁定的字段要逐字相同地出现在每张提示词里,变化的只留在场景、主体、细节段。不锁就会出现十种「干净高级」。 +- **AI 标注义务**:出图前先按 `references/_rules/aigc-disclosure.json` 定图类型——A 实拍无人、B 常规修图、C 写实 AI 人物、D 复刻真人。C 类必须披露(Amazon 用 XMP dc:subject,国内显式加隐式双标识,欧盟与纽约各有显式要求);D 类直接拒绝,不生成。跨境投放按投放地区取最严一条执行。 +- **视觉复核**:机检管几何与合规,管不了产品一致性。需要人工复核单时跑 `tools/vlm_review.py --atom aNN --image out/x.png`,它生成逐条核对表与保留清单,结论由人填。`visual_audit.py` 的分数是启发式,只用于指示先看哪里,不作为通过依据。 diff --git a/ecom-prompt-sop/references/_brand-lock.md b/ecom-prompt-sop/references/_brand-lock.md new file mode 100644 index 0000000..63367bf --- /dev/null +++ b/ecom-prompt-sop/references/_brand-lock.md @@ -0,0 +1,59 @@ +# 品牌锁:让一套图看起来像同一家的 + +> 模板只是起点,一致性才是成品感。这里锁的是「跨图不漂移」的那几个变量,不是风格灵感。 + +## 为什么需要它 + +单张图好看不难,一套从主图到包装、从首图到详情页的图放在一起像同一个品牌,才是难点。 +模型每次生成都会重新解读形容词,「干净高级」在十次生成里能得到十种不同的干净高级。 +锁定的办法是把可枚举的视觉变量写死,让变化的只剩画面内容。 + +## 十个必锁字段 + +| 字段 | 锁什么 | 取值示例 | 不锁的后果 | +|---|---|---|---| +| `light_direction` | 主光方位角与高度 | 左上 45 度、略高于视线 | 每张光线方向不同,套图看不出是同一组 | +| `light_temp` | 色温 | 5500K 冷白 / 4800K 暖白 | 冷暖混杂,像拼凑的素材 | +| `shadow_type` | 投影性质 | 自然接触影、无投射影 | 有的悬浮有的落地,空间关系矛盾 | +| `bg_color` | 背景色值(不是形容词) | #FFFFFF / #EDE8E1 | 十种白,拼图时露馅 | +| `bg_material` | 背景材质 | 无缝纸、哑光布、木纹 | 质感跳变 | +| `camera_height` | 机位高度 | 平视、略俯 15 度 | 产品比例在各图之间不一致 | +| `lens` | 镜头焦距观感 | 50mm 标准、85mm 压缩 | 透视畸变程度不同,同一产品看着变形 | +| `color_grading` | 调色倾向 | 中性、低饱和暖调 | 一张冷一张暖 | +| `typography` | 字体与排版规则 | 无衬线粗体、标题字高 7% | 详情页每页字体不一样 | +| `negative` | 输出边界 | 无文字、无水印、无多余手指 | 各图违规项不一致 | + +## 怎么写进提示词 + +锁定的字段要**逐字相同**地出现在每张提示词里,不是换个说法描述同一件事。 +建议抽成一个 prepend 段,直接复制到每条提示词开头: + +``` +【品牌锁】主光左上 45 度略高于视线,5500K 冷白,自然接触影;背景纯白 #FFFFFF 无缝纸; +机位平视,50mm 观感;中性调色不加暖调滤镜;无文字、无水印、无边框。 +``` + +每张图的差异只写在品牌锁之后的场景段、主体段、细节段里。 + +## 按工位该锁哪几段 + +| 工位 | 必须锁 | 可以不锁 | +|---|---|---| +| 主图台 | 光位、色温、阴影、背景 | 构图角度、是否模特 | +| 详情页台 | 光位、色温、调色、排版规则 | 每页的场景与信息模块 | +| SKU 台 | 光位、色温、背景、机位 | 颜色、数量、版式 | +| 模特与视频台 | 光位、色温、调色、镜头 | 姿势、场景、服装搭配 | +| 反推台 | 自有产品的保留清单 | 其余全部按对标图拆解 | + +## 用法 + +本项目的做法是**在提示词里锁**,不引入外部状态文件。原因:技能是按目录拷贝安装的, +多一个 `brand.yaml` 就多一个必须随包分发、且用户要记得改的文件。写在提示词里, +一条提示词自带全部锁,复制到哪里都不丢。 + +如果确需跨任务复用,把上面的 prepend 段存成自己的文本片段,用的时候粘进第一条即可。 + +## 校验 + +套图出完后,用 `python tools/visual_audit.py` 做一致性提示(感知哈希 + 颜色直方图)。 +它是启发式,分数只用于提示复核,不能作为通过或不通过的最终依据。 diff --git a/ecom-prompt-sop/references/_rules/aigc-disclosure.json b/ecom-prompt-sop/references/_rules/aigc-disclosure.json new file mode 100644 index 0000000..4527416 --- /dev/null +++ b/ecom-prompt-sop/references/_rules/aigc-disclosure.json @@ -0,0 +1,75 @@ +{ + "version": 1, + "note": "AIGC 标注义务与法律依据。这是跨境与国内出图的前置判据,与 platform.json 的机检阈值分开:那份管图上像素,这份管文案与披露义务。数值与金额来自公开法条,平台执行口径以官方卖家中心为准;本文件不是法律意见。", + "checked_at": "2026-11", + "regions": { + "us-federal": { + "label": "美国联邦(FTC)", + "authority": "Federal Trade Commission Act §5(不公平或欺骗性行为)", + "requirement": "AI 生成或 AI 修饰的产品图若造成实质误导(虚假呈现商品外观、材质、尺寸、效果),即属欺骗性行为。不要求逐图披露 AI,但禁止虚假呈现。", + "penalty": "民事罚款与责令改正;消费者集体诉讼风险", + "seller_action": "商品图必须与实物一致;不得用 AI 修出实物没有的效果(如把薄款修成厚款、改颜色、改结构)。" + }, + "us-california": { + "label": "加州 SB 942(California AI Transparency Act)", + "authority": "California AI Transparency Act", + "requirement": "生成式 AI 提供方须提供 AI 检测工具与元数据披露;大型平台须提供可见标识。面向内容生成方的是元数据留存义务。", + "penalty": "每次违规民事罚款(按日累计),另有行政罚款上限", + "seller_action": "保留生成元数据(C2PA / SynthID 等),不要剥离;用于公开投放的 AI 图保留可追溯来源。" + }, + "us-newyork": { + "label": "纽约 SB 8420-A(AI 人物披露)", + "authority": "New York Synthetic Performer Disclosure", + "requirement": "广告中使用合成人像(synthetic performer)须显著披露,且不得使用已故人物肖像未经授权。", + "penalty": "每次违规民事罚款;持续违规按日累计", + "seller_action": "含写实 AI 模特/人脸的广告须显式披露;Amazon 场景下用 XMP dc:subject 标记。" + }, + "eu": { + "label": "欧盟 AI Act Article 50(透明度义务)", + "authority": "Regulation (EU) 2024/1689, Art. 50", + "requirement": "AI 生成或操纵的图像、音频、视频构成 deepfake 时,部署者须以清晰可辨的方式披露其为人工生成或操纵。纯艺术创作可豁免部分义务,商业广告不豁免。", + "penalty": "各成员国设定,上限可达全球营业额比例(与 GDPR 同档)", + "seller_action": "面向欧盟投放的 AI 生成人像广告须标注;机器可读水印与显式声明并行最稳。" + }, + "cn": { + "label": "中国《人工智能生成合成内容标识办法》", + "authority": "《人工智能生成合成内容标识办法》(2025-09-01 施行)+《互联网信息服务深度合成管理规定》", + "requirement": "显式标识(用户可感知的文字、声音、图形提示)与隐式标识(文件元数据)并行。服务提供者与内容传播平台各有义务。", + "penalty": "由网信、公安等部门依职责责令改正、警告、罚款;平台另可按社区规则处置", + "seller_action": "国内平台投放的 AI 图做双标识:画面/描述显式说明 + 文件元数据保留生成信息。" + } + }, + "image_classes": { + "A": { + "label": "实拍无人", + "description": "真实拍摄、未经过 AI 生成,可含常规修图", + "disclosure": "无 AI 披露义务", + "note": "常规色彩、曝光、去瑕疵修图仍属 A;一旦用生成式模型补全或改变商品本身,升为 B 或 C。" + }, + "B": { + "label": "常规修图", + "description": "实拍底图 + 非生成式处理(裁剪、校色、锐化、去背景)", + "disclosure": "通常无;投放地区有专门规则时从其规则", + "note": "去背景若用生成式模型补出原本不存在的边缘结构,应升为 C。" + }, + "C": { + "label": "写实 AI 人物", + "description": "AI 生成的写实人像或模特,含换脸、换装、合成模特", + "disclosure": "必须披露(纽约 SB 8420-A、EU AI Act Art.50、国内双标识)", + "note": "Amazon 场景写 XMP dc:subject = contains-synthetic-performer;画面水印与文件名不算披露。" + }, + "D": { + "label": "复刻真人", + "description": "未经授权复刻可识别真人(明星脸、网红、他人肖像)", + "disclosure": "不得生成", + "note": "主动拦截。即使是「像某明星」的模糊指向也不做出。" + } + }, + "red_lines": [ + "不剥离 C2PA / SynthID 等来源水印或元数据", + "不教用户规避平台 AI 标注义务", + "不把 AI 图伪装成实拍图交付或宣传", + "不生成未经授权的可识别真人肖像" + ], + "usage": "出图前先定 image_class:A 与 B 按常规流程;C 必须在提示词与交付说明里带上披露义务;D 直接拒绝。跨境投放时按投放地区取 regions 中最严的一条执行。" +} diff --git a/evals/real_trigger.py b/evals/real_trigger.py new file mode 100644 index 0000000..b380143 --- /dev/null +++ b/evals/real_trigger.py @@ -0,0 +1,193 @@ +# -*- coding: utf-8 -*- +"""真实 agent 触发评测:验证装进真实引擎后技能会不会真的被触发。 + +和 evals/ 的现有触发回归不是一回事:那边的做法是把 description 与路由表喂给模型, +只断言它输出的路由结论;模型本身没有装技能的概念,所以抓不到「装了但没触发」这类问题。 +这一套改动是把同一个技能目录交给真引擎,看它是否自行加载并走到正确原子。 + +三层分工(借 NVIDIA SkillEvaluator 的分层思路): +- Tier 1 静态检查:tools/lint_skills.py,每次提交跑 +- Tier 2 近似触发:evals/,每次 PR 跑,便宜 +- Tier 3 真实触发:本脚本,只在里程碑手动跑,慢且可能计费 + +用法: + # 先看会跑哪些用例、大概花多少(不发请求) + python evals/real_trigger.py --dry-run + + # 真跑(需要引擎可执行文件与凭据,按你的环境自备) + python evals/real_trigger.py --engine "claude -p" --limit 5 + + # 只跑某几条用例 + python evals/real_trigger.py --engine "claude -p" --ids 1,17,20,23 + +引擎约定:命令从 stdin 读提示词,把回答写到 stdout。任何满足这条约定的引擎都能接 +(claude -p、zcode -p、codex exec 等)。脚本不假设具体引擎的旗标。 +""" +import argparse +import json +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent +REPO_ROOT = HERE.parent +CASES = HERE / "cases.json" + + +def load_cases(): + data = json.loads(CASES.read_text(encoding="utf-8")) + return data.get("cases") or data.get("items") or [] + + +def build_prompt(case): + """真实引擎里技能由引擎自己加载,这里只发用户原话。""" + return case.get("input") or "" + + +def parse_reply(text): + """从回答里找技能名与原子编号。真实引擎输出是自由文本,只做宽松提取。""" + skills = set() + for name in ("ecom-prompt-sop", "ecom-main-image-prompts", "ecom-detail-page-prompts"): + if name in text: + skills.add(name) + atoms = set(re.findall(r"\ba(\d{2})\b", text, re.I)) + return {"skills": sorted(skills), "atoms": ["a" + a.zfill(2) for a in sorted(atoms)]} + + +def judge(case, reply): + """对照用例预期判命中。只用包含匹配,避免把格式差异当成错。""" + expect = case.get("expect") or {} + got = parse_reply(reply) + problems = [] + + want_skill = expect.get("skill") + if want_skill and want_skill not in got["skills"]: + problems.append(f"未出现预期技能 {want_skill}(实际 {got['skills'] or '无'})") + + want_atom = expect.get("atom") + if want_atom and want_atom not in got["atoms"]: + problems.append(f"未出现预期原子 {want_atom}(实际 {got['atoms'] or '无'})") + + return (not problems), problems, got + + +def run_engine(engine, prompt, timeout): + try: + p = subprocess.run( + engine, input=prompt, shell=True, capture_output=True, + text=True, encoding="utf-8", timeout=timeout, + ) + except subprocess.TimeoutExpired: + return None, f"超时({timeout}s)" + except OSError as e: + return None, f"无法启动引擎:{e}" + if p.returncode != 0: + return None, f"引擎退出码 {p.returncode}:{(p.stderr or '')[:200]}" + return p.stdout or "", None + + +def main(): + ap = argparse.ArgumentParser(description="真实 agent 触发评测(Tier 3)") + ap.add_argument("--engine", help="引擎命令,从 stdin 读提示词、stdout 输出回答") + ap.add_argument("--limit", type=int, help="只跑前 N 条") + ap.add_argument("--ids", help="逗号分隔的用例 id,如 1,17,20,23") + ap.add_argument("--timeout", type=int, default=180, help="单条超时秒数,默认 180") + ap.add_argument("--json", action="store_true", help="输出 JSON 汇总") + ap.add_argument("--dry-run", action="store_true", help="只列出将跑的用例,不发请求") + ap.add_argument("--selftest", action="store_true") + args = ap.parse_args() + + if args.selftest: + return selftest() + + cases = load_cases() + if not cases: + print(f"没有读到用例:{CASES}", file=sys.stderr) + return 2 + + if args.ids: + want = {x.strip() for x in args.ids.split(",") if x.strip()} + cases = [c for c in cases if str(c.get("id")) in want] + if args.limit: + cases = cases[: args.limit] + + if args.dry_run or not args.engine: + print(f"将跑 {len(cases)} 条用例(真实引擎评测,可能计费):") + for c in cases: + exp = c.get("expect") or {} + print(f" #{c.get('id')} {c.get('input')} → {exp.get('skill', '-')} / {exp.get('atom', '-')}") + if not args.engine: + print("\n未提供 --engine,仅列出。加 --engine \"<命令>\" 才会真跑。") + return 0 + + if shutil.which(args.engine.split()[0]) is None: + print(f"找不到引擎命令:{args.engine.split()[0]}", file=sys.stderr) + return 2 + + results = [] + for c in cases: + reply, err = run_engine(args.engine, build_prompt(c), args.timeout) + if err: + results.append({"id": c.get("id"), "ok": False, "problems": [err], "got": None}) + continue + ok, problems, got = judge(c, reply) + results.append({"id": c.get("id"), "ok": ok, "problems": problems, "got": got}) + + passed = sum(1 for r in results if r["ok"]) + if args.json: + print(json.dumps({"total": len(results), "passed": passed, "results": results}, + ensure_ascii=False, indent=2)) + else: + for r in results: + mark = "PASS" if r["ok"] else "FAIL" + print(f"{mark} #{r['id']}") + for p in r["problems"]: + print(f" {p}") + print(f"\n{passed}/{len(results)} 通过") + + # 与 evals/README.md 的基线表对比:这里只报告,不擅自改基线 + if passed < len(results): + print("\n有失败项。真实触发失败通常意味着 description 或路由表在实际引擎里不够强," + "而不是用例写错了——先看命中的是哪个技能,再决定改描述还是改判据。", file=sys.stderr) + return 1 + return 0 + + +def selftest(): + fails = [] + + ok, problems, got = judge({"expect": {"skill": "ecom-prompt-sop", "atom": "a21"}}, + "我用 ecom-prompt-sop 的 a21 来做") + if not ok: + fails.append(f"应命中却判失败:{problems}") + + ok, problems, _ = judge({"expect": {"skill": "ecom-main-image-prompts"}}, + "走 ecom-prompt-sop 的 a03") + if ok: + fails.append("技能不匹配应判失败") + + ok, _, _ = judge({"expect": {"atom": "a21"}}, "用 a3 做") + if ok: + fails.append("原子编号不完整时不应误判为 a21") + + got = parse_reply("选 a03 和 a21") + if sorted(got["atoms"]) != ["a03", "a21"]: + fails.append(f"原子提取错误:{got['atoms']}") + + cases = load_cases() + if not cases: + fails.append("用例读取失败") + + if fails: + for f in fails: + print("FAIL", f) + return 1 + print("selftest: 命中判定、不匹配判定、编号提取、用例读取 全部通过") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/hooks/check_after_image.py b/hooks/check_after_image.py new file mode 100644 index 0000000..7888d81 --- /dev/null +++ b/hooks/check_after_image.py @@ -0,0 +1,156 @@ +# -*- coding: utf-8 -*- +"""PostToolUse hook:出图后自动跑平台机检,不过就把结果回注给模型。 + +为什么需要它:SKILL.md 写着「机检不过不交付」,但那只是文档要求,靠模型自觉。 +接上 hook 之后这条变成运行时强制——只要工具调用产出了图片,机检就一定会跑。 + +触发方式:hooks.json 的 PostToolUse 匹配 Bash|Write|Edit(出图通常经由这三者之一 +落盘)。hook 从 stdin 读 JSON,自己判断本次调用是否产生了新图片;没有则安静退出 0。 + +输出契约(ZCode hooks.json schema):只在需要回注时输出 + {"hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": "..."}} +其余情况不输出,靠退出码 0 表示放行。任何异常一律 exit 0,绝不因为机检自身故障 +阻断用户的正常流程。 +""" +import json +import os +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent +REPO_ROOT = HERE.parent +TOOLS_DIR = REPO_ROOT / "tools" +IMG_EXT = {".png", ".jpg", ".jpeg", ".webp"} +# 只有这些目录下的新图才值得自动机检,避免对仓库源码里的杂图报警 +WATCH_DIRS = ("out",) + + +def _find_platform(text): + """只在显式平台参数里认平台,不从文件名或路径推断。 + + 早先的写法把整段命令当 haystack,结果路径里的 product-amazon.png 会被误判成 + 指定了 amazon 平台。平台是用户的意图,必须来自 --platform/--platform= 这类 + 显式写法。 + """ + import re + if not text: + return None + m = re.search(r"--platform[=\s]+([A-Za-z0-9_-]+)", text) + if m: + return m.group(1).lower() + return None + + +def _candidate_images(payload): + """从 hook 输入里找出本次调用落盘的图片路径。""" + tool_input = payload.get("tool_input") or {} + found = [] + + for key in ("file_path", "path", "notebook_path"): + v = tool_input.get(key) + if isinstance(v, str) and Path(v).suffix.lower() in IMG_EXT: + found.append(v) + + cmd = tool_input.get("command") or "" + if isinstance(cmd, str): + for tok in cmd.replace('"', " ").replace("'", " ").split(): + if Path(tok).suffix.lower() in IMG_EXT: + found.append(tok) + + return found + + +def _in_watch_dir(path): + p = Path(path) + try: + rel = p.resolve().relative_to(REPO_ROOT) + except (ValueError, OSError): + return False + return bool(rel.parts) and rel.parts[0] in WATCH_DIRS + + +def main(): + raw = sys.stdin.read() + if not raw.strip(): + return 0 + try: + payload = json.loads(raw) + except json.JSONDecodeError: + return 0 + + images = [p for p in _candidate_images(payload) if _in_watch_dir(p)] + images = [p for p in images if Path(p).exists()] + if not images: + return 0 + + cmd_text = json.dumps(payload.get("tool_input") or {}, ensure_ascii=False) + platform = _find_platform(cmd_text) + if not platform: + return 0 + + sys.path.insert(0, str(TOOLS_DIR)) + lines = [] + for img in images[:5]: + res = _run_check(img, platform) + if res is None: + continue + verdict = res.get("verdict") + if verdict in ("fail", "warn", "error"): + detail = _summarize(res, verdict) + lines.append(detail) + + if not lines: + return 0 + + ctx = ("出图后平台机检结果如下。按 SKILL.md 的交付门禁,机检不通过的图不得交付或上架;" + "请修正提示词后重出,或说明为何该问题不影响本平台规则。\n" + "\n".join(lines)) + sys.stdout.write(json.dumps({ + "hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": ctx} + }, ensure_ascii=False)) + sys.stdout.flush() + return 0 + + +def _run_check(img, platform): + """走 CLI 而不是内部函数:CLI 的 --json 输出与退出码是稳定契约。""" + import subprocess + try: + p = subprocess.run( + [sys.executable, str(TOOLS_DIR / "check_image.py"), img, "--platform", platform, "--json"], + capture_output=True, text=True, encoding="utf-8", timeout=90, + ) + except (subprocess.TimeoutExpired, OSError): + return None + raw = (p.stdout or "").strip() + if not raw: + return None + try: + arr = json.loads(raw) + except json.JSONDecodeError: + return None + if not isinstance(arr, list) or not arr: + return None + return arr[0] + + +def _summarize(res, verdict): + img = res.get("file", "?") + platform = res.get("platform", "?") + if verdict == "error": + return f"{img}:机检无法执行({res.get('detail', '未知原因')}),请人工核对" + bad = [c for c in (res.get("checks") or []) if c.get("status") in ("fail", "warn", "skipped")] + parts = [] + for c in bad[:4]: + item = f"{c.get('id', '')} {c.get('detail', '')}".strip() + if c.get("hint"): + item += f"({c['hint']})" + parts.append(item) + label = "不通过" if verdict == "fail" else "有警告" + return f"{img}:{platform} 机检{label} —— " + ";".join(parts) + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception: + sys.exit(0) diff --git a/hooks/hooks.json b/hooks/hooks.json new file mode 100644 index 0000000..e15c561 --- /dev/null +++ b/hooks/hooks.json @@ -0,0 +1,17 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Bash|Write|Edit", + "hooks": [ + { + "type": "command", + "command": "python \"${CLAUDE_PLUGIN_ROOT}/hooks/check_after_image.py\"", + "timeout": 120, + "statusMessage": "出图后平台机检" + } + ] + } + ] + } +} diff --git a/tools/check_plugin.py b/tools/check_plugin.py new file mode 100644 index 0000000..9737be0 --- /dev/null +++ b/tools/check_plugin.py @@ -0,0 +1,196 @@ +# -*- coding: utf-8 -*- +"""插件清单自校验:字段合法、声明的资源真实存在、路径不越界。 + +为什么需要它:插件是「一次装齐」的承载,清单写错一个路径,用户装完就是缺件, +但本地直接跑脚本时感觉不到——因为脚本是相对仓库根跑的。这个检查把装配问题 +提前到 CI。 + +检查项: +1. `.zcode-plugin/plugin.json` 可解析,name 与目录名一致,version 是语义化版本 +2. skills / commands / hooks / mcpServers 声明的路径全部存在 +3. 所有声明路径都在插件根内,不含 ../ 越界 +4. 每个 skill 目录有 SKILL.md,frontmatter 含 name 与 description +5. hooks.json 的事件名在 ZCode 支持的七个之内 +6. .mcp.json 的 server 配置含 command + +用法: + python tools/check_plugin.py + python tools/check_plugin.py --selftest +""" +import argparse +import json +import re +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parent +MANIFEST = ROOT / ".zcode-plugin" / "plugin.json" + +VALID_EVENTS = { + "SessionStart", "UserPromptSubmit", "PreToolUse", "PermissionRequest", + "PostToolUse", "PostToolUseFailure", "Stop", +} +SEMVER = re.compile(r"^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$") + + +def _load(path, problems): + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as e: + problems.append(f"{path.name} 无法解析:{e}") + return None + + +def _as_list(value): + if value is None: + return [] + return value if isinstance(value, list) else [value] + + +def _in_root(p): + try: + Path(p).resolve().relative_to(ROOT) + return True + except (ValueError, OSError): + return False + + +def check_manifest(problems): + if not MANIFEST.exists(): + problems.append("缺少 .zcode-plugin/plugin.json") + return None + m = _load(MANIFEST, problems) + if m is None: + return None + + if m.get("name") != "ecom-prompt-studio": + problems.append(f"清单 name 应为 ecom-prompt-studio,实际 {m.get('name')!r}") + if not SEMVER.match(str(m.get("version", ""))): + problems.append(f"version 不是语义化版本:{m.get('version')!r}") + if not m.get("description"): + problems.append("清单缺 description") + + for key in ("skills", "commands", "hooks"): + for rel in _as_list(m.get(key)): + target = (ROOT / rel).resolve() if not str(rel).startswith("/") else Path(rel) + if not _in_root(target): + problems.append(f"{key} 路径越出插件根:{rel}") + elif not target.exists(): + problems.append(f"{key} 指向的路径不存在:{rel}") + + mcps = m.get("mcpServers") + if isinstance(mcps, str): + target = (ROOT / mcps).resolve() + if not target.exists(): + problems.append(f"mcpServers 指向的文件不存在:{mcps}") + return m + + +def check_skills(manifest, problems): + for rel in _as_list((manifest or {}).get("skills")): + skill_dir = ROOT / rel + md = skill_dir / "SKILL.md" + if not md.exists(): + problems.append(f"{rel} 缺 SKILL.md") + continue + head = md.read_text(encoding="utf-8")[:800] + if not re.search(r"^name:\s*\S", head, re.M): + problems.append(f"{rel}/SKILL.md 缺 name 字段") + if not re.search(r"^description:\s*\S", head, re.M): + problems.append(f"{rel}/SKILL.md 缺 description 字段") + + +def check_hooks(manifest, problems): + for rel in _as_list((manifest or {}).get("hooks")): + p = ROOT / rel + if not p.exists(): + continue + data = _load(p, problems) + if not data: + continue + events = data.get("hooks") or data + if not isinstance(events, dict): + problems.append(f"{rel} 的 hooks 结构不是对象") + continue + for name in events: + if name not in VALID_EVENTS: + problems.append(f"{rel} 含不支持的事件名:{name}(只支持七个)") + + +def check_mcp(manifest, problems): + mcps = (manifest or {}).get("mcpServers") + if isinstance(mcps, dict): + for name, cfg in mcps.items(): + if not isinstance(cfg, dict) or not cfg.get("command"): + problems.append(f"mcpServers.{name} 缺 command") + return + if isinstance(mcps, str): + p = ROOT / mcps + if not p.exists(): + return + data = _load(p, problems) + for name, cfg in ((data or {}).get("mcpServers") or {}).items(): + if not isinstance(cfg, dict) or not cfg.get("command"): + problems.append(f"mcpServers.{name} 缺 command") + + +def run_checks(): + problems = [] + manifest = check_manifest(problems) + check_skills(manifest, problems) + check_hooks(manifest, problems) + check_mcp(manifest, problems) + return problems + + +def selftest(): + """自检不打桩:直接对真实仓库跑,并确认能造出可识别的错误。""" + fails = [] + problems = run_checks() + if problems: + fails.append(f"真实仓库清单应无问题,实际:{problems}") + + # 负向:事件名写错必须被抓到 + tmp = ROOT / ".tmp" / "plugin-check-probe.json" + tmp.parent.mkdir(parents=True, exist_ok=True) + tmp.write_text(json.dumps({"hooks": {"PostToolUseTypo": []}}), encoding="utf-8") + fake = [] + check_hooks({"hooks": [".tmp/plugin-check-probe.json"]}, fake) + if not fake: + fails.append("非法事件名未被识别") + tmp.unlink(missing_ok=True) + + fake2 = [] + check_skills({"skills": ["./no-such-skill"]}, fake2) + if not fake2: + fails.append("不存在的技能目录未被识别") + + if fails: + for f in fails: + print("FAIL", f) + return 1 + print("selftest: 真实清单通过、非法事件名拦截、缺失技能目录拦截") + return 0 + + +def main(): + ap = argparse.ArgumentParser(description="插件清单自校验") + ap.add_argument("--selftest", action="store_true") + args = ap.parse_args() + + if args.selftest: + return selftest() + + problems = run_checks() + for p in problems: + print(f"错误: {p}") + if problems: + print(f"结果: {len(problems)} 个问题(不通过)") + return 1 + print("结果: 插件清单与声明的资源全部有效(通过)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/fix_stats.py b/tools/fix_stats.py index 3410c92..f67fff0 100644 --- a/tools/fix_stats.py +++ b/tools/fix_stats.py @@ -140,17 +140,21 @@ def main(): manifests = load_manifests(args.dir) if not manifests: - print(f"{args.dir} 下没有 manifest.json,先跑一次出图闭环", file=sys.stderr) + print(f"{args.dir} 下没有 manifest.json。", file=sys.stderr) + print("本版本不内置出图闭环,manifest 需由外部通道产出,格式:" + "{\"result\": \"passed|exhausted\", \"rounds\": [{\"round\": 1, \"verdict\": \"fail\", " + "\"fails\": [\"background\"], \"fixes\": [{\"id\": \"background\", \"text\": \"背景纯白\"}]}]}。" + "至少两轮才能算出命中率。", file=sys.stderr) return 1 got = analyze(manifests) if not got["fixes"]: if got["avg_rounds"] <= 1: - print("这些 manifest 都是单轮的(dry-run 只跑一轮),没有「下一轮」可比,判断不了修复句有没有效。" - "\n要看修复统计,用 --generate 跑真闭环(缺授权时有授权闸门拦着,不会真的花钱)。", file=sys.stderr) + print("这些 manifest 都是单轮的,没有「下一轮」可比,判断不了修复句有没有效。" + "\n需要至少两轮的 manifest:第二轮修完了第一轮失败的项,才谈得上命中率。", file=sys.stderr) else: print("manifest 里有轮次但没记修复句:可能是旧格式(fixes 是字符串而非 {id, text})。" - "\n当前版本闭环写的是带 id 的对象,重跑一次闭环即可。", file=sys.stderr) + "\n当前格式写的是带 id 的对象,按 tools/README.md 的格式重记一次即可。", file=sys.stderr) return 1 if args.json: diff --git a/tools/mcp_server.py b/tools/mcp_server.py new file mode 100644 index 0000000..6756c1f --- /dev/null +++ b/tools/mcp_server.py @@ -0,0 +1,371 @@ +# -*- coding: utf-8 -*- +"""MCP server:把原子库与判据脚本暴露成四个可调用工具。 + +为什么需要它:不接 MCP 时,agent 要拿一条模板得走四步——读 SKILL.md、判工位、读 +_atoms/aNN.json、从 templates 挑一个,全靠模型自觉,任何一步错就取错。这里把那四步 +压成一次调用。 + +四个工具都是现有脚本与索引的薄包装,不引入新判据: +- query_atom 按品类/产物/动作定位原子(读 _index.json,不做语义猜测) +- get_template 取原子里的模板 + 该原子的规则与清单 +- validate_prompt 跑 rules_engine 与 slop_check +- check_output 跑 check_image 做平台机检 + +用法(stdio,ZCode 通过 .mcp.json 拉起): + python tools/mcp_server.py + +协议实现只用标准库:MCP 的 JSON-RPC 2.0 帧就是「一行一个 JSON」,不引第三方 SDK。 +""" +import json +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent +REPO_ROOT = HERE.parent +SOP_DIR = REPO_ROOT / "ecom-prompt-sop" +INDEX_JSON = SOP_DIR / "references" / "_atoms" / "index.json" +ATOM_DIR = SOP_DIR / "references" / "_atoms" + +PROTOCOL_VERSION = "2024-11-05" +SERVER_INFO = {"name": "ecom-prompt-studio", "version": "2.1.0"} + + +def _load_index(): + if not INDEX_JSON.exists(): + return [] + data = json.loads(INDEX_JSON.read_text(encoding="utf-8")) + return data.get("items") or [] + + +def _haystack(atom): + """索引里 categories 常为空,真实可用的匹配面是标题、任务、标签三者合并。""" + parts = [atom.get("title") or "", atom.get("task") or ""] + parts.extend(atom.get("tags") or []) + parts.extend(atom.get("categories") or []) + return " ".join(parts) + + +def _score(atom, category, output, action, text): + """按索引里的结构化字段打分。命中即有依据,不猜。""" + score = 0 + hay = _haystack(atom) + if category and category in hay: + score += 40 + if output: + outs = atom.get("outputs") or [] + if any(output in o for o in outs): + score += 25 + elif output and output in hay: + score += 15 + if action and action in hay: + score += 25 + if text: + for ch in text: + if ch.strip() and ch in hay: + score += 1 + return min(score, 90) + + +def query_atom(args): + category = (args.get("category") or "").strip() + output = (args.get("output") or "").strip() + action = (args.get("action") or "").strip() + text = (args.get("text") or "").strip() + limit = int(args.get("limit") or 3) + + atoms = _load_index() + if not atoms: + return {"error": "原子索引缺失,先跑 python tools/atomize.py merge"} + + scored = [] + for a in atoms: + s = _score(a, category, output, action, text) + if s > 0: + scored.append((s, a)) + scored.sort(key=lambda x: -x[0]) + + if not scored: + return { + "hits": [], + "note": "没有原子命中。检查品类是否在 34 篇覆盖范围内,或改用通用篇(a01/a02/a03)。", + } + + hits = [] + for s, a in scored[:limit]: + hits.append({ + "atom": a.get("id"), + "title": a.get("title"), + "stations": a.get("stations"), + "outputs": a.get("outputs"), + "task": a.get("task"), + "score": s, + }) + return {"hits": hits, "total_matched": len(scored)} + + +def get_template(args): + atom_id = (args.get("atom") or "").strip() + idx = args.get("index") + if not atom_id: + return {"error": "缺少 atom 参数,例如 a21"} + + path = ATOM_DIR / f"{atom_id}.json" + if not path.exists(): + return {"error": f"原子不存在:{atom_id}"} + + atom = json.loads(path.read_text(encoding="utf-8")) + templates = atom.get("templates") or [] + if idx is not None: + try: + i = int(idx) + except (TypeError, ValueError): + return {"error": f"index 必须是整数,收到 {idx!r}"} + if i < 0 or i >= len(templates): + return {"error": f"index 越界:{i},该原子有 {len(templates)} 个模板"} + picked = [templates[i]] + else: + picked = templates + + return { + "atom": atom_id, + "title": atom.get("title"), + "stations": atom.get("stations"), + "template_count": len(templates), + "templates": picked, + "rules": atom.get("rules") or [], + "checklists": atom.get("checklists") or [], + "placeholders": atom.get("placeholders") or [], + } + + +def _run_script(script, argv, timeout=60): + import subprocess + try: + p = subprocess.run( + [sys.executable, str(HERE / script)] + argv, + capture_output=True, text=True, encoding="utf-8", timeout=timeout, + ) + return {"exit": p.returncode, "stdout": (p.stdout or "").strip(), "stderr": (p.stderr or "").strip()} + except subprocess.TimeoutExpired: + return {"exit": -1, "stdout": "", "stderr": f"{script} 超时({timeout}s)"} + except OSError as e: + return {"exit": -1, "stdout": "", "stderr": f"{script} 无法执行:{e}"} + + +def validate_prompt(args): + prompt = args.get("prompt") or "" + if not prompt.strip(): + return {"error": "缺少 prompt 参数"} + + import tempfile + with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False, encoding="utf-8") as f: + f.write(prompt) + tmp = f.name + + try: + out = {"slop": _run_script("slop_check.py", [tmp, "--json"])} + platform = args.get("platform") + scenario = args.get("scenario") + category = args.get("category") + if platform: + slots = args.get("slots") + if slots: + import tempfile as tf + with tf.NamedTemporaryFile("w", suffix=".json", delete=False, encoding="utf-8") as sf: + json.dump(slots, sf, ensure_ascii=False) + slot_path = sf.name + argv = ["--check", slot_path, "--platform", platform] + if scenario: + argv += ["--scenario", scenario] + if category: + argv += ["--category", category] + out["rules"] = _run_script("rules_engine.py", argv) + Path(slot_path).unlink(missing_ok=True) + else: + out["rules"] = {"skipped": "未提供 slots,跳过规则引擎(它校验的是槽位而非自由文本)"} + else: + out["rules"] = {"skipped": "未提供 platform,跳过规则引擎"} + return out + finally: + Path(tmp).unlink(missing_ok=True) + + +def check_output(args): + image = args.get("image") or "" + platform = args.get("platform") or "" + if not image: + return {"error": "缺少 image 参数(图片路径)"} + if not platform: + return {"error": "缺少 platform 参数(如 amazon / taobao / tmall)"} + if not Path(image).exists(): + return {"error": f"图片不存在:{image}"} + + argv = [image, "--platform", platform, "--json"] + if args.get("strict"): + argv.append("--strict") + return _run_script("check_image.py", argv, timeout=120) + + +TOOLS = [ + { + "name": "query_atom", + "description": "按品类、产物、动作定位 SOP 原子,返回命中的原子编号与依据。用于替代人工判工位。", + "inputSchema": { + "type": "object", + "properties": { + "category": {"type": "string", "description": "品类,如 百货杯壶、美妆日化、服饰"}, + "output": {"type": "string", "description": "产物,如 主图、详情页、SKU图、模特图、视频"}, + "action": {"type": "string", "description": "动作,如 生成、反推、翻新、批量、翻译"}, + "text": {"type": "string", "description": "用户原话,用于关键词兜底打分"}, + "limit": {"type": "integer", "description": "最多返回几条,默认 3"}, + }, + }, + }, + { + "name": "get_template", + "description": "取指定原子的提示词模板,连同该原子的规则、验收清单与占位符一起返回。", + "inputSchema": { + "type": "object", + "properties": { + "atom": {"type": "string", "description": "原子编号,如 a21"}, + "index": {"type": "integer", "description": "可选,只取第几个模板(从 0 开始)"}, + }, + "required": ["atom"], + }, + }, + { + "name": "validate_prompt", + "description": "校验提示词:反 AI 感与合规用语(slop_check),可选按平台/场景/品类跑规则引擎。", + "inputSchema": { + "type": "object", + "properties": { + "prompt": {"type": "string", "description": "待校验的提示词全文"}, + "platform": {"type": "string", "description": "可选,平台 id,如 amazon"}, + "scenario": {"type": "string", "description": "可选,场景,如 首图"}, + "category": {"type": "string", "description": "可选,品类"}, + "slots": {"type": "object", "description": "可选,五槽位结构化对象;提供后才跑规则引擎"}, + }, + "required": ["prompt"], + }, + }, + { + "name": "check_output", + "description": "出图后平台机检:白底纯度、主体占比、比例、分辨率、文件大小、透明通道、图内文字。", + "inputSchema": { + "type": "object", + "properties": { + "image": {"type": "string", "description": "图片路径"}, + "platform": {"type": "string", "description": "平台 id,如 amazon、taobao、tmall"}, + "strict": {"type": "boolean", "description": "严格模式:警告也算失败"}, + }, + "required": ["image", "platform"], + }, + }, +] + +HANDLERS = { + "query_atom": query_atom, + "get_template": get_template, + "validate_prompt": validate_prompt, + "check_output": check_output, +} + + +def respond(msg_id, result=None, error=None): + msg = {"jsonrpc": "2.0", "id": msg_id} + if error is not None: + msg["error"] = error + else: + msg["result"] = result + sys.stdout.write(json.dumps(msg, ensure_ascii=False) + "\n") + sys.stdout.flush() + + +def handle(req): + method = req.get("method") + msg_id = req.get("id") + + if method == "initialize": + return respond(msg_id, { + "protocolVersion": PROTOCOL_VERSION, + "capabilities": {"tools": {}}, + "serverInfo": SERVER_INFO, + }) + if method == "notifications/initialized": + return + if method == "tools/list": + return respond(msg_id, {"tools": TOOLS}) + if method == "tools/call": + params = req.get("params") or {} + name = params.get("name") + handler = HANDLERS.get(name) + if not handler: + return respond(msg_id, error={"code": -32601, "message": f"未知工具:{name}"}) + try: + payload = handler(params.get("arguments") or {}) + return respond(msg_id, { + "content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False, indent=2)}], + "isError": bool(payload.get("error")), + }) + except Exception as e: + return respond(msg_id, error={"code": -32603, "message": f"{type(e).__name__}: {e}"}) + if method in ("ping",): + return respond(msg_id, {}) + if msg_id is not None: + respond(msg_id, error={"code": -32601, "message": f"未实现方法:{method}"}) + + +def main(): + for line in sys.stdin: + line = line.strip() + if not line: + continue + try: + handle(json.loads(line)) + except json.JSONDecodeError: + respond(None, error={"code": -32700, "message": "无法解析的 JSON"}) + return 0 + + +def selftest(): + """离线自检:不打网络、不碰真实密钥,只验索引与原子可读。""" + fails = [] + atoms = _load_index() + if len(atoms) != 34: + fails.append(f"原子索引应为 34 条,实际 {len(atoms)}") + + got = query_atom({"category": "杯壶", "output": "主图"}) + hits = got.get("hits") or [] + if not hits: + fails.append("query_atom 对「杯壶+主图」无命中") + elif hits[0]["atom"] != "a21": + fails.append(f"杯壶主图应首选 a21,实际 {hits[0]['atom']}") + + tpl = get_template({"atom": "a21", "index": 0}) + if not tpl.get("templates"): + fails.append("get_template 取 a21 模板失败") + if tpl.get("error"): + fails.append(f"get_template 报错:{tpl['error']}") + + miss = get_template({"atom": "a99"}) + if not miss.get("error"): + fails.append("不存在的原子应返回 error") + + # slop_check 默认把堆词记为 warn 且退出 0,只有 --strict 才判失败 + v = validate_prompt({"prompt": "8k masterpiece 极致完美的保温杯主图"}) + if "\"warn\": 4" not in (v["slop"].get("stdout") or "").replace(",", "").replace(" ", ""): + if "空洞堆词" not in (v["slop"].get("stdout") or ""): + fails.append("堆词提示词应被 slop_check 报出空洞堆词") + + if fails: + for f in fails: + print("FAIL", f) + return 1 + print("selftest: 索引、定位、取模板、堆词拦截 全部通过") + return 0 + + +if __name__ == "__main__": + if "--selftest" in sys.argv: + sys.exit(selftest()) + sys.exit(main()) diff --git a/tools/vlm_review.py b/tools/vlm_review.py new file mode 100644 index 0000000..eebcae3 --- /dev/null +++ b/tools/vlm_review.py @@ -0,0 +1,177 @@ +# -*- coding: utf-8 -*- +"""视觉复核清单生成器:把「该人看什么」整理成一份带图的复核单。 + +为什么这样做而不是直接调模型判图:VLM 判图在「精确编辑是否生效、保留约束是否满足」 +这类细粒度判断上并不可靠(EditJudge-Bench 2026 实测),把它的分数当通过/不通过依据 +会把错误固化。所以这里只做两件事: + +1. 用 visual_audit 的启发式给出嫌疑分(提示复核,不判定) +2. 生成一份结构化复核清单:每个原子自带的检查项 + 保留清单,逐条留空等人填 + +结论永远由人给。脚本不输出 pass。 + +用法: + python tools/vlm_review.py --atom a21 --image out/main-01.png + python tools/vlm_review.py --atom a21 --image out/main-01.png --ref product.jpg --out out/review.md + python tools/vlm_review.py --selftest +""" +import argparse +import json +import subprocess +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent +REPO_ROOT = HERE.parent +ATOM_DIR = REPO_ROOT / "ecom-prompt-sop" / "references" / "_atoms" + + +def load_atom(atom_id): + p = ATOM_DIR / f"{atom_id}.json" + if not p.exists(): + return None + return json.loads(p.read_text(encoding="utf-8")) + + +def audit_scores(image, ref=None): + """调 visual_audit 拿启发式分数。失败不影响复核单生成。""" + argv = [sys.executable, str(HERE / "visual_audit.py"), image, "--json"] + if ref: + argv += ["--ref", ref] + try: + p = subprocess.run(argv, capture_output=True, text=True, encoding="utf-8", timeout=120) + except (subprocess.TimeoutExpired, OSError): + return None + raw = (p.stdout or "").strip() + if not raw: + return None + try: + return json.loads(raw) + except json.JSONDecodeError: + return None + + +def build_review(atom, image, audit): + lines = [] + title = atom.get("title") or atom.get("id") + lines.append(f"# 视觉复核单 · {title}") + lines.append("") + lines.append(f"- 原子:`{atom.get('id')}`") + lines.append(f"- 待检图:`{image}`") + lines.append("- 结论:**待人工填写**(本单不给通过/不通过)") + lines.append("") + + lines.append("## 启发式嫌疑分") + lines.append("") + if audit is None: + lines.append("visual_audit 未能运行(可能缺 Pillow)。这一节留空,不影响下游复核。") + else: + items = audit if isinstance(audit, list) else [audit] + for it in items: + if not isinstance(it, dict): + continue + ai = it.get("ai_feel") or it.get("ai_score") or "—" + cons = it.get("consistency") or it.get("similarity") or "—" + lines.append(f"- `{it.get('file', image)}`:AI 感 {ai},与参考图一致度 {cons}") + lines.append("") + lines.append("这两项都是启发式,只用于指示「先看哪里」,不能作为通过依据。") + lines.append("") + + lines.append("## 逐条复核") + lines.append("") + lines.append("| 判据 | 结论(通过/不通过/不适用) | 证据 |") + lines.append("|---|---|---|") + for c in (atom.get("checklists") or []): + if isinstance(c, dict): + for item in (c.get("items") or []): + lines.append(f"| {item} | | |") + else: + lines.append(f"| {c} | | |") + if not (atom.get("checklists") or []): + lines.append("| 该原子无内置清单,按工位门禁逐条核对 | | |") + lines.append("") + + lines.append("## 产品保留清单逐项核对") + lines.append("") + lines.append("出图必须与参考图是同一个产品。逐项打勾,任何一项不一致就重出:") + lines.append("") + lines.append("| 特征 | 一致 | 不一致 |") + lines.append("|---|---|---|") + for feat in ("形状与比例", "颜色", "材质纹理", "logo 位置与内容", "结构细节(接缝/按键/纹理方向)"): + lines.append(f"| {feat} | | |") + lines.append("") + + lines.append("## 必须人工确认(机检覆盖不到)") + lines.append("") + lines.append("- 模特造型是否符合人物政策") + lines.append("- AI 标注义务是否已履行(见 `_rules/aigc-disclosure.json`)") + lines.append("- 是否货不对板(图与实物不一致)") + lines.append("") + return "\n".join(lines) + + +def selftest(): + fails = [] + atom = load_atom("a21") + if atom is None: + fails.append("a21 原子读取失败") + + md = build_review(atom or {"id": "a21", "title": "t"}, "out/x.png", None) + if "待人工填写" not in md: + fails.append("复核单缺少人工结论占位") + if "通过/不通过" not in md: + fails.append("复核单未声明结论由人工给") + for feat in ("形状与比例", "颜色", "材质纹理"): + if feat not in md: + fails.append(f"复核单缺少保留清单项:{feat}") + + bad = load_atom("a99") + if bad is not None: + fails.append("不存在的原子应返回 None") + + if fails: + for f in fails: + print("FAIL", f) + return 1 + print("selftest: 复核单结构、人工结论占位、保留清单、未知原子处理 全部通过") + return 0 + + +def main(): + ap = argparse.ArgumentParser(description="视觉复核清单生成器(只提示,不判定)") + ap.add_argument("--atom", help="原子编号,如 a21") + ap.add_argument("--image", help="待检图片路径") + ap.add_argument("--ref", help="可选,产品参考图,用于一致性启发式") + ap.add_argument("--out", help="可选,写入文件;不给则打印到 stdout") + ap.add_argument("--selftest", action="store_true") + args = ap.parse_args() + + if args.selftest: + return selftest() + + if not args.atom or not args.image: + ap.print_help() + return 2 + + atom = load_atom(args.atom) + if atom is None: + print(f"原子不存在:{args.atom}", file=sys.stderr) + return 2 + if not Path(args.image).exists(): + print(f"图片不存在:{args.image}", file=sys.stderr) + return 2 + + audit = audit_scores(args.image, args.ref) + md = build_review(atom, args.image, audit) + + if args.out: + Path(args.out).parent.mkdir(parents=True, exist_ok=True) + Path(args.out).write_text(md, encoding="utf-8") + print(f"复核单已写入 {args.out}") + else: + print(md) + return 0 + + +if __name__ == "__main__": + sys.exit(main())