Files
Cyrene_v2/internal/runtime/turnreply/paragraph_repair_test.go
T
AskaEth 8051906afa
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(turnreply): 产出侧不再产出未配对的段落标签(安卓报障的回执)
安卓客户端登记(他们文档 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 仍是旧产物 ⇒ 畸形仍会产出,安卓端的容错请继续保留。
2026-10-07 10:07:13 +08:00

204 lines
9.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 ""
}