把标题生成从「逐行看图、各生成1条、每行一次请求」改为一次请求、纯提示词 (不传图)、生成多条、按序回填(数量由用户写进提示词,docs/11 §17.1/§17.7)。 - ai_text_service:新增 extract_titles_from_response(多行→多条、逐行去 序号/引号、丢空)+ AiTextClient.generate_texts(一次POST返回多条,与 generate_text 共用 _post);generate_text/extract_text 改为取首条 - ai_title:generate_titles(一次请求、image_path=None 纯文本);移除 generate_title/_reference_image/render_title_prompt(不再逐行看图/替换占位符) - config_service:DEFAULT_TITLE_PROMPT 改批量风格(生成多条、每行一条) - 面板 _TitleWorker:一次 generate_titles → 按序 write_title_result 回填; N>行数多的丢+日志、N<行数后面行留空+日志;请求异常→日志+成功0;去掉行间节流 - _start_title 不再传 request_interval 测试:extract_titles 多行→多条/去序号引号、generate_texts 多条且无图、 generate_titles 一次请求/异常。全套 py37 通过(test_config_service 的 packaging 模板失败属并行 §19.13,与本改动无关)。离屏冒烟:3/3 精确、 5/3 丢弃、2/3 留空 三种分发均按序回填 Excel A + 日志正确。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
173 lines
7.0 KiB
Python
173 lines
7.0 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_multiline_to_list_cleaned(self):
|
|
"""§17.1: 多行 → 多条,逐行去序号/引号、丢空行。"""
|
|
from services.ai_text_service import extract_titles_from_response
|
|
|
|
data = {"choices": [{"message": {"content":
|
|
"1. 「韩版宽松卫衣」\n\n2) 复古工装外套\n- 简约百搭T恤\n "}}]}
|
|
self.assertEqual(
|
|
extract_titles_from_response(data),
|
|
["韩版宽松卫衣", "复古工装外套", "简约百搭T恤"])
|
|
|
|
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": []}), [])
|
|
|
|
|
|
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()
|