裁定(已发安卓):**能修**,走**写入侧规整** —— 不指望模型输出、不动客户端。 - 新增 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)⇒ 两边共用同一份规整文本 ⇒ **历史一次性变好、两个客户端零改动**、渲染口径不分叉 (测试与变异详见切片报告)
154 lines
7.1 KiB
Go
154 lines
7.1 KiB
Go
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_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)
|
||
}
|
||
}
|