diff --git a/docs/11-ai-outfit.md b/docs/11-ai-outfit.md index ecff493..458540a 100644 --- a/docs/11-ai-outfit.md +++ b/docs/11-ai-outfit.md @@ -446,7 +446,8 @@ Excel 行 → `OutfitTask` 列表的转换由 `excel_service` 完成;核心只 - 运行时按该名字在 `load_ai_models()` 里**查**对应模型来用;界面上没有下拉,效果等于"固定用 gpt-5.5"。 - **换模型只改配置、不改代码**:管理员在 `ai_models.json` 加/换文本模型条目,再把 `title_model` 改成它的 `name` 即可,无需重新打包。 - **找不到/未配置时明确报错**(不静默猜测):「未找到标题模型『{名字}』,请在 ai_models.json 添加可生成文字的模型(chat/gemini),并在 app_config 的 title_model 指定其 name」。 - - 选到的模型若是纯图片接口(images/images_edits),由 `AiTextClient` 在调用时明确报错提示换文本模型。 + - **命中的模型是纯图片接口(images/images_edits)时,开跑前就拦截(§19.21)**:`_find_title_model` 检出 `api_type ∈ {images, images_edits}` → 返回错误「『{名字}』是图片模型({api_type}),不能生成文字标题,请改选 chat/gemini 文本模型」→ `_resolve_title_model_config` 弹窗 + 中止,不逐行失败。 + - 边界:`api_type=chat` 但实际返回图片的模型(如 Nano Banana)类型上无法预判,仍在运行时由 `AiTextClient`/「未找到文字标题」逐行暴露——这是固有限制。 - **`ai_models.json` 需有一条文本/视觉模型**(管理员维护,含真实 key,不入库):`api_type` 设 `chat`、`model` 填中转的真实 id(如 `gpt-5.5`)、`name` 与 `title_model` 一致(默认 `GPT-5.5 文本`)。`docs/ai_models.sample.json` 已含 chat 示例可参照。 - **标题提示词**:单份,存 `~/.cmbot/config/title_prompt.txt`(`load_title_prompt`/`save_title_prompt`,仿旧式单份,标题侧不做多套模板);默认文案面向电商女装(结合款式/版型/颜色/印花,输出一行中文标题,不加引号/表情/促销词)。 @@ -462,9 +463,9 @@ Excel 行 → `OutfitTask` 列表的转换由 `excel_service` 完成;核心只 - 左栏有「标题生成」组(提示词 + 保存 + 生成标题按钮),**无标题模型下拉**、无预览块。 - `app_config.title_model` 指向的模型在 `ai_models.json` 存在 → 生成走该模型;改 `title_model` 名字即换模型,无需改代码。 - 选印花生成的 Excel(A=印花名、C=目录),点「生成标题」→ 每行 A 被改写为 AI 标题、明细表标题列实时刷新、D/E/F 未动。 -- `title_model` 找不到对应模型 / 指向纯图片接口(images/images_edits)时明确报错提示。 +- `title_model` 找不到对应模型 / 命中条目是纯图片接口(images/images_edits)时,**开跑前**弹窗报错并中止(不逐行失败,§19.21)。 - 生成完重载 Excel,紧接「开始生成」跑图用的是新标题。 -- 文本解析、`generate_title`(单文件/目录取首图/无图失败/客户端异常)、`write_title_result`(只改 A)、`_resolve_title_model_config`(命中/缺失报错)单测通过,Python 3.7。 +- 文本解析、`generate_title`(单文件/目录取首图/无图失败/客户端异常)、`write_title_result`(只改 A)、`_find_title_model`(命中/缺失/图片模型报错)单测通过,Python 3.7。 ### 17.6 决策:标题模型「配置定名」而非下拉 diff --git a/src/app/widgets/ai_outfit_panel.py b/src/app/widgets/ai_outfit_panel.py index 73dcd1e..134fa75 100644 --- a/src/app/widgets/ai_outfit_panel.py +++ b/src/app/widgets/ai_outfit_panel.py @@ -903,10 +903,18 @@ class AiOutfitPanel(QWidget): return None, ("未找到标题模型「{}」。请在 ai_models.json 添加可生成文字的" "模型(chat/gemini),并把 app_config 的 title_model 设为它的 " "name。".format(name or "(未配置)")) - from services.ai_image_service import AiModelConfig, api_config_errors + from services.ai_image_service import ( + API_IMAGES, API_IMAGES_EDITS, AiModelConfig, api_config_errors, + detect_api_type, + ) errors = api_config_errors(data) if errors: return None, "标题模型「{}」配置有误:{}".format(name, ";".join(errors)) + # 命中的若是纯图片接口,开跑前拦截(§19.21)——否则会逐行失败。 + api_type = detect_api_type(data.get("url", ""), data.get("api_type", "auto")) + if api_type in (API_IMAGES, API_IMAGES_EDITS): + return None, ("「{}」是图片模型({}),不能生成文字标题," + "请改选 chat/gemini 文本模型。".format(name, api_type)) return AiModelConfig.from_dict(data), None def _resolve_title_model_config(self): diff --git a/tasks.md b/tasks.md index 8f20987..61d57c5 100644 --- a/tasks.md +++ b/tasks.md @@ -1350,4 +1350,26 @@ - [x] `ai_outfit_panel.py` 弹窗联动:话术 `textChanged` / 分辨率 `currentIndexChanged` / 数据行下拉变化 → 刷新弹窗预览(仅弹窗存在时);弹窗用 `_reload_sample_rows` 思路在打开时/选 Excel 后填行 - [x] `ai_outfit_panel.py` 生成:保持现有 `_start` 的「缺 `{title}` 弹窗询问是否继续」(标题靠占位符);不引入自动前置 - [x] 测试:面板离屏断言——按钮行含「保存」、编辑框下方仍有「插入标题」、有「预览最终提示词」按钮;打开预览弹窗后选数据行/改分辨率,预览文本 = `render_prompt` 结果(含输出要求);`render_prompt` 既有用例保持绿(不改该函数) -- [x] 验证:相关单测 + 全套 py37 通过;离屏冒烟:点「预览最终提示词」弹窗 → 选数据行标题替换、切分辨率输出要求刷新;左栏不因预览常驻而需要滚动 \ No newline at end of file +- [x] 验证:相关单测 + 全套 py37 通过;离屏冒烟:点「预览最终提示词」弹窗 → 选数据行标题替换、切分辨率输出要求刷新;左栏不因预览常驻而需要滚动 + +### 19.21 标题生成:命中图片模型时开跑前拦截 — docs/11 §17.3 / §17.5 + +前置阅读: + +- `docs/11-ai-outfit.md`(§17.3 模型配置定名、§17.5 验收) +- `src/app/widgets/ai_outfit_panel.py`(`_find_title_model`/`_resolve_title_model_config`/`_start_title`) +- `src/services/ai_image_service.py`(`API_IMAGES`/`API_IMAGES_EDITS`/`detect_api_type`) +- `tests/test_ai_outfit_panel.py`(`_find_title_model` 命中/缺失用例) + +背景: + +现状①完全没配模型、②`title_model` 名字找不到对应条目,都已在 `_find_title_model` 开跑前弹窗+中止。缺口是③:名字**命中了、但那条是图片模型**(`api_type=images`/`images_edits`,如误指到 GPT Image 2)——当前不在开跑前拦,会开跑后**逐行失败**(日志/明细写「该模型是图片接口…」,不烧 API)。补一个开跑前拦截,体验对齐①②。 + +边界:`api_type=chat` 但实际返回图片的模型(如 Nano Banana)类型上判不出,仍只能运行时由「未找到文字标题」逐行暴露——固有限制,不在本任务范围。 + +任务: + +- [x] `ai_outfit_panel.py` `_find_title_model`:命中条目后,用 `detect_api_type(url, api_type)` 判类型;若 ∈ {`API_IMAGES`, `API_IMAGES_EDITS`} → 返回错误「『{名字}』是图片模型({api_type}),不能生成文字标题,请改选 chat/gemini 文本模型」(`config` 为 None)。校验顺序:先 `api_config_errors`,再图片类型判定 +- [x] 行为不变确认:`_resolve_title_model_config` 拿到 error 仍是 `QMessageBox.warning` + 返回 None;`_start_title` 据此中止(已有逻辑,无需改) +- [x] 测试:`test_ai_outfit_panel.py` 加用例——`title_model` 命中 `images_edits` 模型 → `_find_title_model` 返回 `(None, 含「图片模型」的错误)`;既有命中(chat)/缺失用例保持绿 +- [x] 验证:相关单测 + 全套 py37 通过;离屏冒烟:`title_model` 指向图片模型 → 点「生成标题」前即弹窗中止、不开跑 \ No newline at end of file diff --git a/tests/test_ai_outfit_panel.py b/tests/test_ai_outfit_panel.py index 333030d..02eea63 100644 --- a/tests/test_ai_outfit_panel.py +++ b/tests/test_ai_outfit_panel.py @@ -178,6 +178,34 @@ class TestAiOutfitPanelDefaults(unittest.TestCase): self.assertIsNone(config) self.assertTrue(error) + def test_find_title_model_image_model_rejected_upfront(self): + """§19.21: title_model 命中图片模型(images_edits)→ 开跑前报错。""" + panel = self._panel() + panel._models = [ + {"name": "GPT-5.5 文本", "url": "https://r/v1/images/edits", + "model": "gpt-image", "api_key": "sk-x", "api_type": "images_edits"}, + ] + panel._title_model_name = "GPT-5.5 文本" + + config, error = panel._find_title_model() + + self.assertIsNone(config) + self.assertIn("图片模型", error) + + def test_find_title_model_image_model_by_url_autodetect_rejected(self): + """api_type=auto 但 URL 是 images 端点 → 仍按图片模型拦截。""" + panel = self._panel() + panel._models = [ + {"name": "标题模型", "url": "https://r/v1/images/generations", + "model": "x", "api_key": "sk-x", "api_type": "auto"}, + ] + panel._title_model_name = "标题模型" + + config, error = panel._find_title_model() + + self.assertIsNone(config) + self.assertIn("图片模型", error) + if __name__ == "__main__": unittest.main()