fix(ai-title): 过滤模型「前导思考」,只取标题 (§19.31)

推理型文本模型即便提示词要求「只返回逗号连接的标题」,仍常在标题前先输出
一段思考散文;该散文自身带逗号,直接逗号切分会切出假标题混进结果。切分前
先剥离前导思考(_strip_title_preamble),两层:
- ① 哨兵优先:文本含 ===TITLES===(正则 =+\s*TITLES\s*=+,忽略大小写、
  吞相邻 * / # 装饰)时只取最后一个哨兵之后,思考连同哨兵丢弃
- ② 句号兜底(无需改提示词):无哨兵时切最后一个 。!? 及之前;真标题不含
  句末标点,故最后一个句号即前导散文↔标题列表的分界;仅当其后仍含逗号/换行
  分隔符时才剥离,避免末条标题的收尾句号被误当分界清空

- ai_text_service.py:加 _TITLE_SENTINEL / _SENTENCE_END / _strip_title_preamble,
  _clean_titles 切分前调用
- config_service.py:DEFAULT_TITLE_PROMPT 补哨兵约定(思考写最前、单独一行
  ===TITLES===、其后只放逗号标题)
- docs/11 §17.2/§17.3 更新 + 新增 §17.9 决策;tasks.md §19.31
- 不碰 packaging/default_config/title_prompt.txt(用户精细模板,并行未提交)

验证:test_ai_text_service(22)+ 全套 py37(236 测试)通过,唯一失败为
无关的 test_config_service packaging 模板;离屏冒烟用真实返回样本 → 前导思考
被剥离、取全部干净标题,默认提示词含哨兵。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 15:50:48 +08:00
co-authored by Claude Opus 4.8
parent bc40905b38
commit a413aa6f15
5 changed files with 128 additions and 6 deletions
+16 -2
View File
@@ -463,7 +463,10 @@ Excel 行 → `OutfitTask` 列表的转换由 `excel_service` 完成;核心只
- `images`/`images_edits`:纯图片接口,不能返回文字 → 抛 `AiTextServiceError`。
- 一次 POST → `extract_titles_from_response` 返回**多条**清洗后标题。
- `generate_text(prompt, image_path=None) -> str`:保留(返回第一条,= `generate_texts` 的 `[0]`),供单条场景与既有单测;与 `generate_texts` 共用同一段 POST。
- `extract_titles_from_response(data) -> List[str]`:取 `choices[0].message.content` / gemini `candidates[0].content.parts[].text` 的原始文本,**按逗号(`,`/`,`,兼容换行)拆分 + 逐段清洗**(去首尾空白/序号/符号/引号、丢空),返回标题列表(§19.23)。`extract_text_from_response` = 取其首条(兼容保留)。
- `extract_titles_from_response(data) -> List[str]`:取 `choices[0].message.content` / gemini `candidates[0].content.parts[].text` 的原始文本,**先剥离前导思考(§17.9)→ 按逗号(`,`/`,`,兼容换行)拆分 + 逐段清洗**(去首尾空白/序号/符号/引号、丢空),返回标题列表(§19.23)。`extract_text_from_response` = 取其首条(兼容保留)。
- **剥离前导思考 `_strip_title_preamble`(§17.9/§19.31)**:推理型模型常在标题前先输出一段"思考"散文(这段本身带逗号,直接逗号切会切出假标题)。切分前先剥离:
- **哨兵优先**:文本含 `===TITLES===`(正则 `=+\s*TITLES\s*=+`,忽略大小写)时,只取**最后一个哨兵之后**的内容——把思考连同哨兵一起丢掉。这是提示词侧约定的确定性切点(§17.3)。
- **句号兜底**:无哨兵时,切掉**最后一个句末标点(`。!?`)及其之前的全部**——真标题不含 `。`(提示词禁标点),故最后一个 `。` 即前导散文与标题列表的分界。仅当"其后仍含逗号/换行分隔符"时才剥离,避免把"末条标题的收尾句号"误当分界而清空。
- **读取超时:标题生成走 4K 的 600 秒(§17.8)**。`generate_texts`/`generate_text` 的 `resolution` 形参本是图像分辨率借来的超时档(`resolution_timeout`:512→180 / 1K→240 / 2K→360 / 4K→600 秒);纯文本请求没有"分辨率",只借那张超时表。`generate_titles` 显式传 `resolution="4K"`(`ai_title._TITLE_TIMEOUT_RESOLUTION`)→ 读取超时 **600 秒**,而非默认档 1K 的 240 秒。理由:一次请求要中转站排队、大模型生成多条标题再整段返回,240 秒对慢模型/排队偏紧。连接超时仍 30 秒不变;模型条目若显式设 `timeout_seconds>0` 仍**优先覆盖**这 600 秒(`_post`:`config.timeout_seconds if >0 else resolution_timeout(resolution)`)。
### 17.3 模型与提示词
@@ -477,7 +480,7 @@ Excel 行 → `OutfitTask` 列表的转换由 `excel_service` 完成;核心只
- 边界:`api_type=chat` 但实际返回图片的模型(如 Nano Banana)类型上无法预判,仍在运行时由 `AiTextClient`/「未找到文字标题」逐行暴露——这是固有限制。
- **图片模型下拉与标题模型隔离**:图片生成的右栏 `AI 模型` 下拉在 `_fill_model_combo()` 填充时,应跳过 `name == title_model` 的模型;标题模型仍留在 `self._models` 里供 `_resolve_title_model_config()` 查找,不应从总模型列表删除。这样既保留标题生成能力,又避免默认 `GPT-5.5 文本` 出现在图片模型下拉里被误选。
- **`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`,仿旧式单份,标题侧不做多套模板)。发布包应带 `app\config\title_prompt.txt`;用户目录缺失时首次播种/运行时兜底复制,已有不覆盖;出厂文件缺失时才退回代码内置 `DEFAULT_TITLE_PROMPT`。默认文案为**批量风格 + 逗号分隔**——让模型生成**多条**电商女装标题、**标题之间用逗号分隔**、**不要表格/换行/序号/引号/表情**,数量由用户在提示词里写(默认示例可写「生成 10 条」)。与 §17.1 的逗号拆分配套。
- **标题提示词**:单份,存 `~/.cmbot/config/title_prompt.txt`(`load_title_prompt`/`save_title_prompt`,仿旧式单份,标题侧不做多套模板)。发布包应带 `app\config\title_prompt.txt`;用户目录缺失时首次播种/运行时兜底复制,已有不覆盖;出厂文件缺失时才退回代码内置 `DEFAULT_TITLE_PROMPT`。默认文案为**批量风格 + 逗号分隔**——让模型生成**多条**电商女装标题、**标题之间用逗号分隔**、**不要表格/换行/序号/引号/表情**,数量由用户在提示词里写(默认示例可写「生成 10 条」)。与 §17.1 的逗号拆分配套。**代码内置 `DEFAULT_TITLE_PROMPT` 还约定「若需思考先写在最前面,思考完毕后单独一行输出 `===TITLES===`,其后只放逗号分隔的标题」——给解析一个确定性切点(§17.9)。用户自定义提示词(含发布包精细模板)建议照抄这行哨兵约定;不加也有 `_strip_title_preamble` 的句号兜底,但加了更稳。**
### 17.4 界面与运行
@@ -524,3 +527,14 @@ Excel 行 → `OutfitTask` 列表的转换由 `excel_service` 完成;核心只
- **改法**:`generate_titles` 显式传 `resolution="4K"`(常量 `ai_title._TITLE_TIMEOUT_RESOLUTION`),把读取超时抬到 600 秒。**只改标题这一条链路**——`generate_texts`/`generate_text` 的默认 `resolution="1K"` 不动,其它/未来调用方不受影响。
- **为什么复用分辨率档而非新增字段**:纯文本请求本无分辨率,`resolution` 只是 `resolution_timeout` 那张 `{512/1K/2K/4K → 180/240/360/600}` 超时表的键;复用它零新增配置、与图片侧同一套超时语义,最省。若将来要独立可调,再在模型条目上用 `timeout_seconds>0` 覆盖(已支持,优先级高于分辨率档)。
- **代价**:慢/挂死的请求现在最长等 600 秒才失败(而非 240 秒),但标题生成是单次、手动触发、非批量循环,多等的是最坏情况,正常返回不受影响。
### 17.9 决策:过滤模型「前导思考」,只取标题(哨兵 + 句号兜底)
推理型文本模型即便提示词写明「只返回逗号连接的标题」,仍常在标题**前面**先输出一段思考散文(例:「我會直接產出符合格式的標題,並先用字元計數檢查…然後一次輸出 42 個標題。【台灣現貨】…」)。这段散文**自身带逗号**,直接逗号切分会把它切成若干**假标题**混进结果。§19.31 在 `_clean_titles` 切分**前**加一步 `_strip_title_preamble`:
- **两层过滤**:
- **① 哨兵优先(确定性)**:提示词约定思考完毕后单独一行输出 `===TITLES===`(§17.3);解析取**最后一个哨兵之后**的内容(正则 `=+\s*TITLES\s*=+`、忽略大小写,容忍 `**===TITLES===**` 之类修饰)。思考连同哨兵一并丢弃,无歧义。
- **② 句号兜底(抗噪,无需改提示词)**:无哨兵时,切掉**最后一个 `。!?` 及其之前的全部**。依据:真标题不含句末标点(提示词禁标点、这批标题用半角空格分词),故最后一个 `。` 就是"前导散文 ↔ 标题列表"的分界。**仅当其后仍含逗号/换行分隔符时才剥离**,防止"末条标题恰好带收尾句号"被误当分界而清空。
- **为什么两层都要**:哨兵最干净但依赖模型照做(推理模型偶尔漏);句号兜底不依赖提示词、对现有精细模板(发布包 `【台灣現貨】` 模板,未含哨兵)当场生效。二者叠加:模型配合时确定性、不配合时仍能救。
- **为什么不选纯提示词约束 / JSON 数组**:纯靠"别输出思考"压不住推理模型(§本节前提)。JSON 数组(`["t1","t2",…]` + 抽第一个 `[...]` 解析)鲁棒性更高、还能容忍标题内嵌逗号,但要改输出格式、超长数组偶有截断/非法风险;当前保留用户熟悉的逗号格式 + 上述两层过滤,改动最小。若日后逗号内嵌成为真问题,再迁 JSON(把逗号切分降级为 fallback)。
- **边界**:② 假设标题不含 `。`;若某条标题真带句号会被误切——由提示词"禁标点"约束兜住,属可接受的固有边界(与 §17.3 一致)。中转站若能单独返回 `reasoning_content` 或支持关思考参数,则从源头无前导思考,比解析更干净(值得在中转文档确认;本次不依赖)。