diff --git a/app/ai.py b/app/ai.py index 49b3651..a26808a 100644 --- a/app/ai.py +++ b/app/ai.py @@ -553,7 +553,7 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No summary["failed"] += 1 summary["ok"] = False step = get_step(task, "title_request") - error = _mark_generate_failed(task, exc, db_path, on_task_update) + error = _mark_generate_failed(task, exc, db_path, on_task_update, step=step) _emit_generation_event(on_event, task, "title", step, "failed", detail=error, level="error") _emit_generation_error(on_error, task, "title", step, exc, error) _emit_generation_progress(on_progress, summary) @@ -590,7 +590,7 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No summary["failed"] += 1 summary["ok"] = False step = get_step(task, fallback_step) - error = _mark_generate_failed(task, exc, db_path, on_task_update) + error = _mark_generate_failed(task, exc, db_path, on_task_update, step=step) _emit_generation_event(on_event, task, "cover", step, "failed", detail=error, level="error") _emit_generation_error(on_error, task, "cover", step, exc, error) @@ -1654,9 +1654,10 @@ def _new_cover_path(task, account_by_alias, image_root): return image_paths.task_image_path(image_root, task, account, "new") -def _mark_generate_failed(task, exc, db_path, on_task_update): +def _mark_generate_failed(task, exc, db_path, on_task_update, step=None): error = diagnostics.redact_log_text(str(exc) or exc.__class__.__name__) - db.mark_failed(task.id, "generate", error, path=db_path) + error = db.format_failure_error(error, step) + db.mark_failed(task.id, "generate", error, path=db_path, step=step) if on_task_update is not None: on_task_update(task.id, {"status": "failed", "last_error": error}) return error diff --git a/app/db.py b/app/db.py index 7c10d50..099ea8b 100644 --- a/app/db.py +++ b/app/db.py @@ -23,6 +23,37 @@ VALID_BATCH_FIELDS = { "deleted_at", "deleted_reason", } +FAILURE_STEP_LABELS = { + "preflight": "预检", + "check_chrome": "检查Chrome", + "login_check": "检测登录", + "open_product": "打开商品页", + "wait_ready": "页面就绪", + "read_title": "读标题", + "read_cover": "读封面", + "download_cover": "下载封面", + "db_write": "写库", + "excel_write_back": "回写Excel", + "write_excel": "回写Excel", + "load_text_model": "加载文本模型", + "title_submit": "提交标题任务", + "title_build_request": "构建标题请求", + "title_request": "请求生成标题", + "title_parse_response": "解析标题响应", + "load_image_model": "加载图片模型", + "cover_validate_input": "校验旧封面", + "cover_prompt_render": "渲染封面提示词", + "cover_submit": "提交封面任务", + "cover_build_request": "构建封面请求", + "cover_request": "请求生成封面", + "cover_download": "下载新封面", + "cover_save": "保存新封面", + "cover_parse_response": "解析封面响应", + "apply_task": "更新商品", + "change_title": "修改标题", + "replace_cover": "更新封面", + "click_update": "点击更新", +} VALID_ACCOUNT_FIELDS = { "account_name", "alias", @@ -607,8 +638,28 @@ def mark_running(task_id, phase, path=None, conn=None) -> None: ) -def mark_failed(task_id, phase, error, path=None, conn=None) -> None: +def failure_step_label(step): + value = str(step or "").strip() + return FAILURE_STEP_LABELS.get(value, value) + + +def format_failure_error(error, step=None): + message = str(error or "") + label = failure_step_label(step) + if not label: + return message + prefix = f"{label}失败:" + if message.startswith(prefix): + return message + for known_label in FAILURE_STEP_LABELS.values(): + if message.startswith(f"{known_label}失败:") or message.startswith(f"{known_label}失败:"): + return message + return prefix + message + + +def mark_failed(task_id, phase, error, path=None, conn=None, step=None) -> None: attempt_field = _attempt_field(phase) + formatted_error = format_failure_error(error, step) with _connection(conn, path) as database: with database: database.execute( @@ -620,7 +671,7 @@ def mark_failed(task_id, phase, error, path=None, conn=None) -> None: updated_at = ? WHERE id = ? """, - (str(error), _now(), int(task_id)), + (formatted_error, _now(), int(task_id)), ) @@ -708,7 +759,7 @@ def update_generated_title(task_id, new_title, path=None, conn=None) -> None: (title, now, int(task_id)), ) -def set_applied(task_id, committed, error=None, path=None, conn=None) -> None: +def set_applied(task_id, committed, error=None, path=None, conn=None, step=None) -> None: now = _now() success = bool(committed) and error is None with _connection(conn, path) as database: @@ -729,6 +780,7 @@ def set_applied(task_id, committed, error=None, path=None, conn=None) -> None: (now, now, int(task_id)), ) else: + formatted_error = format_failure_error(error or "未提交更新", step) database.execute( """ UPDATE tasks @@ -739,7 +791,7 @@ def set_applied(task_id, committed, error=None, path=None, conn=None) -> None: updated_at = ? WHERE id = ? """, - (str(error or "未提交更新"), now, int(task_id)), + (formatted_error, now, int(task_id)), ) diff --git a/app/gui/workers.py b/app/gui/workers.py index 8ef6f9c..b2c564c 100644 --- a/app/gui/workers.py +++ b/app/gui/workers.py @@ -792,6 +792,7 @@ class ApplyWorker(BaseWorker): ) db.mark_running(task.id, "apply", path=self.db_path) self.row_updated.emit(task.id, {"status": "running", "last_error": None}) + current_step = "apply_task" result = editor.apply_task( account, task, @@ -800,6 +801,7 @@ class ApplyWorker(BaseWorker): ) committed = bool(result.get("committed")) and not result.get("error") error = result.get("error") + failed_step = self._failed_apply_step(result, current_step) current_step = "db_write" self._log_run_event( f"step=db_write result=start detail=任务 {task.id} 商品 {task.item_id} 保存更新结果", @@ -824,13 +826,13 @@ class ApplyWorker(BaseWorker): return "applied" error = diagnostics.redact_log_text(error or "更新未提交") - failed_step = self._failed_apply_step(result, current_step) - db.set_applied(task.id, False, error, path=self.db_path) + display_error = db.format_failure_error(error, failed_step) + db.set_applied(task.id, False, error, path=self.db_path, step=failed_step) elapsed_ms = self._elapsed_ms(started) - self.failed.emit(task.id, str(error)) + self.failed.emit(task.id, str(display_error)) self.row_updated.emit( task.id, - {"status": "failed", "last_error": str(error), "committed": 0}, + {"status": "failed", "last_error": str(display_error), "committed": 0}, ) self._log_run_event( f"step={failed_step} result=failed detail={error} elapsed_ms={elapsed_ms}", @@ -852,12 +854,13 @@ class ApplyWorker(BaseWorker): return "failed" except Exception as exc: error = diagnostics.redact_log_text(str(exc) or exc.__class__.__name__) - db.set_applied(task.id, False, error, path=self.db_path) + display_error = db.format_failure_error(error, current_step) + db.set_applied(task.id, False, error, path=self.db_path, step=current_step) elapsed_ms = self._elapsed_ms(started) - self.failed.emit(task.id, error) + self.failed.emit(task.id, display_error) self.row_updated.emit( task.id, - {"status": "failed", "last_error": error, "committed": 0}, + {"status": "failed", "last_error": display_error, "committed": 0}, ) self._log_run_event( f"step={current_step} result=failed detail={error} elapsed_ms={elapsed_ms}", @@ -1259,10 +1262,11 @@ class CollectWorker(BaseWorker): failed += 1 error = str(exc) or exc.__class__.__name__ safe_error = diagnostics.redact_log_text(error) + display_error = db.format_failure_error(safe_error, current_step) elapsed_ms = self._elapsed_ms(started) - db.mark_failed(task.id, "collect", safe_error, path=self.db_path) - self.failed.emit(task.id, safe_error) - self.row_updated.emit(task.id, {"status": "failed", "last_error": safe_error}) + db.mark_failed(task.id, "collect", safe_error, path=self.db_path, step=current_step) + self.failed.emit(task.id, display_error) + self.row_updated.emit(task.id, {"status": "failed", "last_error": display_error}) self._log_run_event( "step={step} result=failed detail={error} elapsed_ms={elapsed_ms}".format( step=current_step, diff --git a/docs/tasks-board.md b/docs/tasks-board.md index 1707226..683c21b 100644 --- a/docs/tasks-board.md +++ b/docs/tasks-board.md @@ -5,8 +5,8 @@ ## 汇总 -- 总任务:2 -- 状态统计:BLOCKED 0 · DOING 0 · DONE 2 · TODO 0 +- 总任务:3 +- 状态统计:BLOCKED 0 · DOING 0 · DONE 3 · TODO 0 - 下一个可领取:暂无 ## Phase 7 @@ -14,6 +14,7 @@ | ID | 任务 | 依赖 | 状态 | | --- | --- | --- | --- | | T-550 | ①②③ 批次下拉默认选中最新批次 | T-523, T-206 | DONE | +| T-552 | 任务失败原因带上失败步骤(①②③ 阶段/状态列可见) | T-207, T-523 | DONE | ## Phase 8 diff --git a/docs/tasks/T-552.md b/docs/tasks/T-552.md index 45af5dd..668bc03 100644 --- a/docs/tasks/T-552.md +++ b/docs/tasks/T-552.md @@ -3,7 +3,7 @@ id: T-552 title: 任务失败原因带上失败步骤(①②③ 阶段/状态列可见) phase: 7 deps: [T-207, T-523] -status: TODO +status: DONE created: 2026-07-08 --- @@ -43,4 +43,9 @@ created: 2026-07-08 ## 执行记录 -(做完在此记录:改了哪些文件、跑的验证命令与结果、决策) +- 2026-07-08:完成 T-552。 +- 代码:`app/db.py` 新增 `FAILURE_STEP_LABELS`、`failure_step_label()`、`format_failure_error()`;`mark_failed()` 与 `set_applied()` 增加可选 `step` 参数,带 step 时把 `last_error` 统一保存为「<中文步骤>失败:<原因>」,无 step 时保持旧行为,已带步骤前缀时不重复添加。 +- 代码:①`CollectWorker` 采集失败时把当前 step 传给 `mark_failed`,并立即向 GUI 发出带步骤前缀的 `last_error`;②`app/ai.py` 的生成失败写库传入当前 title/cover step;③`ApplyWorker` 更新失败和异常失败传入当前 step 给 `set_applied`,默认归因为 `apply_task`,真实编辑器步骤仍由 `on_step` 覆盖。 +- 展示:①②③ 表格模型原本 tooltip 已读取 `last_error`,因此失败行悬停可直接看到失败步骤与原因;未新增列、不改 DB schema、不改 run_log_events。 +- 测试:`tests/test_db.py` 覆盖 `mark_failed` 带 step 前缀、未知 step 兜底、重复前缀不叠加、无 step 保持原样,以及 `set_applied` 更新失败 step 前缀;`tests/test_gui.py` 覆盖①②③失败行 tooltip 显示步骤与原因,并更新③ worker 失败原因断言。 +- 验证:`py -3.10 -m unittest discover -s tests -p "test_db.py"` 通过(9 tests);`py -3.10 -m unittest discover -s tests -p "test_gui.py"` 通过(104 tests);`py -3.10 -m unittest discover -s tests -p "test_ai.py"` 通过(40 tests);`python -m ruff check app tests main.py` 通过;`python -m ruff check scripts\gen_task_board.py tests\test_task_board.py` 通过;`py -3.10 -m compileall app main.py` 通过;`py -3.10 -m py_compile scripts/gen_task_board.py` 通过;`git diff --check` 通过;`py -3.10 -m unittest discover -s tests` 通过(257 tests)。 diff --git a/tests/test_db.py b/tests/test_db.py index 04d258d..484d546 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -88,6 +88,7 @@ class DbTests(TempDirMixin, unittest.TestCase): self.assertEqual("imported", failed.stage) self.assertEqual("failed", failed.status) self.assertEqual(1, failed.collect_attempts) + self.assertEqual("采集失败", failed.last_error) db.set_collected(task.id, "旧标题", "old.jpg", path=db_path) collected = db.list_tasks(path=db_path)[0] @@ -125,6 +126,75 @@ class DbTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_mark_failed_prepends_chinese_step_without_duplicate_prefix(self): + with self.make_temp_dir() as temp_dir: + db_path = os.path.join(temp_dir, "cmshopee.db") + db.init_db(db_path) + batch_id = db.create_batch(["input.xlsx"], path=db_path) + db.insert_tasks( + batch_id, + [ + { + "source_file_abs": os.path.join(temp_dir, "input.xlsx"), + "source_sheet": "Sheet1", + "source_row": 2, + "account_name": "shop", + "alias": "alias", + "item_id": "51100639510", + } + ], + path=db_path, + ) + task = db.list_tasks(batch_id=batch_id, path=db_path)[0] + + db.mark_failed(task.id, "collect", "图片超过大小上限", path=db_path, step="read_cover") + failed = db.get_task(task.id, path=db_path) + self.assertEqual("读封面失败:图片超过大小上限", failed.last_error) + + db.mark_failed(task.id, "collect", "读封面失败:图片超过大小上限", path=db_path, step="read_cover") + failed = db.get_task(task.id, path=db_path) + self.assertEqual("读封面失败:图片超过大小上限", failed.last_error) + + db.mark_failed(task.id, "collect", "未知错误", path=db_path, step="custom_step") + failed = db.get_task(task.id, path=db_path) + self.assertEqual("custom_step失败:未知错误", failed.last_error) + + db.mark_failed(task.id, "collect", "保持原样", path=db_path) + failed = db.get_task(task.id, path=db_path) + self.assertEqual("保持原样", failed.last_error) + + self.assert_removed(temp_dir) + + def test_set_applied_prepends_failed_step_for_update_errors(self): + with self.make_temp_dir() as temp_dir: + db_path = os.path.join(temp_dir, "cmshopee.db") + db.init_db(db_path) + batch_id = db.create_batch(["input.xlsx"], path=db_path) + db.insert_tasks( + batch_id, + [ + { + "source_file_abs": os.path.join(temp_dir, "input.xlsx"), + "source_sheet": "Sheet1", + "source_row": 2, + "account_name": "shop", + "alias": "alias", + "item_id": "51100639510", + } + ], + path=db_path, + ) + task = db.list_tasks(batch_id=batch_id, path=db_path)[0] + db.set_generated(task.id, "新标题", "new.jpg", path=db_path) + + db.set_applied(task.id, False, "按钮禁用", path=db_path, step="click_update") + + failed = db.get_task(task.id, path=db_path) + self.assertEqual("点击更新失败:按钮禁用", failed.last_error) + self.assertEqual("failed", failed.status) + + self.assert_removed(temp_dir) + def test_reset_generated_and_apply_status_keep_local_history(self): with self.make_temp_dir() as temp_dir: diff --git a/tests/test_gui.py b/tests/test_gui.py index f5c950f..901bf7d 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -163,6 +163,62 @@ class GuiTests(TempDirMixin, unittest.TestCase): model.data(model.index(0, 3), gui.Qt.ToolTipRole), ) + def test_workflow_failed_tooltips_show_failed_step_and_reason(self): + account = SimpleNamespace(alias="papa", account_name="papa 店铺") + collect_task = SimpleNamespace( + alias="papa", + account_name="papa 店铺", + item_id="25120403046", + stage="imported", + status="failed", + last_error="读封面失败:图片超过大小上限", + ) + generate_task = SimpleNamespace( + alias="papa", + account_name="papa 店铺", + item_id="26887160467", + old_title="旧标题", + new_title="", + stage="collected", + status="failed", + last_error="请求生成标题失败:cmhub 上游生成失败", + collect_attempts=1, + generate_attempts=1, + apply_attempts=0, + committed=0, + ) + apply_task = SimpleNamespace( + alias="papa", + account_name="papa 店铺", + item_id="28431952912", + new_title="新标题", + new_cover_path="new.jpg", + stage="generated", + status="failed", + last_error="点击更新失败:按钮禁用", + ) + + collect_model = gui.TaskTableModel() + collect_model.set_tasks([collect_task], [account]) + self.assertEqual( + "读封面失败:图片超过大小上限", + collect_model.data(collect_model.index(0, 3), gui.Qt.ToolTipRole), + ) + + generate_model = gui.GenerateTaskTableModel() + generate_model.set_tasks([generate_task], [account]) + self.assertEqual( + "请求生成标题失败:cmhub 上游生成失败", + generate_model.data(generate_model.index(0, 4), gui.Qt.ToolTipRole), + ) + + apply_model = gui.ApplyTaskTableModel() + apply_model.set_tasks([apply_task], [account]) + self.assertEqual( + "点击更新失败:按钮禁用", + apply_model.data(apply_model.index(0, 5), gui.Qt.ToolTipRole), + ) + def test_main_window_has_five_tabs_in_workflow_order(self): with self.make_temp_dir() as temp_dir: window = MainWindow(config=self.make_config(temp_dir)) @@ -3343,7 +3399,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assertEqual(1, by_alias["alias-a"].committed) self.assertEqual("generated", by_alias["alias-b"].stage) self.assertEqual("failed", by_alias["alias-b"].status) - self.assertEqual("UPDATE_DISABLED", by_alias["alias-b"].last_error) + self.assertEqual("更新商品失败:UPDATE_DISABLED", by_alias["alias-b"].last_error) self.assertEqual(0, by_alias["alias-b"].committed) self.assertEqual("skipped", by_alias["missing"].status) self.assertEqual("别名未匹配账号", by_alias["missing"].last_error)