feat(channels): 陌生人私聊正常聊天(跳概率门)+ 强制流控 6条/分与回合槽位 + 安静时段不再硬静默 + 受限车道边界行前置
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 陌生人私聊:不再走「值不值得回」的概率门,唯一闸门 = 每发送者 6 条/分滑窗 + 同会话 1 个在跑回合
- 被限流只登记不回,且留带 source 的可见告警(不静默)
- 安静时段在回复侧只作信号(规则层不再静默),群聊取消夜间静默
- 锁定流控常量:FlowDirectDefault 加字面量钉桩测试(放大它 = 静默关流控,且会让额度循环跑百万轮)
This commit is contained in:
AskaEth committed 2026-10-05 13:26:15 +08:00
1 parent c6f5c3fea0
commit 7ae4298871
10 files changed
+654 -118

No files matched your search

+101 -20
View File
@@ -44,6 +44,13 @@ type FlowGateOptions struct {
// GroupMsgPerMin caps group-chat inbound messages per minute **per
// conversation** (design/12 §6 策略三 / 裁决 06.4.3). Zero disables the limit.
GroupMsgPerMin int
// DirectMsgPerMin caps **direct (private)** inbound messages per minute
// **per conversation** — and for a private message the conversation key is
// (platform, channel, sender), so the limit throttles one sender rather than
// the whole account. It is the mandatory flow control for stranger private
// chats (主人 2026-10-05 裁决③): strangers chat normally, so a sender must be
// unable to flood her. Zero disables the limit.
DirectMsgPerMin int
// MsgPerMin caps total inbound non-owner messages per minute across the whole
// account — the anti-flood total guard. Zero disables it.
MsgPerMin int
@@ -76,6 +83,12 @@ const (
// FlowLimitDefault is the protective default of 6 group messages per minute
// (design/12 §6 策略三). It is applied when an account pins no group rate limit.
FlowLimitDefault = 6
// FlowDirectDefault is the protective default of 6 direct (private) messages
// per minute **per sender** — the mandatory flow control for stranger private
// chats (主人 2026-10-05 裁决③). 6/分钟与群聊保护性默认同一口径:一个人正常
// 一来一回打字到不了 6 条/分钟,而刷屏/机器人会在第 7 条被挡下。它不随
// groupMsgPerMin 变化(陌生人流控不允许被「没配那项」静默关掉)。
FlowDirectDefault = 6
// FlowCapDefault is the protective default total-cap of 50 pending messages
// (design/12 §6 策略四).
FlowCapDefault = 50
@@ -113,16 +126,22 @@ func ConversationKey(msg UnifiedMessage) string {
// 每个会话保存一份,因此两个群并发互不挤占(裁决 06.4.3)。
type flowSession struct {
groupHits []time.Time
directHits []time.Time
buffer []queuedMsg
lastReview time.Time
// inFlight 是该会话**正在跑**的回合数(AcquireTurn/ReleaseTurn 维护)。受限车道
// 用它把「同一发送者同时最多 1 个在跑的回合」落到实处。
inFlight int
}
// FlowGate is a concurrency-safe, per-account ingress gate implementing the two
// measures from design/12 §6 策略三/四:
//
// - 策略三: a sliding-window rate limit on group chat (and, optionally, all
// non-owner messages). Over-limit messages are dropped (downgraded) rather
// than blocking a flood of messages from saturating the loop.
// non-owner messages), plus the mandatory **per-sender** rate limit on direct
// (private) messages that backs the stranger private-chat flow control
// (AllowDirect). Over-limit messages are dropped (downgraded) rather than
// blocking a flood of messages from saturating the loop.
// - 策略四: a bounded pending-message set. When it nears its cap the Reviewer is
// invoked to prune low-value messages; with no Reviewer the oldest messages are
// evicted FIFO. Either way every dropped message is audited.
@@ -131,15 +150,18 @@ type flowSession struct {
//
// Owner-private messages are not routed through the gate (the caller bypasses it),
// so the owner is never throttled or pruned. Identity is never examined here.
// The gate also carries the per-conversation running-turn slot (AcquireTurn /
// ReleaseTurn) used by the stranger private-chat flow control.
type FlowGate struct {
groupPerMin int
msgPerMin int
maxMessages int
reviewAt int
window time.Duration
review Reviewer
onPruned func(UnifiedMessage, string)
now func() time.Time
groupPerMin int
directPerMin int
msgPerMin int
maxMessages int
reviewAt int
window time.Duration
review Reviewer
onPruned func(UnifiedMessage, string)
now func() time.Time
mu sync.Mutex
totalHits []time.Time
@@ -160,14 +182,15 @@ type queuedMsg struct {
// except Window which defaults to one minute when left zero.
func NewFlowGate(opts FlowGateOptions) *FlowGate {
g := &FlowGate{
groupPerMin: opts.GroupMsgPerMin,
msgPerMin: opts.MsgPerMin,
maxMessages: opts.MaxMessages,
window: opts.Window,
review: opts.Review,
onPruned: opts.OnPruned,
now: opts.Now,
sessions: make(map[string]*flowSession),
groupPerMin: opts.GroupMsgPerMin,
directPerMin: opts.DirectMsgPerMin,
msgPerMin: opts.MsgPerMin,
maxMessages: opts.MaxMessages,
window: opts.Window,
review: opts.Review,
onPruned: opts.OnPruned,
now: opts.Now,
sessions: make(map[string]*flowSession),
}
if g.window <= 0 {
g.window = defaultRateWindow
@@ -258,6 +281,61 @@ func (g *FlowGate) Admit(ctx context.Context, msg UnifiedMessage) GateVerdict {
return GateVerdict{Proceed: true, Pruned: queueMessages(dropped)}
}
// AllowDirect enforces the per-sender sliding-window rate limit for a **direct
// (private)** message (主人 2026-10-05 裁决③:陌生人私聊=正常聊天,因此必须有流控).
// For a private message the conversation key is (platform, channel, sender), so
// the window throttles one sender instead of the whole account and two strangers
// never crowd each other out. It returns false when that sender is over
// DirectMsgPerMin; the refused message is recorded to the OnPruned audit hook so
// nothing is dropped silently. A zero limit or a group message always allows.
func (g *FlowGate) AllowDirect(msg UnifiedMessage) bool {
if g.directPerMin <= 0 || msg.ChannelType != ChannelDirect {
return true
}
now := g.now()
key := ConversationKey(msg)
g.mu.Lock()
defer g.mu.Unlock()
st := g.sessionLocked(key)
st.directHits = g.trimHits(st.directHits, now)
if len(st.directHits) >= g.directPerMin {
g.auditMsg(msg, "rate_limited")
return false
}
st.directHits = append(st.directHits, now)
return true
}
// AcquireTurn reserves the single running-turn slot of a conversation, returning
// false when a turn is already in flight for it. Callers must pair every true
// result with ReleaseTurn. It is the second half of the mandatory stranger flow
// control (主人 2026-10-05 裁决③:「同一发送者同时最多 1 个在跑的回合」): a stranger
// can send several messages inside the per-minute allowance, yet only one of them
// may occupy an expensive agent turn at a time. It never decides identity — the
// key is the same structural ConversationKey the rest of the gate uses.
func (g *FlowGate) AcquireTurn(msg UnifiedMessage) bool {
key := ConversationKey(msg)
g.mu.Lock()
defer g.mu.Unlock()
st := g.sessionLocked(key)
if st.inFlight >= 1 {
return false
}
st.inFlight++
return true
}
// ReleaseTurn releases a slot reserved by AcquireTurn.
func (g *FlowGate) ReleaseTurn(msg UnifiedMessage) {
key := ConversationKey(msg)
g.mu.Lock()
defer g.mu.Unlock()
st := g.sessionLocked(key)
if st.inFlight > 0 {
st.inFlight--
}
}
// drop builds a refused verdict from a single message that never entered the
// buffer (rate-limited at the door), so the audit hook still sees it.
func (g *FlowGate) drop(reason string, msg UnifiedMessage) GateVerdict {
@@ -281,7 +359,8 @@ func (g *FlowGate) sessionLocked(key string) *flowSession {
}
// maybeGCLocked 每 sessionGCInterval 次放行清理一次空闲会话,避免会话状态表随历史会话
// 无界增长:滑窗时间戳全部过期且待处理队列为空的会话直接删除。调用方须持有 g.mu。
// 无界增长:滑窗时间戳全部过期、待处理队列为空、且没有回合在跑的会话直接删除。
// 调用方须持有 g.mu。
func (g *FlowGate) maybeGCLocked(now time.Time) {
g.admitted++
if g.admitted%sessionGCInterval != 0 {
@@ -289,7 +368,9 @@ func (g *FlowGate) maybeGCLocked(now time.Time) {
}
for key, st := range g.sessions {
st.groupHits = g.trimHits(st.groupHits, now)
if len(st.groupHits) == 0 && len(st.buffer) == 0 {
st.directHits = g.trimHits(st.directHits, now)
if len(st.groupHits) == 0 && len(st.directHits) == 0 &&
len(st.buffer) == 0 && st.inFlight == 0 {
delete(g.sessions, key)
}
}
@@ -0,0 +1,104 @@
package adapter
// 本文件覆盖主人 2026-10-05 裁决③的**陌生人私聊流控**两件套:
// - AllowDirect:私聊按发送者(会话键 platform+channel+sender)的每分钟滑窗;
// - AcquireTurn/ReleaseTurn:同一发送者同时最多 1 个在跑的回合。
// 两者都复用同一张 flowSession 表与同一套 ConversationKey 口径,不另造第二套限流。
import (
"testing"
"time"
)
// TestFlowGate_AllowDirectIsPerSender 钉住私聊滑窗按发送者独立计数:
// A 用尽额度不挤占 B;窗口滚过后 A 恢复;群聊消息不经这条通道。
func TestFlowGate_AllowDirectIsPerSender(t *testing.T) {
t.Parallel()
now, clock := fixedClock(time.Unix(2000, 0))
var audited []string
g := NewFlowGate(FlowGateOptions{
DirectMsgPerMin: 2,
Window: time.Minute,
Now: now,
OnPruned: func(_ UnifiedMessage, reason string) { audited = append(audited, reason) },
})
for i := 0; i < 2; i++ {
if !g.AllowDirect(directMsgTo("peer-1", i)) {
t.Fatalf("peer-1 第 %d 条被拒,want allowed", i+1)
}
}
if g.AllowDirect(directMsgTo("peer-1", 2)) {
t.Fatal("peer-1 超出自身额度仍被放行")
}
if len(audited) != 1 || audited[0] != "rate_limited" {
t.Fatalf("拒绝未入审计:%v", audited)
}
if !g.AllowDirect(directMsgTo("peer-2", 0)) {
t.Fatal("peer-2 被 peer-1 的额度挤占(私聊限流未按发送者独立)")
}
// 群聊走 Admit 的群聊滑窗,AllowDirect 对它恒放行。
if !g.AllowDirect(groupMsgIn("-100", 0)) {
t.Fatal("群聊消息不该被私聊限流拦下")
}
*clock = clock.Add(61 * time.Second)
if !g.AllowDirect(directMsgTo("peer-1", 3)) {
t.Fatal("窗口滚过后 peer-1 应恢复放行")
}
}
// TestFlowGate_AllowDirectDisabledByZero 钉住零值 = 关闭:没有配置私聊限流时
// (DirectMsgPerMin 为 0)AllowDirect 恒放行,既有调用行为不变。
func TestFlowGate_AllowDirectDisabledByZero(t *testing.T) {
t.Parallel()
g := NewFlowGate(FlowGateOptions{})
for i := 0; i < 50; i++ {
if !g.AllowDirect(directMsgTo("peer-1", i)) {
t.Fatalf("零值额度下第 %d 条被拒,want allowed", i+1)
}
}
}
// TestFlowDirectDefaultIsPinned 用**字面量 6** 钉住保护性默认值(与 FlowLimitDefault
// 的既有钉桩同口径)。不这么钉的后果:`stranger_test.go` 的额度循环以该常量为上界,
// 一旦有人把它改成近似无限,测试不会断言失败,而是**跑一百万轮把整包测试拖到超时**
// ——看起来像「偶发卡死」,实际是流控被静默关掉。主人 2026-10-05 裁决③要求陌生人
// 私聊**必须有**流控,因此这条默认值属于契约,不允许随手放大。
func TestFlowDirectDefaultIsPinned(t *testing.T) {
t.Parallel()
if FlowDirectDefault != 6 {
t.Fatalf("FlowDirectDefault = %d, want 6(每发送者每分钟 6 条的保护性默认;"+
"放大它等于静默关闭陌生人私聊流控)", FlowDirectDefault)
}
}
// TestFlowGate_AcquireTurnSinglePerConversation 钉住「同一会话同时最多 1 个在跑的回合」:
// 第一个占位成功、第二个被拒;换一个发送者互不影响;释放后又能占。
func TestFlowGate_AcquireTurnSinglePerConversation(t *testing.T) {
t.Parallel()
g := NewFlowGate(FlowGateOptions{})
a := directMsgTo("peer-1", 0)
b := directMsgTo("peer-2", 0)
if !g.AcquireTurn(a) {
t.Fatal("首个回合应能占到槽位")
}
if g.AcquireTurn(a) {
t.Fatal("同一发送者的第二个回合不该同时占到槽位")
}
if !g.AcquireTurn(b) {
t.Fatal("另一个发送者的槽位被挤占")
}
g.ReleaseTurn(a)
if !g.AcquireTurn(a) {
t.Fatal("释放后应能重新占到槽位")
}
g.ReleaseTurn(a)
g.ReleaseTurn(b)
}
+15 -4
View File
@@ -19,6 +19,11 @@ import (
// (adapter.ConversationKey:platform+channelID+groupID/peerID),因此两个群绝不会
// 相互挤占(design/12 §6 策略三/四,裁决 06.4.3)。
func (b *Bridge) gateFor(a adapter.PlatformAdapter) *adapter.FlowGate {
if a == nil {
// 便捷路径(process 用平台名找适配器)在平台上未注册任何账号时给出 nil:
// 没有账号就没有闸门,调用方按 nil 跳过(生产路径的 a 恒非 nil)。
return nil
}
name := a.Name()
b.gateMu.Lock()
defer b.gateMu.Unlock()
@@ -31,15 +36,21 @@ func (b *Bridge) gateFor(a adapter.PlatformAdapter) *adapter.FlowGate {
}
// gateOptions 把适配器 Config 快照中的按账号流控限制解析进
// FlowGate。键缺失(单账号环境变量适配器)时限制关闭,
// FlowGate。群聊/总量/上限键缺失(单账号环境变量适配器)时限制关闭,
// 既有渠道行为不变;键存在且值为 0 时采用保护性默认值
// (每群每分钟 6 条、上限 50),负值则关闭限制——
// 这是对 CHANNELS_JSON 账号的向后兼容约定(design/12 §6 实现要点)。
//
// **私聊(陌生人)限流例外:它不依赖任何配置键**(主人 2026-10-05 裁决③),
// 恒定取保护性默认 FlowDirectDefault —— 原本没有任何配置的账号(如 qq-obv11)
// 对陌生人私聊是完全敞口,而裁决要求「陌生人必须有流控」,所以这里不能
// 因为「没配」而静默关掉。主人私聊在 onMessage 就被绕过本闸门,不受影响。
func (b *Bridge) gateOptions(a adapter.PlatformAdapter) adapter.FlowGateOptions {
opts := adapter.FlowGateOptions{
Review: b.review,
OnPruned: b.onRejected,
Now: b.now,
Review: b.review,
OnPruned: b.onRejected,
Now: b.now,
DirectMsgPerMin: adapter.FlowDirectDefault,
}
cfg := a.Config()
if grp, ok := cfgInt(cfg, "groupMsgPerMin"); ok {
@@ -104,7 +104,8 @@ func TestFlowGate_OwnerPrivateBypassesGate(t *testing.T) {
// TestFlowGate_GateOptionsDefaults 钉住「配置→限制」的解析规则,
// 它保证旧的 CHANNELS_JSON 账号向后兼容:键缺失 → 关闭,0 → 保护性
// 默认值,负值 → 关闭,正值 → 固定为该值。
// 默认值,负值 → 关闭,正值 → 固定为该值。**私聊(陌生人)限流是例外**:
// 它不读配置键,恒取保护性默认(裁决③:陌生人必须有流控)。
func TestFlowGate_GateOptionsDefaults(t *testing.T) {
t.Parallel()
@@ -135,6 +136,12 @@ func TestFlowGate_GateOptionsDefaults(t *testing.T) {
if opts.MaxMessages != c.max {
t.Fatalf("MaxMessages = %d, want %d", opts.MaxMessages, c.max)
}
// 陌生人私聊的强制流控不随账号配置开关:无论群聊/上限怎么配,
// 私聊限流恒为保护性默认(主人 2026-10-05 裁决③)。
if opts.DirectMsgPerMin != adapter.FlowDirectDefault {
t.Fatalf("DirectMsgPerMin = %d, want the protective default %d",
opts.DirectMsgPerMin, adapter.FlowDirectDefault)
}
})
}
}
+24 -7
View File
@@ -181,17 +181,18 @@ func (b *Bridge) processFrom(ctx context.Context, msg adapter.UnifiedMessage, a
"session", sess.SessionID, "actor_role", sess.ActorRole,
"env", sess.Env(), "mem_writable", sess.MemWritable)
// design/12 §6 策略一/二/五:面向非主人消息的最小打扰闸门。
// 被廉价轻量判定认为不值得回复的非主人群聊/陌生人
// 消息,只登记到其隔离会话日志并静默——绝不
// 驱动完整 agent 循环。主人消息绝不在此被拦,因此
// 主人私聊与主人在群里的 @/高价值回复不受影响。
// design/12 §6 策略一/二:面向非主人消息的最小打扰闸门。
// 被廉价轻量判定认为不值得回复的非主人**群聊**消息,只登记到
// 其隔离会话日志并静默——绝不驱动完整 agent 循环;**陌生人私聊**则
// 一律走完整回合(裁决①,见 shouldRespond),只受流控约束。
// 主人消息绝不在此被拦,因此主人私聊与主人在群里的
// @/高价值回复不受影响。
//
// 由「传入前审查」放行的系统通知(notice.Applicable)不在此列:它们已由策略一
// 的归一化/分类**终局判定**(高价值升级真会话、其余限定注入),若再走这道面向
// 聊天消息的闸门,actor=system 会在 isLowValueEvent 处被第二次静默掉。
if !actor.IsOwner() && !notice.Applicable {
if ok, reason := b.shouldRespond(ctx, msg, actor, sess); !ok {
if ok, reason := b.shouldRespond(ctx, msg, actor, sess, a); !ok {
b.registerSilenced(ctx, msg, actor, sess, a)
b.log.Debug("non-owner message silenced (minimal disturbance)",
"platform", msg.Platform, "channel", msg.ChannelID,
@@ -200,6 +201,21 @@ func (b *Bridge) processFrom(ctx context.Context, msg adapter.UnifiedMessage, a
}
}
// 裁决①的第二个流控(主人 2026-10-05):陌生人私聊虽然一律回答,但
// **同一发送者同时最多 1 个在跑的回合**。槽位复用同一个 flowgate 的
// 会话计数(AcquireTurn/ReleaseTurn,键口径与限流一致);已有回合在跑时
// 这一条只登记、不回,并留可见告警 —— 一个陌生人无法用并发消息占满循环。
if isStrangerPrivate(sess) {
if gate := b.gateFor(a); gate != nil {
if !gate.AcquireTurn(msg) {
b.registerThrottled(ctx, msg, actor, sess, a,
"stranger private: a turn is already running for this sender")
return
}
defer gate.ReleaseTurn(msg)
}
}
b.runInboundTurn(ctx, msg, a, text, actor, sess)
}
@@ -397,7 +413,8 @@ func deviceIDFor(msg adapter.UnifiedMessage) string {
// RoleUser 消息**上 —— 用户内容只进用户角色消息是项目红线。
//
// 历史转录**不在本函数里**:它由 seedTurnTranscript(transcript.go)在本函数返回之后
// 前置到列表最前面,与网关编排器的播种同序(转录在前、本次消息在后)。因此本函数只负责
// 插入到列表中(主人私聊前置在最前;受限车道插在**最前的系统框架行之后**,
// 于是来源行 + 第三方边界仍在最前、转录居中、当次消息在最后)。因此本函数只负责
// **本回合自己的**消息(来源行 + 当次用户消息),受限车道的形状在有无转录时保持一致。
func (b *Bridge) buildLoopMessages(_ context.Context, text string, env loop.SessionEnv, actor adapter.Actor, senderName string, src adapter.UnifiedMessage) []model.Message {
// design/12 §6 策略一补充:经传入前审查放行的系统通知走「限定注入」——只把剥离后的
+65 -33
View File
@@ -11,15 +11,17 @@ import (
)
// 本文件承载渠道桥接器面向公开平台的入站「最小打扰」分级
// (DevDocs/design/12 §6 策略一/二/五)。非主人的群聊/陌生人
// 消息在进入完整 agent 循环前先经廉价判断,使繁忙的
// 公开群不会把每条消息都扔进昂贵的 LLM 轮次。这些逻辑
// 放在这里是为了让 channelbridge.go 保持在模块体积上限内;它绝不
// 读取消息内容来判定身份——身份早已由
// (DevDocs/design/12 §6 策略一/二)。非主人的**群聊**消息在进入完整
// agent 循环前先经廉价判断,使繁忙的公开群不会把每条消息都扔进
// 昂贵的 LLM 轮次;**陌生人私聊**则一律正常聊天(主人 2026-10-05 裁决①),
// 只受强制流控约束(裁决③)。这些逻辑放在这里是为了让 channelbridge.go
// 保持在模块体积上限内;它绝不读取消息内容来判定身份——身份早已由
// 会话/actor 层结构化解析完毕(项目红线)。
// 安静时段默认值,与 backthink 服务保持一致,使桥接器与
// 后台思考器共用同一个免打扰窗口(design/12 §6 策略五)。
// 后台思考器共用同一个免打扰窗口(design/12 §6 策略五)。它在本车道里
// **只作为信号**随 inQuiet 传给参与度引擎/轻量判定器,不再让规则层静默
// (主人 2026-10-05 裁决②)。
const (
defaultQuietStartHour = 23
defaultQuietEndHour = 7
@@ -32,11 +34,10 @@ type lightVerdict int
const (
// verdictRespond 表示规则信号显示该消息值得回复。
verdictRespond lightVerdict = iota
// verdictSilent 表示规则信号显示该消息应登记但不应
// 回复(例如安静时段内没有强信号)。
verdictSilent
// verdictInconclusive 表示规则无法判断;交给 LightJudge(未装配
// 时则失败关闭为静默)。
// verdictInconclusive 表示规则无法判断;交给参与度引擎(design/18)
// 或注入式 LightJudge(未装配时则失败关闭为静默)。安静时段**不再**
// 单独构成静默(主人 2026-10-05 裁决②)——它只作为信号随 inQuiet
// 传给下游判定器,让轻思考/参与度自己权衡要不要回。
verdictInconclusive
)
@@ -130,12 +131,14 @@ func hasHighValueKeyword(text string) bool {
}
// ruleLightJudge 只依据廉价规则信号判断是否值得回复
// (设计/12 §6 策略二/五)。优先级:直接点名、提及主人的显示
// 名、高价值关键词,其次是安静时段偏向静默;否则
// 结论为无结论(交给注入的 LightJudge,没有则静默)。它
// 只读取已解析的 actor 元数据与消息内容——绝不推断
// 身份。
func (b *Bridge) ruleLightJudge(msg adapter.UnifiedMessage, actor adapter.Actor, inQuiet bool) lightVerdict {
// (设计/12 §6 策略二)。优先级:直接点名、提及主人的显示
// 名、高价值关键词;除此之外一律无结论(交给参与度引擎 /
// 注入的 LightJudge,没有则失败关闭为静默)。**安静时段不是
// 这里的静默理由**(主人 2026-10-05 裁决②:安静时段由轻思考判断
// 是否回复,不是硬性全静默)——该信号由调用方随 inQuiet 传给
// 下游判定器。它只读取已解析的消息信号(提及/正文)——绝不
// 推断身份(身份由 actor 元数据在会话层解析,红线)。
func (b *Bridge) ruleLightJudge(msg adapter.UnifiedMessage) lightVerdict {
if len(msg.Mentions) > 0 {
return verdictRespond
}
@@ -146,20 +149,26 @@ func (b *Bridge) ruleLightJudge(msg adapter.UnifiedMessage, actor adapter.Actor,
if hasHighValueKeyword(msg.Content) {
return verdictRespond
}
if inQuiet {
return verdictSilent
}
return verdictInconclusive
}
// isStrangerPrivate 报告该会话是否为**陌生人私聊**(非群 + 非主人)。
// 它只读会话层结构化解析出的标志(项目红线:身份不靠正文/提示词)。
// 主人私聊在 onMessage/shouldRespond 之前就已被 actor 门拦下,因此
// 这里成真的只会是陌生人私聊。
func isStrangerPrivate(sess session.Session) bool {
return !sess.IsGroup && !sess.Owner
}
// shouldRespond 实现非主人入站消息的最小打扰分级
// (设计/12 §6 策略一/二/五 + design/18 §2/§4/§5)。它返回该消息
// (设计/12 §6 策略一/二 + design/18 §2/§4/§5)。它返回该消息
// 应驱动完整 agent 循环并回复,还是只登记并静默。
// 主人消息绝不会到这里(processFrom 以 !actor.IsOwner 为门),
// 因此主人私聊与主人在群里的 @/高价值回复不受影响。sess 是
// 该消息解析出的隔离群聊/陌生人会话绑定,使参与度
// 引擎探测时能读到群的近期上下文。
func (b *Bridge) shouldRespond(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor, sess session.Session) (bool, string) {
// 引擎探测时能读到群的近期上下文;a 是收到该消息的账号适配器,
// 用于取该账号的流控闸门(陌生人私聊的强制限流)。
func (b *Bridge) shouldRespond(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor, sess session.Session, a adapter.PlatformAdapter) (bool, string) {
// 策略一:平台/系统噪声 → 只登记,绝不进完整循环。
if isLowValueEvent(actor) {
b.log.Debug("non-owner low-value system event, registered only",
@@ -167,18 +176,29 @@ func (b *Bridge) shouldRespond(ctx context.Context, msg adapter.UnifiedMessage,
return false, "low-value system event; registered only"
}
// 策略二/五:基于规则的轻量判定,感知安静时段。直接 @、
// 提及主人显示名或高价值关键词一律回复;安静时段
// 偏向静默。只有无结论的剩余部分才咨询
// 参与度引擎。
// 裁决①(主人 2026-10-05):**陌生人私聊 = 正常聊天** —— 不设安静期、
// 不走「值不值得回」的概率门,直接进入完整回合。唯一的闸门是强制
// 流控:同一发送者每分钟 ≤ FlowDirectDefault 条(AllowDirect 记账)
// 与同时最多 1 个在跑的回合(processFrom 用 AcquireTurn 施加)。
// 被限流时**不回**:调用方会把它登记进隔离会话,这里留一条带
// source 的可见告警(绝不静默)。
if isStrangerPrivate(sess) {
if g := b.gateFor(a); g != nil && !g.AllowDirect(msg) {
b.log.Warn("stranger private chat rate limited, registered only",
"platform", msg.Platform, "channel", msg.ChannelID, "sender", msg.SenderID,
"limit_per_min", adapter.FlowDirectDefault)
return false, "stranger private: over the per-sender flow limit"
}
return true, "stranger private chat: normal reply"
}
// 策略二/五:基于规则的轻量判定。直接 @、提及主人显示名或
// 高价值关键词一律直接回;规则得不出结论的部分交给参与度
// 引擎 / 轻量判定器。**安静时段只作为信号**(inQuiet)继续往下传,
// 规则层不再因为它静默(裁决②)。
inQuiet := b.inQuietHours(b.now())
switch b.ruleLightJudge(msg, actor, inQuiet) {
case verdictRespond:
if b.ruleLightJudge(msg) == verdictRespond {
return true, "rule signal: worth responding"
case verdictSilent:
b.log.Debug("non-owner message silenced (quiet/no signal), registered only",
"platform", msg.Platform, "channel", msg.ChannelID, "quiet", inQuiet, "actor_role", actor.Role)
return false, "rule signal: not worth responding"
}
// 廉价规则得不出结论。参与度引擎(design/18)已配置时
@@ -245,6 +265,18 @@ func (b *Bridge) registerSilenced(ctx context.Context, msg adapter.UnifiedMessag
"platform", msg.Platform, "channel", msg.ChannelID, "session", sess.SessionID)
}
// registerThrottled 是一次被强制流控(同一发送者已有回合在跑)拦下的
// 非主人消息的落点:先走最小打扰的登记路径(registerSilenced)把它写进
// 其隔离会话,再留一条**带 source 的可见告警** —— 被限流绝不静默
// (主人 2026-10-05 裁决③)。限流路径与普通静默因此区分得开:前者
// 一定有可见日志,后者只有 debug。
func (b *Bridge) registerThrottled(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor, sess session.Session, a adapter.PlatformAdapter, reason string) {
b.registerSilenced(ctx, msg, actor, sess, a)
b.log.Warn("inbound message throttled by flow gate, registered only",
"platform", msg.Platform, "channel", msg.ChannelID,
"sender", msg.SenderID, "reason", reason)
}
// inQuietHours 报告 at 是否落在配置的免打扰窗口内
// (与 backthink 语义一致:两个非零且相等的时刻关闭该窗口,
// 跨午夜的窗口也能正确处理)。它是 Bridge 上的纯方法,测试
+75 -33
View File
@@ -14,15 +14,18 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
)
// stubLightJudge 是可配置的 LightJudge,记录自己被咨询了多少次。
// stubLightJudge 是可配置的 LightJudge,记录自己被咨询了多少次,
// 并保留最后一次收到的输入(用于断言 inQuiet 真值确实传到了下游判定器)。
type stubLightJudge struct {
resp LightDecision
err error
calls int
last LightInput
}
func (s *stubLightJudge) Judge(context.Context, LightInput) (LightDecision, error) {
func (s *stubLightJudge) Judge(_ context.Context, in LightInput) (LightDecision, error) {
s.calls++
s.last = in
return s.resp, s.err
}
@@ -127,40 +130,19 @@ func TestNonOwnerHighValueKeywordResponds(t *testing.T) {
}
}
func TestNonOwnerQuietHoursSilenced(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "不该回"}}
sess := &stubSessions{sess: nonOwnerSession()}
hist := &captureHistory{}
b, err := New(Options{
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, History: hist,
QuietStartHour: 23, QuietEndHour: 7,
Now: func() time.Time { return time.Date(2026, 1, 1, 2, 0, 0, 0, time.UTC) },
})
if err != nil {
t.Fatalf("New: %v", err)
}
// 策略五:安静时段内无信号的非主人消息被静默——
// 只登记、不回复。
b.process(context.Background(), nonOwnerGroupMsg("hello all"))
if chat.sessionID != "" || len(chat.messages) != 0 {
t.Fatalf("loop ran during quiet hours for a no-signal message: session=%q messages=%+v", chat.sessionID, chat.messages)
}
if len(hist.records()) != 1 {
t.Fatalf("records = %d, want 1 (registered, no reply)", len(hist.records()))
}
}
func TestNonOwnerQuietHoursMentionResponds(t *testing.T) {
// TestNonOwnerQuietHoursRulesDoNotSilenceGroup 钉住裁决②(主人 2026-10-05):
// **安静时段不再是规则层的硬静默**——群里没被 @、无关键词的普通消息在 02:00
// 仍会走到轻思考(LightJudge),由它决定回不回,而不是连判定器都不咨询就被
// 静默。变异验证:把 `if inQuiet { return verdictSilent }` 加回 ruleLightJudge,
// 本用例立刻变红(judge.calls 会是 0、循环不会跑)。
func TestNonOwnerQuietHoursRulesDoNotSilenceGroup(t *testing.T) {
t.Parallel()
judge := &stubLightJudge{resp: LightDecision{Respond: true, Reason: "夜里这场值得聊"}}
chat := &stubChatter{res: &loop.Result{Text: "在的"}}
sess := &stubSessions{sess: nonOwnerSession()}
b, err := New(Options{
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{},
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, Light: judge,
QuietStartHour: 23, QuietEndHour: 7,
Now: func() time.Time { return time.Date(2026, 1, 1, 2, 0, 0, 0, time.UTC) },
})
@@ -168,12 +150,72 @@ func TestNonOwnerQuietHoursMentionResponds(t *testing.T) {
t.Fatalf("New: %v", err)
}
// 策略五不是硬性静默:被直接点名的消息在安静时段
// 仍会得到回复。
// 无 @、无关键词、无主人显示名的群消息,落在安静时段内。
b.process(context.Background(), nonOwnerGroupMsg("hello all"))
if judge.calls != 1 {
t.Fatalf("light judge calls = %d, want 1(安静时段不得在规则层硬静默,应交给轻思考)", judge.calls)
}
// 安静时段作为**信号**必须是真的(而非硬编码 false)传到下游判定器。
if !judge.last.InQuietHours {
t.Fatalf("light judge InQuietHours = false, want true at 02:00(安静时段信号失真)")
}
if chat.sessionID != "sess-g" {
t.Fatalf("loop session = %q, want sess-g(轻思考说回就该回)", chat.sessionID)
}
}
// TestNonOwnerQuietHoursMentionResponds 保留「安静时段不是硬性全静默」的正面口径:
// 被直接点名的群消息在 02:00 仍直接回,且**根本不咨询轻思考**(规则已能给结论)。
func TestNonOwnerQuietHoursMentionResponds(t *testing.T) {
t.Parallel()
judge := &stubLightJudge{resp: LightDecision{Respond: false, Reason: "不该被问到"}}
chat := &stubChatter{res: &loop.Result{Text: "在的"}}
sess := &stubSessions{sess: nonOwnerSession()}
b, err := New(Options{
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, Light: judge,
QuietStartHour: 23, QuietEndHour: 7,
Now: func() time.Time { return time.Date(2026, 1, 1, 2, 0, 0, 0, time.UTC) },
})
if err != nil {
t.Fatalf("New: %v", err)
}
// 策略二:被直接点名的消息在安静时段仍会得到回复。
b.process(context.Background(), nonOwnerGroupMsg("在吗", adapter.Mention{UserID: "bot"}))
if chat.sessionID != "sess-g" {
t.Fatalf("loop session = %q, want sess-g (mention should break quiet-hours silence)", chat.sessionID)
}
if judge.calls != 0 {
t.Fatalf("light judge calls = %d, want 0(@ 是规则可直接判定的强信号)", judge.calls)
}
}
// TestNonOwnerQuietHoursKeywordResponds 补齐另一条强信号:命中高价值关键词的群消息
// 在 02:00 也直接回,同样不咨询轻思考。
func TestNonOwnerQuietHoursKeywordResponds(t *testing.T) {
t.Parallel()
judge := &stubLightJudge{resp: LightDecision{Respond: false, Reason: "不该被问到"}}
chat := &stubChatter{res: &loop.Result{Text: "我看看"}}
sess := &stubSessions{sess: nonOwnerSession()}
b, err := New(Options{
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, Light: judge,
QuietStartHour: 23, QuietEndHour: 7,
Now: func() time.Time { return time.Date(2026, 1, 1, 2, 0, 0, 0, time.UTC) },
})
if err != nil {
t.Fatalf("New: %v", err)
}
b.process(context.Background(), nonOwnerGroupMsg("有人在吗,紧急求助"))
if chat.sessionID != "sess-g" {
t.Fatalf("loop session = %q, want sess-g (keyword should break quiet-hours silence)", chat.sessionID)
}
if judge.calls != 0 {
t.Fatalf("light judge calls = %d, want 0(高价值关键词是规则可直接判定的强信号)", judge.calls)
}
}
func TestOwnerUnaffectedInQuietHours(t *testing.T) {
@@ -0,0 +1,203 @@
package channelbridge
import (
"bytes"
"context"
"log/slog"
"strings"
"testing"
"time"
"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/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
)
// 本文件覆盖主人 2026-10-05 裁决①②③的三条口径:
// - 陌生人私聊 = 正常聊天:非群 + 非主人一律直接进入完整回合,任何时刻(含安静时段
// 23:30)都不受安静期或概率门影响;
// - 安静时段的硬静默已从规则层摘除(断言在 light_test.go);
// - 陌生人**必须有流控**:同一发送者每分钟 ≤ FlowDirectDefault 条、同时最多 1 个在跑的
// 回合;触发时只登记 + 一条带 source 的可见告警,绝不静默。
// strangerPrivateSession 是陌生人私聊的隔离会话绑定:非群、非主人、记忆只读。
func strangerPrivateSession() session.Session {
return session.Session{SessionID: "sess-s", IsGroup: false, Owner: false, MemWritable: false}
}
// strangerDirectMsg 构造一条来自陌生人(非主人,由 owner 白名单结构化判定)的私聊消息。
// 私聊的会话键是 (platform, channel, sender),因此换一个 sender 就换一个限流桶。
func strangerDirectMsg(sender, text string) adapter.UnifiedMessage {
return adapter.UnifiedMessage{
Platform: "telegram",
ChannelID: "100",
ChannelType: adapter.ChannelDirect,
SenderID: sender,
SenderName: "陌生人" + sender,
Sender: adapter.Actor{Role: adapter.RoleMember},
Content: text,
ContentType: adapter.ContentText,
}
}
// newStrangerBridge 构建一条接好 telegram 适配器的陌生人私聊测试桥接器。
func newStrangerBridge(t *testing.T, chat *stubChatter, hist *captureHistory, at time.Time, log *slog.Logger) (*Bridge, *stubSessions, *fakeAdapter) {
t.Helper()
sess := &stubSessions{sess: strangerPrivateSession()}
ad := &fakeAdapter{name: "telegram"}
b, err := New(Options{
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, History: hist, Log: log,
QuietStartHour: 23, QuietEndHour: 7,
Now: func() time.Time { return at },
})
if err != nil {
t.Fatalf("New: %v", err)
}
b.Add(ad)
return b, sess, ad
}
// TestStrangerPrivateChatRespondsIncludingQuietHours 钉住裁决①:陌生人私聊(非群 + 非主人)
// 在任何时刻都是正常聊天 —— 白天如此,安静时段 23:30 也如此(安静时段不设在她与陌生人之间)。
// 变异验证:把「陌生人跳过概率门」那段摘掉,本用例立刻变红(shouldRespond 会落到规则/参与度
// 链上,无信号 ⇒ 静默、循环不跑)。
func TestStrangerPrivateChatRespondsIncludingQuietHours(t *testing.T) {
t.Parallel()
cases := []struct {
name string
at time.Time
}{
{"daytime", time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)},
{"quiet hours 23:30", time.Date(2026, 1, 1, 23, 30, 0, 0, time.UTC)},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "你好呀"}}
b, sess, ad := newStrangerBridge(t, chat, &captureHistory{}, tc.at, nil)
msg := strangerDirectMsg("777", "在吗,普通的一句话")
actor := session.ResolveActor(msg, b.owner)
if ok, reason := b.shouldRespond(context.Background(), msg, actor, sess.sess, ad); !ok {
t.Fatalf("shouldRespond = false (%s), want true(陌生人私聊一律正常聊天)", reason)
}
b.process(context.Background(), msg)
if chat.sessionID != "sess-s" {
t.Fatalf("loop session = %q, want the isolated sess-s", chat.sessionID)
}
if len(ad.sends) != 1 || ad.sends[0].Content != "你好呀" {
t.Fatalf("stranger replies = %+v, want one text reply", ad.sends)
}
})
}
}
// TestStrangerPrivateFlowControlPerSender 钉住裁决③:陌生人私聊有强制流控。
// 同一发送者在额度内连发都会被回答;超出 FlowDirectDefault 的第 N+1 条**不回**、
// 只登记,并留一条**带 source 的可见告警**;另一条发送者的额度完全独立、不受挤占。
func TestStrangerPrivateFlowControlPerSender(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "嗯"}}
hist := &captureHistory{}
var logBuf bytes.Buffer
log := slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelDebug}))
at := time.Date(2026, 1, 1, 23, 30, 0, 0, time.UTC)
b, sess, ad := newStrangerBridge(t, chat, hist, at, log)
for i := 0; i < adapter.FlowDirectDefault; i++ {
chat.sessionID = ""
b.process(context.Background(), strangerDirectMsg("777", "第 "+itoa(i)+" 句"))
if chat.sessionID != "sess-s" {
t.Fatalf("额度内第 %d 条被拒(session=%q)", i+1, chat.sessionID)
}
}
// 第 N+1 条超限:不回,只登记,且有可见日志。
before := len(hist.records())
chat.sessionID = ""
b.process(context.Background(), strangerDirectMsg("777", "刷屏第 N+1 句"))
if chat.sessionID != "" {
t.Fatalf("超限消息驱动了循环:session=%q", chat.sessionID)
}
if got := len(hist.records()) - before; got != 1 {
t.Fatalf("超限消息新增登记 %d 条,want 1(只登记不回)", got)
}
logged := logBuf.String()
if !strings.Contains(logged, "stranger private chat rate limited") ||
!strings.Contains(logged, "source=services/channelbridge") {
t.Fatalf("被限流没有留下带 source 的可见日志:%q", logged)
}
// 另一条发送者:额度独立,第一条照常回答。
other := strangerDirectMsg("888", "我是另一个人")
if ok, reason := b.shouldRespond(context.Background(), other, session.ResolveActor(other, b.owner), sess.sess, ad); !ok {
t.Fatalf("另一个发送者被同一额度挤占:reason=%s", reason)
}
}
// TestStrangerPrivateSecondTurnRegisteredWhileOneRuns 钉住裁决③的后半句:
// 「同一发送者同时最多 1 个在跑的回合」。先用 AcquireTurn 占住该发送者的槽位(模拟
// 一个回合正在跑),再让下一条消息过门 —— 它必须只登记、不回,并留可见告警。
func TestStrangerPrivateSecondTurnRegisteredWhileOneRuns(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "嗯"}}
hist := &captureHistory{}
var logBuf bytes.Buffer
log := slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelDebug}))
b, _, ad := newStrangerBridge(t, chat, hist, time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC), log)
msg := strangerDirectMsg("777", "在吗")
gate := b.gateFor(ad)
if gate == nil || !gate.AcquireTurn(msg) {
t.Fatal("占位失败:测试前置条件不成立")
}
t.Cleanup(func() { gate.ReleaseTurn(msg) })
b.process(context.Background(), msg)
if chat.sessionID != "" {
t.Fatalf("已有回合在跑时仍开了第二个回合:session=%q", chat.sessionID)
}
if got := len(hist.records()); got != 1 {
t.Fatalf("被并发拦下的消息登记 %d 条,want 1", got)
}
logged := logBuf.String()
if !strings.Contains(logged, "throttled by flow gate") ||
!strings.Contains(logged, "source=services/channelbridge") {
t.Fatalf("并发拦下没有留下带 source 的可见日志:%q", logged)
}
}
// TestNonOwnerLowValueSystemEventRegisteredOnly 钉住既有行为不变:系统/合成发送者的
// 低价值事件只登记、绝不驱动完整循环(design/12 §6 策略一)。
func TestNonOwnerLowValueSystemEventRegisteredOnly(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "不该回"}}
sess := &stubSessions{sess: nonOwnerSession()}
hist := &captureHistory{}
b, err := New(Options{
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, History: hist,
Now: func() time.Time { return time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) },
})
if err != nil {
t.Fatalf("New: %v", err)
}
msg := nonOwnerGroupMsg("群成员变更通知")
msg.Sender.Role = adapter.RoleSystem
b.process(context.Background(), msg)
if chat.sessionID != "" || len(chat.messages) != 0 {
t.Fatalf("低价值系统事件驱动了循环:session=%q messages=%+v", chat.sessionID, chat.messages)
}
if got := len(hist.records()); got != 1 {
t.Fatalf("records = %d, want 1(只登记,不回复)", got)
}
}
+32 -3
View File
@@ -22,7 +22,9 @@ import (
// 看到的历史逐条一致 —— 她不会像第一次见到主人(主人 2026-10-05 要求两条车道环境一致)。
// - **群聊 / 陌生人(受限车道)**:播种**该隔离会话自己**(`sess-*`)的最近转录,因此群里
// 前几句、以及被最小打扰静默掉而只登记不回的那几条(light.go 的 registerSilenced 已把它们
// 落进同一会话)都在她的上下文里,她据此才能理解一段连贯对话的意图。
// 落进同一会话)都在她的上下文里,她据此才能理解一段连贯对话的意图。**顺序为
// frame 先行**:来源行 + 第三方身份边界在最前、转录居中、当次消息在最后
// (主人 2026-10-05 裁定,见 seedTurnTranscript)。
//
// 两条车道读的会话不同,重建逻辑与条数上限却是同一份:internal/runtime/transcript
// (ContextCount / Read / Message)—— 与网关同一份实现、同一个常量,绝不另写第二套
@@ -51,6 +53,13 @@ func turnEnv(sess session.Session, target inboundTarget, personaID string) loop.
// - 主人私聊 → 主会话(`web`)的转录,与 Web 主会话逐条一致;
// - 群聊 / 陌生人 → 该隔离会话(`sess-*`)的转录,含被静默登记的群成员发言。
//
// **受限车道的顺序是「frame 先行」**(主人 2026-10-05 裁定):来源系统行 +
// 第三方身份边界必须排在**最前**,转录居中,当次用户消息在最后——即
// `[边界/来源系统行, …转录…, 当次用户消息]`。理由是提示词注入面:若把转录
// 前置在边界之前,群友的历史发言会先于「这是群、这些人是第三方」进入她的
// 上下文。主人私聊车道**顺序逐字不变**(转录最前、来源行、当次消息),因为
// 那条车道的上下文隔离本就不同。
//
// 时刻与网关一致:调用点在本回合用户消息落库**之前**(runInboundTurn 里装配先于
// persistToMain / persistIsolated),因此当次消息绝不会在上下文里出现两次。
//
@@ -69,7 +78,27 @@ func (b *Bridge) seedTurnTranscript(ctx context.Context, target inboundTarget, m
if len(seed) == 0 {
return msgs
}
out := make([]model.Message, 0, len(seed)+len(msgs))
// 主人私聊:转录前置在最前(既有顺序,逐字不变)。
if target.private {
out := make([]model.Message, 0, len(seed)+len(msgs))
out = append(out, seed...)
return append(out, msgs...)
}
return insertSeedAfterFrame(msgs, seed)
}
// insertSeedAfterFrame 把受限车道的转录插在**最前面的系统框架行之后**
// (来源行 + 第三方身份边界仍在最前,frame 先行),当次用户消息保持在最后:
// `[系统框架行, …转录…, 当次用户消息]`。受限车道的本回合消息恰好是
// 「框架系统行 + 当次用户消息」,因此这一插入位置等价于「边界最前、历史居中、
// 当次最后」,且**当次消息绝不重复**。
func insertSeedAfterFrame(msgs, seed []model.Message) []model.Message {
frame := 0
for frame < len(msgs) && msgs[frame].Role == model.RoleSystem {
frame++
}
out := make([]model.Message, 0, len(msgs)+len(seed))
out = append(out, msgs[:frame]...)
out = append(out, seed...)
return append(out, msgs...)
return append(out, msgs[frame:]...)
}
@@ -249,27 +249,29 @@ func TestGroupTurnSeedsItsOwnSessionTranscript(t *testing.T) {
b.process(context.Background(), groupMessage("9", adapter.Actor{Role: adapter.RoleMember}))
// 形状:该群最近 3 条转录(最旧优先)+ 第三方边界/来源系统行 + 当次用户消息。
// 形状(frame 先行):第三方边界/来源系统行**在最前**,该群最近 3 条转录居中
// (最旧优先),当次用户消息在最后。
if len(chat.msgs) != 5 {
t.Fatalf("loop messages = %d, want 5 (transcript 3 + boundary + user): %+v", len(chat.msgs), chat.msgs)
t.Fatalf("loop messages = %d, want 5 (boundary + transcript 3 + user): %+v", len(chat.msgs), chat.msgs)
}
// 边界行必须最先:历史里的陌生人发言绝不能在她还不知道「这是群、这些人是外人」
// 之前进入上下文(提示词注入面,主人 2026-10-05 裁定)。
if got := chat.msgs[0]; got.Role != model.RoleSystem || !strings.Contains(turnMessageText(got), thirdPartyBoundaryPrompt) ||
!strings.Contains(turnMessageText(got), "群「Gang」") {
t.Fatalf("messages[0] = %+v, want the origin + third-party boundary system row first", got)
}
// 说话人归属逐条正确:两个不同说话人各归各,且走结构化字段(不是织进正文的标签)。
if got := chat.msgs[0]; got.Role != model.RoleUser || turnMessageText(got) != "晚上要不要开黑" || got.SenderName != "张三" {
t.Fatalf("messages[0] = %+v, want 张三's earlier line attributed to 张三", got)
if got := chat.msgs[1]; got.Role != model.RoleUser || turnMessageText(got) != "晚上要不要开黑" || got.SenderName != "张三" {
t.Fatalf("messages[1] = %+v, want 张三's earlier line attributed to 张三", got)
}
if got := chat.msgs[1]; got.Role != model.RoleUser || turnMessageText(got) != "我还在加班" || got.SenderName != "李四" {
t.Fatalf("messages[1] = %+v, want 李四's silenced line attributed to 李四", got)
if got := chat.msgs[2]; got.Role != model.RoleUser || turnMessageText(got) != "我还在加班" || got.SenderName != "李四" {
t.Fatalf("messages[2] = %+v, want 李四's silenced line attributed to 李四", got)
}
if got := chat.msgs[2]; got.Role != model.RoleAssistant || turnMessageText(got) != "那你先忙" || got.SenderName != "cyrene" {
t.Fatalf("messages[2] = %+v, want her earlier reply attributed to cyrene", got)
}
// 第三方身份边界与来源行照旧(内容一个字没变,仍在每轮注入)。
if got := chat.msgs[3]; got.Role != model.RoleSystem || !strings.Contains(turnMessageText(got), thirdPartyBoundaryPrompt) ||
!strings.Contains(turnMessageText(got), "群「Gang」") {
t.Fatalf("messages[3] = %+v, want the origin + third-party boundary system row", got)
if got := chat.msgs[3]; got.Role != model.RoleAssistant || turnMessageText(got) != "那你先忙" || got.SenderName != "cyrene" {
t.Fatalf("messages[3] = %+v, want her earlier reply attributed to cyrene", got)
}
if got := chat.msgs[4]; got.Role != model.RoleUser || turnMessageText(got) != "hello all" || got.SenderName != "张三" {
t.Fatalf("messages[4] = %+v, want this turn's user message", got)
t.Fatalf("messages[4] = %+v, want this turn's user message last", got)
}
// 当次消息只出现一次:转录是在本回合消息落库**之前**读的。活夹具把已经落库的行也返回,
// 因此播种时刻一旦挪到落库之后,这里立刻数出两次。
@@ -430,10 +432,18 @@ func TestOwnerGroupTurnSeedsOnlyItsOwnIsolatedTranscript(t *testing.T) {
t.Fatalf("loop session id = %q, want the isolated sess-g", chat.sessionID)
}
if len(chat.msgs) != 3 {
t.Fatalf("loop messages = %d, want 3 (isolated transcript + origin/boundary + user): %+v", len(chat.msgs), chat.msgs)
t.Fatalf("loop messages = %d, want 3 (boundary + isolated transcript + user): %+v", len(chat.msgs), chat.msgs)
}
if got := chat.msgs[0]; turnMessageText(got) != "群里的上一句" || got.SenderName != "张三" {
t.Fatalf("messages[0] = %+v, want the group's own earlier line", got)
// frame 先行:主人本人在群里同样是受限(群聊)车道 —— 边界/来源行仍在最前,
// 转录居中,当次消息在最后。
if got := chat.msgs[0]; got.Role != model.RoleSystem || !strings.Contains(turnMessageText(got), thirdPartyBoundaryPrompt) {
t.Fatalf("messages[0] = %+v, want the boundary/origin system row first", got)
}
if got := chat.msgs[1]; turnMessageText(got) != "群里的上一句" || got.SenderName != "张三" {
t.Fatalf("messages[1] = %+v, want the group's own earlier line", got)
}
if got := chat.msgs[2]; got.Role != model.RoleUser {
t.Fatalf("messages[2] = %+v, want this turn's user message last", got)
}
for i, m := range chat.msgs {
if strings.Contains(turnMessageText(m), "主人的私事") {