package channelbridge // 本文件钉住「参与度判定器的 reason 是模型自由文本,不得原样出镜」(engage.go 的 7 处 // 出口,统一走 judge_reason.go 的 judgeReason):Debug 日志与返回值必须同时满足 // 「凭据打码」与「压平成一行的短句」。 // // 变异判据(每条都对应一个会红的实现): // - 把 judgeReason 换成恒等返回(或摘掉 trace.RedactTextLimited)⇒ 日志与返回值里 // 都能搜到 sk-live…,红; // - 只做 RedactText 不做 strings.Fields 压平 ⇒ 换行仍在,红(见下面两条换行断言); // - 只改日志、不改返回值(`"engage: " + d.Reason` 保持原样)⇒ 返回值那条断言红。 import ( "bytes" "context" "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" "git.yeij.top/AskaEth/Cyrene_v2/internal/user" ) // engageReasonSecret 是判定器「回显」进 reason 的凭据形状(sk-… 形状级匹配能认)。 const engageReasonSecret = "sk-live-0123456789abcdef" // engageReasonRaw 是判定器回的原始 reason:一句正常短语 + 回显的凭据 + 一个换行 // (换行是想在按行聚合的日志里伪造一条新记录)。 const engageReasonRaw = "第一行:群里有 " + engageReasonSecret + "\n第二行:伪造一条日志" // engageReasonWant 是 engageReason 的期望产物:凭据被打码成 [redacted]、两行被压成 // 一行(空格分隔)、其余原文保留(诊断价值仍在)。 const engageReasonWant = "第一行:群里有 [redacted] 第二行:伪造一条日志" // engageReasonBridge 构建一条装配了参与度引擎与 LightJudge 的桥接器, // 并把 Debug 级日志写进 out(Debug 是 reason 的唯一落点,级别必须降到 debug)。 func engageReasonBridge(t *testing.T, judge LightJudge, out *bytes.Buffer) *Bridge { t.Helper() b, err := New(Options{ Log: logger.New(logger.Options{Format: string(logger.FormatJSON), Level: "debug", Out: out}), Loop: &stubChatter{res: &loop.Result{Text: "参与"}}, Sessions: &stubSessions{sess: nonOwnerSession()}, Owner: &user.OwnerProfile{}, Light: judge, TriggerWords: []string{"昔涟"}, Engage: DefaultEngageConfig(), EngageRand: func() float64 { return 0.5 }, // 永不命中概率触发,只走角色名提及 Now: dayNow(), }) if err != nil { t.Fatalf("New: %v", err) } t.Cleanup(func() { b.Stop(context.Background()) }) return b } // assertNoCredentialNoNewline 断言一段出镜文本既不含凭据原文,也不含换行 // (换行会按行聚合的日志里伪造出「另一条记录」)。 func assertNoCredentialNoNewline(t *testing.T, what, got string) { t.Helper() if strings.Contains(got, "sk-live") { t.Fatalf("%s 里出现了判定器回显的凭据:%q", what, got) } if !strings.Contains(got, "[redacted]") { t.Errorf("%s 里没有打码标记(Reason 应当仍带上限出镜):%q", what, got) } if strings.Contains(got, "\n") || strings.Contains(got, "\\n") { t.Fatalf("%s 里出现了换行(应被压平):%q", what, got) } } // TestEngageReason_ProbeLogAndReturnAreSanitized 覆盖 PROBING 判定(→ ACTIVE): // 日志与返回值都只带打码压平后的短句。 func TestEngageReason_ProbeLogAndReturnAreSanitized(t *testing.T) { t.Parallel() var out bytes.Buffer judge := &engageScriptJudge{responses: []LightDecision{ {Interesting: true, WorthReply: true, Reason: engageReasonRaw}, }} b := engageReasonBridge(t, judge, &out) msg := nonOwnerGroupMsg("大家在聊昔涟的事") respond, reason := b.engageDecision(context.Background(), msg, msg.Sender, nonOwnerSession(), false) if !respond { t.Fatalf("判定器说值得回,engageDecision 却回了 false(reason=%q)", reason) } assertNoCredentialNoNewline(t, "PROBING 返回值", reason) if !strings.Contains(reason, engageReasonWant) { t.Fatalf("返回值 = %q,期望含 %q(打码 + 压平后的短句)", reason, engageReasonWant) } logged := out.String() if !strings.Contains(logged, engageReasonWant) { t.Fatalf("Debug 日志里没有打码压平后的 reason(原文换行未压平?):%s", logged) } if strings.Contains(logged, "sk-live") { t.Fatalf("Debug 日志里出现了判定器回显的凭据:%s", logged) } } // TestEngageReason_FollowUpLogAndReturnAreSanitized 覆盖 ACTIVE 跟进复审(→ 继续参与): // 第二处返回值拼接(`"engage follow-up: " + …`)同样必须打码压平。 func TestEngageReason_FollowUpLogAndReturnAreSanitized(t *testing.T) { t.Parallel() var out bytes.Buffer judge := &engageScriptJudge{responses: []LightDecision{ {Interesting: true, WorthReply: true, Reason: "先进入活跃窗口"}, {Interesting: true, WorthReply: true, Reason: engageReasonRaw}, }} b := engageReasonBridge(t, judge, &out) msg := nonOwnerGroupMsg("大家在聊昔涟的事") ctx := context.Background() if respond, _ := b.engageDecision(ctx, msg, msg.Sender, nonOwnerSession(), false); !respond { t.Fatal("第一次探测应当进入 ACTIVE") } respond, reason := b.engageDecision(ctx, msg, msg.Sender, nonOwnerSession(), false) if !respond { t.Fatalf("跟进判定说继续参与,engageDecision 却回了 false(reason=%q)", reason) } assertNoCredentialNoNewline(t, "跟进返回值", reason) if !strings.Contains(reason, "engage follow-up: "+engageReasonWant) { t.Fatalf("跟进返回值 = %q,期望含 %q", reason, "engage follow-up: "+engageReasonWant) } logged := out.String() if !strings.Contains(logged, engageReasonWant) { t.Fatalf("跟进 Debug 日志里没有打码压平后的 reason:%s", logged) } if strings.Contains(logged, "sk-live") { t.Fatalf("跟进 Debug 日志里出现了判定器回显的凭据:%s", logged) } } // TestEngageReason_AvoidTopicAndNoInterestAreSanitized 覆盖另外两条 Debug 出口 // (回避话题 / 无兴趣),它们同样带 reason。 func TestEngageReason_AvoidTopicAndNoInterestAreSanitized(t *testing.T) { t.Parallel() var out bytes.Buffer judge := &engageScriptJudge{responses: []LightDecision{ {Interesting: false, WorthReply: false, AvoidTopic: true, Reason: engageReasonRaw}, {Interesting: false, WorthReply: false, Reason: engageReasonRaw}, }} b := engageReasonBridge(t, judge, &out) msg := nonOwnerGroupMsg("大家在聊昔涟的事") ctx := context.Background() if respond, reason := b.engageDecision(ctx, msg, msg.Sender, nonOwnerSession(), false); respond { t.Fatalf("回避话题不应回复(reason=%q)", reason) } if respond, reason := b.engageDecision(ctx, msg, msg.Sender, nonOwnerSession(), false); respond { t.Fatalf("无兴趣不应回复(reason=%q)", reason) } logged := out.String() if strings.Contains(logged, "sk-live") { t.Fatalf("回避/无兴趣的 Debug 日志里出现了凭据:%s", logged) } if n := strings.Count(logged, engageReasonWant); n < 2 { t.Fatalf("回避与无兴趣两条日志各应带一次打码压平后的 reason,实际 %d 次:%s", n, logged) } }