Files
Cyrene_v2/internal/runtime/turnreply/paragraph_repair.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

197 lines
9.8 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"
)
// 本文件承载段落规整的**最后一步:标签配对修复**(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
}