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 仍是旧产物 ⇒ 畸形仍会产出,安卓端的容错请继续保留。
This commit is contained in:
1 parent
378faf212e
commit
8051906afa
4 files changed
+449
-3
No files matched your search
@@ -30,6 +30,9 @@ import (
|
||||
// - `<action>` / `<narration>` 块:**永不跨空行拆开**(被拆开的标签对会让 Web 与
|
||||
// IM 都渲染出野生标签),它们随所在段落一起被包进**同一个** `<p>`。
|
||||
// - `<p>` 块内部是原子的:块内的空行不参与分段,块本身不被二次包裹。
|
||||
// - 收尾必过**标签配对修复**(paragraph_repair.go):落库/对外文本里不允许出现未配对
|
||||
// 的段落标签(`<p><p>X</p>`、落单的开或闭),它们会被按「一个 `<p>` = 一段」合并
|
||||
// 或补齐。该步同样幂等,且只增删标签本身,不吞正文。
|
||||
var (
|
||||
// atomRe 匹配文本里**不可再拆**的原子块:完整的 <p>…</p>(含自闭合 <p/>)与
|
||||
// 完整的 <action>/<narration> 块。它们内部的空行不算段落分隔。
|
||||
@@ -48,7 +51,9 @@ var (
|
||||
)
|
||||
|
||||
// NormalizeParagraphs 返回规整后的助手文本:未被 `<p>` 包裹的段落补上 `<p>…</p>`,
|
||||
// 已完整包裹的段落逐字保留,`<action>`/`<narration>` 段也包进它所在的 `<p>`。
|
||||
// 已完整包裹的段落逐字保留,`<action>`/`<narration>` 段也包进它所在的 `<p>`;最后再
|
||||
// 做一遍**标签配对修复**(未配对的段落标签按「一个 `<p>` = 一段」收干净,见
|
||||
// paragraph_repair.go)。
|
||||
//
|
||||
// 它是纯函数且**幂等**(`NormalizeParagraphs(NormalizeParagraphs(s)) ==
|
||||
// NormalizeParagraphs(s)`),空文本、纯空白、含代码围栏的文本原样返回。
|
||||
@@ -58,7 +63,7 @@ func NormalizeParagraphs(text string) string {
|
||||
}
|
||||
cuts := paragraphCuts(text)
|
||||
if len(cuts) == 0 {
|
||||
return normalizePiece(text)
|
||||
return repairParagraphTags(normalizePiece(text))
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(text) + 3*len(cuts))
|
||||
@@ -68,7 +73,7 @@ func NormalizeParagraphs(text string) string {
|
||||
prev = cut
|
||||
}
|
||||
b.WriteString(normalizePiece(text[prev:]))
|
||||
return b.String()
|
||||
return repairParagraphTags(b.String())
|
||||
}
|
||||
|
||||
// paragraphCuts 返回文本里全部的**分段边界**(切点,升序去重):
|
||||
@@ -128,6 +133,11 @@ func isParagraphBlock(s string) bool {
|
||||
// `<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
|
||||
@@ -138,5 +148,8 @@ func normalizePiece(piece string) string {
|
||||
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:]
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package turnreply
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 本文件承载段落规整的**最后一步:标签配对修复**(NormalizeParagraphs 收尾调用)。
|
||||
//
|
||||
// 为什么需要单独一步:paragraph.go 的包裹逻辑原先只做「加法」——它判断一段是否已被
|
||||
// `<p>` 包裹用的是**非贪婪**块正则 `<p…>.*?</p>`,因此对下面两类畸形它既看不见、
|
||||
// 也修不了,甚至会**亲手造出来**:
|
||||
//
|
||||
// - **原样放过**(模型直接吐出双开标签):`<p><p>X</p>` 被非贪婪正则当成**一个**
|
||||
// 完整块(内容 = `<p>X`)⇒ isParagraphBlock 为真 ⇒ 逐字保留。而客户端的解析契约
|
||||
// `P_BLOCK_RE = <p(\s[^>]*)?>([\s\S]*?)</p>` 是**同一个非贪婪口径** ⇒ 用户看到
|
||||
// 字面的 `<p>X`。安卓端上报的正是这条(未配对的段落标签进入落库/对外文本)。
|
||||
// - **亲手制造**(模型末段漏了闭标签):模型吐 `<p>X`(无 `</p>`)时,normalizePiece
|
||||
// 只检查「整段是不是一个完整块」,见它不是块就**再套一层** ⇒ `<p><p>X</p>`。
|
||||
// (已在 paragraph.go/normalizePiece 按「补闭不补开」修掉,这里再兜一次底。)
|
||||
//
|
||||
// 顺带修掉一个既有幂等性漏洞:落单闭标签(`正文。</p>`)会被当作「未包裹的裸段落」再包
|
||||
// 一层 ⇒ `<p>正文。</p></p>`,而且**第二次运行还会继续变**(不幂等)——同一个「只做加法、
|
||||
// 不做配对校验」的根因。
|
||||
//
|
||||
// 契约(唯一口径):**一个 `<p>` = 一段**,段落不嵌套。因此修复后文本里**不允许**出现
|
||||
// 未配对的段落标签(落单的开或闭)。逐条处置(实现见 planParagraphTags):
|
||||
//
|
||||
// - `<p><p>X</p>`(冗余重开)⇒ 合并为 `<p>X</p>`:外层已经在包这一整段,里层是
|
||||
// 同一个段落的重复开标签(客户端只能看到一个段落)。
|
||||
// - `<p>X`(漏闭标签)⇒ 补上 `</p>`(内容是真的,它就是这个段落的正文)。
|
||||
// - `<p>正文<p>`(闭标签缺失后又重开)⇒ 合并为 `<p>正文</p>`:第二个开标签之后没有
|
||||
// 任何内容,它不是「另一段」,只是同段的重复开标签;合并**不吞一个字**。
|
||||
// - `<p>A</p><p>`(尾部落单开标签)⇒ 丢掉那个空开标签(`<p>A</p>`):它的内容区
|
||||
// 全是空白,「一个空 `<p>`」在客户端就是**一个空气泡**,比丢标签更糟。
|
||||
// - 空段落对 `<p></p>` / `<p> </p>` ⇒ 整对丢掉,理由同上(不吞正文:内容区无正文)。
|
||||
// - 落单闭标签 `…</p>`(没有开着的段落)⇒ 丢掉闭标签,正文一字不动。
|
||||
//
|
||||
// **收尾防御(唯一的例外)**:若按上述规则要把**整条文本**收成空串(例如整条只有一个
|
||||
// `<p>`),则保持规整那一步的文本不动 —— 绝不把一条非空消息收成空消息(那比留下一个空
|
||||
// 段落严重得多)。此时留下的段落对仍然是**配对**的(`<p>` ⇒ `<p></p>`),因此不违反
|
||||
// 「不留未配对段落标签」这条契约。
|
||||
//
|
||||
// **一个已知的保守处置**(如实记下,不装作修好):`<p>A\n\n<p>B</p>` 这种「未闭合段落之后
|
||||
// 又出现开标签、且两者之间有正文」的形状按**合并**处理 ⇒ `<p>A\n\nB</p>`。要保住两个段落
|
||||
// 就得在段间空白处**插入**一个隐式 `</p>`,那比丢一个重复标签更凶(改动正文布局);而该
|
||||
// 形状在生产库里为 0 例(2026-10-06 只读统计:全库 1 条畸形,形状是本文档开头那条双开)。
|
||||
// 真出现了再按设计裁决改成插入式收尾。
|
||||
//
|
||||
// 不碰的东西(**不吞正文**这条护栏):
|
||||
// - `<p`(残缺、无 `>`)、`<p2>`、`</p2`、`1 < 2`、`a<b`:paragraphTagRe 只认真正的
|
||||
// `<p>`/`<p …>`/`</p>`/`<p …/>`,其余一律当普通正文,逐字保留。属性区**不允许**含
|
||||
// `<`(`[^<>]*`)—— 否则 `<p 保留</p>` 会被当成一个带属性的开标签,连正文一起丢掉。
|
||||
// - 含 ``` 围栏的文本:NormalizeParagraphs 在进入本步**之前**就整条返回(围栏里的
|
||||
// `<p>` 是代码,不是标记)。
|
||||
// - `<action>`/`<narration>` 块内部的 `<p>` 字面量:整块视为不可解析的正文(与
|
||||
// paragraph.go「原子块内部不碰」同一口径),原样复制、不参与配对计数。
|
||||
// - 自闭合 `<p/>`:既有的合法形状,原样保留(不配对、也不算「空段落」)。
|
||||
var (
|
||||
// paragraphTagRe 只认真正的段落标签:`<p>`、`<p …>`、`</p>`、`</p >`、`<p/>`、
|
||||
// `<p … />`。`<p2>`(p 后紧跟非空白)与 `<p`(无 `>`)都不匹配 —— 这正是
|
||||
// 「`1 < 2`、`a<b`、`<p2>` 原样保留」的实现方式。
|
||||
paragraphTagRe = regexp.MustCompile(`(?i)</?p(?:\s[^<>]*)?/?>`)
|
||||
// opaqueBlockRe 只认 `<action>`/`<narration>` 块(**不含** `<p>` 块本身 ——
|
||||
// paragraph.go 的 atomRe 含 `<p>` 块,用它会把「块内嵌套的 `<p>`」也排除掉,
|
||||
// 而那恰恰是本文件要修的形状)。块内的 `<p>` 字面量随块原样保留。
|
||||
opaqueBlockRe = regexp.MustCompile(`(?is)<action\b[^>]*?>.*?</action\s*>|<narration\b[^>]*?>.*?</narration\s*>`)
|
||||
)
|
||||
|
||||
// repairParagraphTags 是本步的入口:把 text 里**未配对**的段落标签收干净(规则见
|
||||
// 文件头)。它只增删 `<p>`/`</p>` 标签本身,正文(含标签之间的空白)逐字保留;已配对
|
||||
// 且不嵌套的规范文本原样返回(幂等在写入路径上的那一半)。调用方必须自己确保 text
|
||||
// 不含代码围栏(NormalizeParagraphs 在围栏处提前返回)。
|
||||
func repairParagraphTags(text string) string {
|
||||
locs := paragraphTagRe.FindAllStringIndex(text, -1)
|
||||
if len(locs) == 0 {
|
||||
return text
|
||||
}
|
||||
keep, appendClose := planParagraphTags(text, locs)
|
||||
if appendClose == "" && allKept(keep) {
|
||||
return text
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(text) + len(appendClose))
|
||||
prev := 0
|
||||
for i, loc := range locs {
|
||||
b.WriteString(text[prev:loc[0]])
|
||||
if keep[i] {
|
||||
b.WriteString(text[loc[0]:loc[1]])
|
||||
}
|
||||
prev = loc[1]
|
||||
}
|
||||
b.WriteString(text[prev:])
|
||||
b.WriteString(appendClose)
|
||||
out := b.String()
|
||||
if strings.TrimSpace(out) == "" {
|
||||
return text // 绝不把非空文本收成空文本(只剩空段落对时的防御)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// planParagraphTags 扫一遍段落标签,给出每个标签的去留(keep)与需要补在文本末尾的
|
||||
// 闭标签(appendClose,空串表示不用补)。
|
||||
//
|
||||
// 状态只有两个:depth ∈ {0,1}(段落不嵌套)与 blankSinceOpen(当前**打开中**段落的
|
||||
// 内容区到目前为止是否全是空白)。后者是「冗余重开」「空段落」两处判定的唯一依据:
|
||||
//
|
||||
// - depth==1 时再遇到开标签 ⇒ 那一段的重复开标签(内容已合并进退):丢掉;
|
||||
// - 关闭一段时内容区全空白 ⇒ 空段落(`<p></p>`):开标签与闭标签一起丢掉;
|
||||
// - 文本末尾仍开着且有正文的段落 ⇒ 漏了闭标签:补 `</p>`(内容一字不动);
|
||||
// - 文本末尾仍开着但内容全空白 ⇒ 尾部空段落:丢掉开标签(不制造空气泡)。
|
||||
//
|
||||
// 落单的闭标签(depth==0)一律丢掉:没有开着的段落可关,正文一字不动。
|
||||
func planParagraphTags(text string, locs [][]int) (keep []bool, appendClose string) {
|
||||
keep = make([]bool, len(locs))
|
||||
opaque := opaqueBlockRe.FindAllStringIndex(text, -1)
|
||||
depth, openTagIdx, blankSinceOpen, prevEnd := 0, -1, true, 0
|
||||
for i, loc := range locs {
|
||||
if strings.TrimSpace(text[prevEnd:loc[0]]) != "" {
|
||||
blankSinceOpen = false // 标签之间的正文 ⇒ 当前段落不是空的
|
||||
}
|
||||
prevEnd = loc[1]
|
||||
if insideAtoms(opaque, loc) {
|
||||
keep[i] = true // <action>/<narration> 块内的 <p> 字面量:原样保留,不计数
|
||||
blankSinceOpen = false
|
||||
continue
|
||||
}
|
||||
tag := text[loc[0]:loc[1]]
|
||||
switch {
|
||||
case isSelfClosingTag(tag):
|
||||
keep[i] = true
|
||||
case isClosingTag(tag):
|
||||
if depth == 0 {
|
||||
continue // 落单闭标签:丢掉
|
||||
}
|
||||
if blankSinceOpen {
|
||||
keep[openTagIdx] = false // 空段落:开标签与闭标签整对丢掉
|
||||
depth, openTagIdx = 0, -1
|
||||
continue
|
||||
}
|
||||
keep[i] = true
|
||||
depth, openTagIdx = 0, -1
|
||||
default:
|
||||
if depth == 1 {
|
||||
continue // 冗余重开:合并进退(客户端看到的 `<p><p>X</p>` 走这一支)
|
||||
}
|
||||
keep[i] = true
|
||||
depth, openTagIdx, blankSinceOpen = 1, i, true
|
||||
}
|
||||
}
|
||||
if depth == 1 {
|
||||
if strings.TrimSpace(text[prevEnd:]) != "" {
|
||||
blankSinceOpen = false
|
||||
}
|
||||
if blankSinceOpen {
|
||||
keep[openTagIdx] = false // 尾部空段落:丢掉开标签
|
||||
} else {
|
||||
appendClose = "</p>" // 漏闭标签:补上,内容就是这个段落的正文
|
||||
}
|
||||
}
|
||||
return keep, appendClose
|
||||
}
|
||||
|
||||
// startsWithParagraphOpen 报告 s 的开头是不是一个**段落开标签**(`<p>` / `<p …>`):
|
||||
// 自闭合 `<p/>` 与闭标签 `</p>` 都不算。归一化(normalizePiece)用它决定「补闭不补开」——
|
||||
// 段首已经开了段落就只补上 `</p>`,绝不再套一层(那正是 `<p><p>X</p>` 的来历)。
|
||||
func startsWithParagraphOpen(s string) bool {
|
||||
loc := paragraphTagRe.FindStringIndex(s)
|
||||
if loc == nil || loc[0] != 0 {
|
||||
return false
|
||||
}
|
||||
tag := s[loc[0]:loc[1]]
|
||||
return !isSelfClosingTag(tag) && !isClosingTag(tag)
|
||||
}
|
||||
|
||||
// isSelfClosingTag 报告 tag 是不是自闭合段落标签(`<p/>`、`<p … />`)。它是既有的合法
|
||||
// 形状,原样保留,也不参与配对计数。
|
||||
func isSelfClosingTag(tag string) bool {
|
||||
return strings.HasSuffix(strings.TrimSuffix(tag, ">"), "/")
|
||||
}
|
||||
|
||||
// isClosingTag 报告 tag 是不是闭标签(`</p>`)。tag 由 paragraphTagRe 产出,因此这里
|
||||
// 只需看前缀。
|
||||
func isClosingTag(tag string) bool {
|
||||
return strings.HasPrefix(tag, "</")
|
||||
}
|
||||
|
||||
// allKept 报告所有标签都被保留(无改动)时 repairParagraphTags 可以原样返回 text。
|
||||
func allKept(keep []bool) bool {
|
||||
for _, k := range keep {
|
||||
if !k {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
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 ""
|
||||
}
|
||||
@@ -107,6 +107,40 @@ func TestTurnEnd_FullyBareReplyIsRegularized(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestTurnEnd_UnpairedParagraphTagIsMergedBeforePersist 是必测⑤(网关侧,真 loop + 真
|
||||
// SQLite):上报的**未配对段落标签**形状(`<p><p>正文</p>`)落库前被合并成一个 `<p>`。
|
||||
// 断言的就是客户端从历史接口拿到的那份 content —— 「任何按非贪婪 `<p>` 契约解析的消费方
|
||||
// 都会看到字面 `<p>`」这条症状在**产出侧**被掐掉。
|
||||
//
|
||||
// 字面量与 turnreply/paragraph_repair_test.go 的 reportedUnpairedReply / …Fixed
|
||||
// 逐字相同(两侧共用同一份上报原串)。
|
||||
func TestTurnEnd_UnpairedParagraphTagIsMergedBeforePersist(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const reported = "<p><action>满意地点点头</action></p>\n" +
|
||||
"<p>真乖♪ 这可是今天最让人家放心的一句。</p>\n" +
|
||||
"<p><p>那现在——想让人家陪你看会儿晚霞,还是安静坐着发会儿呆?</p>"
|
||||
const want = "<p><action>满意地点点头</action></p>\n" +
|
||||
"<p>真乖♪ 这可是今天最让人家放心的一句。</p>\n" +
|
||||
"<p>那现在——想让人家陪你看会儿晚霞,还是安静坐着发会儿呆?</p>"
|
||||
f := newKBTurnFixture(t, &kbScriptAdapter{toolSteps: 0, final: reported}, 0)
|
||||
res, err := f.submit(t)
|
||||
if err != nil {
|
||||
t.Fatalf("回合不该报错: %v", err)
|
||||
}
|
||||
assistant := f.assertUserAndSingleAssistant(t)
|
||||
if assistant.Content != want {
|
||||
t.Fatalf("落库内容 =\n%q\nwant\n%q", assistant.Content, want)
|
||||
}
|
||||
// res.Text 与落库同值 ⇒ REST reply / WS 事件 / 续跑尾部拿到的是同一份。
|
||||
if res.Text != want {
|
||||
t.Fatalf("Result.Text =\n%q\nwant 与落库同一份\n%q", res.Text, want)
|
||||
}
|
||||
if strings.Contains(assistant.Content, "<p><p>") {
|
||||
t.Fatalf("落库内容里仍有双开段落标签:%q", assistant.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTurnEnd_NonChatModeReplyKeepsItsShape 守住规整的**作用范围**:非 chat 模式
|
||||
// (code/home/work/docs…)的消息在客户端是**按原样整体渲染**的(design/07 §2),
|
||||
// 往里塞 `<p>` 只会在那些形态里露出野生标签 —— 因此规整只对 chat 模式生效,
|
||||
|
||||
Reference in new issue
Block a user