feat(engage): 群聊自主参与度引擎核心(design/18 P2a+P2b)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- persona 加 triggerWords(昔涟/Cyrene/有瑕的追忆/追忆);Loader 解析
- light.go: shouldRespond 拆分 legacy 与 engage 路径;LightInput/LightDecision 扩展
- engage.go(新): EngageConfig + IDLE/PROBING/ACTIVE/COOLDOWN 状态机(按 platform:groupID 内存) +
  概率触发(可注入rand) + 角色名提词触发 + 活动期跟随(话题转移/连续无感/窗口) + 负面态度冷避
- engagejudge.go(新): NewLLMLightJudge,一次成本受控轻LLM判 值不值得回(不判身份),
  解析失败/超时 fail-closed 静默
- channelbridge.go: Options(TriggerWords/PersonaPrompt/Engage/EngageFor/EngageRand) + 字段初始化
- wire.go: 接线 Light+TriggerWords+PersonaPrompt+Engage(默认概率0.10)
- 红线: 未触发/未命中概率绝不调LLM; 身份靠actor元数据; 敏感不落日志; 零新依赖
- 测试: engage_test.go(触发/状态机/冷避/成本受控/JUDGE输入) + persona triggerWords 用例
- 验收: go build/vet/test ./... 全绿
This commit is contained in:
Aska committed 2026-09-07 18:16:55 +08:00
1 parent 2d7a377d74
commit 8eaf81bd0d
9 files changed
+1156 -13

No files matched your search

@@ -16,6 +16,7 @@ import (
"errors"
"fmt"
"log/slog"
"math/rand"
"net/http"
"strings"
"sync"
@@ -101,11 +102,32 @@ type Options struct {
// NewLLMReviewer to wrap a model.Adapter into this signature.
Review adapter.Reviewer
// Light is the optional light-response judge for non-owner group/stranger
// messages (design/12 §6 策略二/五). When nil the bridge uses its built-in
// rule-based judge and fails-closed to silence, so no chat message is sent to an
// LLM just to decide whether to bother the owner. It is only consulted when the
// rule judge is inconclusive.
// messages (design/12 §6 策略二/五 + design/18 §3). When nil the bridge uses its
// built-in rule-based judge and fails-closed to silence, so no chat message is sent
// to an LLM just to decide whether to bother the owner. It is consulted when the
// rule judge is inconclusive and the participation engine (design/18) has either
// been triggered (role-name mention / probability) or is in an ACTIVE window. Use
// NewLLMLightJudge to wrap a model.Adapter into a cost-controlled judge.
Light LightJudge
// TriggerWords are the current persona's role-name reminder words (design/18 §2).
// A group message containing any one routes into participation PROBING even without
// a direct @. They never imply identity (that stays structural). Empty keeps the
// bridge on the legacy design/12 §6 light judgement.
TriggerWords []string
// PersonaPrompt is the current persona's identity/system-prompt block handed to the
// participation judge so it can weigh the role's tone. It carries no owner secret.
PersonaPrompt string
// Engage is the participation-engine config (probability, activity window, cooldown,
// context size). When its Enabled/Probability are zero AND TriggerWords is empty the
// bridge keeps the legacy light judgement and never consults the engine. Use
// DefaultEngageConfig for the design/18 defaults.
Engage EngageConfig
// EngageFor is an optional per-conversation override (design/18 §6 per-group knobs).
// Nil keeps the base Engage config for every conversation (P2a).
EngageFor func(platform, groupID string) *EngageConfig
// EngageRand is the injectable [0,1) source for the probability trigger. Nil uses a
// default math/rand source; tests inject a fixed source for determinism.
EngageRand func() float64
// QuietStartHour and QuietEndHour bound the do-not-disturb window (local clock
// hours), shared with the backthink service so 策略五 reuses the same definition.
// Equal non-zero hours disable the window; both zero pick the backthink defaults
@@ -138,6 +160,17 @@ type Bridge struct {
quietEndHour int
now func() time.Time
// Participation-engine state (design/18): per-conversation in-memory state machine
// + the persona reminder words / engage knobs that gate it, and the injectable
// randomness source for the probability trigger.
triggerWords []string
personaPrompt string
engage EngageConfig
engageFor func(platform, groupID string) *EngageConfig
engageRand func() float64
engageMu sync.Mutex
engageGroups map[string]*engageGroupState
mu sync.RWMutex
adapters map[string]adapter.PlatformAdapter
connected map[string]bool
@@ -182,6 +215,10 @@ func New(opts Options) (*Bridge, error) {
if now == nil {
now = time.Now
}
engageRand := opts.EngageRand
if engageRand == nil {
engageRand = func() float64 { return rand.Float64() }
}
return &Bridge{
log: logger.WithSource(log, "services/channelbridge"),
run: opts.Loop,
@@ -195,6 +232,12 @@ func New(opts Options) (*Bridge, error) {
quietStartHour: qStart,
quietEndHour: qEnd,
now: now,
triggerWords: opts.TriggerWords,
personaPrompt: opts.PersonaPrompt,
engage: opts.Engage,
engageFor: opts.EngageFor,
engageRand: engageRand,
engageGroups: make(map[string]*engageGroupState),
adapters: make(map[string]adapter.PlatformAdapter),
connected: make(map[string]bool),
readCancels: make(map[string]context.CancelFunc),
@@ -496,7 +539,7 @@ func (b *Bridge) processFrom(ctx context.Context, msg adapter.UnifiedMessage, a
// never drives the full agent loop. Owner messages are never gated here, so
// owner-private chat and owner group @/high-value replies are unchanged.
if !actor.IsOwner() {
if ok, reason := b.shouldRespond(ctx, msg, actor); !ok {
if ok, reason := b.shouldRespond(ctx, msg, actor, sess); !ok {
b.registerSilenced(ctx, msg, actor, sess, a)
b.log.Debug("non-owner message silenced (minimal disturbance)",
"platform", msg.Platform, "channel", msg.ChannelID,