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:
2026-06-23 15:59:37 +08:00
co-authored by Claude Opus 4.8
parent 52bc6c0c50
commit e7fab724fc
8 changed files with 216 additions and 194 deletions
+37 -29
View File
@@ -142,8 +142,8 @@ class _OutfitWorker(QObject):
class _TitleWorker(QObject):
"""Generates titles row-by-row on a QThread (docs/11 §17); queued signals.
Sequential by row: each row's garment image + the title prompt -> one title,
written back to that row's A column immediately. Failures are logged and skipped.
One prompt-only request returns many titles; they are written back to rows in
order (i-th title -> i-th row A). Count mismatch falls back per docs/11 §17.1.
"""
tasks_loaded = Signal(object) # List[OutfitTask]
@@ -152,23 +152,19 @@ class _TitleWorker(QObject):
finished = Signal(int, int) # success_count, fail_count
failed = Signal(str) # fatal pre-run error (e.g. Excel locked)
def __init__(self, excel_path, model_config, prompt, request_interval):
def __init__(self, excel_path, model_config, prompt):
super().__init__()
self._excel_path = excel_path
self._model_config = model_config
self._prompt = prompt
self._interval = float(request_interval or 0.0)
self._stop = False
def stop(self):
self._stop = True
def run(self):
import time
from core.ai_title import generate_title
from core.ai_title import generate_titles
from core.models import TitleResult
from services.ai_text_service import AiTextClient
from services.excel_service import (
ensure_excel_writable,
read_all_rows,
@@ -178,7 +174,6 @@ class _TitleWorker(QObject):
try:
ensure_excel_writable(self._excel_path)
rows = read_all_rows(self._excel_path)
client = AiTextClient(self._model_config) # one client for the run
except Exception as exc: # noqa: BLE001 - report to UI
self.failed.emit(str(exc))
return
@@ -189,32 +184,46 @@ class _TitleWorker(QObject):
self.finished.emit(0, 0)
return
success = fail = 0
for index, task in enumerate(rows, start=1):
# One prompt-only request for all titles (docs/11 §17.1/§17.7).
try:
titles = generate_titles(self._prompt, self._model_config)
except Exception as exc: # noqa: BLE001 - whole run fails
self.log.emit("标题生成请求失败:{}".format(exc))
for index, task in enumerate(rows, start=1):
self.progress.emit(index, total,
TitleResult(task=task, success=False, error="请求失败"))
self.finished.emit(0, total)
return
self.log.emit("一次请求返回 {} 条标题,共 {} 行".format(len(titles), total))
if len(titles) > total:
self.log.emit("返回条数多于行数,多出的 {} 条已忽略".format(len(titles) - total))
if len(titles) < total:
self.log.emit("返回条数少于行数,后 {} 行留空".format(total - len(titles)))
success = 0
for index, task in enumerate(rows):
if self._stop:
break
if index > 1 and self._interval > 0:
time.sleep(self._interval)
result = generate_title(task, self._prompt, self._model_config,
api_client=client)
if result.success:
if index < len(titles):
title = titles[index]
try:
write_title_result(self._excel_path, task.row_index,
result.generated_title)
write_title_result(self._excel_path, task.row_index, title)
success += 1
result = TitleResult(task=task, success=True,
generated_title=title, attempts=1)
self.log.emit("第 {} 行标题:{}".format(task.row_index, title))
except Exception as exc: # noqa: BLE001 - keep going
result = TitleResult(task=task, success=False,
error="写回失败:{}".format(exc), attempts=1)
if result.success:
success += 1
self.log.emit("第 {} 行标题:{}".format(
task.row_index, result.generated_title))
self.log.emit("第 {} 行写回失败:{}".format(task.row_index, exc))
else:
fail += 1
self.log.emit("第 {} 行标题失败:{}".format(
task.row_index, result.error))
self.progress.emit(index, total, result)
result = TitleResult(task=task, success=False,
error="未返回足够标题(仅 {} 条)".format(len(titles)),
attempts=1)
self.progress.emit(index + 1, total, result)
self.finished.emit(success, fail)
self.finished.emit(success, total - success)
class _OutfitPreviewDialog(QDialog):
@@ -949,8 +958,7 @@ class AiOutfitPanel(QWidget):
self._progress.setValue(0)
self._log.clear()
self._title_worker = _TitleWorker(
excel, model_config, prompt, self._interval.value())
self._title_worker = _TitleWorker(excel, model_config, prompt)
self._title_thread = QThread(self)
self._title_worker.moveToThread(self._title_thread)
self._title_thread.started.connect(self._title_worker.run)
+13 -39
View File
@@ -1,49 +1,23 @@
"""AI 标题生成单行编排(docs/11 §17)。
"""AI 标题批量生成(docs/11 §17.1 / §17.7)。
看该行衣服图(目录行取首图)+ 用户标题提示词 → 调 AI 文本服务生成电商标题。
返回 TitleResult;never raises(异常聚合进结果)。标题写回 Excel A 列由调用方做。
一次请求、纯提示词(不传图)→ 文本模型返回多条电商标题。数量由用户写进提示词。
返回标题列表;调用方负责按序写回 Excel A 列与兜底(条数与行数对不上)。
"""
import logging
from core.ai_outfit import list_directory_images, looks_like_directory
from core.models import OutfitTask, TitleResult
from services.ai_text_service import AiTextClient
logger = logging.getLogger(__name__)
def render_title_prompt(template, task):
"""Replace {title}/{product_id} in the title prompt (no image-output tail)."""
return str(template).replace("{title}", task.title).replace(
"{product_id}", task.product_id)
def generate_titles(prompt, model_config, api_client=None):
"""Generate a list of titles in ONE prompt-only request (docs/11 §17.1).
def _reference_image(garment_path):
"""Pick the vision reference: the file itself, or a directory's first image."""
if looks_like_directory(garment_path):
images = list_directory_images(garment_path)
return str(images[0]) if images else None
return garment_path
def generate_title(task, prompt_template, model_config, api_client=None):
"""Generate one title for an Excel row and return TitleResult. Never raises."""
if not isinstance(task, OutfitTask):
raise TypeError("task must be OutfitTask")
try:
image_path = _reference_image(task.garment_path)
if not image_path:
return TitleResult(task=task, success=False, attempts=1,
error="目录内没有图片:{}".format(task.garment_path))
prompt = render_title_prompt(prompt_template, task)
client = api_client or AiTextClient(model_config)
title = client.generate_text(prompt, image_path)
if not title:
return TitleResult(task=task, success=False, attempts=1,
error="AI 未返回标题")
logger.info("Generated title row %s -> %s", task.row_index, title)
return TitleResult(task=task, success=True, generated_title=title, attempts=1)
except Exception as exc: # noqa: BLE001 - aggregate
logger.exception("Title generation failed for row %s", task.row_index)
return TitleResult(task=task, success=False, error=str(exc), attempts=1)
prompt is sent as-is (no image, no placeholder substitution). Raises on API
error (caller wraps); returns [] only if the model returned no usable text
(AiTextClient raises in that case, so a normal return is always non-empty).
"""
client = api_client or AiTextClient(model_config)
titles = client.generate_texts(prompt) # image_path=None -> text-only
logger.info("Generated %d title(s) in one request", len(titles))
return titles
+44 -22
View File
@@ -117,29 +117,41 @@ def _extract_raw_text(data):
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:
def _clean_title_line(line):
"""Clean one line into a title: drop list numbering/bullets and wrapping quotes."""
stripped = line.strip()
if not stripped:
return ""
stripped = _TITLE_LEAD.sub("", stripped)
stripped = stripped.lstrip(_TITLE_CIRCLED).strip()
stripped = stripped.strip(_TITLE_QUOTES).strip()
return stripped
def _clean_titles(text):
"""Split raw text into a list of cleaned titles (one per non-empty line)."""
if not text:
return []
out = []
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 ""
cleaned = _clean_title_line(line)
if cleaned:
out.append(cleaned)
return out
def extract_titles_from_response(data):
"""Return all clean titles (one per non-empty line) from an AI JSON response.
Used by 批量标题生成 (docs/11 §17.1): one request → many titles.
"""
return _clean_titles(_extract_raw_text(data))
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))
"""Return the first clean title from an AI JSON response ('' if none)."""
titles = extract_titles_from_response(data)
return titles[0] if titles else ""
class AiTextClient:
@@ -151,7 +163,9 @@ class AiTextClient:
if hasattr(self.session, "trust_env"):
self.session.trust_env = False
def generate_text(self, prompt, image_path=None, resolution="1K"):
def _post(self, prompt, image_path, resolution):
"""Send one chat/gemini request and return the parsed JSON. Shared by
generate_text / generate_texts."""
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):
@@ -178,8 +192,16 @@ class AiTextClient:
payload = build_text_payload(self.config, prompt, data_url)
response = self.session.post(url, headers=headers, json=payload, timeout=timeout)
response.raise_for_status()
return response.json()
title = extract_text_from_response(response.json())
if not title:
def generate_texts(self, prompt, image_path=None, resolution="1K"):
"""One request → list of cleaned titles (docs/11 §17.1). image_path=None
means a text-only request (标题批量生成不传图)."""
titles = extract_titles_from_response(self._post(prompt, image_path, resolution))
if not titles:
raise AiTextServiceError("AI 响应中未找到文字标题")
return title
return titles
def generate_text(self, prompt, image_path=None, resolution="1K"):
"""One request → the first cleaned title (= generate_texts[0])."""
return self.generate_texts(prompt, image_path, resolution)[0]
+5 -3
View File
@@ -44,10 +44,12 @@ DEFAULT_OUTFIT_PROMPT = (
"电商主图风格,不加文字与促销标签。"
)
# Default title prompt (docs/11 §17.3). Used by 标题生成 when title_prompt.txt absent.
# Default title prompt (docs/11 §17.3, batch style). Used when title_prompt.txt absent.
# 数量由用户改写(如「生成 10 条」);一次请求返回多条、按序回填各行 A(§17.1)。
DEFAULT_TITLE_PROMPT = (
"请根据这件女装的款式、版型、颜色与印花特点,生成一条适合台湾蝦皮电商的中文商品标题:"
"突出卖点与适穿场景,控制在 30 字以内。只输出标题本身一行,不要序号、引号、表情或促销词。"
"请生成 10 条适合台湾蝦皮电商的中文女装商品标题,每行一条,"
"突出卖点与适穿场景,每条控制在 30 字以内。"
"只输出标题本身、每行一条,不要序号、引号、表情或促销词。"
)