Files
Cyrene_v2/internal/runtime/turnreply/literal_breaks.go
T
AskaEth 6b0af81136 fix(turnreply): 围栏闭合串不得短于开启串(复审 p2 收口)
复审 review-m19-r1 的 p2:`codeProtectedEnd` 的围栏分支只判「下一个 ≥3 反引号
的串」,没判「闭合不短于开启」。CommonMark 要求闭合串**至少与开启串等长**,
因此 ```` 开启的围栏不能被内层 ``` 关闭。复现形态(````markdown 里嵌 ```go,
示例里写 `print("a\nb")`):旧实现把内层 ``` 当闭合 ⇒ 之后的示例正文被当普通
正文渲染,代码里的 `\n` 变成真换行;而这一步跑在**落库之前**,改坏的代码会永久进
历史 —— 反向踩了本文件「宁可少渲染也不改坏代码」的自己的红线。

- literal_breaks.go:围栏分支改为 findBacktickRun(text, body, n, unboundedRun)
  (closing ≥ opening);注释写明 CommonMark 口径与「改了会落库」的后果。
- 新增 TestRenderLiteralLineBreaks_NestedFenceRespectsOpeningRun:嵌套围栏逐字保留、
  围栏外仍照常渲染(保护不得溢出)。摘掉这一行 ⇒ 该用例红,还原即绿。
- 新增 TestTurnReply_DoubleLiteralBreakBecomesParagraphBreak:钉住 `\n\n` ⇒ 空行 ⇒
  两段 <p> ⇒ 两端各 2 条气泡(复审观察②,语义对但此前无用例覆盖)。
- 措辞收口:样本 455 字符 / 907 字节;IM 侧主动推送的注释补明「Web 活跃客户端那条
  主动推送不经 NormalizeParagraphs,另计」。

行内代码那条等长分支未动。全树 go build ./... + go vet ./... + go test -count=1 ./...
全绿(73 包,RC 0)。
2026-10-24 19:33:33 +08:00

144 lines
6.0 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 (
"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
}