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:
+16
-2
@@ -463,7 +463,10 @@ Excel 行 → `OutfitTask` 列表的转换由 `excel_service` 完成;核心只
|
|||||||
- `images`/`images_edits`:纯图片接口,不能返回文字 → 抛 `AiTextServiceError`。
|
- `images`/`images_edits`:纯图片接口,不能返回文字 → 抛 `AiTextServiceError`。
|
||||||
- 一次 POST → `extract_titles_from_response` 返回**多条**清洗后标题。
|
- 一次 POST → `extract_titles_from_response` 返回**多条**清洗后标题。
|
||||||
- `generate_text(prompt, image_path=None) -> str`:保留(返回第一条,= `generate_texts` 的 `[0]`),供单条场景与既有单测;与 `generate_texts` 共用同一段 POST。
|
- `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)`)。
|
- **读取超时:标题生成走 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 模型与提示词
|
### 17.3 模型与提示词
|
||||||
@@ -477,7 +480,7 @@ Excel 行 → `OutfitTask` 列表的转换由 `excel_service` 完成;核心只
|
|||||||
- 边界:`api_type=chat` 但实际返回图片的模型(如 Nano Banana)类型上无法预判,仍在运行时由 `AiTextClient`/「未找到文字标题」逐行暴露——这是固有限制。
|
- 边界:`api_type=chat` 但实际返回图片的模型(如 Nano Banana)类型上无法预判,仍在运行时由 `AiTextClient`/「未找到文字标题」逐行暴露——这是固有限制。
|
||||||
- **图片模型下拉与标题模型隔离**:图片生成的右栏 `AI 模型` 下拉在 `_fill_model_combo()` 填充时,应跳过 `name == title_model` 的模型;标题模型仍留在 `self._models` 里供 `_resolve_title_model_config()` 查找,不应从总模型列表删除。这样既保留标题生成能力,又避免默认 `GPT-5.5 文本` 出现在图片模型下拉里被误选。
|
- **图片模型下拉与标题模型隔离**:图片生成的右栏 `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 示例可参照。
|
- **`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 界面与运行
|
### 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"` 不动,其它/未来调用方不受影响。
|
- **改法**:`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` 覆盖(已支持,优先级高于分辨率档)。
|
- **为什么复用分辨率档而非新增字段**:纯文本请求本无分辨率,`resolution` 只是 `resolution_timeout` 那张 `{512/1K/2K/4K → 180/240/360/600}` 超时表的键;复用它零新增配置、与图片侧同一套超时语义,最省。若将来要独立可调,再在模型条目上用 `timeout_seconds>0` 覆盖(已支持,优先级高于分辨率档)。
|
||||||
- **代价**:慢/挂死的请求现在最长等 600 秒才失败(而非 240 秒),但标题生成是单次、手动触发、非批量循环,多等的是最坏情况,正常返回不受影响。
|
- **代价**:慢/挂死的请求现在最长等 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` 或支持关思考参数,则从源头无前导思考,比解析更干净(值得在中转文档确认;本次不依赖)。
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ _TITLE_CIRCLED = "①②③④⑤⑥⑦⑧⑨⑩"
|
|||||||
_TITLE_QUOTES = "\"'「」『』“”‘’"
|
_TITLE_QUOTES = "\"'「」『』“”‘’"
|
||||||
# Split titles on comma (half/full width) or newline (docs/11 §17.1 / §19.23).
|
# Split titles on comma (half/full width) or newline (docs/11 §17.1 / §19.23).
|
||||||
_TITLE_SPLIT = re.compile(r"[,,\r\n]+")
|
_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):
|
def build_text_payload(config, prompt, image_data_url=None):
|
||||||
@@ -130,17 +137,47 @@ def _clean_title_line(line):
|
|||||||
return stripped
|
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):
|
def _clean_titles(text):
|
||||||
"""Split raw text into a list of cleaned titles, on comma (or newline).
|
"""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);
|
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 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:
|
if not text:
|
||||||
return []
|
return []
|
||||||
out = []
|
out = []
|
||||||
for piece in _TITLE_SPLIT.split(str(text)):
|
for piece in _TITLE_SPLIT.split(_strip_title_preamble(text)):
|
||||||
cleaned = _clean_title_line(piece)
|
cleaned = _clean_title_line(piece)
|
||||||
if cleaned:
|
if cleaned:
|
||||||
out.append(cleaned)
|
out.append(cleaned)
|
||||||
|
|||||||
@@ -49,8 +49,10 @@ DEFAULT_OUTFIT_PROMPT = (
|
|||||||
DEFAULT_TITLE_PROMPT = (
|
DEFAULT_TITLE_PROMPT = (
|
||||||
"请生成 10 条适合台湾蝦皮电商的中文女装商品标题,突出卖点与适穿场景,"
|
"请生成 10 条适合台湾蝦皮电商的中文女装商品标题,突出卖点与适穿场景,"
|
||||||
"每条控制在 30 字以内。"
|
"每条控制在 30 字以内。"
|
||||||
"标题之间用逗号「,」分隔,只输出标题本身,"
|
"如果你需要思考或说明,请全部写在最前面;思考完毕后单独一行输出 ===TITLES===,"
|
||||||
"不要使用 Markdown 表格、不要换行、不要序号/編號/字元數、不要引号或表情。"
|
"其后只放所有标题、标题之间用逗号「,」分隔。"
|
||||||
|
"===TITLES=== 之后不要出现任何非标题文字,"
|
||||||
|
"不要使用 Markdown 表格、不要序号/編號/字元數、不要引号或表情。"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1563,3 +1563,26 @@
|
|||||||
- [x] 不动 `generate_texts`/`generate_text` 默认 `resolution="1K"`;模型条目 `timeout_seconds>0` 仍优先覆盖(`_post` 逻辑不变)
|
- [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] 测试:`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 模板失败属并行历史遗留,无关)
|
- [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`
|
||||||
@@ -79,6 +79,52 @@ class TestExtractText(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(extract_titles_from_response({"choices": []}), [])
|
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):
|
class TestBuildTextPayload(unittest.TestCase):
|
||||||
def test_chat_with_image_includes_image_url(self):
|
def test_chat_with_image_includes_image_url(self):
|
||||||
|
|||||||
Reference in New Issue
Block a user