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` 或支持关思考参数,则从源头无前导思考,比解析更干净(值得在中转文档确认;本次不依赖)。
+39 -2
View File
@@ -36,6 +36,13 @@ _TITLE_CIRCLED = "①②③④⑤⑥⑦⑧⑨⑩"
_TITLE_QUOTES = "\"'「」『』“”‘’"
# Split titles on comma (half/full width) or newline (docs/11 §17.1 / §19.23).
_TITLE_SPLIT = re.compile(r"[,,\r\n]+")
# Sentinel the prompt asks the model to print after any "thinking" preamble;
# everything after the last sentinel is the title list (docs/11 §17.3 / §17.9).
# Tolerant: matches ===TITLES===, ==TITLES==, **===TITLES===**… case-insensitively,
# consuming adjacent markdown decoration (* / #) so it never leaks into the tail.
_TITLE_SENTINEL = re.compile(r"[*#]*\s*=+\s*TITLES\s*=+\s*[*#]*", re.IGNORECASE)
# Sentence-ending punctuation marking the end of a prose preamble (§17.9 fallback).
_SENTENCE_END = "。!?"
def build_text_payload(config, prompt, image_data_url=None):
@@ -130,17 +137,47 @@ def _clean_title_line(line):
return stripped
def _strip_title_preamble(text):
"""Drop a leading "thinking" preamble so only the title list remains (§17.9).
Reasoning models often emit prose before the titles even when told not to;
that prose carries commas, so comma-splitting it yields fake titles. Two
layers, in priority order:
1. Sentinel (deterministic): if the prompt-agreed `===TITLES===` marker is
present, keep only what follows the LAST sentinel — thinking (and the
marker) is discarded.
2. Sentence-end fallback (no prompt change needed): otherwise cut everything
up to and including the last `。!?`. Real titles carry no sentence-ending
punctuation (the prompt forbids it), so the last one marks the prose↔list
boundary. Only strip when a title separator (`,`/`,`/newline) still
remains after it — else the `。` was a lone trailing mark, not a boundary,
and stripping would wipe the titles.
"""
s = str(text)
matches = list(_TITLE_SENTINEL.finditer(s))
if matches:
return s[matches[-1].end():]
cut = max((s.rfind(ch) for ch in _SENTENCE_END), default=-1)
if cut != -1:
tail = s[cut + 1:]
if _TITLE_SPLIT.search(tail):
return tail
return s
def _clean_titles(text):
"""Split raw text into a list of cleaned titles, on comma (or newline).
The prompt asks the model to comma-separate titles (docs/11 §17.1 / §19.23);
splitting on `,`/`,`/newline avoids the Markdown-table mis-parse that line
splitting produced.
splitting produced. A leading model "thinking" preamble is stripped first
(`_strip_title_preamble`, §17.9) so it never leaks in as fake titles.
"""
if not text:
return []
out = []
for piece in _TITLE_SPLIT.split(str(text)):
for piece in _TITLE_SPLIT.split(_strip_title_preamble(text)):
cleaned = _clean_title_line(piece)
if cleaned:
out.append(cleaned)
+4 -2
View File
@@ -49,8 +49,10 @@ DEFAULT_OUTFIT_PROMPT = (
DEFAULT_TITLE_PROMPT = (
"请生成 10 条适合台湾蝦皮电商的中文女装商品标题,突出卖点与适穿场景,"
"每条控制在 30 字以内。"
"标题之间用逗号「,」分隔,只输出标题本身,"
"不要使用 Markdown 表格、不要换行、不要序号/編號/字元數、不要引号或表情。"
"如果你需要思考或说明,请全部写在最前面;思考完毕后单独一行输出 ===TITLES===,"
"其后只放所有标题、标题之间用逗号「,」分隔。"
"===TITLES=== 之后不要出现任何非标题文字,"
"不要使用 Markdown 表格、不要序号/編號/字元數、不要引号或表情。"
)
+23
View File
@@ -1563,3 +1563,26 @@
- [x] 不动 `generate_texts`/`generate_text` 默认 `resolution="1K"`;模型条目 `timeout_seconds>0` 仍优先覆盖(`_post` 逻辑不变)
- [x] 测试:`tests/test_ai_title.py` 断言 `generate_titles` 以 `resolution="4K"` 调 `generate_texts`(mock 客户端捕获 kwargs);既有用例保持绿
- [x] 验证:`test_ai_title.py`、`test_ai_text_service.py`、全套 py37 通过(`test_config_service` 的 packaging 模板失败属并行历史遗留,无关)
### 19.31 标题解析:过滤模型「前导思考」,只取标题 — docs/11 §17.2 / §17.3 / §17.9
前置阅读:
- `docs/11-ai-outfit.md`(§17.1 / §17.2 / §17.3 / §17.9)
- `src/services/ai_text_service.py`(`_TITLE_SPLIT` / `_clean_titles` / `extract_titles_from_response`)
- `src/services/config_service.py`(`DEFAULT_TITLE_PROMPT`)
- `tests/test_ai_text_service.py`
背景:
推理型文本模型即便提示词写明「只返回逗号连接的标题」,仍常在标题**前面**先输出一段思考散文(实测:「我會直接產出符合格式的標題,並先用字元計數檢查…然後一次輸出 42 個標題。【台灣現貨】…」)。这段散文自身带逗号,直接逗号切分会切出假标题混进结果。加一步剥离前导思考:哨兵优先(`===TITLES===`)+ 句号兜底(切最后一个 `。!?` 及之前)。发布包 `title_prompt.txt` 是用户并行改的精细模板(未提交),本任务**不碰**它,只改代码内置默认 + 解析。
任务:
- [x] 文档更新:`docs/11` §17.2 补「剥离前导思考」、§17.3 补哨兵约定、新增 §17.9 决策
- [x] `ai_text_service.py`:加 `_TITLE_SENTINEL`(`=+\s*TITLES\s*=+`,忽略大小写)+ `_strip_title_preamble(text)`;`_clean_titles` 切分前先调用
- [x] `_strip_title_preamble`:① 有哨兵取最后一个哨兵之后;② 无哨兵切最后一个 `。!?` 及之前,仅当其后仍含 `[,,\r\n]` 分隔符时才剥离(防末条句号清空)
- [x] `config_service.py`:`DEFAULT_TITLE_PROMPT` 补哨兵约定(思考写最前、单独一行 `===TITLES===`、其后只放逗号标题)
- [x] 不碰 `packaging/default_config/title_prompt.txt`(用户精细模板,并行未提交工作)
- [x] 测试:真实前导思考样本(带 `。`+逗号列表)被剥离、取全部标题;哨兵优先于句号且忽略思考内逗号;无前导的纯逗号列表不变;末条带收尾 `。`、无前导时不误切清空
- [x] 验证:`test_ai_text_service.py`、`test_ai_title.py`、全套 py37 通过;离屏冒烟用真实返回样本走 `extract_titles_from_response`
+46
View File
@@ -79,6 +79,52 @@ class TestExtractText(unittest.TestCase):
self.assertEqual(extract_titles_from_response({"choices": []}), [])
def test_strips_reasoning_preamble_by_period(self):
"""§19.31: 真实返回——前导思考散文(带逗号、以 。结尾)被剥离,取全部标题。"""
from services.ai_text_service import extract_titles_from_response
content = (
"我會直接產出符合格式的標題,並先用字元計數檢查每條都落在 54–58 字元內。"
"接下來我用快速本地檢查避免字數超標,然後一次輸出 42 個標題。"
"【台灣出貨】 寬鬆T恤 滿額免運 印花T恤 韓系簡約 透氣舒適 不挑身形 夏季抗熱必備,"
"【台灣出貨】 韓版T恤 蝦皮特殺 圖案T恤 日系休閒 親膚舒適 日常女孩 日常百搭穿搭,"
"【台灣出貨】 韓系上衣 2026熱銷爆款 印花短袖 美式復古 夏季必備 通勤族 休閒出遊,"
"【台灣出貨】 oversize短袖 獨家自訂款 圖案短袖 街頭潮流 百搭單品 學生族 懶人寬鬆穿搭,"
"【台灣出貨】 寬鬆版上衣 免運優惠 印花上衣 輕熟女百搭 日常百搭 上班族 辦公日常"
)
titles = extract_titles_from_response({"choices": [{"message": {"content": content}}]})
self.assertEqual(len(titles), 5) # 5 条真标题,无思考碎片
self.assertTrue(all(t.startswith("【台灣出貨】") for t in titles))
self.assertTrue(all("。" not in t for t in titles)) # 前导散文被切干净
self.assertEqual(titles[0],
"【台灣出貨】 寬鬆T恤 滿額免運 印花T恤 韓系簡約 透氣舒適 不挑身形 夏季抗熱必備")
self.assertNotIn("我會直接產出符合格式的標題", titles)
def test_sentinel_takes_priority_over_thinking_commas(self):
"""§19.31: 有 ===TITLES=== 时只取哨兵之后;哨兵前的思考(含逗号/句号)全丢。"""
from services.ai_text_service import extract_titles_from_response
content = "先想一下,分两步。还有备注,继续。 ===TITLES=== 甲款,乙款,丙款"
titles = extract_titles_from_response({"choices": [{"message": {"content": content}}]})
self.assertEqual(titles, ["甲款", "乙款", "丙款"])
def test_sentinel_tolerates_decoration_and_takes_last(self):
"""§19.31: 哨兵可带 ** 修饰/等号数不定/大小写;取最后一个哨兵之后。"""
from services.ai_text_service import extract_titles_from_response
content = "思路: 提到 ===titles=== 这个词\n**==TITLES==**\n甲,乙"
titles = extract_titles_from_response({"choices": [{"message": {"content": content}}]})
self.assertEqual(titles, ["甲", "乙"])
def test_trailing_period_without_preamble_not_wiped(self):
"""§19.31: 无前导、末条带收尾 。—— 不误当分界清空,标题仍全部保留。"""
from services.ai_text_service import extract_titles_from_response
content = "甲款,乙款,丙款。"
titles = extract_titles_from_response({"choices": [{"message": {"content": content}}]})
self.assertEqual(len(titles), 3)
self.assertEqual(titles[:2], ["甲款", "乙款"])
class TestBuildTextPayload(unittest.TestCase):
def test_chat_with_image_includes_image_url(self):