From f64dfffe4028f6daa153ea59810ce0dec4b2b130 Mon Sep 17 00:00:00 2001 From: chengma Date: Fri, 17 Jul 2026 17:06:47 +0800 Subject: [PATCH] feat: align product suite prompts with reference images --- app/gui/product_suite_prompt_dialog.py | 6 ++++ app/gui/tabs/product_suite.py | 5 +++ app/product_suite.py | 45 ++++++++++++++++++++++---- docs/04-architecture.md | 2 +- docs/api.md | 2 +- docs/routes.md | 2 +- docs/tasks/T-658c.md | 7 ++-- tests/test_product_suite.py | 21 +++++++++--- tests/test_product_suite_gui.py | 4 ++- 9 files changed, 77 insertions(+), 17 deletions(-) diff --git a/app/gui/product_suite_prompt_dialog.py b/app/gui/product_suite_prompt_dialog.py index 0238285..4f49f57 100644 --- a/app/gui/product_suite_prompt_dialog.py +++ b/app/gui/product_suite_prompt_dialog.py @@ -33,6 +33,7 @@ class ProductSuitePromptDialog(QDialog): base_prompt, settings, item_id, + source_image_count=0, parent=None, ): super().__init__(parent) @@ -44,6 +45,10 @@ class ProductSuitePromptDialog(QDialog): self.base_prompt = str(base_prompt or "") self.settings = product_suite.normalize_suite_settings(settings) self.item_id = str(item_id or "") + self.reference_asset_count = product_suite.reference_asset_count( + self.settings, + source_image_count, + ) self._saved_text = "" self._allow_close = False @@ -193,6 +198,7 @@ class ProductSuitePromptDialog(QDialog): category, self.item_id or "未绑定商品", source_index=1, + reference_asset_count=self.reference_asset_count, ) def refresh_preview(self): diff --git a/app/gui/tabs/product_suite.py b/app/gui/tabs/product_suite.py index c7a9509..148036c 100644 --- a/app/gui/tabs/product_suite.py +++ b/app/gui/tabs/product_suite.py @@ -3138,15 +3138,20 @@ class ProductSuiteTab(QWidget): settings = state.settings base_prompt = state.prompt item_id = state.item_id or "未绑定商品" + source_image_count = len( + [asset for asset in self._original_assets(state) if _asset_usable(asset)] + ) else: settings = product_suite.default_suite_settings() base_prompt = "" item_id = "未绑定商品" + source_image_count = 0 dialog = ProductSuitePromptDialog( prompt_path=self.product_suite_prompt_path, base_prompt=base_prompt, settings=settings, item_id=item_id, + source_image_count=source_image_count, parent=self, ) self.prompt_settings_dialog = dialog diff --git a/app/product_suite.py b/app/product_suite.py index 4e74792..e96d02e 100644 --- a/app/product_suite.py +++ b/app/product_suite.py @@ -253,25 +253,53 @@ def validate_product_suite_prompt(template_text): return str(template_text) +def reference_asset_count(settings, source_image_count): + """Return the effective reference-image count for a planned cmhub request.""" + normalized = normalize_suite_settings(settings) + if normalized["per_image_primary"]: + return 0 + try: + image_count = max(0, int(source_image_count or 0)) + except (TypeError, ValueError): + image_count = 0 + return min(7, max(0, image_count - 1)) + + +def product_suite_reference_rule(settings, reference_asset_count=0): + normalized = normalize_suite_settings(settings) + try: + count = max(0, min(7, int(reference_asset_count or 0))) + except (TypeError, ValueError): + count = 0 + if normalized["per_image_primary"] or count <= 0: + return ( + "参考图规则:当前上传图片是本任务唯一主参考图;保持商品主体、款式、颜色和关键细节准确;" + "不编造用户与参考图均未提供的信息。" + ) + return ( + "参考图规则:第1张为主商品图,请保持其主体、款式、颜色和关键细节准确;" + "第2至%d张仅作为风格、构图、场景或排版参考,不得替换主商品;" + "不编造用户与参考图均未提供的信息。" % (count + 1) + ) + + def product_suite_prompt_context( base_prompt, settings, category, item_id, source_index=1, + reference_asset_count=0, ): normalized = normalize_suite_settings(settings) item_text = str(item_id or "").strip() if not item_text or item_text.startswith("draft_"): item_text = "未绑定商品" reference_index = max(1, int(source_index or 1)) - if normalized["per_image_primary"]: - reference_rule = ( - "参考图规则:当前上传图片是本任务唯一主参考图;保持商品主体、款式、颜色和关键细节准确;" - "不编造用户与参考图均未提供的信息。" - ) - else: - reference_rule = "参考图规则:使用第一張上傳圖作為主商品圖,其餘圖片只作為參考。" + reference_rule = product_suite_reference_rule( + normalized, + reference_asset_count, + ) return { "生成目标": category_description(category), "套图名称": str(category or ""), @@ -329,6 +357,7 @@ def build_suite_prompt( category, item_id, source_index=1, + reference_asset_count=0, *, template_text, ): @@ -338,6 +367,7 @@ def build_suite_prompt( category, item_id, source_index=source_index, + reference_asset_count=reference_asset_count, ) return render_product_suite_prompt(template_text, context) @@ -376,6 +406,7 @@ def build_job_specs(source_assets, base_prompt, settings, item_id, *, template_t category, item_id, source_index=source_index, + reference_asset_count=len(reference_asset_ids), template_text=template_text, ), } diff --git a/docs/04-architecture.md b/docs/04-architecture.md index cc76495..2bcec3e 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -440,7 +440,7 @@ data/images///__new. # AI 生成的新 - T-658a 后商品套图异步生图提交统一使用 `images` 数组,单图也使用单元素 `{"image_base64": ...}`,不再提交顶层 `image_base64`。提交层最多传 8 张本地图片,单图原文件上限10MiB、编码后总输入上限32MiB;超限在请求前以中文错误阻断,后续多图参考的资产快照和提示词语义由 T-658b/T-658c 负责。 - T-658b 后 `image_studio_jobs.reference_asset_ids` 以有序 JSON 图片 ID 列表冻结每个商品套图 job 的参考图;新一轮未勾选逐图主图时,第1张为主图、后续最多7张写入快照,勾选时写空数组。历史 `NULL` 行继续按单图任务处理。恢复或重试只读 job 快照,不回读当前原图列表;参考图资产或本地文件缺失时提交前失败,不静默减少提交数量。 - T-647 后商品套图中的「AI帮写」不是②标题生成的复用入口:它使用 `vision_alias` 调 `POST /api/v1/analyze/images`,把当前项目中1至8张可用本地原图按 `source_order` 在一次请求中发送;超过8张时明确只使用前8张,原图勾选状态不改变该输入集合。视觉提示词将这些图片定义为同一商品项目的多角度、细节、包装或场景证据集,要求产出一份商品级联合分析,不按“图1/图2”逐图罗列;结果按商品概述、可确认卖点、适用人群与场景、套图画面要求、待确认或避免编造的信息组织,并把可见差异明确为待确认项,不强行合并为事实。单图上限10MiB、总计上限32MiB,缺图、未下载、超限或未配置别名时不发请求且保留用户现有卖点。读取等待固定120秒、连接等待沿用设置;读超时或网络中断只提示“结果未确认”,不自动重发。成功只显示图片张数、扣点和余额,不显示路径、base64、接口URL、完整提示词或上游原始响应;取消只在请求前/返回后协作生效,不强杀网络线程。 -- `product_suite.render_product_suite_prompt()` 是弹窗预览和真实生成的唯一 renderer;`build_job_specs()` 建立本轮 specs 前只读取一次用户模板并冻结,每个 job 保存最终完整 prompt,运行中修改模板只影响下一轮。新模板使用 `{生成目标}`,其值为内置分类固定目标描述或含实际名称的“生成自定义分类图片:分类名称。”;同时保留 `{套图名称}`、`{补充描述}` 及平台/地区/语言/比例、可选商品ID/主参考图序号、参考图规则、商品卖点和四个只读规则。旧模板必须同时包含 `{套图名称}` 与 `{补充描述}` 才能按兼容路径继续使用;必需变量缺失、未知/未闭合花括号、只读规则变量未独占一行都视为无效。`{参考图规则}` 的变量值以 `参考图规则:` 开头:`per_image_primary=true` 时为“参考图规则:当前上传图片是本任务唯一主参考图;保持商品主体、款式、颜色和关键细节准确;不编造用户与参考图均未提供的信息。”,否则为“参考图规则:使用第一張上傳圖作為主商品圖,其餘圖片只作為參考。”。默认模板独占一行,不额外重复标签或序号。四个只读规则覆盖尺寸与长图(含禁止多宫格拼接)、政治标识、价格和尺码;商品主体一致性与禁止编造并入逐图主图规则。比例仍同时传入 `image_studio_generation.run_jobs(aspect_ratio=...)`,进入 cmhub 请求与输出资产元数据。 +- `product_suite.render_product_suite_prompt()` 是弹窗预览和真实生成的唯一 renderer;`build_job_specs()` 建立本轮 specs 前只读取一次用户模板并冻结,每个 job 保存最终完整 prompt,运行中修改模板只影响下一轮。新模板使用 `{生成目标}`,其值为内置分类固定目标描述或含实际名称的“生成自定义分类图片:分类名称。”;同时保留 `{套图名称}`、`{补充描述}` 及平台/地区/语言/比例、可选商品ID/主参考图序号、参考图规则、商品卖点和四个只读规则。旧模板必须同时包含 `{套图名称}` 与 `{补充描述}` 才能按兼容路径继续使用;必需变量缺失、未知/未闭合花括号、只读规则变量未独占一行都视为无效。`product_suite_reference_rule()` 是 `{参考图规则}` 的唯一事实来源,变量值都以 `参考图规则:` 开头:逐图主图开启或实际参考图数为0时为单图主参考规则;未开启且快照有1至7张参考图时为“第1张主商品图、第2至N张仅作参考”的多图规则,N 必须等于实际提交总图片数。提示词弹窗按当前可用原图数计算,job 按冻结的 `reference_asset_ids` 渲染,两者共用 renderer。默认模板独占一行,不额外重复标签或序号。四个只读规则覆盖尺寸与长图(含禁止多宫格拼接)、政治标识、价格和尺码;商品主体一致性与禁止编造并入参考图规则。比例仍同时传入 `image_studio_generation.run_jobs(aspect_ratio=...)`,进入 cmhub 请求与输出资产元数据。 - `image_studio_projects.suite_settings_json` 持久化套图设置,旧数据库由 `db.init_db()` 原位补列,默认 `{}`;`draft_prompt` 继续保存卖点文本。`image_studio_assets` 中有效商品原图最多16张,历史 missing 记录不占有效名额;手工原图不会因再次同步蝦皮 URL 被误标 missing。商品套图原图列表的批量勾选只保存在当前 `SuiteTaskState` 对应的界面上下文,不写库;批量移除由 `remove_original_assets_if_unused()` 一次校验项目归属、原图类型和 job/终选引用,并在单个 SQLite 事务中删除资产行、连续重排 `source_order`。服务不删除本地文件或蝦皮线上图片,任一资产校验失败时整批回滚。 - T-636 起,`image_studio_projects` 增加 `binding_state`(`draft` / `bound`)和稳定 `storage_key`。既有项目迁移为 `bound`,并以原 `item_id` 回填 `storage_key`;项目目录改用 `storage_key`,因此临时草稿绑定正式商品 ID 后不移动目录、不改写已有资产路径。草稿内部使用 `draft_` 作为仅数据库可见的非空 `item_id`,GUI 输入框始终保持空白,用户日志和 cmhub 提示词只使用“临时草稿”或“未绑定商品”,不得暴露该内部值。 - 在商品套图中,已选账号但未填写商品 ID 时允许输入卖点、导入、拖入或粘贴本地图片。非空卖点在现有防抖稳定后会创建一个可恢复临时草稿并保存到 `image_studio_projects.draft_prompt`;首次有效图片导入也会创建草稿。空白卖点、取消选择和全部导入失败不保留空草稿。只有卖点的草稿同样属于可恢复业务内容;卖点清空且没有资产/job 时仍可按空草稿规则清理。草稿可管理本地图片、AI 帮写、生成套图、查看历史和打开结果目录,但在创建 worker、启动 Chrome 或执行 CDP 前禁止「拉取蝦皮主图」。输入合法数字商品 ID 后,经确认原地绑定同一个 `project_id`;资产、job、selection、提示词、套图设置和 `storage_key` 均保持不变。若同账号目标 ID(含软删除项目)已存在则拒绝覆盖或合并。 diff --git a/docs/api.md b/docs/api.md index aa94091..7147721 100644 --- a/docs/api.md +++ b/docs/api.md @@ -438,7 +438,7 @@ export_generation_round(project_id, generation_round_key, parent_dir, path=None, - 导出只写 JPEG 图片文件,透明图铺白底;商品目录已存在时只能覆盖受管命名文件或新建带时间目录,不合并、不递归清空。 - 所有真实网络/CDP/图片生成/下载/转码由 GUI worker 调用;Widget slot 不直接执行长耗时操作。 - 商品套图默认提示词首行使用 `{套图名称}` 与 `{生成目标}`,结构为“生成目标:{套图名称},{生成目标}”;五个内置分类使用固定目标描述,自定义分类使用“生成自定义分类图片:分类名称。”并注入实际分类名称。旧模板同时包含 `{套图名称}` 和 `{补充描述}` 时继续兼容,预览和 `build_job_specs()` 使用同一 renderer,job 保存最终提示词快照。 -- `product_suite_prompt_context()` 按 `per_image_primary` 生成 `{参考图规则}`:开启时返回“参考图规则:当前上传图片是本任务唯一主参考图;保持商品主体、款式、颜色和关键细节准确;不编造用户与参考图均未提供的信息。”,关闭时返回“参考图规则:使用第一張上傳圖作為主商品圖,其餘圖片只作為參考。”;默认模板独占该变量行,不额外重复标签和序号。 +- `product_suite_reference_rule(settings, reference_asset_count)` 是 `{参考图规则}` 的唯一文案来源:逐图主图开启或实际参考图数为0时返回单图规则;未开启且参考图数为1至7时返回“第1张为主商品图、第2至N张仅作参考”的多图规则。`product_suite_prompt_context()`、提示词弹窗预览和 `build_job_specs()` 都使用该 helper;默认模板独占该变量行,不额外重复标签和序号。 ## gui 模块(`app/gui/` 包,已建,PySide6) diff --git a/docs/routes.md b/docs/routes.md index 51e2624..c1e74fb 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -205,7 +205,7 @@ - 「拉取蝦皮主图」复用只读 CDP,读取 URL 后由最多2个下载 worker 后台落盘;不改标题/封面、不拖拽、不点击更新。拉取、下载期间其余界面和其他任务仍可操作。 - 套图按分类配置生成图片,固定分类顺序为白底图、场景图、模特场景图、细节说明图、卖点图,之后是自定义分类;默认数量为白底图1、场景图2、模特场景图0、细节说明图0、卖点图2。新增分类默认数量为0,用户未主动配置时不增加生成任务或点数消耗。自定义分类名称非空、无空格、最多10字且不可重名。逐图主图开启后,白底图只生成一次,其余分类按每张有效原图展开。 - 套图提示词设置弹窗默认模板首行使用“生成目标:{套图名称},{生成目标}”。五个内置分类使用统一固定目标描述;自定义分类使用“生成自定义分类图片:分类名称。”,实际分类名称必须进入最终提示词。旧模板若仍使用 `{套图名称}` 与 `{补充描述}` 会继续读取和渲染,不静默覆盖用户文件;新模板与旧模板都由同一个 renderer 提供预览和 job prompt。 -- `{参考图规则}` 根据“每张上传图分别作为主图生成”切换:勾选时使用“参考图规则:当前上传图片是本任务唯一主参考图;保持商品主体、款式、颜色和关键细节准确;不编造用户与参考图均未提供的信息。”;未勾选时使用“参考图规则:使用第一張上傳圖作為主商品圖,其餘圖片只作為參考。”。默认模板中的 `{参考图规则}` 独占一行,不再额外重复该标签或追加参考图序号。 +- `{参考图规则}` 由实际提交图片数决定:逐图主图开启、或当前只有1张可用图时,使用“参考图规则:当前上传图片是本任务唯一主参考图;保持商品主体、款式、颜色和关键细节准确;不编造用户与参考图均未提供的信息。”;未开启且有2至8张可用图时,第1张为主商品图,第2至N张仅作风格、构图、场景或排版参考,N 为本次实际提交总数。提示词弹窗按当前可用原图数预览,`build_job_specs()` 按冻结的参考图快照渲染;默认模板中的 `{参考图规则}` 独占一行,不额外重复标签或序号。 - 平台、国家地区、语言和比例以四个带独立标签的同行下拉展示,选项只显示真实值;四项都写进每个 job 的完整提示词,比例还透传到 cmhub 生图请求,不是装饰字段。已有项目保存自己的完整设置;未绑定商品的新任务在重启后采用 `config.json` 的最近四项选择。生成仍走 `image_studio_generation.run_jobs()` 的 submit → poll → download 管线。 - 平台、站点、语言和比例四个生成设置下拉仅在闭合时拦截自身滚轮改值,并把滚动交给 `suiteConfigScroll`;展开时滚轮只浏览候选列表,不静默提交当前值。鼠标点击、方向键、回车和 `Alt+↓` 保持原有显式选择语义;账号、历史筛选和设置页等其它下拉不受影响。 - 生成按钮按当前总数显示并在运行时切换为「停止生成」;确认停止后显示「正在停止...」,重复点击不再弹确认框。每轮生成用独立运行标识隔离旧信号,本轮全部 job 终态或线程结束时都会统一恢复按钮;最终 worker 信号缺失时由数据库终态看门狗兜底,不要求用户重启。停止会取消未开始任务,已提交任务停止本地等待并保留后续继续查询语义;客户端不承诺取消服务端任务或退回点数。T-643 后项目持久化当前生成轮次:常规新轮至少成功一张才替换主结果区,全部失败/取消保留上一当前轮;主结果按稳定槽位显示同轮最新 job,单张重试留在原槽位。旧版无轮次 job 临时显示为“旧版历史记录”,不按时间或图片数量猜测归属。成功图可预览、复制路径、打开目录、重新生成、移入项目废纸篓并撤销,失败卡显示脱敏中文摘要与重试入口。 diff --git a/docs/tasks/T-658c.md b/docs/tasks/T-658c.md index de5ed37..8e2b797 100644 --- a/docs/tasks/T-658c.md +++ b/docs/tasks/T-658c.md @@ -1,7 +1,7 @@ --- id: T-658c title: 商品套图多图参考生成计划与提示词一致性 -status: TODO +status: DONE phase: 7 deps: [T-655, T-656, T-657, T-658b] created: 2026-07-17 @@ -49,4 +49,7 @@ git diff --check ## 执行记录 -- 待实现。 +- 2026-07-17:新增 `product_suite_reference_rule()` 与参考图数量计算 helper,单图和多图规则都保留 `参考图规则:` 前缀;多图规则中的 N 以实际提交总图片数渲染。 +- 2026-07-17:`build_job_specs()` 以冻结的 `reference_asset_ids` 数量构建 job prompt;提示词设置弹窗接收当前可用原图数并使用同一 context,预览与新 job prompt 保持一致。 +- 2026-07-17:补充单图、逐图主图、多图及3张原图预览一致性测试;同步路由、架构和 API 文档。 +- 验证通过:`py -3.10 -m unittest tests.test_product_suite tests.test_product_suite_gui`(66项)、`py -3.10 -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`git diff --check`。离屏 GUI 测试有 Qt 字体/插件告警,但无测试失败。 diff --git a/tests/test_product_suite.py b/tests/test_product_suite.py index dda35ff..33e6820 100644 --- a/tests/test_product_suite.py +++ b/tests/test_product_suite.py @@ -168,11 +168,10 @@ class ProductSuiteTests(unittest.TestCase): template_text=template, ) self.assertIn( - "参考图规则:使用第一張上傳圖作為主商品圖,其餘圖片只作為參考。", + "参考图规则:当前上传图片是本任务唯一主参考图;保持商品主体、款式、颜色和关键细节准确;不编造用户与参考图均未提供的信息。", single_primary, ) - self.assertNotIn("当前上传图片是本任务唯一主参考图", single_primary) - self.assertNotIn("参考图规则:使用", single_primary) + self.assertNotIn("第2至", single_primary) settings["per_image_primary"] = True per_image_primary = product_suite.build_suite_prompt( @@ -187,9 +186,23 @@ class ProductSuiteTests(unittest.TestCase): "参考图规则:当前上传图片是本任务唯一主参考图;保持商品主体、款式、颜色和关键细节准确;不编造用户与参考图均未提供的信息。", per_image_primary, ) - self.assertNotIn("使用第一張上傳圖作為主商品圖", per_image_primary) + self.assertNotIn("第2至", per_image_primary) self.assertNotIn("序号2", per_image_primary) + multi_reference = product_suite.build_suite_prompt( + "卖点", + product_suite.default_suite_settings(), + "场景图", + "51100639510", + reference_asset_count=2, + template_text=template, + ) + self.assertIn( + "参考图规则:第1张为主商品图,请保持其主体、款式、颜色和关键细节准确;第2至3张仅作为风格、构图、场景或排版参考,不得替换主商品;不编造用户与参考图均未提供的信息。", + multi_reference, + ) + self.assertEqual(7, product_suite.reference_asset_count({}, 9)) + def test_product_suite_template_validation_and_custom_category_rendering(self): default_text = prompts.load_default_product_suite_prompt() self.assertEqual([], product_suite.product_suite_prompt_errors(default_text)) diff --git a/tests/test_product_suite_gui.py b/tests/test_product_suite_gui.py index 812a78a..a21bd62 100644 --- a/tests/test_product_suite_gui.py +++ b/tests/test_product_suite_gui.py @@ -422,7 +422,7 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): def test_prompt_preview_matches_frozen_generation_job_prompt(self): with self.make_temp_dir() as temp_dir: config = self._config(temp_dir) - project, _ = self._create_project_with_assets(temp_dir, config, 1) + project, assets = self._create_project_with_assets(temp_dir, config, 3) tab = ProductSuiteTab(config=config, db_path=config["db_path"]) self.addCleanup(tab.close) state = tab._displayed_state @@ -437,6 +437,7 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): base_prompt=state.prompt, settings=state.settings, item_id=state.item_id, + source_image_count=len(assets), ) self.addCleanup(dialog.close) expected = dialog.preview_edit.toPlainText() @@ -448,6 +449,7 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): ), mock.patch.object(tab, "_start_thread", return_value=object()): self.assertTrue(tab.start_generation(state)) self.assertEqual(expected, state.worker.job_specs[0]["prompt"]) + self.assertIn("第2至3张仅作为风格、构图、场景或排版参考", expected) state.worker = None state.thread = None