diff --git a/internal/services/channelbridge/engage.go b/internal/services/channelbridge/engage.go index ad0bb1c..ffca4bd 100644 --- a/internal/services/channelbridge/engage.go +++ b/internal/services/channelbridge/engage.go @@ -10,6 +10,7 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" ) // 本文件承载公开平台群聊/陌生人会话的参与度引擎 @@ -58,6 +59,29 @@ const ( // 因此它失败关闭为静默。 var errNoEngageJudge = errors.New("channelbridge: no light judge wired for engage") +// engageReasonRunes 是判定器 reason 允许出镜的 rune 上限。 +// +// 取 60 的理由:reason 由「只输出 JSON 对象」的轻量判定器给出,实际值是一句短语 +// (「群里在聊周末安排,与角色无关」≈ 16 字),60 rune 容得下三四句这样的短语; +// 一旦超过 60 rune,说明它在**复述群聊原文** —— 那部分对排障没有价值(要的是 +// 「判定方向 + 方向词」),却正是最不该进日志的那段话。 +const engageReasonRunes = 60 + +// engageReason 把判定器回的 reason 整理成可以出镜的短句(Debug 日志与返回值共用 +// 这同一份,口径不会分叉): +// +// - 压平空白:reason 是模型自由文本,一个换行就能在按行聚合的日志里伪造出 +// 「另一条记录」;返回值也会流进日志,所以同样要压; +// - 凭据清洗走 trace.RedactTextLimited(与 trace.RedactText 同一实现,不是第二套 +// 脱敏路径):模型可能把 Authorization / sk-… 之类原样回显进 reason; +// - 截到 engageReasonRunes(见上)。 +// +// 🔴 本函数只决定「进去的那份文本」,不参与任何判定:reason 的分支条件与返回值拼接 +// 结构一个字都不动(2026-10-26 塔口径)。 +func engageReason(raw string) string { + return trace.RedactTextLimited(strings.Join(strings.Fields(raw), " "), engageReasonRunes) +} + // DefaultEngageConfig 返回 design/18 的参数默认值(P2a 基准;P2c 会让它们 // 可按群配置/在面板编辑)。 func DefaultEngageConfig() EngageConfig { @@ -219,16 +243,16 @@ func (b *Bridge) engageProbe(ctx context.Context, msg adapter.UnifiedMessage, ac } if d.AvoidTopic { b.toIdle(st) - b.log.Debug("engage probe avoid topic, staying idle", "group", key, "reason", d.Reason) + b.log.Debug("engage probe avoid topic, staying idle", "group", key, "reason", engageReason(d.Reason)) return false, "engage: avoid topic" } if d.WorthReply || d.Interesting { b.enterActive(st, cfg, now, msg) - b.log.Debug("engage probing → active (respond)", "group", key, "reason", d.Reason) - return true, "engage: " + d.Reason + b.log.Debug("engage probing → active (respond)", "group", key, "reason", engageReason(d.Reason)) + return true, "engage: " + engageReason(d.Reason) } b.toIdle(st) - b.log.Debug("engage probing → idle, no interest", "group", key, "reason", d.Reason) + b.log.Debug("engage probing → idle, no interest", "group", key, "reason", engageReason(d.Reason)) return false, "engage: no interest" } @@ -247,14 +271,14 @@ func (b *Bridge) engageFollowUp(ctx context.Context, msg adapter.UnifiedMessage, } if d.AvoidTopic || (!d.WorthReply && !d.Interesting) { b.countNoInterest(st) - b.log.Debug("engage follow-up no interest / avoid", "group", key, "reason", d.Reason) + b.log.Debug("engage follow-up no interest / avoid", "group", key, "reason", engageReason(d.Reason)) return b.engageAfterFollowUp(st, cfg, now, key) } st.lastInteract = now st.lastTopic = contentSnippet(msg.Content) st.consecutiveNoInterest = 0 - b.log.Debug("engage follow-up respond", "group", key, "reason", d.Reason) - return true, "engage follow-up: " + d.Reason + b.log.Debug("engage follow-up respond", "group", key, "reason", engageReason(d.Reason)) + return true, "engage follow-up: " + engageReason(d.Reason) } // engageAfterFollowUp 判定一次无兴趣/回避跟进探测的结果。 diff --git a/internal/services/channelbridge/engage_reason_test.go b/internal/services/channelbridge/engage_reason_test.go new file mode 100644 index 0000000..9ffdc71 --- /dev/null +++ b/internal/services/channelbridge/engage_reason_test.go @@ -0,0 +1,158 @@ +package channelbridge + +// 本文件钉住「参与度判定器的 reason 是模型自由文本,不得原样出镜」(engage.go 的 +// engageReason):Debug 日志与返回值必须同时满足「凭据打码」与「压平成一行的短句」。 +// +// 变异判据(每条都对应一个会红的实现): +// - 把 engageReason 换成恒等返回(或摘掉 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) + } +}