From 7ac0dcd00e48bd3f27021b547465beb0661917bb Mon Sep 17 00:00:00 2001 From: chengma Date: Mon, 6 Jul 2026 11:58:51 +0800 Subject: [PATCH] fix: clarify AI generation failure retry states --- app/ai.py | 24 +++++++++++++++- app/gui/models.py | 20 ++++++++++++++ app/gui/tabs/generate.py | 6 ++-- app/gui/workers.py | 4 +-- docs/current-state.md | 2 +- docs/troubleshooting.md | 45 ++++++++++++++++++++++++++++++ progress.md | 8 +++++- tests/test_ai.py | 60 ++++++++++++++++++++++++++++++++++++++++ tests/test_gui.py | 35 ++++++++++++++++++++++- 9 files changed, 195 insertions(+), 9 deletions(-) diff --git a/app/ai.py b/app/ai.py index c310a9c..8528e7c 100644 --- a/app/ai.py +++ b/app/ai.py @@ -222,6 +222,28 @@ def _notify_retry(callback, step, attempt, attempts, exc, model): except Exception: pass + +def _task_attempt_count(task, field): + try: + return int(getattr(task, field, 0) or 0) + except (TypeError, ValueError): + return 0 + + +def is_generatable_task(task): + """判断任务是否能由② AI生成执行或重试。""" + + stage = str(getattr(task, "stage", "") or "") + status = str(getattr(task, "status", "") or "") + if status in {"running", "skipped"}: + return False + if stage == "collected": + return True + if stage == "generated" and status == "failed": + return _task_attempt_count(task, "apply_attempts") == 0 + return False + + def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=None): """Generate titles first, then covers, and persist each successful task.""" @@ -256,7 +278,7 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No should_stop = should_stop or (lambda: False) eligible = [ task for task in list(tasks) - if getattr(task, "stage", None) == "collected" + if is_generatable_task(task) ] summary = { "ok": True, diff --git a/app/gui/models.py b/app/gui/models.py index 2f2b968..ad797a4 100644 --- a/app/gui/models.py +++ b/app/gui/models.py @@ -275,10 +275,30 @@ class GenerateTaskTableModel(QAbstractTableModel): return task.account_name or task.alias or "" def _status_text(self, task): + if task.status == "failed": + return self._failed_status_text(task) if task.status in self.STATUS_TEXT and task.status != "pending": return self.STATUS_TEXT[task.status] return self.STAGE_TEXT.get(task.stage, task.stage) + def _failed_status_text(self, task): + collect_attempts = self._attempt_count(task, "collect_attempts") + generate_attempts = self._attempt_count(task, "generate_attempts") + apply_attempts = self._attempt_count(task, "apply_attempts") + if apply_attempts > 0: + return "更新失败" + if generate_attempts > 0: + return "生成失败" + if collect_attempts > 0 or getattr(task, "stage", None) == "imported": + return "采集失败" + return "失败" + + def _attempt_count(self, task, field): + try: + return int(getattr(task, field, 0) or 0) + except (TypeError, ValueError): + return 0 + def _status_color(self, task): base_color = _status_base_color(getattr(task, "status", None)) if base_color is not None: diff --git a/app/gui/tabs/generate.py b/app/gui/tabs/generate.py index 9cb84a2..1164cb2 100644 --- a/app/gui/tabs/generate.py +++ b/app/gui/tabs/generate.py @@ -470,10 +470,10 @@ class GenerateTab(QWidget): generate_cover = bool(self.generate_cover_checkbox.isChecked()) tasks = [ task for task in self.model.tasks - if getattr(task, "stage", None) == "collected" + if ai.is_generatable_task(task) ] if not tasks: - self._set_status("当前筛选结果没有可生成任务") + self._set_status("当前筛选结果没有待生成或生成失败可重试任务;请先在①导入采集完成旧数据采集") return prompt_values = { "title": self.title_prompt_edit.toPlainText(), @@ -896,7 +896,7 @@ class GenerateTab(QWidget): if selected_status in (None, "all"): return True if selected_status == "to_generate": - return task.stage == "collected" and task.status in {"success", "pending"} + return ai.is_generatable_task(task) and task.status != "failed" if selected_status == "generated": return task.stage == "generated" if selected_status == "applied": diff --git a/app/gui/workers.py b/app/gui/workers.py index 96cb2a9..78c866f 100644 --- a/app/gui/workers.py +++ b/app/gui/workers.py @@ -4,7 +4,7 @@ from __future__ import annotations from .widgets import * class GenerateWorker(BaseWorker): - """Generate titles and covers for collected tasks.""" + """Generate titles and covers for eligible collected or failed generation tasks.""" def __init__( self, @@ -39,7 +39,7 @@ class GenerateWorker(BaseWorker): self._account_by_alias = account_by_alias eligible = [ task for task in self.tasks - if getattr(task, "stage", None) == "collected" + if ai.is_generatable_task(task) ] self._eligible_total = len(eligible) self._task_positions = { diff --git a/docs/current-state.md b/docs/current-state.md index 85308bb..4334c64 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -90,7 +90,7 @@ - T-517 已完成:⑤设置页将「Shopee 更新安全 / 执行模式」前置为独立分区,将「基础设施(路径与端口)」后置;`test_item_id`、`dry_run` 不再有用户可操作控件,保存设置仍保留 `test_item_id` 兼容字段并固定 `dry_run=false`,③「检查本轮更新」语义不变。 - T-518 已完成:②AI生成左栏保留模板选择、编辑内容、新建/保存等高频入口,把封面模板「另存为 / 重命名 / 删除」收敛进②本页「模板操作」菜单;动作仍调用原有保存/重命名/删除方法,删除二次确认、错误处理和 prompts 文件结构不变,不涉及 AI/Shopee/CDP。 - T-519 已完成:②AI生成底部增加标题/图片两条独立进度条,现有「AI生成运行日志」改为用户可读、自动滚动、脱敏的长任务日志;日志显示本轮开始、标题/图片开始与成功、商品ID/店铺、调用失败重试、失败、停止请求和完成汇总,不显示 API Key、密码、Cookie、token、完整请求体、base64 图片或超长 prompt,不改变 AI/DB/Excel/Shopee/CDP 核心流程。 -- T-520 已完成:②AI生成页增加「生成封面图片(成本较高)」开关,默认关闭以避免无意产生图片模型成本;关闭时标题生成成功即可写库进入 generated,`new_cover_path=NULL`,③只更新标题且不受「允许更新封面」阻断;开启时保持标题后图片两段生成流程。未改 DB schema、AI HTTP 协议、Excel、Shopee/CDP 更新流程。 +- T-520 已完成:②AI生成页增加「生成封面图片(成本较高)」开关,默认关闭以避免无意产生图片模型成本;关闭时标题生成成功即可写库进入 generated,`new_cover_path=NULL`,③只更新标题且不受「允许更新封面」阻断;开启时保持标题后图片两段生成流程。2026-07-06 运行修正:②AI生成页会把失败按阶段显示为“采集失败 / 生成失败 / 更新失败”;「开始生成」只处理已采集待生成或生成失败可重试任务,采集失败会提示先回到①完成旧数据采集,更新失败不会被②误重试。未改 DB schema、AI HTTP 协议、Excel、Shopee/CDP 更新流程。 - T-528 已完成:②AI生成页在 cmhub backend 下显示生成后的剩余点数;GenerateWorker 读取计费 metadata,把 points_cost、points_balance、call_id 写入脱敏运行日志;遇到 CMHubError.code=insufficient_points 时弹出「点数不足,请先充值」并中止本轮未开始任务,不靠中文错误字符串匹配。未改 AI HTTP 协议、DB schema、Excel、Shopee/CDP 流程。 - T-529 已完成:普通默认 `ai.backend=cmhub`;⑤设置页不再显示「AI 后端」label/dropdown、direct 模型选择、模型详情或标题/图片模型角色下拉,直接展示 cmhub 网关配置;保存设置固定写 `ai.backend=cmhub` 且允许先保存不完整 cmhub 配置,②生成时仍由 `app/ai.py` 提示补齐 Base URL/API Key/别名。direct 代码和 `config/ai_models.json` 保留为内部兼容/手工回滚,不改 AI HTTP 协议、DB schema、Excel、Shopee/CDP 流程。 - T-530 已完成:`appconfig.normalize_cmhub_base_url()` / `cmhub_request_url()` 会把 cmhub Base URL 规整到 scheme+host(+port),去掉 `/api`、`/api/v1`、其它路径、查询串和片段;⑤设置页输入框提示只填网关根,保存/刷新前同步规整;cmhub HTTP 404 统一映射为 `CMHubError(code="not_found")`,显示“cmhub 接口不存在,请检查 Base URL 或该实例是否已部署 /api/v1/models”。未改 AI HTTP 协议、DB schema、Excel、Shopee/CDP 流程。 diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index c74fc2c..1c38a43 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -164,6 +164,51 @@ curl -i "https://<你的BaseURL>/api/v1/balance" -H "Authorization: Bearer sk_cm T-530 已实现:保存和请求前都会把 Base URL 规整为网关根,去掉多余路径、查询串和片段;HTTP 404 会映射为 `not_found`,显示“cmhub 接口不存在,请检查 Base URL 或该实例是否已部署 /api/v1/models”,不再透传 `notfound` 这类生涩原文。 +## ②AI生成提示“当前筛选结果没有可生成任务” + +### 现象 + +在②AI生成模块选中某个批次,列表里看到多条“失败”记录,点击「开始生成」后,状态栏提示: + +```text +当前筛选结果没有待生成或生成失败可重试任务;请先在①导入采集完成旧数据采集 +``` + +旧版本可能提示: + +```text +当前筛选结果没有可生成任务 +``` + +### 原因 + +②AI生成只能处理两类任务: + +- 已完成①采集的待生成任务:底层通常是 `stage=collected`。 +- AI 生成阶段失败、可以重试的任务:例如生成标题或生成封面失败。 + +如果列表里的“失败”其实是①采集失败,任务还停在 `stage=imported/status=failed`,没有旧标题和旧封面,②无法直接生成。2026-07-06 的实测问题就是这种情况:本地最新批次 5 条任务都是 `stage=imported/status=failed/collect_attempts>0/generate_attempts=0`,属于采集失败,不是 AI 生成失败。 + +如果失败来自③更新shopee,任务通常已有新标题/新封面,但这是更新阶段失败,也不应回到②自动重新生成。 + +### 当前处理 + +当前代码已把②AI生成列表的失败状态细分为: + +- `采集失败`:需要先回①重新采集旧标题/旧封面。 +- `生成失败`:可以在②当前筛选范围内再次点击「开始生成」重试。 +- `更新失败`:应去③更新shopee重试或重置更新状态,不会被②误当成生成任务。 + +②「开始生成」使用统一的 `ai.is_generatable_task()` 判断可生成范围,GUI、Worker 和底层 `generate_batch()` 口径一致。 + +### 推荐处理 + +1. 如果②状态列显示“采集失败”:回到①导入采集,确认对应账号 Chrome 已启动且已登录,再重新采集旧标题/旧封面。 +2. 如果②状态列显示“生成失败”:留在②,确认 cmhub Base URL、API Key、别名和点数正常后,直接点击「开始生成」重试当前筛选结果。 +3. 如果②状态列显示“更新失败”:到③更新shopee处理;需要重复更新时使用③的重置更新状态入口,不要在②重新生成。 + +排查时不要把 `cmshopee.db`、`config/cmhub.json`、`config/ai_models.json` 或任何 API Key、密码、Cookie 发到聊天、文档或提交里。 + ## AI生成:标题成功但图片生成失败,且看不到原因 当前代码会为 ② AI生成写两层日志: diff --git a/progress.md b/progress.md index a59902c..7b972e1 100644 --- a/progress.md +++ b/progress.md @@ -1175,4 +1175,10 @@ - 边界:URL、配置键、API 字段、模型别名、第三方/Shopee 原始错误可保留原文,但外围必须给中文解释;不得直接把英文技术报错裸露给用户。 - 文档:同步 `AGENTS.md`、`docs/05-coding-rules.md`、`docs/current-state.md`。 - 代码:同步修正 T-531 设置未保存离开确认框,改用自定义中文按钮“保存 / 放弃 / 取消”,避免系统默认按钮显示英文。 -- 验证:`python -m py_compile app\gui\main_window.py tests\test_gui.py` 通过;`python -m unittest discover -s tests -p test_gui.py` 通过(86 tests);`python -m compileall app main.py` 通过;`python -m unittest discover -s tests` 通过(195 tests);`git diff --check` 通过,仅有本机 LF/CRLF 提示。 \ No newline at end of file +- 验证:`python -m py_compile app\gui\main_window.py tests\test_gui.py` 通过;`python -m unittest discover -s tests -p test_gui.py` 通过(86 tests);`python -m compileall app main.py` 通过;`python -m unittest discover -s tests` 通过(195 tests);`git diff --check` 通过,仅有本机 LF/CRLF 提示。 +## 【2026-07-06】运行修正 · ②AI生成失败状态分阶段提示 + +- 现象:在②AI生成选择一个批次,列表都是“失败”状态时点击「开始生成」,状态栏提示“当前筛选结果没有可生成任务”。 +- 排查:本地最新未删除批次 `20260702_171000_75bd2557` 的 5 条任务均为 `stage=imported/status=failed/collect_attempts=3/generate_attempts=0/apply_attempts=0`,属于①采集失败,不是 AI 生成失败;②没有旧标题/旧封面,不能直接生成。 +- 修正:新增 `ai.is_generatable_task()` 统一 GUI/Worker/底层批处理口径;②状态列把失败细分显示为“采集失败 / 生成失败 / 更新失败”;「开始生成」无可重试任务时提示先到①完成旧数据采集。AI 生成失败可重试,③更新失败不会被②误重试。文档补充 `docs/troubleshooting.md` 的同名排查条目。 +- 测试:新增 `test_generate_batch_retries_failed_generation_record_after_existing_result`、`test_generate_batch_does_not_retry_apply_failed_records`、`test_generate_tab_explains_collect_failed_records_are_not_generatable`。 diff --git a/tests/test_ai.py b/tests/test_ai.py index f45d8ac..013f994 100644 --- a/tests/test_ai.py +++ b/tests/test_ai.py @@ -757,6 +757,66 @@ class AITests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_generate_batch_retries_failed_generation_record_after_existing_result(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] + + with mock.patch("app.ai.gen_title", return_value="重新生成标题") as gen_title, \ + mock.patch("app.ai.gen_cover") as gen_cover: + summary = ai.generate_batch( + [retry_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() + 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) + + def test_generate_batch_does_not_retry_apply_failed_records(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, ["旧标题"]) + db.set_generated(tasks[0].id, "新标题", "new.jpg", path=cfg["db_path"]) + db.mark_failed(tasks[0].id, "apply", "更新失败", path=cfg["db_path"]) + update_failed_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( + [update_failed_task], + {"title": "标题提示", "cover": "封面"}, + ai_cfg={"config": cfg, "db_path": cfg["db_path"]}, + ) + + self.assertTrue(summary["ok"]) + self.assertEqual(0, summary["total"]) + gen_title.assert_not_called() + gen_cover.assert_not_called() + unchanged = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0] + self.assertEqual("generated", unchanged.stage) + self.assertEqual("failed", unchanged.status) + self.assertEqual(1, unchanged.apply_attempts) + + 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() diff --git a/tests/test_gui.py b/tests/test_gui.py index 808944c..7d3d3bb 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -1934,6 +1934,39 @@ class GuiTests(TempDirMixin, unittest.TestCase): 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) + 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.mark_failed(task.id, "collect", "采集失败", path=cfg["db_path"]) + statuses = [] + tab = GenerateTab(config=cfg, status_callback=statuses.append) + self.addCleanup(tab.close) + + self.assertEqual("采集失败", tab.model.index(0, 4).data()) + tab.start_generate() + + self.assertIsNone(tab.generate_worker) + self.assertIn("没有待生成或生成失败可重试任务", statuses[-1]) + self.assertIn("①导入采集", statuses[-1]) + + self.assert_removed(temp_dir) def test_apply_tab_does_not_auto_mix_latest_apply_run_log(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) @@ -3028,7 +3061,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): tab.status_filter.setCurrentIndex(tab.status_filter.findData("all")) tab.batch_filter.setCurrentIndex(tab.batch_filter.findData(batch_b)) self.assertEqual(1, tab.model.rowCount()) - self.assertEqual("失败", tab.model.index(0, 4).data()) + self.assertEqual("生成失败", tab.model.index(0, 4).data()) self.assert_foreground(tab.model, 0, 4, gui.COLOR_DANGER) self.assertEqual( "生成失败",