Files
Cyrene_v2/internal/services/channelbridge/public_presence_test.go
T
AskaEth c34b9d195d feat(channelbridge): 群聊公域呈现块与判定器去人格(M62)
P0-3 受限车道(群聊/陌生人)的系统框架行并列注入【公域呈现】
(publicPresencePrompt):它是呈现层投影(AGENTS §9 行为模式=呈现),
不是人格切换也不是权限门——不称第三方「亲爱的」等主人专属称呼、
不做只对主人的亲近动作、主人的住址/姓名/作息/行程/健康/家人/关系
即使记忆里读到也不外说;主人私聊与 Web 主会话不注入。

P0-4 纵深防御:第三方边界提示词与 loop 受限前导片段各补两条硬规则
(不用主人专属亲昵称呼指代第三方 + 不外说主人私事)。

P1-2 判定器去人格:模型输入只吃「角色名 + 偏好摘要」,整份人格指令
不再进模型(审计 2026-10-30 §2.4 的 reason 亲昵口吻即由此而来)。

P1-3 测试补真:受限期呈现块存在性 + 主人车道不注入 + 判定器输入/
模型请求均不含人格正文;并纠正 memory_filter 的错误注释
(作用域 ≠ 敏感度,审计 §6 R1)。
2026-10-30 08:37:40 +08:00

154 lines
6.1 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"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件覆盖公域呈现块(审计 2026-10-30 §5.3 / P0-3)与第三方边界的
// 两条硬规则(P0-4)的**存在性与注入边界**:
//
// - 受限车道(群聊 / 陌生人私聊)的系统框架行必须带公域呈现块;
// - 主人私聊与 Web 主会话**不得**带(那里没有第三方,呈现档位就是原档);
// - 呈现块自己必须说清它是「呈现层投影」而不是人格切换(AGENTS §9)。
//
// 这些用例在删掉注入点时必红(删实现打红实测见提交说明)。
// restrictedGroupEnv / restrictedStrangerEnv 与渠道桥为受限会话解析出的
// 环境同形:受限、记忆只读、不绑定显式角色(design/22 §7-4)。
func restrictedGroupEnv() loop.SessionEnv {
return loop.SessionEnv{ActorEnv: restrict.ActorGroupMember, MemWritable: false}
}
func restrictedStrangerEnv() loop.SessionEnv {
return loop.SessionEnv{ActorEnv: restrict.ActorStranger, MemWritable: false}
}
// ownerPrivateEnv 是主人私聊 / Web 主会话的环境(非受限)。
func ownerPrivateEnv() loop.SessionEnv {
return loop.SessionEnv{ActorEnv: restrict.ActorOwner, MemWritable: true}
}
// systemFrameOf 返回消息列表里第一条系统行的正文;没有系统行时返回 ""。
func systemFrameOf(msgs []model.Message) string {
for _, m := range msgs {
if m.Role == model.RoleSystem {
return turnMessageText(m)
}
}
return ""
}
// frameMessageOf 返回消息列表里第一条系统消息(用于断言它确实是一条)。
func frameMessageOf(msgs []model.Message) (model.Message, bool) {
for _, m := range msgs {
if m.Role == model.RoleSystem {
return m, true
}
}
return model.Message{}, false
}
func TestRestrictedLanesCarryPublicPresenceBlock(t *testing.T) {
t.Parallel()
b := &Bridge{}
ctx := context.Background()
member := adapter.Actor{Role: adapter.RoleMember}
owner := adapter.Actor{Role: adapter.RoleOwner}
groupMsg := adapter.UnifiedMessage{
Platform: "kook", ChannelType: adapter.ChannelGroup, GroupID: "g1", GroupName: "翁法罗斯",
}
directMsg := adapter.UnifiedMessage{
Platform: "telegram", ChannelType: adapter.ChannelDirect, SenderID: "999",
}
webMsg := adapter.UnifiedMessage{Platform: "web", ChannelType: adapter.ChannelDirect}
cases := []struct {
name string
env loop.SessionEnv
actor adapter.Actor
msg adapter.UnifiedMessage
want bool
}{
{"群聊里的第三方", restrictedGroupEnv(), member, groupMsg, true},
{"陌生人私聊", restrictedStrangerEnv(), member, directMsg, true},
// 主人在群里同样跑受限车道:公域里没有「只对主人的那份亲近」,
// 呈现档位按会话而不是按对方的身份决定(呈现层,不动权限)。
{"主人在群里", restrictedGroupEnv(), owner, groupMsg, true},
{"主人私聊", ownerPrivateEnv(), owner, directMsg, false},
{"Web 主会话", ownerPrivateEnv(), owner, webMsg, false},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
msgs := b.buildLoopMessages(ctx, "hello", tc.env, tc.actor, "", tc.msg)
sys := systemFrameOf(msgs)
if got := strings.Contains(sys, publicPresencePrompt); got != tc.want {
t.Fatalf("公域呈现块存在 = %v, want %v;系统框架行 = %q", got, tc.want, sys)
}
// 呈现块与身份边界**并列**:受限车道缺任何一半都不算完整
// (边界管「谁是谁」,呈现块管「怎么称呼、能说什么」)。
if tc.want {
if !strings.Contains(sys, thirdPartyBoundaryPrompt) {
t.Fatalf("受限框架行缺第三方身份边界:%q", sys)
}
if got, ok := frameMessageOf(msgs); !ok || got.Role != model.RoleSystem {
t.Fatalf("受限框架行不是一条系统消息:%+v", got)
}
}
})
}
}
// TestPublicPresenceBlockIsPresentationalProjection 钉住呈现块的**定位**
// (AGENTS §9:人格 = 身份不限制能力;行为模式 = 呈现可切档):
// 它必须明说「身份与能力照旧」,并且档位取「保留语气风格、不指向第三方、
// 不外说私事」——不是换人格,也不是中性机械口吻。
func TestPublicPresenceBlockIsPresentationalProjection(t *testing.T) {
t.Parallel()
// 定位与档位:身份/能力不变、只调呈现、语气风格保留。
for _, want := range []string{"身份", "能力", "呈现", "语气"} {
if !strings.Contains(publicPresencePrompt, want) {
t.Fatalf("公域呈现块 = %q, want it to state the projection framing (%q)", publicPresencePrompt, want)
}
}
// 三条约束:称呼、亲近动作、私事(住址/姓名/作息/行程/健康/家人/关系)。
for _, want := range []string{
"亲爱的", "昵称",
"凑近", "靠过来",
"住址", "姓名", "作息", "行程", "健康", "家人", "关系",
"记忆",
} {
if !strings.Contains(publicPresencePrompt, want) {
t.Fatalf("公域呈现块 = %q, want it to cover %q", publicPresencePrompt, want)
}
}
}
// TestThirdPartyBoundaryCarriesAddressingAndPrivacyRules 钉住 P0-4:
// 第三方边界提示词除了「谁是谁」,还必须带两条硬规则 —— 不用主人专属的
// 亲昵称呼称呼第三方、不向第三方外说主人的私事(即使记忆里读到)。
func TestThirdPartyBoundaryCarriesAddressingAndPrivacyRules(t *testing.T) {
t.Parallel()
for _, want := range []string{"亲昵称呼", "亲爱的", "住址", "作息", "行程", "健康", "家人", "关系", "记忆"} {
if !strings.Contains(thirdPartyBoundaryPrompt, want) {
t.Fatalf("第三方边界 = %q, want the P0-4 hard rule keyword %q", thirdPartyBoundaryPrompt, want)
}
}
// 边界仍然先讲归属(裁决 06.4.1),P0-4 只追加、不替换。
if !strings.Contains(thirdPartyBoundaryPrompt, "发言归属") {
t.Fatalf("第三方边界 = %q, want the structured-attribution rule to survive", thirdPartyBoundaryPrompt)
}
}