feat(channel): 群聊发言人归属落进模型上下文(user 侧也产出标注)

裁决 06.4.1 行动项①②落地(主人 2026-10-13 定稿:结构化归属 + system 标注,
不把元数据拼进用户正文)。

- loop/attribution.go:发言人归属从「只认 assistant」扩到 user 侧,按三条
  结构事实各出一条边界说明 —— assistant 的角色(她自己 / 别的角色,措辞逐字
  不变)、受限会话里带昵称的第三方人员(明标「不是主人」)、以及不带昵称的
  主人本人;受限会话里未标注的 assistant 历史按 design/22 §7-4 归到本会话角色。
  标注仍只新增 system 消息,正文一字不改。
- channelbridge/inbound_speaker.go(自 inbound.go 拆出):第三方发送者标注车道
  thirdPartySenderName —— 主人恒返回空(主人从不被标注),其余昵称缺失时退到
  平台 uid 再退到「未署名发送者」,绝不落进「空标注 = 主人」那条车道;
  thirdPartyBoundaryPrompt 删掉「只有未标注昵称的消息才是管理员(主人)发言」,
  改为「谁在说话以「发言归属」标注为准」。
- channelbridge/source.go:来源行删掉「(非管理员账户=第三方人员,未标注昵称=主人)」。
- 主人本人在群里的消息落库不再写昵称(sender_name 空 = 主人),使转录里同一句话
  的归属与当回合一致。

验证:go build ./... / go vet ./... / go test ./...(含全树)全绿;
变异自证:把 user 侧归属恢复成不产出 ⇒ 3 条 loop 用例 + 1 条渠道端到端用例变红。
This commit is contained in:
AskaEth committed 2026-10-13 20:24:21 +08:00
1 parent 1cd38514f3
commit 8ab241f627
6 files changed
+808 -65

No files matched your search

+157 -45
View File
@@ -7,12 +7,28 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件拥有模型上下文的发言人归属(DevDocs/design/22
// §7-5)。一个进程现在同时承载多个角色,且会话在切换角色时
// 保留其对话,因此模型必须能分辨上下文中的哪些话
// 出自哪个角色:前一角色的 assistant 消息不得读作
// 「我说过的话」,而当前作答的角色必须能认出
// 自己。
// 本文件拥有模型上下文的发言人归属:多角色会话里「哪句话是哪个角色说的」
// (DevDocs/design/22 §7-5),以及群聊/第三方会话里「这条群消息是谁说的」
// (DevDocs/design/02 §3「主人群内发言识别」、design/16 §4.6)。
//
// 三条**结构事实**决定归属怎么算,它们全部来自调用方的强类型元数据,
// 没有一条是从消息正文推断的(项目红线:身份不靠提示词、不靠正文):
//
// - assistant 消息的 SenderName 是**角色车道**:说出它的角色 id。
// gateway 的会话播种(session_transcript.go)与共享的
// internal/runtime/transcript 都填它。
// - user 消息的 SenderName 是**第三方显示名车道**(design/16 §4.6 §2):
// 渠道桥只给受限(群聊/陌生人)会话里的**非主人**发送者填它,且昵称
// 缺失时一定退到平台 uid(channelbridge/inbound.go 的
// thirdPartySenderName)——主人本人的发言永远留空。
// - 受限会话里未标注的 assistant 消息来自**本会话的角色**(她自己):
// design/22 §7-4 为群聊/陌生人强制默认角色(会话结构上不存在第二个
// 角色),而渠道桥的隔离车道落库时不写 persona_id,因此她的历史回复
// 在转录里不带标签。
//
// 于是受限会话里的「空标签」不是缺信息,而是结构信息本身:空 user = 主人
// 本人,空 assistant = 她自己。非受限会话(主会话、主人私聊、内部流程)里
// 空标签仍表示「未标注」,一律不渲染——那些车道的提示词因此逐字不变。
//
// 两条硬规则塑造了渲染方式:
// - 归属元数据绝不进入消息正文。用户消息是来自外部世界的
@@ -25,13 +41,6 @@ import (
// 指令仍是第一条系统消息,时间 / 工作记忆 / 记忆块保持
// 既有顺序(位置由 assembleContext 拥有;本文件只提供
// 追加的片段)。
//
// 每条消息的发言人从结构化的 model.Message.SenderName
// 车道读取,因为本批次 model/** 已冻结,Message 没有专门的
// persona 字段。只读 assistant 消息:对 assistant 轮,
// 发送者正是说出它的角色,而带标签的用户消息保持其
// 既有含义(design/16 §4.6 的第三方发送者标记),
// 本文件刻意不碰它。
// 归属标记。两者都是编译期常量,使运行路径中
// 不出现魔法字符串,测试也能断言确切的线上形态。
@@ -43,24 +52,91 @@ const (
speakerSwitchMarker = "[发言归属] "
)
// speakerOf 返回被标注为 m 发言人的角色 id;该消息不带
// 归属时返回 ""。只考虑 assistant 消息:用户
// 消息的 SenderName 是 design/16 §4.6 的第三方显示名车道,
// 与人格选择无关。
func speakerOf(m model.Message) string {
if m.Role != model.RoleAssistant {
return ""
}
return strings.TrimSpace(m.SenderName)
// speakerKind 区分一位发言人属于哪条车道,它决定边界说明怎么写。
type speakerKind int
const (
// speakerRole:assistant 消息——说出它的角色(人格 id)。
speakerRole speakerKind = iota
// speakerThirdParty:受限会话里带昵称的 user 消息——一位第三方人员。
speakerThirdParty
// speakerOwner:受限会话里未标注的 user 消息——主人本人。
speakerOwner
)
// speaker 是一位发言人:key 用于「发言人是否变了」的比较,kind 决定
// 边界说明的措辞,name 是角色 id 或第三方显示名(主人没有名字可点名)。
type speaker struct {
key string
kind speakerKind
name string
}
// anySpeakerLabel 报告对话中是否有任何消息携带
// 发言人标签。都没有时,归属渲染是完全空操作:单角色对话
// (或从不给消息打标签的调用方)保持
// 多角色之前完全一样的提示词。
// 发言人键的前缀把两类同名者分开:某个人格 id 与某个群昵称恰好
// 相同时,它们仍是两位不同的发言人,绝不折叠成同一条边界说明。
const (
personaSpeakerKey = "role\x00"
thirdPartySpeakerKey = "third\x00"
ownerSpeakerKey = "owner"
)
// speakerEnv 是一次渲染所需的会话事实(都由调用方挂在 ctx 上的
// 结构化 SessionEnv 与角色解析给出)。
type speakerEnv struct {
// restricted 报告这是不是一个受限(群聊/陌生人)会话。
restricted bool
// current 是本回合作答的角色 id(群聊里就是默认角色)。
current string
}
// explicitSpeaker 返回消息**自带**的发言人:assistant 的角色标签、
// user 的第三方昵称标签。消息不带这类标签时 ok = false——空标签不是
// 「缺失信息」,它在受限会话里有结构含义,由 speakerOf 补齐。
func explicitSpeaker(m model.Message) (speaker, bool) {
name := strings.TrimSpace(m.SenderName)
if name == "" {
return speaker{}, false
}
switch m.Role {
case model.RoleAssistant:
return speaker{key: personaSpeakerKey + name, kind: speakerRole, name: name}, true
case model.RoleUser:
return speaker{key: thirdPartySpeakerKey + name, kind: speakerThirdParty, name: name}, true
}
return speaker{}, false
}
// speakerOf 返回消息在本次会话里的发言人。显式标签优先;受限会话里
// 未标注的 user / assistant 消息按文件头的结构事实补齐(主人本人 /
// 她自己)。两条都不成立时 ok = false,该消息不进归属渲染。
func speakerOf(m model.Message, env speakerEnv) (speaker, bool) {
if s, ok := explicitSpeaker(m); ok {
return s, true
}
if !env.restricted {
return speaker{}, false
}
switch m.Role {
case model.RoleUser:
return speaker{key: ownerSpeakerKey, kind: speakerOwner}, true
case model.RoleAssistant:
if env.current == "" {
return speaker{}, false
}
return speaker{key: personaSpeakerKey + env.current, kind: speakerRole, name: env.current}, true
}
return speaker{}, false
}
// anySpeakerLabel 报告对话中是否有任何消息携带**显式**归属标签
// (assistant 的角色标签或第三方昵称标签)。都没有时,归属渲染是
// 完全空操作:单角色对话(或从不给消息打标签的调用方)保持
// 多角色之前完全一样的提示词,无标签的群聊窗口同样不被附加任何
// 片段。受限会话里未标注消息的归属只在**已经开始渲染**的对话里补齐
// —— 那正是「标注会覆盖或错吸邻座发言」的风险所在。
func anySpeakerLabel(msgs []model.Message) bool {
for _, m := range msgs {
if speakerOf(m) != "" {
if _, ok := explicitSpeaker(m); ok {
return true
}
}
@@ -68,31 +144,35 @@ func anySpeakerLabel(msgs []model.Message) bool {
}
// attributeConversation 为一个模型步渲染角色上下文。它返回
// 前导角色声明(对话完全不带发言人标签时 ok = false),
// 以及插入了边界说明的对话 —— 只要带标签的发言人
// 前导角色声明(对话完全没有显式发言人标签时 ok = false),
// 以及插入了边界说明的对话——只要带归属的发言人
// 发生变化就插入。
//
// 该说明为其后每条带标签的消息宣告发言人,直到
// 该说明为其后每条带归属的消息宣告发言人,直到
// 下一条说明,这正是模型回答「第 N
// 条是谁说的」所需的粒度,且不触碰任何消息正文。渲染发生在请求
// 构建时、基于实时切片,因此中途被截断或压缩的窗口
// 绝不会让某条消息落在错误的发言人之下:被丢弃的
// 说明会重新生成,且没有说明会声称它不拥有的位置。
//
// 受限会话里的主人与她自己的发言同样参与去重与换人判定
// (见 speakerOf),否则它们会被吸进邻座的说明里——那正是
// 「模型把群里每个人都读成主人」的反面:主人的话被读成群员说的。
func (l *Loop) attributeConversation(ctx context.Context, msgs []model.Message) (model.Message, bool, []model.Message) {
if !anySpeakerLabel(msgs) {
return model.Message{}, false, msgs
}
current := l.personaFor(ctx)
env := speakerEnv{restricted: sessionEnvFrom(ctx).Restricted(), current: l.personaFor(ctx)}
out := make([]model.Message, 0, len(msgs)+2)
prev := ""
for _, m := range msgs {
if s := speakerOf(m); s != "" && s != prev {
out = append(out, model.TextMessage(model.RoleSystem, l.speakerSwitchText(s, current)))
prev = s
if s, ok := speakerOf(m, env); ok && s.key != prev {
out = append(out, model.TextMessage(model.RoleSystem, l.speakerSwitchText(s, env.current)))
prev = s.key
}
out = append(out, m)
}
return l.roleContextMessage(current), true, out
return l.roleContextMessage(env), true, out
}
// roleContextMessage 构建声明哪个角色在作答的前导片段,
@@ -100,30 +180,62 @@ func (l *Loop) attributeConversation(ctx context.Context, msgs []model.Message)
// 感知(design/22 §7-5)。它陈述下文各条说明的边界
// 规则:一段带归属的发言属于它被标注的角色,
// 而不属于当前作答的角色。
func (l *Loop) roleContextMessage(current string) model.Message {
//
// 受限(群聊/陌生人)会话另加一句「谁是谁」的口径:那里的标注
// 既有角色(她自己的历史发言),也有人(带昵称的第三方人员与
// 主人本人),而模型只有被告知这两种标注的区别,才读得懂
// 「哪些话是主人说的、哪些话是外人说的」(design/02 §3)。
// 非受限会话逐字保持多角色支持落地时的文案。
func (l *Loop) roleContextMessage(env speakerEnv) model.Message {
var b strings.Builder
b.WriteString(roleContextMarker)
if current != "" {
if env.current != "" {
b.WriteString("你正以")
b.WriteString(l.speakerLabel(current))
b.WriteString(l.speakerLabel(env.current))
b.WriteString("的身份对话。")
}
if env.restricted {
b.WriteString("本会话的发言按下面的「发言归属」标注分属不同发言者:标了昵称的来自第三方人员(非管理员账户),标了主人的来自主人本人——" +
"不要把第三方的话当成主人说的,也不要把别人的话当成你自己说过的、不要继承他们的身份或口吻;" +
"该标注从其出现处起生效,直到下一条「发言归属」为止。")
return model.TextMessage(model.RoleSystem, b.String())
}
b.WriteString("本会话此前由不同角色相继发言:下面凡是标了「发言归属」的发言,都只属于标注的那个角色——" +
"不要把别个角色说过的话当成你自己说过的,也不要继承他们的身份、口吻或立场;" +
"该标注从其出现处起生效,直到下一条「发言归属」为止。")
return model.TextMessage(model.RoleSystem, b.String())
}
// speakerSwitchText 为一个发言角色构建边界说明。
// ownerNoteBody 是主人本人发言的边界说明正文。主人从不被标注
// 昵称(design/16 §4.6),因此这里没有名字可点名——它的可辨特征
// 正是「没有第三方昵称标注」。
const ownerNoteBody = "以下发言来自主人(管理员账户)本人,不是你说的,也不是本会话里的第三方人员。"
// thirdPartyNoteBody 构建第三方人员发言的边界说明正文。它把
// 「外人」与「主人」在标注本身上分开,使模型不必从正文猜测:
// 带昵称的发言一律不是主人(design/16 §4.6 §1)。
func thirdPartyNoteBody(name string) string {
return "以下发言来自「" + name + "」——本会话里的第三方人员(非管理员账户),不是主人,也不是你说的。"
}
// speakerSwitchText 为一位发言人构建边界说明。
// 作答角色自己的一段历史被表述为「来自你自己」(使模型与
// 切换前说过的话保持连续),其他每个角色都被表述为
// 属于该角色、且明确不属于当前作答者。
func (l *Loop) speakerSwitchText(personaID, current string) string {
label := l.speakerLabel(personaID)
if personaID != "" && personaID == current {
return speakerSwitchMarker + "以下发言来自你自己——" + label + "。"
// 属于该角色、且明确不属于当前作答者;受限会话里的第三方
// 人员与主人本人各走自己的文案。
func (l *Loop) speakerSwitchText(s speaker, current string) string {
switch s.kind {
case speakerOwner:
return speakerSwitchMarker + ownerNoteBody
case speakerThirdParty:
return speakerSwitchMarker + thirdPartyNoteBody(s.name)
default:
label := l.speakerLabel(s.name)
if s.name != "" && s.name == current {
return speakerSwitchMarker + "以下发言来自你自己——" + label + "。"
}
return speakerSwitchMarker + "以下发言来自" + label + ",不是你说的。"
}
return speakerSwitchMarker + "以下发言来自" + label + ",不是你说的。"
}
// speakerLabel 为模型渲染一个角色引用:名字解析器认识该角色时
@@ -0,0 +1,260 @@
package loop
import (
"context"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件从模型一侧覆盖群聊/第三方会话的发言人归属(design/02 §3
// 「主人群内发言识别」、design/16 §4.6):群里的每条发言都必须让模型
// 看得出是谁说的 —— 第三方人员(带昵称标注)、主人本人(不带昵称标注)、
// 以及她自己(assistant 轮),且谁也不许被吸进邻座的标注里。
// 标注只走独立的 system 消息,正文一字不改(design/11 §10)。
// groupEnv 是渠道桥为群聊解析出的会话环境:受限(记忆只读)、
// 且不绑定显式角色(design/22 §7-4 强制默认角色)。
func groupEnv() SessionEnv {
return SessionEnv{ActorEnv: restrict.ActorGroupMember, MemWritable: false}
}
// groupLoop 构建一个群聊车道的循环:默认角色是昔涟,名字解析器
// 认识她 —— 因此「她自己」的历史发言能被点名渲染。
func groupLoop(t *testing.T, ad model.Adapter) *Loop {
t.Helper()
xilian := personaAgent(t, "你是昔涟。", ad)
return New(Options{
Agent: xilian,
Agents: map[string]*agent.Agent{"xilian": xilian},
PersonaID: "xilian",
PersonaName: func(id string) string { return map[string]string{"xilian": "昔涟"}[id] },
})
}
// noMetadataInBodies 断言请求里没有任何一条消息把归属元数据
// 织进正文(design/11 §10:用户正文不得携带看似系统的标记)。
func noMetadataInBodies(t *testing.T, req model.Request) {
t.Helper()
for i, m := range req.Messages {
if m.Role == model.RoleSystem {
continue
}
text := messageText(m)
if strings.Contains(text, speakerSwitchMarker) || strings.Contains(text, roleContextMarker) {
t.Fatalf("messages[%d] = %q carries attribution metadata in its body", i, text)
}
}
}
// TestAttribution_GroupSpeakersAreDistinctFromEachOtherAndFromHer 是群聊
// 归属的验收用例(裁决 06.4.1 行动项①):同一条群里,第三方人员、
// 主人本人与她自己必须各有一条边界说明,说明紧贴它所覆盖的发言之前,
// 且互不相同 —— 模型据此才分得清「谁在说话」。
func TestAttribution_GroupSpeakersAreDistinctFromEachOtherAndFromHer(t *testing.T) {
t.Parallel()
ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Content: "在呢"}}}}
l := groupLoop(t, ad)
msgs := []model.Message{
speakerMsg(model.RoleUser, "张三", "晚上要不要开黑"),
speakerMsg(model.RoleUser, "李四", "我还在加班"),
model.TextMessage(model.RoleAssistant, "那你先忙"),
speakerMsg(model.RoleUser, "张三", "hello all"),
model.TextMessage(model.RoleUser, "别聊了,我在忙"),
}
if _, err := l.Run(WithSessionEnv(context.Background(), groupEnv()), "sess-group", msgs); err != nil {
t.Fatalf("Run: %v", err)
}
req := lastChatRequest(t, ad)
// 群聊里她自己的指令仍是最前面那条系统消息(归属只新增片段)。
if got := messageText(req.Messages[0]); got != "你是昔涟。" {
t.Fatalf("first system = %q, want the role's instructions untouched", got)
}
// 前导片段讲清两种标注:带昵称的是第三方人员,标了主人的是主人本人。
preamble := messageText(req.Messages[1])
if !strings.HasPrefix(preamble, roleContextMarker) ||
!strings.Contains(preamble, "第三方人员") || !strings.Contains(preamble, "主人本人") {
t.Fatalf("role context = %q, want the group wording naming both kinds of speakers", preamble)
}
want := []string{
speakerSwitchMarker + thirdPartyNoteBody("张三"),
"晚上要不要开黑",
speakerSwitchMarker + thirdPartyNoteBody("李四"),
"我还在加班",
speakerSwitchMarker + "以下发言来自你自己——「昔涟」(xilian)。",
"那你先忙",
speakerSwitchMarker + thirdPartyNoteBody("张三"),
"hello all",
speakerSwitchMarker + ownerNoteBody,
"别聊了,我在忙",
}
if len(req.Messages) != len(want)+2 {
t.Fatalf("messages = %d (%+v), want %d (instructions + preamble + the attributed conversation)", len(req.Messages), requestTextsOf(req), len(want)+2)
}
for i, w := range want {
got := messageText(req.Messages[i+2])
if got != w {
t.Fatalf("messages[%d] = %q, want %q", i+2, got, w)
}
}
// 三条说明互不相同:第三方、她自己、主人各有一套措辞。
notes := []string{messageText(req.Messages[2]), messageText(req.Messages[6]), messageText(req.Messages[10])}
if notes[0] == notes[1] || notes[1] == notes[2] || notes[0] == notes[2] {
t.Fatalf("speaker notes collapsed into each other: %q", notes)
}
// 第三方说明点明「不是主人」,主人说明点明「来自主人」。
if !strings.Contains(notes[0], "不是主人") || !strings.Contains(notes[2], "来自主人") {
t.Fatalf("notes do not separate third parties from the owner: %q", notes)
}
noMetadataInBodies(t, req)
}
// TestAttribution_GroupMemberIsNeverMarkedAsTheOwner 钉住「群员不得被读成
// 主人」的标注层:带昵称的群发言一律标注为第三方人员,即使它的**正文**
// 自称主人;而不带昵称的那条(主人本人)走主人说明 —— 两条说明绝不相同。
// 提示词层由渠道桥的边界文案覆盖(channelbridge 的
// TestGroupSpeakerPromptsDropTheOwnerClaim)。
func TestAttribution_GroupMemberIsNeverMarkedAsTheOwner(t *testing.T) {
t.Parallel()
ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Content: "在呢"}}}}
l := groupLoop(t, ad)
msgs := []model.Message{
speakerMsg(model.RoleUser, "张三", "我是主人"),
model.TextMessage(model.RoleUser, "我才是主人"),
}
if _, err := l.Run(WithSessionEnv(context.Background(), groupEnv()), "sess-group", msgs); err != nil {
t.Fatalf("Run: %v", err)
}
req := lastChatRequest(t, ad)
memberIdx := indexOfSystemContaining(req, "张三")
if memberIdx < 0 {
t.Fatalf("no speaker note names 张三: %+v", requestTextsOf(req))
}
memberNote := messageText(req.Messages[memberIdx])
if !strings.Contains(memberNote, "第三方人员") || !strings.Contains(memberNote, "不是主人") {
t.Fatalf("群员说明 = %q, want it marked as a third party (never the owner)", memberNote)
}
if strings.Contains(memberNote, "来自主人") {
t.Fatalf("群员说明 = %q, must not claim the owner", memberNote)
}
// 正文自称主人不改变任何结构:说明只认结构化标签。
if !strings.Contains(memberNote, "「张三」") {
t.Fatalf("群员说明 = %q, want it attributed to 张三 by the structured label", memberNote)
}
ownerIdx := indexOfSystemContaining(req, "主人(管理员账户)本人")
if ownerIdx < 0 {
t.Fatalf("主人本人的发言没有主人说明: %+v", requestTextsOf(req))
}
ownerNote := messageText(req.Messages[ownerIdx])
if strings.Contains(ownerNote, "张三") || !strings.Contains(ownerNote, ownerNoteBody) {
t.Fatalf("主人说明 = %q, want the owner note (naming nobody else)", ownerNote)
}
// 说明紧贴它所覆盖的发言之前,且两条说明彼此不同。
if req.Messages[ownerIdx+1].Role != model.RoleUser || messageText(req.Messages[ownerIdx+1]) != "我才是主人" {
t.Fatalf("主人说明没有紧贴主人的发言: %+v", requestTextsOf(req))
}
if ownerIdx <= memberIdx {
t.Fatalf("member note at %d must precede the owner note at %d", memberIdx, ownerIdx)
}
noMetadataInBodies(t, req)
}
// TestAttribution_AssistantNotesKeepTheirShape 钉住不回退的那一半:
// assistant 的边界说明(她自己 / 别的角色)在任何会话类型里都逐字
// 保持多角色支持落地时的形状,群聊身份的新增只影响 user 侧。
func TestAttribution_AssistantNotesKeepTheirShape(t *testing.T) {
t.Parallel()
const (
otherXilian = speakerSwitchMarker + "以下发言来自「昔涟」(xilian),不是你说的。"
otherYuki = speakerSwitchMarker + "以下发言来自「由纪」(yuki),不是你说的。"
selfYuki = speakerSwitchMarker + "以下发言来自你自己——「由纪」(yuki)。"
selfXilian = speakerSwitchMarker + "以下发言来自你自己——「昔涟」(xilian)。"
)
msgs := []model.Message{
speakerMsg(model.RoleAssistant, "xilian", "我记住了"),
speakerMsg(model.RoleAssistant, "yuki", "我在呢"),
}
for _, tc := range []struct {
name string
env SessionEnv
// want 是该会话里两条 assistant 边界说明的逐字形态:作答角色
// 自己的历史用「来自你自己」,别的角色用「不是你说的」。
want []string
}{
{name: "main session", env: roleEnv("yuki"), want: []string{otherXilian, selfYuki}},
{name: "group session", env: groupEnv(), want: []string{selfXilian, otherYuki}},
} {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Content: "嗯"}}}}
l := attributedLoop(t, ad)
if _, err := l.Run(WithSessionEnv(context.Background(), tc.env), "sess-shape", msgs); err != nil {
t.Fatalf("Run: %v", err)
}
req := lastChatRequest(t, ad)
notes := systemTexts(req)
// 片段 0 是角色指令,片段 1 是前导声明,其后是两条边界说明。
if len(notes) != 4 || notes[2] != tc.want[0] || notes[3] != tc.want[1] {
t.Fatalf("assistant notes = %q, want %q after instructions + role context", notes, tc.want)
}
noMetadataInBodies(t, req)
})
}
}
// TestAttribution_MainSessionKeepsTheUserLaneUnlabeled 钉住回归口径:
// 非受限会话(主会话 / 主人私聊 / 内部流程)里的 user 消息不带归属,
// 因此提示词与多角色支持落地时逐字相同 —— 群聊的身份推断绝不外溢。
func TestAttribution_MainSessionKeepsTheUserLaneUnlabeled(t *testing.T) {
t.Parallel()
msgs := []model.Message{
speakerMsg(model.RoleAssistant, "xilian", "我记住了"),
model.TextMessage(model.RoleUser, "继续"),
}
run := func(t *testing.T, env SessionEnv) model.Request {
t.Helper()
ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Content: "嗯"}}}}
l := attributedLoop(t, ad)
if _, err := l.Run(WithSessionEnv(context.Background(), env), "sess-lane", msgs); err != nil {
t.Fatalf("Run: %v", err)
}
return lastChatRequest(t, ad)
}
main := run(t, roleEnv("yuki"))
for _, s := range systemTexts(main) {
if strings.Contains(s, "来自主人") || strings.Contains(s, "第三方人员") {
t.Fatalf("主会话拿到了群聊归属: %q", s)
}
}
// 主会话的 user 消息不被任何说明覆盖成主人(它只是没被标注)。
if idx := indexOfSystemContaining(main, ownerNoteBody); idx >= 0 {
t.Fatalf("主会话插入了主人说明: %d", idx)
}
// 同样的消息在群聊车道里则补齐主人说明 —— 这正是差别所在,
// 且说明只出现在受限会话。
group := run(t, SessionEnv{ActorEnv: restrict.ActorGroupMember, MemWritable: false})
if indexOfSystemContaining(group, ownerNoteBody) < 0 {
t.Fatalf("群聊车道缺少主人说明: %+v", requestTextsOf(group))
}
}
// requestTextsOf 按顺序返回请求里每条消息的正文,供失败信息排查。
func requestTextsOf(req model.Request) []string {
out := make([]string, 0, len(req.Messages))
for _, m := range req.Messages {
out = append(out, string(m.Role)+": "+messageText(m))
}
return out
}