Files
Cyrene_v2/internal/runtime/loop/persona_knowledge_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

260 lines
10 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 loop
import (
"context"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件锁定 P0-10 的**收窄**:主人身份说明书(人格知识)按会话注入,
// 默认只在主人车道出现 —— 群聊与陌生人私聊的模型可见上下文里
// 完全没有它(design/11 §0 红线:不入主会话上下文、不泄露主人敏感信息)。
//
// 分工:本文件证明**机制**(loop 每轮按 SessionEnv 调用解析器,并把返回块
// 放进模型可见上下文);**策略**(owner_only 的判定与开关三档)由
// cmd/cyrene/persona_identity.go + 其测试拥有。
const (
// knowledgeFixture 是测试用的人格知识正文(真实装配里它是 owner.yaml
// identityFile 的正文)。
knowledgeFixture = "【主人身份说明书】\n- 称呼:你叫他「亲爱的」。"
// knowledgeProbe 是正文里唯一的指纹串,用来在上下文中判定「在不在」。
knowledgeProbe = "你叫他「亲爱的」"
)
// envOwner 是主人私聊环境;envGroup / envStranger 是受限环境
// (与网关/channelbridge 生产路径构造的取值一致)。
func envOwner() SessionEnv { return OwnerEnv() }
func envGroup() SessionEnv {
return SessionEnv{ActorEnv: restrict.ActorGroupMember, MemWritable: false}
}
func envStranger() SessionEnv {
return SessionEnv{ActorEnv: restrict.ActorStranger, MemWritable: false}
}
// knowledgeIn 报告消息列表里是否有任一消息带着知识指纹。
func knowledgeIn(messages []model.Message) bool {
for _, m := range messages {
if strings.Contains(messageText(m), knowledgeProbe) {
return true
}
}
return false
}
// gateKnowledge 是 cmd/cyrene 那条生产策略的等价物:只在主人车道返回正文。
func gateKnowledge(env SessionEnv) string {
if env.ActorEnv != restrict.ActorOwner {
return ""
}
return knowledgeFixture
}
// TestPersonaKnowledge_OwnerSessionInjects 是断言①:主人会话的上下文里有
// 身份正文,且它排在所有既有前导块之前(即紧接 agent 指令之后)。
func TestPersonaKnowledge_OwnerSessionInjects(t *testing.T) {
t.Parallel()
l := New(Options{PersonaKnowledgeFor: gateKnowledge})
msgs := []model.Message{model.TextMessage(model.RoleUser, "在吗")}
view := l.assembleContext(WithSessionEnv(context.Background(), envOwner()), msgs, 1)
if len(view) != 2 {
t.Fatalf("主人会话 view = %d 条消息, want 2(知识块 + 用户消息)", len(view))
}
if view[0].Role != model.RoleSystem || !strings.Contains(messageText(view[0]), knowledgeProbe) {
t.Fatalf("主人会话首条不是身份知识块: %+v", view[0])
}
if messageText(view[1]) != "在吗" {
t.Fatalf("用户消息被顺序打乱: %+v", view[1])
}
}
// TestPersonaKnowledge_RestrictedSessionsDoNotInject 是本任务的验收核心:
// 群聊与陌生人私聊的上下文里**一个字都没有**这段知识 —— 因此它也不会
// 随请求发给模型供应商。
func TestPersonaKnowledge_RestrictedSessionsDoNotInject(t *testing.T) {
t.Parallel()
l := New(Options{PersonaKnowledgeFor: gateKnowledge})
for _, tc := range []struct {
name string
env SessionEnv
}{
{name: "群聊会话", env: envGroup()},
{name: "陌生人私聊会话", env: envStranger()},
} {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
msgs := []model.Message{model.TextMessage(model.RoleUser, "在吗")}
view := l.assembleContext(WithSessionEnv(context.Background(), tc.env), msgs, 1)
if len(view) != 1 {
t.Fatalf("%s view = %d 条消息, want 1(不得注入任何块)", tc.name, len(view))
}
if knowledgeIn(view) {
t.Fatalf("%s 的上下文里出现了主人身份说明书: %+v", tc.name, view)
}
})
}
}
// TestPersonaKnowledge_AlwaysReachesGroupChat 是断言③(开关真的生效):
// 解析器无差别返回正文(生产里对应 OWNER_IDENTITY_INJECTION=always)时,
// 群聊上下文里就**有**这段知识 —— 这同时反证上面那条断言不是恒真的
// (它命中与否确实由解析器的返回决定,而不是钩子从未生效)。
func TestPersonaKnowledge_AlwaysReachesGroupChat(t *testing.T) {
t.Parallel()
l := New(Options{PersonaKnowledgeFor: func(SessionEnv) string { return knowledgeFixture }})
msgs := []model.Message{model.TextMessage(model.RoleUser, "在吗")}
view := l.assembleContext(WithSessionEnv(context.Background(), envGroup()), msgs, 1)
if !knowledgeIn(view) {
t.Fatalf("always 档下群聊上下文里没有身份正文(开关没生效): %+v", view)
}
}
// TestPersonaKnowledge_OffSkipsOwnerSession 是断言④:解析器恒返回空串
// (生产里对应 OWNER_IDENTITY_INJECTION=off)时,连主人会话也没有它,
// 且上下文与「没接线」逐字节一致。
func TestPersonaKnowledge_OffSkipsOwnerSession(t *testing.T) {
t.Parallel()
l := New(Options{PersonaKnowledgeFor: func(SessionEnv) string { return "" }})
msgs := []model.Message{model.TextMessage(model.RoleUser, "在吗")}
view := l.assembleContext(WithSessionEnv(context.Background(), envOwner()), msgs, 1)
if len(view) != 1 || knowledgeIn(view) {
t.Fatalf("off 档下主人会话仍被注入: %+v", view)
}
}
// TestPersonaKnowledge_NotWired_IsByteIdentical 钉住向后兼容:未接线该钩子
// (nil)时每轮上下文与接线前逐字节一致,任何会话都不出现该块。
func TestPersonaKnowledge_NotWired_IsByteIdentical(t *testing.T) {
t.Parallel()
l := New(Options{})
msgs := []model.Message{model.TextMessage(model.RoleUser, "在吗")}
view := l.assembleContext(WithSessionEnv(context.Background(), envOwner()), msgs, 1)
if len(view) != 1 || messageText(view[0]) != "在吗" {
t.Fatalf("未接线时上下文被改动: %+v", view)
}
}
// TestRun_PersonaKnowledge_StaysOutOfTheConversation 是同级红线断言:人格知识
// 块只是**每轮模型可见视图**的一部分,绝不进入对话本身 —— 因此记忆提取器
// (以及它写下的持久化面、checkpoint 里的对话)都看不到它。若将来有人把它
// 注入到 `messages` 而不是 `leading`,这条会红:那等于把主人身份知识交给
// 提取器当素材,可能被写成记忆条目(design/11 §0 不泄露主人敏感信息)。
func TestRun_PersonaKnowledge_StaysOutOfTheConversation(t *testing.T) {
t.Parallel()
capture := &knowledgeCaptureExtractor{}
ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Content: "好呀♪"}}}}
l := New(Options{
Agent: newTestAgent(t, ad, nil),
PersonaKnowledgeFor: gateKnowledge,
Memory: newTestMemoryStore(t),
WorkingMemory: memory.NewWorkingMemory(0),
Extractor: capture,
MaxRounds: 8,
})
ctx := WithSessionEnv(context.Background(), envOwner())
if _, err := l.Run(ctx, "sess", []model.Message{model.TextMessage(model.RoleUser, "在吗")}); err != nil {
t.Fatalf("Run: %v", err)
}
// 前提:视图里确实有它(否则这条断言没有意义)。
if len(ad.reqs) == 0 || !knowledgeIn(ad.reqs[0].Messages) {
t.Fatalf("测试前提不成立:模型可见视图里没有知识块")
}
if len(capture.saw) == 0 {
t.Fatal("测试前提不成立:提取器没有被调用")
}
if knowledgeIn(capture.saw) {
t.Fatalf("人格知识块进了对话/提取面(会被持久化或写成记忆):%+v", capture.saw)
}
}
// knowledgeCaptureExtractor 记下提取器实际看到的对话。
type knowledgeCaptureExtractor struct{ saw []model.Message }
func (e *knowledgeCaptureExtractor) Extract(_ context.Context, conv []model.Message) ([]memory.MemoryItem, error) {
e.saw = append([]model.Message(nil), conv...)
return nil, nil
}
// TestRun_PersonaKnowledgePerSession 是端到端真断言(两条路径都测):真正
// 发给模型的消息里,主人会话第 2 条就是身份知识块(第 1 条是 agent 指令),
// 群聊会话则**没有任何一条**带它。流式(OnStream)与非流式(agent.RunStep)
// 共用 assembleContext,因此这里逐路径各断言一次,避免「只有一条路径收窄」。
func TestRun_PersonaKnowledgePerSession(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
stream bool
env SessionEnv
wantHave bool
}{
{name: "主人会话-非流式", stream: false, env: envOwner(), wantHave: true},
{name: "主人会话-流式", stream: true, env: envOwner(), wantHave: true},
{name: "群聊会话-非流式", stream: false, env: envGroup(), wantHave: false},
{name: "群聊会话-流式", stream: true, env: envGroup(), wantHave: false},
{name: "陌生人私聊-非流式", stream: false, env: envStranger(), wantHave: false},
{name: "陌生人私聊-流式", stream: true, env: envStranger(), wantHave: false},
} {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Content: "ok"}}}}
opts := Options{
Agent: newTestAgent(t, ad, nil),
PersonaKnowledgeFor: gateKnowledge,
MaxRounds: 8,
}
if tc.stream {
opts.OnStream = func(model.StreamEvent) {}
}
l := New(opts)
ctx := WithSessionEnv(context.Background(), tc.env)
if _, err := l.Run(ctx, "sess", []model.Message{model.TextMessage(model.RoleUser, "在吗")}); err != nil {
t.Fatalf("Run: %v", err)
}
reqs := ad.reqs
if tc.stream {
reqs = ad.streamReqs
}
if len(reqs) == 0 {
t.Fatal("适配器没有收到任何请求")
}
sent := reqs[0].Messages
if got := knowledgeIn(sent); got != tc.wantHave {
t.Fatalf("发给模型的消息里含身份正文 = %v, want %v: %+v", got, tc.wantHave, sent)
}
if !tc.wantHave {
return
}
// 注入位置:第 1 条是指令,第 2 条是知识块(与它过去作为指令尾段
// 的相对位置一致 —— 紧跟在人格提示词之后)。
if len(sent) < 2 || sent[0].Role != model.RoleSystem || sent[1].Role != model.RoleSystem {
t.Fatalf("注入位置不对(want 指令 + 知识块两条 system): %+v", sent)
}
if !strings.Contains(messageText(sent[1]), knowledgeProbe) {
t.Fatalf("第 2 条 system 不是身份知识块: %+v", sent[1])
}
})
}
}