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:
+49
-2
@@ -164,7 +164,7 @@ Excel 行 → `OutfitTask` 列表的转换由 `excel_service` 完成;核心只
|
|||||||
- 提示词模板存 `~/.cmbot/config/outfit_prompt.txt`,界面可查看/编辑/保存;点「开始」前自动保存一次。
|
- 提示词模板存 `~/.cmbot/config/outfit_prompt.txt`,界面可查看/编辑/保存;点「开始」前自动保存一次。
|
||||||
- 占位符:仅 `{title}`(标题);界面只提供「插入标题」按钮,把占位符插入光标处。货号(`product_id`)不进提示词——它只用于 Excel B 列读取与输出文件命名(`货号.jpg`),不影响 AI 生成内容。(`render_prompt` 仍会替换偶然出现的 `{product_id}`,保持兼容,但界面不再引导插入。)
|
- 占位符:仅 `{title}`(标题);界面只提供「插入标题」按钮,把占位符插入光标处。货号(`product_id`)不进提示词——它只用于 Excel B 列读取与输出文件命名(`货号.jpg`),不影响 AI 生成内容。(`render_prompt` 仍会替换偶然出现的 `{product_id}`,保持兼容,但界面不再引导插入。)
|
||||||
- **「保存话术」按钮**:把当前编辑的模板原文(占位符原样保留,**不**存替换后的结果)经 `config_service` 写入 `outfit_prompt.txt`。用途是"改完先存、暂不开跑"并给用户明确反馈;与"开始前自动保存"并存、互为兜底(保留按钮是有意为之,对非技术用户更安心)。
|
- **「保存话术」按钮**:把当前编辑的模板原文(占位符原样保留,**不**存替换后的结果)经 `config_service` 写入 `outfit_prompt.txt`。用途是"改完先存、暂不开跑"并给用户明确反馈;与"开始前自动保存"并存、互为兜底(保留按钮是有意为之,对非技术用户更安心)。
|
||||||
- **最终提示词预览(内嵌、不弹窗)**:左栏话术编辑下方常驻一块只读预览区,编辑话术时实时把占位符替换成样本行真值,显示最终要发送给 AI 的完整文字。**样本行下拉来自 Excel 的全部有效数据行(标题/货号/衣服图齐全,不看 E 列完成/失败状态)**,因此整表生成完成后仍能预览(见 §10.3);默认选第一行。未选 Excel / 无有效行时显示带高亮占位符的模板原文 + 提示;缺 `{title}` 占位符时在预览区提示。预览只读,不改话术本身。(早期设计为弹窗 `QDialog`,因调话术需"边改边看"改为内嵌。)
|
- ~~**最终提示词预览(内嵌、不弹窗)**~~ **(§17 已移除)**:原左栏话术编辑下方的只读预览区 + 样本行下拉,为给左栏让出「标题生成」组(§17)已整体移除。相关历史问题(§10.1 下拉撑宽、§10.3 样本行为空、§10.4 不清空样本)随之作废。
|
||||||
- 缺标题占位符时开始前弹窗询问是否继续。
|
- 缺标题占位符时开始前弹窗询问是否继续。
|
||||||
- 默认话术方向(穿搭/上身,区别于旧项目的主图场景,需另调):人物上身实穿、保留衣服款式/版型/颜色/印花、合适身材与场景、电商可用、默认纯净不加促销牛皮癣。
|
- 默认话术方向(穿搭/上身,区别于旧项目的主图场景,需另调):人物上身实穿、保留衣服款式/版型/颜色/印花、合适身材与场景、电商可用、默认纯净不加促销牛皮癣。
|
||||||
|
|
||||||
@@ -250,7 +250,7 @@ Excel 行 → `OutfitTask` 列表的转换由 `excel_service` 完成;核心只
|
|||||||
|
|
||||||
三栏布局:
|
三栏布局:
|
||||||
|
|
||||||
- **左栏(数据源 + 话术创作,~360px)**:`Excel` / `输出` 各为**行内一行**(标签 + 路径 + 浏览,省纵向空间);数据源概览(共 N 行 / 完成 / 待处理 / 失败);**加大的**穿搭生成话术编辑 +「保存话术」+「插入标题」;下方常驻**加大的最终生成要求预览**(内嵌、实时,标题行右侧放样本行下拉,含 §7.1 自动附加的输出要求)。AI 模型下拉不在此(见右栏)。
|
- **左栏(数据源 + 标题/话术创作,~360px)**:`Excel` / `输出` 各为**行内一行**(标签 + 路径 + 浏览,省纵向空间);数据源概览(共 N 行 / 完成 / 待处理 / 失败);**「标题生成」组(§17)在上**:标题生成提示词编辑 +「标题模型」下拉 +「保存」+「生成标题」;**「穿搭生成话术」组在下**:话术编辑 +「保存话术」+「插入标题」+ 多套模板下拉(§7.2)。**最终提示词预览已移除(§17)**。图片 AI 模型下拉不在此(见右栏);标题模型下拉在本栏标题组内。
|
||||||
- **中栏(结果 + 明细)**:顶部「最近结果」缩略图条——**只展示已完成的人物效果图**,新图自动加到最左、首图标「最新」,单击看大图、右键开所在文件夹;下方「处理明细」表(行 / 标题 / 货号 / 衣服图 / 状态 / 结果或原因),按 Excel 行顺序,状态用 完成 / 失败 / 生成中 / 待处理 / 跳过 徽章。
|
- **中栏(结果 + 明细)**:顶部「最近结果」缩略图条——**只展示已完成的人物效果图**,新图自动加到最左、首图标「最新」,单击看大图、右键开所在文件夹;下方「处理明细」表(行 / 标题 / 货号 / 衣服图 / 状态 / 结果或原因),按 Excel 行顺序,状态用 完成 / 失败 / 生成中 / 待处理 / 跳过 徽章。
|
||||||
- **右栏(设置 + 运行,~400px)**:生成设置标题与「重试上次失败的行」同排;图片并发数 / 新请求间隔 / 单任务冷却 / 失败重试 / 分辨率 / JPG 质量保持 **3 列 × 2 行**;`AI 模型` 标签与下拉框同排;本次进度 + 统计(完成 / 失败 / 待处理);开始生成 / 停止生成同排;导出失败清单 / 打开输出目录同排;节省的纵向空间给实时日志(含 §8 心跳行)。
|
- **右栏(设置 + 运行,~400px)**:生成设置标题与「重试上次失败的行」同排;图片并发数 / 新请求间隔 / 单任务冷却 / 失败重试 / 分辨率 / JPG 质量保持 **3 列 × 2 行**;`AI 模型` 标签与下拉框同排;本次进度 + 统计(完成 / 失败 / 待处理);开始生成 / 停止生成同排;导出失败清单 / 打开输出目录同排;节省的纵向空间给实时日志(含 §8 心跳行)。
|
||||||
|
|
||||||
@@ -262,6 +262,8 @@ Excel 行 → `OutfitTask` 列表的转换由 `excel_service` 完成;核心只
|
|||||||
|
|
||||||
### 10.1 已知问题:左栏内容被中间面板裁掉(下拉框撑宽)
|
### 10.1 已知问题:左栏内容被中间面板裁掉(下拉框撑宽)
|
||||||
|
|
||||||
|
> **§17 更新:已作废。** 撑宽元凶「样本行下拉」随预览一并移除(§17);保留本节仅作历史记录。新「标题模型」下拉仍套用下方「修复方向」的 elide 策略,防止再撑宽。
|
||||||
|
|
||||||
**现象**:「2 AI 穿搭」左栏组件右侧显示不完整,像被中间面板遮挡了一部分。
|
**现象**:「2 AI 穿搭」左栏组件右侧显示不完整,像被中间面板遮挡了一部分。
|
||||||
|
|
||||||
**定位(离屏实测 1280×720)**:
|
**定位(离屏实测 1280×720)**:
|
||||||
@@ -294,6 +296,8 @@ Excel 行 → `OutfitTask` 列表的转换由 `excel_service` 完成;核心只
|
|||||||
|
|
||||||
### 10.3 已知问题:全表生成完成后,预览样本行下拉为空
|
### 10.3 已知问题:全表生成完成后,预览样本行下拉为空
|
||||||
|
|
||||||
|
> **§17 更新:已作废。** 预览与样本行下拉已移除(§17),本问题不复存在。`read_all_rows` 仍保留——它现在是「标题生成」的行来源(§17)。保留本节作历史记录。
|
||||||
|
|
||||||
**现象**:选中的 Excel 整表生成成功后,再次选中该文件,「最终生成要求预览」的样本行下拉只显示「(选 Excel 后显示替换效果)」,无可选行。
|
**现象**:选中的 Excel 整表生成成功后,再次选中该文件,「最终生成要求预览」的样本行下拉只显示「(选 Excel 后显示替换效果)」,无可选行。
|
||||||
|
|
||||||
**原因**:预览样本行用 `_reload_sample_rows()` 填,调的是给**生成**用的 `load_outfit_tasks()`——它按设计**跳过 E=「完成」的行**(失败行也只在勾「重试失败行」时返回)。整表都「完成」→ 返回空 → 下拉空 → 占位文案。预览本只是"拿一行真实数据看话术替换效果",与"这行要不要重新生成"无关,不该受状态过滤。
|
**原因**:预览样本行用 `_reload_sample_rows()` 填,调的是给**生成**用的 `load_outfit_tasks()`——它按设计**跳过 E=「完成」的行**(失败行也只在勾「重试失败行」时返回)。整表都「完成」→ 返回空 → 下拉空 → 占位文案。预览本只是"拿一行真实数据看话术替换效果",与"这行要不要重新生成"无关,不该受状态过滤。
|
||||||
@@ -306,6 +310,8 @@ Excel 行 → `OutfitTask` 列表的转换由 `excel_service` 完成;核心只
|
|||||||
|
|
||||||
### 10.4 改进:整表无待处理行时的提示 + 生成不清空预览样本
|
### 10.4 改进:整表无待处理行时的提示 + 生成不清空预览样本
|
||||||
|
|
||||||
|
> **§17 更新:改进 2 作废**(预览样本已移除,§17);**改进 1(无待处理行提示)继续有效**。
|
||||||
|
|
||||||
承接 §10.3。整表都「完成」后点「开始生成」时当前体验不清晰,两项改进:
|
承接 §10.3。整表都「完成」后点「开始生成」时当前体验不清晰,两项改进:
|
||||||
|
|
||||||
**改进 1 — 无待处理行时给明确提示。**
|
**改进 1 — 无待处理行时给明确提示。**
|
||||||
@@ -389,3 +395,44 @@ Excel 行 → `OutfitTask` 列表的转换由 `excel_service` 完成;核心只
|
|||||||
- Excel 行顺序处理、图片并发/限速/重试/温和停止生效;实时日志 + 进度 + 结束摘要 + 失败清单导出。
|
- Excel 行顺序处理、图片并发/限速/重试/温和停止生效;实时日志 + 进度 + 结束摘要 + 失败清单导出。
|
||||||
- 日志写入 `~/.cmbot/logs`;AI 密钥不入库。
|
- 日志写入 `~/.cmbot/logs`;AI 密钥不入库。
|
||||||
- 核心逻辑单测通过、不依赖 GUI、Python 3.7 可运行。
|
- 核心逻辑单测通过、不依赖 GUI、Python 3.7 可运行。
|
||||||
|
|
||||||
|
## 17. 标题生成(左栏独立功能)
|
||||||
|
|
||||||
|
「添加印花」批量导出生成的 Excel(`docs/02` §6.12),A 列「标题」只是占位的**印花名**,不是真正的电商标题。本功能在 AI 穿搭页**左栏**新增一个**独立的「生成标题」**流程:用户写标题提示词,AI **看该行衣服图**(视觉)生成电商标题,**写回 Excel A 列**,再点「开始生成」跑图时图片提示词的 `{title}` 即用上新标题。与「开始生成」(图片)**互不绑定**、各自一个按钮。
|
||||||
|
|
||||||
|
### 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.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 解析规则)。
|
||||||
|
|
||||||
|
### 17.3 模型与提示词
|
||||||
|
|
||||||
|
- **标题模型独立下拉**:图片/文本模型类型不同(图片模型返回图、不返回文字),左栏标题组内单独放「标题模型」下拉,与图片模型同源(`load_ai_models()`)但**独立记忆**所选(`app_config` 新键 `title_model`)。
|
||||||
|
- **标题提示词**:单份,存 `~/.cmbot/config/title_prompt.txt`(`load_title_prompt`/`save_title_prompt`,仿旧式单份,标题侧不做多套模板);默认文案面向电商女装(结合款式/版型/颜色/印花,输出一行中文标题,不加引号/表情/促销词)。
|
||||||
|
|
||||||
|
### 17.4 界面与运行
|
||||||
|
|
||||||
|
- **左栏布局**(§10 已同步):「标题生成」组在上、「穿搭生成话术」组在下;预览块移除。标题组含:标题提示词编辑、`标题模型`下拉、「保存」、「生成标题」。
|
||||||
|
- **运行**:`_TitleWorker(QObject)` 跑在 `QThread`(仿 `_OutfitWorker`),按行顺序逐行生成、立即回填、刷新该行 GUI;失败记日志、跳过该行、继续;用右栏「新请求间隔」做行间节流;温和停止。
|
||||||
|
- **互斥**:「生成标题」与「开始生成」运行时互斥(避免同表并发写)。
|
||||||
|
- 进度/结果走中栏「处理明细」表与右栏日志(不写 Excel 状态列)。
|
||||||
|
|
||||||
|
### 17.5 验收要点(标题生成)
|
||||||
|
|
||||||
|
- 左栏有「标题生成」组(提示词 + 标题模型下拉 + 生成标题按钮),无预览块。
|
||||||
|
- 选印花生成的 Excel(A=印花名、C=目录),点「生成标题」→ 每行 A 被改写为 AI 标题、明细表标题列实时刷新、D/E/F 未动。
|
||||||
|
- 标题模型选到纯图片接口(images/images_edits)时明确报错提示换模型。
|
||||||
|
- 生成完重载 Excel,紧接「开始生成」跑图用的是新标题。
|
||||||
|
- 文本解析、`generate_title`(单文件/目录取首图/无图失败/客户端异常)、`write_title_result`(只改 A)单测通过,Python 3.7。
|
||||||
|
|||||||
@@ -43,7 +43,9 @@ from services.config_service import (
|
|||||||
DEFAULT_OUTFIT_PROMPT,
|
DEFAULT_OUTFIT_PROMPT,
|
||||||
load_ai_models,
|
load_ai_models,
|
||||||
load_outfit_prompts,
|
load_outfit_prompts,
|
||||||
|
load_title_prompt,
|
||||||
save_outfit_prompts,
|
save_outfit_prompts,
|
||||||
|
save_title_prompt,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -136,6 +138,84 @@ class _OutfitWorker(QObject):
|
|||||||
self.finished.emit(summary)
|
self.finished.emit(summary)
|
||||||
|
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
tasks_loaded = Signal(object) # List[OutfitTask]
|
||||||
|
log = Signal(str)
|
||||||
|
progress = Signal(int, int, object) # completed, total, TitleResult
|
||||||
|
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):
|
||||||
|
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.models import TitleResult
|
||||||
|
from services.ai_text_service import AiTextClient
|
||||||
|
from services.excel_service import (
|
||||||
|
ensure_excel_writable,
|
||||||
|
read_all_rows,
|
||||||
|
write_title_result,
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
self.tasks_loaded.emit(rows)
|
||||||
|
total = len(rows)
|
||||||
|
if total == 0:
|
||||||
|
self.finished.emit(0, 0)
|
||||||
|
return
|
||||||
|
|
||||||
|
success = fail = 0
|
||||||
|
for index, task in enumerate(rows, start=1):
|
||||||
|
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:
|
||||||
|
try:
|
||||||
|
write_title_result(self._excel_path, task.row_index,
|
||||||
|
result.generated_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))
|
||||||
|
else:
|
||||||
|
fail += 1
|
||||||
|
self.log.emit("第 {} 行标题失败:{}".format(
|
||||||
|
task.row_index, result.error))
|
||||||
|
self.progress.emit(index, total, result)
|
||||||
|
|
||||||
|
self.finished.emit(success, fail)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Main panel
|
# Main panel
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -151,6 +231,9 @@ class AiOutfitPanel(QWidget):
|
|||||||
self._models = [] # list of model dicts from ai_models.json
|
self._models = [] # list of model dicts from ai_models.json
|
||||||
self._thread = None
|
self._thread = None
|
||||||
self._worker = None
|
self._worker = None
|
||||||
|
self._title_thread = None # 标题生成线程(§17)
|
||||||
|
self._title_worker = None
|
||||||
|
self._title_fail = 0 # running failure count for title stats
|
||||||
self._row_to_table = {} # excel row_index -> table row
|
self._row_to_table = {} # excel row_index -> table row
|
||||||
self._failures = [] # list of OutfitResult (failed)
|
self._failures = [] # list of OutfitResult (failed)
|
||||||
self._last_resolution = "" # for "value actually changed" check (§10.2)
|
self._last_resolution = "" # for "value actually changed" check (§10.2)
|
||||||
@@ -241,7 +324,7 @@ class AiOutfitPanel(QWidget):
|
|||||||
col.setContentsMargins(12, 12, 12, 12)
|
col.setContentsMargins(12, 12, 12, 12)
|
||||||
col.setSpacing(10)
|
col.setSpacing(10)
|
||||||
|
|
||||||
# 数据源 / 输出:行内一行(标签 + 路径 + 浏览),省纵向空间给话术/预览
|
# 数据源 / 输出:行内一行(标签 + 路径 + 浏览),省纵向空间给标题/话术
|
||||||
self._excel_edit = QLineEdit()
|
self._excel_edit = QLineEdit()
|
||||||
self._excel_edit.setPlaceholderText("选择商品表 .xlsx")
|
self._excel_edit.setPlaceholderText("选择商品表 .xlsx")
|
||||||
col.addLayout(self._inline_path_row("Excel", self._excel_edit, self._browse_excel))
|
col.addLayout(self._inline_path_row("Excel", self._excel_edit, self._browse_excel))
|
||||||
@@ -249,6 +332,9 @@ class AiOutfitPanel(QWidget):
|
|||||||
self._output_edit.setPlaceholderText("默认:程序旁的「穿搭图片」")
|
self._output_edit.setPlaceholderText("默认:程序旁的「穿搭图片」")
|
||||||
col.addLayout(self._inline_path_row("输出", self._output_edit, self._browse_output))
|
col.addLayout(self._inline_path_row("输出", self._output_edit, self._browse_output))
|
||||||
|
|
||||||
|
# 标题生成(§17):看衣服图 + 提示词 → AI 文字标题 → 写回 Excel A 列
|
||||||
|
col.addWidget(self._build_title_group(), stretch=2)
|
||||||
|
|
||||||
# 穿搭生成话术(多套模板;加大,随窗口高度拉伸)
|
# 穿搭生成话术(多套模板;加大,随窗口高度拉伸)
|
||||||
prm = QGroupBox()
|
prm = QGroupBox()
|
||||||
pv = QVBoxLayout(prm)
|
pv = QVBoxLayout(prm)
|
||||||
@@ -271,7 +357,6 @@ class AiOutfitPanel(QWidget):
|
|||||||
pv.addLayout(trow)
|
pv.addLayout(trow)
|
||||||
self._prompt_edit = QPlainTextEdit()
|
self._prompt_edit = QPlainTextEdit()
|
||||||
self._prompt_edit.setMinimumHeight(150)
|
self._prompt_edit.setMinimumHeight(150)
|
||||||
self._prompt_edit.textChanged.connect(self._refresh_preview)
|
|
||||||
pv.addWidget(self._prompt_edit)
|
pv.addWidget(self._prompt_edit)
|
||||||
prow = QHBoxLayout()
|
prow = QHBoxLayout()
|
||||||
insert_title_btn = QPushButton("插入标题")
|
insert_title_btn = QPushButton("插入标题")
|
||||||
@@ -282,31 +367,37 @@ class AiOutfitPanel(QWidget):
|
|||||||
save_btn.clicked.connect(self._save_prompt)
|
save_btn.clicked.connect(self._save_prompt)
|
||||||
prow.addWidget(save_btn)
|
prow.addWidget(save_btn)
|
||||||
pv.addLayout(prow)
|
pv.addLayout(prow)
|
||||||
col.addWidget(prm, stretch=2)
|
col.addWidget(prm, stretch=3)
|
||||||
|
|
||||||
# 最终生成要求预览(加大;内嵌、实时;替换样本行占位符 + 附加输出要求)
|
|
||||||
prev = QGroupBox()
|
|
||||||
pvw = QVBoxLayout(prev)
|
|
||||||
preview_header = QHBoxLayout()
|
|
||||||
preview_header.addWidget(QLabel("最终生成要求预览"))
|
|
||||||
self._sample_combo = QComboBox()
|
|
||||||
self._compact_combo(self._sample_combo)
|
|
||||||
self._sample_combo.currentIndexChanged.connect(self._refresh_preview)
|
|
||||||
preview_header.addWidget(self._sample_combo, stretch=1)
|
|
||||||
pvw.addLayout(preview_header)
|
|
||||||
self._preview_warn = QLabel("")
|
|
||||||
self._preview_warn.setStyleSheet("color:#b87a00;")
|
|
||||||
self._preview_warn.setVisible(False)
|
|
||||||
pvw.addWidget(self._preview_warn)
|
|
||||||
self._preview_view = QPlainTextEdit()
|
|
||||||
self._preview_view.setReadOnly(True)
|
|
||||||
self._preview_view.setMinimumHeight(200)
|
|
||||||
pvw.addWidget(self._preview_view)
|
|
||||||
col.addWidget(prev, stretch=3)
|
|
||||||
|
|
||||||
scroll.setWidget(inner)
|
scroll.setWidget(inner)
|
||||||
return scroll
|
return scroll
|
||||||
|
|
||||||
|
def _build_title_group(self):
|
||||||
|
"""标题生成组(§17):提示词 + 标题模型下拉 + 保存 + 生成标题。"""
|
||||||
|
box = QGroupBox()
|
||||||
|
v = QVBoxLayout(box)
|
||||||
|
v.addWidget(QLabel("标题生成提示词"))
|
||||||
|
self._title_prompt_edit = QPlainTextEdit()
|
||||||
|
self._title_prompt_edit.setMinimumHeight(110)
|
||||||
|
v.addWidget(self._title_prompt_edit)
|
||||||
|
|
||||||
|
model_row = QHBoxLayout()
|
||||||
|
model_row.addWidget(QLabel("标题模型"))
|
||||||
|
self._title_model_combo = QComboBox()
|
||||||
|
self._compact_combo(self._title_model_combo)
|
||||||
|
model_row.addWidget(self._title_model_combo, stretch=1)
|
||||||
|
v.addLayout(model_row)
|
||||||
|
|
||||||
|
btn_row = QHBoxLayout()
|
||||||
|
save_title_btn = QPushButton("保存")
|
||||||
|
save_title_btn.clicked.connect(self._save_title_prompt)
|
||||||
|
btn_row.addWidget(save_title_btn)
|
||||||
|
self._title_btn = QPushButton("生成标题")
|
||||||
|
self._title_btn.clicked.connect(self._start_title)
|
||||||
|
btn_row.addWidget(self._title_btn)
|
||||||
|
v.addLayout(btn_row)
|
||||||
|
return box
|
||||||
|
|
||||||
def _create_settings_group(self):
|
def _create_settings_group(self):
|
||||||
"""生成设置 group (lives in the right run column); two params per row."""
|
"""生成设置 group (lives in the right run column); two params per row."""
|
||||||
gen = QGroupBox()
|
gen = QGroupBox()
|
||||||
@@ -331,7 +422,6 @@ class AiOutfitPanel(QWidget):
|
|||||||
self._retry_count.setRange(0, 10)
|
self._retry_count.setRange(0, 10)
|
||||||
self._resolution = QComboBox()
|
self._resolution = QComboBox()
|
||||||
self._resolution.addItems(_RESOLUTIONS)
|
self._resolution.addItems(_RESOLUTIONS)
|
||||||
self._resolution.currentIndexChanged.connect(self._refresh_preview)
|
|
||||||
# activated = user click only; programmatic sets won't pop (docs/11 §10.2)
|
# activated = user click only; programmatic sets won't pop (docs/11 §10.2)
|
||||||
self._resolution.activated.connect(self._on_resolution_activated)
|
self._resolution.activated.connect(self._on_resolution_activated)
|
||||||
self._quality = QComboBox()
|
self._quality = QComboBox()
|
||||||
@@ -490,24 +580,29 @@ class AiOutfitPanel(QWidget):
|
|||||||
self._rebuild_prompt_combo()
|
self._rebuild_prompt_combo()
|
||||||
self._apply_prompt(name)
|
self._apply_prompt(name)
|
||||||
|
|
||||||
|
# 标题生成提示词(单份,§17.3)
|
||||||
|
self._title_prompt_edit.setPlainText(load_title_prompt())
|
||||||
|
|
||||||
self._models = load_ai_models()
|
self._models = load_ai_models()
|
||||||
self._model_combo.clear()
|
self._fill_model_combo(self._model_combo, config.get("outfit_model", ""))
|
||||||
if not self._models:
|
self._fill_model_combo(self._title_model_combo, config.get("title_model", ""))
|
||||||
self._model_combo.addItem("(未配置模型,请在 ai_models.json 添加)")
|
|
||||||
self._model_combo.setEnabled(False)
|
|
||||||
else:
|
|
||||||
self._model_combo.setEnabled(True)
|
|
||||||
for m in self._models:
|
|
||||||
self._model_combo.addItem(m.get("name") or m.get("model") or "(未命名)")
|
|
||||||
self._set_combo(self._model_combo, config.get("outfit_model", ""))
|
|
||||||
|
|
||||||
# Snapshot current dropdown values so a later user re-select of the same
|
# Snapshot current dropdown values so a later user re-select of the same
|
||||||
# item doesn't trigger the info popup (§10.2).
|
# item doesn't trigger the info popup (§10.2).
|
||||||
self._last_resolution = self._resolution.currentText()
|
self._last_resolution = self._resolution.currentText()
|
||||||
self._last_model = self._model_combo.currentText() if self._models else ""
|
self._last_model = self._model_combo.currentText() if self._models else ""
|
||||||
|
|
||||||
# Fill the preview's sample-row dropdown from the remembered Excel.
|
def _fill_model_combo(self, combo, selected_name):
|
||||||
self._reload_sample_rows()
|
"""Fill a model dropdown from self._models (shared by 图片/标题 model, §17.3)."""
|
||||||
|
combo.clear()
|
||||||
|
if not self._models:
|
||||||
|
combo.addItem("(未配置模型,请在 ai_models.json 添加)")
|
||||||
|
combo.setEnabled(False)
|
||||||
|
return
|
||||||
|
combo.setEnabled(True)
|
||||||
|
for m in self._models:
|
||||||
|
combo.addItem(m.get("name") or m.get("model") or "(未命名)")
|
||||||
|
self._set_combo(combo, selected_name)
|
||||||
|
|
||||||
def _set_combo(self, combo, value):
|
def _set_combo(self, combo, value):
|
||||||
idx = combo.findText(str(value))
|
idx = combo.findText(str(value))
|
||||||
@@ -527,6 +622,7 @@ class AiOutfitPanel(QWidget):
|
|||||||
"outfit_quality": self._quality.currentText(),
|
"outfit_quality": self._quality.currentText(),
|
||||||
"outfit_retry_failed": self._retry_failed_chk.isChecked(),
|
"outfit_retry_failed": self._retry_failed_chk.isChecked(),
|
||||||
"outfit_prompt_name": self._current_prompt_name,
|
"outfit_prompt_name": self._current_prompt_name,
|
||||||
|
"title_model": self._title_model_combo.currentText() if self._models else "",
|
||||||
})
|
})
|
||||||
|
|
||||||
# -- left actions ---------------------------------------------------
|
# -- left actions ---------------------------------------------------
|
||||||
@@ -537,7 +633,6 @@ class AiOutfitPanel(QWidget):
|
|||||||
if path:
|
if path:
|
||||||
self._excel_edit.setText(path)
|
self._excel_edit.setText(path)
|
||||||
self._emit_config()
|
self._emit_config()
|
||||||
self._reload_sample_rows()
|
|
||||||
|
|
||||||
def _browse_output(self):
|
def _browse_output(self):
|
||||||
path = QFileDialog.getExistingDirectory(
|
path = QFileDialog.getExistingDirectory(
|
||||||
@@ -574,7 +669,7 @@ class AiOutfitPanel(QWidget):
|
|||||||
self._current_prompt_name = name
|
self._current_prompt_name = name
|
||||||
self._saved_text = self._prompt_text(name)
|
self._saved_text = self._prompt_text(name)
|
||||||
self._select_prompt_in_combo(name)
|
self._select_prompt_in_combo(name)
|
||||||
self._prompt_edit.setPlainText(self._saved_text) # fires _refresh_preview
|
self._prompt_edit.setPlainText(self._saved_text)
|
||||||
|
|
||||||
def _store_current_text(self):
|
def _store_current_text(self):
|
||||||
"""Save the editor text into the current template + persist to disk."""
|
"""Save the editor text into the current template + persist to disk."""
|
||||||
@@ -684,54 +779,133 @@ class AiOutfitPanel(QWidget):
|
|||||||
self._apply_prompt(self._prompts[min(idx, len(self._prompts) - 1)]["name"])
|
self._apply_prompt(self._prompts[min(idx, len(self._prompts) - 1)]["name"])
|
||||||
self._emit_config()
|
self._emit_config()
|
||||||
|
|
||||||
# -- inline prompt preview ------------------------------------------
|
# -- 标题生成(§17)-------------------------------------------------
|
||||||
|
|
||||||
def _fill_sample_combo(self, tasks):
|
def _save_title_prompt(self, silent=False):
|
||||||
"""Populate the sample-row dropdown (None data = no sample)."""
|
save_title_prompt(self._title_prompt_edit.toPlainText())
|
||||||
self._sample_combo.blockSignals(True)
|
if not silent:
|
||||||
self._sample_combo.clear()
|
self.statusBar_message("标题提示词已保存")
|
||||||
if tasks:
|
|
||||||
for t in tasks:
|
|
||||||
pid = t.product_id if (t.product_id and t.product_id.strip()) else "(无货号)"
|
|
||||||
self._sample_combo.addItem(
|
|
||||||
"第 {} 行 · {} · {}".format(t.row_index, pid, t.title), t)
|
|
||||||
else:
|
|
||||||
self._sample_combo.addItem("(选 Excel 后显示替换效果)", None)
|
|
||||||
self._sample_combo.blockSignals(False)
|
|
||||||
self._refresh_preview()
|
|
||||||
|
|
||||||
def _reload_sample_rows(self):
|
def _selected_title_model_config(self):
|
||||||
"""Best-effort: read the chosen Excel to fill the sample dropdown.
|
if not self._models:
|
||||||
|
QMessageBox.warning(
|
||||||
|
self, "未配置模型",
|
||||||
|
"尚未配置 AI 模型。请在 ~/.cmbot/config/ai_models.json 添加后重试。")
|
||||||
|
return None
|
||||||
|
idx = self._title_model_combo.currentIndex()
|
||||||
|
if idx < 0 or idx >= len(self._models):
|
||||||
|
QMessageBox.warning(self, "未选择标题模型", "请先选择标题模型。")
|
||||||
|
return None
|
||||||
|
from services.ai_image_service import AiModelConfig, api_config_errors
|
||||||
|
data = self._models[idx]
|
||||||
|
errors = api_config_errors(data)
|
||||||
|
if errors:
|
||||||
|
QMessageBox.warning(self, "模型配置有误", ";".join(errors))
|
||||||
|
return None
|
||||||
|
return AiModelConfig.from_dict(data)
|
||||||
|
|
||||||
Uses read_all_rows (status-independent) so preview still works after the
|
def _start_title(self):
|
||||||
whole sheet is 完成 (docs/11 §10.3); generation still uses load_outfit_tasks.
|
if self._thread is not None or self._title_thread is not None:
|
||||||
"""
|
|
||||||
excel = self._excel_edit.text().strip()
|
|
||||||
rows = []
|
|
||||||
if excel:
|
|
||||||
try:
|
|
||||||
from services.excel_service import read_all_rows
|
|
||||||
rows = read_all_rows(excel)
|
|
||||||
except Exception as exc: # noqa: BLE001 - silent for preview
|
|
||||||
logger.info("Sample rows unavailable: %s", exc)
|
|
||||||
self._fill_sample_combo(rows)
|
|
||||||
|
|
||||||
def _refresh_preview(self):
|
|
||||||
if not hasattr(self, "_preview_view"):
|
|
||||||
return
|
return
|
||||||
from core.ai_outfit import build_output_requirements, render_prompt
|
excel = self._excel_edit.text().strip()
|
||||||
template = self._prompt_edit.toPlainText()
|
if not excel:
|
||||||
self._preview_warn.setVisible("{title}" not in template)
|
QMessageBox.information(self, "提示", "请先选择 Excel 文件。")
|
||||||
if "{title}" not in template:
|
return
|
||||||
self._preview_warn.setText("⚠ 话术缺少 {title} 占位符")
|
model_config = self._selected_title_model_config()
|
||||||
# Mirror what actually gets sent: append the output-requirements block
|
if model_config is None:
|
||||||
# for the currently selected resolution (docs/11 §7.1).
|
return
|
||||||
resolution = self._resolution.currentText() if hasattr(self, "_resolution") else None
|
prompt = self._title_prompt_edit.toPlainText().strip()
|
||||||
task = self._sample_combo.currentData() if hasattr(self, "_sample_combo") else None
|
if not prompt:
|
||||||
if task is None:
|
QMessageBox.information(self, "提示", "请先填写标题生成提示词。")
|
||||||
self._preview_view.setPlainText(template + build_output_requirements(resolution))
|
return
|
||||||
|
|
||||||
|
self._save_title_prompt(silent=True)
|
||||||
|
self._emit_config()
|
||||||
|
|
||||||
|
# reset run state (shared with image generation)
|
||||||
|
self._table.setRowCount(0)
|
||||||
|
self._row_to_table = {}
|
||||||
|
self._title_fail = 0
|
||||||
|
self._progress.setValue(0)
|
||||||
|
self._log.clear()
|
||||||
|
|
||||||
|
self._title_worker = _TitleWorker(
|
||||||
|
excel, model_config, prompt, self._interval.value())
|
||||||
|
self._title_thread = QThread(self)
|
||||||
|
self._title_worker.moveToThread(self._title_thread)
|
||||||
|
self._title_thread.started.connect(self._title_worker.run)
|
||||||
|
self._title_worker.tasks_loaded.connect(self._on_title_tasks_loaded)
|
||||||
|
self._title_worker.log.connect(self._append_log)
|
||||||
|
self._title_worker.progress.connect(self._on_title_progress)
|
||||||
|
self._title_worker.finished.connect(self._on_title_finished)
|
||||||
|
self._title_worker.failed.connect(self._on_title_failed)
|
||||||
|
self._title_worker.finished.connect(self._title_thread.quit)
|
||||||
|
self._title_worker.failed.connect(self._title_thread.quit)
|
||||||
|
self._title_thread.finished.connect(self._cleanup_title_thread)
|
||||||
|
self._title_thread.start()
|
||||||
|
self._set_title_running(True)
|
||||||
|
|
||||||
|
def _on_title_tasks_loaded(self, tasks):
|
||||||
|
self._populate_table(tasks)
|
||||||
|
self._update_stats(0, 0, len(tasks))
|
||||||
|
self._append_log("标题生成:已加载 {} 行".format(len(tasks)))
|
||||||
|
|
||||||
|
def _on_title_progress(self, completed, total, result):
|
||||||
|
row = self._row_to_table.get(result.task.row_index)
|
||||||
|
if row is not None:
|
||||||
|
if result.success:
|
||||||
|
self._set_cell(row, 1, result.generated_title) # refresh 标题 列
|
||||||
|
self._set_cell(row, 4, "完成")
|
||||||
|
self._set_cell(row, 5, "标题已生成")
|
||||||
|
else:
|
||||||
|
self._set_cell(row, 4, "失败")
|
||||||
|
self._set_cell(row, 5, result.error)
|
||||||
|
if not result.success:
|
||||||
|
self._title_fail += 1
|
||||||
|
self._progress.setValue(completed)
|
||||||
|
self._update_stats(completed - self._title_fail, self._title_fail, total - completed)
|
||||||
|
|
||||||
|
def _on_title_finished(self, success, fail):
|
||||||
|
self._set_title_running(False)
|
||||||
|
msg = "标题生成结束:成功 {},失败 {}".format(success, fail)
|
||||||
|
self._append_log(msg)
|
||||||
|
self._reload_after_titles()
|
||||||
|
if success or fail:
|
||||||
|
QMessageBox.information(
|
||||||
|
self, "标题生成",
|
||||||
|
msg + "。\n标题已写回 Excel,可点「开始生成」生成穿搭图。")
|
||||||
else:
|
else:
|
||||||
self._preview_view.setPlainText(render_prompt(template, task, resolution))
|
QMessageBox.information(self, "标题生成", "该表没有可处理的行。")
|
||||||
|
|
||||||
|
def _on_title_failed(self, message):
|
||||||
|
self._set_title_running(False)
|
||||||
|
self._append_log("标题生成无法开始:" + message)
|
||||||
|
QMessageBox.warning(self, "无法开始", message)
|
||||||
|
|
||||||
|
def _cleanup_title_thread(self):
|
||||||
|
self._title_thread = None
|
||||||
|
self._title_worker = None
|
||||||
|
|
||||||
|
def _reload_after_titles(self):
|
||||||
|
"""Re-read Excel so the table + later image generation use new titles."""
|
||||||
|
excel = self._excel_edit.text().strip()
|
||||||
|
if not excel:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
from services.excel_service import read_all_rows
|
||||||
|
rows = read_all_rows(excel)
|
||||||
|
except Exception as exc: # noqa: BLE001 - best effort
|
||||||
|
logger.info("Reload after titles failed: %s", exc)
|
||||||
|
return
|
||||||
|
self._populate_table(rows)
|
||||||
|
self._update_stats(0, 0, len(rows))
|
||||||
|
|
||||||
|
def _set_title_running(self, running):
|
||||||
|
self._title_btn.setEnabled(not running)
|
||||||
|
self._title_btn.setText("生成中…" if running else "生成标题")
|
||||||
|
self._start_btn.setEnabled(not running) # mutually exclusive with 开始生成
|
||||||
|
self._excel_edit.setEnabled(not running)
|
||||||
|
self._title_model_combo.setEnabled(not running and bool(self._models))
|
||||||
|
|
||||||
# -- switch info popups (user-only; §10.2) --------------------------
|
# -- switch info popups (user-only; §10.2) --------------------------
|
||||||
|
|
||||||
@@ -767,7 +941,7 @@ class AiOutfitPanel(QWidget):
|
|||||||
# -- run control ----------------------------------------------------
|
# -- run control ----------------------------------------------------
|
||||||
|
|
||||||
def _start(self):
|
def _start(self):
|
||||||
if self._thread is not None:
|
if self._thread is not None or self._title_thread is not None:
|
||||||
return
|
return
|
||||||
excel = self._excel_edit.text().strip()
|
excel = self._excel_edit.text().strip()
|
||||||
if not excel:
|
if not excel:
|
||||||
@@ -856,11 +1030,14 @@ class AiOutfitPanel(QWidget):
|
|||||||
self._stop_btn.setEnabled(running)
|
self._stop_btn.setEnabled(running)
|
||||||
self._excel_edit.setEnabled(not running)
|
self._excel_edit.setEnabled(not running)
|
||||||
self._model_combo.setEnabled(not running and bool(self._models))
|
self._model_combo.setEnabled(not running and bool(self._models))
|
||||||
|
self._title_btn.setEnabled(not running) # mutually exclusive with 生成标题
|
||||||
|
|
||||||
# -- worker callbacks (UI thread) -----------------------------------
|
# -- worker callbacks (UI thread) -----------------------------------
|
||||||
|
|
||||||
def _on_tasks_loaded(self, tasks):
|
def _populate_table(self, tasks):
|
||||||
|
"""Fill the detail table from a task list (shared by image/title runs)."""
|
||||||
self._table.setRowCount(len(tasks))
|
self._table.setRowCount(len(tasks))
|
||||||
|
self._row_to_table = {}
|
||||||
for row, task in enumerate(tasks):
|
for row, task in enumerate(tasks):
|
||||||
self._row_to_table[task.row_index] = row
|
self._row_to_table[task.row_index] = row
|
||||||
self._set_cell(row, 0, str(task.row_index))
|
self._set_cell(row, 0, str(task.row_index))
|
||||||
@@ -870,9 +1047,10 @@ class AiOutfitPanel(QWidget):
|
|||||||
self._set_cell(row, 4, "待处理")
|
self._set_cell(row, 4, "待处理")
|
||||||
self._set_cell(row, 5, "—")
|
self._set_cell(row, 5, "—")
|
||||||
self._progress.setMaximum(max(1, len(tasks)))
|
self._progress.setMaximum(max(1, len(tasks)))
|
||||||
|
|
||||||
|
def _on_tasks_loaded(self, tasks):
|
||||||
|
self._populate_table(tasks)
|
||||||
self._update_stats(0, 0, len(tasks))
|
self._update_stats(0, 0, len(tasks))
|
||||||
# Preview sample rows are owned by read_all_rows (§10.3/§10.4); don't reset
|
|
||||||
# them to the run's task list (which is empty when the sheet is all 完成).
|
|
||||||
self._append_log("已加载 {} 行待处理任务".format(len(tasks)))
|
self._append_log("已加载 {} 行待处理任务".format(len(tasks)))
|
||||||
|
|
||||||
def _on_progress(self, completed, total, result):
|
def _on_progress(self, completed, total, result):
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""AI 标题生成单行编排(docs/11 §17)。
|
||||||
|
|
||||||
|
看该行衣服图(目录行取首图)+ 用户标题提示词 → 调 AI 文本服务生成电商标题。
|
||||||
|
返回 TitleResult;never raises(异常聚合进结果)。标题写回 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 _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)
|
||||||
@@ -182,6 +182,19 @@ class OutfitResult:
|
|||||||
output_paths: List[str] = field(default_factory=list)
|
output_paths: List[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TitleResult:
|
||||||
|
"""AI 标题生成单行结果(docs/11 §17)。
|
||||||
|
|
||||||
|
generated_title: 成功时为 AI 生成、清洗后的单行标题(写回 Excel A 列)。
|
||||||
|
"""
|
||||||
|
task: OutfitTask
|
||||||
|
success: bool
|
||||||
|
generated_title: str = ""
|
||||||
|
error: str = ""
|
||||||
|
attempts: int = 0
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 合成结果
|
# 合成结果
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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_quality": "均衡",
|
||||||
"outfit_retry_failed": False,
|
"outfit_retry_failed": False,
|
||||||
"outfit_prompt_name": "默认", # last-selected 话术模板 name (docs/11 §7.2)
|
"outfit_prompt_name": "默认", # last-selected 话术模板 name (docs/11 §7.2)
|
||||||
|
"title_model": "", # last-selected 标题模型 name (docs/11 §17.3)
|
||||||
}
|
}
|
||||||
|
|
||||||
_CONFIG_FILENAME = "app_config.json"
|
_CONFIG_FILENAME = "app_config.json"
|
||||||
_AI_MODELS_FILENAME = "ai_models.json"
|
_AI_MODELS_FILENAME = "ai_models.json"
|
||||||
_OUTFIT_PROMPT_FILENAME = "outfit_prompt.txt" # legacy single prompt (migrated)
|
_OUTFIT_PROMPT_FILENAME = "outfit_prompt.txt" # legacy single prompt (migrated)
|
||||||
_OUTFIT_PROMPTS_FILENAME = "outfit_prompts.json" # multi named templates (§7.2)
|
_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_NAME = "默认"
|
||||||
|
|
||||||
# Default outfit prompt (docs/11 §7). Seeded into outfit_prompts.json on first run.
|
# 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():
|
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)
|
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):
|
def _normalize_prompts(data):
|
||||||
"""Keep only valid {name, text} entries (non-empty name, string text)."""
|
"""Keep only valid {name, text} entries (non-empty name, string text)."""
|
||||||
if not isinstance(data, list):
|
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))
|
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):
|
def write_outfit_result(excel_path, result):
|
||||||
"""Write one outfit result to columns D/E/F and save immediately."""
|
"""Write one outfit result to columns D/E/F and save immediately."""
|
||||||
if not isinstance(result, OutfitResult):
|
if not isinstance(result, OutfitResult):
|
||||||
|
|||||||
@@ -1279,3 +1279,29 @@
|
|||||||
- [x] 保留「新请求间隔」启动节流;图片并发数 >1 时日志/缩略图完成顺序允许与文件名排序不同
|
- [x] 保留「新请求间隔」启动节流;图片并发数 >1 时日志/缩略图完成顺序允许与文件名排序不同
|
||||||
- [x] 补测试:Excel 行顺序处理、目录内图片并发、单文件行不并发、已存在跳过、部分失败聚合、UI label/default/config 兼容
|
- [x] 补测试:Excel 行顺序处理、目录内图片并发、单文件行不并发、已存在跳过、部分失败聚合、UI label/default/config 兼容
|
||||||
- [~] 验证:语法检查、`test_outfit_batch.py`、`test_ai_outfit.py`、`test_ai_outfit_panel.py` 通过;全套 `python -m unittest discover -s tests` 当前被工作区未提交的 `packaging/default_config/ai_models.json` 改动阻塞(模型顺序与出厂模板规范不一致),待清理该文件后重跑
|
- [~] 验证:语法检查、`test_outfit_batch.py`、`test_ai_outfit.py`、`test_ai_outfit_panel.py` 通过;全套 `python -m unittest discover -s tests` 当前被工作区未提交的 `packaging/default_config/ai_models.json` 改动阻塞(模型顺序与出厂模板规范不一致),待清理该文件后重跑
|
||||||
|
|
||||||
|
### 19.18 AI 穿搭左栏「标题生成」 — docs/11 §17
|
||||||
|
|
||||||
|
前置阅读:
|
||||||
|
|
||||||
|
- `docs/11-ai-outfit.md`(§17 标题生成、§7 提示词、§10 界面、§4.1 目录行)
|
||||||
|
- `src/services/ai_image_service.py`(`AiModelConfig`/`image_to_data_url`/`detect_api_type`/`normalize_api_url`/`build_payload`/`generate`,复用 HTTP 管道)
|
||||||
|
- `src/core/ai_outfit.py`(`render_prompt`/`looks_like_directory`/`list_directory_images`)
|
||||||
|
- `src/services/excel_service.py`(`read_all_rows`/`write_outfit_result`,COL_TITLE)
|
||||||
|
- `src/services/config_service.py`(`load_ai_models`/`load_outfit_prompt`/`DEFAULT_CONFIG`)
|
||||||
|
- `src/app/widgets/ai_outfit_panel.py`(`_build_left`/`_OutfitWorker`/预览相关方法)
|
||||||
|
|
||||||
|
背景:
|
||||||
|
|
||||||
|
「添加印花」批量导出的 Excel,A 列「标题」是占位印花名。需在 AI 穿搭页左栏新增**独立**的「生成标题」:用户写标题提示词,AI 看该行衣服图(视觉)生成电商标题,逐行回填 Excel A 列并刷新 GUI;之后「开始生成」跑图即用新标题。已确认:写回 A 列;独立按钮;看图(视觉);独立「标题模型」下拉;逐行各生成 1 条、第 n 条回填第 n 行 A、重载刷新 GUI。
|
||||||
|
|
||||||
|
任务:
|
||||||
|
|
||||||
|
- [ ] `src/services/ai_text_service.py`(新建):`AiTextClient(config, session=None)` 复用 `ai_image_service` 管道;`generate_text(prompt, image_path=None)`(chat/gemini 带图视觉文本输出,images/images_edits 抛 `AiTextServiceError`);`extract_text_from_response`(chat/gemini 取文本,返回第一条标题:首个非空行、去序号/引号、单行化)
|
||||||
|
- [ ] `src/core/ai_title.py`(新建):`render_title_prompt(template, task)`(替换 `{title}`/`{product_id}`,不加图片输出要求尾巴);`generate_title(task, prompt_template, model_config, api_client=None) -> TitleResult`(目录行取 `list_directory_images` 首图,无图失败,never raises)
|
||||||
|
- [ ] `src/core/models.py`:新增 `TitleResult`(task/success/generated_title/error/attempts)
|
||||||
|
- [ ] `src/services/excel_service.py`:新增 `write_title_result(excel_path, row_index, title)`(只写 A 列并保存,不动 D/E/F)
|
||||||
|
- [ ] `src/services/config_service.py`:`DEFAULT_CONFIG` 增 `title_model`;新增 `load_title_prompt`/`save_title_prompt`(`title_prompt.txt`)+ `DEFAULT_TITLE_PROMPT`
|
||||||
|
- [ ] `src/app/widgets/ai_outfit_panel.py`:移除预览块(`_sample_combo`/`_preview_*`/`_refresh_preview`/`_fill_sample_combo`/`_reload_sample_rows` 及信号连接);新增「标题生成」组(提示词编辑 + `标题模型`下拉 + 保存 + 生成标题)置于话术组上方;`apply_config`/`_emit_config` 接 `title_model`+标题提示词;`_TitleWorker` 顺序逐行生成+立即回填+刷新明细表标题列,完成后重载 Excel;「生成标题」与「开始生成」互斥
|
||||||
|
- [ ] 测试:`tests/test_ai_text_service.py`(文本解析含多行只取第一条、images_edits 抛错、payload 含图);`tests/test_ai_title.py`(render、单文件/目录首图/无图失败/异常);`tests/test_excel_service.py` 加 `write_title_result` 只改 A;删预览后 `ai_outfit_panel` 离屏可构建
|
||||||
|
- [ ] 验证:相关单测 + 全套 py37 通过(`test_config_service` 的 `ai_models.json` 顺序失败属并行 §19.13 遗留,与本改动无关);离屏冒烟(mock `AiTextClient`):选印花 Excel → 生成标题 → A 列改写、明细表刷新、D/E/F 未动、面板无预览控件
|
||||||
|
|||||||
@@ -73,6 +73,31 @@ class TestAiOutfitPanelDefaults(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertIn("图片并发数", labels)
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.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(tasks[0].product_id, "")
|
||||||
self.assertEqual([r.row_index for r in read_all_rows(p)], [2])
|
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):
|
def test_check_excel_writable_true_for_existing_workbook(self):
|
||||||
from services.excel_service import check_excel_writable
|
from services.excel_service import check_excel_writable
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user