From 46995b301d8a5dc6dce9d216aeecbd693196226c Mon Sep 17 00:00:00 2001 From: chengma Date: Mon, 6 Jul 2026 17:57:39 +0800 Subject: [PATCH] feat: complete T-533 incremental generation --- app/ai.py | 128 ++++++++++++++++++++++--------- app/gui/tabs/generate.py | 29 ++++--- app/gui/workers.py | 26 +++++-- docs/06-tasks.md | 2 +- docs/current-state.md | 8 +- progress.md | 11 ++- tests/test_ai.py | 143 +++++++++++++++++++++++++++++++---- tests/test_gui.py | 159 ++++++++++++++++++++++++++++++++++++++- 8 files changed, 434 insertions(+), 72 deletions(-) diff --git a/app/ai.py b/app/ai.py index 8528e7c..d078b93 100644 --- a/app/ai.py +++ b/app/ai.py @@ -230,18 +230,57 @@ def _task_attempt_count(task, field): return 0 -def is_generatable_task(task): - """判断任务是否能由② AI生成执行或重试。""" +def _has_generated_title(task): + return bool(str(getattr(task, "new_title", "") or "").strip()) + + +def _has_generated_cover(task): + return bool(str(getattr(task, "new_cover_path", "") or "").strip()) + + +def generation_needs(task, generate_cover=False): + """Return which AI components still need generation for this task.""" 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 + return {"title": False, "cover": False} + if stage not in {"collected", "generated"}: + return {"title": False, "cover": False} + if stage == "generated" and _task_attempt_count(task, "apply_attempts") > 0: + return {"title": False, "cover": False} + needs_title = not _has_generated_title(task) + needs_cover = bool(generate_cover) and not _has_generated_cover(task) + return {"title": needs_title, "cover": needs_cover} + + +def is_generatable_task(task, generate_cover=False): + """判断任务是否能由② AI生成执行或重试。""" + + needs = generation_needs(task, generate_cover=generate_cover) + return bool(needs["title"] or needs["cover"]) + + +def generation_component_totals(tasks, generate_cover=False): + """Count task and component gaps for a generation run.""" + + eligible = [ + task for task in list(tasks) + if is_generatable_task(task, generate_cover=generate_cover) + ] + title_total = 0 + cover_total = 0 + for task in eligible: + needs = generation_needs(task, generate_cover=generate_cover) + if needs["title"]: + title_total += 1 + if needs["cover"]: + cover_total += 1 + return { + "total": len(eligible), + "title_total": title_total, + "cover_total": cover_total, + } def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=None): @@ -278,14 +317,27 @@ 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 is_generatable_task(task) + if is_generatable_task(task, generate_cover=generate_cover) + ] + needs_by_task = { + getattr(task, "id", None): generation_needs(task, generate_cover=generate_cover) + for task in eligible + } + title_tasks = [ + task for task in eligible + if needs_by_task.get(getattr(task, "id", None), {}).get("title") + ] + cover_candidates = [ + task for task in eligible + if needs_by_task.get(getattr(task, "id", None), {}).get("cover") ] summary = { "ok": True, "total": len(eligible), + "title_total": len(title_tasks), "title_done": 0, "cover_done": 0, - "cover_total": len(eligible) if generate_cover else 0, + "cover_total": len(cover_candidates) if generate_cover else 0, "generated_done": 0, "failed": 0, "cancelled": False, @@ -331,7 +383,7 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No max_workers=max(1, int(generation_cfg.get("title_concurrency", 1))) ) as executor: futures = {} - for task in eligible: + for task in title_tasks: if should_stop(): summary["cancelled"] = True break @@ -361,29 +413,31 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No summary["title_done"] += 1 set_step(task, "title_done") _emit_generation_event(on_event, task, "title", "title_done", "success") - if not generate_cover: - set_step(task, "db_write") - _emit_generation_event(on_event, task, "title", "db_write", "start") - db.set_generated(task.id, new_title, None, path=db_path) + set_step(task, "db_write") + _emit_generation_event(on_event, task, "title", "db_write", "start") + existing_cover_path = getattr(task, "new_cover_path", None) + db.set_generated(task.id, new_title, existing_cover_path, path=db_path) + needs_cover = needs_by_task.get(getattr(task, "id", None), {}).get("cover") + if not needs_cover: summary["generated_done"] += 1 - if on_task_update is not None: - on_task_update( - task.id, - { - "stage": "generated", - "status": "success", - "new_title": new_title, - "new_cover_path": None, - }, - ) - _emit_generation_event( - on_event, - task, - "title", - "db_write", - "success", - detail="仅生成标题", + if on_task_update is not None: + on_task_update( + task.id, + { + "stage": "generated", + "status": "success", + "new_title": new_title, + "new_cover_path": existing_cover_path, + }, ) + _emit_generation_event( + on_event, + task, + "title", + "db_write", + "success", + detail="标题已保存,等待封面" if needs_cover else "仅生成标题", + ) except CancelledError: summary["cancelled"] = True _emit_generation_event(on_event, task, "title", get_step(task, "title_request"), "cancelled", level="warning") @@ -401,9 +455,15 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No summary["ok"] = False return summary + for task in eligible: + task_id = getattr(task, "id", None) + if task_id not in title_results and _has_generated_title(task): + title_results[task_id] = str(getattr(task, "new_title") or "") + _emit_generation_event(on_event, task, "title", "title_submit", "skipped", detail="已有标题") + cover_tasks = [ - task for task in eligible - if task.id in title_results + task for task in cover_candidates + if getattr(task, "id", None) in title_results ] with ThreadPoolExecutor( max_workers=max(1, int(generation_cfg.get("image_concurrency", 1))) diff --git a/app/gui/tabs/generate.py b/app/gui/tabs/generate.py index 1164cb2..7e042e0 100644 --- a/app/gui/tabs/generate.py +++ b/app/gui/tabs/generate.py @@ -270,6 +270,7 @@ class GenerateTab(QWidget): def _on_generate_cover_toggled(self, checked): previous = bool(appconfig.ai_config(self.config).get("generate_cover", False)) if self._save_generate_cover_setting(show_status=True): + self.refresh_tasks() return self.generate_cover_checkbox.blockSignals(True) self.generate_cover_checkbox.setChecked(previous) @@ -470,11 +471,17 @@ class GenerateTab(QWidget): generate_cover = bool(self.generate_cover_checkbox.isChecked()) tasks = [ task for task in self.model.tasks - if ai.is_generatable_task(task) + if ai.is_generatable_task(task, generate_cover=generate_cover) ] if not tasks: - self._set_status("当前筛选结果没有待生成或生成失败可重试任务;请先在①导入采集完成旧数据采集") + self._set_status( + "当前筛选结果没有可生成的缺失内容;请先在①导入采集完成采集,或勾选“生成封面图片(成本较高)”补封面" + ) return + component_totals = ai.generation_component_totals( + tasks, + generate_cover=generate_cover, + ) prompt_values = { "title": self.title_prompt_edit.toPlainText(), "cover": self.cover_prompt_edit.toPlainText(), @@ -502,9 +509,10 @@ class GenerateTab(QWidget): self._update_generate_progress( { "total": len(tasks), + "title_total": component_totals["title_total"], "title_done": 0, "cover_done": 0, - "cover_total": len(tasks) if generate_cover else 0, + "cover_total": component_totals["cover_total"] if generate_cover else 0, "generated_done": 0, "failed": 0, "generate_cover": generate_cover, @@ -681,15 +689,16 @@ class GenerateTab(QWidget): def _update_generate_progress(self, payload): total = max(0, int(payload.get("total", 0) or 0)) + title_total = max(0, int(payload.get("title_total", total) or 0)) title_done = max(0, int(payload.get("title_done", 0) or 0)) cover_done = max(0, int(payload.get("cover_done", 0) or 0)) cover_total = self._cover_total_for_progress(payload, total) failed = max(0, int(payload.get("failed", 0) or 0)) self.progress_label.setText("进度:" + self._generate_progress_text(payload)) - self.title_progress_label.setText(f"标题 {title_done}/{total}") + self.title_progress_label.setText(f"标题 {title_done}/{title_total}") self.cover_progress_label.setText(f"图片 {cover_done}/{cover_total}") self.failed_progress_label.setText(f"失败 {failed}") - self._set_progress_bar(self.title_progress_bar, title_done, total) + self._set_progress_bar(self.title_progress_bar, title_done, title_total) self._set_progress_bar(self.cover_progress_bar, cover_done, cover_total) def _reset_cmhub_balance_label(self): @@ -736,12 +745,13 @@ class GenerateTab(QWidget): def _generate_progress_text(self, payload): total = max(0, int(payload.get("total", 0) or 0)) + title_total = max(0, int(payload.get("title_total", total) or 0)) cover_total = self._cover_total_for_progress(payload, total) - return "标题{title}/{total} · 图片{cover}/{cover_total} · 失败{failed}".format( + return "标题{title}/{title_total} · 图片{cover}/{cover_total} · 失败{failed}".format( title=payload.get("title_done", 0), cover=payload.get("cover_done", 0), cover_total=cover_total, - total=payload.get("total", 0), + title_total=title_total, failed=payload.get("failed", 0), ) @@ -896,7 +906,8 @@ class GenerateTab(QWidget): if selected_status in (None, "all"): return True if selected_status == "to_generate": - return ai.is_generatable_task(task) and task.status != "failed" + generate_cover = bool(self.generate_cover_checkbox.isChecked()) + return ai.is_generatable_task(task, generate_cover=generate_cover) and task.status != "failed" if selected_status == "generated": return task.stage == "generated" if selected_status == "applied": @@ -906,5 +917,3 @@ class GenerateTab(QWidget): if selected_status == "skipped": return task.status == "skipped" return True - - diff --git a/app/gui/workers.py b/app/gui/workers.py index 78c866f..117d7db 100644 --- a/app/gui/workers.py +++ b/app/gui/workers.py @@ -37,10 +37,16 @@ class GenerateWorker(BaseWorker): if str(account.alias).strip() } self._account_by_alias = account_by_alias + ai_cfg = appconfig.ai_config(self.config) + generate_cover = bool(ai_cfg.get("generate_cover", False)) eligible = [ task for task in self.tasks - if ai.is_generatable_task(task) + if ai.is_generatable_task(task, generate_cover=generate_cover) ] + component_totals = ai.generation_component_totals( + eligible, + generate_cover=generate_cover, + ) self._eligible_total = len(eligible) self._task_positions = { getattr(task, "id", None): index @@ -48,17 +54,18 @@ class GenerateWorker(BaseWorker): } batch_ids = self._batch_ids(eligible) self._run_id = self._create_run_log(eligible, batch_ids) - ai_cfg = appconfig.ai_config(self.config) - generate_cover = bool(ai_cfg.get("generate_cover", False)) if generate_cover: - start_message = "[开始] 本轮生成 {total} 条:标题{total},图片{total};标题并发{title_concurrency},图片并发{image_concurrency}".format( + start_message = "[开始] 本轮生成 {total} 条:标题{title_total},图片{cover_total};标题并发{title_concurrency},图片并发{image_concurrency}".format( total=len(eligible), + title_total=component_totals["title_total"], + cover_total=component_totals["cover_total"], title_concurrency=ai_cfg.get("title_concurrency", 1), image_concurrency=ai_cfg.get("image_concurrency", 1), ) else: - start_message = "[开始] 本轮生成 {total} 条:本轮仅生成标题,不生成图片;标题并发{title_concurrency}".format( + start_message = "[开始] 本轮生成 {total} 条:本轮仅生成标题,不生成图片;标题{title_total};标题并发{title_concurrency}".format( total=len(eligible), + title_total=component_totals["title_total"], title_concurrency=ai_cfg.get("title_concurrency", 1), ) self._log_run_event(start_message) @@ -85,9 +92,10 @@ class GenerateWorker(BaseWorker): "ok": False, "error": error, "total": len(eligible), + "title_total": component_totals["title_total"], "title_done": 0, "cover_done": 0, - "cover_total": len(eligible) if generate_cover else 0, + "cover_total": component_totals["cover_total"] if generate_cover else 0, "generated_done": 0, "failed": len(eligible), "cancelled": self.should_cancel(), @@ -160,6 +168,8 @@ class GenerateWorker(BaseWorker): if phase == "title": if result == "start" and step == "title_submit": return f"[标题] {self._task_progress_label(task)} 开始生成" + if result == "skipped": + return f"[标题] {self._task_progress_label(task)} 已有标题,跳过生文" if result == "success" and step == "title_done": return f"[标题] {self._task_progress_label(task)} 成功" if result == "success" and step == "db_write": @@ -251,12 +261,13 @@ class GenerateWorker(BaseWorker): return f"[完成] AI 生成完成:{progress}" def _summary_text(self, summary): + title_total = summary.get("title_total", summary.get("total", 0)) cover_total = summary.get("cover_total", summary.get("total", 0)) return "标题{title}/{total},图片{cover}/{cover_total},失败{failed}".format( title=summary.get("title_done", 0), cover=summary.get("cover_done", 0), cover_total=cover_total, - total=summary.get("total", 0), + total=title_total, failed=summary.get("failed", 0), ) @@ -1994,4 +2005,3 @@ class AIModelTestWorker(BaseWorker): def _elapsed_ms(self, started): return _elapsed_ms(started) - diff --git a/docs/06-tasks.md b/docs/06-tasks.md index 4ad9965..d6e761b 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -132,7 +132,7 @@ | T-530 | cmhub Base URL 规整 + 404 明确提示 | T-526, T-527 | 现象:手动填入 Base URL 后点「刷新别名」提示 notfound(HTTP 404)。核对确认 cmshopee 请求已达标(`GET /api/v1/models` + Bearer,对齐对接文档 §4.4 与 cmhub `ModelsView` 路由,见 `docs/troubleshooting.md`「cmhub 刷新别名 notfound」与 `docs/cmhub-integration-design.md` v3.4);404 根因是 Base URL 带多余 `/api(/v1)` 路径导致双拼、或所连实例未部署 `/api/v1/models`。方案:保存/请求前规整 Base URL——去掉结尾的 `/`、`/api`、`/api/v1` 等多余路径段,只保留 scheme+host(+port)(`appconfig.cmhub_request_url()` 或保存时统一处理),并在⑤输入框旁给出「只填网关根,如 https://host」提示;`_cmhub_code_for_status` 给 404 一个明确码/中文提示(如「cmhub 接口不存在,请检查 Base URL 或该实例是否已部署 /api/v1/models」),不再透传生涩英文原文。补 `test_appconfig`(Base URL 规整:带 `/api/v1`、带尾斜杠、带路径均归一到根)与 `test_ai`(404 → 明确提示)单测;不改 AI HTTP 协议、DB schema、Excel、Shopee/CDP 流程 | DONE | | T-531 | ⑤ 设置未保存状态追踪 + 离开确认 | T-530, T-529, T-527 | 现象:在⑤填写 cmhub Base URL/API Key(或改任何设置)后,没有任何"未保存"提示;切换到其它 Tab、关闭窗口都不拦截,输入静默丢失。且「刷新别名/测试连接」用输入框实时文本、不落盘,成功后易误以为已保存。采用**方式 B(显式保存 + 脏状态追踪 + 离开确认)**,不改自动保存(API Key/半份 cmhub 配置不宜自动写盘)。方案:① 脏追踪——⑤ 所有可编辑控件(cmhub Base URL/API Key/别名下拉、模型详情、生成参数、路径端口、Shopee 更新安全等)接 `textChanged`/`currentIndexChanged`/`toggled`/`valueChanged` 置 `self._dirty=True`;「保存设置」成功后清 `False`;`save_app_settings()` 或等价保存入口必须返回成功/失败,失败时保留 dirty 并阻止离开。② 程序化回填保护——加载配置、保存后重载、刷新别名写回下拉、`_populate_app_settings()` / `_populate_cmhub_alias_combos()` 等必须用 `_suspend_dirty` 或 `QSignalBlocker` 避免误置脏;用户手动输入后由刷新/测试触发的 Base URL 规整仍应保持 dirty,提醒其保存。③ 离开拦截——`main_window` 切 Tab 与 `closeEvent` 若发现⑤ `_dirty` 为真,弹三选一「保存 / 放弃 / 取消」:保存则调保存入口且成功后再切/关;放弃必须重新从 `config.json` + `config/cmhub.json` 载入并回填控件,再清 dirty,不能只清标志;取消则留在⑤不切不关。`QTabWidget.currentChanged` 是切换后信号,实现时需维护上一 Tab index,并用 `_reverting_tab_change`/类似 guard 防止 `setCurrentIndex()` 递归触发。④ 视觉提示——「保存设置」旁显示「● 未保存更改」,保存或放弃回填后消失。⑤ 「刷新别名/测试连接」成功后提示语补「别名已拉取,记得点『保存设置』持久化」,**不**顺带偷偷保存 url/key(保持显式保存一致性)。只改 ⑤ 设置页、`main_window` Tab 切换/关闭守卫和 GUI 单测(脏标志置位/清除、切 Tab 保存/放弃/取消/保存失败分支、程序化回填不置脏、放弃会还原输入、currentChanged 回退不递归);不改配置 schema、AI HTTP 协议、DB schema、Excel、Shopee/CDP 流程 | DONE | | T-532 | ⑤ cmhub 连接成功提示显示账号名 | T-531, T-527, T-528 | 问题:⑤「测试连接/查余额」或刷新别名成功后,界面只提示 `cmhub 连接成功:...`,用户无法确认当前 API Key 实际属于哪个 cmhub 账号,尤其多账号或换 Key 时容易误判。方案:成功 payload 中若 `/api/v1/balance` 返回可识别账号信息,则在结果 label 和状态栏优先显示 `cmhub 账号「<账号名>」连接成功:生文别名 X 个,生图别名 X 个;余额 Y;别名已拉取,记得点『保存设置』持久化`;当前接口结构按 `{ "user": "cmhub_user", "points_balance": 88, "account": { "username": "cmhub_user", "display_name": "主账号" } }` 兼容,显示名优先取 `account.display_name/name/account_name/username/email/id`,再兜底 `user` 字符串或 `user.name/account_name/username/email/id`、顶层 `display_name/name/username/email/id`;邮箱需要脱敏展示;没有账号字段、仅刷新别名不查余额或接口未返回用户信息时,保留现有 `cmhub 连接成功:...` 文案。不得把 API Key、token、完整邮箱或原始敏感响应写入 GUI、run log、诊断日志;不改变 cmhub HTTP 协议、配置 schema、保存逻辑、AI 生成流程或 Shopee/CDP 流程。同步 GUI 单测覆盖有账号名、当前接口结构、只有邮箱、无账号名四种成功提示 | DONE | -| T-533 | ② 增量生成:按缺失组件补生成(先标题后封面分离) | T-303, T-520, T-509 | 现象:先关闭「生成封面图片」只生成了标题(`stage=generated/status=success`、`new_cover_path` 为空),之后勾选封面再点「开始生成」,提示「当前筛选结果没有待生成任务」,无法补生成封面;若改用「重置生成结果」会连标题一起清掉、重生成时白扣一次标题点并覆盖 T-509 手动改的标题。根因:`ai.is_generatable_task` 把 `generated+success` 一律判为不可生成,且 `generate_batch` 对每条 eligible 任务都先重生成标题再生成封面。方案:把「开始生成」改为**按缺失组件增量补齐**——① 判定放宽:`generated+success` 且(封面开关 ON 且 `new_cover_path` 为空)判为可生成(只补封面);② `generate_batch` 组件级跳过:`new_title` 已存在则**跳过标题生成**(复用现有标题,不再调 cmhub 生文、不扣标题点、**不覆盖用户手动标题**),封面开关 ON 且 `new_cover_path` 为空才生成封面;③ 进度计数分组件——`title_total`=缺标题任务数、`cover_total`=(封面开关 ON 时)缺封面任务数,不再用 `len(eligible)` 一刀切;④「没有待生成任务」提示只在**按当前设置真的无缺口**(标题全有 且(封面关 或 封面全有))时出现;⑤ 幂等/点数:全组件齐备的任务不纳入、不重复调用扣点。保持 `set_generated` 写库、run_logs、诊断日志、停止取消未开始项语义不变;不改 cmhub HTTP 协议、DB schema、Excel、Shopee/CDP 流程。单测覆盖:只补封面(跳过标题、标题点不增、手动标题不被覆盖)、混合批(部分缺标题+部分缺封面)、无缺口提示无任务、封面关时标题态任务不纳入 | TODO | +| T-533 | ② 增量生成:按缺失组件补生成(先标题后封面分离) | T-303, T-520, T-509 | 现象:先关闭「生成封面图片」只生成了标题(`stage=generated/status=success`、`new_cover_path` 为空),之后勾选封面再点「开始生成」,提示「当前筛选结果没有待生成任务」,无法补生成封面;若改用「重置生成结果」会连标题一起清掉、重生成时白扣一次标题点并覆盖 T-509 手动改的标题。根因:`ai.is_generatable_task` 把 `generated+success` 一律判为不可生成,且 `generate_batch` 对每条 eligible 任务都先重生成标题再生成封面。方案:把「开始生成」改为**按缺失组件增量补齐**——① 判定放宽:`generated+success` 且(封面开关 ON 且 `new_cover_path` 为空)判为可生成(只补封面);② `generate_batch` 组件级跳过:`new_title` 已存在则**跳过标题生成**(复用现有标题,不再调 cmhub 生文、不扣标题点、**不覆盖用户手动标题**),封面开关 ON 且 `new_cover_path` 为空才生成封面;③ 进度计数分组件——`title_total`=缺标题任务数、`cover_total`=(封面开关 ON 时)缺封面任务数,不再用 `len(eligible)` 一刀切;④「没有待生成任务」提示只在**按当前设置真的无缺口**(标题全有 且(封面关 或 封面全有))时出现;⑤ 幂等/点数:全组件齐备的任务不纳入、不重复调用扣点。保持 `set_generated` 写库、run_logs、诊断日志、停止取消未开始项语义不变;不改 cmhub HTTP 协议、DB schema、Excel、Shopee/CDP 流程。单测覆盖:只补封面(跳过标题、标题点不增、手动标题不被覆盖)、混合批(部分缺标题+部分缺封面)、无缺口提示无任务、封面关时标题态任务不纳入 | DONE | | T-534 | ② 重置增强:多选/筛选范围 + 按组件(标题/封面/全部)重置 | T-533, T-404a, T-509 | 现状:②「重置生成结果」只能对单条、且整条全清(`db.reset_generated` 同时清 `new_title` 和 `new_cover_path` 退回 collected)。真实诉求是精准重置——一批标题不满意但封面 OK(只重标题、保封面、不重扣封面点)、或封面差但标题 OK(只重封面、**保留 `new_title` 保护 T-509 手动标题**、不重扣标题点);且需要作用于多条。方案:与 T-533 的组件模型咬合——① 后端 `db.reset_generated` 加组件开关 `reset_title`/`reset_cover`(`delete_file` 仅在重置封面时可选),只清对应字段与 `last_error`,stage 保持 `generated`,由 T-533 泛化后的 `is_generatable_task`(缺任一必需组件即可生成)识别缺口后由「开始生成」只补被清组件;② UI 重置入口给三选项「重置标题 / 重置封面 / 重置全部」;③ 作用域从单条扩到**多选(表格改 `ExtendedSelection`)或当前筛选结果**,与③「作用于当前筛选结果」心智一致;④ 二次确认框显示将重置的标题/封面条数、其中 `committed=1` 条数,并提示「线上已提交、本地重置不回滚 Shopee、重生成后再更新会再次提交线上」+ 重生成会消耗 cmhub 点数;⑤ 运行中禁用;批量写 `run_type=reset` 运行日志,不触碰 Shopee、不回写 Excel。**只重置封面时绝不改 `new_title`**。不改 cmhub HTTP 协议、Excel、Shopee/CDP 流程。单测覆盖:只重标题保留封面、只重封面保留标题(含手动标题不被清)、重置全部、多选/筛选批量、committed 提示、运行中禁用 | TODO | ## Phase 8 · 工程基础设施后续(`docs/engineering-review.md`) diff --git a/docs/current-state.md b/docs/current-state.md index 722cd7f..6973cea 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -6,7 +6,7 @@ ## 当前快照 - 日期:2026-07-06 -- 阶段:V0 单账号 CDP 流程已验证;V1 已完成 T-000 正式代码包结构、T-001 `app/editor.py` 模块化、T-002 `app/appconfig.py` 应用配置、T-003 SQLite 持久化地基、T-004 本地数据忽略规则、T-005 AI 模型清单后端、T-006 单元测试基座、T-101 账号 user-data-dir 工具、T-102 Chrome 启动器、T-103 登录保活与检测、T-103b 登录检测补充识别 Shopee accounts 登录页、T-104 PySide6 主窗口骨架、T-104b PySide6 worker 基座、T-105 Tab④ 账号管理、T-105b ④启动登录复用已打开 Chrome、T-106 账号快捷方式、T-201 Excel 导入入库、T-202 Tab① 任务列表与导入按钮、T-202b Tab① 导入汇总栏、T-203 采集旧标题旧封面、T-204 回写旧字段到原 Excel、T-204b 采集完成自动回写、T-205 首次未配账号/Chrome 未启动/未登录引导保护、T-205b 采集后关闭自动新建商品页 tab、T-206 Tab① 删除指定批次软删除、T-301 AI 生成接口、T-302 Tab② AI 生成布局与任务列表、T-302p 提示词管理、T-303 Tab② 开始生成/停止/进度、T-303b ②/③ 商品ID筛选、T-401 Tab③ 更新列表筛选与开始更新确认、T-402 Tab③ 确认后串行更新、T-403 Tab③ 结果回写与结束汇总、T-501 Tab⑤ AI 模型管理 UI、T-501b Tab⑤ 角色与生成参数、T-501c Tab⑤ Shopee 更新安全开关、T-502 换封面删第一张再上传、T-503 敏感信息本地明文保存提示与日志脱敏、T-504 多账号并行/dry-run/运行日志、T-207 ①采集诊断日志、② AI生成图片失败诊断日志补丁、T-404a ②/③ 选中记录重置、T-404 真实 Shopee 单条更新冒烟验收、T-506 正式使用批量更新体验、T-507 正式批量更新移除普通流程测试商品 ID 限制、T-508 ③ 更新shopee生产化操作区、T-509 ② 新标题人工微调、T-510 ③ 检查本轮更新文案统一、T-505 全流程诊断日志扩展、T-511 语义色板与任务状态列上色、T-512 ③高风险按钮上色与①导入校验数字标红、T-513 登录点/③Tab危险标识/破坏性按钮上色、T-514 ①②③ 首次空状态引导卡片、T-515 批次阶段进度总览、T-516 ①筛选对齐②③、T-517 ⑤设置分区与兼容字段清理、T-518 ②左栏提示词区组件密度优化、T-519 ②AI生成长任务进度条与用户可读滚动日志、T-520 ②AI生成封面可选生成开关、T-521 依赖清单(锁版本 requirements)、T-522 CI 自动跑语法 + 单元 / GUI 测试、T-523 拆分 `app/gui.py` 为 `app/gui/` 包、T-523a ②/③ 新一轮运行前清空界面日志显示、T-404b 商品详情页加载失败 toast 自动捕获、T-524 PyInstaller 打包为免安装 exe、T-526 `app/ai.py` + `appconfig` 接入 cmhub backend、T-527 ⑤设置 cmhub 网关面板、T-528 ②计费错误提示与余额展示、T-529 默认 cmhub 网关并隐藏 AI 后端选择、T-530 cmhub Base URL 规整与 404 明确提示、T-531 ⑤设置未保存状态追踪与离开确认、T-532 ⑤cmhub连接成功提示显示账号名。 +- 阶段:V0 单账号 CDP 流程已验证;V1 已完成 T-000 正式代码包结构、T-001 `app/editor.py` 模块化、T-002 `app/appconfig.py` 应用配置、T-003 SQLite 持久化地基、T-004 本地数据忽略规则、T-005 AI 模型清单后端、T-006 单元测试基座、T-101 账号 user-data-dir 工具、T-102 Chrome 启动器、T-103 登录保活与检测、T-103b 登录检测补充识别 Shopee accounts 登录页、T-104 PySide6 主窗口骨架、T-104b PySide6 worker 基座、T-105 Tab④ 账号管理、T-105b ④启动登录复用已打开 Chrome、T-106 账号快捷方式、T-201 Excel 导入入库、T-202 Tab① 任务列表与导入按钮、T-202b Tab① 导入汇总栏、T-203 采集旧标题旧封面、T-204 回写旧字段到原 Excel、T-204b 采集完成自动回写、T-205 首次未配账号/Chrome 未启动/未登录引导保护、T-205b 采集后关闭自动新建商品页 tab、T-206 Tab① 删除指定批次软删除、T-301 AI 生成接口、T-302 Tab② AI 生成布局与任务列表、T-302p 提示词管理、T-303 Tab② 开始生成/停止/进度、T-303b ②/③ 商品ID筛选、T-401 Tab③ 更新列表筛选与开始更新确认、T-402 Tab③ 确认后串行更新、T-403 Tab③ 结果回写与结束汇总、T-501 Tab⑤ AI 模型管理 UI、T-501b Tab⑤ 角色与生成参数、T-501c Tab⑤ Shopee 更新安全开关、T-502 换封面删第一张再上传、T-503 敏感信息本地明文保存提示与日志脱敏、T-504 多账号并行/dry-run/运行日志、T-207 ①采集诊断日志、② AI生成图片失败诊断日志补丁、T-404a ②/③ 选中记录重置、T-404 真实 Shopee 单条更新冒烟验收、T-506 正式使用批量更新体验、T-507 正式批量更新移除普通流程测试商品 ID 限制、T-508 ③ 更新shopee生产化操作区、T-509 ② 新标题人工微调、T-510 ③ 检查本轮更新文案统一、T-505 全流程诊断日志扩展、T-511 语义色板与任务状态列上色、T-512 ③高风险按钮上色与①导入校验数字标红、T-513 登录点/③Tab危险标识/破坏性按钮上色、T-514 ①②③ 首次空状态引导卡片、T-515 批次阶段进度总览、T-516 ①筛选对齐②③、T-517 ⑤设置分区与兼容字段清理、T-518 ②左栏提示词区组件密度优化、T-519 ②AI生成长任务进度条与用户可读滚动日志、T-520 ②AI生成封面可选生成开关、T-521 依赖清单(锁版本 requirements)、T-522 CI 自动跑语法 + 单元 / GUI 测试、T-523 拆分 `app/gui.py` 为 `app/gui/` 包、T-523a ②/③ 新一轮运行前清空界面日志显示、T-404b 商品详情页加载失败 toast 自动捕获、T-524 PyInstaller 打包为免安装 exe、T-526 `app/ai.py` + `appconfig` 接入 cmhub backend、T-527 ⑤设置 cmhub 网关面板、T-528 ②计费错误提示与余额展示、T-529 默认 cmhub 网关并隐藏 AI 后端选择、T-530 cmhub Base URL 规整与 404 明确提示、T-531 ⑤设置未保存状态追踪与离开确认、T-532 ⑤cmhub连接成功提示显示账号名、T-533 ②增量生成:按缺失组件补生成。 - 技术栈:Python 3.10+,根目录 `requirements.txt` 锁定运行依赖,GitHub Actions 使用 Windows + Python 3.11 自动跑语法和单元/GUI 测试,PyInstaller onedir 打包为 Windows 免安装 exe;自研 CDP(websocket-client + requests),SQLite(sqlite3)+ `config.json` + openpyxl + AI(默认 cmhub: `config.json` 网关配置 + `config/cmhub.json` Key;direct: `config/ai_models.json` 通用 HTTP 内部兼容),GUI PySide6 5 Tab(已定)。 - 生产代码:已建立 `app/` 包 + 根入口 `main.py`;`app/cdp.py` 为已验证 CDP 底座,已区分 `CDP.close()` 断开 WebSocket 与 `close_tab()` 关闭浏览器 target;`app/editor.py` 已封装登录状态检测、标题/封面/采集/更新按钮能力,打开商品详情页时会安装 toast 捕获并在详情页加载失败时上浮商品失效/无权限等错误原因,`open_product()` 内部失败会关闭本轮自动新建的失败 tab,复用用户已有 tab 不关闭,并在采集结束后只关闭本轮自动新建的商品编辑页 tab、保留用户已有 tab,更新提交成功且设置开启时可关闭本轮自动新建商品页,确认后跳回商品列表页时关闭前等待 2 秒;`click_update()` 已补 Shopee 站点侧确认框处理,页面主「更新」后若出现 `確定您要更新商品嗎?` modal(`.eds-modal__content` / `.eds-modal__box`),只点击弹窗主按钮「更新」并避开「立即優化」,且记录确认后是否跳回商品列表页;`replace_cover()` 已实现更新封面统一先校验本地旧封面备份,再点第一张删除、可见确认框、等待图片管理器稳定和上传 input 恢复,随后先点击上传块模拟人工入口、短暂等待、重新获取 input、注入文件上传新图并等待 Shopee CDN 后拖到第一位的代码路径;已修正 2026-06-30 稳定等待回归:删除第一张前不要求上传 input 可用,只等图片列表稳定;删除后再等上传入口恢复;`重複/重复/duplicate` 上传 toast 会立即判为新封面重复错误;`app/image_paths.py` 已统一新采集/新生成图片路径为 `images///__old/new.jpg`,历史 DB 路径继续按原路径读取;`app/appconfig.py` 已实现 `config.json` 默认值/读写/更新、AI 参数、端口读取、Shopee 更新安全与执行模式默认值,`ai.backend` 默认 `cmhub`,`config/ai_models.json` 模型清单 CRUD/过滤/打码/测试连接与 OpenAI-compatible base URL 自动补 endpoint,`config/cmhub.json` cmhub Key 读写/打码 helper、cmhub Base URL 规整与请求 URL 拼接 helper,以及 `mask_secret()`、`sanitize_for_log()`、`redact_secrets()` 敏感信息脱敏工具;`app/ai.py` 已实现 `gen_title()`/`gen_cover()`/`generate_batch()`,支持 direct 通用 HTTP 和 cmhub 网关 backend,direct 按默认文本/图片模型调用,cmhub 按 Base URL + 生文/生图别名调用 title/image/models/balance 接口,生图 `image_url` 安全下载后转本地 JPEG,计费 metadata 通过事件回调传出,HTTP 404 映射为 `not_found` 并给出中文排障提示,且保留重试、错误脱敏、图片 URL/base64 解析、resolution resize、jpg_quality 保存、按 `ai.generate_cover` 选择只生成标题或先并发标题再并发封面、逐条 `set_generated`、失败 `mark_failed`、步骤级事件/错误回调与停止取消未开始项;`app/prompts.py` 已实现标题提示词读写、封面模板 CRUD 与变量替换;`app/db.py` 已实现 SQLite schema、连接 PRAGMA、批次/账号/任务与阶段写库函数、T-206 批次软删除标记与默认业务查询过滤、T-404a 本地生成结果/更新状态重置函数、T-509 `update_generated_title()` 本地新标题微调函数,以及 `run_logs/run_log_events` 运行日志函数;`app/diagnostics.py` 已实现 gitignore 本地诊断日志、滚动写入、结构化 payload 和自由文本脱敏;T-505 已把 Excel 导入/回写、③更新shopee、④Chrome 启动/登录检测、⑤AI模型测试连接接入 `run_logs/run_log_events` 与本地脱敏诊断日志;`app/excel.py` 已实现多 Excel 输入列解析、整文件列校验、脏行统计跳过、导入批次与任务入库、别名匹配统计、旧标题/旧封面路径回写原 Excel、更新结果回写原 Excel 与另存副本;`app/config.py` 已实现账号 slug 与 user-data-dir 创建;`app/accounts.py` 已实现账号 CRUD 服务、端口默认分配、启动登录幂等复用已打开 Chrome、检测登录、生成快捷方式;`app/chrome.py` 已实现 Chrome 参数拼装、启动、CDP 端口探测、PowerShell `.lnk` 快捷方式生成;`app/gui/` 已由 T-523 拆分为 PySide6 GUI 包,包入口 `__init__.py` 兼容旧导入,`main_window.py` 放 `MainWindow`,`models.py` 放 3 个 TableModel,`widgets.py` 放色板/空状态/批次总览/helper,`workers.py` 放具体 GUI worker,`tabs/` 放 ①~⑤ Tab;整体仍实现 PySide6 `MainWindow`、五 Tab、顶部 Tab 栏防误点样式、统一语义色板、①②③任务状态列前景色、③「开始更新」warning 描边/文字色和①导入校验数字标红、④登录状态点上色、③更新shopee Tab warning 小圆点和删除类按钮 danger 样式、①②③首次空状态引导卡片、①②③批次阶段进度总览、① 导入采集的 Excel 导入按钮/导入汇总栏/批次筛选与删除批次软删除入口/QTableView 任务列表/未匹配筛选与略过标记/采集旧标题旧封面 worker/采集前账号就绪预检与④引导/采集完成自动回写/旧数据回写重试按钮与 worker/采集运行日志视图、② AI生成左右布局/标题与封面提示词管理/批次/店铺/商品ID/状态筛选/任务列表/新标题列本地微调/变量预览/生成封面图片成本开关/开始生成/停止/标题与图片双进度条/双击新旧封面预览/用户可读自动滚动AI生成运行日志/cmhub余额显示/点数不足弹窗/计费日志/重置生成结果与 `GenerateWorker`、③ 更新shopee批次/店铺/商品ID/状态筛选栏/任务列表/开始更新主按钮/重置更新状态右键菜单/更新安全开关拦截与「前往设置」跳转/「检查本轮更新」按钮/开始更新确认弹窗/确认后 `ApplyWorker` 按每批最大更新条数分批执行当前筛选全部可更新记录/账号就绪和端口冲突预检/按账号并行可选/逐条 `set_applied`/运行日志/自动回写结果到 Excel/结束汇总弹窗/手动回写重试按钮、④ 账号管理表格/弹窗/按钮/快捷方式与状态栏、密码明文保存提示、⑤ 设置页 T-529 后默认展示 cmhub 网关 Base URL、API Key、动态生文/生图别名下拉、Base URL 网关根提示、刷新别名与测试连接/查余额,不再显示「AI 后端」label/dropdown、direct 模型选择、模型详情或标题/图片模型角色下拉;保存设置固定写 `ai.backend=cmhub`,保存/刷新前会把 Base URL 规整为网关根,允许先保存不完整 cmhub 配置,生成时再提示补齐;direct 模型配置、`AIModelTestWorker` 和 `config/ai_models.json` 仅作内部兼容/手工回滚;通用生成参数、路径/端口、Shopee 更新安全与多账号并行设置继续持久化 `config.json`,cmhub Key 单独写入 `config/cmhub.json`,保存成功后弹轻量提示框;⑤ 设置页已将「Shopee 更新安全 / 执行模式」前置、将「基础设施(路径与端口)」后置,`test_item_id` 与 `dry_run` 不再有用户可操作控件,保存时保留 `test_item_id` 兼容值并固定 `dry_run=false`;③ 普通正式更新不再用 `test_item_id` 阻断非测试商品,确认弹窗不再显示测试商品 ID;`app/workers.py` 已实现 `BaseWorker`、通用 signals、取消标记和 `QThread` 启动包装。 - 测试:`tests/` 已建立;T-522 已新增 GitHub Actions 在 push / pull_request 自动运行语法检查与全量 unittest;T-006 后纯逻辑改动必须运行 `python -m unittest discover -s tests`,当前覆盖 appconfig/db/config/accounts(含 T-105b 启动登录复用已运行 Chrome)/chrome 启动与快捷方式/editor 登录检测(含 Shopee accounts 登录页)与商品 tab 生命周期、商品详情页失效/隐藏 toast 捕获、更新成功后关闭本轮新开 tab、更新封面备份缺失阻断/8张与9张先删第一张/删除确认/上传入口初始不可用/重复图片 toast/物流错误不阻断上传/删图后稳定等待/上传状态诊断/拖首位 mock 路径/Shopee 更新确认框、成功跳回商品列表和残留错误 toast 不覆盖成功 mock 路径/excel 导入/旧字段与更新结果回写/ai 标题与封面 HTTP 解析/`generate_batch` 正常、失败与停止/prompts 读写与渲染/gui ① 导入采集、删除批次软删除与采集诊断日志/gui ② AI生成布局与批次/店铺/商品ID/状态筛选、提示词管理、生成 worker、图片失败诊断日志、双击预览、重置生成结果与新标题本地编辑/gui ③ 更新shopee批次/店铺/商品ID/状态筛选列表、确认弹窗、开始更新主按钮、重置更新状态右键菜单、Shopee 更新安全拦截与前往设置、`ApplyWorker` 串行/检查/分批/按账号并行/端口冲突预检、运行日志、结果回写与汇总/gui ④ 账号管理、密码打码与明文保存提示/gui ⑤ AI 模型管理、API Key 打码与明文保存提示、角色/生成参数设置、Shopee 更新安全设置、检查按钮/每批最大更新条数/多账号并行设置、测试商品 ID 限制移除/worker signal 与线程包装、T-505 全流程诊断日志(import/write_back/apply/chrome_launch/login_check/ai_model_test)与本地日志脱敏、gui T-511 语义色板和任务状态列前景色、T-512 高风险按钮与导入校验数字样式、T-513 登录状态/Tab 标识/删除按钮样式、T-514 首次空状态引导卡片、T-515 批次阶段进度总览、T-516 ①筛选对齐②③、T-517 ⑤设置分区与兼容字段清理、T-518 ②左栏提示词区组件密度优化、T-519 ②AI生成长任务进度条与用户可读滚动日志、T-520 ②AI生成封面可选生成开关、T-524 PyInstaller 打包入口/构建脚本/发布目录校验、T-526 cmhub backend mock、T-527 设置页 cmhub backend 切换/别名下拉/worker mock、T-528 ②cmhub余额显示/计费日志/点数不足中止提示、T-529 默认 cmhub/隐藏 AI 后端选择/保存固定 cmhub/direct 兼容、T-530 cmhub Base URL 规整/404 not_found 明确提示、T-531 设置页未保存状态/离开确认/放弃还原、T-532 cmhub连接成功账号名提示;2026-07-01 已完成 5 个真实商品的 T-404 更新验收,后续 CDP/Shopee 改动仍需测试商品手动验证。 @@ -70,7 +70,8 @@ 任务状态以 [`06-tasks.md`](06-tasks.md) 为准,历史记录见 [`../progress.md`](../progress.md)。 - 已完成:T-000(正式代码包结构)、T-001(`app/editor.py` 模块化)、T-002(`app/appconfig.py` + `config.json`)、T-003(SQLite 建表)、T-004(本地数据 gitignore)、T-005(AI 模型清单后端)、T-006(单元测试基座)、T-101(账号 slug/user-data-dir)、T-102(Chrome 启动器)、T-103(登录保活与检测)、T-103b(登录检测补充识别 Shopee accounts 登录页)、T-104(PySide6 五 Tab 主窗口骨架)、T-104b(PySide6 worker 基座)、T-105(Tab④ 账号管理)、T-105b(④启动登录复用已打开 Chrome)、T-106(账号快捷方式)、T-201(Excel 导入:解析多文件输入列入库)、T-202(Tab① 任务列表 + 导入按钮 + 别名匹配标记)、T-202b(Tab① 导入汇总栏)、T-203(采集旧标题+旧封面)、T-204(回写旧字段到原 Excel)、T-204b(采集完成自动回写旧字段)、T-205(首次未配账号 / Chrome 未启动 / 未登录引导保护)、T-205b(采集后关闭自动新建商品页 tab)、T-206(Tab① 删除指定批次软删除)、T-301(AI 生成接口)、T-302(Tab② 左右布局与任务列表)、T-302p(提示词管理)、T-303(Tab② 开始生成 + 停止 + 进度)、T-303b(②/③ 商品ID筛选)、T-401(Tab③ 更新列表筛选 + 开始更新确认弹窗)、T-402(Tab③ 确认后串行更新)、T-403(Tab③ 结果回写与结束汇总)、T-501(Tab⑤ AI 模型管理 UI)、T-501b(Tab⑤ 角色与生成参数)、T-501c(Tab⑤ Shopee 更新安全开关)、T-502(换封面删第一张再上传)、T-503(敏感信息本地明文保存提示与日志脱敏)、T-504(多账号并行 / dry-run / 运行日志)、T-207(①采集诊断日志)、② AI生成图片失败诊断日志补丁、T-404a(②/③ 选中记录重置)、T-404(真实 Shopee 单条更新冒烟验收)、T-506(正式使用批量更新体验)、T-507(正式批量更新:移除普通流程测试商品 ID 限制)、T-508(③ 更新shopee生产化操作区)、T-509(② 新标题人工微调)、T-510(③ 检查本轮更新文案统一)、T-505(全流程诊断日志扩展)、T-511(语义色板 + ①②③任务状态列上色)、T-512(③高风险按钮上色 + ①导入校验数字标红)、T-513(登录点 / ③Tab危险标识 / 破坏性按钮上色)、T-514(①②③ 首次空状态引导卡片)、T-515(批次阶段进度总览)、T-516(①筛选对齐②③)、T-517(⑤设置分区 + 清理兼容字段)、T-518(②左栏提示词区组件密度优化)、T-519(②AI生成长任务进度条 + 用户可读滚动日志)、T-520(②AI生成封面可选生成开关)、T-521(依赖清单:锁版本 requirements)、T-522(CI:自动跑语法 + 单元 / GUI 测试)、T-523(拆分 `app/gui.py` 为 `app/gui/` 包)、T-523a(②/③ 新一轮运行前清空界面日志显示)、T-404b(商品详情页加载失败 toast 自动捕获)、T-524(PyInstaller 打包为免安装 exe)、T-526(`app/ai.py` + `appconfig` 接入 cmhub backend)、T-527(⑤设置 cmhub 网关面板)、T-528(②计费错误提示 + 余额展示)、T-529(默认 cmhub 网关并隐藏 AI 后端选择)、T-530(cmhub Base URL 规整 + 404 明确提示)、T-531(⑤设置未保存状态追踪 + 离开确认)、T-532(⑤cmhub连接成功提示显示账号名)。 -- 下一个可领取任务:T-525(引入 ruff lint + format)。 +- 最近完成补充:T-533(②增量生成:按缺失组件补生成)。 +- 下一个可领取任务:T-534(② 重置增强:多选/筛选范围 + 按组件重置);T-525 ruff 继续顺延。 ## 当前已知限制 @@ -98,13 +99,14 @@ - 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 流程。 - T-531 已完成:⑤设置页增加未保存状态追踪与离开确认。用户修改设置后显示“● 未保存更改”;`save_app_settings()` 成功写入 `config.json` + `config/cmhub.json` 后清 dirty,失败保留 dirty;切换 Tab/关闭窗口时提供保存、放弃、取消三选一,且使用自定义中文按钮避免系统默认英文按钮;放弃会重新从 `config.json` 与 `config/cmhub.json` 回填控件;程序化回填和刷新别名写入下拉不置脏;`currentChanged` 回退使用 guard 防递归;刷新别名/测试连接成功只提醒保存,不自动落盘。 - T-532 已完成:⑤设置页 cmhub 测试连接/查余额成功后,会从 `/balance` 返回的 `account.display_name/account.username/user` 以及 name/account_name/email/id 等字段提取 cmhub 账号身份,并显示 `cmhub 账号「<账号名>」连接成功:...`;当前接口结构 `{ "user": "cmhub_user", "points_balance": 88, "account": { "username": "cmhub_user", "display_name": "主账号" } }` 会优先显示 `主账号`;邮箱只显示脱敏形式(如 `o***r@example.com`),无账号信息时保留 `cmhub 连接成功:...` 兜底。`sanitize_for_log()` 同步脱敏 email 字段,避免 run log summary 保存完整邮箱;未改 cmhub HTTP 协议、配置 schema、保存逻辑、AI 生成或 Shopee/CDP 流程。 +- T-533 已完成:②AI生成已从整条任务生成改为按缺失组件增量补齐。标题已存在但封面缺失、且本轮开启“生成封面图片(成本较高)”时,只补封面,不再调用生文、不覆盖手动标题;组件全齐时不纳入本轮生成;标题/图片进度分别按 `title_total` / `cover_total` 统计。未改 cmhub HTTP 协议、DB schema、Excel、Shopee/CDP 流程。 - T-521 已完成:根目录新增 `requirements.txt` 并锁定当前运行依赖版本;`docs/03-tech-stack.md` 和 `docs/README.md` 已改为 `python -m pip install -r requirements.txt` 安装;未新增运行时依赖、未改业务代码。 - T-522 已完成:新增 `.github/workflows/tests.yml`,在 push / pull_request 上用 Windows + Python 3.11 安装 `requirements.txt`,设置 `QT_QPA_PLATFORM=offscreen`,自动运行语法检查和全量单元/GUI 测试;CI 不连接真实 Shopee 或真实 AI。 - T-523 已完成:旧 `app/gui.py` 已拆为 `app/gui/` 包,公开导入路径保持兼容;本轮为纯结构重构,未改 Shopee/CDP、AI、DB、Excel 行为。 - T-523a 已完成:②点击「开始生成」、③点击「检查本轮更新」或「开始更新」时,先清空对应界面的旧日志显示并写入本轮开始摘要;运行中只追加本轮日志。历史 `run_logs/run_log_events` 和本地 `logs/` 不删除、不自动混入当前运行界面。 - T-404b 已完成:`open_product()` 在导航商品详情页前后安装 toast 捕获,等待详情页关键元素超时时会读取最近 `.eds-toasts`/toast/message 节点及页面缓存,把明确商品失效/商品不存在/无权限类 toast 上浮为 `商品失效:<原始toast>`;如果失败发生在 `open_product()` 内部,本轮自动新建的失败 tab 会关闭,复用用户已有 tab 不关闭;①列表“阶段”列只在这类明确失效错误时显示“商品失效”,底层仍保持 `stage=imported/status=failed`,其他打开失败仍显示“失败”。 - T-524 已完成:新增 PyInstaller onedir 打包配置、构建脚本和发布目录校验;打包版启动时工作目录切到 `cmshopee.exe` 所在目录,首次运行在 exe 同级生成/使用本地配置、DB、图片、日志、登录态和提示词;发布包不内置这些本地数据,用户后续更新采用关闭程序后覆盖程序文件、保留本地数据的方式。 -- Phase 7 cmhub 网关对接已完成 T-526~T-532;⑤设置页连接成功提示已能显示 cmhub 账号身份。下一步回到 T-525 ruff。 +- Phase 7 cmhub 网关对接已完成 T-526~T-533;②生成阶段已支持按标题/封面缺失组件增量生成,避免补封面时重跑标题。下一步按看板进入 T-534;T-525 ruff 继续顺延。 - T-301/T-303 已完成通用 HTTP AI 接口、批量生成编排和 GUI 接入 mock 单测;真实 AI 生成还需要在 `config/ai_models.json` 填入可用 url/model/api_key 后做一次成本可控的小样本实测;URL 可填完整 endpoint 或 OpenAI-compatible base URL。 - 本地 `config/ai_models.json` 若由旧版本或手工维护,可能缺少 `category`;启动报 “AI 模型 category 必须是 text 或 image” 时,按 [`troubleshooting.md`](troubleshooting.md) 只补 `category` / `enabled` 等非密钥字段,保留 API Key,且不要提交该文件。 diff --git a/progress.md b/progress.md index b825f3d..d9523a2 100644 --- a/progress.md +++ b/progress.md @@ -1225,4 +1225,13 @@ - 文档:`docs/06-tasks.md`、`docs/api.md`、`docs/routes.md`、`docs/cmhub-integration-design.md`、`docs/current-state.md` 已记录当前 `/api/v1/balance` 返回结构 `{user, points_balance, account:{username, display_name}}`。 - 代码:`SettingsTab` 账号显示名提取优先读取 `account.display_name`,再兜底 `account.username`、`user`、name/account_name/email/id 等字段;邮箱仍脱敏展示。 -- 测试:补充 GUI worker 与设置页成功提示单测,确认当前结构显示 `主账号` 而不是 `cmhub_user`;未改 cmhub HTTP 协议、配置 schema、AI 生成或 Shopee/CDP 流程。 \ No newline at end of file +- 测试:补充 GUI worker 与设置页成功提示单测,确认当前结构显示 `主账号` 而不是 `cmhub_user`;未改 cmhub HTTP 协议、配置 schema、AI 生成或 Shopee/CDP 流程。 + +## 【2026-07-06】T-533 完成 · ②增量生成按缺失组件补齐 + +- 状态:DONE +- 代码:`app/ai.py` 新增 `generation_needs()` / `generation_component_totals()`,`is_generatable_task()` 改为按当前封面开关判断标题/封面缺口;`generate_batch()` 对已有 `new_title` 的任务跳过生文,只在缺 `new_cover_path` 且开启封面时补封面,避免覆盖 T-509 手动标题和重复扣标题点。 +- GUI:②开始生成、待生成筛选、`GenerateWorker` 和底部进度改用组件口径;本轮日志会显示已有标题跳过生文,标题/图片进度分别按 `title_total` / `cover_total` 统计;无缺口时提示先完成①采集或勾选封面补封面。 +- 边界:未改 cmhub HTTP 协议、配置 schema、DB schema、Excel、Shopee/CDP 流程;全组件齐备任务不纳入本轮生成。 +- 测试:新增/调整 AI 与 GUI 单测覆盖只补封面、混合标题/封面缺口、封面关闭时标题态任务不纳入、全组件齐备不重复生成、②入口可启动补封面。 +- 验证:`python -m py_compile app\ai.py app\gui\workers.py app\gui\tabs\generate.py tests\test_ai.py tests\test_gui.py` 通过;`python -m unittest discover -s tests -p test_ai.py` 通过(23 tests);相关 GUI 用例通过;`python -m compileall app main.py` 通过;`python -m unittest discover -s tests` 通过(207 tests);`git diff --check` 通过,仅有本机 LF/CRLF 提示。 diff --git a/tests/test_ai.py b/tests/test_ai.py index 013f994..ba1a218 100644 --- a/tests/test_ai.py +++ b/tests/test_ai.py @@ -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) diff --git a/tests/test_gui.py b/tests/test_gui.py index 1150565..b1491a7 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -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)