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「原子块内部不碰」同一口径),原样复制、不参与配对计数。 // - 自闭合 `

`:既有的合法形状,原样保留(不配对、也不算「空段落」)。 var ( // paragraphTagRe 只认真正的段落标签:`

`、`

`、`

`、`

`、`

`、 // `

`。``(p 后紧跟非空白)与 ``)都不匹配 —— 这正是 // 「`1 < 2`、`a` 原样保留」的实现方式。 paragraphTagRe = regexp.MustCompile(`(?i)]*)?/?>`) // opaqueBlockRe 只认 ``/`` 块(**不含** `

` 块本身 —— // paragraph.go 的 atomRe 含 `

` 块,用它会把「块内嵌套的 `

`」也排除掉, // 而那恰恰是本文件要修的形状)。块内的 `

` 字面量随块原样保留。 opaqueBlockRe = regexp.MustCompile(`(?is)]*?>.*?|]*?>.*?`) ) // repairParagraphTags 是本步的入口:把 text 里**未配对**的段落标签收干净(规则见 // 文件头)。它只增删 `

`/`

` 标签本身,正文(含标签之间的空白)逐字保留;已配对 // 且不嵌套的规范文本原样返回(幂等在写入路径上的那一半)。调用方必须自己确保 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 时再遇到开标签 ⇒ 那一段的重复开标签(内容已合并进退):丢掉; // - 关闭一段时内容区全空白 ⇒ 空段落(`

`):开标签与闭标签一起丢掉; // - 文本末尾仍开着且有正文的段落 ⇒ 漏了闭标签:补 `

`(内容一字不动); // - 文本末尾仍开着但内容全空白 ⇒ 尾部空段落:丢掉开标签(不制造空气泡)。 // // 落单的闭标签(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 // / 块内的

字面量:原样保留,不计数 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, "