test(ai-studio): add e2e integration checklist

This commit is contained in:
chengma
2026-07-11 14:34:07 +08:00
parent dcaf68fd07
commit fe03c65f07
6 changed files with 288 additions and 7 deletions
+1
View File
@@ -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 设计视角评估主流程模块 / 组件合理性,含优化方案与优先级清单。
+68
View File
@@ -0,0 +1,68 @@
# AI工场端到端验收与发布检查
> T-595 阶段性收口文档。本文只记录 AI工场当前已实现的 cmhub 托管主线,不包含 BYOK、自定义 Provider、自动上传蝦皮或本地图片导入。
## 已实现闭环
当前 ⑥「AI工场」支持:
1. 按账号别名 + 商品 ID 创建/恢复项目。
2. 后台只读打开蝦皮商品详情页,读取原主图 URL 并写入照片资产。
3. 单击原主图下载到本地 `data/images/pool/<slug>/<item_id>/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 数据或日志。
+51 -4
View File
@@ -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/<slug>/<item_id>/` 下分 `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 任务重复实现。
④ 账号管理要点:
+8 -2
View File
@@ -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` 写明人工验收步骤,不伪造真实验收结果。
+1 -1
View File
@@ -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 提案):标题提示词加模板控件行、封面控件压一行、插入类按钮下移、标题输入框增高 |
+159
View File
@@ -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()