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:
@@ -0,0 +1,185 @@
|
||||
"""AI 文本服务(docs/11 §17):复用图像服务的 HTTP 管道,输出文字(标题)。
|
||||
|
||||
与 `ai_image_service.ImageApiClient` 平行:同一份 `AiModelConfig`、传图、鉴权、
|
||||
超时、session 全部复用,只把「解析图片」换成「解析文字」。仅 chat / gemini 这类
|
||||
能返回文字的接口可用;纯图片接口(images / images_edits)会明确报错。
|
||||
"""
|
||||
import logging
|
||||
import re
|
||||
|
||||
import requests
|
||||
|
||||
from services.ai_image_service import (
|
||||
API_CHAT,
|
||||
API_GEMINI,
|
||||
API_IMAGES,
|
||||
API_IMAGES_EDITS,
|
||||
_coerce_config,
|
||||
_split_data_url,
|
||||
detect_api_type,
|
||||
image_to_data_url,
|
||||
normalize_api_url,
|
||||
resolution_timeout,
|
||||
validate_api_config,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AiTextServiceError(RuntimeError):
|
||||
"""Raised when AI text (title) generation fails."""
|
||||
|
||||
|
||||
# Strip a leading list marker (1. / 2) / - / • / ①…) from a title line.
|
||||
_TITLE_LEAD = re.compile(r"^\s*(?:\d+\s*[\.\)、::]|[-*•])\s*")
|
||||
_TITLE_CIRCLED = "①②③④⑤⑥⑦⑧⑨⑩"
|
||||
_TITLE_QUOTES = "\"'「」『』“”‘’"
|
||||
|
||||
|
||||
def build_text_payload(config, prompt, image_data_url=None):
|
||||
"""Build a JSON body that asks a chat/gemini model for TEXT output.
|
||||
|
||||
image_data_url optional: when given, the garment image is sent as a vision
|
||||
reference (docs/11 §17 看图生成). images/images_edits are text-incapable.
|
||||
"""
|
||||
cfg = _coerce_config(config)
|
||||
api_type = detect_api_type(cfg.url, cfg.api_type)
|
||||
|
||||
if api_type == API_CHAT:
|
||||
content = [{"type": "text", "text": prompt}]
|
||||
if image_data_url:
|
||||
content.append({"type": "image_url", "image_url": {"url": image_data_url}})
|
||||
payload = {
|
||||
"model": cfg.model,
|
||||
"messages": [{"role": "user", "content": content}],
|
||||
"stream": False,
|
||||
}
|
||||
elif api_type == API_GEMINI:
|
||||
parts = [{"text": prompt}]
|
||||
if image_data_url:
|
||||
mime_type, data = _split_data_url(image_data_url)
|
||||
parts.append({"inlineData": {"mimeType": mime_type, "data": data}})
|
||||
payload = {
|
||||
"contents": [{"parts": parts}],
|
||||
"generationConfig": {"responseModalities": ["TEXT"]},
|
||||
}
|
||||
else:
|
||||
raise AiTextServiceError(
|
||||
"该模型是图片接口({}),不能生成文字标题,请改选能返回文字的模型"
|
||||
"(chat 或 gemini)".format(api_type))
|
||||
|
||||
payload.update(cfg.extra_body)
|
||||
return payload
|
||||
|
||||
|
||||
def _content_to_text(content):
|
||||
"""Flatten an OpenAI chat message 'content' (str or parts list) to text."""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
texts = []
|
||||
for part in content:
|
||||
if isinstance(part, dict) and isinstance(part.get("text"), str):
|
||||
texts.append(part["text"])
|
||||
elif isinstance(part, str):
|
||||
texts.append(part)
|
||||
return "\n".join(texts)
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_raw_text(data):
|
||||
"""Pull the model's text out of a chat or gemini JSON response."""
|
||||
if not isinstance(data, dict):
|
||||
return ""
|
||||
|
||||
choices = data.get("choices")
|
||||
if isinstance(choices, list) and choices and isinstance(choices[0], dict):
|
||||
message = choices[0].get("message")
|
||||
if isinstance(message, dict):
|
||||
text = _content_to_text(message.get("content"))
|
||||
if text.strip():
|
||||
return text
|
||||
# Some relays use the legacy completion shape choices[0].text.
|
||||
legacy = choices[0].get("text")
|
||||
if isinstance(legacy, str) and legacy.strip():
|
||||
return legacy
|
||||
|
||||
candidates = data.get("candidates")
|
||||
if isinstance(candidates, list) and candidates and isinstance(candidates[0], dict):
|
||||
content = candidates[0].get("content")
|
||||
if isinstance(content, dict) and isinstance(content.get("parts"), list):
|
||||
texts = [p.get("text") for p in content["parts"]
|
||||
if isinstance(p, dict) and isinstance(p.get("text"), str)]
|
||||
joined = "\n".join(t for t in texts if t)
|
||||
if joined.strip():
|
||||
return joined
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def _clean_title(text):
|
||||
"""Return the first non-empty line as a single clean title.
|
||||
|
||||
Drops list numbering/bullets and wrapping quotes; even if the prompt asked
|
||||
for several titles, only the first is used (docs/11 §17.1).
|
||||
"""
|
||||
if not text:
|
||||
return ""
|
||||
for line in str(text).splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
stripped = _TITLE_LEAD.sub("", stripped)
|
||||
stripped = stripped.lstrip(_TITLE_CIRCLED).strip()
|
||||
stripped = stripped.strip(_TITLE_QUOTES).strip()
|
||||
if stripped:
|
||||
return stripped
|
||||
return ""
|
||||
|
||||
|
||||
def extract_text_from_response(data):
|
||||
"""Return the first clean title text from an AI JSON response ('' if none)."""
|
||||
return _clean_title(_extract_raw_text(data))
|
||||
|
||||
|
||||
class AiTextClient:
|
||||
"""HTTP client for AI text (title) generation; mirrors ImageApiClient."""
|
||||
|
||||
def __init__(self, config, session=None):
|
||||
self.config = _coerce_config(config)
|
||||
self.session = session or requests.Session()
|
||||
if hasattr(self.session, "trust_env"):
|
||||
self.session.trust_env = False
|
||||
|
||||
def generate_text(self, prompt, image_path=None, resolution="1K"):
|
||||
validate_api_config(self.config)
|
||||
api_type = detect_api_type(self.config.url, self.config.api_type)
|
||||
if api_type in (API_IMAGES, API_IMAGES_EDITS):
|
||||
raise AiTextServiceError(
|
||||
"该模型是图片接口({}),不能生成文字标题,请改选能返回文字的模型"
|
||||
"(chat 或 gemini)".format(api_type))
|
||||
|
||||
url = normalize_api_url(self.config.url, api_type)
|
||||
if api_type == API_GEMINI:
|
||||
url = url.replace("{model}", self.config.model)
|
||||
|
||||
headers = {
|
||||
"Authorization": "Bearer {}".format(self.config.api_key),
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
read_timeout = (
|
||||
self.config.timeout_seconds
|
||||
if self.config.timeout_seconds > 0
|
||||
else resolution_timeout(resolution)
|
||||
)
|
||||
timeout = (self.config.connect_timeout_seconds, read_timeout)
|
||||
|
||||
data_url = image_to_data_url(image_path) if image_path else None
|
||||
payload = build_text_payload(self.config, prompt, data_url)
|
||||
response = self.session.post(url, headers=headers, json=payload, timeout=timeout)
|
||||
response.raise_for_status()
|
||||
|
||||
title = extract_text_from_response(response.json())
|
||||
if not title:
|
||||
raise AiTextServiceError("AI 响应中未找到文字标题")
|
||||
return title
|
||||
@@ -27,12 +27,14 @@ DEFAULT_CONFIG = {
|
||||
"outfit_quality": "均衡",
|
||||
"outfit_retry_failed": False,
|
||||
"outfit_prompt_name": "默认", # last-selected 话术模板 name (docs/11 §7.2)
|
||||
"title_model": "", # last-selected 标题模型 name (docs/11 §17.3)
|
||||
}
|
||||
|
||||
_CONFIG_FILENAME = "app_config.json"
|
||||
_AI_MODELS_FILENAME = "ai_models.json"
|
||||
_OUTFIT_PROMPT_FILENAME = "outfit_prompt.txt" # legacy single prompt (migrated)
|
||||
_OUTFIT_PROMPTS_FILENAME = "outfit_prompts.json" # multi named templates (§7.2)
|
||||
_TITLE_PROMPT_FILENAME = "title_prompt.txt" # single 标题生成提示词 (§17.3)
|
||||
DEFAULT_OUTFIT_PROMPT_NAME = "默认"
|
||||
|
||||
# Default outfit prompt (docs/11 §7). Seeded into outfit_prompts.json on first run.
|
||||
@@ -42,6 +44,12 @@ DEFAULT_OUTFIT_PROMPT = (
|
||||
"电商主图风格,不加文字与促销标签。"
|
||||
)
|
||||
|
||||
# Default title prompt (docs/11 §17.3). Used by 标题生成 when title_prompt.txt absent.
|
||||
DEFAULT_TITLE_PROMPT = (
|
||||
"请根据这件女装的款式、版型、颜色与印花特点,生成一条适合台湾蝦皮电商的中文商品标题:"
|
||||
"突出卖点与适穿场景,控制在 30 字以内。只输出标题本身一行,不要序号、引号、表情或促销词。"
|
||||
)
|
||||
|
||||
|
||||
def load_config():
|
||||
"""
|
||||
@@ -173,6 +181,34 @@ def save_outfit_prompt(text):
|
||||
logger.error("Failed to save outfit prompt to %s: %s", prompt_file, exc)
|
||||
|
||||
|
||||
def load_title_prompt():
|
||||
"""Return the saved 标题生成提示词, or the built-in default (docs/11 §17.3)."""
|
||||
from services.file_service import get_config_path
|
||||
prompt_file = get_config_path(_TITLE_PROMPT_FILENAME)
|
||||
if not prompt_file.exists():
|
||||
return DEFAULT_TITLE_PROMPT
|
||||
try:
|
||||
with open(str(prompt_file), encoding="utf-8-sig") as f:
|
||||
text = f.read()
|
||||
return text if text.strip() else DEFAULT_TITLE_PROMPT
|
||||
except OSError as exc:
|
||||
logger.warning("Title prompt unreadable (%s): %s", exc, prompt_file)
|
||||
return DEFAULT_TITLE_PROMPT
|
||||
|
||||
|
||||
def save_title_prompt(text):
|
||||
"""Persist the 标题生成提示词 (utf-8, no BOM). Does not raise."""
|
||||
from services.file_service import get_config_path
|
||||
prompt_file = get_config_path(_TITLE_PROMPT_FILENAME)
|
||||
try:
|
||||
prompt_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(str(prompt_file), "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
logger.info("Title prompt saved to %s", prompt_file)
|
||||
except OSError as exc:
|
||||
logger.error("Failed to save title prompt to %s: %s", prompt_file, exc)
|
||||
|
||||
|
||||
def _normalize_prompts(data):
|
||||
"""Keep only valid {name, text} entries (non-empty name, string text)."""
|
||||
if not isinstance(data, list):
|
||||
|
||||
@@ -173,6 +173,22 @@ def write_outfit_source_excel(excel_path, rows):
|
||||
logger.info("Wrote outfit source Excel: %s (%d rows)", excel_path, len(rows))
|
||||
|
||||
|
||||
def write_title_result(excel_path, row_index, title):
|
||||
"""Write a generated title into column A (标题) and save (docs/11 §17).
|
||||
|
||||
Only touches the title cell; D/E/F (image-generation status) are untouched.
|
||||
"""
|
||||
path = Path(excel_path)
|
||||
workbook = load_workbook(str(path))
|
||||
try:
|
||||
sheet = workbook.worksheets[0]
|
||||
sheet.cell(row_index, COL_TITLE).value = title
|
||||
workbook.save(str(path))
|
||||
logger.info("Outfit row %s title written: %s", row_index, title)
|
||||
finally:
|
||||
workbook.close()
|
||||
|
||||
|
||||
def write_outfit_result(excel_path, result):
|
||||
"""Write one outfit result to columns D/E/F and save immediately."""
|
||||
if not isinstance(result, OutfitResult):
|
||||
|
||||
Reference in New Issue
Block a user