Files
Cyrene_v2/internal/services/channelbridge/transcript_test.go
T
AskaEth 7ae4298871
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(channels): 陌生人私聊正常聊天(跳概率门)+ 强制流控 6条/分与回合槽位 + 安静时段不再硬静默 + 受限车道边界行前置
- 陌生人私聊:不再走「值不值得回」的概率门,唯一闸门 = 每发送者 6 条/分滑窗 + 同会话 1 个在跑回合
- 被限流只登记不回,且留带 source 的可见告警(不静默)
- 安静时段在回复侧只作信号(规则层不再静默),群聊取消夜间静默
- 锁定流控常量:FlowDirectDefault 加字面量钉桩测试(放大它 = 静默关流控,且会让额度循环跑百万轮)
2026-10-05 13:26:15 +08:00

459 lines
21 KiB
Go
Raw 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 channelbridge
import (
"context"
"errors"
"strings"
"sync"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"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/transcript"
)
// 本文件覆盖 IM 回合的**历史转录**那一半(见 transcript.go 的文件头、
// DevDocs/工作小结/2026-10-05_QQ判主失效与脱敏日志.md 附 2):
//
// - 主人私聊车道的模型可见列表 = 主会话最近转录(同一个重建实现、同一条数上限)+
// 渠道来源系统行 + 当次用户消息;
// - 群聊/陌生人(受限车道)= **该隔离会话自己**最近转录(含只被静默登记的发言,
// 带说话人归属)+ 第三方边界/来源系统行 + 当次用户消息;
// - 两条车道都**只读自己的会话**:主人的主会话历史、别的隔离会话的内容一个字都不进
// 受限上下文,记忆仍只读(design/02 §3);
// - 车道环境由 loop.OwnerEnvFor 同源派生,因此主人私聊与 Web 主会话逐字段相同(不靠各自手搓)。
//
// Web 侧的端到端对应断言在 gateway 的 TestChatTurnCarriesSpeakerAttributionFromTranscript
// (同一份 internal/runtime/transcript 产出的列表)。
// seededHistory 在 captureHistory(记录落库)之上把 Recent 换成「返回预置的转录行」,
// 使播种用例无需真库即可驱动历史。它记录每次请求的会话与条数上限 —— 前者断言
// 「读的是哪条会话」,后者断言「条数取自共享常量」。
type seededHistory struct {
*captureHistory
rows []history.Message
// live 把夹具变成「活存储」:Recent 除了预置行之外,还把本次运行**已经落库**的行
// (captureHistory 记录的 Append)一并返回。真库就是这样 —— 写进去的行立刻出现在
// Recent 里。因此「播种发生在当次消息落库之后」这类时刻错误会让当次消息在上下文里
// 出现两次,而活夹具能把它数出来。
live bool
// recentErr 让 Recent 失败,用来钉住「读不出来就退化为今天的行为」。
recentErr error
mu sync.Mutex
asks []transcriptAsk
}
// transcriptAsk 是一次 Recent 调用的入参快照。
type transcriptAsk struct {
session string
n int
}
func (h *seededHistory) Recent(_ context.Context, sessionID string, n int) ([]history.Message, error) {
h.mu.Lock()
defer h.mu.Unlock()
h.asks = append(h.asks, transcriptAsk{session: sessionID, n: n})
if h.recentErr != nil {
return nil, h.recentErr
}
all := h.rows
if h.live && len(h.captureHistory.records()) > 0 {
all = append(append([]history.Message(nil), h.rows...), h.captureHistory.records()...)
}
rows := make([]history.Message, 0, len(all))
for _, m := range all {
if m.SessionID == sessionID {
rows = append(rows, m)
}
}
if n <= 0 {
return nil, nil
}
if len(rows) > n {
rows = rows[len(rows)-n:]
}
return rows, nil
}
// asksFor 返回已经发生的 Recent 调用(按发生顺序)。
func (h *seededHistory) asksFor() []transcriptAsk {
h.mu.Lock()
defer h.mu.Unlock()
return append([]transcriptAsk(nil), h.asks...)
}
// turnMessageText 拼接一条模型消息里的文本部件。
func turnMessageText(m model.Message) string {
out := ""
for _, c := range m.Content {
if c.Type == model.ContentTypeText {
out += c.Text
}
}
return out
}
// groupMessage 是一条被点名的群消息(design/12 §6 策略二 ⇒ 会得到回复),
// 因此群/陌生人用例能走到循环,从而验证转录的注入口径。
// noMentionGroupMessage 是一条既没 @ 她、也没有任何规则信号的群消息(design/12 §6 ⇒
// 只登记、不回复),用来验证「被静默的发言也落进同一会话、因而看得见」。
func noMentionGroupMessage(senderID, senderName, text string) adapter.UnifiedMessage {
return adapter.UnifiedMessage{
Platform: "telegram", ChannelID: "-20", ChannelType: adapter.ChannelGroup,
GroupID: "-20", GroupName: "Gang", SenderID: senderID, SenderName: senderName,
Sender: adapter.Actor{Role: adapter.RoleMember},
Content: text, ContentType: adapter.ContentText,
}
}
// groupMessage 是一条被点名的群消息(design/12 §6 策略二 ⇒ 会得到回复),
// 因此群/陌生人用例能走到循环,从而验证转录的注入口径。
func groupMessage(senderID string, sender adapter.Actor) adapter.UnifiedMessage {
return adapter.UnifiedMessage{
Platform: "telegram", ChannelID: "-20", ChannelType: adapter.ChannelGroup,
GroupID: "-20", GroupName: "Gang", SenderID: senderID, SenderName: "张三",
Sender: sender, Mentions: []adapter.Mention{{UserID: senderID}},
Content: "hello all", ContentType: adapter.ContentText,
}
}
func TestOwnerPrivateTurnSeedsMainSessionTranscript(t *testing.T) {
t.Parallel()
hist := &seededHistory{captureHistory: &captureHistory{}, rows: []history.Message{
{ID: "m1", SessionID: history.MainSessionID, Role: history.RoleUser, Content: "喵"},
{ID: "m2", SessionID: history.MainSessionID, Role: history.RoleAssistant, Content: "又用喵来敲门啦", PersonaID: "cyrene"},
// 隔离会话的行绝不进主会话车道的上下文。
{ID: "x1", SessionID: "sess-other", Role: history.RoleUser, Content: "群里的别人的话"},
}}
chat := &envChatter{}
sess := &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}}
b, err := New(Options{Loop: chat, Sessions: sess, Owner: testOwnerProfile("telegram", "100"), History: hist})
if err != nil {
t.Fatalf("New: %v", err)
}
b.Add(&fakeAdapter{name: "telegram"})
b.process(context.Background(), directMessage())
// 与 Web 主会话是同一条车道:本轮跑在主会话上(design/16 §4.4)。
if chat.sessionID != history.MainSessionID {
t.Fatalf("loop session id = %q, want %q", chat.sessionID, history.MainSessionID)
}
// 模型可见列表 = 主会话转录(最旧优先)+ 渠道来源系统行 + 当次用户消息。
// 除来源行外,它与 Web 主会话回合的列表逐条一致 —— 这正是「同一个她」的前提。
if len(chat.msgs) != 4 {
t.Fatalf("loop messages = %d, want 4 (transcript 2 + origin + user): %+v", len(chat.msgs), chat.msgs)
}
if got := chat.msgs[0]; got.Role != model.RoleUser || turnMessageText(got) != "喵" {
t.Fatalf("messages[0] = %+v, want the earlier user turn 喵", got)
}
if got := chat.msgs[1]; got.Role != model.RoleAssistant || turnMessageText(got) != "又用喵来敲门啦" || got.SenderName != "cyrene" {
t.Fatalf("messages[1] = %+v, want her earlier reply attributed to cyrene", got)
}
if got := chat.msgs[2]; got.Role != model.RoleSystem || !strings.Contains(turnMessageText(got), "来源:telegram · 私聊") {
t.Fatalf("messages[2] = %+v, want the telegram origin system row", got)
}
if got := chat.msgs[3]; got.Role != model.RoleUser || turnMessageText(got) != "hello" {
t.Fatalf("messages[3] = %+v, want this turn's user message", got)
}
// 当次消息只出现一次:转录是在本回合消息落库**之前**读的(与网关播种同一时刻)。
seen := 0
for _, m := range chat.msgs {
if m.Role == model.RoleUser && turnMessageText(m) == "hello" {
seen++
}
}
if seen != 1 {
t.Fatalf("current user message appears %d times, want exactly 1", seen)
}
// 只读主会话,且条数上限取自共享常量(本包不许另造一个数)。
asks := hist.asksFor()
if len(asks) != 1 || asks[0].session != history.MainSessionID {
t.Fatalf("transcript reads = %+v, want exactly one for %q", asks, history.MainSessionID)
}
if asks[0].n != transcript.ContextCount {
t.Fatalf("transcript window = %d, want the shared transcript.ContextCount (%d)", asks[0].n, transcript.ContextCount)
}
// 车道环境与 Web 主会话**同源派生**:本夹具主会话未绑定角色,因此逐字段等于 loop.OwnerEnv()。
if chat.env != loop.OwnerEnv() {
t.Fatalf("run env = %+v, want the shared owner env %+v", chat.env, loop.OwnerEnv())
}
}
func TestGroupTurnNeverSeedsMainSessionTranscript(t *testing.T) {
t.Parallel()
hist := &seededHistory{captureHistory: &captureHistory{}, rows: []history.Message{
{ID: "m1", SessionID: history.MainSessionID, Role: history.RoleAssistant, Content: "主人的私事:明天九点体检", PersonaID: "cyrene"},
}}
chat := &envChatter{}
sess := &stubSessions{sess: session.Session{SessionID: "sess-g", IsGroup: true, Owner: false, MemWritable: false}}
b, err := New(Options{Loop: chat, Sessions: sess, Owner: testOwnerProfile("telegram", "100"), History: hist})
if err != nil {
t.Fatalf("New: %v", err)
}
b.process(context.Background(), groupMessage("9", adapter.Actor{Role: adapter.RoleMember}))
// 该群自己没有历史 ⇒ 形状与改动前逐字相同:第三方边界系统行 + 当次用户消息。
if len(chat.msgs) != 2 {
t.Fatalf("loop messages = %d, want 2 (boundary + user): %+v", len(chat.msgs), chat.msgs)
}
if got := chat.msgs[0]; got.Role != model.RoleSystem || !strings.Contains(turnMessageText(got), thirdPartyBoundaryPrompt) {
t.Fatalf("messages[0] = %+v, want the third-party boundary system row", got)
}
// 主人的主会话历史一个字都不许出现在群聊的模型可见消息里。
for i, m := range chat.msgs {
if strings.Contains(turnMessageText(m), "主人的私事") {
t.Fatalf("messages[%d] leaked the owner's main-session transcript: %+v", i, m)
}
}
// 受限车道读的是**它自己**的会话,绝不是主会话(design/02 §3 的上下文隔离)。
asks := hist.asksFor()
if len(asks) != 1 || asks[0].session != "sess-g" {
t.Fatalf("transcript reads = %+v, want exactly one for the isolated sess-g", asks)
}
if !chat.env.Restricted() || chat.env.MemWritable {
t.Fatalf("run env = %+v, want the restricted (read-only memory) env", chat.env)
}
}
// TestGroupTurnSeedsItsOwnSessionTranscript 是本切片的正面用例:受限(群聊)回合必须带上
// **该隔离会话自己**最近的几条消息 —— 包括只被登记、没被回复的那几条 —— 并逐条保留结构化
// 的说话人归属(SenderName),她据此才看得懂一段连贯对话的意图(主人 2026-10-05 反馈:
// 「不 @ 就不理解意图」)。
func TestGroupTurnSeedsItsOwnSessionTranscript(t *testing.T) {
t.Parallel()
hist := &seededHistory{captureHistory: &captureHistory{}, live: true, rows: []history.Message{
// 主人的主会话历史:绝不进群聊上下文。
{ID: "m1", SessionID: history.MainSessionID, Role: history.RoleAssistant, Content: "主人的私事:明天九点体检", PersonaID: "cyrene"},
// 该群自己的历史:两个不同说话人各归各,含一条只被静默登记的发言。
{ID: "g1", SessionID: "sess-g", Role: history.RoleUser, Content: "晚上要不要开黑", SenderName: "张三"},
{ID: "g2", SessionID: "sess-g", Role: history.RoleUser, Content: "我还在加班", SenderName: "李四"},
{ID: "g3", SessionID: "sess-g", Role: history.RoleAssistant, Content: "那你先忙", PersonaID: "cyrene"},
// 别的隔离会话:同一条受限车道上也不许串。
{ID: "x1", SessionID: "sess-other", Role: history.RoleUser, Content: "别的群的话", SenderName: "王五"},
}}
chat := &envChatter{}
sess := &stubSessions{sess: session.Session{SessionID: "sess-g", IsGroup: true, Owner: false, MemWritable: false}}
b, err := New(Options{Loop: chat, Sessions: sess, Owner: testOwnerProfile("telegram", "100"), History: hist})
if err != nil {
t.Fatalf("New: %v", err)
}
b.process(context.Background(), groupMessage("9", adapter.Actor{Role: adapter.RoleMember}))
// 形状(frame 先行):第三方边界/来源系统行**在最前**,该群最近 3 条转录居中
// (最旧优先),当次用户消息在最后。
if len(chat.msgs) != 5 {
t.Fatalf("loop messages = %d, want 5 (boundary + transcript 3 + user): %+v", len(chat.msgs), chat.msgs)
}
// 边界行必须最先:历史里的陌生人发言绝不能在她还不知道「这是群、这些人是外人」
// 之前进入上下文(提示词注入面,主人 2026-10-05 裁定)。
if got := chat.msgs[0]; got.Role != model.RoleSystem || !strings.Contains(turnMessageText(got), thirdPartyBoundaryPrompt) ||
!strings.Contains(turnMessageText(got), "群「Gang」") {
t.Fatalf("messages[0] = %+v, want the origin + third-party boundary system row first", got)
}
// 说话人归属逐条正确:两个不同说话人各归各,且走结构化字段(不是织进正文的标签)。
if got := chat.msgs[1]; got.Role != model.RoleUser || turnMessageText(got) != "晚上要不要开黑" || got.SenderName != "张三" {
t.Fatalf("messages[1] = %+v, want 张三's earlier line attributed to 张三", got)
}
if got := chat.msgs[2]; got.Role != model.RoleUser || turnMessageText(got) != "我还在加班" || got.SenderName != "李四" {
t.Fatalf("messages[2] = %+v, want 李四's silenced line attributed to 李四", got)
}
if got := chat.msgs[3]; got.Role != model.RoleAssistant || turnMessageText(got) != "那你先忙" || got.SenderName != "cyrene" {
t.Fatalf("messages[3] = %+v, want her earlier reply attributed to cyrene", got)
}
if got := chat.msgs[4]; got.Role != model.RoleUser || turnMessageText(got) != "hello all" || got.SenderName != "张三" {
t.Fatalf("messages[4] = %+v, want this turn's user message last", got)
}
// 当次消息只出现一次:转录是在本回合消息落库**之前**读的。活夹具把已经落库的行也返回,
// 因此播种时刻一旦挪到落库之后,这里立刻数出两次。
seen := 0
for _, m := range chat.msgs {
if m.Role == model.RoleUser && turnMessageText(m) == "hello all" {
seen++
}
}
if seen != 1 {
t.Fatalf("current user message appears %d times, want exactly 1", seen)
}
// 绝不泄露:主会话与别的隔离会话的内容一个字都不进这个群聊上下文。
for i, m := range chat.msgs {
text := turnMessageText(m)
if strings.Contains(text, "主人的私事") || strings.Contains(text, "别的群的话") {
t.Fatalf("messages[%d] = %q, leaked another conversation's transcript", i, text)
}
}
// 只读该会话自己,且条数上限取自共享常量(本包不许另造一个数)。
asks := hist.asksFor()
if len(asks) != 1 || asks[0].session != "sess-g" {
t.Fatalf("transcript reads = %+v, want exactly one for sess-g", asks)
}
if asks[0].n != transcript.ContextCount {
t.Fatalf("transcript window = %d, want the shared transcript.ContextCount (%d)", asks[0].n, transcript.ContextCount)
}
// 转录不改隔离边界:记忆仍然只读、绝不写。
if !chat.env.Restricted() || chat.env.MemWritable {
t.Fatalf("run env = %+v, want the restricted (read-only memory) env", chat.env)
}
}
// TestGroupTurnSeesEarlierSilencedMessages 端到端证明「被静默的消息也看得见」:先走一条既没 @
// 也没有任何规则信号的群消息(design/12 §6:只登记、不驱动循环),再让一条 @ 她的消息触发回合,
// 断言前一条**已经落库的静默发言**出现在模型可见上下文里,且归到它的说话人名下。
func TestGroupTurnSeesEarlierSilencedMessages(t *testing.T) {
t.Parallel()
hist := &seededHistory{captureHistory: &captureHistory{}, live: true}
chat := &envChatter{}
sess := &stubSessions{sess: session.Session{SessionID: "sess-g", IsGroup: true, Owner: false, MemWritable: false}}
b, err := New(Options{Loop: chat, Sessions: sess, Owner: testOwnerProfile("telegram", "100"), History: hist})
if err != nil {
t.Fatalf("New: %v", err)
}
b.process(context.Background(), noMentionGroupMessage("8", "李四", "我还在加班"))
if chat.got {
t.Fatalf("a no-signal message drove the loop: %+v", chat.msgs)
}
b.process(context.Background(), groupMessage("9", adapter.Actor{Role: adapter.RoleMember}))
found := false
for i, m := range chat.msgs {
if turnMessageText(m) != "我还在加班" {
continue
}
found = true
if m.Role != model.RoleUser || m.SenderName != "李四" {
t.Fatalf("messages[%d] = %+v, want 李四's silenced line attributed to 李四", i, m)
}
}
if !found {
t.Fatalf("the earlier silenced line is missing from the turn context: %+v", chat.msgs)
}
}
// TestRestrictedSeedDegradesToTodaysShape 钉住降级口径:空历史或读失败时,受限回合的模型
// 可见消息与今天逐字相同(来源/边界系统行 + 当次用户消息)—— 一次元数据查找绝不拖垮回合。
func TestRestrictedSeedDegradesToTodaysShape(t *testing.T) {
t.Parallel()
cases := []struct {
name string
hist *seededHistory
}{
{name: "empty history", hist: &seededHistory{captureHistory: &captureHistory{}}},
{name: "read failure", hist: &seededHistory{captureHistory: &captureHistory{}, recentErr: errors.New("boom")}},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
chat := &envChatter{}
sess := &stubSessions{sess: session.Session{SessionID: "sess-g", IsGroup: true, Owner: false, MemWritable: false}}
b, err := New(Options{Loop: chat, Sessions: sess, Owner: testOwnerProfile("telegram", "100"), History: tc.hist})
if err != nil {
t.Fatalf("New: %v", err)
}
b.process(context.Background(), groupMessage("9", adapter.Actor{Role: adapter.RoleMember}))
if !chat.got {
t.Fatal("the loop was never driven")
}
if len(chat.msgs) != 2 || chat.msgs[0].Role != model.RoleSystem || chat.msgs[1].Role != model.RoleUser {
t.Fatalf("loop messages = %+v, want today's shape (boundary + user)", chat.msgs)
}
})
}
}
// TestRestrictedSeedRefusesMainSession 直接驱动播种函数,钉住上下文隔离的第二道守卫:受限车道
// 即便(结构上不可能地)拿到主会话 id,也宁可不播种 —— 主人的主会话历史绝不进群聊/陌生人上下文
// (design/02 §3、design/16 §4.6 §3)。主人私聊车道读的仍是主会话(既有行为逐字不变)。
func TestRestrictedSeedRefusesMainSession(t *testing.T) {
t.Parallel()
hist := &seededHistory{captureHistory: &captureHistory{}, rows: []history.Message{
{ID: "m1", SessionID: history.MainSessionID, Role: history.RoleAssistant, Content: "主人的私事", PersonaID: "cyrene"},
}}
b, err := New(Options{Loop: &envChatter{}, Sessions: &stubSessions{}, Owner: testOwnerProfile("telegram", "100"), History: hist})
if err != nil {
t.Fatalf("New: %v", err)
}
msgs := []model.Message{
model.TextMessage(model.RoleSystem, thirdPartyBoundaryPrompt),
model.TextMessage(model.RoleUser, "hello"),
}
got := b.seedTurnTranscript(context.Background(), inboundTarget{sessionID: history.MainSessionID}, msgs)
if len(got) != len(msgs) {
t.Fatalf("restricted seed = %+v, want the turn's own messages only", got)
}
if asks := hist.asksFor(); len(asks) != 0 {
t.Fatalf("restricted lane read %+v, want no read at all", asks)
}
got = b.seedTurnTranscript(context.Background(), inboundTarget{sessionID: history.MainSessionID, private: true}, msgs)
if len(got) != len(msgs)+1 || turnMessageText(got[0]) != "主人的私事" {
t.Fatalf("owner private seed = %+v, want the main-session transcript in front", got)
}
}
// TestOwnerGroupTurnSeedsOnlyItsOwnIsolatedTranscript 是主人本人在群里的情形:它同样跑在隔离
// 会话上(design/02 §3:主人群内发言只在这个群聊隔离上下文里处理,不并入主会话),因此转录
// 只能来自该群会话 —— 主人的主会话历史仍不得进群上下文。
func TestOwnerGroupTurnSeedsOnlyItsOwnIsolatedTranscript(t *testing.T) {
t.Parallel()
hist := &seededHistory{captureHistory: &captureHistory{}, rows: []history.Message{
{ID: "m1", SessionID: history.MainSessionID, Role: history.RoleAssistant, Content: "主人的私事", PersonaID: "cyrene"},
{ID: "g1", SessionID: "sess-g", Role: history.RoleUser, Content: "群里的上一句", SenderName: "张三"},
}}
chat := &envChatter{}
sess := &stubSessions{sess: session.Session{SessionID: "sess-g", IsGroup: true, Owner: true, MemWritable: false}}
b, err := New(Options{Loop: chat, Sessions: sess, Owner: testOwnerProfile("telegram", "owner_uid"), History: hist})
if err != nil {
t.Fatalf("New: %v", err)
}
b.process(context.Background(), groupMessage("owner_uid", adapter.Actor{Role: adapter.RoleOwner}))
if chat.sessionID != "sess-g" {
t.Fatalf("loop session id = %q, want the isolated sess-g", chat.sessionID)
}
if len(chat.msgs) != 3 {
t.Fatalf("loop messages = %d, want 3 (boundary + isolated transcript + user): %+v", len(chat.msgs), chat.msgs)
}
// frame 先行:主人本人在群里同样是受限(群聊)车道 —— 边界/来源行仍在最前,
// 转录居中,当次消息在最后。
if got := chat.msgs[0]; got.Role != model.RoleSystem || !strings.Contains(turnMessageText(got), thirdPartyBoundaryPrompt) {
t.Fatalf("messages[0] = %+v, want the boundary/origin system row first", got)
}
if got := chat.msgs[1]; turnMessageText(got) != "群里的上一句" || got.SenderName != "张三" {
t.Fatalf("messages[1] = %+v, want the group's own earlier line", got)
}
if got := chat.msgs[2]; got.Role != model.RoleUser {
t.Fatalf("messages[2] = %+v, want this turn's user message last", got)
}
for i, m := range chat.msgs {
if strings.Contains(turnMessageText(m), "主人的私事") {
t.Fatalf("messages[%d] leaked the owner's main-session transcript: %+v", i, m)
}
}
// 只读该群会话,绝不读主会话。
asks := hist.asksFor()
if len(asks) != 1 || asks[0].session != "sess-g" {
t.Fatalf("transcript reads = %+v, want exactly one for sess-g", asks)
}
}