fix: clarify AI generation failure retry states

This commit is contained in:
chengma
2026-07-06 11:58:51 +08:00
parent 19eaa794cf
commit 7ac0dcd00e
9 changed files with 195 additions and 9 deletions
+23 -1
View File
@@ -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,
+20
View File
@@ -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:
+3 -3
View File
@@ -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":
+2 -2
View File
@@ -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 = {
+1 -1
View File
@@ -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 流程。
+45
View File
@@ -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生成写两层日志:
+6
View File
@@ -1176,3 +1176,9 @@
- 文档:同步 `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 提示。
## 【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`。
+60
View File
@@ -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()
+34 -1
View File
@@ -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(
"生成失败",