feat: 完成T-303 AI批量生成
- 新增 generate_batch,先并发生成标题再并发生成封面,成功逐条 set_generated 落库 - Tab② 接入开始生成、停止、进度展示和双击新旧封面预览 - 新增 GenerateWorker,通过 worker signal 回传进度与行刷新 - 补充批量生成成功、失败、停止取消和 GUI worker 单元测试 - 同步任务看板、API、路由、当前状态与 progress 文档
This commit is contained in:
+154
-1
@@ -4,13 +4,14 @@ import json
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
from _helpers import TempDirMixin
|
||||
|
||||
from app import ai, appconfig
|
||||
from app import ai, appconfig, db
|
||||
|
||||
|
||||
class _Response:
|
||||
@@ -64,6 +65,35 @@ class AITests(TempDirMixin, unittest.TestCase):
|
||||
cfg["ai"]["jpg_quality"] = 80
|
||||
return cfg
|
||||
|
||||
def _collected_tasks(self, temp_dir, cfg, titles=None):
|
||||
titles = titles or ["旧标题A", "旧标题B"]
|
||||
db.init_db(cfg["db_path"])
|
||||
batch_id = db.create_batch([os.path.join(temp_dir, "input.xlsx")], path=cfg["db_path"])
|
||||
db.insert_tasks(
|
||||
batch_id,
|
||||
[
|
||||
{
|
||||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||||
"source_sheet": "商品",
|
||||
"source_row": index + 2,
|
||||
"account_name": "Excel主店",
|
||||
"alias": "alias-a",
|
||||
"item_id": "5110063951%s" % index,
|
||||
}
|
||||
for index in range(len(titles))
|
||||
],
|
||||
path=cfg["db_path"],
|
||||
)
|
||||
tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||||
for task, title in zip(tasks, titles):
|
||||
db.set_collected(
|
||||
task.id,
|
||||
title,
|
||||
os.path.join(temp_dir, "%s_old.jpg" % task.item_id),
|
||||
path=cfg["db_path"],
|
||||
)
|
||||
return batch_id, db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||||
|
||||
def test_gen_title_uses_configured_model_and_retries(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
models_path = os.path.join(temp_dir, "ai_models.json")
|
||||
@@ -159,6 +189,129 @@ class AITests(TempDirMixin, unittest.TestCase):
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_generate_batch_persists_titles_and_covers_per_task(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg = self._config()
|
||||
cfg["db_path"] = os.path.join(temp_dir, "cmshopee.db")
|
||||
cfg["image_dir"] = os.path.join(temp_dir, "images")
|
||||
cfg["ai"]["title_concurrency"] = 2
|
||||
cfg["ai"]["image_concurrency"] = 2
|
||||
batch_id, tasks = self._collected_tasks(temp_dir, cfg)
|
||||
cover_prompts = []
|
||||
progress = []
|
||||
|
||||
def fake_title(title_prompt, old_title, **kwargs):
|
||||
self.assertEqual("标题提示", title_prompt)
|
||||
return "新" + old_title
|
||||
|
||||
def fake_cover(cover_prompt, old_cover_path, out_path, **kwargs):
|
||||
cover_prompts.append(cover_prompt)
|
||||
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
||||
with open(out_path, "wb") as fh:
|
||||
fh.write(b"jpeg")
|
||||
return out_path
|
||||
|
||||
with mock.patch("app.ai.gen_title", side_effect=fake_title), \
|
||||
mock.patch("app.ai.gen_cover", side_effect=fake_cover):
|
||||
summary = ai.generate_batch(
|
||||
tasks,
|
||||
{
|
||||
"title": "标题提示",
|
||||
"cover": "封面 {新标题} {店铺} {商品id}",
|
||||
},
|
||||
ai_cfg={
|
||||
"config": cfg,
|
||||
"db_path": cfg["db_path"],
|
||||
"account_by_alias": {
|
||||
"alias-a": SimpleNamespace(account_name="主店", slug="main")
|
||||
},
|
||||
},
|
||||
on_progress=progress.append,
|
||||
)
|
||||
|
||||
self.assertTrue(summary["ok"])
|
||||
self.assertEqual(2, summary["title_done"])
|
||||
self.assertEqual(2, summary["cover_done"])
|
||||
self.assertEqual(0, summary["failed"])
|
||||
self.assertTrue(progress)
|
||||
updated = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||||
self.assertTrue(all(task.stage == "generated" for task in updated))
|
||||
self.assertEqual({"新旧标题A", "新旧标题B"}, {task.new_title for task in updated})
|
||||
self.assertTrue(all(task.new_cover_path.endswith("_new.jpg") for task in updated))
|
||||
self.assertTrue(all(os.path.exists(task.new_cover_path) for task in updated))
|
||||
self.assertIn("主店", "\n".join(cover_prompts))
|
||||
self.assertIn("新旧标题A", "\n".join(cover_prompts))
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_generate_batch_marks_failed_task_without_blocking_others(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg = self._config()
|
||||
cfg["db_path"] = os.path.join(temp_dir, "cmshopee.db")
|
||||
cfg["image_dir"] = os.path.join(temp_dir, "images")
|
||||
batch_id, tasks = self._collected_tasks(temp_dir, cfg, ["好标题", "坏标题"])
|
||||
|
||||
def fake_title(title_prompt, old_title, **kwargs):
|
||||
if old_title == "坏标题":
|
||||
raise ai.AIError("标题生成失败")
|
||||
return "新" + old_title
|
||||
|
||||
def fake_cover(cover_prompt, old_cover_path, out_path, **kwargs):
|
||||
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
||||
with open(out_path, "wb") as fh:
|
||||
fh.write(b"jpeg")
|
||||
return out_path
|
||||
|
||||
with mock.patch("app.ai.gen_title", side_effect=fake_title), \
|
||||
mock.patch("app.ai.gen_cover", side_effect=fake_cover):
|
||||
summary = ai.generate_batch(
|
||||
tasks,
|
||||
{"title": "标题提示", "cover": "封面 {新标题}"},
|
||||
ai_cfg={"config": cfg, "db_path": cfg["db_path"]},
|
||||
)
|
||||
|
||||
self.assertFalse(summary["ok"])
|
||||
self.assertEqual(1, summary["title_done"])
|
||||
self.assertEqual(1, summary["cover_done"])
|
||||
self.assertEqual(1, summary["failed"])
|
||||
updated = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||||
by_title = {task.old_title: task for task in updated}
|
||||
self.assertEqual("generated", by_title["好标题"].stage)
|
||||
self.assertEqual("success", by_title["好标题"].status)
|
||||
self.assertEqual("collected", by_title["坏标题"].stage)
|
||||
self.assertEqual("failed", by_title["坏标题"].status)
|
||||
self.assertIn("标题生成失败", by_title["坏标题"].last_error)
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_generate_batch_stop_before_scheduling_keeps_tasks_collected(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg = self._config()
|
||||
cfg["db_path"] = os.path.join(temp_dir, "cmshopee.db")
|
||||
cfg["image_dir"] = os.path.join(temp_dir, "images")
|
||||
batch_id, tasks = self._collected_tasks(temp_dir, cfg)
|
||||
|
||||
with mock.patch("app.ai.gen_title") as gen_title, \
|
||||
mock.patch("app.ai.gen_cover") as gen_cover:
|
||||
summary = ai.generate_batch(
|
||||
tasks,
|
||||
{"title": "标题提示", "cover": "封面"},
|
||||
ai_cfg={"config": cfg, "db_path": cfg["db_path"]},
|
||||
should_stop=lambda: True,
|
||||
)
|
||||
|
||||
self.assertFalse(summary["ok"])
|
||||
self.assertTrue(summary["cancelled"])
|
||||
self.assertEqual(0, summary["title_done"])
|
||||
self.assertEqual(0, summary["cover_done"])
|
||||
self.assertEqual(0, summary["failed"])
|
||||
gen_title.assert_not_called()
|
||||
gen_cover.assert_not_called()
|
||||
updated = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||||
self.assertTrue(all(task.stage == "collected" for task in updated))
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -22,6 +22,7 @@ from app.gui import (
|
||||
AccountsTab,
|
||||
CollectWorker,
|
||||
CollectTab,
|
||||
GenerateWorker,
|
||||
GenerateTab,
|
||||
MainWindow,
|
||||
TAB_STYLE,
|
||||
@@ -91,6 +92,10 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
self.assertEqual("标题提示词", tab.title_prompt_edit.placeholderText())
|
||||
self.assertEqual("封面提示词", tab.cover_prompt_edit.placeholderText())
|
||||
self.assertEqual("保存标题提示词", tab.save_title_button.text())
|
||||
self.assertEqual("开始生成", tab.generate_button.text())
|
||||
self.assertEqual("停止", tab.stop_generate_button.text())
|
||||
self.assertFalse(tab.stop_generate_button.isEnabled())
|
||||
self.assertEqual("进度:标题0/0 · 封面0/0 · 失败0", tab.progress_label.text())
|
||||
self.assertEqual("默认", tab.cover_template_combo.currentText())
|
||||
self.assertEqual(["店铺", "商品ID", "旧标题", "新标题", "状态"], tab.model.HEADERS)
|
||||
self.assertEqual("任务 0/0 条", tab.summary_label.text())
|
||||
@@ -161,6 +166,10 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
tab.preview_cover_prompt()
|
||||
self.assertIn("预览 旧标题 新标题 51100639510 主店", info.call_args[0][2])
|
||||
|
||||
with mock.patch("app.gui.QDialog.exec", return_value=0) as exec_dialog:
|
||||
tab.show_task_images(tab.model.index(0, 0))
|
||||
exec_dialog.assert_called_once()
|
||||
|
||||
tab.cover_prompt_edit.moveCursor(QTextCursor.End)
|
||||
tab.insert_title_placeholder()
|
||||
self.assertTrue(tab.cover_prompt_edit.toPlainText().endswith("{新标题}"))
|
||||
@@ -171,6 +180,59 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_generate_worker_calls_generate_batch_and_emits_signals(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg = self.make_config(temp_dir)
|
||||
account = accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||||
db.insert_tasks(
|
||||
batch_id,
|
||||
[
|
||||
{
|
||||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||||
"source_sheet": "商品",
|
||||
"source_row": 2,
|
||||
"account_name": "Excel主店",
|
||||
"alias": "alias-a",
|
||||
"item_id": "51100639510",
|
||||
}
|
||||
],
|
||||
path=cfg["db_path"],
|
||||
)
|
||||
task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
||||
db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"])
|
||||
tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||||
progress = []
|
||||
rows = []
|
||||
|
||||
def fake_generate_batch(tasks_arg, prompt_values, ai_cfg=None, on_progress=None, should_stop=None):
|
||||
self.assertEqual(tasks, tasks_arg)
|
||||
self.assertEqual({"title": "标题提示", "cover": "封面提示"}, prompt_values)
|
||||
self.assertEqual(account, ai_cfg["account_by_alias"]["alias-a"])
|
||||
self.assertEqual(cfg["db_path"], ai_cfg["db_path"])
|
||||
self.assertFalse(should_stop())
|
||||
on_progress({"total": 1, "title_done": 1, "cover_done": 0, "failed": 0})
|
||||
ai_cfg["on_task_update"](tasks[0].id, {"stage": "generated"})
|
||||
return {"ok": True, "total": 1, "title_done": 1, "cover_done": 1, "failed": 0}
|
||||
|
||||
worker = GenerateWorker(
|
||||
tasks,
|
||||
{"title": "标题提示", "cover": "封面提示"},
|
||||
db_path=cfg["db_path"],
|
||||
config=cfg,
|
||||
)
|
||||
worker.progress.connect(progress.append)
|
||||
worker.row_updated.connect(lambda task_id, fields: rows.append((task_id, fields)))
|
||||
|
||||
with mock.patch("app.gui.ai.generate_batch", side_effect=fake_generate_batch):
|
||||
summary = worker.execute()
|
||||
|
||||
self.assertEqual(1, summary["cover_done"])
|
||||
self.assertEqual([{"total": 1, "title_done": 1, "cover_done": 0, "failed": 0}], progress)
|
||||
self.assertEqual([(tasks[0].id, {"stage": "generated"})], rows)
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_generate_tab_lists_tasks_and_filters_by_shop_status_and_batch(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg = self.make_config(temp_dir)
|
||||
|
||||
Reference in New Issue
Block a user