fix(gateway,channelbridge,turnreply): p 标签半包写入侧规整(修「整条塌成一段」)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

裁定(已发安卓):**能修**,走**写入侧规整** —— 不指望模型输出、不动客户端。
- 新增 internal/runtime/turnreply/paragraph.go 的 NormalizeParagraphs:
  把未被 <p> 包裹的段落补上 <p>(按空行分段;<action>/<narration> 段也包进 <p>,
  与正确形态 <p><action>…</action>…</p> 一致)
- **幂等**守门断言:已完全包裹的文本逐字不变(防二次加工)
- 落点覆盖 Web 与 IM 两条落库路径(gateway/session_turn_reply.go、channelbridge/turnreply.go、
  proactive.go)⇒ 两边共用同一份规整文本 ⇒ **历史一次性变好、两个客户端零改动**、渲染口径不分叉
(测试与变异详见切片报告)
This commit is contained in:
AskaEth committed 2026-10-05 10:56:39 +08:00
1 parent d532eb3fcd
commit 5de708a560
17 files changed
+721 -46

No files matched your search

+142
View File
@@ -0,0 +1,142 @@
package turnreply
import (
"regexp"
"sort"
"strings"
)
// 本文件承载助手文本的**段落包裹规整**:把「只包了最后一段」的半包回复,在写入
// 侧规整成「每个段落一个 <p>…</p>」的规范形态。
//
// 缺陷形状(安卓侧实测):模型的多数回复按段产出 `<p>…</p>`,但它偶尔**只包最后
// 一段**,前面几段是裸文本用空行分隔。客户端的解析规则是「**只要存在完整 <p> 块,
// 就不再按空行回退切分**」—— 于是半包恰好落在唯一会塌的那一侧:整条消息被渲染成
// 一整坨,而不是多个气泡。反过来,全都不包反而正常(客户端会按空行拆开)。
//
// 为什么放在这里(而不是各写一套):网关(Web/REST/WS 回合,见
// internal/services/gateway/session_turn_reply.go)与渠道桥(IM 回合、主动推送,
// 见 internal/services/channelbridge/turnreply.go、proactive.go)都必须吃到**同一份**
// 规整后的文本 —— 落库内容、WS `message_persisted`、REST reply、续跑尾部、IM 出站
// 气泡切分(splitBubbles 按 <p> 切)全部以它为准。两份实现迟早漂移,而这一份正是
// 客户端「看到一段还是一坨」的唯一判据。
//
// 判据与边界(逐条对应下面的实现):
// - **幂等**(最重要的守门断言):已完全包裹的文本**逐字不变**。规整只做加法,
// 绝不二次加工好文本;段落之间的分隔空白(几个空行、缩进)原样保留。
// - 空文本 / 纯空白:原样返回(绝不制造空段落)。
// - 含 markdown 代码围栏(```):**整条跳过**。围栏内部按空行拆开会把一段代码
// 切成互不相连的段落(比不规整更糟),无法安全判定时宁可不碰。
// - `<action>` / `<narration>` 块:**永不跨空行拆开**(被拆开的标签对会让 Web 与
// IM 都渲染出野生标签),它们随所在段落一起被包进**同一个** `<p>`。
// - `<p>` 块内部是原子的:块内的空行不参与分段,块本身不被二次包裹。
var (
// atomRe 匹配文本里**不可再拆**的原子块:完整的 <p>…</p>(含自闭合 <p/>)与
// 完整的 <action>/<narration> 块。它们内部的空行不算段落分隔。
atomRe = regexp.MustCompile(`(?is)<p\b[^>]*?/>|<p\b[^>]*?>.*?</p\s*>` +
`|<action\b[^>]*?>.*?</action\s*>|<narration\b[^>]*?>.*?</narration\s*>`)
// paragraphBlockRe 只匹配完整的 <p>…</p> 块(含自闭合 <p/>),用于判断一段是否
// 已经被包裹。
paragraphBlockRe = regexp.MustCompile(`(?is)<p\b[^>]*?/>|<p\b[^>]*?>.*?</p\s*>`)
// blankLineRe 匹配分隔段落的**空行组**:两个及以上换行,中间只允许水平空白。
blankLineRe = regexp.MustCompile(`\n[ \t\r]*\n(?:[ \t\r]*\n)*`)
// codeFenceMarker 是 markdown 围栏代码块的起止标记(见到即整条跳过)。
codeFenceMarker = "```"
// trimCutset 是段落外缘的空白字符集,与 blankLineRe 的口径保持一致(空行组
// 只由这些字符构成),因此「外缘空白」与「段间空行」不会互相切错。
trimCutset = " \t\r\n\v\f"
)
// NormalizeParagraphs 返回规整后的助手文本:未被 `<p>` 包裹的段落补上 `<p>…</p>`,
// 已完整包裹的段落逐字保留,`<action>`/`<narration>` 段也包进它所在的 `<p>`。
//
// 它是纯函数且**幂等**(`NormalizeParagraphs(NormalizeParagraphs(s)) ==
// NormalizeParagraphs(s)`),空文本、纯空白、含代码围栏的文本原样返回。
func NormalizeParagraphs(text string) string {
if strings.TrimSpace(text) == "" || strings.Contains(text, codeFenceMarker) {
return text
}
cuts := paragraphCuts(text)
if len(cuts) == 0 {
return normalizePiece(text)
}
var b strings.Builder
b.Grow(len(text) + 3*len(cuts))
prev := 0
for _, cut := range cuts {
b.WriteString(normalizePiece(text[prev:cut]))
prev = cut
}
b.WriteString(normalizePiece(text[prev:]))
return b.String()
}
// paragraphCuts 返回文本里全部的**分段边界**(切点,升序去重):
// - 空行组的末尾(段落分隔);
// - 每个完整 `<p>` 块的起点与终点(`<p>` 块自成一段,绝不被包进另一个 `<p>`)。
//
// 落在原子块(`<p>` 块 / `<action>` / `<narration>`)内部的空行**不是**边界 ——
// 这正是「不把标签对切开」的实现方式。
func paragraphCuts(text string) []int {
atoms := atomRe.FindAllStringIndex(text, -1)
cuts := make([]int, 0, 2*len(atoms)+4)
for _, loc := range blankLineRe.FindAllStringIndex(text, -1) {
if insideAtoms(atoms, loc) {
continue
}
cuts = append(cuts, loc[1])
}
for _, loc := range atoms {
if isParagraphBlock(text[loc[0]:loc[1]]) {
cuts = append(cuts, loc[0], loc[1])
}
}
sort.Ints(cuts)
out := make([]int, 0, len(cuts))
for _, c := range cuts {
if len(out) > 0 && c == out[len(out)-1] {
continue
}
out = append(out, c)
}
return out
}
// insideAtoms 报告 loc 是否完全落在某个原子块内部(原子块之间互不重叠,因此只需
// 逐个判断包含关系)。
func insideAtoms(atoms [][]int, loc []int) bool {
for _, a := range atoms {
if loc[0] >= a[0] && loc[1] <= a[1] {
return true
}
}
return false
}
// isParagraphBlock 报告 s 整体就是**一个**完整的 `<p>` 块(即该段已被包裹)。
func isParagraphBlock(s string) bool {
t := strings.TrimSpace(s)
if t == "" {
return false
}
loc := paragraphBlockRe.FindStringIndex(t)
return loc != nil && loc[0] == 0 && loc[1] == len(t)
}
// normalizePiece 规整相邻两个切点之间的一段原文。切点已经含**所有**位于原子块
// 之外的空行组,因此这一段**恰好就是一段**(它内部若还有空行,那只可能落在
// `<action>`/`<narration>` 块里,必须原样保留)。已是 `<p>` 块或纯空白时逐字返回;
// 否则整段包上 `<p>`,这一段自己的首尾空白(它同时是相邻 `<p>` 块之间的分隔)
// 原样保留。
func normalizePiece(piece string) string {
if isParagraphBlock(piece) || strings.TrimSpace(piece) == "" {
return piece
}
lead := len(piece) - len(strings.TrimLeft(piece, trimCutset))
trail := len(piece) - len(strings.TrimRight(piece, trimCutset))
core := piece[lead : len(piece)-trail]
if strings.TrimSpace(core) == "" {
return piece
}
return piece[:lead] + "<p>" + core + "</p>" + piece[len(piece)-trail:]
}