From 335c4b62064f82ffd27a30a0927b449c4c092a53 Mon Sep 17 00:00:00 2001 From: chengma Date: Fri, 17 Jul 2026 10:15:34 +0800 Subject: [PATCH] feat(product-suite): improve multi-image AI writing --- app/ai.py | 14 ++++++++++---- docs/04-architecture.md | 2 +- docs/api.md | 4 ++-- docs/routes.md | 2 +- docs/tasks/T-647.md | 6 +++++- tests/test_ai.py | 8 ++++++++ tests/test_product_suite_gui.py | 3 ++- 7 files changed, 29 insertions(+), 10 deletions(-) diff --git a/app/ai.py b/app/ai.py index 75768f7..df454a7 100644 --- a/app/ai.py +++ b/app/ai.py @@ -1484,10 +1484,16 @@ def _compose_title_prompt(title_prompt, old_title): def _compose_product_suite_vision_prompt(instruction, context): return ( - "请分析当前电商商品原图,并根据图片可见信息生成可直接编辑的「商品卖点与要求」。" - "请使用任务指定的输出语言,只输出卖点与画面要求正文,不要解释分析过程。" - "内容应包含商品名称或品类、颜色、款式、可见细节、核心卖点、目标人群、使用场景" - "和适合套图生成的画面要求。已有要求仅是补充约束,不能当作图片事实。" + "请把本次传入的全部电商商品原图视为同一个商品项目的多角度、细节、包装或场景展示。" + "这些图片是一组证据,不是需要逐张分别识别的样本;请综合全部图片,只输出一份商品级的" + "可直接编辑「商品卖点与要求」。禁止输出“图1/图2/第N张”等图片编号,也不要逐图复述" + "或解释分析过程。" + "请使用任务指定的输出语言,严格使用五个独立段落并以相应标题起行,段落语义依次为:" + "商品概述、可确认卖点、适用人群与场景、套图画面要求、待确认或避免编造的信息。" + "不要输出 JSON、Markdown 表格、接口字段或模型说明。" + "内容应综合图片可见的商品名称或品类、颜色、款式、细节和核心卖点;已有要求仅是补充约束," + "不能当作图片事实。若图片中确有可见的颜色、款式、配件、包装或规格差异,请写入待确认或" + "可见差异,不能强行合并为确定事实,也不能按图片顺序猜测主款、附赠品或变体关系。" "不要虚构材质、尺寸、功能、认证、价格、物流承诺或图片中不可确认的信息。" "\n\n任务要求:\n%s\n\n任务上下文:\n%s" % (str(instruction or "").strip(), str(context or "").strip()) diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 823fd0a..a9398e5 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -435,7 +435,7 @@ data/images///__new. # AI 生成的新 - 任一组件生成后 `stage=generated`;**不设逐条人工审核阶段**。若只有标题,③可选择只更新标题;若只有封面,③可选择只更新封面,②标题状态仍为待生成,后续补标题会保留已有封面且不重复生图。双击任务弹窗查看旧封面、新封面和历史候选图;T-577 后弹窗内「重置图片」只清当前任务 `new_cover_path` 并归档旧图,不启动单条 `GenerateWorker`,用户退出后用状态筛选「待生成」批量补生成封面。②「重置生成结果」提供标题/封面/全部的多选或当前筛选范围重置,默认不删除本地新封面文件;已生成且未提交线上的新标题可在②表格本地微调。 - 并发数、重试、分辨率、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-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-645 后⑥「AI帮写」不是②标题生成的复用入口:它使用 `vision_alias` 调 `POST /api/v1/analyze/images`,把当前项目中1至8张可用本地原图按 `source_order` 发送;超过8张时明确只使用前8张。单图上限10MiB、总计上限32MiB,缺图、未下载、超限或未配置别名时不发请求且保留用户现有卖点。读取等待固定120秒、连接等待沿用⑤设置;读超时或网络中断只提示“结果未确认”,不自动重发。成功只显示图片张数、扣点和余额,不显示路径、base64、接口URL、完整提示词或上游原始响应;取消只在请求前/返回后协作生效,不强杀网络线程。 +- 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/主参考图序号、参考图规则、商品卖点及四个只读规则;必需变量缺失、未知/未闭合花括号、只读规则变量未独占一行都视为无效。四个只读规则覆盖尺寸与长图(含禁止多宫格拼接)、政治标识、价格和尺码;商品主体一致性与禁止编造并入参考图规则。比例仍同时传入 `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`。服务不删除本地文件或蝦皮线上图片,任一资产校验失败时整批回滚。 - T-636 起,`image_studio_projects` 增加 `binding_state`(`draft` / `bound`)和稳定 `storage_key`。既有项目迁移为 `bound`,并以原 `item_id` 回填 `storage_key`;项目目录改用 `storage_key`,因此临时草稿绑定正式商品 ID 后不移动目录、不改写已有资产路径。草稿内部使用 `draft_` 作为仅数据库可见的非空 `item_id`,GUI 输入框始终保持空白,用户日志和 cmhub 提示词只使用“临时草稿”或“未绑定商品”,不得暴露该内部值。 diff --git a/docs/api.md b/docs/api.md index e650bf2..4e3e858 100644 --- a/docs/api.md +++ b/docs/api.md @@ -305,7 +305,7 @@ gen_cover(cover_prompt, old_cover_path, out_path, resolution=None, jpg_quality=N # 支持返回 url / data URL / b64_json,按 resolution resize 并以 jpg_quality 保存 JPEG,返回路径;新生成默认写入 `image_dir///__new.jpg`,历史 DB 已存路径继续按原路径读取 analyze_product_images(instruction, context, image_paths, config=None, cmhub_config_path="data/config/cmhub.json", on_event=None) -> dict -# 仅⑥商品套图AI帮写使用;backend 必须为 cmhub。按输入顺序校验1..8张本地图片(单张<=10MiB、合计<=32MiB),POST /api/v1/analyze/images,返回 {text, image_count, metadata};metadata 只允许 alias/model_used/points_cost/points_balance/call_id。 +# 仅⑥商品套图AI帮写使用;backend 必须为 cmhub。按输入顺序校验1..8张本地图片(单张<=10MiB、合计<=32MiB),在一次 POST /api/v1/analyze/images 中作为同商品多角度证据集发送;固定提示词要求一份商品级联合分析,不输出逐图编号,并按商品概述/可确认卖点/人群场景/套图画面/待确认信息组织文本。返回 {text, image_count, metadata};metadata 只允许 alias/model_used/points_cost/points_balance/call_id。 generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=None) -> dict fetch_cmhub_models(base_url, api_key, connect_timeout=10, read_timeout=30) -> list[dict] @@ -320,7 +320,7 @@ fetch_cmhub_models(base_url, api_key, connect_timeout=10, read_timeout=30) -> li 要点: - `backend=direct`:内部兼容/手工回滚路径;标题用 `default_text_model`、封面用 `default_image_model`(`appconfig.get_model` 取定义,含 url/key/api_type)。 -- `backend=cmhub`:普通产品默认路径;标题调用 `POST /api/v1/generate/title`;②批量封面生成调用 `POST /api/v1/generate/image/tasks` + `GET /api/v1/generate/image/tasks/{task_id}`,模型字段使用 `ai.cmhub.title_alias/image_alias`,Key 来自 `data/config/cmhub.json`。⑥「AI帮写」单独调用 `POST /api/v1/analyze/images`,只使用 `ai.cmhub.vision_alias`,不得回退或混用生文/生图别名。`gen_cover()` 单独调用没有任务/DB 上下文,第一版保留旧同步 `POST /api/v1/generate/image` 兼容路径。 +- `backend=cmhub`:普通产品默认路径;标题调用 `POST /api/v1/generate/title`;②批量封面生成调用 `POST /api/v1/generate/image/tasks` + `GET /api/v1/generate/image/tasks/{task_id}`,模型字段使用 `ai.cmhub.title_alias/image_alias`,Key 来自 `data/config/cmhub.json`。⑥「AI帮写」单独调用 `POST /api/v1/analyze/images`,只使用 `ai.cmhub.vision_alias`,不得回退或混用生文/生图别名;同一商品项目的原图在一条请求内联合理解,用户勾选状态不参与选图。`gen_cover()` 单独调用没有任务/DB 上下文,第一版保留旧同步 `POST /api/v1/generate/image` 兼容路径。 - 标题提示词组装:`gen_title()` 的 direct 与 cmhub 路径共用标题 prompt 规则。若标题提示词包含 `{旧标题}`,生成前替换为该任务旧标题,不再自动追加旧标题块;若不包含 `{旧标题}`,保持旧行为自动追加“旧标题:...”块。两种情况都会追加“请只返回新标题,不要解释。”输出约束;其它 `{...}` 原样保留。 - `fetch_cmhub_models()` 调 `GET /api/v1/models` 返回别名清单,供⑤设置页动态下拉使用;Base URL 会先规整为网关根,HTTP 404 映射为 `not_found` 并提示检查 Base URL 或实例是否部署 `/api/v1/models`。GUI 可读取 `display_name/tags/recommended_for/tier/prices` 生成“默认档 / 高质量档 / 省点档”中文说明,但执行层只保存 cmhub alias。 - `api_type=chat/auto` 走 OpenAI-compatible chat JSON;`api_type=images_edits` 走 multipart form。 diff --git a/docs/routes.md b/docs/routes.md index 48bed9e..303f267 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -207,7 +207,7 @@ - 平台、国家地区、语言和比例以四个带独立标签的同行下拉展示,选项只显示真实值;四项都写进每个 job 的完整提示词,比例还透传到 cmhub 生图请求,不是装饰字段。已有项目保存自己的完整设置;未绑定商品的新任务在重启后采用 `config.json` 的最近四项选择。生成仍走 `image_studio_generation.run_jobs()` 的 submit → poll → download 管线。 - 生成按钮按当前总数显示并在运行时切换为「停止生成」;确认停止后显示「正在停止...」,重复点击不再弹确认框。每轮生成用独立运行标识隔离旧信号,本轮全部 job 终态或线程结束时都会统一恢复按钮;最终 worker 信号缺失时由数据库终态看门狗兜底,不要求用户重启。停止会取消未开始任务,已提交任务停止本地等待并保留后续继续查询语义;客户端不承诺取消服务端任务或退回点数。T-643 后项目持久化当前生成轮次:常规新轮至少成功一张才替换主结果区,全部失败/取消保留上一当前轮;主结果按稳定槽位显示同轮最新 job,单张重试留在原槽位。旧版无轮次 job 临时显示为“旧版历史记录”,不按时间或图片数量猜测归属。成功图可预览、复制路径、打开目录、重新生成、移入项目废纸篓并撤销,失败卡显示脱敏中文摘要与重试入口。 - T-646 后「历史生成」打开全局非模态「套图历史生成记录」窗口,默认显示所有未删除商品项目最近创建的生成轮次,主结果区不切换。窗口支持店铺/账号、商品 ID 关键字和“仅当前商品”快捷筛选,但默认不限制当前任务;一行就是一次正常生成轮次,单张失败重试仍归入原行。行内固定显示时间、店铺/账号、商品 ID、成功/失败/停止/重试统计、最多5张缩略图及余量 `+N`,当前轮标记“当前”,NULL 轮次标记“旧版历史记录”,临时项目显示“临时草稿”。双击缩略图或整行从对应图片打开该轮所有可用图的自适应原尺寸浏览;“导出本轮”后台复制该轮成功且本地存在的图片到用户选择目录下的新安全子目录,不覆盖或修改内部图片。旧版记录、全失败轮和本地文件缺失项保留中文说明;不提供批量导出、删除、重试、切换当前轮或再次生成。重复点击复用同一窗口;关闭任务页不关闭全局窗口,应用退出时正常释放。 -- AI帮写和生图按任务独立运行。AI帮写只使用⑤设置的「图片理解别名」调用图片理解能力,不走②标题生成;按商品原图 `source_order` 取1至8张已下载的本地图片,超过8张时状态提示只使用前8张。单图超过10MiB、总计超过32MiB、没有可用本地图、别名未配置或服务异常时不改现有卖点;图片理解读超时或网络中断提示“结果未确认,请先查看点数余额或稍后重试”,不自动重发。成功状态显示理解图片张数、扣点和余额;AI帮写期间若用户改过卖点,返回后必须确认才覆盖;全部用户可见错误隐藏图片路径、URL、接口路径、base64、完整提示词和敏感信息。 +- AI帮写和生图按任务独立运行。AI帮写只使用⑤设置的「图片理解别名」调用图片理解能力,不走②标题生成;按商品原图 `source_order` 取1至8张已下载的本地图片,在一次请求中作为同商品的多角度/细节/包装/场景证据集联合理解,超过8张时状态提示只使用前8张,原图勾选不改变输入图片。返回一份可直接编辑的商品级卖点与套图画面要求,按商品概述、可确认卖点、人群与场景、套图画面要求、待确认或避免编造的信息组织,不按图1、图2逐图说明;图片有可见差异时明确为待确认项。单图超过10MiB、总计超过32MiB、没有可用本地图、别名未配置或服务异常时不改现有卖点;图片理解读超时或网络中断提示“结果未确认,请先查看点数余额或稍后重试”,不自动重发。成功状态显示理解图片张数、扣点和余额;AI帮写期间若用户改过卖点,返回后必须确认才覆盖;全部用户可见错误隐藏图片路径、URL、接口路径、base64、完整提示词和敏感信息。 - ⑥只管理本地图片资产,不自动上传或修改蝦皮;③线上更新边界不受影响。旧 `ImageStudioTab` 留作代码兼容但不再作为主窗口入口。 ## 流程导航 diff --git a/docs/tasks/T-647.md b/docs/tasks/T-647.md index ae3aaaa..fe52acb 100644 --- a/docs/tasks/T-647.md +++ b/docs/tasks/T-647.md @@ -3,7 +3,7 @@ id: T-647 title: 商品套图AI帮写改为同商品多图联合分析 phase: 7 deps: [T-645] -status: TODO +status: DONE created: 2026-07-17 --- @@ -78,3 +78,7 @@ created: 2026-07-17 ## 执行记录 - 2026-07-17:根据多原图同商品联合分析需求创建任务。待实现。 +- 2026-07-17:更新 `ai._compose_product_suite_vision_prompt()`,将同一商品项目的多张原图明确为联合证据集,禁止图号式逐图输出;要求按任务语言输出商品概述、可确认卖点、适用人群与场景、套图画面要求、待确认或避免编造的信息五个独立段落,并保留可见差异和禁止编造约束。 +- 2026-07-17:补充图片理解请求的一次请求、联合提示词契约测试;扩展商品套图 GUI 回归测试,确认即使原图勾选状态只选中其他图片,AI帮写仍按 `source_order` 使用前8张可用本地图。 +- 2026-07-17:同步更新 `docs/04-architecture.md`、`docs/api.md`、`docs/routes.md` 的同商品多图联合理解语义。 +- 验证通过:`py -3.10 -m unittest tests.test_ai tests.test_product_suite_gui tests.test_workers`(114 项)、`py -3.10 -m unittest discover -s tests`(568 项)、`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_ai.py b/tests/test_ai.py index e84b940..8a53d87 100644 --- a/tests/test_ai.py +++ b/tests/test_ai.py @@ -494,6 +494,7 @@ class AITests(TempDirMixin, unittest.TestCase): self.assertEqual(231, result["metadata"]["points_balance"]) self.assertNotIn("image_base64", result) self.assertEqual("POST", calls[0][0]) + self.assertEqual(1, len(calls)) self.assertEqual( "https://cmhub.example.com/api/v1/analyze/images", calls[0][1], @@ -504,6 +505,13 @@ class AITests(TempDirMixin, unittest.TestCase): self.assertTrue(payload["images"][0]["image_base64"].startswith("data:image/png;base64,")) self.assertTrue(payload["images"][1]["image_base64"].startswith("data:image/jpeg;base64,")) self.assertEqual({"temperature": 0.2}, payload["parameters"]) + prompt = payload["prompt"] + self.assertIn("同一个商品项目", prompt) + self.assertIn("一组证据", prompt) + self.assertIn("禁止输出“图1/图2/第N张”", prompt) + self.assertIn("商品概述、可确认卖点、适用人群与场景、套图画面要求", prompt) + self.assertIn("待确认或避免编造的信息", prompt) + self.assertIn("可见差异", prompt) self.assertEqual((3, ai.CMHUB_VISION_READ_TIMEOUT_SECONDS), calls[0][2]["timeout"]) self.assert_removed(temp_dir) diff --git a/tests/test_product_suite_gui.py b/tests/test_product_suite_gui.py index 3ce93cb..325e180 100644 --- a/tests/test_product_suite_gui.py +++ b/tests/test_product_suite_gui.py @@ -933,7 +933,7 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) - def test_ai_write_uses_first_eight_originals_in_source_order(self): + def test_ai_write_uses_first_eight_originals_in_source_order_regardless_of_checks(self): with self.make_temp_dir() as temp_dir: config = self._config(temp_dir) project, assets = self._create_project_with_assets(temp_dir, config, 9) @@ -945,6 +945,7 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): state.project_id = project.id state.project_binding_state = project.binding_state tab._load_state(state) + tab.original_list.set_checked_asset_ids([assets[-1].id]) captured = {} class _Signal: