feat(product-suite): add prompt template settings

This commit is contained in:
chengma
2026-07-16 16:18:11 +08:00
parent 613950c9f3
commit eea3fdd0a9
14 changed files with 1017 additions and 35 deletions
+3
View File
@@ -32,6 +32,9 @@ class PackagingTests(unittest.TestCase):
self.assertIn("datas=default_prompt_datas", normalized)
self.assertIn("COLLECT(", spec)
self.assertIn('name="cmshopee"', normalized)
suite_prompt = self.read_text("app/default_prompts/product_suite/base.txt")
self.assertIn("{套图名称}", suite_prompt)
self.assertIn("{尺寸与长图规则}", suite_prompt)
def test_build_script_blocks_user_data_in_release_output(self):
script = self.read_text("scripts/build_exe.ps1")
+76 -3
View File
@@ -5,7 +5,7 @@ from types import SimpleNamespace
sys.path.insert(0, os.path.dirname(__file__))
from app import product_suite
from app import product_suite, prompts
class ProductSuiteTests(unittest.TestCase):
@@ -88,6 +88,7 @@ class ProductSuiteTests(unittest.TestCase):
"40小时续航,适合通勤",
settings,
"51100639510",
template_text=prompts.load_default_product_suite_prompt(),
)
self.assertEqual(3, len(specs))
@@ -97,9 +98,81 @@ class ProductSuiteTests(unittest.TestCase):
self.assertIn("平台:Shopee", spec["prompt"])
self.assertIn("国家地区:中国台湾", spec["prompt"])
self.assertIn("输出语言:繁体中文", spec["prompt"])
self.assertIn("图片比例:4:3", spec["prompt"])
self.assertIn("商品ID:51100639510", spec["prompt"])
self.assertIn("本次生成比例:4:3", spec["prompt"])
self.assertNotIn("商品ID:51100639510", spec["prompt"])
self.assertIn("40小时续航", spec["prompt"])
self.assertIn("禁止海报长图、详情页长图和多宫格拼接版面", spec["prompt"])
self.assertIn("禁止在画面中出现任何国旗", spec["prompt"])
self.assertIn("当前上传图片是本任务唯一主参考图", spec["prompt"])
self.assertTrue(
spec["prompt"].splitlines()[-1].startswith("本次生成比例:4:3")
)
self.assertIn("套图名称:白底图,白底主图", specs[0]["prompt"])
self.assertIn("套图名称:场景图,生活化场景", specs[1]["prompt"])
white_prompt = specs[0]["prompt"]
self.assertLess(white_prompt.index("重要尺寸要求"), white_prompt.index("重要禁用内容"))
self.assertLess(white_prompt.index("重要禁用内容"), white_prompt.index("价格信息规则"))
self.assertLess(white_prompt.index("价格信息规则"), white_prompt.index("尺码信息规则"))
self.assertLess(white_prompt.index("尺码信息规则"), white_prompt.index("参考图规则"))
self.assertLess(white_prompt.index("参考图规则"), white_prompt.index("商品卖点与要求"))
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))
unknown = default_text + "\n{未知变量}"
self.assertIn("未知变量", ";".join(product_suite.product_suite_prompt_errors(unknown)))
missing = default_text.replace("{图片比例}", "")
self.assertIn(
"缺少必需变量",
";".join(product_suite.product_suite_prompt_errors(missing)),
)
inline_rule = default_text.replace(
"{价格信息规则}",
"价格:{价格信息规则}",
)
self.assertIn(
"只读规则变量必须独占一行",
";".join(product_suite.product_suite_prompt_errors(inline_rule)),
)
literal_brace = default_text + "\n普通内容{"
self.assertIn(
"不支持的字面花括号",
";".join(product_suite.product_suite_prompt_errors(literal_brace)),
)
settings = product_suite.default_suite_settings()
context = product_suite.product_suite_prompt_context(
"突出轻量材质",
settings,
"尺寸图",
"draft_hidden",
)
rendered = product_suite.render_product_suite_prompt(default_text, context)
self.assertIn("套图名称:尺寸图", rendered)
self.assertNotIn("draft_", rendered)
self.assertNotIn("白底主图", rendered)
def test_product_suite_optional_item_and_reference_variables(self):
template = prompts.load_default_product_suite_prompt() + (
"\n商品ID:{商品ID}\n参考图序号:{主参考图序号}"
)
context = product_suite.product_suite_prompt_context(
"卖点",
product_suite.default_suite_settings(),
"卖点图",
"",
source_index=2,
)
rendered = product_suite.render_product_suite_prompt(template, context)
self.assertIn("商品ID:未绑定商品", rendered)
self.assertIn("参考图序号:2", rendered)
if __name__ == "__main__":
+149 -2
View File
@@ -8,14 +8,14 @@ sys.path.insert(0, os.path.dirname(__file__))
from _helpers import TempDirMixin
from app import accounts, appconfig, image_studio, image_studio_images
from app import accounts, appconfig, image_studio, image_studio_images, product_suite, prompts
from app import gui
if gui.QT_IMPORT_ERROR is not None:
raise unittest.SkipTest("PySide6 未安装")
from PySide6.QtCore import QMimeData, Qt, QUrl
from PySide6.QtGui import QIcon, QImage, QPixmap
from PySide6.QtGui import QIcon, QImage, QPixmap, QTextCursor
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication, QLabel, QListWidgetItem, QPushButton
@@ -26,6 +26,7 @@ from app.gui.tabs.product_suite import (
ProductSuiteTab,
SuiteResultCard,
)
from app.gui.product_suite_prompt_dialog import ProductSuitePromptDialog
class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase):
@@ -150,6 +151,19 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase):
self.assertEqual("打开结果文件夹", tab.open_folder_button.text())
self.assertEqual("合计 5 张", tab.category_total_label.text())
self.assertEqual("生成套图(5)", tab.generate_button.text())
self.assertLess(
tab.prompt_title_layout.indexOf(tab.prompt_title_label),
tab.prompt_title_layout.indexOf(tab.ai_write_button),
)
self.assertLess(
tab.prompt_title_layout.indexOf(tab.ai_write_button),
tab.prompt_title_layout.indexOf(tab.ai_cancel_button),
)
self.assertLess(
tab.prompt_title_layout.indexOf(tab.ai_cancel_button),
tab.prompt_title_layout.indexOf(tab.prompt_settings_button),
)
self.assertEqual("提示词设置", tab.prompt_settings_button.text())
visible_text = " ".join(
[widget.text() for widget in tab.findChildren(QLabel)]
@@ -171,6 +185,139 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir)
def test_prompt_settings_dialog_previews_validates_saves_and_restores(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
prompt_path = appconfig.product_suite_prompt_path(config)
prompts.ensure_default_product_suite_prompt(prompt_path)
dialog = ProductSuitePromptDialog(
prompt_path=prompt_path,
base_prompt="40小时续航,适合通勤",
settings=product_suite.default_suite_settings(),
item_id="51100639510",
)
self.addCleanup(dialog.close)
dialog.show()
self.app.processEvents()
self.assertEqual("白底图", dialog.category_combo.currentData())
self.assertTrue(dialog.preview_edit.isReadOnly())
self.assertIn("套图名称:白底图,白底主图", dialog.preview_edit.toPlainText())
self.assertIn("40小时续航", dialog.preview_edit.toPlainText())
sizes = dialog.splitter.sizes()
self.assertLessEqual(abs(sizes[0] - sizes[1]), 12)
cursor = dialog.template_edit.textCursor()
cursor.movePosition(QTextCursor.End)
dialog.template_edit.setTextCursor(cursor)
dialog.insert_variable("商品ID")
self.assertTrue(dialog.template_edit.toPlainText().endswith("{商品ID}"))
default_text = prompts.load_default_product_suite_prompt()
invalid_text = default_text.replace("{图片比例}", "")
dialog.template_edit.setPlainText(invalid_text)
QTest.qWait(230)
self.assertFalse(dialog.save_button.isEnabled())
self.assertIn("缺少必需变量", dialog.validation_label.text())
custom_text = "自定义规则\n" + default_text
dialog.template_edit.setPlainText(custom_text)
QTest.qWait(230)
self.assertTrue(dialog.save_button.isEnabled())
self.assertTrue(dialog.save_template())
self.assertEqual(custom_text, prompts.load_product_suite_prompt(prompt_path))
self.assertFalse(dialog.is_dirty())
dialog.template_edit.setPlainText("临时未保存\n" + custom_text)
with mock.patch.object(dialog, "_confirm_restore", return_value=True):
self.assertTrue(dialog.restore_default())
self.assertEqual(default_text, dialog.template_edit.toPlainText())
self.assertEqual(default_text, prompts.load_product_suite_prompt(prompt_path))
self.assertFalse(dialog.is_dirty())
self.assert_removed(temp_dir)
def test_prompt_settings_dialog_unsaved_close_uses_chinese_three_way_action(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
prompt_path = appconfig.product_suite_prompt_path(config)
prompts.ensure_default_product_suite_prompt(prompt_path)
dialog = ProductSuitePromptDialog(
prompt_path=prompt_path,
base_prompt="卖点",
settings=product_suite.default_suite_settings(),
item_id="",
)
dialog.show()
self.app.processEvents()
dialog.template_edit.setPlainText(
"未保存修改\n" + prompts.load_default_product_suite_prompt()
)
self.assertTrue(dialog.is_dirty())
with mock.patch.object(dialog, "_unsaved_action", return_value="cancel"):
dialog.reject()
self.assertTrue(dialog.isVisible())
with mock.patch.object(dialog, "_unsaved_action", return_value="discard"):
dialog.reject()
self.assertFalse(dialog.isVisible())
self.assert_removed(temp_dir)
def test_invalid_product_suite_template_blocks_before_project_or_worker(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
accounts.create_account("主店", "alias-a", debug_port=9222, config=config)
tab = ProductSuiteTab(config=config, db_path=config["db_path"])
self.addCleanup(tab.close)
with open(tab.product_suite_prompt_path, "w", encoding="utf-8") as handle:
handle.write("无效模板{未知变量}")
message = mock.Mock()
with mock.patch.object(tab, "_message", message), mock.patch.object(
tab,
"_start_thread",
) as start_thread:
result = tab.start_generation(tab._displayed_state)
self.assertFalse(result)
self.assertIsNone(tab._displayed_state.project_id)
self.assertEqual([], image_studio.list_projects(path=config["db_path"]))
start_thread.assert_not_called()
self.assertEqual("套图提示词模板无效", message.call_args.args[0])
self.assert_removed(temp_dir)
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)
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 = "51100639510"
state.project_id = project.id
state.prompt = "40小时续航,适合通勤"
tab._load_state(state)
dialog = ProductSuitePromptDialog(
prompt_path=tab.product_suite_prompt_path,
base_prompt=state.prompt,
settings=state.settings,
item_id=state.item_id,
)
self.addCleanup(dialog.close)
expected = dialog.preview_edit.toPlainText()
with mock.patch.object(tab, "_start_thread", return_value=object()):
self.assertTrue(tab.start_generation(state))
self.assertEqual(expected, state.worker.job_specs[0]["prompt"])
state.worker = None
state.thread = None
self.assert_removed(temp_dir)
def test_category_rows_are_vertical_with_helpers_and_independent_counters(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
+80
View File
@@ -1,6 +1,7 @@
import os
import sys
import unittest
from unittest import mock
sys.path.insert(0, os.path.dirname(__file__))
@@ -183,6 +184,85 @@ class PromptTests(TempDirMixin, unittest.TestCase):
rendered,
)
def test_product_suite_prompt_seed_save_and_restore_are_isolated(self):
with self.make_temp_dir() as temp_dir:
path = os.path.join(temp_dir, "prompts", "product_suite", "base.txt")
default_text = prompts.load_default_product_suite_prompt()
self.assertEqual(default_text, prompts.ensure_default_product_suite_prompt(path))
self.assertEqual(default_text, prompts.load_product_suite_prompt(path))
custom_text = "自定义说明\n" + default_text
prompts.save_product_suite_prompt(custom_text, path)
self.assertEqual(custom_text, prompts.ensure_default_product_suite_prompt(path))
self.assertEqual(custom_text, prompts.load_product_suite_prompt(path))
self.assertEqual(default_text, prompts.restore_default_product_suite_prompt(path))
self.assertEqual(default_text, prompts.load_product_suite_prompt(path))
self.assert_removed(temp_dir)
def test_product_suite_prompt_invalid_user_file_is_preserved(self):
with self.make_temp_dir() as temp_dir:
path = os.path.join(temp_dir, "prompts", "product_suite", "base.txt")
os.makedirs(os.path.dirname(path), exist_ok=True)
invalid = "坏模板{未知变量}"
with open(path, "w", encoding="utf-8") as handle:
handle.write(invalid)
with self.assertRaisesRegex(prompts.PromptError, "套图提示词模板无效"):
prompts.ensure_default_product_suite_prompt(path)
with open(path, "r", encoding="utf-8") as handle:
self.assertEqual(invalid, handle.read())
self.assert_removed(temp_dir)
def test_product_suite_prompt_unreadable_utf8_is_not_overwritten(self):
with self.make_temp_dir() as temp_dir:
path = os.path.join(temp_dir, "prompts", "product_suite", "base.txt")
os.makedirs(os.path.dirname(path), exist_ok=True)
raw = b"\xff\xfe\x00\x80"
with open(path, "wb") as handle:
handle.write(raw)
with self.assertRaisesRegex(prompts.PromptError, "读取失败"):
prompts.ensure_default_product_suite_prompt(path)
with open(path, "rb") as handle:
self.assertEqual(raw, handle.read())
self.assert_removed(temp_dir)
def test_product_suite_prompt_invalid_packaged_default_does_not_create_file(self):
with self.make_temp_dir() as temp_dir:
path = os.path.join(temp_dir, "prompts", "product_suite", "base.txt")
with mock.patch.object(
prompts,
"load_default_product_suite_prompt",
side_effect=prompts.PromptError(prompts.PRODUCT_SUITE_DEFAULT_ERROR),
):
with self.assertRaisesRegex(prompts.PromptError, "内置套图提示词模板无效"):
prompts.ensure_default_product_suite_prompt(path)
self.assertFalse(os.path.exists(path))
self.assert_removed(temp_dir)
def test_product_suite_prompt_atomic_save_failure_keeps_original(self):
with self.make_temp_dir() as temp_dir:
path = os.path.join(temp_dir, "prompts", "product_suite", "base.txt")
default_text = prompts.ensure_default_product_suite_prompt(path)
custom_text = "自定义说明\n" + default_text
with mock.patch("app.prompts.os.replace", side_effect=OSError("文件占用")):
with self.assertRaisesRegex(prompts.PromptError, "保存失败"):
prompts.save_product_suite_prompt(custom_text, path)
self.assertEqual(default_text, prompts.load_product_suite_prompt(path))
self.assertFalse(
any(name.startswith(".prompt-") for name in os.listdir(os.path.dirname(path)))
)
self.assert_removed(temp_dir)
if __name__ == "__main__":
unittest.main()