review p1:回帖路径已改用 thirdPartySenderName(非主人恒有标注:昵称 → actor uid → 「未署名发送者」),但 registerSilenced 仍用 senderDisplayName,非主人 无昵称时写出空串;静默行会被 transcript 读回她的上下文,而受限会话里空标注就是 「主人本人」⇒ 在 senderName 可选且无兜底的渠道(webhook)上,一条没昵称的陌生人 消息被渲染成「[发言归属] 以下发言来自主人(管理员账户)本人」。 - light.go:270:senderDisplayName → thirdPartySenderName,与回帖路径同源。 senderDisplayName 未成死代码:仍被 light.go:223(LightJudge 输入)、 engage.go:284、flowgate.go:183 使用。 - 新增 TestSilencedNamelessMemberIsNotReadAsTheOwner:走真链路(静默登记 → 落库 → 下一回合播种转录 → loop 归属渲染 → provider 请求),钉住「无昵称的非主人经 静默登记后,读回上下文时不得出现主人标注」。修前必红(写入口径与模型侧两条 断言同时报,含被读成主人的原样请求),修后绿。 验证:go build ./... / go vet ./... / go test -count=1 ./...(全树)全绿。
368 lines
16 KiB
Go
368 lines
16 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]
|
||
}
|
||
|
||
// count 返回已记录的 provider 请求数(0 表示循环没被驱动)。
|
||
func (c *captureModelAdapter) count() int {
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
return len(c.reqs)
|
||
}
|
||
|
||
// 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))
|
||
}
|
||
|
||
// ownerNotePrefix 是主人本人边界说明的开头。断言只能认这个**带标记的
|
||
// 完整前缀**:受限会话的角色声明里也含「来自主人本人」这类字面,
|
||
// 用「来自主人」做子串匹配会把它误当成主人说明。
|
||
const ownerNotePrefix = "[发言归属] 以下发言来自主人(管理员账户)本人"
|
||
|
||
// TestSilencedNamelessMemberIsNotReadAsTheOwner 覆盖 review p1 暴露的路径盲区:
|
||
// **静默登记**(registerSilenced:没 @ 她、也没有规则信号的群消息只登记不回)
|
||
// 落库的发送者标注,会被之后的回合经 transcript 读回她的上下文。受限会话里
|
||
// 空标注就是「主人本人」,因此这条路径上的无昵称非主人若写成空串,就会被渲染成
|
||
// 「来自主人」——正是本切片修掉的那个误认的镜像(webhook 适配器的 senderName
|
||
// 是可选参数且无兜底,可达)。
|
||
//
|
||
// 用例走真链路:静默登记 → 落库 → 下一回合播种转录 → loop 归属渲染 → provider 请求。
|
||
func TestSilencedNamelessMemberIsNotReadAsTheOwner(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
ad := &captureModelAdapter{}
|
||
// live 夹具让 Recent 连本次运行已落库的行一起返回 —— 真库就是这样。
|
||
hist := &seededHistory{captureHistory: &captureHistory{}, live: true}
|
||
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(), noMentionGroupMessage("8", "", "我是群里一个没昵称的人"))
|
||
if ad.count() != 0 {
|
||
t.Fatalf("静默消息驱动了循环(应当只登记)")
|
||
}
|
||
recs := hist.records()
|
||
if len(recs) != 1 {
|
||
t.Fatalf("静默登记行数 = %d, want 1", len(recs))
|
||
}
|
||
// 写入口径:非主人恒有标注 —— 空 sender_name 在模型侧就是「主人本人」。
|
||
// 这里用 Errorf(不中断):空串既会让上面这条红,也会让下面的模型侧
|
||
// 断言红,两条一起报出来才看得见「空串 → 被读成主人」的完整因果。
|
||
// 这条断言在修之前会红(senderDisplayName 无昵称时返回空串)。
|
||
if recs[0].SenderName != "8" {
|
||
t.Errorf("静默登记的 sender_name = %q, want 8(空串会被读成主人)", recs[0].SenderName)
|
||
}
|
||
|
||
// ② 之后一条 @ 她的群消息驱动回合,静默那条随转录读回上下文。
|
||
b.process(context.Background(), namedGroupMessage("9", "张三", adapter.Actor{Role: adapter.RoleMember}))
|
||
req := ad.lastRequest(t)
|
||
texts := requestTextsOf(req)
|
||
// 主断言(本用例守的就是它):静默行绝不能被渲染成主人本人的发言。
|
||
for _, txt := range texts {
|
||
if strings.Contains(txt, ownerNotePrefix) {
|
||
t.Fatalf("无昵称的非主人被读成了主人:\n%s", requestDump(req))
|
||
}
|
||
}
|
||
if !containsText(texts, "[发言归属] 以下发言来自「8」——本会话里的第三方人员(非管理员账户),不是主人,也不是你说的。") {
|
||
t.Fatalf("静默的无昵称群员没有第三方标注:\n%s", requestDump(req))
|
||
}
|
||
if !containsText(texts, "我是群里一个没昵称的人") {
|
||
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
|
||
}
|