From fe03c65f0725f7e383854df8b6ed2b76f018afae Mon Sep 17 00:00:00 2001 From: chengma Date: Sat, 11 Jul 2026 14:34:07 +0800 Subject: [PATCH] test(ai-studio): add e2e integration checklist --- docs/README.md | 1 + docs/ai-studio-e2e-checklist.md | 68 ++++++++++++++ docs/api.md | 55 ++++++++++- docs/tasks/T-595.md | 10 +- docs/ui/README.md | 2 +- tests/test_image_studio_e2e.py | 159 ++++++++++++++++++++++++++++++++ 6 files changed, 288 insertions(+), 7 deletions(-) create mode 100644 docs/ai-studio-e2e-checklist.md create mode 100644 tests/test_image_studio_e2e.py diff --git a/docs/README.md b/docs/README.md index b0f28e7..789996d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,6 +21,7 @@ - [任务看板(历史归档)](06-tasks.md):T-000~T-549 历史任务,已冻结,不再新增。 - [模块 / CLI 合约](api.md):本地模块接口、Chrome 启动参数、账号配置 schema。 - [界面与流程结构](routes.md):GUI 窗口、操作流程、按钮职责(无前端路由,用 GUI 流程替代)。 +- [AI工场端到端验收](ai-studio-e2e-checklist.md):⑥ AI工场 cmhub 托管主线的自动化覆盖、人工只读验收和发布检查。 - [当前实现状态](current-state.md):当前代码现实、可运行命令、下一步可做任务。 - [常见问题排查](troubleshooting.md):本地配置、启动报错、敏感文件修复等排障记录。 - [产品与 UI 评估](ux-review.md):以 PM + UI 设计视角评估主流程模块 / 组件合理性,含优化方案与优先级清单。 diff --git a/docs/ai-studio-e2e-checklist.md b/docs/ai-studio-e2e-checklist.md new file mode 100644 index 0000000..c989174 --- /dev/null +++ b/docs/ai-studio-e2e-checklist.md @@ -0,0 +1,68 @@ +# AI工场端到端验收与发布检查 + +> T-595 阶段性收口文档。本文只记录 AI工场当前已实现的 cmhub 托管主线,不包含 BYOK、自定义 Provider、自动上传蝦皮或本地图片导入。 + +## 已实现闭环 + +当前 ⑥「AI工场」支持: + +1. 按账号别名 + 商品 ID 创建/恢复项目。 +2. 后台只读打开蝦皮商品详情页,读取原主图 URL 并写入照片资产。 +3. 单击原主图下载到本地 `data/images/pool///originals/`,并可作为源图。 +4. 使用完整提示词和 cmhub 托管生图别名创建 N 个独立 job。 +5. submit 后立即保存 `task_key/task_id/call_id/扣点/余额`;重启后继续查询时只 poll/download,不重复 submit。 +6. 成功图片进入照片池;失败/过期/停止任务在照片池任务行独立显示。 +7. 将照片拖入主图/详情图终选,可插入、重排、移出,同类别去重,跨类别允许复用。 +8. 允许主图/详情图未满数量时导出当前终选为 JPEG。 +9. 商品目录已存在时只允许覆盖受管命名文件、新建带时间目录或取消。 + +## 自动化覆盖 + +自动化不连接真实蝦皮或真实 cmhub,不产生费用。当前覆盖: + +- SQLite:项目、资产、job、终选顺序。 +- 远程图片:公网 URL、大小/类型/解码校验、原图落盘幂等。 +- cmhub 托管:N 张独立 job、submit/poll/download、续查、下载失败恢复、单张失败不影响其它任务。 +- GUI:6 Tab 顺序、AI工场控件、照片池、终选拖放等价操作、导出入口、继续查询入口和计费状态。 +- 导出:部分导出、JPEG 真转码、透明铺白底、已存在目录三选、受管文件覆盖保护、源文件缺失失败不创建目标。 +- 端到端 mock:项目 → 原图 URL → 原图下载 → cmhub mock 生图 → 终选 → 导出。 + +推荐验证命令: + +```powershell +python -m ruff check app tests main.py +py -3.10 -m compileall app main.py +py -3.10 -m unittest discover -s tests +git diff --check +``` + +## 人工真实只读验收 + +需要本机存在已登录的测试账号 Chrome、可用 cmhub Key 和允许测试的商品 ID。人工验收时必须遵守: + +- 只读拉取原主图,不点击蝦皮更新,不上传图片,不修改标题/封面。 +- 不把 API Key、Cookie、账号、真实商品图片或 `data/` 内容提交到仓库。 +- 真实 cmhub 小样本建议 1~2 张,确认扣点、余额、失败提示和继续查询即可,不做大批量费用测试。 + +建议步骤: + +1. ④账号管理确认测试账号已登录。 +2. ⑥AI工场选择该账号和测试商品 ID,点击「打开项目」。 +3. 点击「拉取主图」,确认原主图列表出现,浏览器不进入编辑提交动作。 +4. 单击一张原主图,确认照片池出现本地可用源图。 +5. 输入短提示词,数量设为 1,点击「开始生成」。 +6. 生成成功后把图片拖入主图终选,点击「导出终选」到临时目录。 +7. 关闭软件后重开,确认项目、照片池、job 状态、终选顺序仍保留。 +8. 若中途停止或下载失败,点击「继续查询任务」,确认不重复 submit。 + +当前 T-595 自动化已完成;真实只读验收需要运行环境,不在仓库中伪造结果。 + +## 发布检查 + +发布前确认: + +- `release/` 或 `dist/` 不包含 `data/`、真实 DB、真实图片、Chrome user-data-dir、日志或 cmhub Key。 +- ⑤设置页和 ⑥AI工场没有 OpenAI Key、Provider URL、direct backend 或“自定义模型”入口。 +- ⑥AI工场没有「导入本地图片」按钮或隐藏入口。 +- 运行日志、状态栏、弹窗不展示 API Key、Authorization、Cookie、完整请求体或 cmhub 内部接口路径。 +- 导出只写用户选择目录下的图片文件,不导出 prompt、task key、SQLite 数据或日志。 diff --git a/docs/api.md b/docs/api.md index b5c464e..af89871 100644 --- a/docs/api.md +++ b/docs/api.md @@ -354,21 +354,68 @@ render_prompt(template_text, task) -> str - `list_cover_templates()` 不会在启动时创建文件;只有保存/新建/另存为才写 `data/prompts/cover/*.txt`。 - 模板名不可为空,不允许路径分隔符、`..` 或 Windows 非法文件名字符;重命名时目标重名会报错。 +## AI工场模块(`app/image_studio*.py`,已建) + +AI工场是 ⑥ Tab 的本地图片项目工作区,不自动上传蝦皮、不修改线上商品。 + +```python +# app/image_studio.py +ImageStudioProject / ImageStudioAsset / ImageStudioJob / ImageStudioSelection +create_or_get_project(account_or_fields, item_id, ...) -> ImageStudioProject +list_projects(path=None) -> list[ImageStudioProject] +update_project_prompt(project_id, draft_prompt, path=None) -> ImageStudioProject +sync_original_asset_urls(project_id, image_urls, path=None) -> list[ImageStudioAsset] +list_assets(project_id, kind=None, include_missing=True, path=None) -> list[ImageStudioAsset] +create_job(project_id, source_asset_id=None, job_type="main", prompt="", ...) -> ImageStudioJob +list_resumable_jobs(project_id=None, include_failed_downloads=False, path=None) -> list[ImageStudioJob] +replace_selections(project_id, selection_type, asset_ids, path=None) -> list[ImageStudioSelection] +pull_remote_main_image_urls(account_or_alias, item_id, path=None, config=None) -> dict + +# app/image_studio_images.py +download_remote_image(url, max_bytes=..., timeout=(connect, read)) -> RemoteImage +load_thumbnail(url, key=None, max_size=220) -> ThumbnailResult +download_original_asset(asset_id, path=None, config=None) -> ImageStudioAsset + +# app/image_studio_generation.py +generate_image_jobs(project_id, source_asset_id, prompt, count, job_type="main", aspect_ratio="1:1", ...) -> dict +resume_image_jobs(project_id=None, aspect_ratio="1:1", ...) -> dict + +# app/image_studio_export.py +export_project_selection(project_id, parent_dir, existing_mode="fail", path=None, config=None) -> ExportResult +``` + +要点: + +- 项目唯一键为账号别名 + 商品 ID;图片文件默认在 `data/images/pool///` 下分 `originals/generated/exports`。 +- 拉取蝦皮原主图只读:复用 `editor.open_product(..., bring_to_front=False)` 和 `editor.read_product_image_urls()`,不上传、不拖拽、不点击更新。 +- 原图下载走 `image_studio_images` 的公网 URL、大小、Content-Type、重定向和 PIL 解码校验;只在用户单击时落盘。 +- cmhub 托管生图每张都是独立 job:保存 `task_key/task_id/status/call_id/points_cost/points_balance`;已有 `task_id` 时只 poll/download,不重复 submit。 +- `include_failed_downloads=True` 允许 failed 但已有 `task_id`、无输出 asset 的任务继续查询,用于下载失败或本地保存失败恢复。 +- 终选顺序由 `replace_selections()` 事务替换,主图/详情图同类别去重、跨类别可复用。 +- 导出只写 JPEG 图片文件,透明图铺白底;商品目录已存在时只能覆盖受管命名文件或新建带时间目录,不合并、不递归清空。 +- 所有真实网络/CDP/图片生成/下载/转码由 GUI worker 调用;Widget slot 不直接执行长耗时操作。 + ## gui 模块(`app/gui/` 包,已建,PySide6) ```python # GUI 入口 main() -> int # 创建 QApplication + MainWindow -class MainWindow(QMainWindow) # QTabWidget: ①②③④⑤;支持注入 db_path/config/config_path/ai_models_path 便于测试 +class MainWindow(QMainWindow) # QTabWidget: ①②③④⑤⑥;支持注入 db_path/config/config_path/ai_models_path 便于测试 class CollectTab(QWidget) # ① 导入采集:导入 Excel + 汇总栏 + QTableView 任务列表 + 未匹配略过标记 class GenerateTab(QWidget) # ② AI生成:提示词管理 + 筛选任务 + 生成封面图片成本开关 + 开始/停止生成 + 新旧封面预览 + AI生成运行日志 class ApplyTab(QWidget) # ③ 更新蝦皮:筛选已生成任务 + 检查本轮更新 + 缺失内容校验 + 确认后分批真实更新 + 运行日志 class SettingsTab(QWidget) # ⑤ 设置:cmhub 网关配置 + 响应式三列布局 + 角色/生成参数/路径端口 + 蝦皮更新安全 + 未保存状态追踪 +class ImageStudioTab(QWidget) # ⑥ AI工场:项目、原图、照片池、终选、cmhub托管生成、导出 class CollectWorker(BaseWorker) # ① 后台采集:账号就绪预检 -> editor.collect -> db.set_collected/mark_skipped/mark_failed class GenerateWorker(BaseWorker) # ② 后台生成:ai.generate_batch -> db.set_generated/mark_failed + 进度 class ApplyWorker(BaseWorker) # ③ 后台更新:账号就绪预检 -> 检查或按批调用 editor.apply_task(...) -> db.set_applied/mark_skipped class WriteBackWorker(BaseWorker) # ①/③ 后台回写:旧字段或更新结果写回原 Excel class AIModelTestWorker(BaseWorker) # ⑤ 后台测试 AI 模型连接:appconfig.test_ai_model +class ImageStudioPullImagesWorker(BaseWorker) # ⑥ 后台只读拉蝦皮原主图 URL +class ImageStudioDownloadOriginalWorker(BaseWorker)# ⑥ 后台下载远程原图 +class ImageStudioGenerateJobsWorker(BaseWorker) # ⑥ 后台提交/查询/下载 cmhub 生图 job +class ImageStudioResumeJobsWorker(BaseWorker) # ⑥ 后台恢复已有 task_id 的生图 job +class ImageStudioExportWorker(BaseWorker) # ⑥ 后台导出终选 JPEG class TaskTableModel(QAbstractTableModel) # 任务表格模型:账号/别名/商品ID/阶段;未匹配别名显示“略过” class GenerateTaskTableModel(QAbstractTableModel) # ② 任务表格模型:店铺/商品ID/旧标题/新标题/状态;generated/未提交/非运行中新标题可本地编辑 class ApplyTaskTableModel(QAbstractTableModel) # ③ 任务表格模型:店铺/商品ID/新标题/新封面/阶段/结果;保持只读,重置更新状态走右键菜单 @@ -378,11 +425,11 @@ TAB_TITLES: list[str] # 固定 Tab 顺序 TAB_STYLE: str # 顶层 Tab 栏防误点样式:最小宽度/padding/间距/当前态 ``` -T-523 后 GUI 已从旧 `app/gui.py` 拆为 `app/gui/` 包:`__init__.py` 负责旧导入路径兼容与 `main()`;`main_window.py` 放 `MainWindow`;`models.py` 放 3 个 TableModel;`widgets.py` 放色板、空状态卡、批次总览和日志 helper;`workers.py` 放具体 GUI worker;`tabs/` 下按 ①~⑤ 拆分各 Tab。对外仍保留 `from app import gui`、`from app.gui import MainWindow/CollectTab/GenerateWorker/...`。 +T-523 后 GUI 已从旧 `app/gui.py` 拆为 `app/gui/` 包:`__init__.py` 负责旧导入路径兼容与 `main()`;`main_window.py` 放 `MainWindow`;`models.py` 放 3 个 TableModel;`widgets.py` 放色板、空状态卡、批次总览和日志 helper;`workers.py` 放具体 GUI worker;`tabs/` 下按 ①~⑥ 拆分各 Tab。对外仍保留 `from app import gui`、`from app.gui import MainWindow/CollectTab/GenerateWorker/...`。 -`MainWindow` 已实现五 Tab、① 导入采集任务列表、② AI生成布局/提示词/开始生成/停止/封面对照预览、③ 更新蝦皮筛选列表与检查/确认后分批真实更新、④ 账号管理、⑤ AI 模型管理。缺 PySide6 时 `main()` 返回 1 并输出明确提示。 +`MainWindow` 已实现六 Tab、① 导入采集任务列表、② AI生成布局/提示词/开始生成/停止/封面对照预览、③ 更新蝦皮筛选列表与检查/确认后分批真实更新、④ 账号管理、⑤ cmhub 设置、⑥ AI工场图片候选生成。缺 PySide6 时 `main()` 返回 1 并输出明确提示。 -主 Tab 栏必须在 `MainWindow` 初始化时应用 `TAB_STYLE`:5 个 Tab 不使用 Qt 默认紧凑宽度,需保证点击区域稳定、间距清晰、当前 Tab 高亮明显。该样式属于全局导航基础,不归后续业务 Tab 任务重复实现。 +主 Tab 栏必须在 `MainWindow` 初始化时应用 `TAB_STYLE`:6 个 Tab 不使用 Qt 默认紧凑宽度,需保证点击区域稳定、间距清晰、当前 Tab 高亮明显。该样式属于全局导航基础,不归后续业务 Tab 任务重复实现。 ④ 账号管理要点: diff --git a/docs/tasks/T-595.md b/docs/tasks/T-595.md index 54d78c8..9b7549c 100644 --- a/docs/tasks/T-595.md +++ b/docs/tasks/T-595.md @@ -3,7 +3,7 @@ id: T-595 title: AI工场端到端集成、真实只读验收与产品文档收口 phase: 7 deps: [T-593, T-594] -status: TODO +status: DONE created: 2026-07-11 --- @@ -39,4 +39,10 @@ AI工场横跨 DB、CDP、远程图片、cmhub 异步生成、GUI 拖放和本 ## 执行记录 -(完成后记录端到端场景、真实只读验收、截图和全量回归。) +- 2026-07-11:完成 AI工场端到端集成收口。 + - 新增 `tests/test_image_studio_e2e.py`:mock 串起项目创建、原主图 URL 同步、原图下载、cmhub 托管生图、终选排序和 JPEG 导出,验证文件顺序与导出结果。 + - 新增 `docs/ai-studio-e2e-checklist.md`:记录已实现闭环、自动化覆盖、人工真实只读验收步骤和发布检查;明确不包含 BYOK、自定义 Provider、自动上传蝦皮或本地图片导入。 + - 更新 `docs/api.md`:补充 `image_studio.py`、`image_studio_images.py`、`image_studio_generation.py`、`image_studio_export.py` 合约,并把 GUI 合约更新为 6 Tab 和 AI工场 worker。 + - 更新 `docs/README.md`、`docs/ui/README.md`,登记 AI工场验收文档和 6 Tab/主流水线事实。 + - 验证:主工作区 targeted 测试通过;因无关默认提示词脏文件仍会影响全量 unittest,已在干净 worktree 仅套用 T-595 diff 后运行 `python -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`py -3.10 -m unittest discover -s tests`(370 tests)和 `git diff --check`,全部通过。 + - 真实只读验收未执行:本轮没有使用本机真实已登录账号、真实商品 ID 或真实 cmhub Key;已在 `docs/ai-studio-e2e-checklist.md` 写明人工验收步骤,不伪造真实验收结果。 diff --git a/docs/ui/README.md b/docs/ui/README.md index 533a124..bfb165b 100644 --- a/docs/ui/README.md +++ b/docs/ui/README.md @@ -4,7 +4,7 @@ | 文件 | 内容 | | --- | --- | -| [overview-pipeline.svg](overview-pipeline.svg) | 5 Tab 流水线总览(stage:imported→collected→generated→applied) | +| [overview-pipeline.svg](overview-pipeline.svg) | 主流水线总览(stage:imported→collected→generated→applied;⑥ AI工场为图片候选工作区) | | [tab1-import-collect.svg](tab1-import-collect.svg) | ① 导入采集:导入 Excel、任务列表、采集旧标题/旧封面、回写、日志 | | [tab2-ai-generate.svg](tab2-ai-generate.svg) | ② AI生成:提示词、生成、新旧封面对照、重置图片后批量补生成 | | [tab2-ai-generate-T584.svg](tab2-ai-generate-T584.svg) | ② AI生成(T-584 提案):标题提示词加模板控件行、封面控件压一行、插入类按钮下移、标题输入框增高 | diff --git a/tests/test_image_studio_e2e.py b/tests/test_image_studio_e2e.py new file mode 100644 index 0000000..a869c24 --- /dev/null +++ b/tests/test_image_studio_e2e.py @@ -0,0 +1,159 @@ +import io +import os +import sys +import unittest +from unittest import mock + +sys.path.insert(0, os.path.dirname(__file__)) + +from _helpers import TempDirMixin + +from app import db, image_studio, image_studio_export, image_studio_generation, image_studio_images + + +class ImageStudioE2ETests(TempDirMixin, unittest.TestCase): + def _png_bytes(self, color=(80, 130, 220)): + from PIL import Image + + output = io.BytesIO() + Image.new("RGB", (32, 32), color).save(output, format="PNG") + return output.getvalue() + + def _config(self, temp_dir): + return { + "data_dir": temp_dir, + "db_path": os.path.join(temp_dir, "cmshopee.db"), + "image_dir": os.path.join(temp_dir, "images"), + "ai": { + "backend": "cmhub", + "image_concurrency": 2, + "retry": 0, + "resolution": "1k", + "jpg_quality": 90, + "cmhub": { + "base_url": "https://cmhub.example.com", + "image_alias": "image-hd", + "connect_timeout": 3, + "download_with_curl": "false", + }, + }, + } + + def _runtime(self): + return { + "base_url": "https://cmhub.example.com", + "api_key": "sk-test", + "alias": "image-hd", + "connect_timeout": 3, + "use_system_proxy": False, + "download_with_curl": "false", + } + + def test_mock_ai_studio_project_to_export_flow(self): + with self.make_temp_dir() as temp_dir: + cfg = self._config(temp_dir) + db.init_db(cfg["db_path"]) + project = image_studio.create_or_get_project( + account_alias="alias", + account_slug="alias_slug", + item_id="51100639510", + draft_prompt="完整提示词", + path=cfg["db_path"], + ) + original = image_studio.sync_original_asset_urls( + project.id, + [{"index": 1, "src": "https://cdn.example.com/original.png"}], + path=cfg["db_path"], + )[0] + + remote = image_studio_images.RemoteImage( + url=original.remote_url, + content=self._png_bytes(), + content_type="image/png", + final_url=original.remote_url, + redirected=False, + ) + with mock.patch("app.image_studio_images.download_remote_image", return_value=remote): + source = image_studio_images.download_original_asset( + original.id, + path=cfg["db_path"], + config=cfg, + ) + + submitted = [] + + def fake_submit(method, url, api_key, **kwargs): + task_id = f"cmhub-task-{len(submitted) + 1}" + submitted.append(task_id) + return { + "task_id": task_id, + "status": "queued", + "call_id": f"call-{len(submitted)}", + "points_cost": 2, + "points_balance": 100 - len(submitted) * 2, + } + + def fake_poll(method, url, api_key, **kwargs): + task_id = url.rsplit("/", 1)[-1] + return { + "task_id": task_id, + "status": "succeeded", + "result": {"image_url": f"https://cdn.example.com/{task_id}.png"}, + } + + with mock.patch("app.image_studio_generation._runtime", return_value=self._runtime()), \ + mock.patch( + "app.image_studio_generation.ai._cmhub_call_with_retry", + side_effect=fake_submit, + ), \ + mock.patch("app.image_studio_generation.ai._cmhub_call_once", side_effect=fake_poll), \ + mock.patch( + "app.image_studio_generation.ai._download_cmhub_image_with_retry", + return_value=(self._png_bytes(color=(10, 200, 120)), 0.1), + ): + summary = image_studio_generation.generate_image_jobs( + project.id, + source.id, + "完整提示词", + 2, + config=cfg, + path=cfg["db_path"], + ) + + self.assertEqual(2, summary["success"]) + generated = image_studio.list_assets(project.id, kind="generated_main", path=cfg["db_path"]) + self.assertEqual(2, len(generated)) + image_studio.replace_selections( + project.id, + "main", + [asset.id for asset in generated], + path=cfg["db_path"], + ) + image_studio.replace_selections(project.id, "detail", [source.id], path=cfg["db_path"]) + + export_parent = os.path.join(temp_dir, "exports") + os.makedirs(export_parent) + result = image_studio_export.export_project_selection( + project.id, + export_parent, + path=cfg["db_path"], + config=cfg, + ) + + self.assertEqual(2, result.main_count) + self.assertEqual(1, result.detail_count) + self.assertEqual( + [ + "51100639510_主图_1.jpg", + "51100639510_主图_2.jpg", + "51100639510_详情图_1.jpg", + ], + [os.path.basename(item.output_path) for item in result.files], + ) + self.assertTrue(all(os.path.isfile(item.output_path) for item in result.files)) + + self.assert_removed(temp_dir) + + +if __name__ == "__main__": + unittest.main()