fix(gateway,channelbridge,turnreply): p 标签半包写入侧规整(修「整条塌成一段」)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

裁定(已发安卓):**能修**,走**写入侧规整** —— 不指望模型输出、不动客户端。
- 新增 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)⇒ 两边共用同一份规整文本 ⇒ **历史一次性变好、两个客户端零改动**、渲染口径不分叉
(测试与变异详见切片报告)
This commit is contained in:
AskaEth committed 2026-10-05 10:56:39 +08:00
1 parent d532eb3fcd
commit 5de708a560
17 files changed
+721 -46

No files matched your search

@@ -309,10 +309,12 @@ func TestOwnerPrivatePersistsToMainSessionWithPlatform(t *testing.T) {
recs[0].Mode != "chat" {
t.Fatalf("user record = %+v, want web/telegram/hello/telegram/chat", recs[0])
}
// 助手正文的落库内容是**规范形态**(写入侧段落包裹规整,见
// turnreply_paragraph_test.go):这里只关心归属字段,因此只要求包裹正确。
if recs[1].SessionID != history.MainSessionID || recs[1].Role != history.RoleAssistant ||
recs[1].Content != "微信回复" || recs[1].Platform != "telegram" || recs[1].AdapterID != "telegram" ||
recs[1].Content != "<p>微信回复</p>" || recs[1].Platform != "telegram" || recs[1].AdapterID != "telegram" ||
recs[1].Mode != "chat" {
t.Fatalf("assistant record = %+v, want web/telegram/微信回复/telegram/chat", recs[1])
t.Fatalf("assistant record = %+v, want web/telegram/<p>微信回复</p>/telegram/chat", recs[1])
}
// 每条已持久化的消息都交给了广播钩子。
if len(persisted) != 2 {
@@ -359,7 +361,7 @@ func TestOwnerPrivatePersistsToMainWithDeviceID(t *testing.T) {
if recs[0].Role != history.RoleUser || recs[0].Content != "hello" {
t.Fatalf("user record = %+v", recs[0])
}
if recs[1].Role != history.RoleAssistant || recs[1].Content != "微信回复" {
if recs[1].Role != history.RoleAssistant || recs[1].Content != "<p>微信回复</p>" {
t.Fatalf("assistant record = %+v", recs[1])
}
}
+10 -4
View File
@@ -7,6 +7,7 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/turnreply"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
)
@@ -54,6 +55,10 @@ func (b *Bridge) SendProactive(ctx context.Context, msg adapter.UnifiedMessage)
out := msg
out.Platform = target.platform
out.ChannelType = adapter.ChannelDirect
// 主动推送的文本与回合回复走**同一份**段落包裹规整(同一函数、同一口径):
// 半包 <p> 的推送在 Web 历史里同样会塌成一整段,在 IM 侧同样只会得到
// 一个气泡(splitBubbles 只认完整 <p> 块)。
out.Content = turnreply.NormalizeParagraphs(msg.Content)
// 主人的账号 id 在每个适配器上都作私聊对端:telegram/qq 读
// ChannelID,kook/weixin 读 SenderID(ChannelID 作回退)。两者
// 都设置,无论适配器偏好哪个字段,私聊消息都可投达,
@@ -63,17 +68,18 @@ func (b *Bridge) SendProactive(ctx context.Context, msg adapter.UnifiedMessage)
// 推送经共享的 IM 气泡派发器出口,因此多段主动消息
// 会被拆成气泡、角色扮演/旁白被剥离,并采用
// 与回复相同的 0.5-2s 间隔——WeChat 主动推送不再作为
// 一整条未拆分消息发送。
// 一整条未拆分消息发送。切分吃的是**规整后**的同一份文本。
if err := b.sendIMBubbles(ctx, out, func(ctx context.Context, msg adapter.UnifiedMessage) error {
return ad.SendProactive(ctx, msg)
}, msg.Content); err != nil {
}, out.Content); err != nil {
return fmt.Errorf("channelbridge: send proactive via %q: %w", target.platform, err)
}
// 把主动消息镜像进主对话上下文(design/16
// §4.4 + §4.6.1)。否则模型看不到自己主动说了什么,
// 主人后续回复就会显得不连贯(该消息「丢失」了历史)。
// 它与主人私聊 IM 回复走同一条镜像路径,作用域为 MainSessionID。
b.persistToMain(ctx, history.RoleAssistant, msg.Content, "", "", target.platform,
// 它与主人私聊 IM 回复走同一条镜像路径,作用域为 MainSessionID,
// 落库的同样是规整后的那一份(Web 与 IM 看到的必须是同一份)。
b.persistToMain(ctx, history.RoleAssistant, out.Content, "", "", target.platform,
fmt.Sprintf("%s:%s:%s", target.platform, target.accountID, target.accountID),
target.platform)
return nil
+6 -2
View File
@@ -32,11 +32,15 @@ import (
// 一条按结束原因区分的诚实说明。
//
// 真实文本按渠道既有行为去掉首尾空白后交付(内容一字不改),与加入本保证之前
// 的行为完全一致。
// 的行为完全一致 —— **唯一例外**是与网关同一次(同一份)的段落包裹规整
// (turnreply.NormalizeParagraphs):它给未被 `<p>` 包裹的段落补上包裹,
// 于是**落库文本**(主人私聊会镜像进 Web 主会话)与**出站气泡切分**
// (splitBubbles 按 `<p>` 切)吃的是同一份文本,客户端那侧「整条塌成一段」
// 的同一条判据在 IM 侧不会再出现第二个答案。
func turnVisibleReply(ctx context.Context, res *loop.Result, runErr error) (string, bool) {
text := turnreply.Text(res)
if res != nil && res.Text == text {
return strings.TrimSpace(text), true
return strings.TrimSpace(turnreply.NormalizeParagraphs(res.Text)), true
}
if errors.Is(runErr, context.Canceled) || ctx.Err() != nil {
return "", false
@@ -0,0 +1,165 @@
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)
}
}
@@ -207,12 +207,16 @@ func assertNothingDelivered(t *testing.T, ad *fakeAdapter, hist *captureHistory)
}
// TestTurnReply_NormalReplyPassedThroughVerbatim 是必测③:正常回合(有正文)
// 的出站与落库内容与模型正文**逐字相同**,绝不掺进兜底文案。
// 的出站内容与模型正文**逐字相同**(段落标记由 IM 出口按既有行为剥除),落库则是
// 同一条正文的**规范形态**(每段一个 <p>,写入侧规整,见
// turnreply_paragraph_test.go)——两者都不掺进兜底文案。
func TestTurnReply_NormalReplyPassedThroughVerbatim(t *testing.T) {
t.Parallel()
const want = "你好,我在。今天想聊点什么?"
chat := &stubChatter{res: &loop.Result{Text: want, Mood: "平静"}}
const reply = "你好,我在。今天想聊点什么?"
// 规范形态:单段正文也会被包上 <p>,因此落库内容与模型原文相差的只有包裹。
const canonical = "<p>" + reply + "</p>"
chat := &stubChatter{res: &loop.Result{Text: reply, Mood: "平静"}}
b, ad, hist := replyTurnBridge(t, chat)
b.process(context.Background(), directMessage())
@@ -220,10 +224,10 @@ func TestTurnReply_NormalReplyPassedThroughVerbatim(t *testing.T) {
if len(ad.sends) != 1 {
t.Fatalf("出站消息 = %d 条, want 1", len(ad.sends))
}
if got := ad.sends[0].Content; got != want {
t.Fatalf("出站文案 = %q, want 逐字等于模型正文", got)
if got := ad.sends[0].Content; got != reply {
t.Fatalf("出站文案 = %q, want 逐字等于模型正文(段落标记不投给 IM)", got)
}
rec := assertAssistantReply(t, hist, want)
rec := assertAssistantReply(t, hist, canonical)
if rec.Mood != "平静" {
t.Fatalf("assistant mood = %q, want 平静(真实结果的心情照旧带到消息上)", rec.Mood)
}