From 8051906afa6fa6fffccf798e0bd0739ceb014eca Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 7 Oct 2026 10:07:13 +0800 Subject: [PATCH] =?UTF-8?q?fix(turnreply):=20=E4=BA=A7=E5=87=BA=E4=BE=A7?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E4=BA=A7=E5=87=BA=E6=9C=AA=E9=85=8D=E5=AF=B9?= =?UTF-8?q?=E7=9A=84=E6=AE=B5=E8=90=BD=E6=A0=87=E7=AD=BE=EF=BC=88=E5=AE=89?= =?UTF-8?q?=E5=8D=93=E6=8A=A5=E9=9A=9C=E7=9A=84=E5=9B=9E=E6=89=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 安卓客户端登记(他们文档 02 末节):一条助手消息的 content 是 `

正文

`,按他们的解析契约(非贪婪)必然把 `

正文` 当内容 ⇒ 用户 直接看到字面 `

`。任何按该契约定实现的消费方都会踩。 根因(两半都证实,都在同一处): - (a) 规整器**原样放过**模型吐出的 `

X

`:段块正则非贪婪,把它当成 一个"已完整包裹"的块逐字保留(旧的覆盖检查用同一个正则 ⇒ 护栏本身是盲的); - (b) 规整器**亲手制造**该形状:末段漏 `

` 时无条件套一层 ⇒ `

X

`。 实证:模型吐 `

那现在…?`(无闭标签)经旧代码规整后与上报串**逐字节相同**; - (c) 顺带修一个幂等漏洞:落单 `

` 旧行为会越跑越多(`

`)。 修法(新增 paragraph_repair.go:配对修复 + 接线): `

X

`⇒`

X

`;`

X`⇒补 `

`;`

正文

`⇒合并;`

A

`⇒ 丢空开标签;`

`⇒整对丢;落单 `

`⇒丢标签正文不动。不吞正文护栏: ``)/``/`1 < 2`/`a`/代码围栏/`` 内 `

`/自闭合 `

` 一律原样(每条都有用例)。`normalizePiece` 改为"补闭不补开"——在造出 畸形的那一点上根治。 验收(主会话复跑 + 亲自变异):go build ./... + vet 绿;turnreply / gateway 全量 / channelbridge 三包绿;我用 -overlay 摘掉配对修复接线 ⇒ TestNormalizeParagraphs_* 变红。生产库只读核查:177 行 assistant 里**仅 1 行** 畸形(各消费方已容错)⇒ 不做一次性修复脚本。 ⚠️ 未部署:跑着的 :10942 仍是旧产物 ⇒ 畸形仍会产出,安卓端的容错请继续保留。 --- internal/runtime/turnreply/paragraph.go | 19 +- .../runtime/turnreply/paragraph_repair.go | 196 +++++++++++++++++ .../turnreply/paragraph_repair_test.go | 203 ++++++++++++++++++ .../session_turn_reply_paragraph_test.go | 34 +++ 4 files changed, 449 insertions(+), 3 deletions(-) create mode 100644 internal/runtime/turnreply/paragraph_repair.go create mode 100644 internal/runtime/turnreply/paragraph_repair_test.go diff --git a/internal/runtime/turnreply/paragraph.go b/internal/runtime/turnreply/paragraph.go index eaa6031..ce3a06e 100644 --- a/internal/runtime/turnreply/paragraph.go +++ b/internal/runtime/turnreply/paragraph.go @@ -30,6 +30,9 @@ import ( // - `` / `` 块:**永不跨空行拆开**(被拆开的标签对会让 Web 与 // IM 都渲染出野生标签),它们随所在段落一起被包进**同一个** `

`。 // - `

` 块内部是原子的:块内的空行不参与分段,块本身不被二次包裹。 +// - 收尾必过**标签配对修复**(paragraph_repair.go):落库/对外文本里不允许出现未配对 +// 的段落标签(`

X

`、落单的开或闭),它们会被按「一个 `

` = 一段」合并 +// 或补齐。该步同样幂等,且只增删标签本身,不吞正文。 var ( // atomRe 匹配文本里**不可再拆**的原子块:完整的

…

(含自闭合

)与 // 完整的 / 块。它们内部的空行不算段落分隔。 @@ -48,7 +51,9 @@ var ( ) // NormalizeParagraphs 返回规整后的助手文本:未被 `

` 包裹的段落补上 `

…

`, -// 已完整包裹的段落逐字保留,``/`` 段也包进它所在的 `

`。 +// 已完整包裹的段落逐字保留,``/`` 段也包进它所在的 `

`;最后再 +// 做一遍**标签配对修复**(未配对的段落标签按「一个 `

` = 一段」收干净,见 +// 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 { // ``/`` 块里,必须原样保留)。已是 `

` 块或纯空白时逐字返回; // 否则整段包上 `

`,这一段自己的首尾空白(它同时是相邻 `

` 块之间的分隔) // 原样保留。 +// +// **例外(补闭不补开)**:段首已经是一个段落开标签(模型漏了闭标签,如 `

X`)时 +// **只补 `

`、不再套外层 `

`** —— 再套一层就造出 `

X

`:客户端的非贪婪 +// 契约会把 `

X` 当成气泡内容,用户直接看到字面的 `

`。这一处与 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 + "

" + piece[len(piece)-trail:] + } return piece[:lead] + "

" + core + "

" + piece[len(piece)-trail:] } diff --git a/internal/runtime/turnreply/paragraph_repair.go b/internal/runtime/turnreply/paragraph_repair.go new file mode 100644 index 0000000..5cad83c --- /dev/null +++ b/internal/runtime/turnreply/paragraph_repair.go @@ -0,0 +1,196 @@ +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, "

正文

`。客户端的解析契约 +// `P_BLOCK_RE = ]*)?>([\s\S]*?)

` 是**非贪婪**的 ⇒ 它必然把 `

正文` 当成 +// 气泡内容 ⇒ 用户直接看到字面的 `

`。任何按该契约实现的消费方(Web 面板 / IM 桥 / +// 第三方)都会踩同一条,因此修在**产出侧**(落库/对外文本里不允许有未配对的段落标签)。 +// +// 两个断言助手分别对应两层: +// - unpairedParagraphTag:标签配对本身(不出现落单的开或闭、不嵌套); +// - clientBubblesWithLiteralTags:**按客户端契约**拆出的气泡正文里还残留字面 `

` +// 标签 —— 它就是上报症状本身,且**不依赖**实现用的正则(独立口径)。 + +// reportedUnpairedReply 是上报原串(逐字照抄,`\n` 是真换行)。 +const reportedUnpairedReply = "

满意地点点头

\n" + + "

真乖♪ 这可是今天最让人家放心的一句。

\n" + + "

那现在——想让人家陪你看会儿晚霞,还是安静坐着发会儿呆?

" + +// reportedUnpairedFixed 是上报原串的**目标形态**:只有那处双开标签被合并( +// `

正文

` ⇒ `

正文

`),其余一字不动。 +const reportedUnpairedFixed = "

满意地点点头

\n" + + "

真乖♪ 这可是今天最让人家放心的一句。

\n" + + "

那现在——想让人家陪你看会儿晚霞,还是安静坐着发会儿呆?

" + +// TestNormalizeParagraphs_ReportedUnpairedTagIsMerged 是必测①:上报原串规整后只剩 +// 恰好一处改动 —— 第三段合并成一个 `

`。 +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, "

") != 3 || strings.Count(got, "

") != 3 { + t.Fatalf("段落标签数不配对:

=%d

=%d\n%q", + strings.Count(got, "

"), strings.Count(got, "

"), 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 亲手再套一层」造出来的同一种形状。 + {"双开标签", "

正文。

", "

正文。

"}, + {"双开标签落在多段中间", "

第一。

\n\n

第二。

", "

第一。

\n\n

第二。

"}, + {"末段漏闭标签(规整器补包裹)", "

第一。

\n\n

第二。", "

第一。

\n\n

第二。

"}, + // 中段漏闭标签:段首已开段落 ⇒ **只补闭、不再套一层**(段落边界因此保住; + // 若套一层,配对修复会把两段合并成一段 —— 「一个 `

` = 一段」丢结构)。 + {"中段漏闭标签保住段落边界", "

第一。\n\n第二。", "

第一。

\n\n

第二。

"}, + // 「只补闭不补开」也保住了模型自己写的开标签(含属性)—— 不套外层就不会被 + // 配对修复的「冗余重开合并」把属性一起丢掉。 + {"带属性的漏闭段落保住开标签", "

正文。", "

正文。

"}, + // 整条只有一个落单开标签:把它补成一个**配对**的空段落(paired),而不是把一条 + // 非空消息收成空串(见 repairParagraphTags 的收尾防御)。 + {"整条只有一个落单开标签", "

", "

"}, + // (2) 闭标签缺失后又重开:第二个开标签之后没有内容 ⇒ 合并进退,不吞正文。 + {"闭标签缺失后重开", "

正文

", "

正文

"}, + // (3) 尾部落单开标签:内容区全是空白 ⇒ 丢掉(`

` 在客户端是一个空气泡)。 + {"尾部落单开标签", "

正文。

", "

正文。

"}, + {"尾部空开标签被丢掉", "正文。

", "

正文。

"}, + // (4) 落单闭标签:没有开着的段落 ⇒ 丢掉闭标签,正文一字不动。 + {"落单闭标签", "

正文。

", "

正文。

"}, + {"裸正文带落单闭标签", "正文。

", "

正文。

"}, + {"双开双闭", "

X

", "

X

"}, + // 空段落对:内容区无正文,整对丢掉(理由同上,不吞正文)。 + {"空段落对", "

正文。

\n\n

", "

正文。

\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 是必测③:畸形形状经过一次规整后 +// 必须**稳定**(第二次逐字相同)—— 修复前的 `正文。

` 第二次运行还会继续变(不幂等), +// 这条用例把它钉住。 +func TestNormalizeParagraphs_UnpairedShapeIsIdempotent(t *testing.T) { + t.Parallel() + + shapes := []string{ + reportedUnpairedReply, + "

正文。

", + "

正文

", + "

正文。

", + "

正文。

", + "正文。

", + "

X

", + "

正文。

\n\n

", + } + 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 是必测④(护栏):不是段落标签 +// 的尖括号内容(残缺 ``、`1 < 2`、`a`)一律不进修复 +// 逻辑,正文逐字保留。 +func TestNormalizeParagraphs_NonTagAngleBracketsStayVerbatim(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + in string + want string + }{ + {"小于号与裸 a1 < 2 且 a"}, + {"残缺无 > 的半标签", "

残缺的半标签

残缺的半标签

"}, + // `` 不是段落标签(p 后紧跟非空白)⇒ 它只是正文,因此仍按裸段落被包一层。 + {" 不是段落标签", "正文", "

正文

"}, + // 围栏文本整条跳过:里面的 `

` 是代码,一个字符都不许动(含不配对的情形)。 + {"围栏内的未配对标签", "看这段:\n\n```html\n

X

\n```", "看这段:\n\n```html\n

X

\n```"}, + // 块内的 `

` 字面量随块原样保留,不触发配对机器。 + {"动作块内的字面

", "写着

两个字\n\n第二段。", + "

写着

两个字

\n\n

第二段。

"}, + // 自闭合

是既有合法形状:保留,且不算空段落。 + {"自闭合段落保留", "

正文。

\n\n

", "

正文。

\n\n

"}, + } + 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)]*)?>([\s\S]*?)

`) + +// clientBubblesWithLiteralTags 按客户端契约拆出气泡正文,返回其中**仍含字面 `

` / +// `

`** 的那一条(没有则返回空串)。上报症状正是「非贪婪契约把 `

正文` 当内容 ⇒ +// 用户看到字面 `

`」,因此这个助手直接复现症状,且与实现用的正则无关。 +func clientBubblesWithLiteralTags(text string) string { + for _, m := range clientBlockRe.FindAllStringSubmatch(text, -1) { + if strings.Contains(m[2], "

") || strings.Contains(m[2], "

") { + 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 "

" + } + return "" +} diff --git a/internal/services/gateway/session_turn_reply_paragraph_test.go b/internal/services/gateway/session_turn_reply_paragraph_test.go index 8af609a..4d51dbf 100644 --- a/internal/services/gateway/session_turn_reply_paragraph_test.go +++ b/internal/services/gateway/session_turn_reply_paragraph_test.go @@ -107,6 +107,40 @@ func TestTurnEnd_FullyBareReplyIsRegularized(t *testing.T) { } } +// TestTurnEnd_UnpairedParagraphTagIsMergedBeforePersist 是必测⑤(网关侧,真 loop + 真 +// SQLite):上报的**未配对段落标签**形状(`

正文

`)落库前被合并成一个 `

`。 +// 断言的就是客户端从历史接口拿到的那份 content —— 「任何按非贪婪 `

` 契约解析的消费方 +// 都会看到字面 `

`」这条症状在**产出侧**被掐掉。 +// +// 字面量与 turnreply/paragraph_repair_test.go 的 reportedUnpairedReply / …Fixed +// 逐字相同(两侧共用同一份上报原串)。 +func TestTurnEnd_UnpairedParagraphTagIsMergedBeforePersist(t *testing.T) { + t.Parallel() + + const reported = "

满意地点点头

\n" + + "

真乖♪ 这可是今天最让人家放心的一句。

\n" + + "

那现在——想让人家陪你看会儿晚霞,还是安静坐着发会儿呆?

" + const want = "

满意地点点头

\n" + + "

真乖♪ 这可是今天最让人家放心的一句。

\n" + + "

那现在——想让人家陪你看会儿晚霞,还是安静坐着发会儿呆?

" + 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, "

") { + t.Fatalf("落库内容里仍有双开段落标签:%q", assistant.Content) + } +} + // TestTurnEnd_NonChatModeReplyKeepsItsShape 守住规整的**作用范围**:非 chat 模式 // (code/home/work/docs…)的消息在客户端是**按原样整体渲染**的(design/07 §2), // 往里塞 `

` 只会在那些形态里露出野生标签 —— 因此规整只对 chat 模式生效,