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

156 lines
7.4 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"
"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>` 块内部是原子的:块内的空行不参与分段,块本身不被二次包裹。
// - 收尾必过**标签配对修复**(paragraph_repair.go):落库/对外文本里不允许出现未配对
// 的段落标签(`<p><p>X</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>`;最后再
// 做一遍**标签配对修复**(未配对的段落标签按「一个 `<p>` = 一段」收干净,见
// paragraph_repair.go)。
//
// 它是纯函数且**幂等**(`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 repairParagraphTags(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 repairParagraphTags(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>` 块之间的分隔)
// 原样保留。
//
// **例外(补闭不补开)**:段首已经是一个段落开标签(模型漏了闭标签,如 `<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
}
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
}
if startsWithParagraphOpen(core) {
return piece[:lead] + core + "</p>" + piece[len(piece)-trail:]
}
return piece[:lead] + "<p>" + core + "</p>" + piece[len(piece)-trail:]
}