diff --git a/docs/11-ai-outfit.md b/docs/11-ai-outfit.md index ff5051d..f8d5c29 100644 --- a/docs/11-ai-outfit.md +++ b/docs/11-ai-outfit.md @@ -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` 或支持关思考参数,则从源头无前导思考,比解析更干净(值得在中转文档确认;本次不依赖)。 diff --git a/src/services/ai_text_service.py b/src/services/ai_text_service.py index 73c9aac..603a0a6 100644 --- a/src/services/ai_text_service.py +++ b/src/services/ai_text_service.py @@ -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) diff --git a/src/services/config_service.py b/src/services/config_service.py index ec69048..81bca5e 100644 --- a/src/services/config_service.py +++ b/src/services/config_service.py @@ -49,8 +49,10 @@ DEFAULT_OUTFIT_PROMPT = ( DEFAULT_TITLE_PROMPT = ( "请生成 10 条适合台湾蝦皮电商的中文女装商品标题,突出卖点与适穿场景," "每条控制在 30 字以内。" - "标题之间用逗号「,」分隔,只输出标题本身," - "不要使用 Markdown 表格、不要换行、不要序号/編號/字元數、不要引号或表情。" + "如果你需要思考或说明,请全部写在最前面;思考完毕后单独一行输出 ===TITLES===," + "其后只放所有标题、标题之间用逗号「,」分隔。" + "===TITLES=== 之后不要出现任何非标题文字," + "不要使用 Markdown 表格、不要序号/編號/字元數、不要引号或表情。" ) diff --git a/tasks.md b/tasks.md index 167b521..aea5896 100644 --- a/tasks.md +++ b/tasks.md @@ -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` \ No newline at end of file diff --git a/tests/test_ai_text_service.py b/tests/test_ai_text_service.py index df9ddc6..830c45f 100644 --- a/tests/test_ai_text_service.py +++ b/tests/test_ai_text_service.py @@ -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):