fix(channelbridge): M51 追加 —— 参与度判定 reason 出镜前打码压平

Debug 日志与返回值共 7 处(engage.go 222/227/228/231/250/256/257 原行号)此前把
判定模型回的 d.Reason 原样写出:模型可以把 Authorization / sk-… 或换行塞进 reason,
前者进日志就是凭据泄漏,后者能按行聚合的日志里伪造一条新记录。

新增 engageReason(trace.RedactTextLimited + strings.Fields,上限 60 rune):
- 控制流零改动:分支条件、状态迁移、返回值拼接结构一个字未动,
  只把「进去的那份文本」从 d.Reason 换成 engageReason(d.Reason);
- 上限 60 rune 的理由写在常量注释里(reason 是一句短语,超过即是在复述群聊原文)。

用例 engage_reason_test.go:PROBING / 跟进 / 回避+无兴趣三条出口,
断言日志与返回值都不含 sk-live…、都含 [redacted]、都不含换行。
变异实测:摘 RedactText ⇒ 三条红;摘压平 ⇒ 三条红。
This commit is contained in:
AskaEth committed 2026-10-26 19:47:35 +08:00
1 parent d6493d79f4
commit 7fbb8d6232
2 files changed
+189 -7

No files matched your search

+31 -7
View File
@@ -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 判定一次无兴趣/回避跟进探测的结果。