feat(ai-outfit): 左栏新增「标题生成」(看图→提示词→AI 文字标题→写回A列) (§19.18)
AI 穿搭页左栏新增独立的「生成标题」流程:用户写标题提示词,AI 看该行 衣服图(目录行取首图)生成电商标题,逐行立即写回 Excel A 列并刷新明细表; 完成后重载 Excel,紧接「开始生成」跑图即用新标题。移除原「最终生成要求预览」 腾出版面(docs/11 §17)。 - ai_text_service.py:AiTextClient 复用图像服务 HTTP 管道做文本输出; chat/gemini 带图视觉,images/images_edits 明确报错;extract_text 取首条标题 - ai_title.py + TitleResult:单行编排,never raises - excel_service.write_title_result:只写 A 列、不动 D/E/F - config_service:title_model 默认 + load/save_title_prompt + 默认标题话术 - 面板:标题生成组(提示词+标题模型下拉+保存+生成标题)置于话术组上方; _TitleWorker 顺序逐行+立即回填+刷新;与「开始生成」互斥;删预览相关组件 - 测试:文本解析/payload、generate_title(单文件/目录首图/失败)、 write_title_result、面板标题组存在且无预览;全套 py37 通过 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -73,6 +73,31 @@ class TestAiOutfitPanelDefaults(unittest.TestCase):
|
||||
|
||||
self.assertIn("图片并发数", labels)
|
||||
|
||||
def test_title_group_present_and_preview_removed(self):
|
||||
"""标题生成组存在,预览块已移除(docs/11 §17)。"""
|
||||
from PySide6.QtWidgets import QLabel
|
||||
|
||||
panel = self._panel()
|
||||
panel.apply_config(dict(DEFAULT_CONFIG))
|
||||
|
||||
labels = [label.text() for label in panel.findChildren(QLabel)]
|
||||
self.assertIn("标题生成提示词", labels)
|
||||
self.assertIn("标题模型", labels)
|
||||
self.assertNotIn("最终生成要求预览", labels)
|
||||
# The preview widgets and methods are gone.
|
||||
self.assertFalse(hasattr(panel, "_preview_view"))
|
||||
self.assertFalse(hasattr(panel, "_sample_combo"))
|
||||
self.assertTrue(hasattr(panel, "_title_btn"))
|
||||
self.assertTrue(hasattr(panel, "_title_model_combo"))
|
||||
|
||||
def test_title_prompt_defaults_loaded(self):
|
||||
from services.config_service import DEFAULT_TITLE_PROMPT
|
||||
|
||||
panel = self._panel()
|
||||
panel.apply_config(dict(DEFAULT_CONFIG))
|
||||
|
||||
self.assertEqual(panel._title_prompt_edit.toPlainText(), DEFAULT_TITLE_PROMPT)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"""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({}), "")
|
||||
|
||||
|
||||
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("标题")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Tests for AI title generation core (docs/11 §17)."""
|
||||
import shutil
|
||||
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 tearDown(self):
|
||||
shutil.rmtree(str(self.tmp), ignore_errors=True)
|
||||
|
||||
def _make_image(self, path):
|
||||
Image.new("RGB", (32, 32), (120, 80, 40)).save(str(path), format="PNG")
|
||||
|
||||
def _task(self, garment_path, title="占位标题", product_id=""):
|
||||
from core.models import OutfitTask
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class _RecordingTextClient:
|
||||
def __init__(self, title):
|
||||
self._title = title
|
||||
self.image_paths = []
|
||||
|
||||
def generate_text(self, prompt, image_path=None, resolution="1K"):
|
||||
self.image_paths.append(str(image_path))
|
||||
return self._title
|
||||
|
||||
|
||||
class _FailingTextClient:
|
||||
def generate_text(self, prompt, image_path=None, resolution="1K"):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -126,6 +126,22 @@ class TestOutfitExcelService(unittest.TestCase):
|
||||
self.assertEqual(tasks[0].product_id, "")
|
||||
self.assertEqual([r.row_index for r in read_all_rows(p)], [2])
|
||||
|
||||
def test_write_title_result_updates_a_only_and_saves(self):
|
||||
"""标题生成回填只改 A 列,不动 D/E/F(docs/11 §17)。"""
|
||||
from services.excel_service import write_title_result
|
||||
|
||||
# Row 3 starts as 完成 with D/E filled; writing a title must not touch them.
|
||||
write_title_result(self.excel_path, 3, "AI 生成的电商标题")
|
||||
|
||||
wb = load_workbook(str(self.excel_path))
|
||||
try:
|
||||
ws = wb.active
|
||||
self.assertEqual(ws.cell(3, 1).value, "AI 生成的电商标题") # A 改写
|
||||
self.assertEqual(ws.cell(3, 4).value, r"D:\out\ty002.jpg") # D 不动
|
||||
self.assertEqual(ws.cell(3, 5).value, "完成") # E 不动
|
||||
finally:
|
||||
wb.close()
|
||||
|
||||
def test_check_excel_writable_true_for_existing_workbook(self):
|
||||
from services.excel_service import check_excel_writable
|
||||
|
||||
|
||||
Reference in New Issue
Block a user