安卓客户端登记(他们文档 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 仍是旧产物 ⇒ 畸形仍会产出,安卓端的容错请继续保留。
204 lines
9.3 KiB
Go
204 lines
9.3 KiB
Go
package turnreply
|
||
|
||
import (
|
||
"regexp"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// 本文件锁定「未配对段落标签」的修复口径(paragraph_repair.go)。
|
||
//
|
||
// 上报形状(安卓端 02-前后端协同事项.md):某条助手消息的 content **原始值**里,第三段
|
||
// 是**两个开标签一个闭标签** `<p><p>正文</p>`。客户端的解析契约
|
||
// `P_BLOCK_RE = <p(\s[^>]*)?>([\s\S]*?)</p>` 是**非贪婪**的 ⇒ 它必然把 `<p>正文` 当成
|
||
// 气泡内容 ⇒ 用户直接看到字面的 `<p>`。任何按该契约实现的消费方(Web 面板 / IM 桥 /
|
||
// 第三方)都会踩同一条,因此修在**产出侧**(落库/对外文本里不允许有未配对的段落标签)。
|
||
//
|
||
// 两个断言助手分别对应两层:
|
||
// - unpairedParagraphTag:标签配对本身(不出现落单的开或闭、不嵌套);
|
||
// - clientBubblesWithLiteralTags:**按客户端契约**拆出的气泡正文里还残留字面 `<p>`
|
||
// 标签 —— 它就是上报症状本身,且**不依赖**实现用的正则(独立口径)。
|
||
|
||
// reportedUnpairedReply 是上报原串(逐字照抄,`\n` 是真换行)。
|
||
const reportedUnpairedReply = "<p><action>满意地点点头</action></p>\n" +
|
||
"<p>真乖♪ 这可是今天最让人家放心的一句。</p>\n" +
|
||
"<p><p>那现在——想让人家陪你看会儿晚霞,还是安静坐着发会儿呆?</p>"
|
||
|
||
// reportedUnpairedFixed 是上报原串的**目标形态**:只有那处双开标签被合并(
|
||
// `<p><p>正文</p>` ⇒ `<p>正文</p>`),其余一字不动。
|
||
const reportedUnpairedFixed = "<p><action>满意地点点头</action></p>\n" +
|
||
"<p>真乖♪ 这可是今天最让人家放心的一句。</p>\n" +
|
||
"<p>那现在——想让人家陪你看会儿晚霞,还是安静坐着发会儿呆?</p>"
|
||
|
||
// TestNormalizeParagraphs_ReportedUnpairedTagIsMerged 是必测①:上报原串规整后只剩
|
||
// 恰好一处改动 —— 第三段合并成一个 `<p>`。
|
||
func TestNormalizeParagraphs_ReportedUnpairedTagIsMerged(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
got := NormalizeParagraphs(reportedUnpairedReply)
|
||
if got != reportedUnpairedFixed {
|
||
t.Fatalf("规整结果 =\n%q\nwant\n%q", got, reportedUnpairedFixed)
|
||
}
|
||
if strings.Count(got, "<p>") != 3 || strings.Count(got, "</p>") != 3 {
|
||
t.Fatalf("段落标签数不配对:<p>=%d </p>=%d\n%q",
|
||
strings.Count(got, "<p>"), strings.Count(got, "</p>"), got)
|
||
}
|
||
if bad := unpairedParagraphTag(got); bad != "" {
|
||
t.Fatalf("仍有未配对段落标签 %q:\n%q", bad, got)
|
||
}
|
||
if bad := clientBubblesWithLiteralTags(got); bad != "" {
|
||
t.Fatalf("按客户端契约拆出的气泡里仍有字面标签 %q:\n%q", bad, got)
|
||
}
|
||
}
|
||
|
||
// TestNormalizeParagraphs_UnpairedTagShapes 是必测②:四种畸形形状逐条对齐口径
|
||
// (合并冗余重开 / 合并「闭标签缺失后重开」/ 丢掉尾部空开标签 / 丢掉落单闭标签)。
|
||
func TestNormalizeParagraphs_UnpairedTagShapes(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
cases := []struct {
|
||
name string
|
||
in string
|
||
want string
|
||
}{
|
||
// (1) 双开标签:模型原样吐出(非贪婪块正则把它当「一个完整块」放过),也覆盖
|
||
// 「模型末段漏闭标签 ⇒ normalizePiece 亲手再套一层」造出来的同一种形状。
|
||
{"双开标签", "<p><p>正文。</p>", "<p>正文。</p>"},
|
||
{"双开标签落在多段中间", "<p>第一。</p>\n\n<p><p>第二。</p>", "<p>第一。</p>\n\n<p>第二。</p>"},
|
||
{"末段漏闭标签(规整器补包裹)", "<p>第一。</p>\n\n<p>第二。", "<p>第一。</p>\n\n<p>第二。</p>"},
|
||
// 中段漏闭标签:段首已开段落 ⇒ **只补闭、不再套一层**(段落边界因此保住;
|
||
// 若套一层,配对修复会把两段合并成一段 —— 「一个 `<p>` = 一段」丢结构)。
|
||
{"中段漏闭标签保住段落边界", "<p>第一。\n\n第二。", "<p>第一。</p>\n\n<p>第二。</p>"},
|
||
// 「只补闭不补开」也保住了模型自己写的开标签(含属性)—— 不套外层就不会被
|
||
// 配对修复的「冗余重开合并」把属性一起丢掉。
|
||
{"带属性的漏闭段落保住开标签", "<p class=\"x\">正文。", "<p class=\"x\">正文。</p>"},
|
||
// 整条只有一个落单开标签:把它补成一个**配对**的空段落(paired),而不是把一条
|
||
// 非空消息收成空串(见 repairParagraphTags 的收尾防御)。
|
||
{"整条只有一个落单开标签", "<p>", "<p></p>"},
|
||
// (2) 闭标签缺失后又重开:第二个开标签之后没有内容 ⇒ 合并进退,不吞正文。
|
||
{"闭标签缺失后重开", "<p>正文<p>", "<p>正文</p>"},
|
||
// (3) 尾部落单开标签:内容区全是空白 ⇒ 丢掉(`<p></p>` 在客户端是一个空气泡)。
|
||
{"尾部落单开标签", "<p>正文。</p><p>", "<p>正文。</p>"},
|
||
{"尾部空开标签被丢掉", "正文。<p>", "<p>正文。</p>"},
|
||
// (4) 落单闭标签:没有开着的段落 ⇒ 丢掉闭标签,正文一字不动。
|
||
{"落单闭标签", "<p>正文。</p></p>", "<p>正文。</p>"},
|
||
{"裸正文带落单闭标签", "正文。</p>", "<p>正文。</p>"},
|
||
{"双开双闭", "<p><p>X</p></p>", "<p>X</p>"},
|
||
// 空段落对:内容区无正文,整对丢掉(理由同上,不吞正文)。
|
||
{"空段落对", "<p>正文。</p>\n\n<p></p>", "<p>正文。</p>\n\n"},
|
||
}
|
||
for _, c := range cases {
|
||
t.Run(c.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
got := NormalizeParagraphs(c.in)
|
||
if got != c.want {
|
||
t.Fatalf("规整 = %q, want %q", got, c.want)
|
||
}
|
||
if bad := unpairedParagraphTag(got); bad != "" {
|
||
t.Fatalf("仍有未配对段落标签 %q:%q", bad, got)
|
||
}
|
||
if bad := clientBubblesWithLiteralTags(got); bad != "" {
|
||
t.Fatalf("客户端契约下气泡里仍有字面标签 %q:%q", bad, got)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestNormalizeParagraphs_UnpairedShapeIsIdempotent 是必测③:畸形形状经过一次规整后
|
||
// 必须**稳定**(第二次逐字相同)—— 修复前的 `正文。</p>` 第二次运行还会继续变(不幂等),
|
||
// 这条用例把它钉住。
|
||
func TestNormalizeParagraphs_UnpairedShapeIsIdempotent(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
shapes := []string{
|
||
reportedUnpairedReply,
|
||
"<p><p>正文。</p>",
|
||
"<p>正文<p>",
|
||
"<p>正文。</p><p>",
|
||
"<p>正文。</p></p>",
|
||
"正文。</p>",
|
||
"<p><p>X</p></p>",
|
||
"<p>正文。</p>\n\n<p></p>",
|
||
}
|
||
for _, s := range shapes {
|
||
once := NormalizeParagraphs(s)
|
||
if twice := NormalizeParagraphs(once); twice != once {
|
||
t.Fatalf("不幂等:\n in=%q\n once=%q\ntwice=%q", s, once, twice)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestNormalizeParagraphs_NonTagAngleBracketsStayVerbatim 是必测④(护栏):不是段落标签
|
||
// 的尖括号内容(残缺 `<p`、`<p2>`、`1 < 2`、`a<b`、代码围栏里的 `<p>`)一律不进修复
|
||
// 逻辑,正文逐字保留。
|
||
func TestNormalizeParagraphs_NonTagAngleBracketsStayVerbatim(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
cases := []struct {
|
||
name string
|
||
in string
|
||
want string
|
||
}{
|
||
{"小于号与裸 a<b", "1 < 2 且 a<b。", "<p>1 < 2 且 a<b。</p>"},
|
||
{"残缺无 > 的半标签", "<p>残缺的半标签 <p 保留", "<p>残缺的半标签 <p 保留</p>"},
|
||
// `<p2>` 不是段落标签(p 后紧跟非空白)⇒ 它只是正文,因此仍按裸段落被包一层。
|
||
{"<p2> 不是段落标签", "<p2>正文</p2>", "<p><p2>正文</p2></p>"},
|
||
// 围栏文本整条跳过:里面的 `<p>` 是代码,一个字符都不许动(含不配对的情形)。
|
||
{"围栏内的未配对标签", "看这段:\n\n```html\n<p><p>X</p>\n```", "看这段:\n\n```html\n<p><p>X</p>\n```"},
|
||
// <action> 块内的 `<p>` 字面量随块原样保留,不触发配对机器。
|
||
{"动作块内的字面 <p>", "<action>写着 <p> 两个字</action>\n\n第二段。",
|
||
"<p><action>写着 <p> 两个字</action></p>\n\n<p>第二段。</p>"},
|
||
// 自闭合 <p/> 是既有合法形状:保留,且不算空段落。
|
||
{"自闭合段落保留", "<p>正文。</p>\n\n<p/>", "<p>正文。</p>\n\n<p/>"},
|
||
}
|
||
for _, c := range cases {
|
||
t.Run(c.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
if got := NormalizeParagraphs(c.in); got != c.want {
|
||
t.Fatalf("规整 = %q, want %q", got, c.want)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// clientBlockRe 是安卓端契约 P_BLOCK_RE 的**逐字口径**(非贪婪)。
|
||
var clientBlockRe = regexp.MustCompile(`(?s)<p(\s[^>]*)?>([\s\S]*?)</p>`)
|
||
|
||
// clientBubblesWithLiteralTags 按客户端契约拆出气泡正文,返回其中**仍含字面 `<p>` /
|
||
// `</p>`** 的那一条(没有则返回空串)。上报症状正是「非贪婪契约把 `<p>正文` 当内容 ⇒
|
||
// 用户看到字面 `<p>`」,因此这个助手直接复现症状,且与实现用的正则无关。
|
||
func clientBubblesWithLiteralTags(text string) string {
|
||
for _, m := range clientBlockRe.FindAllStringSubmatch(text, -1) {
|
||
if strings.Contains(m[2], "<p>") || strings.Contains(m[2], "</p>") {
|
||
return m[2]
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// unpairedParagraphTag 返回 text 里第一个**未配对**的段落标签(没有则返回空串):
|
||
// 落单的闭标签、嵌套/落单的开标签、或文本结束时仍开着的段落。
|
||
func unpairedParagraphTag(text string) string {
|
||
depth := 0
|
||
for _, loc := range paragraphTagRe.FindAllStringIndex(text, -1) {
|
||
tag := text[loc[0]:loc[1]]
|
||
switch {
|
||
case isSelfClosingTag(tag):
|
||
case isClosingTag(tag):
|
||
if depth == 0 {
|
||
return tag
|
||
}
|
||
depth--
|
||
default:
|
||
if depth > 0 {
|
||
return tag
|
||
}
|
||
depth++
|
||
}
|
||
}
|
||
if depth > 0 {
|
||
return "<p>"
|
||
}
|
||
return ""
|
||
}
|