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)。
This commit is contained in:
AskaEth committed 2026-10-30 08:37:40 +08:00
1 parent 9d28477e3a
commit c34b9d195d
10 files changed
+469 -30

No files matched your search

@@ -0,0 +1,133 @@
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)
}
}
}
}