From d4082ec4777db97ff1f0f213f0ab29d7528734f3 Mon Sep 17 00:00:00 2001 From: ila Date: Mon, 22 Jun 2026 10:40:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai-outfit):=20auto-append=20output=20requi?= =?UTF-8?q?rements=20to=20prompt=20(=C2=A719.6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - core/ai_outfit: OUTPUT_REQUIREMENTS constant + build_output_requirements(); render_prompt(template, task, resolution=None) appends the block when a resolution is given; generate_outfit_image passes the current resolution. - ai_outfit_panel: inline preview now uses render_prompt/build_output_requirements with the selected resolution and refreshes when the resolution combo changes, so the preview equals what is actually sent. - tests: +3 cases (tail with resolution, none without, helper empty/filled); updated the success test for the appended tail. Full suite (12 files) green. Co-Authored-By: Claude Opus 4.8 --- src/app/widgets/ai_outfit_panel.py | 11 ++++++---- src/core/ai_outfit.py | 32 ++++++++++++++++++++++++++---- tasks.md | 8 ++++---- tests/test_ai_outfit.py | 24 +++++++++++++++++++++- 4 files changed, 62 insertions(+), 13 deletions(-) diff --git a/src/app/widgets/ai_outfit_panel.py b/src/app/widgets/ai_outfit_panel.py index b271021..398c028 100644 --- a/src/app/widgets/ai_outfit_panel.py +++ b/src/app/widgets/ai_outfit_panel.py @@ -258,6 +258,7 @@ class AiOutfitPanel(QWidget): self._retry_count.setRange(0, 10) self._resolution = QComboBox() self._resolution.addItems(_RESOLUTIONS) + self._resolution.currentIndexChanged.connect(self._refresh_preview) self._quality = QComboBox() self._quality.addItems(_QUALITIES) @@ -472,17 +473,19 @@ class AiOutfitPanel(QWidget): def _refresh_preview(self): if not hasattr(self, "_preview_view"): return + from core.ai_outfit import build_output_requirements, render_prompt template = self._prompt_edit.toPlainText() self._preview_warn.setVisible("{title}" not in template) if "{title}" not in template: self._preview_warn.setText("⚠ 话术缺少 {title} 占位符") + # Mirror what actually gets sent: append the output-requirements block + # for the currently selected resolution (docs/11 §7.1). + resolution = self._resolution.currentText() if hasattr(self, "_resolution") else None task = self._sample_combo.currentData() if hasattr(self, "_sample_combo") else None if task is None: - self._preview_view.setPlainText(template) + self._preview_view.setPlainText(template + build_output_requirements(resolution)) else: - rendered = template.replace("{title}", task.title).replace( - "{product_id}", task.product_id) - self._preview_view.setPlainText(rendered) + self._preview_view.setPlainText(render_prompt(template, task, resolution)) # -- run control ---------------------------------------------------- diff --git a/src/core/ai_outfit.py b/src/core/ai_outfit.py index 2e04fe9..d330432 100644 --- a/src/core/ai_outfit.py +++ b/src/core/ai_outfit.py @@ -25,10 +25,34 @@ QUALITY_PRESETS = { MAX_JPG_BYTES = 2 * 1024 * 1024 _INVALID_FILENAME_CHARS = re.compile(r'[<>:"/\\|?*\x00-\x1f]') +# Auto-appended to every prompt (ported from 标题生成产品图工具; docs/11 §7.1). +# Only 参考解析度 is dynamic (the chosen resolution); the rest is fixed. +OUTPUT_REQUIREMENTS = ( + "\n\n批量生成输出要求:" + "\n- 参考解析度:{resolution}" + "\n- 固定 1:1 正方形主图" + "\n- 必须结合商品标题与参考商品图片" + "\n- 服装本身、版型、颜色与图案不可跑版" +) -def render_prompt(template, task): - """Render an outfit prompt for one task.""" - return str(template).replace("{title}", task.title).replace("{product_id}", task.product_id) + +def build_output_requirements(resolution): + """Return the auto-appended output-requirements block (empty if no resolution).""" + if not resolution: + return "" + return OUTPUT_REQUIREMENTS.format(resolution=resolution) + + +def render_prompt(template, task, resolution=None): + """Render the final prompt for one task. + + Replaces {title}/{product_id}, then appends the fixed output-requirements + block when *resolution* is given (docs/11 §7.1). The user's 话术 box holds + only the creative part; structural requirements are appended by code. + """ + rendered = str(template).replace("{title}", task.title).replace( + "{product_id}", task.product_id) + return rendered + build_output_requirements(resolution) def safe_product_filename(product_id): @@ -88,7 +112,7 @@ def generate_outfit_image( raise TypeError("task must be OutfitTask") try: - prompt = render_prompt(prompt_template, task) + prompt = render_prompt(prompt_template, task, resolution=resolution) client = api_client or ImageApiClient(model_config) image_bytes = client.generate(prompt, task.garment_path, resolution=resolution) output_path = make_outfit_output_path(output_dir, task.product_id) diff --git a/tasks.md b/tasks.md index a96b17d..d528794 100644 --- a/tasks.md +++ b/tasks.md @@ -1087,7 +1087,7 @@ 背景:旧项目最终提示词 = 用户话术 + 自动附加的输出要求(参考解析度 / 固定 1:1 / 结合标题与参考图 / 不可跑版);当前 cmbot 的 `render_prompt` 只替换 `{title}`,缺这段。决策:简体文案、始终自动附加(不做开关)。 -- [ ] `core/ai_outfit.py`:加 `OUTPUT_REQUIREMENTS` 常量 + `build_output_requirements(resolution)`;`render_prompt(template, task, resolution=None)` 在替换占位符后附加该段(`resolution` 为空不加);`generate_outfit_image` 传入当前 resolution -- [ ] `app/widgets/ai_outfit_panel.py`:内嵌预览改用 `render_prompt`(带当前分辨率),分辨率下拉变化时刷新预览,使「预览 = 实际发送」 -- [ ] 单测:带 `resolution` 追加尾巴且内容正确、无 `resolution` 不加;`build_output_requirements` 空/非空(加入 `tests/test_ai_outfit.py`) -- [ ] 同步 `docs/ui-ai-outfit.html` 预览框示例含该段并重渲 `.png`(可选) +- [x] `core/ai_outfit.py`:加 `OUTPUT_REQUIREMENTS` 常量 + `build_output_requirements(resolution)`;`render_prompt(template, task, resolution=None)` 在替换占位符后附加该段(`resolution` 为空不加);`generate_outfit_image` 传入当前 resolution +- [x] `app/widgets/ai_outfit_panel.py`:内嵌预览改用 `render_prompt`(带当前分辨率),分辨率下拉变化时刷新预览,使「预览 = 实际发送」 +- [x] 单测:带 `resolution` 追加尾巴且内容正确、无 `resolution` 不加;`build_output_requirements` 空/非空(加入 `tests/test_ai_outfit.py`,全套 12 文件绿) +- [ ] 同步 `docs/ui-ai-outfit.html` 预览框示例含该段并重渲 `.png`(可选,未做) diff --git a/tests/test_ai_outfit.py b/tests/test_ai_outfit.py index b664366..93b824c 100644 --- a/tests/test_ai_outfit.py +++ b/tests/test_ai_outfit.py @@ -39,8 +39,28 @@ class TestAiOutfitCore(unittest.TestCase): prompt = render_prompt("商品 {title} / {product_id}", self._task()) + # No resolution -> no appended requirements tail. self.assertEqual(prompt, "商品 纯棉短袖 / TY001") + def test_render_prompt_appends_requirements_with_resolution(self): + from core.ai_outfit import render_prompt + + prompt = render_prompt("话术 {title}", self._task(), resolution="2K") + + self.assertTrue(prompt.startswith("话术 纯棉短袖")) + self.assertIn("批量生成输出要求:", prompt) + self.assertIn("参考解析度:2K", prompt) + self.assertIn("不可跑版", prompt) + + def test_build_output_requirements_empty_and_filled(self): + from core.ai_outfit import build_output_requirements + + self.assertEqual(build_output_requirements(""), "") + self.assertEqual(build_output_requirements(None), "") + filled = build_output_requirements("4K") + self.assertIn("参考解析度:4K", filled) + self.assertIn("固定 1:1 正方形主图", filled) + def test_safe_product_filename_replaces_invalid_chars(self): from core.ai_outfit import safe_product_filename @@ -83,7 +103,9 @@ class TestAiOutfitCore(unittest.TestCase): self.assertTrue(result.success, result.error) self.assertTrue(Path(result.output_path).exists()) - self.assertEqual(client.prompt, "为 纯棉短袖 生成 TY001") + # generate passes resolution -> rendered prompt + appended requirements. + self.assertTrue(client.prompt.startswith("为 纯棉短袖 生成 TY001")) + self.assertIn("批量生成输出要求:", client.prompt) self.assertTrue(client.image_path.endswith("garment.png")) def test_generate_outfit_image_failure(self):