feat(engage): 群聊自主参与度引擎核心(design/18 P2a+P2b)
- 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:
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,
|
||||
|
||||
Reference in new issue
Block a user