diff --git a/SKILL.md b/SKILL.md index 817e311..b10836f 100644 --- a/SKILL.md +++ b/SKILL.md @@ -25,11 +25,20 @@ description: 生成或优化符合 maomomo / 猫MOMO 风格的中文出海金融 ## 硬性约束 +- 如果本技能任何旧规则与“确认优先、单步推进”的流程冲突,以本节和“默认工作流”中的阶段门禁为准。 +- 确认优先,单步推进;不要一上来就完整生成文章、全部配图或 ZIP。 - 读写 Markdown、JSON、CSV、HTML、代码或文本文件时显式使用 UTF-8。 - 处理用户上传或提供的 Markdown / PDF / 官方链接 / 参考文章时,必须先完整读取或说明无法读取的部分,再写最终稿。 - 费用、活动日期、奖励门槛、账户规则、返现、券商迎新、转账路径、数字货币出入金和合规事项,必须优先核查最新信息。优先级:用户提供的最新官方材料 > 官方页面 / 条款 PDF / App 显示 > 用户实测 > MAOMOMO 旧文 > 其他参考。 - 没有被确认的数字、日期、奖励、链接、邀请码、产品名和规则,不得编造;可用 `【待确认:...】` 标出缺口。 - 用户素材包含明确“实测”结果时,必须用确定语气写结论;包含“以往惯例”“过往尿性”“过往经验”等历史依据时,必须用“大概率 / 按过往案例判断”的强概率语气,不要降级成“可能 / 疑似 / 大概”。 +- 写作前必须先给用户确认标题或标题方向、文章核心口径、`outline.md` 大纲、来源缺口、必选素材映射和配图风格。用户明确要求跳过确认时,未确认事实仍必须标 `【待确认:...】`。 +- 大纲未确认前,不得创建最终 `deck_spec.json`、`speech.md`、图片 prompt jobs、文章配图图片、ZIP 或用户指定的最终文章包。 +- 出图前必须先把全部图片 prompt 写入文件,包含文件名、用途、放置位置、核心文案、完整 prompt 和依赖素材;不要在聊天里输出超长 prompt。 +- 先只生成一张代表性风格确认图,优先封面图,保存为正式命名,例如 `origin_image/hsbc-mastercard-alipay-campaign-01-cover.png`。用户批准样张前不得批量生成剩余图片。 +- 样张批准后,后续每张图片必须继承同一个生图后端、同一套视觉系统和同一种质量配置;不得为了加快速度擅自改成 `low`。 +- 每张最终图片必须来自确认后的图片生成后端:当前 agent 的内置图片生成工具,或用户确认后的 `scripts/maomomo_image_gen.py` API/CLI fallback。不得用 Playwright、HTML/CSS、SVG、Pillow、canvas、本地拼贴或手工文字覆盖冒充最终生成图。 +- 生成图片按“一张图一个 job / 一次请求”推进。运行环境支持子 agent 时,样张通过后可以一图一个子 agent 并发处理;每个 dispatch、结果和 blocker 都必须写入状态文件,不能只靠聊天说明。 - 完整交付模式下,不能只在聊天里输出正文;必须生成 Markdown 文件、`assets/` 目录和 ZIP 包,除非用户明确说只要文字、只要大纲、不要图片、不要打包或先不生成文件。 - 配图文件必须是独立 PNG;不要用一张大拼图代替多张图,不要只给提示词冒充图片文件。 - 后续改稿必须更新文件本体;影响图片、来源区、标题、摘要或 ZIP 时同步更新。 @@ -38,49 +47,118 @@ description: 生成或优化符合 maomomo / 猫MOMO 风格的中文出海金融 复杂任务使用一个用户可见 checklist,并且同一时间只推进一个步骤: -1. 识别任务模式、来源和事实缺口。 -2. 确认文章大纲、口径和素材映射。 +1. 读取资料,整理来源状态和事实表。 +2. 确认标题、核心口径、`outline.md` 大纲、素材映射、配图风格和生图后端。 3. 写作或优化发布用 Markdown。 -4. 规划并生成 / 准备配图 assets。 -5. QA、重新打包并交付最终文件。 +4. 输出全部图片 prompt 文件和图片任务状态文件。 +5. 生成并确认一张样张。 +6. 逐张生成剩余图片并记录 dispatch / result / blocker。 +7. QA、返修、生成 `speech.md` 或备注稿。 +8. 打包并交付最终文件。 不要因为聊天里说“已完成”就标记完成;用实际文件、已读取来源、已生成图片和 ZIP 作为完成证据。 ## 默认工作流 +一句话版:读资料 -> 给标题 / 大纲 / 风格让用户确认 -> 写文章 -> 输出图片 prompts 文件让用户改 -> 先出一张图确认 -> 逐张生成剩余图 -> QA -> 打包交付。 + 1. 判断任务模式。 - 完整交付、只要大纲、只要正文、只要配图、SEO 优化、改稿更新、小红书发布版。 - 判断是否涉及金融 / 活动 / 合规事实核查。 - - 完整交付或打包任务先读 `references/delivery-rules.md`。 + - 完整交付或打包任务先读 `references/delivery-rules.md`;需要配图时先读 `references/visual-assets.md`。 -2. 读取来源并建立事实表。 +2. 读取源材料并建立事实表。 + - 先读用户提供的官方页面、PDF、旧文、参考链接、截图或素材,不先动手写最终稿。 - 涉及官方规则、活动或金融产品时,先读 `references/source-and-fact-check.md`。 - - 列出用户提供的所有来源,并标记已读 / 未读 / 无法读取。 - - 提炼日期、门槛、奖励、限制、费用、适用对象、实测口径和冲突点。 + - 列出所有来源的已读 / 未读 / 无法读取状态。 + - 明确区分:已官宣事实、用户实测、过往惯例判断、待确认内容。 + - 确认主题、目标读者、文章目标、章节数量或图片数量、品牌 / 风格限制,以及必须放入文章的图片素材。 -3. 确认范围和大纲。 - - 写最终稿前先给出文章类型、目标读者、标题方向、核心口径、章节结构、来源缺口和图片规划。 +3. 规划并确认 `outline.md`。 + - 写最终稿前先给出推荐标题或使用用户指定标题、文章核心口径、章节大纲、来源缺口、图片规划和必选素材映射。 + - `outline.md` 应包含每个大小标题、3-5 个要点、页面 / 图位角色、视觉想法、必选素材路径或附件名。 + - 有必选素材时,必须先确认“素材到章节 / 图位”的映射,再进入风格选择或图片生成。 - 若存在多个活动分支、旧版活动、小众资格或来源冲突,先建议主文范围,等用户确认后再写最终稿。 - 用户明确要求“直接生成”时,可以继续执行,但所有未确认事实必须标 `【待确认:...】`。 -4. 写作或优化文章。 - - 写作前读 `references/style-guide.md`。 - - 先结论、再步骤、再坑点;段落短,数字具体,App 菜单名 / 操作名尽量写清楚。 +4. 确认视觉风格。 + - 若用户没有指定视觉方向,读 `references/recommended-styles.md`,给 2-3 个 MAOMOMO 配图风格选项并推荐一个。 + - 用户提供参考图、PPT、PDF 或旧文章风格时,先总结可复用的配色、版式、字体气质、猫咪元素、信息密度和禁用项。 + - 确认后,整篇文章保持同一视觉系统:稳定配色、猫咪造型、标题区、图标语言、品牌识别和信息密度;不同图位可以变化构图。 + +5. 确认生图后端。 + - 出第一张图前读 `references/image-generation.md`。 + - 优先主动检查当前 agent 是否可调用内置图片生成工具,并向用户说明检查结果和准备使用的后端。 + - 只有内置工具不可用、能力不足、用户明确要求 API/CLI,或需要使用第三方图片 API 时,才走 `scripts/maomomo_image_gen.py` fallback。 + - 相信用户环境变量或配置文件里的 baseURL、model、quality;除非报错或用户要求,不要重新索要配置。 + +6. 写作或优化文章。 + - 用户确认标题、大纲、口径和素材映射后,再读 `references/style-guide.md` 写发布用 Markdown。 + - 不要把“推测 / 大概率 / 过往惯例”写成“已官宣”。 + - 金融活动文章尤其要分清官方条款、App 显示、用户实测和经验判断。 - 发布用 Markdown 使用 frontmatter、H1、太长不看版、正文、最后提醒和来源区。 - SEO 优化既要改标题、摘要、slug / tags / categories、图片 alt,也要清理无效来源和发布残留。 -5. 配图规划和生成。 - - 需要配图时读 `references/visual-assets.md`。 - - 若用户没有指定视觉方向,读 `references/recommended-styles.md`,先提供 2-3 个 MAOMOMO 配图风格选项并推荐一个;确认后整篇文章保持同一视觉身份。 - - 先生成图片清单:类型、标题、放置位置、核心文案、文件名、alt text、风格、是否依赖用户截图。 - - 每张图独立生成或准备为高清 PNG,放入 `assets/`,用相对路径插入 Markdown。 - - 优先使用当前 agent 的内置图片生成工具;当用户要求 API/CLI、内置工具不可用或需要批量 manifest 时,读 `references/image-generation.md` 并使用 `scripts/maomomo_image_gen.py`。 - - 没有真实截图时只能生成“示意图 / 信息图”,不能伪造官方 App 截图。 +7. 创建项目目录和任务文件。 + - 完整交付默认使用独立项目目录;如果用户没指定位置,使用当前工作目录或源文件所在目录。 + - 推荐结构: -6. 打包与最终 QA。 +```text +{base_dir}/{article_slug}/ +├── origin_image/ +│ ├── {article_slug}_01_cover.png +│ └── ... +├── assets/ +│ ├── {article_slug}_01_cover.png +│ └── ... +├── prompts/ +│ ├── {article_slug}_01.json +│ └── ... +├── deck_spec.json +├── slide_jobs.json +├── slide_run_state.json +├── outline.md +├── article.md +├── speech.md +└── {article_slug}.zip +``` + + - `origin_image/` 保存图片后端生成并通过 QA 的原始最终图;`assets/` 保存 Markdown 实际引用的发布图片。两者可以是同名复制件,但不得只保留临时图。 + - `deck_spec.json` 记录文章级上下文、确认后的风格、生图后端、样张生成方法 `sample_generation_method` 和图片任务列表。 + - `prompts/{article_slug}_XX.json` 记录每张图的文件名、用途、放置位置、核心文案、完整 prompt、依赖素材和输出路径。 + - `slide_jobs.json` 和 `slide_run_state.json` 记录每个图片 job 的 pending / dispatched / recorded / blocked 状态;使用 `scripts/maomomo_job_state.py` 初始化和记录状态。 + +8. 出图前整理全部 prompt。 + - 每张图的文件名、用途、放置位置、核心文案、完整 prompt、alt text、风格、依赖素材和输出路径先写入 `prompts/` 或 manifest 文件。 + - 不要在聊天里输出超长 prompt;告诉用户可直接编辑 prompt 文件。 + - 如果用户修改 prompt,先同步文件,再生成图片。 + +9. 先生成一张样张。 + - 优先生成封面图或最能代表文章视觉节奏的一张内容图。 + - 样张直接保存为正式文件名,例如 `origin_image/hsbc-mastercard-alipay-campaign-01-cover.png`。 + - 展示样张并等待用户确认风格、内容、中文文字质量和视觉密度。 + - 如果用户不满意,先改该图 prompt,再重出同一张;样张确认前不得生成剩余图片。 + - 样张确认后,在 `deck_spec.json` 记录 `sample_generation_method`,包括 backend、tool/command、mode、quality、model/config、prompt 文件、approved_sample_path 和 handoff_rule。 + +10. 逐张生成剩余图片。 + - 样张批准后,剩余图片必须使用同一后端、同一质量配置和同一视觉系统。 + - 每张图独立生成或准备为高清 PNG,先进入 `origin_image/`,通过 QA 后同步到 `assets/`,并用相对路径插入 Markdown。 + - 图片生成必须一张张来:一张图一个 prompt job、一次图片请求、一次 QA。不要一次请求多张图,不要为了快降低质量。 + - 如果运行环境支持子 agent,样张通过后可以一图一个子 agent 并发处理;父 agent 负责状态文件、QA、Markdown、`speech.md` 和打包。 + - 没有真实截图时只能生成“示意图 / 信息图”,不能伪造官方 App 截图。 + - 不要用 Playwright 替代图片生成;Playwright 只用于已有 HTML / SVG / 网页截图检查。 + +11. 记录状态、QA 和返修。 + - 每个图片 job 的 dispatch、结果和 blocker 都用 `scripts/maomomo_job_state.py` 写入 `slide_jobs.json` / `slide_run_state.json`,不能只靠聊天说明完成。 + - 检查文字清晰度、中文乱码、金额 / 日期 / 规则是否残留旧口径、标题截断、风格一致性、必选素材是否正确、元素是否重叠、是否伪造截图或 Logo。 + - 严重问题重新生成该单张;局部问题优先使用已确认后端的编辑能力修复,不得本地手工覆盖关键文字冒充模型输出。 + +12. 生成 `speech.md`、组装文章包并最终 QA。 + - `speech.md` 用作每张图的备注 / 讲解稿,标题格式使用 `## Slide N: 标题`,便于和图片顺序映射。 + - 组装最终文章包时,把 `origin_image/{article_slug}_XX.png` 按顺序同步到 Markdown 引用的 `assets/`,并确保 `article.md`、图片路径、`speech.md`、来源区和 ZIP 都是最新版本。 - 完整交付、文件交付或改稿打包前读 `references/delivery-rules.md`。 - - 检查 Markdown 图片路径、来源区、编辑痕迹、待确认项、金融风险提示、用户要求删除内容和 ZIP 内容。 - - 最终回复保持简短:给出 Markdown、assets 目录和 ZIP 路径,以及必要限制。 + - QA 必须检查 Markdown 图片路径、assets 是否存在、来源区只保留实际使用来源、正文无编辑痕迹、图片无错误日期 / 金额 / 未确认结论、用户要求删除内容不残留、ZIP 内容正确。 + - 最终回复保持简短:给出项目目录、文章路径、图片目录、ZIP 路径、图片数量、生图后端、状态记录情况、备注写入情况和已知限制。 ## 模式选择 @@ -123,5 +201,5 @@ description: 生成或优化符合 maomomo / 猫MOMO 风格的中文出海金融 - `references/visual-assets.md`:配图类型、猫咪视觉、文件命名、Markdown 插图、禁止伪造截图和图片 QA。 - `references/recommended-styles.md`:推荐配图风格、适用场景、推荐话术和提示词片段。 - `references/image-styles/*.md`:具体出图风格 brief,借鉴 `codex-ppt-skill` 风格库结构并适配 MAOMOMO 文章配图。 -- `references/image-generation.md`:图片生成后端选择、`scripts/maomomo_image_gen.py` 用法、manifest 和故障处理。 +- `references/image-generation.md`:图片生成后端选择、`scripts/maomomo_image_gen.py` 用法、`scripts/maomomo_job_state.py` 状态记录、manifest 和故障处理。 - `references/delivery-rules.md`:完整交付、ZIP、更新打包、附件策略和最终检查清单。 diff --git a/agents/openai.yaml b/agents/openai.yaml index b404104..7c0a3e3 100644 --- a/agents/openai.yaml +++ b/agents/openai.yaml @@ -1,7 +1,7 @@ interface: display_name: "MAOMOMO 文章生成器" - short_description: "阶段化生成 MAOMOMO 文章、多风格配图和 ZIP 发布包" - default_prompt: "使用 $maomomo-article-writer 生成或优化一篇 MAOMOMO 风格的中文出海金融实操文章。请先核查来源和口径,再确认配图风格,最后交付 Markdown、assets 独立配图目录和 ZIP 包。" + short_description: "分阶段确认后生成 MAOMOMO 文章、配图和 ZIP 发布包" + default_prompt: "使用 $maomomo-article-writer 生成或优化一篇 MAOMOMO 风格的中文出海金融实操文章。请先核查来源和事实表,再确认标题、口径、大纲、素材映射、配图风格和生图后端;写文章后先输出图片 prompts 文件,只生成一张样张确认,再逐张生成剩余图片,最后交付 Markdown、assets 独立配图目录和 ZIP 包。" policy: allow_implicit_invocation: true diff --git a/references/delivery-rules.md b/references/delivery-rules.md index f93f235..b6f000d 100644 --- a/references/delivery-rules.md +++ b/references/delivery-rules.md @@ -2,6 +2,8 @@ 本文件用于完整文章、配图、发布包、打包、一键下载和后续更新任务。只要用户没有明确说“只要文字”“不要图片”“不要打包”“先不要生成文件”“先只要大纲”,默认按完整交付模式执行。 +完整交付模式指“最终交付物必须完整”,不代表可以跳过确认一次性全量生成。执行时必须遵守 `SKILL.md` 的阶段门禁:先读资料和事实表,再确认标题 / 口径 / 大纲 / 素材映射 / 配图风格 / 生图后端,随后写文章、整理 prompts、先出一张样张,样张确认后再逐张生成剩余图片。 + ## 强制完整交付规则 当用户请求包含以下任一表达时,必须默认进入“完整交付模式”: @@ -19,13 +21,15 @@ - “生成文章 + 配图” - “最终交付” -完整交付模式必须一次性完成: +完整交付模式最终必须完成以下交付物,但必须分阶段推进: 1. 读取并核对用户提供的所有官方链接、PDF、参考文章和条款。 -2. 输出发布用 Markdown 文件,而不是只在聊天框展示正文。 -3. 生成或准备多张独立高清 PNG 配图,放入 `assets/` 目录。 -4. 将图片以相对路径插入 Markdown,例如 `![图片说明](assets/hsbc-mastercard-alipay-campaign-01-cover.png)`。 -5. 创建 ZIP 包,结构类似: +2. 在写最终稿前给用户确认标题、核心口径、`outline.md` 大纲、来源缺口、必选素材映射和配图风格。 +3. 用户确认后输出发布用 Markdown 文件,而不是只在聊天框展示正文。 +4. 出图前把全部图片 prompt 写入 `prompts/` 或 manifest 文件,让用户可直接编辑。 +5. 先生成并确认一张样张,优先封面图;样张确认后再逐张生成剩余独立高清 PNG。 +6. 将通过 QA 的图片同步到 `assets/` 目录,并以相对路径插入 Markdown,例如 `![图片说明](assets/hsbc-mastercard-alipay-campaign-01-cover.png)`。 +7. 创建 ZIP 包,结构类似: ```text article_package.zip @@ -36,7 +40,7 @@ article_package.zip hsbc-mastercard-alipay-campaign-03-timeline.png ``` -6. 最终回复只给下载链接和极简说明。 +8. 最终回复只给路径和极简说明,同时说明图片数量、生图后端、状态记录和已知限制。 如果因为工具限制、图片生成失败、文件写入失败或资料缺失导致无法完成完整交付,必须明确说明失败点,并尽量交付已经完成的部分。不要只输出配图提示词代替图片文件,不要只输出文章正文代替 Markdown 文件。 @@ -59,14 +63,18 @@ article_package.zip 当用户要求“配图也按 Skill”“高清配图”“精美配图”“生成配图”“文章和配图一起”时: - 必须生成独立 PNG 图片文件,而不是只给提示词。 +- 出图前必须先写入 prompt 文件,包含文件名、用途、放置位置、核心文案、完整 prompt、alt text、风格和依赖素材。 +- 必须先生成一张样张,优先封面图;用户确认前不得生成剩余图片。 +- 样张确认后,每张图按一个 job / 一次请求 / 一次 QA 推进;运行环境支持子 agent 时,可以一图一个子 agent 并发。 - 不要只生成一张大长图替代所有配图。 - 每张图必须有独立主题,例如封面图、活动总览图、时间线图、重点规则图、交易法图、避坑图、FAQ 图。 - 不得使用未经用户提供的真实银行 Logo、券商 Logo 或支付品牌 Logo。 - 不得伪造真实 App 截图。 - 生成图应明确是“MAOMOMO 风格示意图 / 信息图”。 -- 生成后必须保存到 `assets/`,并插入 Markdown。 +- 生成后的原始最终图先保存到 `origin_image/`,通过 QA 后同步到 `assets/`,并插入 Markdown。 - 图片文件名使用稳定英文小写命名,并包含主题前缀,例如 `hsbc-mastercard-alipay-campaign-01-cover.png`。 - 不要使用中文文件名或缺少主题前缀的通用文件名作为最终 assets 文件名,避免站点路径兼容和多文章混淆问题。 +- 不要用 Playwright、HTML/CSS、SVG、Pillow、canvas、本地拼贴或手工覆盖文字替代 GPT Image2 / 图片 API 生成最终配图。 ## 图片生成偏差处理 @@ -118,6 +126,11 @@ article_package.zip 交付前必须逐项确认: - Markdown 文件已生成。 +- `outline.md` 已按用户确认版本保存。 +- `prompts/` 中每张图的 prompt 文件已保存,且与最终图片数量和顺序一致。 +- 一张样张已确认,后续图片继承同一生图后端和视觉系统。 +- `slide_jobs.json` / `slide_run_state.json` 已记录图片任务状态;如缺少记录,最终回复必须说明原因。 +- `origin_image/` 中最终原始图片存在。 - `assets/` 目录已生成。 - 所有 Markdown 图片路径都能对应到真实图片文件。 - 图片是多张独立 PNG,不是一张大图代替全部。 diff --git a/references/image-generation.md b/references/image-generation.md index 956724e..ac701f4 100644 --- a/references/image-generation.md +++ b/references/image-generation.md @@ -11,6 +11,15 @@ 不要因为脚本存在就强制使用脚本。若内置图片工具可用,仍优先使用内置工具生成独立 PNG,再保存到 `assets/`。 +生成第一张图前,必须向用户确认: + +- 已检查哪种内置图片生成工具是否可用。 +- 准备使用的生图后端。 +- 是否需要 `scripts/maomomo_image_gen.py` fallback,以及原因。 +- 当前会沿用环境变量或配置文件中的 baseURL、model、quality,不会擅自改成 `low`。 + +确认后,整篇文章所有图片保持同一个生图后端和质量配置。只有用户明确要求切换,或当前后端无法完成必需能力时,才重新确认后端。 + ## 配置来源 脚本按以下顺序读取配置: @@ -27,7 +36,7 @@ ```bash python3 scripts/maomomo_image_gen.py generate \ - --out article/assets/hsbc-mastercard-alipay-campaign-01-cover.png \ + --out article/origin_image/hsbc-mastercard-alipay-campaign-01-cover.png \ --style warm-fintech-guide \ --title "汇丰 Mastercard 支付宝活动" \ --image-type "封面图" \ @@ -38,7 +47,7 @@ python3 scripts/maomomo_image_gen.py generate \ ```bash python3 scripts/maomomo_image_gen.py generate \ - --out article/assets/hsbc-mastercard-alipay-campaign-01-cover.png \ + --out article/origin_image/hsbc-mastercard-alipay-campaign-01-cover.png \ --style data-card-dashboard \ --title "返现计算图" \ --image-type "计算图" \ @@ -54,7 +63,7 @@ manifest 示例: { "images": [ { - "file_name": "assets/hsbc-mastercard-alipay-campaign-01-cover.png", + "file_name": "origin_image/hsbc-mastercard-alipay-campaign-01-cover.png", "type": "封面图", "title": "汇丰 Mastercard 支付宝活动", "core_text": "先报名,再看门槛和封顶", @@ -63,7 +72,7 @@ manifest 示例: "aspect_ratio": "16:9" }, { - "file_name": "assets/hsbc-mastercard-alipay-campaign-02-calculation.png", + "file_name": "origin_image/hsbc-mastercard-alipay-campaign-02-calculation.png", "type": "计算图", "title": "怎么刷到刚刚好", "core_text": "HK$190 ÷ 9% ≈ HK$2112,超过部分没有额外收益", @@ -81,7 +90,14 @@ python3 scripts/maomomo_image_gen.py batch \ --base-dir article ``` -`file_name` 必须与 Markdown 中的相对路径一致,且最终文件必须真实存在。 +`file_name` 必须与本次生成目录一致,且最终文件必须真实存在。发布用 Markdown 默认引用 `assets/`,因此输出到 `origin_image/` 后要在 QA 通过时同步到同名 `assets/` 文件。 + +重要限制: + +- 不要在样张确认前运行批量生成。 +- 默认用 `generate` 单张生成;`batch` 只用于用户明确授权的全量生成或 dry-run prompt 输出。 +- 即使使用 `batch`,脚本也是逐项请求图片接口;生成后仍要逐张 QA,并把通过的图片同步到 `assets/`。 +- 发布用 Markdown 默认引用 `assets/`,因此如果 manifest 输出到 `origin_image/`,必须在 QA 后复制到对应 `assets/` 路径。 ## 内置风格名 @@ -111,3 +127,69 @@ python3 scripts/maomomo_image_gen.py styles - 接口失败:保留已生成图片,说明失败项,必要时用 `--dry-run` 交付 prompt 清单作为临时排查材料。 - 图片文字乱码或金额错误:重新生成该单张图片;不要手工覆盖文字伪装成模型输出。 - 图片不符合风格:先更新 manifest 的 `style` / `core_text` / `extra`,再重跑对应项。 +- 图片生成超时:可以对同一张图重试较长超时;不要为了快擅自降低 `quality`。 +- Playwright 只用于已有 HTML / SVG / 网页截图检查,不能替代 GPT Image2 / 图片 API 生成海报、OG 图、卡片图或文章配图。 + +## 状态记录 + +每张图片 job 的 dispatch、result 和 blocker 都要写入项目状态文件。推荐状态文件: + +```text +{article_dir}/slide_jobs.json +{article_dir}/slide_run_state.json +``` + +记录字段至少包括: + +- job id,例如 `hsbc-mastercard-alipay-campaign_01` +- prompt 文件,例如 `prompts/hsbc-mastercard-alipay-campaign_01.json` +- 输出原图,例如 `origin_image/hsbc-mastercard-alipay-campaign-01-cover.png` +- 发布图,例如 `assets/hsbc-mastercard-alipay-campaign-01-cover.png` +- 状态:`pending`、`dispatched`、`recorded`、`blocked` +- 生图后端、agent id、QA note、失败原因 + +使用 `scripts/maomomo_job_state.py` 更新状态,不要手工改 JSON 后声称完成。 + +初始化状态: + +```bash +python3 scripts/maomomo_job_state.py init article \ + --selected-backend "built-in image tool" +``` + +查看状态: + +```bash +python3 scripts/maomomo_job_state.py status article +``` + +记录 dispatch: + +```bash +python3 scripts/maomomo_job_state.py dispatch article \ + --job hsbc-mastercard-alipay-campaign_02 \ + --agent-id "" \ + --prompt-file prompts/hsbc-mastercard-alipay-campaign_02.json +``` + +记录结果并同步到 `origin_image/` 和 `assets/`: + +```bash +python3 scripts/maomomo_job_state.py result article \ + --job hsbc-mastercard-alipay-campaign_02 \ + --agent-id "" \ + --backend-used "built-in image tool" \ + --selected-source /absolute/path/to/generated.png \ + --qa-note "文字清楚,风格与样张一致。" +``` + +记录 blocker: + +```bash +python3 scripts/maomomo_job_state.py blocker article \ + --job hsbc-mastercard-alipay-campaign_02 \ + --agent-id "" \ + --reason "选定图片后端在 worker 中不可用" +``` + +如果状态脚本失败,最终报告必须说明原因,并附上状态文件当前路径和未完成 job。 diff --git a/references/visual-assets.md b/references/visual-assets.md index c808663..05d8351 100644 --- a/references/visual-assets.md +++ b/references/visual-assets.md @@ -7,9 +7,15 @@ - 完整文章默认配独立 PNG:短文章 4-6 张,普通攻略 6-8 张,长教程 / 多活动整理 8-12 张。 - 不为凑数量硬加图;每张图必须服务正文。 - 每张图独立生成或准备,不要用一张拼图、长图或总图代替全部。 +- 出图前先把全部图片 prompt 写入 `prompts/` 或 manifest 文件;不要在聊天里输出超长 prompt 代替文件。 +- 先只生成一张样张,优先封面图;用户确认样张前不得生成剩余图片。 +- 样张确认后,后续图片使用同一生图后端、同一视觉系统和同一质量配置。 +- 图片生成按“一张图一个 job / 一次请求 / 一次 QA”推进;运行环境支持子 agent 时,可以一图一个子 agent 并发,但不要一次请求多张图。 +- 最终原始图保存到 `origin_image/`,通过 QA 后同步到 Markdown 引用的 `assets/`。 - 生成图必须是“示意图 / 信息图 / 教程视觉”,不得伪造真实 App 截图、账单或官方页面。 - 用户提供真实截图时,优先用真实截图,并隐去姓名、账号、余额、地址、手机号、二维码、交易号等个人信息。 - 每张图必须包含清晰可见的 `MAOMOMO` 标识。 +- 不要用 Playwright、HTML/CSS、SVG、Pillow、canvas、本地拼贴或手工覆盖文字替代图片生成后端。 ## 风格选择 @@ -66,7 +72,7 @@ C. handdrawn-note:便签和手绘箭头更明显,适合避坑经验和保姆 ## 图片清单格式 -生成图片前先规划清单: +生成图片前先规划清单,并同步写入 `prompts/{article_slug}_XX.json` 或图片 manifest。每个 prompt 文件至少包含:文件名、类型、放置位置、核心文案、alt text、风格、依赖素材、输出路径和完整 prompt。 ```markdown | 文件名 | 类型 | 放置位置 | 核心文案 | alt text | 风格 | 依赖素材 | @@ -75,13 +81,35 @@ C. handdrawn-note:便签和手绘箭头更明显,适合避坑经验和保姆 | hong-kong-bank-account-02-flow.png | 流程图 | 操作步骤前 | 三步完成账户活跃 | 港卡保号操作流程图 | handdrawn-note | 用户截图 1 | ``` -确认用户不需要先看清单时,可以直接执行,但最终必须让文件名、alt text 和插图位置与正文一致。 +确认用户不需要先看清单时,可以不在聊天里展开清单,但仍必须写入 prompt 文件,并遵守“先样张、后逐张生成”的门禁。最终必须让文件名、alt text 和插图位置与正文一致。 + +推荐单图 prompt JSON: + +```json +{ + "id": "hsbc-mastercard-alipay-campaign_01", + "file_name": "hsbc-mastercard-alipay-campaign-01-cover.png", + "origin_out": "origin_image/hsbc-mastercard-alipay-campaign-01-cover.png", + "asset_out": "assets/hsbc-mastercard-alipay-campaign-01-cover.png", + "type": "封面图", + "placement": "标题下方", + "core_text": "先报名,再看门槛和封顶", + "alt_text": "MAOMOMO 汇丰 Mastercard 支付宝返现活动封面图", + "style": "warm-fintech-guide", + "required_assets": [], + "prompt": "完整图片生成 prompt,包含风格、文案、约束和禁止项" +} +``` ## 文件命名 图片文件名必须 SEO 友好,并包含主题前缀: ```text +origin_image/ + hsbc-mastercard-alipay-campaign-01-cover.png + hsbc-mastercard-alipay-campaign-02-overview.png + assets/ hsbc-mastercard-alipay-campaign-01-cover.png hsbc-mastercard-alipay-campaign-02-overview.png @@ -120,6 +148,8 @@ assets/ 图片说明必须根据图片内容写,不要都写成“配图”。 +`origin_image/` 不直接写进 Markdown,除非用户明确要求引用原始图目录。发布用 Markdown 默认引用 `assets/`。 + ## 猫咪视觉识别 基础方向: @@ -163,10 +193,13 @@ MAOMOMO 风格中文金融教程信息图,干净明亮白色背景,暖橙色 交付前检查: +- 样张已被用户确认,后续图片没有切换生图后端或视觉风格。 - 文件真实存在,格式为 PNG。 +- `origin_image/` 原始最终图和 `assets/` 发布图对应。 - Markdown 路径能打开对应文件。 - 图片文字清楚,无乱码、错字和过期金额。 - 标题、金额、日期、规则与最终文章口径一致。 - 没有真实个人信息。 - 没有伪造官方 App 页面或未经授权的品牌 Logo 场景。 - 每张图独立主题明确,不是同一画面的机械变体。 +- `slide_jobs.json` / `slide_run_state.json` 中没有未处理的 `pending`、`dispatched` 或 `blocked` job;如有,不能声称完整交付完成。 diff --git a/scripts/maomomo_image_gen.py b/scripts/maomomo_image_gen.py index d04566b..070267f 100644 --- a/scripts/maomomo_image_gen.py +++ b/scripts/maomomo_image_gen.py @@ -221,7 +221,7 @@ def request_image(prompt: str, *, size: str, quality: str, output_format: str) - ) try: - with urllib.request.urlopen(req, timeout=180) as resp: + with urllib.request.urlopen(req, timeout=300) as resp: data = json.loads(resp.read().decode("utf-8")) except urllib.error.HTTPError as exc: detail = exc.read().decode("utf-8", "replace") @@ -238,7 +238,7 @@ def request_image(prompt: str, *, size: str, quality: str, output_format: str) - if isinstance(first.get("b64_json"), str): return base64.b64decode(first["b64_json"]) if isinstance(first.get("url"), str): - with urllib.request.urlopen(first["url"], timeout=180) as resp: + with urllib.request.urlopen(first["url"], timeout=300) as resp: return resp.read() die("图片接口响应中没有 b64_json 或 url。") return b"" diff --git a/scripts/maomomo_job_state.py b/scripts/maomomo_job_state.py new file mode 100644 index 0000000..186014d --- /dev/null +++ b/scripts/maomomo_job_state.py @@ -0,0 +1,330 @@ +#!/usr/bin/env python3 +"""记录 MAOMOMO 文章配图任务状态。 + +本脚本用于把图片生成进度写入状态文件,避免只用聊天记录声称已完成。 +""" + +from __future__ import annotations + +import argparse +from datetime import datetime, timezone +import json +from pathlib import Path +import shutil +import sys +from typing import Any, Dict, Iterable, List, Optional, Tuple + + +JOBS_FILE = "slide_jobs.json" +STATE_FILE = "slide_run_state.json" + + +def now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def die(message: str, code: int = 1) -> None: + print(f"错误:{message}", file=sys.stderr) + raise SystemExit(code) + + +def read_text(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def write_json(path: Path, data: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8", newline="\n") as handle: + json.dump(data, handle, ensure_ascii=False, indent=2) + handle.write("\n") + + +def read_json(path: Path) -> Any: + try: + return json.loads(read_text(path)) + except FileNotFoundError: + die(f"找不到文件:{path}") + except json.JSONDecodeError as exc: + die(f"JSON 格式错误:{path}: {exc}") + + +def project_path(project: str) -> Path: + path = Path(project).expanduser().resolve() + if not path.exists(): + die(f"项目目录不存在:{path}") + if not path.is_dir(): + die(f"不是项目目录:{path}") + return path + + +def resolve_in_project(project: Path, value: str) -> Path: + path = Path(value) + if not path.is_absolute(): + path = project / path + return path.resolve() + + +def load_job_doc(project: Path) -> Dict[str, Any]: + raw = read_json(project / JOBS_FILE) + if isinstance(raw, list): + return {"version": 1, "jobs": raw} + if not isinstance(raw, dict): + die(f"{JOBS_FILE} 必须是对象或数组。") + jobs = raw.get("jobs") + if not isinstance(jobs, list): + die(f"{JOBS_FILE} 缺少 jobs 数组。") + return raw + + +def load_state_doc(project: Path) -> Dict[str, Any]: + path = project / STATE_FILE + if not path.exists(): + return {"version": 1, "events": []} + raw = read_json(path) + if not isinstance(raw, dict): + die(f"{STATE_FILE} 必须是对象。") + if not isinstance(raw.get("events"), list): + raw["events"] = [] + return raw + + +def save_docs(project: Path, job_doc: Dict[str, Any], state_doc: Dict[str, Any]) -> None: + write_json(project / JOBS_FILE, job_doc) + write_json(project / STATE_FILE, state_doc) + + +def job_key(job: Dict[str, Any]) -> Optional[str]: + for key in ("id", "job_id", "slide", "name"): + value = job.get(key) + if isinstance(value, str) and value: + return value + return None + + +def find_job(job_doc: Dict[str, Any], wanted: str) -> Dict[str, Any]: + for job in job_doc["jobs"]: + if isinstance(job, dict) and job_key(job) == wanted: + return job + die(f"未找到图片 job:{wanted}") + return {} + + +def event(state_doc: Dict[str, Any], kind: str, job_id: str, payload: Dict[str, Any]) -> None: + state_doc.setdefault("events", []).append( + { + "time": now_iso(), + "event": kind, + "job": job_id, + **payload, + } + ) + + +def infer_origin_out(prompt_path: Path, data: Dict[str, Any]) -> str: + for key in ("origin_out", "out", "output", "selected_output"): + value = data.get(key) + if isinstance(value, str) and value: + return value + file_name = data.get("file_name") + if isinstance(file_name, str) and file_name: + return str(Path("origin_image") / Path(file_name).name) + return str(Path("origin_image") / f"{prompt_path.stem}.png") + + +def infer_asset_out(data: Dict[str, Any], origin_out: str) -> str: + value = data.get("asset_out") + if isinstance(value, str) and value: + return value + return str(Path("assets") / Path(origin_out).name) + + +def cmd_init(args: argparse.Namespace) -> int: + project = project_path(args.project) + prompts_dir = resolve_in_project(project, args.prompts_dir) + if not prompts_dir.exists(): + die(f"prompts 目录不存在:{prompts_dir}") + + jobs: List[Dict[str, Any]] = [] + for prompt_path in sorted(prompts_dir.glob("*.json")): + data = read_json(prompt_path) + if not isinstance(data, dict): + die(f"prompt job 必须是 JSON 对象:{prompt_path}") + job_id = str(data.get("id") or data.get("job_id") or prompt_path.stem) + origin_out = infer_origin_out(prompt_path, data) + jobs.append( + { + "id": job_id, + "prompt_file": str(prompt_path.relative_to(project)), + "origin_out": origin_out, + "asset_out": infer_asset_out(data, origin_out), + "status": "pending", + "selected_backend": args.selected_backend, + "created_at": now_iso(), + } + ) + + if not jobs: + die(f"没有找到 prompt JSON:{prompts_dir}") + + job_doc = {"version": 1, "jobs": jobs} + state_doc = {"version": 1, "events": [{"time": now_iso(), "event": "init", "job_count": len(jobs)}]} + save_docs(project, job_doc, state_doc) + print(json.dumps({"status": "initialized", "jobs": len(jobs)}, ensure_ascii=False, indent=2)) + return 0 + + +def cmd_status(args: argparse.Namespace) -> int: + project = project_path(args.project) + job_doc = load_job_doc(project) + counts: Dict[str, int] = {} + for job in job_doc["jobs"]: + if not isinstance(job, dict): + continue + status = str(job.get("status") or "unknown") + counts[status] = counts.get(status, 0) + 1 + + print(json.dumps({"project": str(project), "counts": counts}, ensure_ascii=False, indent=2)) + for job in job_doc["jobs"]: + if isinstance(job, dict): + print(f"{job_key(job) or ''}\t{job.get('status', 'unknown')}\t{job.get('origin_out', '')}") + return 0 + + +def cmd_dispatch(args: argparse.Namespace) -> int: + project = project_path(args.project) + job_doc = load_job_doc(project) + state_doc = load_state_doc(project) + job = find_job(job_doc, args.job) + prompt_file = args.prompt_file or job.get("prompt_file") + if not isinstance(prompt_file, str) or not prompt_file: + die("缺少 prompt 文件路径。") + + job["status"] = "dispatched" + job["dispatch"] = { + "agent_id": args.agent_id, + "agent_nickname": args.agent_nickname, + "prompt_file": prompt_file, + "time": now_iso(), + } + event(state_doc, "dispatch", args.job, job["dispatch"]) + save_docs(project, job_doc, state_doc) + print(json.dumps({"status": "dispatched", "job": args.job}, ensure_ascii=False, indent=2)) + return 0 + + +def copy_if_needed(source: Path, target: Path) -> None: + if not source.exists(): + die(f"结果图片不存在:{source}") + target.parent.mkdir(parents=True, exist_ok=True) + try: + if source.resolve() == target.resolve(): + return + except FileNotFoundError: + pass + shutil.copyfile(source, target) + + +def cmd_result(args: argparse.Namespace) -> int: + project = project_path(args.project) + job_doc = load_job_doc(project) + state_doc = load_state_doc(project) + job = find_job(job_doc, args.job) + source = resolve_in_project(project, args.selected_source) + + origin_value = args.origin_out or job.get("origin_out") + if not isinstance(origin_value, str) or not origin_value: + die("缺少 origin_out。") + origin_out = resolve_in_project(project, origin_value) + copy_if_needed(source, origin_out) + + asset_value = args.asset_out or job.get("asset_out") + if isinstance(asset_value, str) and asset_value: + copy_if_needed(origin_out, resolve_in_project(project, asset_value)) + + result = { + "agent_id": args.agent_id, + "backend_used": args.backend_used, + "selected_source": str(source), + "origin_out": str(origin_out.relative_to(project) if origin_out.is_relative_to(project) else origin_out), + "asset_out": asset_value, + "qa_note": args.qa_note, + "time": now_iso(), + } + job["status"] = "recorded" + job["result"] = result + event(state_doc, "result", args.job, result) + save_docs(project, job_doc, state_doc) + print(json.dumps({"status": "recorded", "job": args.job, "origin_out": result["origin_out"]}, ensure_ascii=False, indent=2)) + return 0 + + +def cmd_blocker(args: argparse.Namespace) -> int: + project = project_path(args.project) + job_doc = load_job_doc(project) + state_doc = load_state_doc(project) + job = find_job(job_doc, args.job) + blocker = { + "agent_id": args.agent_id, + "reason": args.reason, + "evidence": args.evidence, + "time": now_iso(), + } + job["status"] = "blocked" + job["blocker"] = blocker + event(state_doc, "blocker", args.job, blocker) + save_docs(project, job_doc, state_doc) + print(json.dumps({"status": "blocked", "job": args.job, "reason": args.reason}, ensure_ascii=False, indent=2)) + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Record MAOMOMO article image job state.") + subparsers = parser.add_subparsers(dest="command", required=True) + + init = subparsers.add_parser("init", help="Create slide_jobs.json from prompts/*.json.") + init.add_argument("project") + init.add_argument("--prompts-dir", default="prompts") + init.add_argument("--selected-backend", required=True) + init.set_defaults(func=cmd_init) + + status = subparsers.add_parser("status", help="Print job status summary.") + status.add_argument("project") + status.set_defaults(func=cmd_status) + + dispatch = subparsers.add_parser("dispatch", help="Record a dispatched image job.") + dispatch.add_argument("project") + dispatch.add_argument("--job", required=True) + dispatch.add_argument("--agent-id", required=True) + dispatch.add_argument("--agent-nickname", default="") + dispatch.add_argument("--prompt-file") + dispatch.set_defaults(func=cmd_dispatch) + + result = subparsers.add_parser("result", help="Record a generated image result.") + result.add_argument("project") + result.add_argument("--job", required=True) + result.add_argument("--agent-id", required=True) + result.add_argument("--backend-used", required=True) + result.add_argument("--selected-source", required=True) + result.add_argument("--origin-out") + result.add_argument("--asset-out") + result.add_argument("--qa-note", default="") + result.set_defaults(func=cmd_result) + + blocker = subparsers.add_parser("blocker", help="Record a blocked image job.") + blocker.add_argument("project") + blocker.add_argument("--job", required=True) + blocker.add_argument("--agent-id", required=True) + blocker.add_argument("--reason", required=True) + blocker.add_argument("--evidence", default="") + blocker.set_defaults(func=cmd_blocker) + return parser + + +def main(argv: Optional[Iterable[str]] = None) -> int: + parser = build_parser() + args = parser.parse_args(list(argv) if argv is not None else None) + return args.func(args) + + +if __name__ == "__main__": + raise SystemExit(main())