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) } } } }