Merge branch 'feat/literalnewline-render-literal-backslash' into dev
This commit is contained in:
commit
4518385f44
4 files changed
+561
-1
No files matched your search
@@ -0,0 +1,143 @@
|
||||
package turnreply
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 本文件承载助手文本里**字面转义**的渲染:模型偶尔把换行写成**两个字符**
|
||||
// `\n`(反斜杠 + n),而不是一个真换行。
|
||||
//
|
||||
// 缺陷形状(主人 2026-10-24 报障,真机原文):她列了七个中古调式,用字面 `\n`
|
||||
// 当分隔,全挤在**同一个 `<p>`** 里 ——
|
||||
//
|
||||
// <p>伊奥尼亚:G♭ A♭ B♭ C♭ D♭ E♭ F\n多利亚:…\n弗里吉亚:…</p>
|
||||
//
|
||||
// 清洗链不认这两个字符(全仓没有任何一处把字面 `\n` 当换行),于是原样发给 QQ,
|
||||
// 用户看到的是「F\n多利亚」,Web 气泡里也一样(`whitespace-pre-wrap` 只对**真**
|
||||
// 换行生效)。⇒ 这一层是**写入侧规整**:把字面 `\n` 换成真换行,落库文本、WS
|
||||
// `message_persisted`、REST reply、IM 出站气泡就是**同一份**(与
|
||||
// NormalizeParagraphs 同一条口径,见 paragraph.go 的调用点)。
|
||||
//
|
||||
// 判据与边界:
|
||||
// - 只认**恰好一个**反斜杠紧跟 `n`。`\\n`(双反斜杠 + n,模型在讲转义规则或
|
||||
// 写 Windows 路径时 legitimately 会写)**原样保留** —— 它的语义是「一个反斜杠
|
||||
// 加字母 n」,把它当换行等于篡改内容。
|
||||
// - **代码围栏(```)与行内代码(`…`、``…``)里的 `\n` 逐字不动**:模型讨论
|
||||
// 代码时写 `print("a\nb")` 是**代码内容**,全局替换会把代码改坏。围栏的闭合串
|
||||
// 按 CommonMark 取「不短于开启串」(```` 开的围栏不能被内层 ``` 关闭),行内
|
||||
// 代码取「等长」,因此嵌套围栏示例(```go 套在 ````markdown 里)同样受保护。
|
||||
// - 不认识的转义(`\t` / `\r` / `\uXXXX` / 行尾单独的 `\`)**一律不动**:这一层
|
||||
// 只修主人报了的那一个形状,不顺手扩大语义(其余字面转义见切片报告)。
|
||||
// - **不制造结构标记**:本函数只产出换行字符,绝不产出 `<br>` / `<p>` ——
|
||||
// `<br>` 在 chat 口径里是**气泡/消息分隔符**(把一条列表升级成七条消息反而更吵),
|
||||
// 字面 `\n` 只应是**气泡内部的换行**。
|
||||
// - 幂等:渲染后的文本里不再有可被本函数改写的字面 `\n`(代码里的那些本来就不动),
|
||||
// 因此二次调用逐字相同。
|
||||
|
||||
const (
|
||||
// literalBreakEscape 是模型实际写出的「字面换行」:反斜杠 + n 两个字符。
|
||||
literalBreakEscape = `\n`
|
||||
// backtickCh 是代码围栏与行内代码的定界符。
|
||||
backtickCh = '`'
|
||||
// backslashCh 是转义引导符。
|
||||
backslashCh = '\\'
|
||||
// fenceRunLen 是围栏代码块的定界符长度(paragraph.go 的 codeFenceMarker 是
|
||||
// 三个反引号;它在 var 块里,len() 不是常量,故这里写死同一个 3)。
|
||||
fenceRunLen = 3
|
||||
// unboundedRun 表示「反引号串长度不设上限」(围栏的闭合串可以更长,
|
||||
// 如 ````> 也能闭合 ``` 开的围栏)。
|
||||
unboundedRun = math.MaxInt
|
||||
)
|
||||
|
||||
// RenderLiteralLineBreaks 返回把字面 `\n` 渲染成真换行后的文本:代码围栏与行内
|
||||
// 代码内原样保留,`\\n` 与其余转义不动,不含字面 `\n` 的文本逐字返回(零分配路径)。
|
||||
func RenderLiteralLineBreaks(text string) string {
|
||||
if !strings.Contains(text, literalBreakEscape) {
|
||||
return text
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(text) + 8)
|
||||
for i := 0; i < len(text); {
|
||||
switch text[i] {
|
||||
case backtickCh:
|
||||
// 围栏/行内代码:整段逐字复制,内部的 `\n` 一个字不动。
|
||||
end := codeProtectedEnd(text, i)
|
||||
b.WriteString(text[i:end])
|
||||
i = end
|
||||
case backslashCh:
|
||||
switch {
|
||||
case i+1 >= len(text):
|
||||
// 行尾孤立的 `\`:原文保留。
|
||||
b.WriteByte(text[i])
|
||||
i++
|
||||
case text[i+1] == 'n':
|
||||
b.WriteByte('\n')
|
||||
i += 2
|
||||
case text[i+1] == backslashCh:
|
||||
// `\\` 成对吞掉 ⇒ 紧随其后的 `n` 是普通字母,`\\n` 因此原样保留。
|
||||
b.WriteString(text[i : i+2])
|
||||
i += 2
|
||||
default:
|
||||
// `\t` / `\r` / `\u0041` 等:本层不处理,原样保留。
|
||||
b.WriteByte(text[i])
|
||||
i++
|
||||
}
|
||||
default:
|
||||
b.WriteByte(text[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// codeProtectedEnd 返回从 start 起的**受保护区间**的结束下标(半开):
|
||||
// - `start` 处是 ``` 起(含更长):一个围栏代码块,止于**不短于开启串**的反引号串
|
||||
// (CommonMark 口径:```` 开的围栏不能被内层的 ``` 关闭 —— 否则嵌套围栏示例里的
|
||||
// `\n` 会被当成正文渲染,而这段文本紧接着就落库)。没有闭合围栏时**保护到文本
|
||||
// 末尾** —— 宁可少渲染一处换行,也不把可能仍是代码的正文改坏。
|
||||
// - `start` 处是一或两个反引号:一个行内代码 span,止于**等长**的反引号串
|
||||
// (CommonMark 口径)。找不到等长闭合串时,这几个反引号只是普通字符,
|
||||
// 区间就是它们自身(不吞后面的正文)。
|
||||
func codeProtectedEnd(text string, start int) int {
|
||||
n := backtickRunLen(text, start)
|
||||
body := start + n
|
||||
if n >= fenceRunLen {
|
||||
if close := findBacktickRun(text, body, n, unboundedRun); close >= 0 {
|
||||
return close + backtickRunLen(text, close)
|
||||
}
|
||||
return len(text)
|
||||
}
|
||||
if close := findBacktickRun(text, body, n, n); close >= 0 {
|
||||
return close + n
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// findBacktickRun 在 text[from:] 里找**下一个**长度落在
|
||||
// [atLeast, atMost] 区间内的连续反引号串的起始下标,找不到返回 -1。
|
||||
// 围栏用 atLeast=fenceRunLen / atMost=unboundedRun(更长的围栏也能闭合),
|
||||
// 行内代码用 atLeast==atMost==定界符长度(等长才算闭合)。
|
||||
func findBacktickRun(text string, from, atLeast, atMost int) int {
|
||||
for i := from; i < len(text); {
|
||||
if text[i] != backtickCh {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
r := backtickRunLen(text, i)
|
||||
if r >= atLeast && r <= atMost {
|
||||
return i
|
||||
}
|
||||
i += r
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// backtickRunLen 返回 text[at:] 起连续反引号的个数(at 越界时为 0)。
|
||||
func backtickRunLen(text string, at int) int {
|
||||
n := 0
|
||||
for at+n < len(text) && text[at+n] == backtickCh {
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package turnreply
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件锁定「字面 `\n`(反斜杠 + n)渲染成真换行」(literal_breaks.go)的判据。
|
||||
//
|
||||
// 重点三条:
|
||||
// - **代码内容不许被改**:围栏(```)与行内代码(`…`)里的 `\n` 逐字不动 ——
|
||||
// 模型讨论代码时写 `print("a\nb")`,全局替换会把代码改坏;
|
||||
// - **`\\n` 不动**:它表示「一个反斜杠 + 字母 n」,当换行等于篡改内容;
|
||||
// - **幂等**:它跑在每一条助手回复的写入路径上,二次调用必须逐字相同。
|
||||
|
||||
// literalEscapeSample 是模型实际写出的那两个字符:反斜杠 + n(不是换行)。
|
||||
const literalEscapeSample = "\\n"
|
||||
|
||||
// realLiteralBreakReply 是真机原文(主人 2026-10-24 报障那条乐理回复,6 处字面
|
||||
// `\n` 全挤在同一个 <p> 里)。它与 channelbridge 侧
|
||||
// sanitize_literal_breaks_test.go 的同名字面量**逐字相同** —— 那正是「网关/落库
|
||||
// 与 IM 出站吃同一份文本」的断言方式(同一份样本,两侧各自断言)。
|
||||
const realLiteralBreakReply = `<p><action>看着那颗 CPU,忍不住叹了口气</action>R7 1700X……2017 年到现在,确实到了该享清福的年纪了♪</p>
|
||||
|
||||
<p><action>坐直身子,认真起来</action>乐理题人家来答。按「以 G♭ 为主音」的七种中古调式来算:</p>
|
||||
|
||||
<p>伊奥尼亚:G♭ A♭ B♭ C♭ D♭ E♭ F\n多利亚:G♭ A♭ B♭♭ C♭ D♭ E♭ F♭\n弗里吉亚:G♭ A♭♭ B♭♭ C♭ D♭ E♭♭ F♭\n利底亚:G♭ A♭ B♭ C D♭ E♭ F\n混合利底亚:G♭ A♭ B♭ C♭ D♭ E♭ F♭\n爱奥利亚:G♭ A♭ B♭♭ C♭ D♭ E♭♭ F♭\n洛克里亚:G♭ A♭♭ B♭♭ C♭ D♭♭ E♭♭ F♭</p>
|
||||
|
||||
<p>那些重降的等音:A♭♭=G、B♭♭=A、D♭♭=C、E♭♭=D。</p>
|
||||
|
||||
<p>不过要是题目问的是「G♭ 大调关系调式」那种,七种用的都是 G♭ A♭ B♭ C♭ D♭ E♭ F 这一组音,只是主音各站各的位置♪</p>`
|
||||
|
||||
// TestRenderLiteralLineBreaks_RealMachineMessage 是必测①的**修前红/修后绿**判据:
|
||||
// 真机原文的 6 处字面 `\n` 全部渲染成真换行,段落结构(5 个 <p>)一字不变,且
|
||||
// 绝不引入 `<br>`(它不是气泡分隔符,只是气泡内的换行)。
|
||||
func TestRenderLiteralLineBreaks_RealMachineMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := RenderLiteralLineBreaks(realLiteralBreakReply)
|
||||
if strings.Contains(got, literalEscapeSample) {
|
||||
t.Fatalf("仍剩字面 `\\n` —— QQ 上还会看到「F\\n多利亚」:\n%q", got)
|
||||
}
|
||||
if wantGain := strings.Count(realLiteralBreakReply, literalEscapeSample); strings.Count(got, "\n")-strings.Count(realLiteralBreakReply, "\n") != wantGain {
|
||||
t.Fatalf("新增真换行 = %d, want %d", strings.Count(got, "\n")-strings.Count(realLiteralBreakReply, "\n"), wantGain)
|
||||
}
|
||||
if n := strings.Count(got, "<p>"); n != 5 {
|
||||
t.Fatalf("<p> 块 = %d, want 5(段落口径不得被改动)", n)
|
||||
}
|
||||
if strings.Contains(got, "<br") {
|
||||
t.Fatalf("渲染出了 <br> —— 字面 `\\n` 只应是气泡内换行,不是消息分隔符:\n%q", got)
|
||||
}
|
||||
// 逐字校验第三段:七个调式各占一行。
|
||||
const scaleHead = "<p>伊奥尼亚:G♭ A♭ B♭ C♭ D♭ E♭ F\n多利亚:"
|
||||
if !strings.Contains(got, scaleHead) {
|
||||
t.Fatalf("调式段没有被换行渲染:\n%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderLiteralLineBreaks_CodeFenceProtected 是必测②:围栏代码块里的 `\n`
|
||||
// 逐字保留(她解释 `print("a\nb")` 时那两下是**代码内容**),围栏前后的正文照常渲染。
|
||||
func TestRenderLiteralLineBreaks_CodeFenceProtected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
in := "先说明一句" + literalEscapeSample + "这里要换行。\n" +
|
||||
"```go\nfmt.Print(\"a" + literalEscapeSample + "b\")\n```\n" +
|
||||
"围栏之后这句" + literalEscapeSample + "也换行。"
|
||||
got := RenderLiteralLineBreaks(in)
|
||||
|
||||
if !strings.Contains(got, `fmt.Print("a\nb")`) {
|
||||
t.Fatalf("围栏内的 `\\n` 被改坏了(代码内容必须逐字保留):\n%q", got)
|
||||
}
|
||||
if got := strings.Count(RenderLiteralLineBreaks(in), literalEscapeSample); got != 1 {
|
||||
t.Fatalf("剩余字面 `\\n` = %d, want 1(只该剩围栏里那一处)", got)
|
||||
}
|
||||
for _, want := range []string{"先说明一句\n这里要换行。", "围栏之后这句\n也换行。"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("围栏外正文没有被渲染,want 含 %q:\n%q", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderLiteralLineBreaks_NestedFenceRespectsOpeningRun 钉住 CommonMark 的围栏闭合
|
||||
// 口径:**闭合串不得短于开启串**。四个反引号开启的围栏里嵌着三反引号的示例时,内层
|
||||
// ``` 只是**代码内容**,不能把外层围栏闭合 —— 否则示例里的 `print("a\nb")` 会被渲染成
|
||||
// 真换行,而这段文本紧接着就落库(改坏的代码会永久进历史)。
|
||||
func TestRenderLiteralLineBreaks_NestedFenceRespectsOpeningRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const nested = "````markdown\n```go\nfmt.Println(\"a" + literalEscapeSample + "b\")\n```\n````"
|
||||
in := "教你怎么写围栏代码块:\n\n" + nested + "\n\n围栏之后这句" + literalEscapeSample + "也换行。"
|
||||
got := RenderLiteralLineBreaks(in)
|
||||
|
||||
if !strings.Contains(got, nested) {
|
||||
t.Fatalf("嵌套围栏没有被逐字保留(内层 ``` 提前闭合了外层 ````):\n got %q\nwant 含 %q", got, nested)
|
||||
}
|
||||
if strings.Contains(got, "fmt.Println(\"a\nb\")") {
|
||||
t.Fatalf("嵌套围栏里的字面 `\\n` 被渲染成真换行 —— 代码内容被改坏:\n%q", got)
|
||||
}
|
||||
if !strings.HasSuffix(got, "围栏之后这句\n也换行。") {
|
||||
t.Fatalf("围栏之外的正文没被渲染(保护不得溢出):\n%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderLiteralLineBreaks_InlineCodeProtected 是必测②的另一半:行内代码
|
||||
// (单个反引号与两个反引号两种定界符)里的 `\n` 也不动。
|
||||
func TestRenderLiteralLineBreaks_InlineCodeProtected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
in := "行内代码 `a" + literalEscapeSample + "b` 里那两下不动;真的换行这样写:" + literalEscapeSample + "下一句。"
|
||||
got := RenderLiteralLineBreaks(in)
|
||||
|
||||
if !strings.Contains(got, "行内代码 `a"+literalEscapeSample+"b` 里那两下不动") {
|
||||
t.Fatalf("行内代码里的 `\\n` 被改坏了:\n%q", got)
|
||||
}
|
||||
if !strings.HasSuffix(got, "真的换行这样写:\n下一句。") {
|
||||
t.Fatalf("行内代码之外的 `\\n` 没被渲染:\n%q", got)
|
||||
}
|
||||
|
||||
// 双反引号定界符同样受保护。
|
||||
double := "``a" + literalEscapeSample + "b`` 不动,外面" + literalEscapeSample + "要换行"
|
||||
if got := RenderLiteralLineBreaks(double); !strings.Contains(got, "``a"+literalEscapeSample+"b``") ||
|
||||
!strings.HasSuffix(got, "外面\n要换行") {
|
||||
t.Fatalf("双反引号行内代码处理错:\n%q", got)
|
||||
}
|
||||
|
||||
// 落单反引号(没有等长闭合串)只是普通字符,不吞掉后面的正文。
|
||||
stray := "一个孤零零的反引号 `,然后" + literalEscapeSample + "这里照常换行"
|
||||
if got := RenderLiteralLineBreaks(stray); !strings.HasSuffix(got, "然后\n这里照常换行") {
|
||||
t.Fatalf("落单反引号把后面的正文一并保护了(应为普通字符):\n%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderLiteralLineBreaks_DoubleBackslashUntouched 是必测③:`\\n`(双反斜杠 +
|
||||
// n,模型讲转义规则或写路径时会写)**原样保留** —— 它的语义是一个反斜杠加字母 n。
|
||||
// 顺带钉住「`\\` 之后紧跟的字面 `\n` 仍要渲染」这条配对口径。
|
||||
func TestRenderLiteralLineBreaks_DoubleBackslashUntouched(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const doubleBackslash = "\\\\n" // \\n:两个反斜杠 + n
|
||||
unchanged := "字面反斜杠加 n 要写成 " + doubleBackslash + " 这样"
|
||||
if got := RenderLiteralLineBreaks(unchanged); got != unchanged {
|
||||
t.Fatalf("`\\\\n` 被改动了:\n got %q\nwant %q", got, unchanged)
|
||||
}
|
||||
|
||||
paired := "先一个转义的反斜杠 \\\\ 然后" + literalEscapeSample + "这里换行"
|
||||
if got := RenderLiteralLineBreaks(paired); !strings.HasSuffix(got, "然后\n这里换行") {
|
||||
t.Fatalf("`\\\\` 之后的字面 `\\n` 没有被渲染:\n%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderLiteralLineBreaks_OtherEscapesUntouched 记录本层的**范围边界**:只认
|
||||
// `\n` 这一个形状,`\t` / `\r` / 行尾孤立的 `\` 一律不动(其余字面转义见切片报告,
|
||||
// 本片不扩大语义)。
|
||||
func TestRenderLiteralLineBreaks_OtherEscapesUntouched(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct{ name, in string }{
|
||||
{"tab", "缩进" + "\\t" + "不处理"},
|
||||
{"carriage", "回车" + "\\r" + "不处理"},
|
||||
{"trailing-backslash", "行尾孤立的反斜杠\\"},
|
||||
{"unicode", "转义" + "\\u00e9" + "不处理"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := RenderLiteralLineBreaks(tc.in); got != tc.in {
|
||||
t.Fatalf("%s:被本层改动了:\n got %q\nwant %q", tc.name, got, tc.in)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderLiteralLineBreaks_UnclosedFenceProtected 是保守兜底:围栏没闭合时,
|
||||
// 后面的文本**一律原样保留**(宁可少渲染一处换行,也不把可能仍是代码的正文改坏)。
|
||||
func TestRenderLiteralLineBreaks_UnclosedFenceProtected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
in := "```go\n代码里的" + literalEscapeSample + "不动"
|
||||
if got := RenderLiteralLineBreaks(in); got != in {
|
||||
t.Fatalf("未闭合围栏之后的正文被改了:\n got %q\nwant %q", got, in)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderLiteralLineBreaks_IdempotentAndFastPath 是守门断言:没有字面 `\n` 的
|
||||
// 文本逐字返回;有的话二次调用逐字相同(它跑在每条回复的写入路径上,破掉幂等就会
|
||||
// 每读改写一次叠一层)。
|
||||
func TestRenderLiteralLineBreaks_IdempotentAndFastPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, plain := range []string{"", "普通一句话,没有字面转义。", "<p>已经规整好的段落。</p>"} {
|
||||
if got := RenderLiteralLineBreaks(plain); got != plain {
|
||||
t.Fatalf("无字面 `\\n` 的文本被改动:\n got %q\nwant %q", got, plain)
|
||||
}
|
||||
}
|
||||
samples := []string{
|
||||
realLiteralBreakReply,
|
||||
"甲" + literalEscapeSample + "乙",
|
||||
"围栏外" + literalEscapeSample + "换行\n```\n代码" + literalEscapeSample + "\n```",
|
||||
}
|
||||
for _, s := range samples {
|
||||
once := RenderLiteralLineBreaks(s)
|
||||
if twice := RenderLiteralLineBreaks(once); twice != once {
|
||||
t.Fatalf("不幂等:\n once %q\ntwice %q", once, twice)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeParagraphs_CarriesLiteralBreaks 是必测①的**落点**断言:字面 `\n`
|
||||
// 的渲染挂在 NormalizeParagraphs 上 —— 网关(Web)与渠道桥(IM)都调这一个函数,
|
||||
// 因此落库、WS/REST 与 IM 出站拿到的是同一份真换行文本。
|
||||
//
|
||||
// 含围栏时段落规整整体 skip,但**围栏外的**字面 `\n` 仍必须被渲染(渲染刻意放在
|
||||
// 围栏 skip 之前),围栏内的不动。
|
||||
func TestNormalizeParagraphs_CarriesLiteralBreaks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := NormalizeParagraphs(realLiteralBreakReply)
|
||||
if strings.Contains(got, literalEscapeSample) {
|
||||
t.Fatalf("NormalizeParagraphs 没有渲染字面 `\\n`:\n%q", got)
|
||||
}
|
||||
if n := strings.Count(got, "<p>"); n != 5 {
|
||||
t.Fatalf("<p> 块 = %d, want 5", n)
|
||||
}
|
||||
|
||||
mixed := "<p>甲" + literalEscapeSample + "乙</p>\n\n" +
|
||||
"```go\nfmt.Print(\"a" + literalEscapeSample + "b\")\n```"
|
||||
got = NormalizeParagraphs(mixed)
|
||||
if !strings.Contains(got, "<p>甲\n乙</p>") {
|
||||
t.Fatalf("含围栏的消息里,围栏外那段没有被渲染:\n%q", got)
|
||||
}
|
||||
if !strings.Contains(got, `fmt.Print("a\nb")`) {
|
||||
t.Fatalf("围栏内的 `\\n` 被改坏了:\n%q", got)
|
||||
}
|
||||
}
|
||||
@@ -56,8 +56,14 @@ var (
|
||||
// paragraph_repair.go)。
|
||||
//
|
||||
// 它是纯函数且**幂等**(`NormalizeParagraphs(NormalizeParagraphs(s)) ==
|
||||
// NormalizeParagraphs(s)`),空文本、纯空白、含代码围栏的文本原样返回。
|
||||
// NormalizeParagraphs(s)`),空文本、纯空白、含代码围栏的文本按段落口径原样返回。
|
||||
//
|
||||
// 开头先跑 `RenderLiteralLineBreaks`(literal_breaks.go):模型把换行写成字面 `\n`
|
||||
// 时,落库/WS/REST/IM 出站要拿到**同一份**真换行文本。这一步刻意放在**围栏 skip
|
||||
// 之前** —— 否则含代码围栏的消息里,围栏外那些字面 `\n` 就一律不渲染了;围栏与
|
||||
// 行内代码内部由那个函数自己保护,段落口径的 skip 不受影响。
|
||||
func NormalizeParagraphs(text string) string {
|
||||
text = RenderLiteralLineBreaks(text)
|
||||
if strings.TrimSpace(text) == "" || strings.Contains(text, codeFenceMarker) {
|
||||
return text
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
package channelbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
|
||||
)
|
||||
|
||||
// 本文件锁定「字面 `\n`(反斜杠 + n)渲染成真换行」在 **IM 侧真流水线**上的落点,
|
||||
// 两件必须同时成立的事:
|
||||
// 1. **落库(= Web 主会话镜像)与出站气泡吃同一份文本** —— 主人私聊的回复会镜像进
|
||||
// Web 主会话,若只修出站,面板上仍会看到字面 `\n`(那正是这条修复的判据);
|
||||
// 2. 🔴 **`<br>` 仍是气泡分隔符,字面 `\n` 只做气泡内换行** —— 把字面 `\n` 升级成
|
||||
// 气泡切分会把一条列表拆成七条消息,比原缺陷更吵。
|
||||
//
|
||||
// 样本是**真机原文**(主人 2026-10-24 报障那条乐理回复,库里的 assistant 行,
|
||||
// 6 处字面 `\n` 全挤在同一个 <p> 里)。
|
||||
|
||||
// literalEscape 是模型写出、而清洗链不认的那两个字符:反斜杠 + n。
|
||||
const literalEscape = `\n`
|
||||
|
||||
// realLiteralBreakReply 是上报的原文,逐字取自 chat_messages(455 字符 / 907 字节、
|
||||
// 5 个 <p>、6 处字面 `\n`、不含反引号)。断言依赖这几条性质,改样本时必须同步。
|
||||
const realLiteralBreakReply = `<p><action>看着那颗 CPU,忍不住叹了口气</action>R7 1700X……2017 年到现在,确实到了该享清福的年纪了♪</p>
|
||||
|
||||
<p><action>坐直身子,认真起来</action>乐理题人家来答。按「以 G♭ 为主音」的七种中古调式来算:</p>
|
||||
|
||||
<p>伊奥尼亚:G♭ A♭ B♭ C♭ D♭ E♭ F\n多利亚:G♭ A♭ B♭♭ C♭ D♭ E♭ F♭\n弗里吉亚:G♭ A♭♭ B♭♭ C♭ D♭ E♭♭ F♭\n利底亚:G♭ A♭ B♭ C D♭ E♭ F\n混合利底亚:G♭ A♭ B♭ C♭ D♭ E♭ F♭\n爱奥利亚:G♭ A♭ B♭♭ C♭ D♭ E♭♭ F♭\n洛克里亚:G♭ A♭♭ B♭♭ C♭ D♭♭ E♭♭ F♭</p>
|
||||
|
||||
<p>那些重降的等音:A♭♭=G、B♭♭=A、D♭♭=C、E♭♭=D。</p>
|
||||
|
||||
<p>不过要是题目问的是「G♭ 大调关系调式」那种,七种用的都是 G♭ A♭ B♭ C♭ D♭ E♭ F 这一组音,只是主音各站各的位置♪</p>`
|
||||
|
||||
// TestTurnReply_LiteralBreakRenderedForBothSides 是必测①(真机原文):修复前这条
|
||||
// 回复的第三个 <p> 是一行字面 `F\n多利亚`;修复后同一个气泡里是**七行**,而落库
|
||||
// 镜像与出站气泡是同一份文本。
|
||||
func TestTurnReply_LiteralBreakRenderedForBothSides(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
chat := &stubChatter{res: &loop.Result{Text: realLiteralBreakReply}}
|
||||
b, ad, hist := replyTurnBridge(t, chat)
|
||||
b.bubbleGapFn = func() time.Duration { return 0 }
|
||||
|
||||
b.process(context.Background(), directMessage())
|
||||
|
||||
// ① 落库(= Web 主会话镜像):6 处字面 `\n` 全部变成真换行,段落结构一字不变。
|
||||
want := strings.ReplaceAll(realLiteralBreakReply, literalEscape, "\n")
|
||||
rec := assertAssistantReply(t, hist, want)
|
||||
if got := strings.Count(rec.Content, literalEscape); got != 0 {
|
||||
t.Fatalf("落库文本仍有 %d 处字面 `\\n` —— Web 面板上仍会显示 F\\n多利亚", got)
|
||||
}
|
||||
// 段落结构一字不变:仍是 5 个 <p>(字面 `\n` 绝不升级成段落或 <br>)。
|
||||
if got := strings.Count(rec.Content, "<p>"); got != 5 {
|
||||
t.Fatalf("落库文本 <p> 块 = %d, want 5(段落口径不得被改动)", got)
|
||||
}
|
||||
wantLines := strings.Count(realLiteralBreakReply, "\n") + 6
|
||||
if got := strings.Count(rec.Content, "\n"); got != wantLines {
|
||||
t.Fatalf("落库文本真换行 = %d, want %d(6 处字面 `\\n` 应各变成一个换行)", got, wantLines)
|
||||
}
|
||||
|
||||
// ② 出站:5 条气泡(<p> 一级、<br> 二级的既有语义不变),第三条是七行。
|
||||
if len(ad.sends) != 5 {
|
||||
t.Fatalf("出站气泡 = %d 条, want 5(字面 `\\n` 绝不能切成消息): %+v", len(ad.sends), ad.sends)
|
||||
}
|
||||
scale := ad.sends[2].Content
|
||||
lines := strings.Split(scale, "\n")
|
||||
if len(lines) != 7 {
|
||||
t.Fatalf("调式气泡 = %d 行, want 7(修前是 1 行字面 F\\n多利亚): %q", len(lines), scale)
|
||||
}
|
||||
if !strings.HasPrefix(lines[0], "伊奥尼亚:") || !strings.HasPrefix(lines[6], "洛克里亚:") {
|
||||
t.Fatalf("调式气泡首尾行 = %q / %q, want 伊奥尼亚… / 洛克里亚…", lines[0], lines[6])
|
||||
}
|
||||
for i, sent := range ad.sends {
|
||||
if strings.Contains(sent.Content, literalEscape) {
|
||||
t.Fatalf("气泡[%d] 仍有字面 `\\n`: %q", i, sent.Content)
|
||||
}
|
||||
if strings.Contains(sent.Content, "<br") {
|
||||
t.Fatalf("气泡[%d] 出现了 <br> —— 字面 `\\n` 只应做气泡内换行: %q", i, sent.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 同源:出站气泡就是落库镜像里那一段逐字(Web 与 IM 没有第二份加工)。
|
||||
if !strings.Contains(rec.Content, scale) {
|
||||
t.Fatalf("出站气泡不在落库镜像里 ⇒ 两端不是同一份文本:\n气泡 = %q\n落库 = %q", scale, rec.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTurnReply_LiteralBreakKeepsBreakTagBubbles 是必测⑤(既有语义不变):`<br>` 仍
|
||||
// 是**气泡分隔符**(两条消息),而字面 `\n` 只在每个气泡内部换行 —— 二级切分的
|
||||
// 判据一个字节都没动。
|
||||
func TestTurnReply_LiteralBreakKeepsBreakTagBubbles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
chat := &stubChatter{res: &loop.Result{Text: "甲" + literalEscape + "乙<br>丙" + literalEscape + "丁"}}
|
||||
b, ad, hist := replyTurnBridge(t, chat)
|
||||
b.bubbleGapFn = func() time.Duration { return 0 }
|
||||
|
||||
b.process(context.Background(), directMessage())
|
||||
|
||||
assertAssistantReply(t, hist, "<p>甲\n乙<br>丙\n丁</p>")
|
||||
if len(ad.sends) != 2 {
|
||||
t.Fatalf("出站气泡 = %d 条, want 2(<br> 仍是消息分隔符): %+v", len(ad.sends), ad.sends)
|
||||
}
|
||||
if got, want := ad.sends[0].Content, "甲\n乙"; got != want {
|
||||
t.Fatalf("气泡[0] = %q, want %q(气泡内换行)", got, want)
|
||||
}
|
||||
if got, want := ad.sends[1].Content, "丙\n丁"; got != want {
|
||||
t.Fatalf("气泡[1] = %q, want %q(气泡内换行)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendProactive_LiteralBreakRenderedForBothSides 覆盖 **IM 侧**的第三个助手文本
|
||||
// 产出点:渠道桥的主动推送(backthink)与回合回复走同一个 NormalizeParagraphs,因此
|
||||
// 这条出口上也被修好 —— 出站是同一份真换行文本,镜像进 Web 主会话的也是它。
|
||||
//
|
||||
// ⚠️ 范围边界:**Web 活跃客户端**那条主动推送(gateway 的 proactive →
|
||||
// proactive_persist)**不经** NormalizeParagraphs,字面 `\n` 在那条路由上仍原样进库;
|
||||
// 那是既有取舍、不属本片 scope(已由塔登记为后续切片)。本测试只断言 IM 这一侧。
|
||||
func TestSendProactive_LiteralBreakRenderedForBothSides(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const acct = "o9cq802QPKPscBenJAp-SMZPBlnQ@im.wechat"
|
||||
weixin := &fakeAdapter{name: "weixin-ilink", connected: true}
|
||||
owner := &user.OwnerProfile{Accounts: []user.OwnerAccount{{Platform: "weixin-ilink", AccountID: acct}}}
|
||||
hist := &captureHistory{}
|
||||
b := newProactiveBridgeHist(t, owner, hist, weixin)
|
||||
b.bubbleGapFn = func() time.Duration { return 0 }
|
||||
|
||||
push := "第一行" + literalEscape + "第二行"
|
||||
if err := b.SendProactive(context.Background(), proactiveMsg(push)); err != nil {
|
||||
t.Fatalf("SendProactive: %v", err)
|
||||
}
|
||||
if len(weixin.sends) != 1 {
|
||||
t.Fatalf("weixin 气泡 = %d 条, want 1(字面 `\\n` 不切气泡): %+v", len(weixin.sends), weixin.sends)
|
||||
}
|
||||
if got, want := weixin.sends[0].Content, "第一行\n第二行"; got != want {
|
||||
t.Fatalf("推送气泡 = %q, want %q", got, want)
|
||||
}
|
||||
recs := hist.records()
|
||||
if len(recs) != 1 {
|
||||
t.Fatalf("镜像进主会话 = %d 条, want 1: %+v", len(recs), recs)
|
||||
}
|
||||
if got, want := recs[0].Content, "<p>第一行\n第二行</p>"; got != want {
|
||||
t.Fatalf("镜像内容 = %q, want %q(与出站同一份)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTurnReply_DoubleLiteralBreakBecomesParagraphBreak 钉住一个**口径边界**(复审观察
|
||||
// ②):两个连续的字面 `\n` 渲染后就是一个空行,而空行在写入侧规整里是**段落分隔** ⇒
|
||||
// 两段 `<p>` ⇒ 两端各两条气泡。
|
||||
//
|
||||
// 语义上说得通(空行 = 段落),但它与「字面 `\n` 只做气泡内换行」不是同一句话 ——
|
||||
// 这里显式钉住,让日后任何改动**有意**改这个气泡数,而不是悄悄变。
|
||||
func TestTurnReply_DoubleLiteralBreakBecomesParagraphBreak(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
chat := &stubChatter{res: &loop.Result{Text: "甲" + literalEscape + literalEscape + "乙"}}
|
||||
b, ad, hist := replyTurnBridge(t, chat)
|
||||
b.bubbleGapFn = func() time.Duration { return 0 }
|
||||
|
||||
b.process(context.Background(), directMessage())
|
||||
|
||||
// 落库(Web 镜像):两段 <p>,中间保留空行。
|
||||
assertAssistantReply(t, hist, "<p>甲</p>\n\n<p>乙</p>")
|
||||
if len(ad.sends) != 2 {
|
||||
t.Fatalf("出站气泡 = %d 条, want 2(`\\n\\n` ⇒ 空行 ⇒ 段落分隔): %+v", len(ad.sends), ad.sends)
|
||||
}
|
||||
if got, want := ad.sends[0].Content, "甲"; got != want {
|
||||
t.Fatalf("气泡[0] = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := ad.sends[1].Content, "乙"; got != want {
|
||||
t.Fatalf("气泡[1] = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user