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:
+35
-17
@@ -420,24 +420,30 @@ Excel 行 → `OutfitTask` 列表的转换由 `excel_service` 完成;核心只
|
||||
|
||||
## 17. 标题生成(左栏独立功能)
|
||||
|
||||
「添加印花」批量导出生成的 Excel(`docs/02` §6.12),A 列「标题」只是占位的**印花名**,不是真正的电商标题。本功能在 AI 穿搭页**左栏**新增一个**独立的「生成标题」**流程:用户写标题提示词,AI **看该行衣服图**(视觉)生成电商标题,**写回 Excel A 列**,再点「开始生成」跑图时图片提示词的 `{title}` 即用上新标题。与「开始生成」(图片)**互不绑定**、各自一个按钮。
|
||||
「添加印花」批量导出生成的 Excel(`docs/02` §6.12),A 列「标题」只是占位的**印花名**,不是真正的电商标题。本功能在 AI 穿搭页**左栏**新增一个**独立的「生成标题」**流程:用户写标题提示词(含数量),**一次请求、纯提示词(不传图)生成多条电商标题**,**按序回填 Excel A 列**(第 i 条 → 第 i 行),再点「开始生成」跑图时图片提示词的 `{title}` 即用上新标题。与「开始生成」(图片)**互不绑定**、各自一个按钮。
|
||||
|
||||
### 17.1 数据流与回填语义
|
||||
### 17.1 数据流与回填语义(一次请求 · 纯提示词 · 多条 · 按序填行)
|
||||
|
||||
- **行来源**:复用 `excel_service.read_all_rows(excel)`(状态无关)——对全部有效行生成、**覆盖式**写 A,不看 E 列状态、不引入新列(保持与 `标题生成产品图.xlsx` 七列一致)。
|
||||
- **逐行看图、各生成 1 条、顺序回填**:按行顺序处理,第 i 行看第 i 行衣服图 → 生成 1 条标题 → **立即**写回第 i 行 A(`write_title_result(excel, row_index, title)`)→ 刷新中栏「处理明细」表该行「标题」列。即「第 n 条标题 → 第 n 行 A,顺序回填 + GUI 实时刷新」。
|
||||
- **目录行取首图**:C 列为图片目录时(印花 Excel 即如此),标题写回**一个 A 单元格**(整行一个标题),故只取 `ai_outfit.list_directory_images(dir)` 的**第一张**作视觉参考;标题提示词宜写成概括款式/印花的通用句式。
|
||||
- **条数对齐**:因逐行各生成 1 条,标题数永远 = 行数,不存在「AI 返回条数和行数对不上」的兜底问题。提示词即使写「生成 N 条」,每行也**只取第一条**(解析时取首个非空行、去行首序号/符号与首尾引号、单行化)。
|
||||
- **"覆盖图片组件的标题" = 写回 A + 重载**:图片生成在「开始生成」时当场 `load_outfit_tasks(excel)` 读 A 列,故标题写回 A 后无需另改图片组件;标题全部生成完**重新加载一次 Excel**,刷新界面与内存任务,紧接着「开始生成」即用新标题。
|
||||
> **§17.7 改版**:早期为「逐行看图、各生成 1 条」(每行一次请求、带图)。现改为
|
||||
> **一次请求、纯提示词(不传图)、生成多条、按序回填**(用户在提示词里自写数量)。
|
||||
|
||||
- **行来源**:复用 `excel_service.read_all_rows(excel)`(状态无关)——拿到全部有效行(仅为「填到哪些行 + 行号」),**覆盖式**写 A,不看 E 列状态、不引入新列。
|
||||
- **一次请求、纯提示词**:把用户标题提示词**原样**(不带图、不替换 `{title}`)发一次给文本模型;提示词里由用户自写数量(如「生成 6 条…每行一条」)。
|
||||
- **解析多条**:从响应里把文本**按行**拆成多条,每条清洗(去行首序号/符号 `1. / - / ①`、去首尾引号、单行化、丢空行)→ 得到标题列表。
|
||||
- **按序回填**:第 i 条 → 第 i 行 A(`write_title_result(excel, row_index, title)`),逐条写、刷新中栏「处理明细」表该行「标题」列。
|
||||
- **条数对不上时兜底**:`N = len(标题)`,`R = len(行)`。`N > R` → 多出的 `N-R` 条**丢弃** + 日志提示;`N < R` → 后 `R-N` 行**留空** + 日志提示。不报错中止。
|
||||
- **生成完重载 Excel**:刷新界面与内存任务,紧接「开始生成」跑图即用新标题(图片生成当场 `load_outfit_tasks` 读 A 列)。
|
||||
|
||||
### 17.2 文本服务(复用图像服务的 HTTP 管道)
|
||||
|
||||
- 新增 `services/ai_text_service.py` 的 `AiTextClient(config, session=None)`:复用 `ai_image_service` 的 `AiModelConfig` / `image_to_data_url` / `detect_api_type` / `normalize_api_url` / Bearer 鉴权 / 超时 / session,不重写 HTTP。
|
||||
- `generate_text(prompt, image_path=None) -> str`:
|
||||
- `chat`:`messages=[{role:user, content:[{type:text,text:prompt}, {type:image_url,...}]}]`(带图=视觉)。
|
||||
- `gemini`:`contents[].parts=[{text},{inlineData}]`,`generationConfig.responseModalities=["TEXT"]`。
|
||||
- `images`/`images_edits`:纯图片接口,不能返回文字 → 抛 `AiTextServiceError`,提示「该模型不能生成文字,请改选文本/视觉模型」。
|
||||
- `extract_text_from_response(data)`:取 `choices[0].message.content`(str 或 content 列表的 text)/ gemini `candidates[0].content.parts[].text`;都取不到则抛错;返回**第一条标题**(见 §17.1 解析规则)。
|
||||
- `generate_texts(prompt, image_path=None) -> List[str]`(标题生成走此:`image_path=None` 纯文本):
|
||||
- `chat`:`messages=[{role:user, content:[{type:text,text:prompt}]}]`(无图时不含 `image_url`)。
|
||||
- `gemini`:`contents[].parts=[{text}]`,`generationConfig.responseModalities=["TEXT"]`。
|
||||
- `images`/`images_edits`:纯图片接口,不能返回文字 → 抛 `AiTextServiceError`。
|
||||
- 一次 POST → `extract_titles_from_response` 返回**多条**清洗后标题。
|
||||
- `generate_text(prompt, image_path=None) -> str`:保留(返回第一条,= `generate_texts` 的 `[0]`),供单条场景与既有单测;与 `generate_texts` 共用同一段 POST。
|
||||
- `extract_titles_from_response(data) -> List[str]`:取 `choices[0].message.content` / gemini `candidates[0].content.parts[].text` 的原始文本,**按行拆分 + 逐行清洗**(去序号/符号/引号、单行化、丢空),返回标题列表。`extract_text_from_response` = 取其首条(兼容保留)。
|
||||
|
||||
### 17.3 模型与提示词
|
||||
|
||||
@@ -449,12 +455,12 @@ Excel 行 → `OutfitTask` 列表的转换由 `excel_service` 完成;核心只
|
||||
- **命中的模型是纯图片接口(images/images_edits)时,开跑前就拦截(§19.21)**:`_find_title_model` 检出 `api_type ∈ {images, images_edits}` → 返回错误「『{名字}』是图片模型({api_type}),不能生成文字标题,请改选 chat/gemini 文本模型」→ `_resolve_title_model_config` 弹窗 + 中止,不逐行失败。
|
||||
- 边界:`api_type=chat` 但实际返回图片的模型(如 Nano Banana)类型上无法预判,仍在运行时由 `AiTextClient`/「未找到文字标题」逐行暴露——这是固有限制。
|
||||
- **`ai_models.json` 需有一条文本/视觉模型**(管理员维护,含真实 key,不入库):`api_type` 设 `chat`、`model` 填中转的真实 id(如 `gpt-5.5`)、`name` 与 `title_model` 一致(默认 `GPT-5.5 文本`)。`docs/ai_models.sample.json` 已含 chat 示例可参照。
|
||||
- **标题提示词**:单份,存 `~/.cmbot/config/title_prompt.txt`(`load_title_prompt`/`save_title_prompt`,仿旧式单份,标题侧不做多套模板);默认文案面向电商女装(结合款式/版型/颜色/印花,输出一行中文标题,不加引号/表情/促销词)。
|
||||
- **标题提示词**:单份,存 `~/.cmbot/config/title_prompt.txt`(`load_title_prompt`/`save_title_prompt`,仿旧式单份,标题侧不做多套模板);默认文案为**批量风格**——让模型生成**多条**电商女装标题、**每行一条**、不带序号/引号/表情,数量由用户在提示词里写(默认示例可写「生成 10 条」)。
|
||||
|
||||
### 17.4 界面与运行
|
||||
|
||||
- **左栏布局**(§10 已同步):「标题生成」组在上、「穿搭生成话术」组在下;预览块移除。标题组含:标题提示词编辑、「保存」、「生成标题」(**无标题模型下拉**,模型由 `title_model` 配置定名,§17.3)。
|
||||
- **运行**:`_TitleWorker(QObject)` 跑在 `QThread`(仿 `_OutfitWorker`),按行顺序逐行生成、立即回填、刷新该行 GUI;失败记日志、跳过该行、继续;用右栏「新请求间隔」做行间节流;温和停止。模型用 `_resolve_title_model_config()`(按 `title_model` 名字查 `ai_models.json`)。
|
||||
- **运行**:`_TitleWorker(QObject)` 跑在 `QThread`(仿 `_OutfitWorker`),**一次请求**(纯提示词,无图)拿到标题列表 → **按序逐条回填** `write_title_result` + 刷新该行 GUI;条数对不上按 §17.1 兜底(多丢、少留空、记日志);请求失败记日志并以「成功 0」收尾;温和停止。模型用 `_resolve_title_model_config()`(按 `title_model` 名字查 `ai_models.json`)。**只有一次请求,不再用「新请求间隔」做行间节流**。
|
||||
- **互斥**:「生成标题」与「开始生成」运行时互斥(避免同表并发写)。
|
||||
- 进度/结果走中栏「处理明细」表与右栏日志(不写 Excel 状态列)。
|
||||
|
||||
@@ -462,10 +468,11 @@ Excel 行 → `OutfitTask` 列表的转换由 `excel_service` 完成;核心只
|
||||
|
||||
- 左栏有「标题生成」组(提示词 + 保存 + 生成标题按钮),**无标题模型下拉**、无预览块。
|
||||
- `app_config.title_model` 指向的模型在 `ai_models.json` 存在 → 生成走该模型;改 `title_model` 名字即换模型,无需改代码。
|
||||
- 选印花生成的 Excel(A=印花名、C=目录),点「生成标题」→ 每行 A 被改写为 AI 标题、明细表标题列实时刷新、D/E/F 未动。
|
||||
- `title_model` 找不到对应模型 / 命中条目是纯图片接口(images/images_edits)时,**开跑前**弹窗报错并中止(不逐行失败,§19.21)。
|
||||
- 选印花生成的 Excel,点「生成标题」→ **一次请求**返回多条 → 按序回填各行 A、明细表标题列实时刷新、D/E/F 未动。
|
||||
- 条数对不上:N>行数多的丢弃 + 日志;N<行数后面行留空 + 日志(不报错中止)。
|
||||
- `title_model` 找不到对应模型 / 命中条目是纯图片接口(images/images_edits)时,**开跑前**弹窗报错并中止(§19.21)。
|
||||
- 生成完重载 Excel,紧接「开始生成」跑图用的是新标题。
|
||||
- 文本解析、`generate_title`(单文件/目录取首图/无图失败/客户端异常)、`write_title_result`(只改 A)、`_find_title_model`(命中/缺失/图片模型报错)单测通过,Python 3.7。
|
||||
- `extract_titles_from_response`(多行→多条、清洗)、`generate_titles`(一次请求纯文本、客户端异常)、`write_title_result`(只改 A)、`_find_title_model`(命中/缺失/图片模型报错)单测通过,Python 3.7。
|
||||
|
||||
### 17.6 决策:标题模型「配置定名」而非下拉
|
||||
|
||||
@@ -475,3 +482,14 @@ Excel 行 → `OutfitTask` 列表的转换由 `excel_service` 完成;核心只
|
||||
- **为什么不把模型名写死在代码里**:写死后换模型/升级要改 `.py` 重新打包分发。改放 `app_config.title_model`(默认 `GPT-5.5 文本`)后,换模型只改配置一行、重启生效,对非技术管理员友好。
|
||||
- **界面效果一致**:两种做法用户都看不到下拉、都固定一个模型;差别只在「换模型」的代价(改配置 vs 改代码)。
|
||||
- 标题提示词暂不做多套模板(同 §17 决策;真有多套切换需求再按 §7.2 范式补)。
|
||||
|
||||
### 17.7 决策:标题改为「一次请求、纯提示词、多条按序填」
|
||||
|
||||
§19.18 初版为「逐行看图、各生成 1 条」(每行一次请求、带衣服图做视觉)。用户复盘后改为
|
||||
**一次请求、纯提示词(不传图)、生成多条、按序回填**(§19.22):
|
||||
|
||||
- **为什么去掉图片**:印花 Excel 一行=一个印花子目录、多张图,"看哪张"本就要取首图近似;且用户的标题更偏**通用电商 SEO 风格**(同批女装标题可互换),不必逐件看图。去图后改为纯文本批量,**一次请求拿多条**,更快更省(N 行从 N 次请求降到 1 次)。
|
||||
- **数量由用户在提示词里写**(已确认):代码不自动附加数量、不替换 `{title}`,原样发;解析返回的多行为多条标题。
|
||||
- **条数对不上不强求**:`N>行数` 多的丢、`N<行数` 后面行留空 + 日志,不报错中止(用户可改提示词数量重跑)。
|
||||
- **代价**:标题不再与具体某件衣服一一对应(无图);若将来要"每件看图各出标题",那是另一种模式,按 §17.1 旧版思路另做。
|
||||
- 文本服务保留 `generate_text`(单条)+ 新增 `generate_texts`(多条),共用同一段 POST;标题流程走 `generate_texts(prompt, image_path=None)`。
|
||||
|
||||
@@ -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 字以内。"
|
||||
"只输出标题本身、每行一条,不要序号、引号、表情或促销词。"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1373,3 +1373,28 @@
|
||||
- [x] 行为不变确认:`_resolve_title_model_config` 拿到 error 仍是 `QMessageBox.warning` + 返回 None;`_start_title` 据此中止(已有逻辑,无需改)
|
||||
- [x] 测试:`test_ai_outfit_panel.py` 加用例——`title_model` 命中 `images_edits` 模型 → `_find_title_model` 返回 `(None, 含「图片模型」的错误)`;既有命中(chat)/缺失用例保持绿
|
||||
- [x] 验证:相关单测 + 全套 py37 通过;离屏冒烟:`title_model` 指向图片模型 → 点「生成标题」前即弹窗中止、不开跑
|
||||
|
||||
### 19.22 标题生成改为「一次请求·纯提示词·多条按序填」 — docs/11 §17.1 / §17.7
|
||||
|
||||
前置阅读:
|
||||
|
||||
- `docs/11-ai-outfit.md`(§17 总述、§17.1 数据流、§17.2 文本服务、§17.4 运行、§17.7 决策)
|
||||
- `src/services/ai_text_service.py`(`generate_text`/`extract_text_from_response`/`_clean_title`)
|
||||
- `src/core/ai_title.py`(`generate_title`/`render_title_prompt`/`_reference_image`——本任务替换)
|
||||
- `src/services/config_service.py`(`DEFAULT_TITLE_PROMPT`)
|
||||
- `src/app/widgets/ai_outfit_panel.py`(`_TitleWorker`/`_start_title`/`_on_title_*`)
|
||||
- `tests/test_ai_title.py` / `test_ai_text_service.py`(既有用例需改)
|
||||
|
||||
背景:
|
||||
|
||||
用户确认把标题生成从「逐行看图、各生成 1 条、每行一次请求」改为**一次请求、纯提示词(不传图)、生成多条、按序回填**;数量由用户自写进提示词;条数与行数对不上时多丢/少留空 + 日志(§17.7)。
|
||||
|
||||
任务:
|
||||
|
||||
- [x] `ai_text_service.py`:新增 `extract_titles_from_response(data) -> List[str]`(原始文本按行拆 + 逐行清洗,复用提取序号/引号的 `_clean_title` 逐行版);新增 `AiTextClient.generate_texts(prompt, image_path=None) -> List[str]`(与 `generate_text` 共用一段 POST,返回多条);`generate_text` 保留(= 多条取首条)
|
||||
- [x] `core/ai_title.py`:新增 `generate_titles(prompt, model_config, api_client=None) -> List[str]`(一次请求、`image_path=None` 纯文本);移除 `generate_title`/`_reference_image`/`render_title_prompt`(不再逐行看图、不替换占位符——提示词原样发)
|
||||
- [x] `config_service.py`:`DEFAULT_TITLE_PROMPT` 改批量风格(让模型生成多条、每行一条、不带序号/引号;示例含数量如「生成 10 条」)
|
||||
- [x] `ai_outfit_panel.py` `_TitleWorker`:改为一次 `generate_titles` → 按序 `write_title_result` 回填、逐条 `progress` 刷新明细表;`N>行数`多的丢+日志、`N<行数`后面行留空+日志;请求异常 → 日志 + 成功 0 收尾;去掉行间 `request_interval` sleep
|
||||
- [x] `ai_outfit_panel.py` `_start_title`:不再传 `request_interval`;图片模型开跑前拦截(§19.21)保持
|
||||
- [x] 测试:`test_ai_text_service.py` 加 `extract_titles_from_response`(多行→多条、去序号/引号、丢空)+ `generate_texts`(mock,返回多条、无图 payload 不含 image_url);`test_ai_title.py` 改为 `generate_titles`(一次请求多条、客户端异常);删旧 `generate_title` 用例
|
||||
- [x] 验证:相关单测 + 全套 py37 通过;离屏冒烟:一次请求返回 6 条 → 6 行 A 按序回填;返回 3 条/8 条 → 兜底(留空/丢弃)+ 日志;payload 不含图片
|
||||
@@ -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