feat(ai-outfit): 标题模型命中图片模型时开跑前拦截 (§19.21)

补齐缺口③:title_model 名字命中了、但那条是纯图片接口
(api_type=images/images_edits,如误指 GPT Image 2)时,过去会开跑后
逐行失败;现在 _find_title_model 在 api_config_errors 后用 detect_api_type
判类型,命中图片接口即返回错误「『{名字}』是图片模型({api_type}),不能
生成文字标题,请改选 chat/gemini 文本模型」→ _resolve_title_model_config
弹窗 + _start_title 中止,体验对齐①没配模型/②名字找不到。

边界:api_type=chat 但实际返图的模型(如 Nano Banana)类型上判不出,仍
运行时由「未找到文字标题」逐行暴露,固有限制。

测试:_find_title_model 命中 images_edits / URL 自动判图片 两例报错;
既有命中(chat)/缺失用例保持绿。离屏冒烟:title_model 指向图片模型 →
点「生成标题」前即弹窗中止、线程不启动。全套 py37 通过
(test_config_service 的 ai_models.json 顺序失败属并行 §19.13,与本改动无关)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 14:59:04 +08:00
co-authored by Claude Opus 4.8
parent 81351c77b5
commit 52bc6c0c50
4 changed files with 64 additions and 5 deletions
+4 -3
View File
@@ -446,7 +446,8 @@ Excel 行 → `OutfitTask` 列表的转换由 `excel_service` 完成;核心只
- 运行时按该名字在 `load_ai_models()` 里**查**对应模型来用;界面上没有下拉,效果等于"固定用 gpt-5.5"。 - 运行时按该名字在 `load_ai_models()` 里**查**对应模型来用;界面上没有下拉,效果等于"固定用 gpt-5.5"。
- **换模型只改配置、不改代码**:管理员在 `ai_models.json` 加/换文本模型条目,再把 `title_model` 改成它的 `name` 即可,无需重新打包。 - **换模型只改配置、不改代码**:管理员在 `ai_models.json` 加/换文本模型条目,再把 `title_model` 改成它的 `name` 即可,无需重新打包。
- **找不到/未配置时明确报错**(不静默猜测):「未找到标题模型『{名字}』,请在 ai_models.json 添加可生成文字的模型(chat/gemini),并在 app_config 的 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 示例可参照。 - **`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`,仿旧式单份,标题侧不做多套模板);默认文案面向电商女装(结合款式/版型/颜色/印花,输出一行中文标题,不加引号/表情/促销词)。 - **标题提示词**:单份,存 `~/.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` 名字即换模型,无需改代码。 - `app_config.title_model` 指向的模型在 `ai_models.json` 存在 → 生成走该模型;改 `title_model` 名字即换模型,无需改代码。
- 选印花生成的 Excel(A=印花名、C=目录),点「生成标题」→ 每行 A 被改写为 AI 标题、明细表标题列实时刷新、D/E/F 未动。 - 选印花生成的 Excel(A=印花名、C=目录),点「生成标题」→ 每行 A 被改写为 AI 标题、明细表标题列实时刷新、D/E/F 未动。
- `title_model` 找不到对应模型 / 指向纯图片接口(images/images_edits)时明确报错提示。 - `title_model` 找不到对应模型 / 命中条目是纯图片接口(images/images_edits)时,**开跑前**弹窗报错并中止(不逐行失败,§19.21)。
- 生成完重载 Excel,紧接「开始生成」跑图用的是新标题。 - 生成完重载 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 决策:标题模型「配置定名」而非下拉 ### 17.6 决策:标题模型「配置定名」而非下拉
+9 -1
View File
@@ -903,10 +903,18 @@ class AiOutfitPanel(QWidget):
return None, ("未找到标题模型「{}」。请在 ai_models.json 添加可生成文字的" return None, ("未找到标题模型「{}」。请在 ai_models.json 添加可生成文字的"
"模型(chat/gemini),并把 app_config 的 title_model 设为它的 " "模型(chat/gemini),并把 app_config 的 title_model 设为它的 "
"name。".format(name or "(未配置)")) "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) errors = api_config_errors(data)
if errors: if errors:
return None, "标题模型「{}」配置有误:{}".format(name, ";".join(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 return AiModelConfig.from_dict(data), None
def _resolve_title_model_config(self): def _resolve_title_model_config(self):
+23 -1
View File
@@ -1350,4 +1350,26 @@
- [x] `ai_outfit_panel.py` 弹窗联动:话术 `textChanged` / 分辨率 `currentIndexChanged` / 数据行下拉变化 → 刷新弹窗预览(仅弹窗存在时);弹窗用 `_reload_sample_rows` 思路在打开时/选 Excel 后填行 - [x] `ai_outfit_panel.py` 弹窗联动:话术 `textChanged` / 分辨率 `currentIndexChanged` / 数据行下拉变化 → 刷新弹窗预览(仅弹窗存在时);弹窗用 `_reload_sample_rows` 思路在打开时/选 Excel 后填行
- [x] `ai_outfit_panel.py` 生成:保持现有 `_start` 的「缺 `{title}` 弹窗询问是否继续」(标题靠占位符);不引入自动前置 - [x] `ai_outfit_panel.py` 生成:保持现有 `_start` 的「缺 `{title}` 弹窗询问是否继续」(标题靠占位符);不引入自动前置
- [x] 测试:面板离屏断言——按钮行含「保存」、编辑框下方仍有「插入标题」、有「预览最终提示词」按钮;打开预览弹窗后选数据行/改分辨率,预览文本 = `render_prompt` 结果(含输出要求);`render_prompt` 既有用例保持绿(不改该函数) - [x] 测试:面板离屏断言——按钮行含「保存」、编辑框下方仍有「插入标题」、有「预览最终提示词」按钮;打开预览弹窗后选数据行/改分辨率,预览文本 = `render_prompt` 结果(含输出要求);`render_prompt` 既有用例保持绿(不改该函数)
- [x] 验证:相关单测 + 全套 py37 通过;离屏冒烟:点「预览最终提示词」弹窗 → 选数据行标题替换、切分辨率输出要求刷新;左栏不因预览常驻而需要滚动 - [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` 指向图片模型 → 点「生成标题」前即弹窗中止、不开跑
+28
View File
@@ -178,6 +178,34 @@ class TestAiOutfitPanelDefaults(unittest.TestCase):
self.assertIsNone(config) self.assertIsNone(config)
self.assertTrue(error) 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__": if __name__ == "__main__":
unittest.main() unittest.main()