fix(ai-outfit): 标题解析改为「按逗号分割」,避开 Markdown 表格坑 (§19.23)

实测模型常返回 Markdown 表格(編號/標題/字元數估算 三列),按行解析会把
表头/分隔线/带 | 数字的行当成标题,写进 Excel A 全是「| 編號 | 標題 | … |」。
改用「提示词约定标题逗号分隔 + 解析按逗号拆分」,简单可靠。

- ai_text_service._clean_titles:从按行拆改为按逗号拆(半角 , / 全角 ,,
  换行作兜底分隔),每段走 _clean_title_line 去空白/序号/引号、丢空
- config_service.DEFAULT_TITLE_PROMPT:改逗号分隔风格,明确禁 Markdown 表格/
  换行/序号/編號/字元數/引号/表情
- extract_text_from_response(取首条)、generate_texts/generate_titles/
  _TitleWorker 均不变

测试:逗号分隔(全角/半角)→ 多条;逗号+换行混用都拆开;逐段去序号/引号;
Markdown 表头单行不再炸成多条垃圾。全套 py37 通过(test_config_service 的
packaging 模板失败属并行 §19.13,与本改动无关)。离屏冒烟:逗号响应解析 +
按序回填 Excel A。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 16:24:34 +08:00
co-authored by Claude Opus 4.8
parent e7fab724fc
commit 61d8e20d86
5 changed files with 64 additions and 19 deletions
+5 -5
View File
@@ -428,8 +428,8 @@ Excel 行 → `OutfitTask` 列表的转换由 `excel_service` 完成;核心只
> **一次请求、纯提示词(不传图)、生成多条、按序回填**(用户在提示词里自写数量)。
- **行来源**:复用 `excel_service.read_all_rows(excel)`(状态无关)——拿到全部有效行(仅为「填到哪些行 + 行号」),**覆盖式**写 A,不看 E 列状态、不引入新列。
- **一次请求、纯提示词**:把用户标题提示词**原样**(不带图、不替换 `{title}`)发一次给文本模型;提示词里由用户自写数量(如「生成 6 条…每行一条」)。
- **解析多条**:从响应里把文本**按行**拆成多条,每条清洗(去行首序号/符号 `1. / - / ①`、去首尾引号、单行化、丢空行)→ 得到标题列表。
- **一次请求、纯提示词**:把用户标题提示词**原样**(不带图、不替换 `{title}`)发一次给文本模型;提示词里由用户自写数量并**约定标题之间用逗号隔开**(如「生成 6 条…标题之间用逗号分隔,不要表格/换行/序号」)。
- **按逗号拆分多条**:把响应文本按**逗号**(半角 `,` / 全角 `,`,并兼容换行作兜底分隔)拆成多条,每条清洗(去首尾空白、去行首序号/符号 `1. / - / ①`、去首尾引号、丢空)→ 得到标题列表。**不再按行/不解析 Markdown 表格**——靠提示词约定逗号分隔(§19.23)。
- **按序回填**:第 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 列)。
@@ -443,7 +443,7 @@ Excel 行 → `OutfitTask` 列表的转换由 `excel_service` 完成;核心只
- `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` = 取其首条(兼容保留)。
- `extract_titles_from_response(data) -> List[str]`:取 `choices[0].message.content` / gemini `candidates[0].content.parts[].text` 的原始文本,**按逗号(`,`/`,`,兼容换行)拆分 + 逐段清洗**(去首尾空白/序号/符号/引号、丢空),返回标题列表(§19.23)。`extract_text_from_response` = 取其首条(兼容保留)。
### 17.3 模型与提示词
@@ -455,7 +455,7 @@ 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`,仿旧式单份,标题侧不做多套模板);默认文案为**批量风格**——让模型生成**多条**电商女装标题、**每行一条**、不带序号/引号/表情,数量由用户在提示词里写(默认示例可写「生成 10 条」)。
- **标题提示词**:单份,存 `~/.cmbot/config/title_prompt.txt`(`load_title_prompt`/`save_title_prompt`,仿旧式单份,标题侧不做多套模板);默认文案为**批量风格 + 逗号分隔**——让模型生成**多条**电商女装标题、**标题之间用逗号分隔**、**不要表格/换行/序号/引号/表情**,数量由用户在提示词里写(默认示例可写「生成 10 条」)。与 §17.1 的逗号拆分配套。
### 17.4 界面与运行
@@ -472,7 +472,7 @@ Excel 行 → `OutfitTask` 列表的转换由 `excel_service` 完成;核心只
- 条数对不上:N>行数多的丢弃 + 日志;N<行数后面行留空 + 日志(不报错中止)。
- `title_model` 找不到对应模型 / 命中条目是纯图片接口(images/images_edits)时,**开跑前**弹窗报错并中止(§19.21)。
- 生成完重载 Excel,紧接「开始生成」跑图用的是新标题。
- `extract_titles_from_response`(多行→多条、清洗)、`generate_titles`(一次请求纯文本、客户端异常)、`write_title_result`(只改 A)、`_find_title_model`(命中/缺失/图片模型报错)单测通过,Python 3.7。
- `extract_titles_from_response`(**按逗号拆分**→多条、清洗、兼容换行、Markdown 表格不再当多条)、`generate_titles`(一次请求纯文本、客户端异常)、`write_title_result`(只改 A)、`_find_title_model`(命中/缺失/图片模型报错)单测通过,Python 3.7。
### 17.6 决策:标题模型「配置定名」而非下拉
+12 -5
View File
@@ -34,6 +34,8 @@ class AiTextServiceError(RuntimeError):
_TITLE_LEAD = re.compile(r"^\s*(?:\d+\s*[\.\)、::]|[-*•])\s*")
_TITLE_CIRCLED = "①②③④⑤⑥⑦⑧⑨⑩"
_TITLE_QUOTES = "\"'「」『』“”‘’"
# Split titles on comma (half/full width) or newline (docs/11 §17.1 / §19.23).
_TITLE_SPLIT = re.compile(r"[,,\r\n]+")
def build_text_payload(config, prompt, image_data_url=None):
@@ -129,21 +131,26 @@ def _clean_title_line(line):
def _clean_titles(text):
"""Split raw text into a list of cleaned titles (one per non-empty line)."""
"""Split raw text into a list of cleaned titles, on comma (or newline).
The prompt asks the model to comma-separate titles (docs/11 §17.1 / §19.23);
splitting on `,`/`,`/newline avoids the Markdown-table mis-parse that line
splitting produced.
"""
if not text:
return []
out = []
for line in str(text).splitlines():
cleaned = _clean_title_line(line)
for piece in _TITLE_SPLIT.split(str(text)):
cleaned = _clean_title_line(piece)
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.
"""Return all clean titles from an AI JSON response (comma-split, §17.1/§19.23).
Used by 批量标题生成 (docs/11 §17.1): one request → many titles.
Used by 批量标题生成: one request → many titles.
"""
return _clean_titles(_extract_raw_text(data))
+6 -5
View File
@@ -44,12 +44,13 @@ DEFAULT_OUTFIT_PROMPT = (
"电商主图风格,不加文字与促销标签。"
)
# Default title prompt (docs/11 §17.3, batch style). Used when title_prompt.txt absent.
# 数量由用户改写(如「生成 10 条」);一次请求返回多条、按序回填各行 A(§17.1)。
# Default title prompt (docs/11 §17.3, batch + comma-separated). Used when title_prompt.txt absent.
# 数量由用户改写(如「生成 10 条」);一次请求返回多条、按逗号拆分后按序回填各行 A(§17.1/§19.23)。
DEFAULT_TITLE_PROMPT = (
"请生成 10 条适合台湾蝦皮电商的中文女装商品标题,每行一条,"
"突出卖点与适穿场景,每条控制在 30 字以内。"
"只输出标题本身、每行一条,不要序号、引号、表情或促销词。"
"请生成 10 条适合台湾蝦皮电商的中文女装商品标题,突出卖点与适穿场景,"
"每条控制在 30 字以内。"
"标题之间用逗号「,」分隔,只输出标题本身,"
"不要使用 Markdown 表格、不要换行、不要序号/編號/字元數、不要引号或表情。"
)
+21 -1
View File
@@ -1397,4 +1397,24 @@
- [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 不含图片
- [x] 验证:相关单测 + 全套 py37 通过;离屏冒烟:一次请求返回 6 条 → 6 行 A 按序回填;返回 3 条/8 条 → 兜底(留空/丢弃)+ 日志;payload 不含图片
### 19.23 标题解析改为「按逗号分割」 — docs/11 §17.1 / §17.2 / §17.3
前置阅读:
- `docs/11-ai-outfit.md`(§17.1 数据流、§17.2 文本服务、§17.3 默认提示词)
- `src/services/ai_text_service.py`(`_clean_titles`/`extract_titles_from_response`/`_clean_title_line`)
- `src/services/config_service.py`(`DEFAULT_TITLE_PROMPT`)
- `tests/test_ai_text_service.py`(`extract_titles` 用例)
背景:
实测模型常返回 **Markdown 表格**(編號/標題/字元數估算 三列),按行解析会把表头/分隔线/带 `|` 数字的行当成标题(垃圾),写进 Excel A 全是 `| 編號 | 標題 | 字元數估算 |`。改用**提示词约定「标题之间用逗号分隔」+ 解析按逗号拆分**,简单可靠、避开表格坑。
任务:
- [x] `ai_text_service.py`:`_clean_titles(text)` 改为**按逗号拆分**——先把原始文本按 `,`/`,`(并把换行也当分隔,兜底)切成段,每段走 `_clean_title_line`(去首尾空白/序号/符号/引号),丢空,返回列表。`extract_titles_from_response`/`extract_text_from_response`(取首条)签名不变
- [x] `config_service.py`:`DEFAULT_TITLE_PROMPT` 改为「逗号分隔」风格——明确「标题之间用逗号分隔、不要 Markdown 表格/换行/序号/引号/表情」,数量由用户写
- [x] 测试:`test_ai_text_service.py` 更新/新增——逗号分隔(半角/全角)→ 多条;混换行+逗号→拆开;段内去序号/引号;Markdown 表格输入**不再**当多条(要么单条要么靠提示词避免,至少不产出表头那种垃圾的多条断言);`extract_text_from_response` 取首条
- [x] 验证:相关单测 + 全套 py37 通过;离屏冒烟:逗号分隔字符串 → 按序回填各行 A
+20 -3
View File
@@ -47,16 +47,33 @@ 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: 多行 → 多条,逐行去序号/引号、丢空行。"""
def test_extract_titles_comma_separated(self):
"""§19.23: 逗号分隔(全角/半角)→ 多条,逐段去空白。"""
from services.ai_text_service import extract_titles_from_response
full = {"choices": [{"message": {"content": "甲款,乙款,丙款,丁款"}}]}
self.assertEqual(extract_titles_from_response(full),
["甲款", "乙款", "丙款", "丁款"])
half = {"choices": [{"message": {"content": "甲款, 乙款 ,丙款"}}]}
self.assertEqual(extract_titles_from_response(half), ["甲款", "乙款", "丙款"])
def test_extract_titles_mixed_comma_newline_and_cleaning(self):
"""§19.23: 逗号 + 换行混用都拆开;逐段去序号/引号。"""
from services.ai_text_service import extract_titles_from_response
data = {"choices": [{"message": {"content":
"1. 「韩版宽松卫衣」\n\n2) 复古工装外套\n- 简约百搭T恤\n "}}]}
"1. 「韩版宽松卫衣」,2) 复古工装外套\n- 简约百搭T恤"}}]}
self.assertEqual(
extract_titles_from_response(data),
["韩版宽松卫衣", "复古工装外套", "简约百搭T恤"])
def test_extract_titles_markdown_table_not_exploded(self):
"""§19.23: 表头单行无逗号 → 至多 1 条,不再炸成表头/分隔/多行垃圾。"""
from services.ai_text_service import extract_titles_from_response
data = {"choices": [{"message": {"content": "| 編號 | 標題 | 字元數估算 |"}}]}
self.assertEqual(len(extract_titles_from_response(data)), 1)
def test_extract_titles_empty_when_no_text(self):
from services.ai_text_service import extract_titles_from_response