feat(runtime): IM 主人私聊补历史转录(与 Web 主会话同源:抽共享 transcript 包 + OwnerEnvFor 单入口)
This commit is contained in:
1 parent
df8fc660c0
commit
9b903a89bc
14 files changed
+676
-181
No files matched your search
@@ -232,7 +232,8 @@ func inboundTargetFor(sess session.Session, platform string) inboundTarget {
|
||||
// runInboundTurn 为被放行的消息驱动 agent 轮次:解析
|
||||
// 路由目标、把消息携带的文件类附件**落库并挂成文档部件**
|
||||
// (design/24 §3;切片 M1)、把图片媒体内联成 data: URI 部件挂上既有视觉链路
|
||||
// (切片 IM-IMG;design/24 §4.2 末)、构建模型可见的消息列表、在轮次之前持久化入站
|
||||
// (切片 IM-IMG;design/24 §4.2 末)、构建模型可见的消息列表(主人私聊车道上再前置主会话的
|
||||
// 历史转录,见 transcript.go),在轮次之前持久化入站
|
||||
// 用户消息**连同它们的 `document` 引用列与 `image` 列**(因此即使渠道回复失败它也已落地,
|
||||
// 客户端也有附件芯片与图片可看),并在轮次之后把抽取结论补回同一列,运行
|
||||
// 循环并把结果交给 deliverReply。
|
||||
@@ -258,11 +259,13 @@ func (b *Bridge) runInboundTurn(ctx context.Context, msg adapter.UnifiedMessage,
|
||||
// 镜像进主会话时取主会话的绑定,否则取账号的
|
||||
// 渠道级绑定,再否则取全局默认;群聊或
|
||||
// 陌生人会话强制使用默认角色。
|
||||
runEnv := loop.SessionEnv{ActorEnv: sess.Env(), MemWritable: sess.MemWritable}
|
||||
runEnv.PersonaID = b.resolvePersona(ctx, sess, target, adapterName(a))
|
||||
runEnv := turnEnv(sess, target, b.resolvePersona(ctx, sess, target, adapterName(a)))
|
||||
senderName := senderDisplayName(msg, actor)
|
||||
loopMsgs := b.buildLoopMessages(ctx, text, runEnv, actor, senderName, msg)
|
||||
// 附件/图片先挂到**本回合的**用户消息上(attachInboundContent 认列表里第一条
|
||||
// RoleUser),再前置历史转录 —— 顺序反过来会把附件挂到转录里的旧用户消息上。
|
||||
attachInboundContent(loopMsgs, attachments, images)
|
||||
loopMsgs = b.seedTurnTranscript(ctx, target, loopMsgs)
|
||||
|
||||
// 附件引用与图片必须同批落进用户消息各自的列(design/24 §2.4 / design/16 §4.4):
|
||||
// 只把部件挂在内存里的模型可见消息上,客户端读到的那条消息就是一片空白 —— 附件芯片
|
||||
@@ -368,6 +371,10 @@ func deviceIDFor(msg adapter.UnifiedMessage) string {
|
||||
// 入站内容摄取的产物(已落库的文档部件、内联成 data: URI 的图片部件,以及两条路上
|
||||
// 「读不到」的诚实提示行)由 attachInboundContent(inbound_content.go)挂到**同一条
|
||||
// RoleUser 消息**上 —— 用户内容只进用户角色消息是项目红线。
|
||||
//
|
||||
// 历史转录**不在本函数里**:它由 seedTurnTranscript(transcript.go)在本函数返回之后
|
||||
// 前置到列表最前面,与网关编排器的播种同序(转录在前、本次消息在后)。因此本函数只负责
|
||||
// **本回合自己的**消息(来源行 + 当次用户消息),受限车道的形状在有无转录时保持一致。
|
||||
func (b *Bridge) buildLoopMessages(_ context.Context, text string, env loop.SessionEnv, actor adapter.Actor, senderName string, src adapter.UnifiedMessage) []model.Message {
|
||||
msg := userTurnMessage(text)
|
||||
thirdParty := env.Restricted() && !actor.IsOwner() && senderName != ""
|
||||
|
||||
@@ -27,15 +27,19 @@ func (p *personaHistory) SessionPersona(_ context.Context, sessionID string) (st
|
||||
return p.personas[sessionID], nil
|
||||
}
|
||||
|
||||
// envChatter 是一个 Chatter,记录桥接器为该轮解析出的 SessionEnv,
|
||||
// 角色正是经由它传递(循环从 ctx 读取它)。
|
||||
// envChatter 是一个 Chatter,记录桥接器为该轮解析出的 SessionEnv(角色正是经由它传递,
|
||||
// 循环从 ctx 读取它)、目标会话 id 与模型可见的消息列表。
|
||||
type envChatter struct {
|
||||
env loop.SessionEnv
|
||||
got bool
|
||||
env loop.SessionEnv
|
||||
got bool
|
||||
sessionID string
|
||||
msgs []model.Message
|
||||
}
|
||||
|
||||
func (e *envChatter) Run(ctx context.Context, _ string, _ []model.Message) (*loop.Result, error) {
|
||||
func (e *envChatter) Run(ctx context.Context, sessionID string, msgs []model.Message) (*loop.Result, error) {
|
||||
e.env = loop.SessionEnvFrom(ctx)
|
||||
e.sessionID = sessionID
|
||||
e.msgs = msgs
|
||||
e.got = true
|
||||
return &loop.Result{Text: "在呢"}, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package channelbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
|
||||
"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 的**主人私聊**回合与 Web 主会话回合的环境逐项一致(主人 2026-10-05 要求:
|
||||
// 处理其他平台的主人消息时和主会话环境完全一致)。
|
||||
//
|
||||
// 三条环境在 loop.SessionEnv 上早已一致(ActorEnv=owner、MemWritable=true、PersonaID 取
|
||||
// 主会话绑定,design/22 §7-3),缺的是**历史转录**:Web 主会话回合由编排器播种该会话最近
|
||||
// N 条转录(gateway/session_transcript.go),而 IM 回合此前只有「来源系统行 + 当次消息」。
|
||||
// 于是消息明明落在同一条会话流里,模型看到的历史却不同 —— 她像是第一次见到主人。
|
||||
//
|
||||
// 本文件补上这一半:主人私聊回合在装配完本回合自己的消息之后、交给循环之前,前置**主会话**
|
||||
// 的最近 N 条转录。重建逻辑与条数上限都取自 internal/runtime/transcript —— 与网关同一份
|
||||
// 实现、同一个常量,绝不另写第二套(两份映射必然漂移)。受限车道(群聊/陌生人)**绝不注入**:
|
||||
// 那是隔离会话,只看得到自己会话里的当次消息,主人的主会话历史不得外泄(design/16 §4.6)。
|
||||
|
||||
// turnEnv 返回一条入站消息本轮运行所用的 loop 会话环境。
|
||||
//
|
||||
// - 主人私聊(inboundTarget.private)与 Web 主会话是**同一条车道**,因此经主人车道的
|
||||
// 唯一构造入口 loop.OwnerEnvFor 派生:两条车道逐字段同源,SessionEnv 将来新增字段时
|
||||
// 会一起拿到,而不是各自漂移出「同一个她、两套环境」。
|
||||
// - 受限会话(群聊/陌生人)由结构化解析出的隔离边界决定:记忆只读(MemWritable=false)、
|
||||
// 并强制默认角色(design/22 §7-4 —— resolvePersona 对受限会话恒返回 "")。身份与权限
|
||||
// 始终来自 actor 元数据,绝不来自这里的任何字符串(项目红线)。
|
||||
func turnEnv(sess session.Session, target inboundTarget, personaID string) loop.SessionEnv {
|
||||
if target.private {
|
||||
return loop.OwnerEnvFor(personaID)
|
||||
}
|
||||
return loop.SessionEnv{ActorEnv: sess.Env(), MemWritable: sess.MemWritable, PersonaID: personaID}
|
||||
}
|
||||
|
||||
// seedTurnTranscript 在主人私聊车道上把**主会话**最近的转录置于本回合模型可见消息之前
|
||||
// (与网关编排器的 startTurnMessages 同序:转录在前、本次消息在后),使 IM 回合与 Web 主
|
||||
// 会话看到同一份对话历史。受限(群聊/陌生人)车道原样返回 —— 隔离上下文绝不注入主人的
|
||||
// 主会话历史。
|
||||
//
|
||||
// 时刻与网关一致:调用点在本回合用户消息落库**之前**(runInboundTurn 里装配先于
|
||||
// persistToMain),因此当次消息绝不会在上下文里出现两次。
|
||||
//
|
||||
// 转录读不出来(未接历史存储、读失败、空会话)时同样原样返回,这正是既有行为:主人私聊
|
||||
// 回合退化为「来源行 + 当次消息」,而不是让回合失败。
|
||||
func (b *Bridge) seedTurnTranscript(ctx context.Context, target inboundTarget, msgs []model.Message) []model.Message {
|
||||
if !target.private {
|
||||
return msgs
|
||||
}
|
||||
seed := transcript.Read(ctx, b.history, target.sessionID)
|
||||
if len(seed) == 0 {
|
||||
return msgs
|
||||
}
|
||||
out := make([]model.Message, 0, len(seed)+len(msgs))
|
||||
out = append(out, seed...)
|
||||
return append(out, msgs...)
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package channelbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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 主人私聊的回合环境与 Web 主会话完全一致」这条要求里**历史转录**那一半
|
||||
// (见 transcript.go 的文件头、DevDocs/工作小结/2026-10-05_QQ判主失效与脱敏日志.md 附 2):
|
||||
//
|
||||
// - 主人私聊车道的模型可见列表 = 主会话最近转录(同一个重建实现、同一条数上限)+
|
||||
// 渠道来源系统行 + 当次用户消息;
|
||||
// - 群聊/陌生人/隔离会话**绝不**注入:不读主会话历史、主人的话一个字都不进模型可见消息;
|
||||
// - 车道环境由 loop.OwnerEnvFor 同源派生,因此与 Web 主会话逐字段相同(不靠各自手搓)。
|
||||
//
|
||||
// Web 侧的端到端对应断言在 gateway 的 TestChatTurnCarriesSpeakerAttributionFromTranscript
|
||||
// (同一份 internal/runtime/transcript 产出的列表)。
|
||||
|
||||
// seededHistory 在 captureHistory(记录落库)之上把 Recent 换成「返回预置的转录行」,
|
||||
// 使播种用例无需真库即可驱动历史。它记录每次请求的会话与条数上限 —— 前者断言
|
||||
// 「只读主会话」,后者断言「条数取自共享常量」。
|
||||
type seededHistory struct {
|
||||
*captureHistory
|
||||
rows []history.Message
|
||||
|
||||
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})
|
||||
rows := make([]history.Message, 0, len(h.rows))
|
||||
for _, m := range h.rows {
|
||||
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 策略二 ⇒ 会得到回复),
|
||||
// 因此群/陌生人用例能走到循环,从而验证「转录没有被注入」。
|
||||
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)
|
||||
}
|
||||
// 主人的主会话历史一个字都不许出现在群聊的模型可见消息里。
|
||||
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)
|
||||
}
|
||||
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) {
|
||||
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: "群里的上一句"},
|
||||
}}
|
||||
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) != 2 {
|
||||
t.Fatalf("loop messages = %d, want 2 (origin/boundary + user): %+v", len(chat.msgs), chat.msgs)
|
||||
}
|
||||
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 asks := hist.asksFor(); len(asks) != 0 {
|
||||
t.Fatalf("isolated lane read the transcript: %+v", asks)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user