feat: complete T-533 incremental generation

This commit is contained in:
chengma
2026-07-06 17:57:39 +08:00
parent be182b91cf
commit 46995b301d
8 changed files with 434 additions and 72 deletions
+129 -14
View File
@@ -757,35 +757,150 @@ class AITests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir)
def test_generate_batch_retries_failed_generation_record_after_existing_result(self):
def test_generate_batch_fills_missing_cover_without_regenerating_title(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"]["generate_cover"] = True
batch_id, tasks = self._collected_tasks(temp_dir, cfg, ["旧标题"])
db.set_generated(tasks[0].id, "手动标题", None, path=cfg["db_path"])
cover_only_task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
progress = []
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") as gen_title, \
mock.patch("app.ai.gen_cover", side_effect=fake_cover) as gen_cover:
summary = ai.generate_batch(
[cover_only_task],
{"title": "标题提示", "cover": "封面 {新标题}"},
ai_cfg={"config": cfg, "db_path": cfg["db_path"]},
on_progress=progress.append,
)
self.assertTrue(summary["ok"])
self.assertEqual(1, summary["total"])
self.assertEqual(0, summary["title_total"])
self.assertEqual(0, summary["title_done"])
self.assertEqual(1, summary["cover_total"])
self.assertEqual(1, summary["cover_done"])
self.assertEqual(1, summary["generated_done"])
self.assertEqual(0, progress[-1]["title_total"])
self.assertEqual(1, progress[-1]["cover_total"])
gen_title.assert_not_called()
gen_cover.assert_called_once()
updated = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
self.assertEqual("generated", updated.stage)
self.assertEqual("success", updated.status)
self.assertEqual("手动标题", updated.new_title)
self.assertTrue(os.path.exists(updated.new_cover_path))
self.assert_removed(temp_dir)
def test_generate_batch_handles_mixed_title_and_cover_gaps(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"]["generate_cover"] = True
batch_id, tasks = self._collected_tasks(temp_dir, cfg, ["旧标题A", "旧标题B"])
db.set_generated(tasks[1].id, "已有标题B", None, path=cfg["db_path"])
mixed_tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
cover_prompts = []
def fake_title(title_prompt, old_title, **kwargs):
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) as gen_title, \
mock.patch("app.ai.gen_cover", side_effect=fake_cover) as gen_cover:
summary = ai.generate_batch(
mixed_tasks,
{"title": "标题提示", "cover": "封面 {新标题}"},
ai_cfg={"config": cfg, "db_path": cfg["db_path"]},
)
self.assertTrue(summary["ok"])
self.assertEqual(2, summary["total"])
self.assertEqual(1, summary["title_total"])
self.assertEqual(1, summary["title_done"])
self.assertEqual(2, summary["cover_total"])
self.assertEqual(2, summary["cover_done"])
self.assertEqual(2, summary["generated_done"])
self.assertEqual(1, gen_title.call_count)
self.assertEqual(2, gen_cover.call_count)
updated = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
by_old_title = {task.old_title: task for task in updated}
self.assertEqual("新旧标题A", by_old_title["旧标题A"].new_title)
self.assertEqual("已有标题B", by_old_title["旧标题B"].new_title)
self.assertTrue(all(os.path.exists(task.new_cover_path) for task in updated))
self.assertIn("新旧标题A", "\n".join(cover_prompts))
self.assertIn("已有标题B", "\n".join(cover_prompts))
self.assert_removed(temp_dir)
def test_generate_batch_ignores_title_only_task_when_cover_disabled(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"]["generate_cover"] = False
batch_id, tasks = self._collected_tasks(temp_dir, cfg, ["旧标题"])
db.set_generated(tasks[0].id, "旧AI标题", "old-new.jpg", path=cfg["db_path"])
db.mark_failed(tasks[0].id, "generate", "上次生成失败", path=cfg["db_path"])
retry_task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
db.set_generated(tasks[0].id, "已有标题", None, path=cfg["db_path"])
title_only_task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
with mock.patch("app.ai.gen_title", return_value="重新生成标题") as gen_title, \
with mock.patch("app.ai.gen_title") as gen_title, \
mock.patch("app.ai.gen_cover") as gen_cover:
summary = ai.generate_batch(
[retry_task],
[title_only_task],
{"title": "标题提示", "cover": "封面"},
ai_cfg={"config": cfg, "db_path": cfg["db_path"]},
)
self.assertTrue(summary["ok"])
self.assertEqual(1, summary["total"])
self.assertEqual(1, summary["generated_done"])
gen_title.assert_called_once()
self.assertEqual(0, summary["total"])
self.assertEqual(0, summary["title_total"])
self.assertEqual(0, summary["cover_total"])
gen_title.assert_not_called()
gen_cover.assert_not_called()
self.assert_removed(temp_dir)
def test_generate_batch_ignores_complete_generated_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"]["generate_cover"] = True
batch_id, tasks = self._collected_tasks(temp_dir, cfg, ["旧标题"])
db.set_generated(tasks[0].id, "已有标题", "new.jpg", path=cfg["db_path"])
complete_task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
with mock.patch("app.ai.gen_title") as gen_title, \
mock.patch("app.ai.gen_cover") as gen_cover:
summary = ai.generate_batch(
[complete_task],
{"title": "标题提示", "cover": "封面"},
ai_cfg={"config": cfg, "db_path": cfg["db_path"]},
)
self.assertTrue(summary["ok"])
self.assertEqual(0, summary["total"])
self.assertEqual(0, summary["title_total"])
self.assertEqual(0, summary["cover_total"])
gen_title.assert_not_called()
gen_cover.assert_not_called()
updated = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
self.assertEqual("generated", updated.stage)
self.assertEqual("success", updated.status)
self.assertEqual("重新生成标题", updated.new_title)
self.assertIsNone(updated.new_cover_path)
self.assert_removed(temp_dir)
+158 -1
View File
@@ -1356,6 +1356,41 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir)
def test_generate_tab_refreshes_to_generate_filter_when_cover_toggle_changes(self):
with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir)
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"])
db.set_generated(task.id, "已有标题", None, path=cfg["db_path"])
tab = GenerateTab(config=cfg)
self.addCleanup(tab.close)
tab.status_filter.setCurrentIndex(tab.status_filter.findData("to_generate"))
self.assertEqual(0, tab.model.rowCount())
tab.generate_cover_checkbox.setChecked(True)
self.assertEqual(1, tab.model.rowCount())
self.assertEqual("51100639510", tab.model.task_at(0).item_id)
self.assert_removed(temp_dir)
def test_generate_tab_manages_prompt_files_and_preview(self):
with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir)
@@ -1927,6 +1962,73 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir)
def test_generate_worker_fills_missing_cover_without_title_call(self):
with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir)
cfg["ai"] = appconfig.ai_config(cfg)
cfg["ai"]["generate_cover"] = True
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"])
db.set_generated(task.id, "手动标题", None, path=cfg["db_path"])
tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
logs = []
progress = []
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
worker = GenerateWorker(
tasks,
{"title": "标题提示", "cover": "封面 {新标题}"},
db_path=cfg["db_path"],
config=cfg,
)
worker.log.connect(logs.append)
worker.progress.connect(progress.append)
with mock.patch("app.ai.gen_title") as gen_title, \
mock.patch("app.ai.gen_cover", side_effect=fake_cover) as gen_cover:
summary = worker.execute()
self.assertTrue(summary["ok"])
self.assertEqual(1, summary["total"])
self.assertEqual(0, summary["title_total"])
self.assertEqual(1, summary["cover_total"])
self.assertEqual(0, summary["title_done"])
self.assertEqual(1, summary["cover_done"])
self.assertEqual(1, summary["generated_done"])
gen_title.assert_not_called()
gen_cover.assert_called_once()
updated = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
self.assertEqual("手动标题", updated.new_title)
self.assertTrue(os.path.exists(updated.new_cover_path))
self.assertEqual(0, progress[-1]["title_total"])
self.assertEqual(1, progress[-1]["cover_total"])
joined_logs = "\n".join(logs)
self.assertIn("[开始] 本轮生成 1 条:标题0,图片1", joined_logs)
self.assertIn("已有标题,跳过生文", joined_logs)
self.assertIn("[完成] AI 生成完成:标题0/0,图片1/1,失败0", joined_logs)
self.assert_removed(temp_dir)
def test_generate_tab_does_not_auto_mix_latest_generate_run_log(self):
with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir)
@@ -2009,6 +2111,61 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir)
def test_generate_tab_starts_cover_only_run_for_missing_cover(self):
with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir)
cfg["ai"] = appconfig.ai_config(cfg)
cfg["ai"]["generate_cover"] = True
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"])
db.set_generated(task.id, "手动标题", None, path=cfg["db_path"])
tab = GenerateTab(config=cfg)
self.addCleanup(tab.close)
class FakeSignal:
def __init__(self):
self.callbacks = []
def connect(self, callback):
self.callbacks.append(callback)
class FakeThread:
def __init__(self):
self.finished = FakeSignal()
self.started = False
def start(self):
self.started = True
fake_thread = FakeThread()
with mock.patch("app.gui.run_worker", return_value=fake_thread):
tab.start_generate()
self.assertTrue(fake_thread.started)
self.assertEqual("标题 0/0", tab.title_progress_label.text())
self.assertEqual("图片 0/1", tab.cover_progress_label.text())
text = tab.run_log_view.toPlainText()
self.assertIn("本轮AI生成开始:任务 1 条", text)
self.assertIn("生成封面:是", text)
self.assert_removed(temp_dir)
def test_generate_tab_explains_collect_failed_records_are_not_generatable(self):
with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir)
@@ -2038,7 +2195,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
tab.start_generate()
self.assertIsNone(tab.generate_worker)
self.assertIn("没有待生成或生成失败可重试任务", statuses[-1])
self.assertIn("没有可生成的缺失内容", statuses[-1])
self.assertIn("①导入采集", statuses[-1])
self.assert_removed(temp_dir)