安卓客户端登记(他们文档 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 仍是旧产物 ⇒ 畸形仍会产出,安卓端的容错请继续保留。
188 lines
8.8 KiB
Go
188 lines
8.8 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_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)
|
||
}
|
||
}
|