fix(gateway,channelbridge,turnreply): p 标签半包写入侧规整(修「整条塌成一段」)
裁定(已发安卓):**能修**,走**写入侧规整** —— 不指望模型输出、不动客户端。 - 新增 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:
1 parent
d532eb3fcd
commit
5de708a560
17 files changed
+721
-46
No files matched your search
@@ -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:]
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package turnreply
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件锁定「段落包裹规整」(paragraph.go)的判据,重点是**幂等**这一条守门断言:
|
||||
// 已规范的文本再跑一次必须逐字相同 —— 否则这套规整就会把好文本二次加工坏,而它
|
||||
// 跑在每一条助手回复的写入路径上。
|
||||
//
|
||||
// 半包样本照抄上报形状:多段裸文本 + 一段 <action>,只有最后一段带 <p>。
|
||||
|
||||
// halfWrappedSample 是上报的半包形状:4 段裸文本(含内联 <action> 的那段)+
|
||||
// 只有最后一段带 <p>。它正是会塌成一坨的那一侧。
|
||||
const halfWrappedSample = "第一段,读到头也读到尾啦♪\n\n" +
|
||||
"第二段,轻轻合上纸条。\n\n" +
|
||||
"第三段,纸张有点卷边了。\n\n" +
|
||||
"<action>把整卷纸条摊在小桌上</action>第四段,你看这里还有个小记号。\n\n" +
|
||||
"<p>第五段,我把它们按日子排好啦。</p>"
|
||||
|
||||
// wrappedSample 是半包样本**规整后**的规范形态(每段一个 <p>,<action> 段也包
|
||||
// 在它所在的 <p> 里,只有最后一段原本就是这样)。
|
||||
const wrappedSample = "<p>第一段,读到头也读到尾啦♪</p>\n\n" +
|
||||
"<p>第二段,轻轻合上纸条。</p>\n\n" +
|
||||
"<p>第三段,纸张有点卷边了。</p>\n\n" +
|
||||
"<p><action>把整卷纸条摊在小桌上</action>第四段,你看这里还有个小记号。</p>\n\n" +
|
||||
"<p>第五段,我把它们按日子排好啦。</p>"
|
||||
|
||||
// TestNormalizeParagraphs_HalfWrappedGetsEveryParagraphWrapped 是必测①:半包样本
|
||||
// 规整后**每一段**都被 <p> 包裹 —— 包括 <action> 那一段(它必须与同段的正文一起
|
||||
// 落在同一个 <p> 里,而不是被拆出去或漏在外面)。
|
||||
func TestNormalizeParagraphs_HalfWrappedGetsEveryParagraphWrapped(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := NormalizeParagraphs(halfWrappedSample)
|
||||
if got != wrappedSample {
|
||||
t.Fatalf("规整结果 =\n%q\nwant\n%q", got, wrappedSample)
|
||||
}
|
||||
// 唯一的判据是「<p> 块之外有没有非空白文本」—— 客户端的塌陷规则正是按它生效。
|
||||
if outside := bareTextOutsideParagraphs(got); outside != "" {
|
||||
t.Fatalf("<p> 块之外仍有裸文本 %q —— 客户端仍会塌成一坨", outside)
|
||||
}
|
||||
// <action> 段没有被拆开(开闭标签仍在同一段里,且与同段正文同在一个 <p>)。
|
||||
const action = "<action>把整卷纸条摊在小桌上</action>第四段,你看这里还有个小记号。"
|
||||
if !strings.Contains(got, "<p>"+action+"</p>") {
|
||||
t.Fatalf("<action> 段没有被原样包进同一个 <p>:\n%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeParagraphs_Idempotent 是必测②(守门断言):已规范的文本再跑一次
|
||||
// 必须**逐字相同**。它是这套规整能跑在写入路径上的前提 —— 幂等一旦破掉,每次
|
||||
// 读改写都会往好文本上再叠一层标签。
|
||||
func TestNormalizeParagraphs_Idempotent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
canonical := []struct {
|
||||
name string
|
||||
text string
|
||||
}{
|
||||
{"半包样本的规范形态", wrappedSample},
|
||||
{"上报的正确形态", "<p><action>把整卷纸条…</action>读到头也读到尾啦♪</p>"},
|
||||
{"多段规范文本", "<p>第一段。</p>\n\n<p>第二段。</p>"},
|
||||
{"规范文本但空行更多", "<p>第一段。</p>\n\n\n\n<p>第二段。</p>"},
|
||||
{"规范文本但段间有缩进空白", "<p>第一段。</p>\n \n<p>第二段。</p>"},
|
||||
{"单段规范文本(带外缘空白)", " <p>就一句。</p>\n"},
|
||||
{"段内多行保持不动", "<p>第一行\n第二行</p>\n\n<p>下一段。</p>"},
|
||||
{"动作段内的空行不被切开", "<p><action>前半;\n\n后半。</action>说完啦。</p>"},
|
||||
{"自闭合段落", "<p>正文。</p>\n\n<p/>"},
|
||||
{"空文本", ""},
|
||||
{"纯空白", " \n\t "},
|
||||
}
|
||||
for _, c := range canonical {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
once := NormalizeParagraphs(c.text)
|
||||
if twice := NormalizeParagraphs(once); twice != once {
|
||||
t.Fatalf("不幂等:第二次运行改动了文本\n once=%q\ntwice=%q", once, twice)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeParagraphs_CanonicalTextIsByteIdentical 是必测②的另一半:**已完全
|
||||
// 包裹**的文本必须逐字不变(不只是「第二次运行相同」,而是「一次都不许改」)。
|
||||
func TestNormalizeParagraphs_CanonicalTextIsByteIdentical(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
canonical := []string{
|
||||
wrappedSample,
|
||||
"<p><action>把整卷纸条…</action>读到头也读到尾啦♪</p>",
|
||||
"<p>第一段。</p>\n\n<p>第二段。</p>",
|
||||
"<p>第一段。</p>\n\n\n\n<p>第二段。</p>",
|
||||
"<p>第一段。</p>\n \n<p>第二段。</p>",
|
||||
" <p>就一句。</p>\n",
|
||||
"<p>第一行\n第二行</p>\n\n<p>下一段。</p>",
|
||||
"<p><action>前半;\n\n后半。</action>说完啦。</p>",
|
||||
"<p>正文。</p>\n\n<p/>",
|
||||
}
|
||||
for _, text := range canonical {
|
||||
if got := NormalizeParagraphs(text); got != text {
|
||||
t.Fatalf("已规范的文本被改动:\n in=%q\nout=%q", text, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeParagraphs_BareTextGetsWrapped 是必测③:完全没有任何 <p> 的裸文本
|
||||
// 也要被规整(它现在靠客户端的空行回退才拆得开,一旦别处混进一个 <p> 就会塌)。
|
||||
func TestNormalizeParagraphs_BareTextGetsWrapped(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"多段裸文本", "第一段。\n\n第二段。", "<p>第一段。</p>\n\n<p>第二段。</p>"},
|
||||
{"单段裸文本", "就一句。", "<p>就一句。</p>"},
|
||||
{"带动作的裸文本", "<action>弯起眼睛</action>当然看得到呀。\n\n第二段。",
|
||||
"<p><action>弯起眼睛</action>当然看得到呀。</p>\n\n<p>第二段。</p>"},
|
||||
{"独立动作段", "正文。\n\n<action>拍拍你</action>",
|
||||
"<p>正文。</p>\n\n<p><action>拍拍你</action></p>"},
|
||||
{"旁白段", "<narration>心里有点甜</narration>\n\n正文。",
|
||||
"<p><narration>心里有点甜</narration></p>\n\n<p>正文。</p>"},
|
||||
{"段内单换行保持在同一段", "第一行\n第二行\n\n第二段。",
|
||||
"<p>第一行\n第二行</p>\n\n<p>第二段。</p>"},
|
||||
}
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeParagraphs_BlankAndFencedStayUntouched 是必测④与代码块边界:空文本、
|
||||
// 纯空白一字不动;含 ``` 围栏的文本**整条保守跳过**(把围栏里的空行当段落切开,
|
||||
// 会把一段代码拆成互不相连的段落,比不规整更糟)。
|
||||
func TestNormalizeParagraphs_BlankAndFencedStayUntouched(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
text string
|
||||
}{
|
||||
{"空文本", ""},
|
||||
{"纯空白", " \n\t\n"},
|
||||
{"空行围栏", "先看这段:\n\n```go\nfunc main() {}\n\nfunc other() {}\n```\n\n就这样。"},
|
||||
{"行内围栏", "看看 ```code``` 这个词。\n\n第二段。"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := NormalizeParagraphs(c.text); got != c.text {
|
||||
t.Fatalf("不该被改动:\n in=%q\nout=%q", c.text, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeParagraphs_ActionAcrossBlankLineStaysWhole 守住另一条边界:<action>
|
||||
// 块**跨空行**时绝不能被切成分属两段 —— 那会让 Web 与 IM 都看到野生标签。
|
||||
func TestNormalizeParagraphs_ActionAcrossBlankLineStaysWhole(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const in = "<action>把整卷纸条摊开;\n\n又轻轻卷回去。</action>读到头也读到尾啦♪\n\n<p>下一段。</p>"
|
||||
const want = "<p><action>把整卷纸条摊开;\n\n又轻轻卷回去。</action>读到头也读到尾啦♪</p>\n\n<p>下一段。</p>"
|
||||
if got := NormalizeParagraphs(in); got != want {
|
||||
t.Fatalf("规整 = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// bareTextOutsideParagraphs 返回所有落在完整 <p> 块之外的**非空白**文本,用于
|
||||
// 断言「没有裸文本漏在 <p> 之外」。
|
||||
func bareTextOutsideParagraphs(text string) string {
|
||||
rest := paragraphBlockRe.ReplaceAllString(text, " ")
|
||||
return strings.TrimSpace(rest)
|
||||
}
|
||||
Reference in new issue
Block a user