package turnreply import ( "regexp" "strings" ) // 本文件承载段落规整的**最后一步:标签配对修复**(NormalizeParagraphs 收尾调用)。 // // 为什么需要单独一步:paragraph.go 的包裹逻辑原先只做「加法」——它判断一段是否已被 // `
` 包裹用的是**非贪婪**块正则 `
.*?
`,因此对下面两类畸形它既看不见、 // 也修不了,甚至会**亲手造出来**: // // - **原样放过**(模型直接吐出双开标签):`X
` 被非贪婪正则当成**一个** // 完整块(内容 = `X`)⇒ isParagraphBlock 为真 ⇒ 逐字保留。而客户端的解析契约 // `P_BLOCK_RE =
]*)?>([\s\S]*?)
` 是**同一个非贪婪口径** ⇒ 用户看到 // 字面的 `X`。安卓端上报的正是这条(未配对的段落标签进入落库/对外文本)。 // - **亲手制造**(模型末段漏了闭标签):模型吐 `
X`(无 `
`)时,normalizePiece // 只检查「整段是不是一个完整块」,见它不是块就**再套一层** ⇒ `X
`。 // (已在 paragraph.go/normalizePiece 按「补闭不补开」修掉,这里再兜一次底。) // // 顺带修掉一个既有幂等性漏洞:落单闭标签(`正文。`)会被当作「未包裹的裸段落」再包 // 一层 ⇒ `正文。
`,而且**第二次运行还会继续变**(不幂等)——同一个「只做加法、 // 不做配对校验」的根因。 // // 契约(唯一口径):**一个 `` = 一段**,段落不嵌套。因此修复后文本里**不允许**出现 // 未配对的段落标签(落单的开或闭)。逐条处置(实现见 planParagraphTags): // // - `
X
`(冗余重开)⇒ 合并为 `X
`:外层已经在包这一整段,里层是 // 同一个段落的重复开标签(客户端只能看到一个段落)。 // - `X`(漏闭标签)⇒ 补上 `
`(内容是真的,它就是这个段落的正文)。 // - `正文
`(闭标签缺失后又重开)⇒ 合并为 `
正文
`:第二个开标签之后没有 // 任何内容,它不是「另一段」,只是同段的重复开标签;合并**不吞一个字**。 // - `A
`(尾部落单开标签)⇒ 丢掉那个空开标签(`
A
`):它的内容区 // 全是空白,「一个空 ``」在客户端就是**一个空气泡**,比丢标签更糟。 // - 空段落对 `
` / `` ⇒ 整对丢掉,理由同上(不吞正文:内容区无正文)。 // - 落单闭标签 `…`(没有开着的段落)⇒ 丢掉闭标签,正文一字不动。 // // **收尾防御(唯一的例外)**:若按上述规则要把**整条文本**收成空串(例如整条只有一个 // `
`),则保持规整那一步的文本不动 —— 绝不把一条非空消息收成空消息(那比留下一个空 // 段落严重得多)。此时留下的段落对仍然是**配对**的(`
` ⇒ `
`),因此不违反 // 「不留未配对段落标签」这条契约。 // // **一个已知的保守处置**(如实记下,不装作修好):`A\n\n
B
` 这种「未闭合段落之后 // 又出现开标签、且两者之间有正文」的形状按**合并**处理 ⇒ `A\n\nB
`。要保住两个段落 // 就得在段间空白处**插入**一个隐式 ``,那比丢一个重复标签更凶(改动正文布局);而该 // 形状在生产库里为 0 例(2026-10-06 只读统计:全库 1 条畸形,形状是本文档开头那条双开)。 // 真出现了再按设计裁决改成插入式收尾。 // // 不碰的东西(**不吞正文**这条护栏): // - ``)、`
`/`
`/``,其余一律当普通正文,逐字保留。属性区**不允许**含 // `<`(`[^<>]*`)—— 否则 `` 会被当成一个带属性的开标签,连正文一起丢掉。 // - 含 ``` 围栏的文本:NormalizeParagraphs 在进入本步**之前**就整条返回(围栏里的 // `
` 是代码,不是标记)。
// - ` ` 字面量:整块视为不可解析的正文(与
// paragraph.go「原子块内部不碰」同一口径),原样复制、不参与配对计数。
// - 自闭合 ` `、` `、`
`)都不匹配 —— 这正是
// 「`1 < 2`、`a` 原样保留」的实现方式。
paragraphTagRe = regexp.MustCompile(`(?i)?p(?:\s[^<>]*)?/?>`)
// opaqueBlockRe 只认 ` ` 块本身 ——
// paragraph.go 的 atomRe 含 ` ` 块,用它会把「块内嵌套的 ` `」也排除掉,
// 而那恰恰是本文件要修的形状)。块内的 ` ` 字面量随块原样保留。
opaqueBlockRe = regexp.MustCompile(`(?is) `/`
字面量:原样保留,不计数 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 // 冗余重开:合并进退(客户端看到的 `
X
` 走这一支) } 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 = "" // 漏闭标签:补上,内容就是这个段落的正文 } } return keep, appendClose } // startsWithParagraphOpen 报告 s 的开头是不是一个**段落开标签**(`` / `
`): // 自闭合 `
` 与闭标签 `` 都不算。归一化(normalizePiece)用它决定「补闭不补开」—— // 段首已经开了段落就只补上 ``,绝不再套一层(那正是 `X
` 的来历)。 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 是不是自闭合段落标签(``、``)。它是既有的合法 // 形状,原样保留,也不参与配对计数。 func isSelfClosingTag(tag string) bool { return strings.HasSuffix(strings.TrimSuffix(tag, ">"), "/") } // isClosingTag 报告 tag 是不是闭标签(``)。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 }