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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 字以内。"
|
||||
"只输出标题本身、每行一条,不要序号、引号、表情或促销词。"
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user