Files
Cyrene_v2/internal/services/channelbridge/engage_judge_brief_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

134 lines
5.4 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/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件覆盖判定器去人格(审计 2026-10-30 §2.4 / P1-2):判定器只做价值
// 判断(design/18 §3),因此它的模型输入只吃「角色名 + 偏好摘要」,
// **绝不**吃整份人格指令 —— 否则它会拿「你只唤他『亲爱的』」去看群友,
// 把第三方称作「亲爱的」(现场:reason 写成「让亲爱的他们自己聊吧」)。
//
// 删掉 judgePersonaBrief 的折算、把 in.PersonaPrompt 塞回渲染,这些用例必红。
// intimatePersonaBody 是一份「带称呼与关系设定」的人格指令样本:它就是要被
// 挡在判定器之外的那一类文本(结构同 configs/personas/cyrene.yaml:33/46/72)。
const intimatePersonaBody = "你是昔涟。你只唤他「亲爱的」,你是我独一无二的小女友,靠过来的时候要圈住他。"
// TestEngageJudgePromptCarriesNoPersonaBody 钉住渲染契约:人格正文、
// 亲昵称呼与亲近动作都不得出现在判定器输入里;角色名与偏好摘要在。
func TestEngageJudgePromptCarriesNoPersonaBody(t *testing.T) {
t.Parallel()
in := LightInput{
Engage: true,
PersonaPrompt: intimatePersonaBody,
TriggerWords: []string{"昔涟", "小昔"},
RecentContext: "user 张三: 有人知道昔涟吗\n",
SenderName: "张三",
Msg: adapter.UnifiedMessage{Content: "那聊聊昔涟嘛"},
}
got := buildEngageUserContent(in)
for _, banned := range []string{intimatePersonaBody, "你只唤他", "小女友", "亲爱的", "圈住"} {
if strings.Contains(got, banned) {
t.Fatalf("判定器输入含人格正文(%q):\n%s", banned, got)
}
}
for _, want := range []string{"【角色设定摘要】", "角色名:昔涟", engageJudgePreferenceDigest} {
if !strings.Contains(got, want) {
t.Fatalf("判定器输入缺 %q:\n%s", want, got)
}
}
// 群上下文与触发消息照旧喂(判定要靠它们,且它们不是人格正文)。
if !strings.Contains(got, "有人知道昔涟吗") || !strings.Contains(got, "那聊聊昔涟嘛") {
t.Fatalf("判定器输入缺上下文或触发消息:\n%s", got)
}
}
// TestEngageJudgeBriefSurvivesMissingRoleName 是无提醒词的情形(纯概率触发,
// design/18 §2 触发③):没有角色名可报,但偏好摘要仍在,人格正文仍不许出现。
func TestEngageJudgeBriefSurvivesMissingRoleName(t *testing.T) {
t.Parallel()
in := LightInput{
Engage: true,
PersonaPrompt: intimatePersonaBody,
Msg: adapter.UnifiedMessage{Content: "今天天气不错"},
}
got := buildEngageUserContent(in)
if strings.Contains(got, "亲爱的") || strings.Contains(got, "小女友") {
t.Fatalf("判定器输入含人格正文:\n%s", got)
}
if !strings.Contains(got, engageJudgePreferenceDigest) {
t.Fatalf("判定器输入缺偏好摘要:\n%s", got)
}
if strings.Contains(got, "角色名:") {
t.Fatalf("没有角色名提醒词时不该编一个角色名:\n%s", got)
}
}
// judgeJSONAdapter 记录请求并回一条合法裁决,使端到端用例能走完整 Judge
// (captureModelAdapter 的「在呢」不是 JSON,会在解析处失败关闭)。
type judgeJSONAdapter struct{ captureModelAdapter }
func (j *judgeJSONAdapter) Name() string { return "judge-capture" }
func (j *judgeJSONAdapter) Chat(ctx context.Context, req model.Request, _ model.Options) (model.Response, error) {
j.mu.Lock()
j.reqs = append(j.reqs, req)
j.mu.Unlock()
return model.Response{
Content: `{"interested":true,"worthReply":true,"avoidTopic":false,"reason":"群友在聊角色本身"}`,
}, nil
}
func (j *judgeJSONAdapter) ChatStream(ctx context.Context, req model.Request, _ func(model.StreamEvent)) (model.Response, error) {
return j.Chat(ctx, req, model.Options{})
}
// TestEngageJudgeRequestNeverSeesPersonaBody 是**模型侧**的验收:真实走一次
// Judge,断言 provider 收到的每一条消息都不含人格正文与亲昵称呼,且裁决
// reason 是一句中性短语(不再用称呼指代第三方)。
func TestEngageJudgeRequestNeverSeesPersonaBody(t *testing.T) {
t.Parallel()
ad := &judgeJSONAdapter{}
judge := NewLLMLightJudge(ad, nil)
d, err := judge.Judge(context.Background(), LightInput{
Engage: true,
PersonaPrompt: intimatePersonaBody,
TriggerWords: []string{"昔涟"},
RecentContext: "user 张三: 有人知道昔涟吗\n",
SenderName: "张三",
Msg: adapter.UnifiedMessage{Content: "那聊聊昔涟嘛"},
})
if err != nil {
t.Fatalf("Judge: %v", err)
}
if !d.WorthReply || d.Reason != "群友在聊角色本身" {
t.Fatalf("decision = %+v, want the parsed verdict", d)
}
req := ad.lastRequest(t)
if len(req.Messages) != 2 || req.Messages[0].Role != model.RoleSystem {
t.Fatalf("judge request = %+v, want system + user", req.Messages)
}
if got := turnMessageText(req.Messages[0]); got != engageJudgeSystemPrompt {
t.Fatalf("judge system = %q, want the engagement-only prompt", got)
}
for i, m := range req.Messages {
text := turnMessageText(m)
for _, banned := range []string{"亲爱的", "小女友", "你只唤他", "圈住"} {
if strings.Contains(text, banned) {
t.Fatalf("judge messages[%d] 含人格正文(%q):%s", i, banned, text)
}
}
}
}