feat(channels): 受限车道回合补自身会话历史(群聊/陌生人能连贯理解一段对话;复用共享 transcript,拒绝主会话 id)
This commit is contained in:
1 parent
be6e5f5780
commit
89715d1813
3 files changed
+281
-41
No files matched your search
@@ -2,6 +2,7 @@ package channelbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -14,23 +15,33 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/transcript"
|
||||
)
|
||||
|
||||
// 本文件覆盖「IM 主人私聊的回合环境与 Web 主会话完全一致」这条要求里**历史转录**那一半
|
||||
// (见 transcript.go 的文件头、DevDocs/工作小结/2026-10-05_QQ判主失效与脱敏日志.md 附 2):
|
||||
// 本文件覆盖 IM 回合的**历史转录**那一半(见 transcript.go 的文件头、
|
||||
// DevDocs/工作小结/2026-10-05_QQ判主失效与脱敏日志.md 附 2):
|
||||
//
|
||||
// - 主人私聊车道的模型可见列表 = 主会话最近转录(同一个重建实现、同一条数上限)+
|
||||
// 渠道来源系统行 + 当次用户消息;
|
||||
// - 群聊/陌生人/隔离会话**绝不**注入:不读主会话历史、主人的话一个字都不进模型可见消息;
|
||||
// - 车道环境由 loop.OwnerEnvFor 同源派生,因此与 Web 主会话逐字段相同(不靠各自手搓)。
|
||||
// - 群聊/陌生人(受限车道)= **该隔离会话自己**最近转录(含只被静默登记的发言,
|
||||
// 带说话人归属)+ 第三方边界/来源系统行 + 当次用户消息;
|
||||
// - 两条车道都**只读自己的会话**:主人的主会话历史、别的隔离会话的内容一个字都不进
|
||||
// 受限上下文,记忆仍只读(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
|
||||
@@ -46,8 +57,15 @@ func (h *seededHistory) Recent(_ context.Context, sessionID string, n int) ([]hi
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.asks = append(h.asks, transcriptAsk{session: sessionID, n: n})
|
||||
rows := make([]history.Message, 0, len(h.rows))
|
||||
for _, m := range h.rows {
|
||||
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)
|
||||
}
|
||||
@@ -80,7 +98,20 @@ func turnMessageText(m model.Message) string {
|
||||
}
|
||||
|
||||
// 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,
|
||||
@@ -169,31 +200,222 @@ func TestGroupTurnNeverSeedsMainSessionTranscript(t *testing.T) {
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
if asks := hist.asksFor(); len(asks) != 0 {
|
||||
t.Fatalf("restricted lane read the transcript: %+v", asks)
|
||||
// 受限车道读的是**它自己**的会话,绝不是主会话(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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerGroupTurnKeepsIsolatedLaneWithoutTranscript(t *testing.T) {
|
||||
// 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}))
|
||||
|
||||
// 形状:该群最近 3 条转录(最旧优先)+ 第三方边界/来源系统行 + 当次用户消息。
|
||||
if len(chat.msgs) != 5 {
|
||||
t.Fatalf("loop messages = %d, want 5 (transcript 3 + boundary + user): %+v", len(chat.msgs), chat.msgs)
|
||||
}
|
||||
// 说话人归属逐条正确:两个不同说话人各归各,且走结构化字段(不是织进正文的标签)。
|
||||
if got := chat.msgs[0]; got.Role != model.RoleUser || turnMessageText(got) != "晚上要不要开黑" || got.SenderName != "张三" {
|
||||
t.Fatalf("messages[0] = %+v, want 张三's earlier line attributed to 张三", got)
|
||||
}
|
||||
if got := chat.msgs[1]; got.Role != model.RoleUser || turnMessageText(got) != "我还在加班" || got.SenderName != "李四" {
|
||||
t.Fatalf("messages[1] = %+v, want 李四's silenced line attributed to 李四", got)
|
||||
}
|
||||
if got := chat.msgs[2]; got.Role != model.RoleAssistant || turnMessageText(got) != "那你先忙" || got.SenderName != "cyrene" {
|
||||
t.Fatalf("messages[2] = %+v, want her earlier reply attributed to cyrene", got)
|
||||
}
|
||||
// 第三方身份边界与来源行照旧(内容一个字没变,仍在每轮注入)。
|
||||
if got := chat.msgs[3]; got.Role != model.RoleSystem || !strings.Contains(turnMessageText(got), thirdPartyBoundaryPrompt) ||
|
||||
!strings.Contains(turnMessageText(got), "群「Gang」") {
|
||||
t.Fatalf("messages[3] = %+v, want the origin + third-party boundary system row", 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", 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"},
|
||||
{ID: "g1", SessionID: "sess-g", Role: history.RoleUser, Content: "群里的上一句"},
|
||||
}}
|
||||
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}}
|
||||
@@ -207,16 +429,20 @@ func TestOwnerGroupTurnKeepsIsolatedLaneWithoutTranscript(t *testing.T) {
|
||||
if chat.sessionID != "sess-g" {
|
||||
t.Fatalf("loop session id = %q, want the isolated sess-g", chat.sessionID)
|
||||
}
|
||||
if len(chat.msgs) != 2 {
|
||||
t.Fatalf("loop messages = %d, want 2 (origin/boundary + user): %+v", len(chat.msgs), chat.msgs)
|
||||
if len(chat.msgs) != 3 {
|
||||
t.Fatalf("loop messages = %d, want 3 (isolated transcript + origin/boundary + user): %+v", len(chat.msgs), chat.msgs)
|
||||
}
|
||||
if got := chat.msgs[0]; turnMessageText(got) != "群里的上一句" || got.SenderName != "张三" {
|
||||
t.Fatalf("messages[0] = %+v, want the group's own earlier line", got)
|
||||
}
|
||||
for i, m := range chat.msgs {
|
||||
text := turnMessageText(m)
|
||||
if strings.Contains(text, "主人的私事") || strings.Contains(text, "群里的上一句") {
|
||||
t.Fatalf("messages[%d] = %q, want no injected transcript on an isolated lane", i, text)
|
||||
if strings.Contains(turnMessageText(m), "主人的私事") {
|
||||
t.Fatalf("messages[%d] leaked the owner's main-session transcript: %+v", i, m)
|
||||
}
|
||||
}
|
||||
if asks := hist.asksFor(); len(asks) != 0 {
|
||||
t.Fatalf("isolated lane read the transcript: %+v", asks)
|
||||
// 只读该群会话,绝不读主会话。
|
||||
asks := hist.asksFor()
|
||||
if len(asks) != 1 || asks[0].session != "sess-g" {
|
||||
t.Fatalf("transcript reads = %+v, want exactly one for sess-g", asks)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user