Files
Cyrene_v2/internal/services/gateway/session_turn_reply_paragraph_test.go
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

188 lines
8.8 KiB
Go
Raw Permalink 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 gateway
import (
"context"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/turnreply"
)
// 本文件锁定「助手回复落库前做段落包裹规整」这条写入侧修复(网关/Web 一侧)。
//
// 缺陷形状(安卓侧实测):模型有时**只包最后一段**,前面几段是裸文本用空行分隔。
// 客户端的解析规则是「只要存在完整 <p> 块,就不再按空行回退切分」⇒ 半包恰好落在
// 唯一会塌的一侧:整条消息被渲染成一整坨,而不是多个气泡。
//
// 断言走真路径:真 loop(脚本化模型适配器)+ 真 SQLite history + 真编排器
// (复用 session_turn_reply_test.go 的 kbTurnFixture),因此这里看到的落库内容与
// Web 客户端从历史接口拿到的是同一个东西。
//
// 两侧共用同一份样本字面量(渠道桥一侧见 channelbridge/turnreply_paragraph_test.go,
// 两处的 halfWrappedTurnReply / wrappedTurnReply 必须逐字相同 —— 那正是「Web 与 IM
// 吃到同一份文本」的断言方式)。
// halfWrappedTurnReply 是上报的半包形状:4 段裸文本(第 4 段内联一个 <action>)+
// 只有第 5 段带 <p>。
const halfWrappedTurnReply = "第一段,读到头也读到尾啦♪\n\n" +
"第二段,轻轻合上纸条。\n\n" +
"第三段,纸张有点卷边了。\n\n" +
"<action>把整卷纸条摊在小桌上</action>第四段,你看这里还有个小记号。\n\n" +
"<p>第五段,我把它们按日子排好啦。</p>"
// wrappedTurnReply 是 halfWrappedTurnReply 规整后的规范形态(每段一个 <p>)。
const wrappedTurnReply = "<p>第一段,读到头也读到尾啦♪</p>\n\n" +
"<p>第二段,轻轻合上纸条。</p>\n\n" +
"<p>第三段,纸张有点卷边了。</p>\n\n" +
"<p><action>把整卷纸条摊在小桌上</action>第四段,你看这里还有个小记号。</p>\n\n" +
"<p>第五段,我把它们按日子排好啦。</p>"
// storedParagraph 返回一段**单段**助手正文落库后的规范形态。写入侧会给未被 `<p>`
// 包裹的段落补上包裹(权威断言是本文件的硬编码字面量),因此那些「按内容定位落库行」
// 的既有测试要用这个形态去找,而不是模型原文。
func storedParagraph(raw string) string {
return "<p>" + raw + "</p>"
}
// TestTurnEnd_HalfWrappedReplyRegularizedBeforePersist 是必测①(网关侧):半包回复
// 落库前被规整 —— 每一段都在自己的 <p> 里(含 <action> 那一段),且结果与 IM 侧
// 断言的字面量**完全相同**。
func TestTurnEnd_HalfWrappedReplyRegularizedBeforePersist(t *testing.T) {
t.Parallel()
f := newKBTurnFixture(t, &kbScriptAdapter{toolSteps: 0, final: halfWrappedTurnReply}, 0)
res, err := f.submit(t)
if err != nil {
t.Fatalf("回合不该报错: %v", err)
}
assistant := f.assertUserAndSingleAssistant(t)
if assistant.Content != wrappedTurnReply {
t.Fatalf("落库内容 =\n%q\nwant\n%q", assistant.Content, wrappedTurnReply)
}
// res.Text 与落库同值 ⇒ REST reply、WS 事件、续跑尾部与语音拿到的是同一份
// 文本(客户端不会在同一段回复上看到两套形状)。
if res.Text != wrappedTurnReply {
t.Fatalf("Result.Text =\n%q\nwant 与落库同一份\n%q", res.Text, wrappedTurnReply)
}
if strings.Count(assistant.Content, "<p>") != 5 || strings.Count(assistant.Content, "</p>") != 5 {
t.Fatalf("段落包裹数不对:%q", assistant.Content)
}
}
// TestTurnEnd_CanonicalReplyPersistedByteIdentical 是必测②(网关侧):**已经规范**的
// 回复逐字不变 —— 规整绝不能把好文本二次加工坏(幂等在写入路径上的那一半)。
func TestTurnEnd_CanonicalReplyPersistedByteIdentical(t *testing.T) {
t.Parallel()
f := newKBTurnFixture(t, &kbScriptAdapter{toolSteps: 0, final: wrappedTurnReply}, 0)
res, err := f.submit(t)
if err != nil {
t.Fatalf("回合不该报错: %v", err)
}
assistant := f.assertUserAndSingleAssistant(t)
if assistant.Content != wrappedTurnReply {
t.Fatalf("已规范的回复被改动:\n in=%q\nout=%q", wrappedTurnReply, assistant.Content)
}
if res.Text != wrappedTurnReply {
t.Fatalf("Result.Text 被改动:%q", res.Text)
}
}
// TestTurnEnd_FullyBareReplyIsRegularized 是必测③(网关侧):完全没有任何 <p> 的
// 裸文本也被规整(它同样落在客户端的回退路径上,一旦别处混进一个 <p> 就会塌)。
func TestTurnEnd_FullyBareReplyIsRegularized(t *testing.T) {
t.Parallel()
const bare = "第一段。\n\n第二段。\n\n<action>拍拍你</action>"
const want = "<p>第一段。</p>\n\n<p>第二段。</p>\n\n<p><action>拍拍你</action></p>"
f := newKBTurnFixture(t, &kbScriptAdapter{toolSteps: 0, final: bare}, 0)
if _, err := f.submit(t); err != nil {
t.Fatalf("回合不该报错: %v", err)
}
assistant := f.assertUserAndSingleAssistant(t)
if assistant.Content != want {
t.Fatalf("落库内容 = %q, want %q", assistant.Content, want)
}
}
// TestTurnEnd_UnpairedParagraphTagIsMergedBeforePersist 是必测⑤(网关侧,真 loop + 真
// SQLite):上报的**未配对段落标签**形状(`<p><p>正文</p>`)落库前被合并成一个 `<p>`。
// 断言的就是客户端从历史接口拿到的那份 content —— 「任何按非贪婪 `<p>` 契约解析的消费方
// 都会看到字面 `<p>`」这条症状在**产出侧**被掐掉。
//
// 字面量与 turnreply/paragraph_repair_test.go 的 reportedUnpairedReply / …Fixed
// 逐字相同(两侧共用同一份上报原串)。
func TestTurnEnd_UnpairedParagraphTagIsMergedBeforePersist(t *testing.T) {
t.Parallel()
const reported = "<p><action>满意地点点头</action></p>\n" +
"<p>真乖♪ 这可是今天最让人家放心的一句。</p>\n" +
"<p><p>那现在——想让人家陪你看会儿晚霞,还是安静坐着发会儿呆?</p>"
const want = "<p><action>满意地点点头</action></p>\n" +
"<p>真乖♪ 这可是今天最让人家放心的一句。</p>\n" +
"<p>那现在——想让人家陪你看会儿晚霞,还是安静坐着发会儿呆?</p>"
f := newKBTurnFixture(t, &kbScriptAdapter{toolSteps: 0, final: reported}, 0)
res, err := f.submit(t)
if err != nil {
t.Fatalf("回合不该报错: %v", err)
}
assistant := f.assertUserAndSingleAssistant(t)
if assistant.Content != want {
t.Fatalf("落库内容 =\n%q\nwant\n%q", assistant.Content, want)
}
// res.Text 与落库同值 ⇒ REST reply / WS 事件 / 续跑尾部拿到的是同一份。
if res.Text != want {
t.Fatalf("Result.Text =\n%q\nwant 与落库同一份\n%q", res.Text, want)
}
if strings.Contains(assistant.Content, "<p><p>") {
t.Fatalf("落库内容里仍有双开段落标签:%q", assistant.Content)
}
}
// TestTurnEnd_NonChatModeReplyKeepsItsShape 守住规整的**作用范围**:非 chat 模式
// (code/home/work/docs…)的消息在客户端是**按原样整体渲染**的(design/07 §2),
// 往里塞 `<p>` 只会在那些形态里露出野生标签 —— 因此规整只对 chat 模式生效,
// 其余模式的正文逐字不变。IM 永远是 chat,所以这条闸门不让 Web 与 IM 分叉。
func TestTurnEnd_NonChatModeReplyKeepsItsShape(t *testing.T) {
t.Parallel()
f := newKBTurnFixture(t, &kbScriptAdapter{toolSteps: 0, final: halfWrappedTurnReply}, 0)
sr, res := f.orch.SubmitSync(withMode(context.Background(), "code"), kbSessionID,
[]model.Message{userMsg("改代码")}, loop.OwnerEnv())
if !sr.Accepted || !sr.Immediate {
t.Fatalf("submit = %+v, want Accepted+Immediate", sr)
}
if res.Text != halfWrappedTurnReply {
t.Fatalf("非 chat 模式的 Result.Text 被改动:%q", res.Text)
}
assistant := f.assertUserAndSingleAssistant(t)
if assistant.Content != halfWrappedTurnReply {
t.Fatalf("非 chat 模式的落库内容被改动:\n%q\nwant 逐字不变", assistant.Content)
}
if assistant.Mode != "code" {
t.Fatalf("mode = %q, want code", assistant.Mode)
}
}
// TestTurnEnd_BlankReplyStaysUntouchedByRegularization 是必测④(网关侧):空回复
// 走的是另一条分支(补诚实说明),规整不介入 —— 落库内容与共享包文案逐字相同,
// 既没有被包上 <p>,也没有被吞掉。
func TestTurnEnd_BlankReplyStaysUntouchedByRegularization(t *testing.T) {
t.Parallel()
f := newKBTurnFixture(t, &kbScriptAdapter{toolSteps: 0, final: " "}, 0)
res, err := f.submit(t)
if err != nil {
t.Fatalf("空回复不该报错: %v", err)
}
assistant := f.assertUserAndSingleAssistant(t)
if assistant.Content != turnreply.FallbackEmptyReply {
t.Fatalf("落库内容 = %q, want %q", assistant.Content, turnreply.FallbackEmptyReply)
}
if res.Text != turnreply.FallbackEmptyReply {
t.Fatalf("Result.Text = %q, want %q", res.Text, turnreply.FallbackEmptyReply)
}
}