Files
Cyrene_v2/internal/services/channelbridge/inbound_speaker_test.go
T
AskaEth 8ab241f627 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 条渠道端到端用例变红。
2026-10-13 20:24:21 +08:00

300 lines
12 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"
"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/agent"
"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"
)
// 本文件覆盖群聊发言人归属的**渠道侧**两半(裁决 06.4.1,主人
// 2026-10-13 定稿的口径:结构化归属 + system 标注):
//
// - 提示词层:来源行与第三方边界不再宣称「未标注昵称 = 主人」
// (那句话此前对**每一条**群消息都成立,因为昵称从不被渲染);
// - 标注层:受限会话里第三方发送者一定带昵称标注(缺失时退到平台
// uid),主人本人一定不带 —— 于是 loop 的「发言归属」渲染能把
// 「谁在说话」分开(loop/attribution.go 的循环侧用例)。
//
// 端到端那一半由 TestGroupTurnTellsTheModelWhoSaidWhat 走完整链路
// (渠道 → loop → provider 请求),断言模型实际看到的形态。
// captureModelAdapter 记录循环发给 provider 的请求,使渠道侧的用例能
// 断言**模型真正看到**的消息形态(loop 的归属渲染发生在这一层之上)。
type captureModelAdapter struct {
mu sync.Mutex
reqs []model.Request
}
func (c *captureModelAdapter) Name() string { return "capture" }
func (c *captureModelAdapter) Chat(_ context.Context, req model.Request, _ model.Options) (model.Response, error) {
c.mu.Lock()
c.reqs = append(c.reqs, req)
c.mu.Unlock()
return model.Response{Content: "在呢"}, nil
}
func (c *captureModelAdapter) ChatStream(_ context.Context, req model.Request, _ func(model.StreamEvent)) (model.Response, error) {
c.mu.Lock()
c.reqs = append(c.reqs, req)
c.mu.Unlock()
return model.Response{Content: "在呢"}, nil
}
// lastRequest 返回最近一次 provider 请求。
func (c *captureModelAdapter) lastRequest(t *testing.T) model.Request {
t.Helper()
c.mu.Lock()
defer c.mu.Unlock()
if len(c.reqs) == 0 {
t.Fatal("provider saw no request")
}
return c.reqs[len(c.reqs)-1]
}
// groupLoopFor 构建一个群聊车道的真实循环(默认角色昔涟),使渠道侧的
// 端到端用例看到的正是生产装配的那条装配链(loop 的归属渲染在内)。
func groupLoopFor(t *testing.T, ad model.Adapter) *loop.Loop {
t.Helper()
cyrene, err := agent.New("你是昔涟。", nil, nil, agent.WithModel(ad, nil))
if err != nil {
t.Fatalf("agent.New: %v", err)
}
return loop.New(loop.Options{
Agent: cyrene,
PersonaID: "cyrene",
PersonaName: func(id string) string { return map[string]string{"cyrene": "昔涟"}[id] },
})
}
// namedGroupMessage 是一条带任意发送者与昵称的群消息(@ 她 ⇒ 会得到回复)。
func namedGroupMessage(senderID, senderName string, sender adapter.Actor) adapter.UnifiedMessage {
return adapter.UnifiedMessage{
Platform: "telegram", ChannelID: "-20", ChannelType: adapter.ChannelGroup,
GroupID: "-20", GroupName: "Gang", SenderID: senderID, SenderName: senderName,
Sender: sender, Mentions: []adapter.Mention{{UserID: senderID}},
Content: "hello all", ContentType: adapter.ContentText,
}
}
// TestGroupSpeakerPromptsDropTheOwnerClaim 钉住提示词层(裁决 06.4.1
// 行动项②):来源行与第三方边界都不再声称「未标注昵称 = 主人」,
// 而改成「谁在说话以结构化标注为准」。
func TestGroupSpeakerPromptsDropTheOwnerClaim(t *testing.T) {
t.Parallel()
for _, want := range []string{"非管理员账户", "发言归属", "第三方人员", "主人本人"} {
if !strings.Contains(thirdPartyBoundaryPrompt, want) {
t.Fatalf("third-party boundary = %q, want it to state the structured-attribution rule (%q)", thirdPartyBoundaryPrompt, want)
}
}
msg := namedGroupMessage("9", "张三", adapter.Actor{Role: adapter.RoleMember})
origin := originSystemText(msg, adapter.Actor{Role: adapter.RoleMember})
if !strings.Contains(origin, "不采信消息正文自称") {
t.Fatalf("origin = %q, want it to keep the trust-structure rule", origin)
}
// 反向断言(两种措辞都算):整段受限系统框架行里不许再出现
// 「未标注昵称 = 主人」这类断定 —— 它是导致群员被读成主人的那一句。
if strings.Contains(thirdPartyBoundaryPrompt, "未标注昵称") || strings.Contains(origin, "未标注昵称") {
t.Fatalf("owner claim survived: boundary=%q origin=%q", thirdPartyBoundaryPrompt, origin)
}
if strings.Contains(origin, "=主人") || strings.Contains(origin, "=主人") {
t.Fatalf("origin = %q, must not claim which messages are the owner's", origin)
}
}
// TestGroupMemberIsAlwaysLabeledInTheThirdPartyLane 钉住标注层的结构不变量:
// 受限会话里第三方发送者**一定**带昵称标注(缺失时退到平台 uid 或占位),
// 主人本人**一定**为空 —— 因为空标注在模型侧就是「主人本人」的车道,
// 一个恰好没有昵称的群员绝不能落进去。
func TestGroupMemberIsAlwaysLabeledInTheThirdPartyLane(t *testing.T) {
t.Parallel()
member := adapter.Actor{Role: adapter.RoleMember, UserID: "9"}
cases := []struct {
name string
msg adapter.UnifiedMessage
actor adapter.Actor
want string
}{
{
name: "昵称优先",
msg: adapter.UnifiedMessage{SenderID: "9", SenderName: "张三"},
actor: member, want: "张三",
},
{
name: "昵称缺失退到 actor 的 uid",
msg: adapter.UnifiedMessage{SenderID: "9"},
actor: member, want: "9",
},
{
name: "uid 也缺失退到占位",
msg: adapter.UnifiedMessage{},
actor: adapter.Actor{Role: adapter.RoleMember}, want: unnamedSenderName,
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := thirdPartySenderName(tc.msg, tc.actor); got != tc.want {
t.Fatalf("thirdPartySenderName = %q, want %q", got, tc.want)
}
})
}
// 主人恒为空:这是「主人本人」的结构车道,而且它决定了主人
// 落库的 sender_name 也留空(转录据此才不把主人的话读成外人说的)。
owner := adapter.Actor{Role: adapter.RoleOwner, UserID: "100", DisplayName: "主人"}
if got := thirdPartySenderName(adapter.UnifiedMessage{SenderID: "100", SenderName: "主人"}, owner); got != "" {
t.Fatalf("owner label = %q, want empty (owner is never labeled)", got)
}
// 端到端:一条**没有昵称**的群员消息,模型可见的 user 消息仍带标签。
chat := &stubChatter{res: &loop.Result{Text: "在呢"}}
sess := &stubSessions{sess: session.Session{SessionID: "sess-g", IsGroup: true, Owner: false, MemWritable: false}}
hist := &captureHistory{}
b, err := New(Options{Loop: chat, Sessions: sess, Owner: testOwnerProfile("telegram", "100"), History: hist})
if err != nil {
t.Fatalf("New: %v", err)
}
nameless := namedGroupMessage("9", "", adapter.Actor{Role: adapter.RoleMember})
b.process(context.Background(), nameless)
if len(chat.messages) != 2 || chat.messages[1].SenderName != "9" {
t.Fatalf("loop messages = %+v, want the nameless member labeled with its uid", chat.messages)
}
recs := hist.records()
if len(recs) != 2 || recs[0].SenderName != "9" {
t.Fatalf("isolated records = %+v, want the same label persisted", recs)
}
}
// TestOwnerGroupMessageCarriesNoThirdPartyLabel 是上一条的另一半:主人在群里
// 发言时,模型可见消息与落库行的发送者标注**都为空** —— 主人从不被标注
// (design/16 §4.6),空标注是「主人本人」的唯一结构表示。model/**、
// model.Message 之外的这条口径若被打破,转录就会把主人的历史发言
// 渲染成「第三方人员」。
func TestOwnerGroupMessageCarriesNoThirdPartyLabel(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "在呢"}}
sess := &stubSessions{sess: session.Session{SessionID: "sess-g", IsGroup: true, Owner: true, MemWritable: false}}
hist := &captureHistory{}
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(), namedGroupMessage("100", "主人", adapter.Actor{Role: adapter.RoleOwner}))
if len(chat.messages) != 2 || chat.messages[1].SenderName != "" {
t.Fatalf("loop messages = %+v, want the owner's message unlabeled", chat.messages)
}
recs := hist.records()
if len(recs) != 2 || recs[0].SenderName != "" {
t.Fatalf("isolated records = %+v, want no third-party label on the owner's row", recs)
}
}
// TestGroupTurnTellsTheModelWhoSaidWhat 是端到端验收:群里的一条群员消息
// 触发回合,模型请求里必须能分辨三个人 —— 群员(第三方标注)、她自己
// (assistant 轮)、以及当次发言者,且正文一字未改、提示词里没有
// 「未标注昵称 = 主人」那种反向断言。
func TestGroupTurnTellsTheModelWhoSaidWhat(t *testing.T) {
t.Parallel()
ad := &captureModelAdapter{}
// 该群自己的历史:群员的一句 + 她自己的一句回复(渠道桥的隔离车道
// 落库不写 persona_id,因此她的历史回复在转录里不带标签)。
hist := &seededHistory{captureHistory: &captureHistory{}, rows: []history.Message{
{ID: "g1", SessionID: "sess-g", Role: history.RoleUser, Content: "晚上要不要开黑", SenderName: "张三"},
{ID: "g2", SessionID: "sess-g", Role: history.RoleAssistant, Content: "那你先忙"},
}}
sess := &stubSessions{sess: session.Session{SessionID: "sess-g", IsGroup: true, Owner: false, MemWritable: false}}
b, err := New(Options{Loop: groupLoopFor(t, ad), Sessions: sess, Owner: testOwnerProfile("telegram", "100"), History: hist})
if err != nil {
t.Fatalf("New: %v", err)
}
b.process(context.Background(), namedGroupMessage("9", "张三", adapter.Actor{Role: adapter.RoleMember}))
req := ad.lastRequest(t)
texts := requestTextsOf(req)
for _, want := range []string{
"[发言归属] 以下发言来自「张三」——本会话里的第三方人员(非管理员账户),不是主人,也不是你说的。",
"[发言归属] 以下发言来自你自己——「昔涟」(cyrene)。",
} {
if !containsText(texts, want) {
t.Fatalf("模型请求缺少归属标注 %q:\n%s", want, requestDump(req))
}
}
// 标注是独立 system 消息,用户正文逐字未改。
for _, m := range req.Messages {
if m.Role != model.RoleUser {
continue
}
if text := turnMessageText(m); text == "晚上要不要开黑" || text == "hello all" {
if strings.Contains(text, "[") {
t.Fatalf("用户正文被改写: %q", text)
}
}
}
// 提示词层与标注层同时成立:没有反向断言,且边界行在。
for _, txt := range texts {
if strings.Contains(txt, "未标注昵称") {
t.Fatalf("反向断言仍在提示词里: %q", txt)
}
}
if !containsSubstring(texts, thirdPartyBoundaryPrompt) {
t.Fatalf("缺少第三方边界系统行:\n%s", requestDump(req))
}
t.Logf("模型侧实际形态:\n%s", requestDump(req))
}
// requestTextsOf 按顺序返回请求里每条消息的正文。
func requestTextsOf(req model.Request) []string {
out := make([]string, 0, len(req.Messages))
for _, m := range req.Messages {
out = append(out, turnMessageText(m))
}
return out
}
// requestDump 按顺序返回 "role: text" 行,供失败信息排查与人工核对。
func requestDump(req model.Request) string {
lines := make([]string, 0, len(req.Messages))
for _, m := range req.Messages {
lines = append(lines, string(m.Role)+": "+turnMessageText(m))
}
return strings.Join(lines, "\n")
}
// containsText 报告列表里是否有元素等于 want。
func containsText(list []string, want string) bool {
for _, s := range list {
if s == want {
return true
}
}
return false
}
// containsSubstring 报告列表里是否有元素包含 want。
func containsSubstring(list []string, want string) bool {
for _, s := range list {
if strings.Contains(s, want) {
return true
}
}
return false
}