From 1fabd8afcf3b4454d4fee530bcc10ce9c5192d1c Mon Sep 17 00:00:00 2001 From: chengma Date: Fri, 17 Jul 2026 17:03:40 +0800 Subject: [PATCH] feat: snapshot product suite reference assets --- app/db.py | 12 ++++ app/gui/tabs/product_suite.py | 7 +++ app/gui/workers.py | 1 + app/image_studio.py | 54 ++++++++++++++++- app/image_studio_generation.py | 18 ++++++ app/product_suite.py | 8 +++ docs/04-architecture.md | 1 + docs/api.md | 1 + docs/tasks/T-658b.md | 8 ++- tests/test_image_studio.py | 43 ++++++++++++++ tests/test_image_studio_generation.py | 83 +++++++++++++++++++++++++++ tests/test_product_suite.py | 21 +++++++ 12 files changed, 252 insertions(+), 5 deletions(-) diff --git a/app/db.py b/app/db.py index e72412d..9bfebeb 100644 --- a/app/db.py +++ b/app/db.py @@ -338,6 +338,7 @@ CREATE TABLE IF NOT EXISTS image_studio_jobs ( id INTEGER PRIMARY KEY, project_id INTEGER NOT NULL REFERENCES image_studio_projects(id) ON DELETE CASCADE, source_asset_id INTEGER REFERENCES image_studio_assets(id) ON DELETE SET NULL, + reference_asset_ids TEXT, output_asset_id INTEGER REFERENCES image_studio_assets(id) ON DELETE SET NULL, generation_source TEXT NOT NULL DEFAULT 'cmhub', provider TEXT NOT NULL DEFAULT 'cmhub', @@ -488,6 +489,7 @@ def init_db(path=None, conn=None) -> None: _ensure_image_studio_project_draft_columns(database) _ensure_image_studio_job_recovery_columns(database) _ensure_image_studio_generation_round_columns(database) + _ensure_image_studio_job_reference_asset_ids_column(database) def _ensure_batch_delete_columns(database): @@ -612,6 +614,16 @@ def _ensure_image_studio_generation_round_columns(database): "project_id, generation_round_key, generation_slot_index, id)" ) + +def _ensure_image_studio_job_reference_asset_ids_column(database): + columns = { + row["name"] for row in database.execute("PRAGMA table_info(image_studio_jobs)").fetchall() + } + if "reference_asset_ids" not in columns: + database.execute( + "ALTER TABLE image_studio_jobs ADD COLUMN reference_asset_ids TEXT" + ) + def create_batch(file_paths: Iterable[str], note=None, path=None, conn=None) -> str: batch_id = datetime.now().strftime("%Y%m%d_%H%M%S_") + uuid.uuid4().hex[:8] files = [os.path.abspath(file_path) for file_path in file_paths] diff --git a/app/gui/tabs/product_suite.py b/app/gui/tabs/product_suite.py index 7ef830d..c7a9509 100644 --- a/app/gui/tabs/product_suite.py +++ b/app/gui/tabs/product_suite.py @@ -4704,6 +4704,7 @@ class ProductSuiteTab(QWidget): return tuple( ( int(spec.get("source_asset_id") or 0), + tuple(int(asset_id) for asset_id in (spec.get("reference_asset_ids") or [])), str(spec.get("category") or spec.get("job_type") or ""), int(spec.get("category_index") or 0), int(spec.get("source_index") or 0), @@ -5582,8 +5583,14 @@ class ProductSuiteTab(QWidget): }: self._status("当前图片无需重试", "warning") return + try: + reference_asset_ids = image_studio.job_reference_asset_ids(job) + except Exception as exc: + self._message("读取重试图片失败", _user_error(exc)) + return spec = { "source_asset_id": job.source_asset_id, + "reference_asset_ids": reference_asset_ids, "job_type": job.job_type, "prompt": job.prompt, } diff --git a/app/gui/workers.py b/app/gui/workers.py index 3b3cefa..346a2c7 100644 --- a/app/gui/workers.py +++ b/app/gui/workers.py @@ -372,6 +372,7 @@ class ProductSuiteGenerateWorker(BaseWorker): image_studio.create_job( self.project_id, source_asset_id=spec.get("source_asset_id"), + reference_asset_ids=spec.get("reference_asset_ids"), job_type=spec.get("job_type") or "套图", prompt=spec.get("prompt") or "", generation_source="cmhub", diff --git a/app/image_studio.py b/app/image_studio.py index 25c19f3..b5a0d28 100644 --- a/app/image_studio.py +++ b/app/image_studio.py @@ -78,6 +78,7 @@ class ImageStudioJob: id: int project_id: int source_asset_id: Optional[int] + reference_asset_ids: Optional[str] output_asset_id: Optional[int] generation_source: str provider: str @@ -344,6 +345,41 @@ def _ensure_assets_belong_to_project(database, project_id, asset_ids): raise db.DbError("AI工场资产不属于当前项目") +def _parse_reference_asset_ids(value, source_asset_id=None): + if value is None or str(value).strip() == "": + return [] + if isinstance(value, str): + try: + value = json.loads(value) + except (TypeError, ValueError, json.JSONDecodeError) as exc: + raise db.DbError("AI工场参考图快照格式无效") from exc + if not isinstance(value, (list, tuple)): + raise db.DbError("AI工场参考图快照必须是图片ID列表") + source_id = int(source_asset_id) if source_asset_id is not None else None + normalized = [] + for asset_id in value: + try: + parsed = int(asset_id) + except (TypeError, ValueError) as exc: + raise db.DbError("AI工场参考图快照包含无效图片ID") from exc + if parsed <= 0: + raise db.DbError("AI工场参考图快照包含无效图片ID") + if source_id is not None and parsed == source_id: + raise db.DbError("AI工场参考图不能包含主图") + if parsed in normalized: + raise db.DbError("AI工场参考图不能重复") + normalized.append(parsed) + return normalized + + +def job_reference_asset_ids(job): + """Return a validated, ordered reference asset snapshot for one job.""" + return _parse_reference_asset_ids( + getattr(job, "reference_asset_ids", None), + getattr(job, "source_asset_id", None), + ) + + def get_project(project_id, path=None, conn=None, include_deleted=False): clauses = ["id = ?"] params = [int(project_id)] @@ -1114,6 +1150,7 @@ def create_job( project_id, *, source_asset_id=None, + reference_asset_ids=None, job_type="main", prompt="", task_key=None, @@ -1126,6 +1163,12 @@ def create_job( ): now = _now() task_key = str(task_key or _task_key(project_id)) + reference_ids = _parse_reference_asset_ids(reference_asset_ids, source_asset_id) + reference_json = None if reference_asset_ids is None else json.dumps( + reference_ids, + ensure_ascii=True, + separators=(",", ":"), + ) if generation_round_key is not None: generation_round_key = str(generation_round_key).strip() if not generation_round_key: @@ -1140,18 +1183,23 @@ def create_job( with _connection(conn, path) as database: try: with database: - _ensure_assets_belong_to_project(database, project_id, [source_asset_id]) + _ensure_assets_belong_to_project( + database, + project_id, + [source_asset_id, *reference_ids], + ) cursor = database.execute( """ INSERT INTO image_studio_jobs - (project_id, source_asset_id, generation_source, provider, + (project_id, source_asset_id, reference_asset_ids, generation_source, provider, job_type, task_key, status, prompt, generation_round_key, generation_slot_index, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?) """, ( int(project_id), source_asset_id, + reference_json, str(generation_source or "cmhub"), str(provider or "cmhub"), str(job_type), diff --git a/app/image_studio_generation.py b/app/image_studio_generation.py index 697b008..8f7067c 100644 --- a/app/image_studio_generation.py +++ b/app/image_studio_generation.py @@ -270,6 +270,9 @@ def _run_one_job(job_id, runtime, config, image_root, aspect_ratio, db_path, sho return {"job": updated, "status": "failed", "error": "项目或源图不存在"} try: image_studio.update_job_status(job.id, "running", path=db_path) + reference_assets = () + if not job.task_id: + reference_assets = _reference_assets_for_job(job, db_path) request_result = _submit_or_resume_job( job, source_asset, @@ -279,6 +282,7 @@ def _run_one_job(job_id, runtime, config, image_root, aspect_ratio, db_path, sho db_path, should_stop, on_event, + reference_assets=reference_assets, ) image_studio.update_job_status(job.id, "running", path=db_path) request_result = _poll_job(job.id, request_result["task_id"], runtime, request_result, db_path, should_stop, on_event) @@ -343,6 +347,20 @@ def _run_one_job(job_id, runtime, config, image_root, aspect_ratio, db_path, sho return {"job": updated, "status": status, "error": error} +def _reference_assets_for_job(job, db_path): + assets = [] + for asset_id in image_studio.job_reference_asset_ids(job): + asset = image_studio.get_asset(asset_id, path=db_path) + if asset is None: + raise ImageStudioGenerationError("参考图资产不存在,无法继续生成") + try: + _source_path(asset) + except ImageStudioGenerationError as exc: + raise ImageStudioGenerationError("参考图尚未下载到本地,无法继续生成") from exc + assets.append(asset) + return assets + + def _submit_or_resume_job( job, source_asset, diff --git a/app/product_suite.py b/app/product_suite.py index e93f692..4e74792 100644 --- a/app/product_suite.py +++ b/app/product_suite.py @@ -347,6 +347,13 @@ def build_job_specs(source_assets, base_prompt, settings, item_id, *, template_t if not assets: return [] normalized = normalize_suite_settings(settings) + primary_asset_id = int(getattr(assets[0], "id", assets[0])) + reference_asset_ids = [] + if not normalized["per_image_primary"]: + for asset in assets[1:8]: + asset_id = int(getattr(asset, "id", asset)) + if asset_id != primary_asset_id and asset_id not in reference_asset_ids: + reference_asset_ids.append(asset_id) specs = [] for category in category_order(normalized): count = _count(normalized["categories"].get(category, 0)) @@ -358,6 +365,7 @@ def build_job_specs(source_assets, base_prompt, settings, item_id, *, template_t specs.append( { "source_asset_id": int(getattr(asset, "id", asset)), + "reference_asset_ids": list(reference_asset_ids), "job_type": str(category), "category": str(category), "category_index": category_index, diff --git a/docs/04-architecture.md b/docs/04-architecture.md index a217cbe..cc76495 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -438,6 +438,7 @@ data/images///__new. # AI 生成的新 - 并发数、重试、分辨率、jpg 质量、模型/Key 均来自设置(`data/config.json` 的 `ai` 段;Key 存 `data/config/cmhub.json` 或 direct 兼容清单)。T-547 后标题并发和图片并发都限制为 1..5,失败重试次数限制为 0..10;旧 `config.json` 或手工配置的超限值会在加载/保存时夹紧。设置仍只展示一个「图片并发」设置;cmhub 模式下②运行日志显示“图片并发 X,cmhub实际生图并发 Y,下载并发 Y”。 - 商品套图固定使用设置保存的 cmhub 生图 alias。T-651 后,常规新一轮生成先完成历史确认,再按最终 `build_job_specs()` 在后台读取或复用短期模型目录缓存,最后才显示数量和扣点确认;逐图主图开启时白底图仅使用第一张原图,其他分类按每张原图展开。固定分类顺序为白底图、场景图、模特场景图、细节说明图、卖点图;默认数量为1、2、0、0、2,新增分类不因升级自动产生任务。只有当前生图别名存在唯一无条件 `points_cost` 时显示单张与总预估点数,总数严格按 planned job 数量计算;目录不可用或价格有条件时不显示数字。确认默认、Esc 或关闭均取消,计划在读取期间发生变化时不提交旧 specs;单图重试和恢复不进入该常规确认。预估不落库、不预扣,实际扣点仍以 job 的 cmhub 响应为准。T-637 后套图提示词事实来源分为:安装包只读默认 `app/default_prompts/product_suite/base.txt`、用户全局模板 `data/prompts/product_suite/base.txt`、`app/product_suite.py` 中的分类目标、结构化上下文与只读规则常量。用户模板首次缺失或为空时,必须先按完整占位符契约校验内置模板,再通过原子写入初始化;已有用户模板不被升级静默覆盖。模板无效时在创建 project/job/worker 和调用 cmhub 前阻断新一轮生成,单张历史重试继续使用原 `image_studio_jobs.prompt` 快照。 - T-658a 后商品套图异步生图提交统一使用 `images` 数组,单图也使用单元素 `{"image_base64": ...}`,不再提交顶层 `image_base64`。提交层最多传 8 张本地图片,单图原文件上限10MiB、编码后总输入上限32MiB;超限在请求前以中文错误阻断,后续多图参考的资产快照和提示词语义由 T-658b/T-658c 负责。 +- T-658b 后 `image_studio_jobs.reference_asset_ids` 以有序 JSON 图片 ID 列表冻结每个商品套图 job 的参考图;新一轮未勾选逐图主图时,第1张为主图、后续最多7张写入快照,勾选时写空数组。历史 `NULL` 行继续按单图任务处理。恢复或重试只读 job 快照,不回读当前原图列表;参考图资产或本地文件缺失时提交前失败,不静默减少提交数量。 - T-647 后商品套图中的「AI帮写」不是②标题生成的复用入口:它使用 `vision_alias` 调 `POST /api/v1/analyze/images`,把当前项目中1至8张可用本地原图按 `source_order` 在一次请求中发送;超过8张时明确只使用前8张,原图勾选状态不改变该输入集合。视觉提示词将这些图片定义为同一商品项目的多角度、细节、包装或场景证据集,要求产出一份商品级联合分析,不按“图1/图2”逐图罗列;结果按商品概述、可确认卖点、适用人群与场景、套图画面要求、待确认或避免编造的信息组织,并把可见差异明确为待确认项,不强行合并为事实。单图上限10MiB、总计上限32MiB,缺图、未下载、超限或未配置别名时不发请求且保留用户现有卖点。读取等待固定120秒、连接等待沿用设置;读超时或网络中断只提示“结果未确认”,不自动重发。成功只显示图片张数、扣点和余额,不显示路径、base64、接口URL、完整提示词或上游原始响应;取消只在请求前/返回后协作生效,不强杀网络线程。 - `product_suite.render_product_suite_prompt()` 是弹窗预览和真实生成的唯一 renderer;`build_job_specs()` 建立本轮 specs 前只读取一次用户模板并冻结,每个 job 保存最终完整 prompt,运行中修改模板只影响下一轮。新模板使用 `{生成目标}`,其值为内置分类固定目标描述或含实际名称的“生成自定义分类图片:分类名称。”;同时保留 `{套图名称}`、`{补充描述}` 及平台/地区/语言/比例、可选商品ID/主参考图序号、参考图规则、商品卖点和四个只读规则。旧模板必须同时包含 `{套图名称}` 与 `{补充描述}` 才能按兼容路径继续使用;必需变量缺失、未知/未闭合花括号、只读规则变量未独占一行都视为无效。`{参考图规则}` 的变量值以 `参考图规则:` 开头:`per_image_primary=true` 时为“参考图规则:当前上传图片是本任务唯一主参考图;保持商品主体、款式、颜色和关键细节准确;不编造用户与参考图均未提供的信息。”,否则为“参考图规则:使用第一張上傳圖作為主商品圖,其餘圖片只作為參考。”。默认模板独占一行,不额外重复标签或序号。四个只读规则覆盖尺寸与长图(含禁止多宫格拼接)、政治标识、价格和尺码;商品主体一致性与禁止编造并入逐图主图规则。比例仍同时传入 `image_studio_generation.run_jobs(aspect_ratio=...)`,进入 cmhub 请求与输出资产元数据。 - `image_studio_projects.suite_settings_json` 持久化套图设置,旧数据库由 `db.init_db()` 原位补列,默认 `{}`;`draft_prompt` 继续保存卖点文本。`image_studio_assets` 中有效商品原图最多16张,历史 missing 记录不占有效名额;手工原图不会因再次同步蝦皮 URL 被误标 missing。商品套图原图列表的批量勾选只保存在当前 `SuiteTaskState` 对应的界面上下文,不写库;批量移除由 `remove_original_assets_if_unused()` 一次校验项目归属、原图类型和 job/终选引用,并在单个 SQLite 事务中删除资产行、连续重排 `source_order`。服务不删除本地文件或蝦皮线上图片,任一资产校验失败时整批回滚。 diff --git a/docs/api.md b/docs/api.md index 83b3149..aa94091 100644 --- a/docs/api.md +++ b/docs/api.md @@ -432,6 +432,7 @@ export_generation_round(project_id, generation_round_key, parent_dir, path=None, - `remove_original_assets_if_unused()` 会先校验整批原图的项目归属、资产类型及 job/终选引用,再在单个事务中删除资产行并连续重排 `source_order`;任一图片不可删除时整批不变,本地源文件和蝦皮线上图片始终保留。 - cmhub 托管生图每张都是独立 job:保存 `task_key/task_id/status/call_id/points_cost/points_balance`;已有 `task_id` 时只 poll/download,不重复 submit。商品套图把平台/国家/语言/比例等上下文写入每个 job prompt,并把比例实参传到 cmhub;界面不展示 Provider URL、OpenAI Key 或上游接口路径。 - T-658a 后商品套图异步提交的图片字段统一为 `images` 数组(每项仅含本地编码的 `image_base64`),单图不再保留顶层 `image_base64` 兼容字段。提交层限制最多8张、单图原文件10MiB、编码后总输入32MiB;参考图快照字段由 T-658b 扩展。 +- T-658b 后 `image_studio.create_job(..., reference_asset_ids=...)` 接收同项目、去重且不包含主图的有序图片 ID 列表,并以 JSON 快照写入 `image_studio_jobs.reference_asset_ids`;`job_reference_asset_ids(job)` 负责解析和校验。历史 `NULL` 快照返回空列表,恢复/重试不根据当前商品原图补图。 - `include_failed_downloads=True` 允许 failed 但已有 `task_id`、无输出 asset 的任务继续查询,用于下载失败或本地保存失败恢复。 - 终选顺序由 `replace_selections()` 事务替换,主图/详情图同类别去重、跨类别可复用。 - 导出只写 JPEG 图片文件,透明图铺白底;商品目录已存在时只能覆盖受管命名文件或新建带时间目录,不合并、不递归清空。 diff --git a/docs/tasks/T-658b.md b/docs/tasks/T-658b.md index ff864c3..9d4dc13 100644 --- a/docs/tasks/T-658b.md +++ b/docs/tasks/T-658b.md @@ -1,7 +1,7 @@ --- id: T-658b title: 商品套图多图参考任务快照与历史兼容 -status: TODO +status: DONE phase: 7 deps: [T-658a] created: 2026-07-17 @@ -63,4 +63,8 @@ git diff --check ## 执行记录 -- 待实现。 +- 2026-07-17:新增 `image_studio_jobs.reference_asset_ids TEXT` additive 迁移,并扩展 `ImageStudioJob`、`create_job()` 与快照解析校验;历史 `NULL` 保持单图语义。 +- 2026-07-17:`build_job_specs()` 在未勾选逐图主图时冻结第1张后的最多7张参考图;GUI worker、计划签名和单图重试均传递原 job 快照。 +- 2026-07-17:运行时仅在尚未提交 cmhub 的 job 读取参考图快照;资产或本地文件缺失时提交前失败,不静默减少图片数量。已有 `task_id` 的任务继续只轮询下载,不要求历史参考文件仍存在。 +- 2026-07-17:补充 schema/历史 NULL、跨任务快照、参考图提交顺序和缺失参考图不提交的测试;同步架构/API 文档。 +- 验证通过:`py -3.10 -m unittest tests.test_product_suite tests.test_image_studio tests.test_image_studio_generation`(46项)、`py -3.10 -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`git diff --check`。 diff --git a/tests/test_image_studio.py b/tests/test_image_studio.py index 69b2c37..b2eddda 100644 --- a/tests/test_image_studio.py +++ b/tests/test_image_studio.py @@ -75,6 +75,7 @@ class ImageStudioTests(TempDirMixin, unittest.TestCase): "recovery_action", "generation_round_key", "generation_slot_index", + "reference_asset_ids", }.issubset(jobs_columns) ) indexes = { @@ -843,6 +844,48 @@ class ImageStudioTests(TempDirMixin, unittest.TestCase): self.assertTrue( all(job.generation_round_key is None for job in legacy_jobs) ) + self.assertTrue( + all(job.reference_asset_ids is None for job in legacy_jobs) + ) + + self.assert_removed(temp_dir) + + def test_create_job_freezes_valid_reference_asset_ids(self): + with self.make_temp_dir() as temp_dir: + config = self._config(temp_dir) + db.init_db(config["db_path"]) + project = image_studio.create_or_get_project( + account_alias="店铺", + account_slug="shop", + item_id="51100639510", + path=config["db_path"], + ) + source = image_studio.add_asset( + project.id, + image_studio.ASSET_KIND_ORIGINAL, + path=config["db_path"], + ) + reference = image_studio.add_asset( + project.id, + image_studio.ASSET_KIND_ORIGINAL, + path=config["db_path"], + ) + job = image_studio.create_job( + project.id, + source_asset_id=source.id, + reference_asset_ids=[reference.id], + path=config["db_path"], + ) + + self.assertEqual("[%d]" % reference.id, job.reference_asset_ids) + self.assertEqual([reference.id], image_studio.job_reference_asset_ids(job)) + with self.assertRaisesRegex(db.DbError, "不能包含主图"): + image_studio.create_job( + project.id, + source_asset_id=source.id, + reference_asset_ids=[source.id], + path=config["db_path"], + ) self.assert_removed(temp_dir) diff --git a/tests/test_image_studio_generation.py b/tests/test_image_studio_generation.py index f310d60..a59c90e 100644 --- a/tests/test_image_studio_generation.py +++ b/tests/test_image_studio_generation.py @@ -246,6 +246,89 @@ class ImageStudioGenerationTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_job_reference_snapshot_submits_ordered_images(self): + with self.make_temp_dir() as temp_dir: + cfg, project, source = self._project_source(temp_dir) + reference_path = os.path.join(temp_dir, "reference.png") + with open(reference_path, "wb") as fh: + fh.write(self._png_bytes()) + reference = image_studio.add_asset( + project.id, + image_studio.ASSET_KIND_ORIGINAL, + local_path=reference_path, + source_order=2, + path=cfg["db_path"], + ) + job = image_studio.create_job( + project.id, + source_asset_id=source.id, + reference_asset_ids=[reference.id], + prompt="多图提示词", + path=cfg["db_path"], + ) + submitted = [] + + def fake_submit(method, url, api_key, **kwargs): + submitted.append(kwargs["payload"]) + return {"task_id": "multi-image-task", "status": "queued"} + + 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", + return_value={ + "task_id": "multi-image-task", + "status": "succeeded", + "result": {"image_url": "https://cdn.example.com/multi.png"}, + }, + ), \ + mock.patch( + "app.image_studio_generation.ai._download_cmhub_image_with_retry", + return_value=(self._png_bytes(), 0.1), + ): + summary = image_studio_generation.run_jobs( + [job], + config=cfg, + path=cfg["db_path"], + ) + + self.assertEqual(1, summary["success"]) + self.assertEqual(2, len(submitted[0]["images"])) + self.assertNotIn("image_base64", submitted[0]) + + self.assert_removed(temp_dir) + + def test_missing_reference_snapshot_fails_without_submitting(self): + with self.make_temp_dir() as temp_dir: + cfg, project, source = self._project_source(temp_dir) + reference = image_studio.add_asset( + project.id, + image_studio.ASSET_KIND_ORIGINAL, + local_path=os.path.join(temp_dir, "missing-reference.png"), + source_order=2, + path=cfg["db_path"], + ) + job = image_studio.create_job( + project.id, + source_asset_id=source.id, + reference_asset_ids=[reference.id], + prompt="多图提示词", + path=cfg["db_path"], + ) + with mock.patch("app.image_studio_generation._runtime", return_value=self._runtime()), \ + mock.patch("app.image_studio_generation.ai._cmhub_call_with_retry") as submit: + summary = image_studio_generation.run_jobs( + [job], + config=cfg, + path=cfg["db_path"], + ) + + self.assertEqual(1, summary["failed"]) + self.assertIn("参考图尚未下载", summary["jobs"][0]["error"]) + submit.assert_not_called() + + self.assert_removed(temp_dir) + def test_stop_after_download_discards_temporary_result(self): with self.make_temp_dir() as temp_dir: cfg, project, source = self._project_source(temp_dir) diff --git a/tests/test_product_suite.py b/tests/test_product_suite.py index 6a8643c..dda35ff 100644 --- a/tests/test_product_suite.py +++ b/tests/test_product_suite.py @@ -99,6 +99,7 @@ class ProductSuiteTests(unittest.TestCase): self.assertEqual(3, len(specs)) self.assertEqual([11, 11, 12], [spec["source_asset_id"] for spec in specs]) + self.assertEqual([[], [], []], [spec["reference_asset_ids"] for spec in specs]) self.assertEqual(["白底图", "场景图", "场景图"], [spec["job_type"] for spec in specs]) for spec in specs: self.assertIn("平台:Shopee", spec["prompt"]) @@ -135,6 +136,26 @@ class ProductSuiteTests(unittest.TestCase): self.assertLess(white_prompt.index("尺码信息规则"), white_prompt.index(reference_rule)) self.assertLess(white_prompt.index(reference_rule), white_prompt.index("商品卖点与要求")) + def test_job_specs_freeze_first_image_references_when_not_per_image_primary(self): + settings = product_suite.default_suite_settings() + settings.update( + { + "per_image_primary": False, + "categories": {"白底图": 1, "场景图": 1, "卖点图": 0}, + } + ) + specs = product_suite.build_job_specs( + [SimpleNamespace(id=11), SimpleNamespace(id=12), SimpleNamespace(id=13)], + "卖点", + settings, + "51100639510", + template_text=prompts.load_default_product_suite_prompt(), + ) + + self.assertEqual(2, len(specs)) + self.assertEqual([11, 11], [spec["source_asset_id"] for spec in specs]) + self.assertEqual([[12, 13], [12, 13]], [spec["reference_asset_ids"] for spec in specs]) + def test_reference_rule_follows_per_image_primary_setting(self): template = prompts.load_default_product_suite_prompt() settings = product_suite.default_suite_settings()