Files
cmbot/tests/test_ai_text_service.py
T
adminandClaude Opus 4.8 a413aa6f15 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>
2026-07-04 15:50:48 +08:00

236 lines
11 KiB
Python

"""Tests for the AI text (title) service (docs/11 §17)."""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
CHAT_CFG = {"url": "https://relay.example.com/v1/chat/completions",
"model": "gpt-4o", "api_key": "sk-x", "api_type": "chat"}
GEMINI_CFG = {"url": "https://relay.example.com/v1beta/models/{model}:generateContent",
"model": "gemini-2.5-flash", "api_key": "sk-x", "api_type": "gemini"}
IMAGES_EDITS_CFG = {"url": "https://relay.example.com/v1/images/edits",
"model": "img", "api_key": "sk-x", "api_type": "images_edits"}
class TestExtractText(unittest.TestCase):
def test_chat_string_content(self):
from services.ai_text_service import extract_text_from_response
data = {"choices": [{"message": {"content": "纯棉宽松短袖T恤"}}]}
self.assertEqual(extract_text_from_response(data), "纯棉宽松短袖T恤")
def test_chat_list_content(self):
from services.ai_text_service import extract_text_from_response
data = {"choices": [{"message": {"content": [
{"type": "text", "text": "印花连衣裙"}]}}]}
self.assertEqual(extract_text_from_response(data), "印花连衣裙")
def test_gemini_parts(self):
from services.ai_text_service import extract_text_from_response
data = {"candidates": [{"content": {"parts": [{"text": "碎花雪纺衫"}]}}]}
self.assertEqual(extract_text_from_response(data), "碎花雪纺衫")
def test_multiline_takes_first_and_strips_numbering_quotes(self):
from services.ai_text_service import extract_text_from_response
data = {"choices": [{"message": {"content":
"1. 「韩版宽松卫衣」\n2. 复古工装外套\n3. 简约百搭T恤"}}]}
self.assertEqual(extract_text_from_response(data), "韩版宽松卫衣")
def test_missing_text_returns_empty(self):
from services.ai_text_service import extract_text_from_response
self.assertEqual(extract_text_from_response({"choices": []}), "")
self.assertEqual(extract_text_from_response({}), "")
def test_extract_titles_comma_separated(self):
"""§19.23: 逗号分隔(全角/半角)→ 多条,逐段去空白。"""
from services.ai_text_service import extract_titles_from_response
full = {"choices": [{"message": {"content": "甲款,乙款,丙款,丁款"}}]}
self.assertEqual(extract_titles_from_response(full),
["甲款", "乙款", "丙款", "丁款"])
half = {"choices": [{"message": {"content": "甲款, 乙款 ,丙款"}}]}
self.assertEqual(extract_titles_from_response(half), ["甲款", "乙款", "丙款"])
def test_extract_titles_mixed_comma_newline_and_cleaning(self):
"""§19.23: 逗号 + 换行混用都拆开;逐段去序号/引号。"""
from services.ai_text_service import extract_titles_from_response
data = {"choices": [{"message": {"content":
"1. 「韩版宽松卫衣」,2) 复古工装外套\n- 简约百搭T恤"}}]}
self.assertEqual(
extract_titles_from_response(data),
["韩版宽松卫衣", "复古工装外套", "简约百搭T恤"])
def test_extract_titles_markdown_table_not_exploded(self):
"""§19.23: 表头单行无逗号 → 至多 1 条,不再炸成表头/分隔/多行垃圾。"""
from services.ai_text_service import extract_titles_from_response
data = {"choices": [{"message": {"content": "| 編號 | 標題 | 字元數估算 |"}}]}
self.assertEqual(len(extract_titles_from_response(data)), 1)
def test_extract_titles_empty_when_no_text(self):
from services.ai_text_service import extract_titles_from_response
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):
from services.ai_text_service import build_text_payload
payload = build_text_payload(CHAT_CFG, "写标题", "data:image/png;base64,AAA")
content = payload["messages"][0]["content"]
types = [part["type"] for part in content]
self.assertEqual(types, ["text", "image_url"])
self.assertEqual(content[1]["image_url"]["url"], "data:image/png;base64,AAA")
def test_chat_without_image_text_only(self):
from services.ai_text_service import build_text_payload
payload = build_text_payload(CHAT_CFG, "写标题")
content = payload["messages"][0]["content"]
self.assertEqual([p["type"] for p in content], ["text"])
def test_gemini_text_only_modality(self):
from services.ai_text_service import build_text_payload
payload = build_text_payload(GEMINI_CFG, "写标题",
"data:image/jpeg;base64,BBB")
self.assertEqual(
payload["generationConfig"]["responseModalities"], ["TEXT"])
parts = payload["contents"][0]["parts"]
self.assertIn("inlineData", parts[1])
def test_images_edits_rejected(self):
from services.ai_text_service import AiTextServiceError, build_text_payload
with self.assertRaises(AiTextServiceError):
build_text_payload(IMAGES_EDITS_CFG, "写标题")
class _FakeResponse:
def __init__(self, payload):
self._payload = payload
def raise_for_status(self):
pass
def json(self):
return self._payload
class _FakeSession:
def __init__(self, payload):
self._payload = payload
self.posted = None
def post(self, url, headers=None, json=None, timeout=None):
self.posted = {"url": url, "headers": headers, "json": json}
return _FakeResponse(self._payload)
class TestGenerateText(unittest.TestCase):
def test_generate_text_posts_and_returns_title(self):
from services.ai_text_service import AiTextClient
session = _FakeSession({"choices": [{"message": {"content": "夏季冰丝防晒衣"}}]})
client = AiTextClient(CHAT_CFG, session=session)
# No image_path -> text-only payload; still returns the parsed title.
title = client.generate_text("给这件衣服起标题")
self.assertEqual(title, "夏季冰丝防晒衣")
self.assertIn("chat/completions", session.posted["url"])
self.assertEqual(session.posted["headers"]["Authorization"], "Bearer sk-x")
def test_generate_text_images_edits_raises(self):
from services.ai_text_service import AiTextClient, AiTextServiceError
client = AiTextClient(IMAGES_EDITS_CFG, session=_FakeSession({}))
with self.assertRaises(AiTextServiceError):
client.generate_text("标题")
def test_generate_text_empty_response_raises(self):
from services.ai_text_service import AiTextClient, AiTextServiceError
client = AiTextClient(CHAT_CFG, session=_FakeSession({"choices": []}))
with self.assertRaises(AiTextServiceError):
client.generate_text("标题")
def test_generate_texts_returns_all_titles_text_only(self):
"""§17.1: 一次请求 → 多条;纯文本(payload 不含 image_url)。"""
from services.ai_text_service import AiTextClient
session = _FakeSession({"choices": [{"message": {"content":
"标题一\n标题二\n标题三"}}]})
client = AiTextClient(CHAT_CFG, session=session)
titles = client.generate_texts("生成 3 条标题")
self.assertEqual(titles, ["标题一", "标题二", "标题三"])
content = session.posted["json"]["messages"][0]["content"]
self.assertEqual([p["type"] for p in content], ["text"]) # 无图
def test_generate_texts_empty_raises(self):
from services.ai_text_service import AiTextClient, AiTextServiceError
client = AiTextClient(CHAT_CFG, session=_FakeSession({"choices": []}))
with self.assertRaises(AiTextServiceError):
client.generate_texts("标题")
if __name__ == "__main__":
unittest.main()