From 2f05974a611c6cdefd4bb92e93823379a957dfe5 Mon Sep 17 00:00:00 2001 From: chengma Date: Wed, 22 Jul 2026 17:23:20 +0800 Subject: [PATCH] fix(product-suite): allow braces in business prompts --- app/gui/tabs/product_suite.py | 37 +++++++++++-------- app/product_suite.py | 3 +- docs/04-architecture.md | 2 +- docs/routes.md | 2 +- docs/tasks/T-687.md | 4 ++- tests/test_product_suite.py | 15 ++++++++ tests/test_product_suite_gui.py | 64 +++++++++++++++++++++++++++++++++ 7 files changed, 108 insertions(+), 19 deletions(-) diff --git a/app/gui/tabs/product_suite.py b/app/gui/tabs/product_suite.py index 7a74257..9249a87 100644 --- a/app/gui/tabs/product_suite.py +++ b/app/gui/tabs/product_suite.py @@ -4766,13 +4766,19 @@ class ProductSuiteTab(QWidget): if specs is None and not state.prompt.strip(): self._message("商品卖点不能为空", "请填写商品卖点与要求,或使用AI帮写。") return False - specs = list(specs or product_suite.build_job_specs( - local_assets, - state.prompt, - state.settings, - state.item_id or "未绑定商品", - template_text=template_text, - )) + try: + specs = list(specs or product_suite.build_job_specs( + local_assets, + state.prompt, + state.settings, + state.item_id or "未绑定商品", + template_text=template_text, + )) + except product_suite.ProductSuitePromptError: + message = "提示词渲染失败,请检查提示词设置中的变量格式。" + self._message("无法生成商品套图", message) + self._status(message, "danger") + return False if not specs: self._message("生成数量为0", "请至少把一个套图分类的数量设为1。") return False @@ -5044,13 +5050,16 @@ class ProductSuiteTab(QWidget): except prompts.PromptError: return False local_assets = [asset for asset in self._original_assets(state) if _asset_usable(asset)] - current_specs = product_suite.build_job_specs( - local_assets, - state.prompt, - state.settings, - state.item_id or "未绑定商品", - template_text=template_text, - ) + try: + current_specs = product_suite.build_job_specs( + local_assets, + state.prompt, + state.settings, + state.item_id or "未绑定商品", + template_text=template_text, + ) + except product_suite.ProductSuitePromptError: + return False return snapshot == self._generation_plan_snapshot( state, local_assets, diff --git a/app/product_suite.py b/app/product_suite.py index 8935eb8..906dc4c 100644 --- a/app/product_suite.py +++ b/app/product_suite.py @@ -338,8 +338,7 @@ def render_product_suite_prompt(template_text, context): lambda match: values[match.group(1)], str(template_text), ) - if "{" in rendered or "}" in rendered: - raise ProductSuitePromptError("提示词渲染后仍有未替换变量") + # 模板已在替换前校验完成;业务文本中的花括号不是模板变量。 return rendered.strip() diff --git a/docs/04-architecture.md b/docs/04-architecture.md index e9a85af..5c9690c 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -464,7 +464,7 @@ data/images///__new. # AI 生成的新 - T-658b 后 `image_studio_jobs.reference_asset_ids` 以有序 JSON 图片 ID 列表冻结每个商品套图 job 的参考图;新一轮未勾选逐图主图时,第1张为主图、后续最多7张写入快照,勾选时写空数组。历史 `NULL` 行继续按单图任务处理。恢复或重试只读 job 快照,不回读当前原图列表;参考图资产或本地文件缺失时提交前失败,不静默减少提交数量。 - T-658d 后逐图主图 checkbox 右侧显示可换行中文 helper,说明多SKU应逐图生成、同商品多角度应作为参考图一同提交。常规生成确认框从最终 specs 计算主图/参考图数量;未开启逐图主图且可用原图超过8张时,明确第1张主图加前7张参考图的上限和忽略数量。确认总点数仍只按 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/主参考图序号、参考图规则、商品卖点和四个只读规则。旧模板必须同时包含 `{套图名称}` 与 `{补充描述}` 才能按兼容路径继续使用;必需变量缺失、未知/未闭合花括号、只读规则变量未独占一行都视为无效。`product_suite_reference_rule()` 是 `{参考图规则}` 的唯一事实来源,变量值都以 `参考图规则:` 开头:逐图主图开启时为单图主参考规则;未开启时固定为“使用第一張上傳圖作為主商品圖,其餘圖片只作為參考。”,不随实际参考图数量变化。提示词弹窗与 job 共用 renderer;实际提交的参考图数量仍由冻结的 `reference_asset_ids` 决定。默认模板独占一行,不额外重复标签或序号。四个只读规则覆盖尺寸与长图(含禁止多宫格拼接)、政治标识、价格和尺码;商品主体一致性与禁止编造并入参考图规则。比例仍同时传入 `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()` 是 `{参考图规则}` 的唯一事实来源,变量值都以 `参考图规则:` 开头:逐图主图开启时为单图主参考规则;未开启时固定为“使用第一張上傳圖作為主商品圖,其餘圖片只作為參考。”,不随实际参考图数量变化。提示词弹窗与 job 共用 renderer;实际提交的参考图数量仍由冻结的 `reference_asset_ids` 决定。默认模板独占一行,不额外重复标签或序号。四个只读规则覆盖尺寸与长图(含禁止多宫格拼接)、政治标识、价格和尺码;商品主体一致性与禁止编造并入参考图规则。比例仍同时传入 `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/routes.md b/docs/routes.md index 3824944..6aa6aa0 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -221,7 +221,7 @@ - 每张真实原图左上角提供独立勾选框,标题行显示「已选 N 张 / 全选 / 反选」;添加占位图不参与选择。勾选只在当前任务界面内临时保留,普通刷新和排序按资产 ID 保留,切换任务或删除成功后清空。右键或 Delete 可批量移除,确认框说明准确数量、主图变化及非破坏性边界;生成中或勾选项仍在下载时整批阻断。移除只删除当前项目的本地资产记录,不删除本地源文件或蝦皮线上图片。 - 「拉取蝦皮主图」复用只读 CDP,读取 URL 后由最多2个下载 worker 后台落盘;不改标题/封面、不拖拽、不点击更新。拉取、下载期间其余界面和其他任务仍可操作。 - 套图按分类配置生成图片,固定分类顺序为白底图、场景图、模特场景图、细节说明图、卖点图,之后是自定义分类;默认数量为白底图1、场景图2、模特场景图0、细节说明图0、卖点图2。新增分类默认数量为0,用户未主动配置时不增加生成任务或点数消耗。自定义分类名称非空、无空格、最多10字且不可重名。逐图主图开启后,白底图只生成一次,其余分类按每张有效原图展开。 -- 套图提示词设置弹窗默认模板首行使用“生成目标:{套图名称},{生成目标}”。五个内置分类使用统一固定目标描述;自定义分类使用“生成自定义分类图片:分类名称。”,实际分类名称必须进入最终提示词。旧模板若仍使用 `{套图名称}` 与 `{补充描述}` 会继续读取和渲染,不静默覆盖用户文件;新模板与旧模板都由同一个 renderer 提供预览和 job prompt。 +- 套图提示词设置弹窗默认模板首行使用“生成目标:{套图名称},{生成目标}”。五个内置分类使用统一固定目标描述;自定义分类使用“生成自定义分类图片:分类名称。”,实际分类名称必须进入最终提示词。旧模板若仍使用 `{套图名称}` 与 `{补充描述}` 会继续读取和渲染,不静默覆盖用户文件;新模板与旧模板都由同一个 renderer 提供预览和 job prompt。模板变量只在替换前校验,商品卖点与要求、分类名等业务文本允许原样包含 `{}`,不会再次按变量解析;发生其他渲染异常时显示「无法生成商品套图」,不创建 job、worker、价格查询或网关请求。 - `{参考图规则}` 由实际提交图片数决定:逐图主图开启、或当前只有1张可用图时,使用“参考图规则:当前上传图片是本任务唯一主参考图;保持商品主体、款式、颜色和关键细节准确;不编造用户与参考图均未提供的信息。”;未开启且有2至8张可用图时,第1张为主商品图,第2至N张仅作风格、构图、场景或排版参考,N 为本次实际提交总数。提示词弹窗按当前可用原图数预览,`build_job_specs()` 按冻结的参考图快照渲染;默认模板中的 `{参考图规则}` 独占一行,不额外重复标签或序号。 - 「每张上传图分别作为主图生成」右侧固定显示可换行 helper:“多款式或多SKU图请勾选;同一商品多角度图不勾选,其余图会作为参考图一同提交。”确认生成框必须说明当前主图/参考图模式和实际参考图数;未开启且上传超过8张时,明确仅提交第1张主图加前7张参考图,以及未参与本轮的剩余数量。 - 平台、国家地区、语言和比例以四个带独立标签的同行下拉展示,选项只显示真实值;四项都写进每个 job 的完整提示词,比例还透传到 cmhub 生图请求,不是装饰字段。已有项目保存自己的完整设置;未绑定商品的新任务在重启后采用 `config.json` 的最近四项选择。生成仍走 `image_studio_generation.run_jobs()` 的 submit → poll → download 管线。 diff --git a/docs/tasks/T-687.md b/docs/tasks/T-687.md index b1a22f6..ec4eec3 100644 --- a/docs/tasks/T-687.md +++ b/docs/tasks/T-687.md @@ -3,7 +3,7 @@ id: T-687 title: 商品套图提示词花括号兼容与生成错误反馈 phase: 8 deps: [T-637] -status: TODO +status: DONE created: 2026-07-22 --- @@ -66,3 +66,5 @@ git diff --check ## 执行记录 - 2026-07-22:任务创建。已确认当前落盘模板可正常渲染;问题来自 renderer 将替换后的业务文本 `{}` 误判为未替换模板变量,且生成入口没有把异常转换为 GUI 提示。 +- 2026-07-22:完成 renderer 修复:模板在替换前仍严格校验,替换值不再二次按变量解析,商品卖点中的 `{}` 原样保留。生成入口和计划核对路径捕获 `ProductSuitePromptError`;入口异常显示「无法生成商品套图」及中文状态栏提示,不创建 worker、job、价格查询或网关请求。 +- 2026-07-22:验证通过:`py -3.10 -m compileall app main.py`、`py -3.10 -m ruff check app tests main.py`、`py -3.10 -m unittest discover -s tests -p test_product_suite.py`(11 项)、`py -3.10 -m unittest discover -s tests -p test_product_suite_gui.py`(66 项)以及完整单测 `py -3.10 -m unittest discover -s tests`(668 项)。 diff --git a/tests/test_product_suite.py b/tests/test_product_suite.py index 672d248..021afa1 100644 --- a/tests/test_product_suite.py +++ b/tests/test_product_suite.py @@ -294,6 +294,21 @@ class ProductSuiteTests(unittest.TestCase): self.assertIn("商品ID:未绑定商品", rendered) self.assertIn("参考图序号:2", rendered) + def test_business_prompt_braces_are_not_reparsed_as_template_variables(self): + template = prompts.load_default_product_suite_prompt() + business_prompt = "适合 {儿童} 使用,支持 {日常通勤} 场景" + + rendered = product_suite.build_suite_prompt( + business_prompt, + product_suite.default_suite_settings(), + "白底图", + "51100639510", + template_text=template, + ) + + self.assertIn(business_prompt, rendered) + self.assertEqual([], product_suite.product_suite_prompt_errors(template)) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_product_suite_gui.py b/tests/test_product_suite_gui.py index 9b20ef2..a015136 100644 --- a/tests/test_product_suite_gui.py +++ b/tests/test_product_suite_gui.py @@ -3501,6 +3501,70 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_generation_accepts_business_prompt_with_braces(self): + with self.make_temp_dir() as temp_dir: + config = self._config(temp_dir) + project, _assets = self._create_project_with_assets(temp_dir, config, 1) + tab = ProductSuiteTab(config=config, db_path=config["db_path"]) + self.addCleanup(tab.close) + state = tab._displayed_state + state.account_alias = "alias-a" + state.item_id = project.item_id + state.project_id = project.id + state.project_binding_state = project.binding_state + state.prompt = "适合 {儿童} 使用" + tab._load_state(state) + captured = [] + tab._start_generation_price_confirmation = ( + lambda _state, _assets, specs, *_args: captured.extend(specs) or True + ) + + self.assertTrue(tab.start_generation(state)) + + self.assertTrue(captured) + self.assertIn("适合 {儿童} 使用", captured[0]["prompt"]) + self.assertEqual([], image_studio.list_jobs(project.id, path=config["db_path"])) + + self.assert_removed(temp_dir) + + def test_generation_renders_error_as_chinese_feedback_without_side_effects(self): + with self.make_temp_dir() as temp_dir: + config = self._config(temp_dir) + project, _assets = self._create_project_with_assets(temp_dir, config, 1) + tab = ProductSuiteTab(config=config, db_path=config["db_path"]) + self.addCleanup(tab.close) + state = tab._displayed_state + state.account_alias = "alias-a" + state.item_id = project.item_id + state.project_id = project.id + state.project_binding_state = project.binding_state + state.prompt = "正常商品卖点" + tab._load_state(state) + messages = [] + statuses = [] + tab._message = lambda title, message, **_kwargs: messages.append((title, message)) + tab._status = lambda message, level=None: statuses.append((message, level)) + + with mock.patch( + "app.gui.tabs.product_suite.product_suite.build_job_specs", + side_effect=product_suite.ProductSuitePromptError("内部渲染失败"), + ), mock.patch.object(tab, "_start_generation_price_confirmation") as confirmation: + self.assertFalse(tab.start_generation(state)) + + self.assertEqual( + [("无法生成商品套图", "提示词渲染失败,请检查提示词设置中的变量格式。")], + messages, + ) + self.assertEqual( + [("提示词渲染失败,请检查提示词设置中的变量格式。", "danger")], + statuses, + ) + confirmation.assert_not_called() + self.assertIsNone(state.worker) + self.assertEqual([], image_studio.list_jobs(project.id, path=config["db_path"])) + + self.assert_removed(temp_dir) + def test_generation_confirmation_uses_planned_specs_for_cached_image_price(self): with self.make_temp_dir() as temp_dir: config = self._config(temp_dir)