安卓客户端登记(他们文档 02 末节):一条助手消息的 content 是
`<p><p>正文</p>`,按他们的解析契约(非贪婪)必然把 `<p>正文` 当内容 ⇒ 用户
直接看到字面 `<p>`。任何按该契约定实现的消费方都会踩。
根因(两半都证实,都在同一处):
- (a) 规整器**原样放过**模型吐出的 `<p><p>X</p>`:段块正则非贪婪,把它当成
一个"已完整包裹"的块逐字保留(旧的覆盖检查用同一个正则 ⇒ 护栏本身是盲的);
- (b) 规整器**亲手制造**该形状:末段漏 `</p>` 时无条件套一层 ⇒ `<p><p>X</p>`。
实证:模型吐 `<p>那现在…?`(无闭标签)经旧代码规整后与上报串**逐字节相同**;
- (c) 顺带修一个幂等漏洞:落单 `</p>` 旧行为会越跑越多(`<p></p></p>`)。
修法(新增 paragraph_repair.go:配对修复 + 接线):
`<p><p>X</p>`⇒`<p>X</p>`;`<p>X`⇒补 `</p>`;`<p>正文<p>`⇒合并;`<p>A</p><p>`⇒
丢空开标签;`<p></p>`⇒整对丢;落单 `</p>`⇒丢标签正文不动。不吞正文护栏:
`<p`(无 `>`)/`<p2>`/`1 < 2`/`a<b>`/代码围栏/`<action>` 内 `<p>`/自闭合
`<p/>` 一律原样(每条都有用例)。`normalizePiece` 改为"补闭不补开"——在造出
畸形的那一点上根治。
验收(主会话复跑 + 亲自变异):go build ./... + vet 绿;turnreply /
gateway 全量 / channelbridge 三包绿;我用 -overlay 摘掉配对修复接线 ⇒
TestNormalizeParagraphs_* 变红。生产库只读核查:177 行 assistant 里**仅 1 行**
畸形(各消费方已容错)⇒ 不做一次性修复脚本。
⚠️ 未部署:跑着的 :10942 仍是旧产物 ⇒ 畸形仍会产出,安卓端的容错请继续保留。
156 lines
7.4 KiB
Go
156 lines
7.4 KiB
Go
package turnreply
|
||
|
||
import (
|
||
"regexp"
|
||
"sort"
|
||
"strings"
|
||
)
|
||
|
||
// 本文件承载助手文本的**段落包裹规整**:把「只包了最后一段」的半包回复,在写入
|
||
// 侧规整成「每个段落一个 <p>…</p>」的规范形态。
|
||
//
|
||
// 缺陷形状(安卓侧实测):模型的多数回复按段产出 `<p>…</p>`,但它偶尔**只包最后
|
||
// 一段**,前面几段是裸文本用空行分隔。客户端的解析规则是「**只要存在完整 <p> 块,
|
||
// 就不再按空行回退切分**」—— 于是半包恰好落在唯一会塌的那一侧:整条消息被渲染成
|
||
// 一整坨,而不是多个气泡。反过来,全都不包反而正常(客户端会按空行拆开)。
|
||
//
|
||
// 为什么放在这里(而不是各写一套):网关(Web/REST/WS 回合,见
|
||
// internal/services/gateway/session_turn_reply.go)与渠道桥(IM 回合、主动推送,
|
||
// 见 internal/services/channelbridge/turnreply.go、proactive.go)都必须吃到**同一份**
|
||
// 规整后的文本 —— 落库内容、WS `message_persisted`、REST reply、续跑尾部、IM 出站
|
||
// 气泡切分(splitBubbles 按 <p> 切)全部以它为准。两份实现迟早漂移,而这一份正是
|
||
// 客户端「看到一段还是一坨」的唯一判据。
|
||
//
|
||
// 判据与边界(逐条对应下面的实现):
|
||
// - **幂等**(最重要的守门断言):已完全包裹的文本**逐字不变**。规整只做加法,
|
||
// 绝不二次加工好文本;段落之间的分隔空白(几个空行、缩进)原样保留。
|
||
// - 空文本 / 纯空白:原样返回(绝不制造空段落)。
|
||
// - 含 markdown 代码围栏(```):**整条跳过**。围栏内部按空行拆开会把一段代码
|
||
// 切成互不相连的段落(比不规整更糟),无法安全判定时宁可不碰。
|
||
// - `<action>` / `<narration>` 块:**永不跨空行拆开**(被拆开的标签对会让 Web 与
|
||
// IM 都渲染出野生标签),它们随所在段落一起被包进**同一个** `<p>`。
|
||
// - `<p>` 块内部是原子的:块内的空行不参与分段,块本身不被二次包裹。
|
||
// - 收尾必过**标签配对修复**(paragraph_repair.go):落库/对外文本里不允许出现未配对
|
||
// 的段落标签(`<p><p>X</p>`、落单的开或闭),它们会被按「一个 `<p>` = 一段」合并
|
||
// 或补齐。该步同样幂等,且只增删标签本身,不吞正文。
|
||
var (
|
||
// atomRe 匹配文本里**不可再拆**的原子块:完整的 <p>…</p>(含自闭合 <p/>)与
|
||
// 完整的 <action>/<narration> 块。它们内部的空行不算段落分隔。
|
||
atomRe = regexp.MustCompile(`(?is)<p\b[^>]*?/>|<p\b[^>]*?>.*?</p\s*>` +
|
||
`|<action\b[^>]*?>.*?</action\s*>|<narration\b[^>]*?>.*?</narration\s*>`)
|
||
// paragraphBlockRe 只匹配完整的 <p>…</p> 块(含自闭合 <p/>),用于判断一段是否
|
||
// 已经被包裹。
|
||
paragraphBlockRe = regexp.MustCompile(`(?is)<p\b[^>]*?/>|<p\b[^>]*?>.*?</p\s*>`)
|
||
// blankLineRe 匹配分隔段落的**空行组**:两个及以上换行,中间只允许水平空白。
|
||
blankLineRe = regexp.MustCompile(`\n[ \t\r]*\n(?:[ \t\r]*\n)*`)
|
||
// codeFenceMarker 是 markdown 围栏代码块的起止标记(见到即整条跳过)。
|
||
codeFenceMarker = "```"
|
||
// trimCutset 是段落外缘的空白字符集,与 blankLineRe 的口径保持一致(空行组
|
||
// 只由这些字符构成),因此「外缘空白」与「段间空行」不会互相切错。
|
||
trimCutset = " \t\r\n\v\f"
|
||
)
|
||
|
||
// NormalizeParagraphs 返回规整后的助手文本:未被 `<p>` 包裹的段落补上 `<p>…</p>`,
|
||
// 已完整包裹的段落逐字保留,`<action>`/`<narration>` 段也包进它所在的 `<p>`;最后再
|
||
// 做一遍**标签配对修复**(未配对的段落标签按「一个 `<p>` = 一段」收干净,见
|
||
// paragraph_repair.go)。
|
||
//
|
||
// 它是纯函数且**幂等**(`NormalizeParagraphs(NormalizeParagraphs(s)) ==
|
||
// NormalizeParagraphs(s)`),空文本、纯空白、含代码围栏的文本原样返回。
|
||
func NormalizeParagraphs(text string) string {
|
||
if strings.TrimSpace(text) == "" || strings.Contains(text, codeFenceMarker) {
|
||
return text
|
||
}
|
||
cuts := paragraphCuts(text)
|
||
if len(cuts) == 0 {
|
||
return repairParagraphTags(normalizePiece(text))
|
||
}
|
||
var b strings.Builder
|
||
b.Grow(len(text) + 3*len(cuts))
|
||
prev := 0
|
||
for _, cut := range cuts {
|
||
b.WriteString(normalizePiece(text[prev:cut]))
|
||
prev = cut
|
||
}
|
||
b.WriteString(normalizePiece(text[prev:]))
|
||
return repairParagraphTags(b.String())
|
||
}
|
||
|
||
// paragraphCuts 返回文本里全部的**分段边界**(切点,升序去重):
|
||
// - 空行组的末尾(段落分隔);
|
||
// - 每个完整 `<p>` 块的起点与终点(`<p>` 块自成一段,绝不被包进另一个 `<p>`)。
|
||
//
|
||
// 落在原子块(`<p>` 块 / `<action>` / `<narration>`)内部的空行**不是**边界 ——
|
||
// 这正是「不把标签对切开」的实现方式。
|
||
func paragraphCuts(text string) []int {
|
||
atoms := atomRe.FindAllStringIndex(text, -1)
|
||
cuts := make([]int, 0, 2*len(atoms)+4)
|
||
for _, loc := range blankLineRe.FindAllStringIndex(text, -1) {
|
||
if insideAtoms(atoms, loc) {
|
||
continue
|
||
}
|
||
cuts = append(cuts, loc[1])
|
||
}
|
||
for _, loc := range atoms {
|
||
if isParagraphBlock(text[loc[0]:loc[1]]) {
|
||
cuts = append(cuts, loc[0], loc[1])
|
||
}
|
||
}
|
||
sort.Ints(cuts)
|
||
out := make([]int, 0, len(cuts))
|
||
for _, c := range cuts {
|
||
if len(out) > 0 && c == out[len(out)-1] {
|
||
continue
|
||
}
|
||
out = append(out, c)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// insideAtoms 报告 loc 是否完全落在某个原子块内部(原子块之间互不重叠,因此只需
|
||
// 逐个判断包含关系)。
|
||
func insideAtoms(atoms [][]int, loc []int) bool {
|
||
for _, a := range atoms {
|
||
if loc[0] >= a[0] && loc[1] <= a[1] {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// isParagraphBlock 报告 s 整体就是**一个**完整的 `<p>` 块(即该段已被包裹)。
|
||
func isParagraphBlock(s string) bool {
|
||
t := strings.TrimSpace(s)
|
||
if t == "" {
|
||
return false
|
||
}
|
||
loc := paragraphBlockRe.FindStringIndex(t)
|
||
return loc != nil && loc[0] == 0 && loc[1] == len(t)
|
||
}
|
||
|
||
// normalizePiece 规整相邻两个切点之间的一段原文。切点已经含**所有**位于原子块
|
||
// 之外的空行组,因此这一段**恰好就是一段**(它内部若还有空行,那只可能落在
|
||
// `<action>`/`<narration>` 块里,必须原样保留)。已是 `<p>` 块或纯空白时逐字返回;
|
||
// 否则整段包上 `<p>`,这一段自己的首尾空白(它同时是相邻 `<p>` 块之间的分隔)
|
||
// 原样保留。
|
||
//
|
||
// **例外(补闭不补开)**:段首已经是一个段落开标签(模型漏了闭标签,如 `<p>X`)时
|
||
// **只补 `</p>`、不再套外层 `<p>`** —— 再套一层就造出 `<p><p>X</p>`:客户端的非贪婪
|
||
// 契约会把 `<p>X` 当成气泡内容,用户直接看到字面的 `<p>`。这一处与 paragraph_repair.go
|
||
// 的配对修复是同一条根因的两半(详见该文件头)。
|
||
func normalizePiece(piece string) string {
|
||
if isParagraphBlock(piece) || strings.TrimSpace(piece) == "" {
|
||
return piece
|
||
}
|
||
lead := len(piece) - len(strings.TrimLeft(piece, trimCutset))
|
||
trail := len(piece) - len(strings.TrimRight(piece, trimCutset))
|
||
core := piece[lead : len(piece)-trail]
|
||
if strings.TrimSpace(core) == "" {
|
||
return piece
|
||
}
|
||
if startsWithParagraphOpen(core) {
|
||
return piece[:lead] + core + "</p>" + piece[len(piece)-trail:]
|
||
}
|
||
return piece[:lead] + "<p>" + core + "</p>" + piece[len(piece)-trail:]
|
||
}
|