refactor(ai-outfit): 标题生成改为「一次请求·纯提示词·多条按序填」 (§19.22)
把标题生成从「逐行看图、各生成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>
This commit is contained in:
@@ -47,6 +47,21 @@ class TestExtractText(unittest.TestCase):
|
||||
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):
|
||||
@@ -131,6 +146,27 @@ class TestGenerateText(unittest.TestCase):
|
||||
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()
|
||||
|
||||
+20
-83
@@ -1,106 +1,43 @@
|
||||
"""Tests for AI title generation core (docs/11 §17)."""
|
||||
import shutil
|
||||
"""Tests for AI title batch generation core (docs/11 §17.1 / §17.7)."""
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
|
||||
|
||||
class TestAiTitleCore(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = Path(tempfile.mkdtemp())
|
||||
def test_generate_titles_one_request_returns_list(self):
|
||||
from core.ai_title import generate_titles
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(str(self.tmp), ignore_errors=True)
|
||||
client = _RecordingTextClient(["纯棉短袖T恤", "印花连衣裙", "碎花雪纺衫"])
|
||||
titles = generate_titles("生成 3 条标题", model_config={}, api_client=client)
|
||||
|
||||
def _make_image(self, path):
|
||||
Image.new("RGB", (32, 32), (120, 80, 40)).save(str(path), format="PNG")
|
||||
self.assertEqual(titles, ["纯棉短袖T恤", "印花连衣裙", "碎花雪纺衫"])
|
||||
self.assertEqual(client.calls, 1) # 只请求一次
|
||||
self.assertEqual(client.image_paths, [None]) # 纯文本,不传图
|
||||
|
||||
def _task(self, garment_path, title="占位标题", product_id=""):
|
||||
from core.models import OutfitTask
|
||||
def test_generate_titles_propagates_client_error(self):
|
||||
from core.ai_title import generate_titles
|
||||
|
||||
return OutfitTask(row_index=2, title=title, product_id=product_id,
|
||||
garment_path=str(garment_path))
|
||||
|
||||
def test_render_title_prompt_replaces_placeholders(self):
|
||||
from core.ai_title import render_title_prompt
|
||||
|
||||
task = self._task("g.png", title="旧标题", product_id="TY001")
|
||||
out = render_title_prompt("参考 {title} / {product_id}", task)
|
||||
|
||||
self.assertEqual(out, "参考 旧标题 / TY001")
|
||||
|
||||
def test_generate_title_single_file_success(self):
|
||||
from core.ai_title import generate_title
|
||||
|
||||
garment = self.tmp / "shirt.png"
|
||||
self._make_image(garment)
|
||||
client = _RecordingTextClient("纯棉短袖T恤")
|
||||
|
||||
result = generate_title(self._task(garment), "起个标题 {title}",
|
||||
model_config={}, api_client=client)
|
||||
|
||||
self.assertTrue(result.success, result.error)
|
||||
self.assertEqual(result.generated_title, "纯棉短袖T恤")
|
||||
self.assertEqual(client.image_paths, [str(garment)])
|
||||
|
||||
def test_generate_title_directory_uses_first_image(self):
|
||||
from core.ai_title import generate_title
|
||||
|
||||
d = self.tmp / "FG201"
|
||||
d.mkdir()
|
||||
for name in ("b.png", "a.png", "c.png"):
|
||||
self._make_image(d / name)
|
||||
client = _RecordingTextClient("印花连衣裙")
|
||||
|
||||
result = generate_title(self._task(str(d) + "/"), "话术",
|
||||
model_config={}, api_client=client)
|
||||
|
||||
self.assertTrue(result.success, result.error)
|
||||
# Sorted: a.png is the first reference image.
|
||||
self.assertEqual(client.image_paths, [str(d / "a.png")])
|
||||
|
||||
def test_generate_title_empty_directory_fails(self):
|
||||
from core.ai_title import generate_title
|
||||
|
||||
d = self.tmp / "empty"
|
||||
d.mkdir()
|
||||
|
||||
result = generate_title(self._task(str(d) + "/"), "话术",
|
||||
model_config={}, api_client=_RecordingTextClient("x"))
|
||||
|
||||
self.assertFalse(result.success)
|
||||
self.assertIn("没有图片", result.error)
|
||||
|
||||
def test_generate_title_client_error_aggregated(self):
|
||||
from core.ai_title import generate_title
|
||||
|
||||
garment = self.tmp / "shirt.png"
|
||||
self._make_image(garment)
|
||||
|
||||
result = generate_title(self._task(garment), "话术",
|
||||
model_config={}, api_client=_FailingTextClient())
|
||||
|
||||
self.assertFalse(result.success)
|
||||
self.assertIn("boom", result.error)
|
||||
with self.assertRaises(RuntimeError):
|
||||
generate_titles("x", model_config={}, api_client=_FailingTextClient())
|
||||
|
||||
|
||||
class _RecordingTextClient:
|
||||
def __init__(self, title):
|
||||
self._title = title
|
||||
def __init__(self, titles):
|
||||
self._titles = titles
|
||||
self.calls = 0
|
||||
self.image_paths = []
|
||||
|
||||
def generate_text(self, prompt, image_path=None, resolution="1K"):
|
||||
self.image_paths.append(str(image_path))
|
||||
return self._title
|
||||
def generate_texts(self, prompt, image_path=None, resolution="1K"):
|
||||
self.calls += 1
|
||||
self.image_paths.append(image_path)
|
||||
return list(self._titles)
|
||||
|
||||
|
||||
class _FailingTextClient:
|
||||
def generate_text(self, prompt, image_path=None, resolution="1K"):
|
||||
def generate_texts(self, prompt, image_path=None, resolution="1K"):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user