Files
Cyrene_v2/cmd/cyrene/persona_identity_test.go
T
AskaEth da3a386daf
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(cyrene,config,loop): 护栏/压缩/身份知识三条落地(P0-5/P0-10/P0-11)+ 身份注入收窄到主人会话
- P0-5 护栏不再是空壳:挂 WithSensitiveInfo(阻断) + 长度上限(输入 262144 / 输出 65536 字符,依据=
  组装完成的请求体量级与「不误伤正常对话」) + 关键词**仅在配置时**挂;正常对话有用例证明放行
- P0-11 上下文压缩默认启用:CONTEXT_MAX_TOKENS 默认 32768(=loop.DefaultMaxContextTokens,两常量由测试锁死),
  0/负 = 关闭(行为退回接线前);长对话真压缩且摘要消息进请求首位、最近一轮保留
- P0-10 身份说明书(owner.yaml identityFile)注入人格上下文,并且**收窄为仅主人会话**:
  · 新增最小钩子 loop.Options.PersonaKnowledgeFor(env):**按该轮会话环境**解析(不看提示词、不看会话 id),
    块只进每轮视图、**不进 messages**(不进 checkpoint、不进记忆提取面)
  · 开关 OWNER_IDENTITY_INJECTION:owner_only(默认) / always(仅自测) / off;非法值与读不动 → WARN + 回退默认(不静默)
  · **顺手关掉第二条同型泄漏**:channelbridge 群聊参与度判定器的 PersonaPrompt 此前带身份正文 ⇒ 现改为纯人格指令
  · 回归:缺文件/目录/未配置逐字节等于旧输出;静态指令与角色池 Instructions() 全不含正文;
    文件里点名的陌生账号仍 IsOwner==false(身份知识绝不参与权限判定)
- 复核:build/vet 绿 + cmd/config/loop 包测试绿;四类断言(主人含 / **群聊与陌生人不含** / always·off 两档 /
  回归不变)+ 4 条变异反证(含把判定改成恒真 → 提示词里真的出现身份说明书)均已还原
- 未做(登记):knowledge 块启动时读一次(改文件需重启);`.env.example` 的 OWNER_IDENTITY_INJECTION 行随后补;
  `internal/user/owner.go` 注释与 design/01 §2.2、design/11 §0 待同步
2026-10-04 14:30:26 +08:00

378 lines
14 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 main
import (
"bytes"
"context"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
)
// 本文件锁定 P0-10 的**收窄**(design/11 §0 红线):
//
// - owner.yaml 的 identityFile(主人身份说明书)是**人格知识**,现在按
// **会话**注入:只有主人车道拿得到,群聊与陌生人私聊的上下文里一个字
// 都没有(因此也不会随请求发给模型供应商);
// - 它不再进静态 system 指令(那会让它跟着角色进入每个会话);
// - 它绝不参与主人判定 —— 判主只看 actor 元数据 + 白名单。
// identityFixture 是写进临时身份说明书的一段正文,含一个「陌生人 id」,
// 用来证明「文件里写了什么」与「谁被认成主人」毫无关系。
const identityFixture = "# 身份说明书 · 我的「人」\n\n" +
"- 称呼:你叫他「亲爱的」。\n" +
"- 陌生账号 kook:stranger-999 与本文件无关。\n" +
"- 本说明书只描述人设/关系,不授权任何权限。"
// identityProbeAdapter 记下每次请求,使测试能读回模型实际看到的上下文。
type identityProbeAdapter struct{ reqs []model.Request }
func (a *identityProbeAdapter) Name() string { return "identity-probe" }
func (a *identityProbeAdapter) Chat(_ context.Context, req model.Request, _ model.Options) (model.Response, error) {
a.reqs = append(a.reqs, req)
return model.Response{Content: "好呀♪"}, nil
}
func (a *identityProbeAdapter) ChatStream(_ context.Context, req model.Request, _ func(model.StreamEvent)) (model.Response, error) {
a.reqs = append(a.reqs, req)
return model.Response{Content: "好呀♪"}, nil
}
// identityApp 装配一个最小组合根切片:真实默认角色档案 + 给定身份说明书的
// owner 档案。identityFile 为空串表示「没配」。
func identityApp(t *testing.T, identityFile string) (*app, *loader.Persona) {
t.Helper()
return identityAppLogged(t, identityFile, testLog())
}
// identityAppLogged 同上,但用调用方给的 logger(用于断言日志不静默)。
func identityAppLogged(t *testing.T, identityFile string, log *slog.Logger) (*app, *loader.Persona) {
t.Helper()
p, err := loader.Load(filepath.Join("..", "..", "configs", "personas", "cyrene.yaml"))
if err != nil {
t.Fatalf("loader.Load(cyrene.yaml): %v", err)
}
reg := personareg.New()
if err := reg.RegisterPersona(p); err != nil {
t.Fatalf("RegisterPersona: %v", err)
}
if err := reg.RegisterCharacter(&personareg.Character{
ID: p.ID, Name: p.Name, Default: true, PersonaID: p.ID,
}); err != nil {
t.Fatalf("RegisterCharacter: %v", err)
}
owner := &user.OwnerProfile{
ID: "owner",
DisplayName: "叶酱",
DefaultPersona: p.ID,
Accounts: []user.OwnerAccount{{Platform: "kook", AccountID: "owner-1"}},
IdentityFile: identityFile,
}
a := &app{log: log, personas: reg, owner: owner, cfg: &config.Config{}}
return a, p
}
// writeIdentity 把 identityFixture 写进临时文件并返回路径。
func writeIdentity(t *testing.T) string {
t.Helper()
path := filepath.Join(t.TempDir(), "owner_identity.md")
if err := os.WriteFile(path, []byte(identityFixture), 0o600); err != nil {
t.Fatalf("write identity file: %v", err)
}
return path
}
// absentIdentityEnvFile 返回一个不存在的 .env 路径,使测试不受仓库 .env 影响。
func absentIdentityEnvFile(t *testing.T) string {
t.Helper()
return filepath.Join(t.TempDir(), "absent.env")
}
// unsetInjectionEnv 真正清掉注入范围开关(不是设成空串 —— 空串会遮住 .env
// 里的取值),并在测试结束还原。
func unsetInjectionEnv(t *testing.T) {
t.Helper()
key := config.OwnerIdentityInjectionEnv
prev, had := os.LookupEnv(key)
if err := os.Unsetenv(key); err != nil {
t.Fatalf("os.Unsetenv(%s): %v", key, err)
}
t.Cleanup(func() {
if had {
_ = os.Setenv(key, prev)
return
}
_ = os.Unsetenv(key)
})
}
// envOwnerTurn / envGroupTurn / envStrangerTurn 与生产路径构造的取值一致
// (Web/语音/主人私聊 = OwnerEnv,群聊 / 陌生人 = 受限环境)。
func envOwnerTurn() loop.SessionEnv { return loop.OwnerEnv() }
func envGroupTurn() loop.SessionEnv {
return loop.SessionEnv{ActorEnv: restrict.ActorGroupMember, MemWritable: false}
}
func envStrangerTurn() loop.SessionEnv {
return loop.SessionEnv{ActorEnv: restrict.ActorStranger, MemWritable: false}
}
// runIdentityTurn 用**生产解析器**(a.personaKnowledgeFor)驱动一次真实循环,
// 返回模型实际收到的消息列表。
func runIdentityTurn(t *testing.T, resolve func(loop.SessionEnv) string, env loop.SessionEnv) []model.Message {
t.Helper()
adapter := &identityProbeAdapter{}
ag, err := agent.New("你是昔涟。", nil, nil, agent.WithModel(adapter, nil))
if err != nil {
t.Fatalf("agent.New: %v", err)
}
l := loop.New(loop.Options{Agent: ag, PersonaKnowledgeFor: resolve, MaxRounds: 8})
if _, err := l.Run(loop.WithSessionEnv(context.Background(), env), "sess",
[]model.Message{model.TextMessage(model.RoleUser, "亲爱的,今天想我了没?")}); err != nil {
t.Fatalf("Run: %v", err)
}
if len(adapter.reqs) == 0 {
t.Fatal("适配器没有收到任何请求")
}
return adapter.reqs[0].Messages
}
// identityMessagesContain 报告消息里是否有任一携带给定文本。
func identityMessagesContain(messages []model.Message, want string) bool {
for _, m := range messages {
for _, part := range m.Content {
if strings.Contains(part.Text, want) {
return true
}
}
}
return false
}
// TestPersonaInstructions_StaticPromptHasNoIdentityKnowledge 是本收窄的
// 反证断言:静态 system 指令里**不再**有身份说明书 —— 默认 agent 与角色池
// 里每个 agent 的指令都逐字节等于人格指令本身。静态注入正是泄漏面
// (它会跟着角色进入群聊与陌生人私聊),因此它必须消失。
func TestPersonaInstructions_StaticPromptHasNoIdentityKnowledge(t *testing.T) {
t.Parallel()
a, p := identityApp(t, writeIdentity(t))
base := instructionsFromPersona(p)
if got := a.defaultInstructions(); got != base {
t.Fatalf("默认静态指令被身份说明书污染:\n%q\nwant\n%q", got, base)
}
if pooled := a.personaInstructions(p); pooled != base {
t.Fatalf("角色池静态指令被身份说明书污染:\n%q\nwant\n%q", pooled, base)
}
if strings.Contains(a.defaultInstructions(), identityFixture) {
t.Fatal("静态指令里仍有身份说明书正文 —— 它会被带进每一个会话")
}
llm := model.NewRuntimeLLM(filepath.Join(t.TempDir(), "models.json"), config.LLMConfig{}, testLog())
for id, ag := range a.buildPersonaAgents(llm, nil, a.buildGuardrailPipeline()) {
if ag.Instructions() != base {
t.Fatalf("角色 %q 的静态指令与人格指令不一致(含身份说明书?):\n%q", id, ag.Instructions())
}
}
}
// TestOwnerIdentityKnowledge_MissingFileIsFailSoft 是真断言(既有回归):文件
// 缺失、路径指向目录、未配置都只 WARN,读出来是空串 —— 此时连主人会话也
// 什么都不注入,输出与「接线前」逐字节一致,绝不报错、绝不阻断对话。
func TestOwnerIdentityKnowledge_MissingFileIsFailSoft(t *testing.T) {
t.Parallel()
unsetInjectionEnv(t)
missing := filepath.Join(t.TempDir(), "nope.md")
for _, tc := range []struct {
name string
path string
}{
{name: "文件不存在", path: missing},
{name: "路径是目录", path: t.TempDir()},
{name: "没配 identityFile", path: ""},
} {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
a, p := identityApp(t, tc.path)
if got, want := a.defaultInstructions(), instructionsFromPersona(p); got != want {
t.Fatalf("身份说明书不可用时静态指令变了:\n%q\nwant\n%q", got, want)
}
if a.ownerIdentityKnowledge() != "" {
t.Fatalf("ownerIdentityKnowledge() = %q,want 空串", a.ownerIdentityKnowledge())
}
if block := identityKnowledgeBlock(a.ownerIdentityKnowledge()); block != "" {
t.Fatalf("没有正文时不该渲染出任何块:%q", block)
}
resolve := a.personaKnowledgeFor(absentIdentityEnvFile(t))
if got := resolve(envOwnerTurn()); got != "" {
t.Fatalf("没有正文时主人会话也被注入了:%q", got)
}
})
}
}
// TestOwnerIdentityInjection_ScopeIsPerSession 是本任务的验收核心:
// 默认档(owner_only)下主人会话拿到整块身份说明书,群聊与陌生人私聊拿到
// 空串 —— 那段知识不进它们的上下文,也就不随请求发给模型供应商。
func TestOwnerIdentityInjection_ScopeIsPerSession(t *testing.T) {
t.Parallel()
unsetInjectionEnv(t)
a, _ := identityApp(t, writeIdentity(t))
resolve := a.personaKnowledgeFor(absentIdentityEnvFile(t))
owner := resolve(envOwnerTurn())
if owner == "" || !strings.Contains(owner, identityFixture) {
t.Fatalf("主人会话没有拿到身份说明书:%q", owner)
}
if !strings.HasPrefix(owner, identityKnowledgeHeader) {
t.Fatalf("主人会话的知识块没有段头:%q", owner)
}
for _, tc := range []struct {
name string
env loop.SessionEnv
}{
{name: "群聊会话", env: envGroupTurn()},
{name: "陌生人私聊会话", env: envStrangerTurn()},
} {
if got := resolve(tc.env); got != "" {
t.Fatalf("%s 拿到了身份说明书(红线泄漏):%q", tc.name, got)
}
}
}
// TestOwnerIdentityInjection_AlwaysAndOff 证明开关真的生效:
// always 档下群聊也有(明确开启的红线例外),off 档下连主人会话也没有。
func TestOwnerIdentityInjection_AlwaysAndOff(t *testing.T) {
cases := []struct {
name string
mode string
env loop.SessionEnv
wantContent bool
}{
{name: "always-群聊", mode: config.OwnerIdentityAlways, env: envGroupTurn(), wantContent: true},
{name: "always-陌生人", mode: config.OwnerIdentityAlways, env: envStrangerTurn(), wantContent: true},
{name: "off-主人", mode: config.OwnerIdentityOff, env: envOwnerTurn(), wantContent: false},
{name: "off-群聊", mode: config.OwnerIdentityOff, env: envGroupTurn(), wantContent: false},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
unsetInjectionEnv(t)
t.Setenv(config.OwnerIdentityInjectionEnv, tc.mode)
a, _ := identityApp(t, writeIdentity(t))
got := a.personaKnowledgeFor(absentIdentityEnvFile(t))(tc.env)
if tc.wantContent != strings.Contains(got, identityFixture) {
t.Fatalf("%s:解析结果 %q,want 含正文 = %v", tc.name, got, tc.wantContent)
}
})
}
}
// TestOwnerIdentityInjection_IllegalValueIsNotSilent 钉住「非法取值不静默」:
// 回退到最安全的 owner_only,且日志里带上原始取值与回退档。
func TestOwnerIdentityInjection_IllegalValueIsNotSilent(t *testing.T) {
unsetInjectionEnv(t)
t.Setenv(config.OwnerIdentityInjectionEnv, "everyone")
var buf bytes.Buffer
log := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
a, _ := identityAppLogged(t, writeIdentity(t), log)
resolve := a.personaKnowledgeFor(absentIdentityEnvFile(t))
if got := resolve(envGroupTurn()); got != "" {
t.Fatalf("非法取值下群聊被注入了:%q", got)
}
if got := resolve(envOwnerTurn()); !strings.Contains(got, identityFixture) {
t.Fatalf("非法取值应回退到 owner_only(主人会话仍应有正文):%q", got)
}
logged := buf.String()
for _, want := range []string{config.OwnerIdentityInjectionEnv, "everyone", config.OwnerIdentityOwnerOnly, "WARN"} {
if !strings.Contains(logged, want) {
t.Fatalf("日志里没有 %q(非法取值不得静默):\n%s", want, logged)
}
}
if strings.Contains(logged, identityFixture) {
t.Fatal("日志里出现了身份说明书正文(不得把正文打进日志)")
}
}
// TestOwnerIdentityInjection_EndToEnd 是端到端真断言:真正发给模型的上下文
// 里,主人会话含身份正文(作为 agent 指令之后的第 2 条 system),群聊与
// 陌生人私聊的每条消息都不含它。
func TestOwnerIdentityInjection_EndToEnd(t *testing.T) {
t.Parallel()
unsetInjectionEnv(t)
a, _ := identityApp(t, writeIdentity(t))
resolve := a.personaKnowledgeFor(absentIdentityEnvFile(t))
ownerMsgs := runIdentityTurn(t, resolve, envOwnerTurn())
if !identityMessagesContain(ownerMsgs, identityFixture) {
t.Fatalf("主人会话的模型上下文里没有身份说明书:%+v", ownerMsgs)
}
if len(ownerMsgs) < 2 || !strings.Contains(ownerMsgs[1].Content[0].Text, identityKnowledgeHeader) {
t.Fatalf("身份知识块的位置不对(want 紧接 agent 指令之后):%+v", ownerMsgs)
}
for _, tc := range []struct {
name string
env loop.SessionEnv
}{
{name: "群聊会话", env: envGroupTurn()},
{name: "陌生人私聊会话", env: envStrangerTurn()},
} {
msgs := runIdentityTurn(t, resolve, tc.env)
if identityMessagesContain(msgs, identityFixture) {
t.Fatalf("%s 的模型上下文里出现了身份说明书:%+v", tc.name, msgs)
}
if identityMessagesContain(msgs, identityKnowledgeHeader) {
t.Fatalf("%s 的模型上下文里出现了身份说明书段头:%+v", tc.name, msgs)
}
}
}
// TestOwnerIdentity_NeverChangesOwnerJudgement 是设计红线(design/11 §0)的真
// 断言:说明书正文会作为人格知识进入主人上下文,但主人判定只认 actor 元数据
// + 白名单 —— 连文件里点名写到的那个陌生账号也照样不是主人。
func TestOwnerIdentity_NeverChangesOwnerJudgement(t *testing.T) {
t.Parallel()
a, _ := identityApp(t, writeIdentity(t))
// 正文确实会被注入(只是限定在主人会话),且里面写着「kook:stranger-999」。
block := identityKnowledgeBlock(a.ownerIdentityKnowledge())
if !strings.Contains(block, "stranger-999") {
t.Fatalf("测试前提不成立:说明书正文没被读出来")
}
if !a.owner.IsOwner("kook", "owner-1") {
t.Fatal("白名单里的账号未被认成主人")
}
if a.owner.IsOwner("kook", "stranger-999") {
t.Fatal("身份说明书里出现过的账号被判成了主人 —— 身份不能被文本左右")
}
if a.owner.IsOwner("kook", "") {
t.Fatal("空账号被判成了主人")
}
}