裁定(已发安卓):**能修**,走**写入侧规整** —— 不指望模型输出、不动客户端。 - 新增 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)⇒ 两边共用同一份规整文本 ⇒ **历史一次性变好、两个客户端零改动**、渲染口径不分叉 (测试与变异详见切片报告)
166 lines
7.2 KiB
Go
166 lines
7.2 KiB
Go
package channelbridge
|
||
|
||
import (
|
||
"context"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/turnreply"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
|
||
)
|
||
|
||
// 本文件锁定「助手回复落库/出站前做段落包裹规整」这条写入侧修复(IM 一侧)。
|
||
//
|
||
// 同一根缺陷在 IM 侧有两个出口,本文件两条都钉住:
|
||
// - **落库**:主人私聊回复会镜像进 Web 主会话,半包文本落到 Web 历史里同样会
|
||
// 被客户端渲染成一整坨;
|
||
// - **出站**:splitBubbles 的判据与客户端**逐字相同**(只要存在完整 <p> 块就
|
||
// 只切 <p> 块),半包文本在 IM 侧同样只会得到一个气泡、前面几段凭空消失。
|
||
//
|
||
// 两侧共用同一份样本字面量(网关一侧见 gateway/session_turn_reply_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>"
|
||
|
||
// TestTurnReply_HalfWrappedReplyRegularizedForBothSides 是必测①/⑤(IM 侧):半包
|
||
// 回复经真流水线后,**落库文本**与**出站气泡**都基于同一份规整文本 —— 落库逐字
|
||
// 等于网关侧断言的那份规范文本,出站是 5 个气泡(而不是过去那 1 个),<action>
|
||
// 按渠道既有行为被剥掉。
|
||
func TestTurnReply_HalfWrappedReplyRegularizedForBothSides(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
chat := &stubChatter{res: &loop.Result{Text: halfWrappedTurnReply, Mood: "平静"}}
|
||
b, ad, hist := replyTurnBridge(t, chat)
|
||
// 5 个气泡 ⇒ 4 次气泡间延时;测试里归零,否则真的会 sleep 0.5-2s。
|
||
b.bubbleGapFn = func() time.Duration { return 0 }
|
||
|
||
b.process(context.Background(), directMessage())
|
||
|
||
// 落库(Web 主会话镜像)吃的是规整后的同一份文本。
|
||
rec := assertAssistantReply(t, hist, wrappedTurnReply)
|
||
if rec.Mood != "平静" {
|
||
t.Fatalf("assistant mood = %q, want 平静", rec.Mood)
|
||
}
|
||
// 出站:不再塌成一条 —— 每段一个气泡。
|
||
want := []string{
|
||
"第一段,读到头也读到尾啦♪",
|
||
"第二段,轻轻合上纸条。",
|
||
"第三段,纸张有点卷边了。",
|
||
"第四段,你看这里还有个小记号。",
|
||
"第五段,我把它们按日子排好啦。",
|
||
}
|
||
if len(ad.sends) != len(want) {
|
||
t.Fatalf("出站气泡 = %d 条, want %d(半包曾整条塌成一个气泡): %+v", len(ad.sends), len(want), ad.sends)
|
||
}
|
||
for i, w := range want {
|
||
if got := ad.sends[i].Content; got != w {
|
||
t.Fatalf("气泡[%d] = %q, want %q", i, got, w)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestTurnReply_CanonicalReplyStaysByteIdentical 是必测②(IM 侧):已规范的回复
|
||
// 落库与出站都不被二次加工 —— 落库逐字不变。
|
||
func TestTurnReply_CanonicalReplyStaysByteIdentical(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
chat := &stubChatter{res: &loop.Result{Text: wrappedTurnReply}}
|
||
b, ad, hist := replyTurnBridge(t, chat)
|
||
b.bubbleGapFn = func() time.Duration { return 0 }
|
||
|
||
b.process(context.Background(), directMessage())
|
||
|
||
assertAssistantReply(t, hist, wrappedTurnReply)
|
||
if len(ad.sends) != 5 {
|
||
t.Fatalf("出站气泡 = %d 条, want 5", len(ad.sends))
|
||
}
|
||
}
|
||
|
||
// TestTurnReply_FullyBareReplyIsRegularized 是必测③(IM 侧):完全没有任何 <p> 的
|
||
// 裸文本也会被规整后交付 —— 过去它拿到的是「一个含换行的气泡」,现在是三段气泡。
|
||
func TestTurnReply_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>"
|
||
chat := &stubChatter{res: &loop.Result{Text: bare}}
|
||
b, ad, hist := replyTurnBridge(t, chat)
|
||
b.bubbleGapFn = func() time.Duration { return 0 }
|
||
|
||
b.process(context.Background(), directMessage())
|
||
|
||
assertAssistantReply(t, hist, want)
|
||
// 纯动作段被剥成空 ⇒ 只发两个气泡(既有行为,不受本改动影响)。
|
||
if len(ad.sends) != 2 {
|
||
t.Fatalf("出站气泡 = %d 条, want 2: %+v", len(ad.sends), ad.sends)
|
||
}
|
||
if ad.sends[0].Content != "第一段。" || ad.sends[1].Content != "第二段。" {
|
||
t.Fatalf("气泡 = %+v, want [第一段。 第二段。]", ad.sends)
|
||
}
|
||
}
|
||
|
||
// TestTurnReply_BlankReplyStaysUntouchedByRegularization 是必测④(IM 侧):空回复
|
||
// 走的是另一条分支(补诚实说明),规整不介入 —— 文案与共享包逐字相同。
|
||
func TestTurnReply_BlankReplyStaysUntouchedByRegularization(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
for _, blank := range []string{"", " "} {
|
||
chat := &stubChatter{res: &loop.Result{Text: blank}}
|
||
b, ad, hist := replyTurnBridge(t, chat)
|
||
|
||
b.process(context.Background(), directMessage())
|
||
|
||
if len(ad.sends) != 1 {
|
||
t.Fatalf("出站消息 = %d 条, want 1(空回复也要有可见回复)", len(ad.sends))
|
||
}
|
||
if got := ad.sends[0].Content; strings.Contains(got, "<p>") {
|
||
t.Fatalf("兜底文案被包上了段落标记: %q", got)
|
||
}
|
||
assertAssistantReply(t, hist, turnreply.FallbackEmptyReply)
|
||
}
|
||
}
|
||
|
||
// TestSendProactive_HalfWrappedPushRegularized 覆盖**第三个**助手文本产出点:主动
|
||
// 推送(backthink)。它与回合回复同一类缺陷、同一份判据,因此走同一份规整 ——
|
||
// IM 侧不再塌成一个气泡,镜像进 Web 主会话的也是规整后的同一份文本。
|
||
func TestSendProactive_HalfWrappedPushRegularized(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 }
|
||
|
||
if err := b.SendProactive(context.Background(), proactiveMsg(halfWrappedTurnReply)); err != nil {
|
||
t.Fatalf("SendProactive: %v", err)
|
||
}
|
||
if len(weixin.sends) != 5 {
|
||
t.Fatalf("weixin 气泡 = %d 条, want 5(半包推送曾整条塌成一个气泡): %+v", len(weixin.sends), weixin.sends)
|
||
}
|
||
recs := hist.records()
|
||
if len(recs) != 1 {
|
||
t.Fatalf("镜像进主会话 = %d 条, want 1: %+v", len(recs), recs)
|
||
}
|
||
if recs[0].Content != wrappedTurnReply {
|
||
t.Fatalf("镜像内容 =\n%q\nwant 与 IM 出站同一份(网关侧同一字面量)\n%q", recs[0].Content, wrappedTurnReply)
|
||
}
|
||
}
|