裁决 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 条渠道端到端用例变红。
300 lines
12 KiB
Go
300 lines
12 KiB
Go
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
|
||
}
|