裁定(已发安卓):**能修**,走**写入侧规整** —— 不指望模型输出、不动客户端。 - 新增 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)⇒ 两边共用同一份规整文本 ⇒ **历史一次性变好、两个客户端零改动**、渲染口径不分叉 (测试与变异详见切片报告)
143 lines
6.4 KiB
Go
143 lines
6.4 KiB
Go
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:]
|
||
}
|