fix(ai-title): 过滤模型「前导思考」,只取标题 (§19.31)

推理型文本模型即便提示词要求「只返回逗号连接的标题」,仍常在标题前先输出
一段思考散文;该散文自身带逗号,直接逗号切分会切出假标题混进结果。切分前
先剥离前导思考(_strip_title_preamble),两层:
- ① 哨兵优先:文本含 ===TITLES===(正则 =+\s*TITLES\s*=+,忽略大小写、
  吞相邻 * / # 装饰)时只取最后一个哨兵之后,思考连同哨兵丢弃
- ② 句号兜底(无需改提示词):无哨兵时切最后一个 。!? 及之前;真标题不含
  句末标点,故最后一个句号即前导散文↔标题列表的分界;仅当其后仍含逗号/换行
  分隔符时才剥离,避免末条标题的收尾句号被误当分界清空

- ai_text_service.py:加 _TITLE_SENTINEL / _SENTENCE_END / _strip_title_preamble,
  _clean_titles 切分前调用
- config_service.py:DEFAULT_TITLE_PROMPT 补哨兵约定(思考写最前、单独一行
  ===TITLES===、其后只放逗号标题)
- docs/11 §17.2/§17.3 更新 + 新增 §17.9 决策;tasks.md §19.31
- 不碰 packaging/default_config/title_prompt.txt(用户精细模板,并行未提交)

验证:test_ai_text_service(22)+ 全套 py37(236 测试)通过,唯一失败为
无关的 test_config_service packaging 模板;离屏冒烟用真实返回样本 → 前导思考
被剥离、取全部干净标题,默认提示词含哨兵。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 15:50:48 +08:00
co-authored by Claude Opus 4.8
parent bc40905b38
commit a413aa6f15
5 changed files with 128 additions and 6 deletions
+39 -2
View File
@@ -36,6 +36,13 @@ _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]+")
# Sentinel the prompt asks the model to print after any "thinking" preamble;
# everything after the last sentinel is the title list (docs/11 §17.3 / §17.9).
# Tolerant: matches ===TITLES===, ==TITLES==, **===TITLES===**… case-insensitively,
# consuming adjacent markdown decoration (* / #) so it never leaks into the tail.
_TITLE_SENTINEL = re.compile(r"[*#]*\s*=+\s*TITLES\s*=+\s*[*#]*", re.IGNORECASE)
# Sentence-ending punctuation marking the end of a prose preamble (§17.9 fallback).
_SENTENCE_END = "。!?"
def build_text_payload(config, prompt, image_data_url=None):
@@ -130,17 +137,47 @@ def _clean_title_line(line):
return stripped
def _strip_title_preamble(text):
"""Drop a leading "thinking" preamble so only the title list remains (§17.9).
Reasoning models often emit prose before the titles even when told not to;
that prose carries commas, so comma-splitting it yields fake titles. Two
layers, in priority order:
1. Sentinel (deterministic): if the prompt-agreed `===TITLES===` marker is
present, keep only what follows the LAST sentinel — thinking (and the
marker) is discarded.
2. Sentence-end fallback (no prompt change needed): otherwise cut everything
up to and including the last `。!?`. Real titles carry no sentence-ending
punctuation (the prompt forbids it), so the last one marks the prose↔list
boundary. Only strip when a title separator (`,`/`,`/newline) still
remains after it — else the `。` was a lone trailing mark, not a boundary,
and stripping would wipe the titles.
"""
s = str(text)
matches = list(_TITLE_SENTINEL.finditer(s))
if matches:
return s[matches[-1].end():]
cut = max((s.rfind(ch) for ch in _SENTENCE_END), default=-1)
if cut != -1:
tail = s[cut + 1:]
if _TITLE_SPLIT.search(tail):
return tail
return s
def _clean_titles(text):
"""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.
splitting produced. A leading model "thinking" preamble is stripped first
(`_strip_title_preamble`, §17.9) so it never leaks in as fake titles.
"""
if not text:
return []
out = []
for piece in _TITLE_SPLIT.split(str(text)):
for piece in _TITLE_SPLIT.split(_strip_title_preamble(text)):
cleaned = _clean_title_line(piece)
if cleaned:
out.append(cleaned)
+4 -2
View File
@@ -49,8 +49,10 @@ DEFAULT_OUTFIT_PROMPT = (
DEFAULT_TITLE_PROMPT = (
"请生成 10 条适合台湾蝦皮电商的中文女装商品标题,突出卖点与适穿场景,"
"每条控制在 30 字以内。"
"标题之间用逗号「,」分隔,只输出标题本身,"
"不要使用 Markdown 表格、不要换行、不要序号/編號/字元數、不要引号或表情。"
"如果你需要思考或说明,请全部写在最前面;思考完毕后单独一行输出 ===TITLES===,"
"其后只放所有标题、标题之间用逗号「,」分隔。"
"===TITLES=== 之后不要出现任何非标题文字,"
"不要使用 Markdown 表格、不要序号/編號/字元數、不要引号或表情。"
)