推理型文本模型即便提示词要求「只返回逗号连接的标题」,仍常在标题前先输出 一段思考散文;该散文自身带逗号,直接逗号切分会切出假标题混进结果。切分前 先剥离前导思考(_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>
236 lines
11 KiB
Python
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()
|