fix(turnreply): 产出侧不再产出未配对的段落标签(安卓报障的回执)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

安卓客户端登记(他们文档 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:
AskaEth committed 2026-10-07 10:07:13 +08:00
1 parent 378faf212e
commit 8051906afa
4 files changed
+449 -3

No files matched your search

+16 -3
View File
@@ -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:]
}