- 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 ./... 全绿
405 lines
16 KiB
Go
405 lines
16 KiB
Go
package channelbridge
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
|
|
)
|
|
|
|
// This file holds the participation engine for public-platform group/stranger
|
|
// conversations (DevDocs/design/18): a per-conversation state machine
|
|
// (IDLE→PROBING→ACTIVE→COOLDOWN), a cost-controlled trigger gate (role-name mention
|
|
// / probability), an activity window with follow-up review, and cold-avoid when the
|
|
// group voices a negative attitude. It is in-memory per process (a restart resets it)
|
|
// and consults the light LLM judge only on an actual trigger, so no chat message is
|
|
// sent to an LLM just to decide whether to bother someone (a design/18 red line).
|
|
|
|
// EngageConfig carries the design/18 per-conversation participation tuning. P2c will
|
|
// make it per-group / panel-editable; for now DefaultEngageConfig supplies the
|
|
// constants and the EngageFor seam is left nil in the composition root.
|
|
type EngageConfig struct {
|
|
// Enabled is the per-conversation switch (design/18 §6 engageEnabled). When false
|
|
// only the role-name-reminder trigger still probes; the probability path is off.
|
|
Enabled bool
|
|
Probability float64 // 触发③ probability, default 0.10
|
|
ActivityWindow time.Duration // ACTIVE window, default 15m
|
|
ActivityMessages int // ACTIVE window by message count, default 20
|
|
Cooldown time.Duration // COOLDOWN duration, default 30m
|
|
ContextMessages int // how many recent messages the probe feeds the judge
|
|
}
|
|
|
|
// Knob defaults (design/18 §6). Compile-time so no bare magic number appears in the
|
|
// run path (project rule §10).
|
|
const (
|
|
defaultEngageProbability = 0.10
|
|
defaultActivityWindow = 15 * time.Minute
|
|
defaultActivityMessages = 20
|
|
defaultCooldownDuration = 30 * time.Minute
|
|
defaultEngageContext = 12
|
|
// consecutiveNoInterestThreshold is how many follow-up probes in a row return "no
|
|
// interest"/avoid before she exits ACTIVE back to IDLE (design/18 §4). A negative
|
|
// attitude instead parks her in COOLDOWN via the rule list below.
|
|
consecutiveNoInterestThreshold = 2
|
|
)
|
|
|
|
// errNoEngageJudge is returned when the participation engine is configured but no
|
|
// LightJudge was wired, so it fails closed to silence.
|
|
var errNoEngageJudge = errors.New("channelbridge: no light judge wired for engage")
|
|
|
|
// DefaultEngageConfig returns the design/18 knob defaults (P2a base; P2c renders them
|
|
// per-group/panel editable).
|
|
func DefaultEngageConfig() EngageConfig {
|
|
return EngageConfig{
|
|
Enabled: true,
|
|
Probability: defaultEngageProbability,
|
|
ActivityWindow: defaultActivityWindow,
|
|
ActivityMessages: defaultActivityMessages,
|
|
Cooldown: defaultCooldownDuration,
|
|
ContextMessages: defaultEngageContext,
|
|
}
|
|
}
|
|
|
|
// engageState is one phase of the per-conversation state machine (design/18 §1).
|
|
type engageState int
|
|
|
|
const (
|
|
engageIDLE engageState = iota
|
|
engagePROBING // transient: a trigger just routed this message to the judge
|
|
engageACTIVE // she is participating; follow-up review is on
|
|
engageCOOLDOWN // she was asked to be quiet; auto-resumes when the timer ends
|
|
)
|
|
|
|
// engageGroupState is one participation-engine instance, keyed by a group/stranger
|
|
// conversation (design/18 §1). It is in-memory only (a process restart resets it).
|
|
type engageGroupState struct {
|
|
mu sync.Mutex
|
|
state engageState
|
|
lastTopic string
|
|
lastInteract time.Time
|
|
activeUntil time.Time
|
|
msgsInWindow int
|
|
cooldownUntil time.Time
|
|
consecutiveNoInterest int
|
|
}
|
|
|
|
// engageConfigured reports whether the design/18 participation engine is wired for
|
|
// this bridge at all. It is true when the persona carries trigger words or the engage
|
|
// knobs enable the engine; otherwise the bridge keeps the pre-engage legacy light
|
|
// judgement (design/12 §6) untouched.
|
|
func (b *Bridge) engageConfigured() bool {
|
|
return len(b.triggerWords) > 0 || b.engage.Enabled || b.engage.Probability > 0
|
|
}
|
|
|
|
// engageKey returns the per-conversation state key (design/18 §1: 群聊会话 platform:groupID).
|
|
// A stranger direct chat (no group) falls back to platform:senderID so the engine still
|
|
// applies to one-to-one strangers.
|
|
func engageKey(msg adapter.UnifiedMessage, actor adapter.Actor) string {
|
|
if msg.IsGroup() || actor.IsGroupChat() || actor.GroupID != "" {
|
|
g := msg.GroupID
|
|
if g == "" {
|
|
g = actor.GroupID
|
|
}
|
|
return msg.Platform + ":" + g
|
|
}
|
|
return msg.Platform + ":" + firstNonEmptyStr(msg.SenderID, msg.Sender.UserID)
|
|
}
|
|
|
|
// engageState returns the state for a conversation key, creating an IDLE instance on
|
|
// first use (in-memory, safe for concurrent use).
|
|
func (b *Bridge) engageState(key string) *engageGroupState {
|
|
b.engageMu.Lock()
|
|
defer b.engageMu.Unlock()
|
|
st := b.engageGroups[key]
|
|
if st == nil {
|
|
st = &engageGroupState{state: engageIDLE}
|
|
b.engageGroups[key] = st
|
|
}
|
|
return st
|
|
}
|
|
|
|
// engageConfigFor resolves the effective engage config for a message: the per-group
|
|
// override (EngageFor, nil in P2a) when one is wired, else the base config, with any
|
|
// zero field normalized to its default.
|
|
func (b *Bridge) engageConfigFor(msg adapter.UnifiedMessage, actor adapter.Actor) EngageConfig {
|
|
cfg := b.engage
|
|
if b.engageFor != nil {
|
|
g := msg.GroupID
|
|
if g == "" {
|
|
g = actor.GroupID
|
|
}
|
|
if ov := b.engageFor(msg.Platform, g); ov != nil {
|
|
cfg = *ov
|
|
}
|
|
}
|
|
// Normalize unset fields to the sheltered defaults so a trigger-only config still
|
|
// bounds the activity window and cooldown instead of running with zero values.
|
|
if cfg.ActivityWindow <= 0 {
|
|
cfg.ActivityWindow = defaultActivityWindow
|
|
}
|
|
if cfg.ActivityMessages <= 0 {
|
|
cfg.ActivityMessages = defaultActivityMessages
|
|
}
|
|
if cfg.Cooldown <= 0 {
|
|
cfg.Cooldown = defaultCooldownDuration
|
|
}
|
|
if cfg.ContextMessages <= 0 {
|
|
cfg.ContextMessages = defaultEngageContext
|
|
}
|
|
if cfg.Probability <= 0 {
|
|
cfg.Probability = defaultEngageProbability
|
|
}
|
|
return cfg
|
|
}
|
|
|
|
// engageDecision is the design/18 participation decision for an inconclusive (to the
|
|
// cheap rules) non-owner message. It is only reached when engageConfigured() is true.
|
|
// It returns whether the message should drive the full agent loop and reply, plus a
|
|
// short reason for the log. The caller (shouldRespond) holds no lock; the per-key
|
|
// state mutex is acquired here so concurrent messages for one conversation serialize,
|
|
// while different conversations stay independent.
|
|
func (b *Bridge) engageDecision(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor, sess session.Session, inQuiet bool) (bool, string) {
|
|
cfg := b.engageConfigFor(msg, actor)
|
|
key := engageKey(msg, actor)
|
|
st := b.engageState(key)
|
|
st.mu.Lock()
|
|
defer st.mu.Unlock()
|
|
now := b.now()
|
|
|
|
// design/18 §5 cold-avoid first: a group member voicing a negative attitude is a
|
|
// hard "be quiet" signal → COOLDOWN, even mid-engagement. A direct @ / high-value
|
|
// keyword already returned above, so only genuinely "worth a reply by content"
|
|
// (or an owner message, which never reaches here) can override it.
|
|
if b.negativeAttitude(msg.Content) {
|
|
b.enterCooldown(st, cfg, now)
|
|
b.log.Debug("engage cooldown (negative attitude)",
|
|
"group", key, "until", st.cooldownUntil)
|
|
return false, "engage: cooldown (negative attitude)"
|
|
}
|
|
|
|
if st.inCooldown(now) {
|
|
b.log.Debug("engage cooldown active, not responding",
|
|
"group", key, "until", st.cooldownUntil)
|
|
return false, "engage: cooldown active"
|
|
}
|
|
|
|
// ACTIVE follow-up: while the activity window is live she keeps lightly judging
|
|
// the group's continuing discussion and tends to stay engaged (design/18 §4).
|
|
if st.state == engageACTIVE {
|
|
if st.activityExpired(now, cfg) {
|
|
b.toIdle(st)
|
|
b.log.Debug("engage activity window expired, back to idle", "group", key)
|
|
} else {
|
|
return b.engageFollowUp(ctx, msg, actor, sess, key, st, cfg, inQuiet)
|
|
}
|
|
}
|
|
|
|
// IDLE: only a role-name mention or a probability hit routes this message into a
|
|
// cost-controlled PROBING judgment (design/18 §2 触发②/③); otherwise it is
|
|
// registered and silenced with no LLM call (a red line).
|
|
if !b.mentionsTriggerWord(msg.Content) && !b.engageProbabilityHit(cfg) {
|
|
b.log.Debug("engage no trigger, registered only", "group", key)
|
|
return false, "engage: no trigger"
|
|
}
|
|
return b.engageProbe(ctx, msg, actor, sess, key, st, cfg, inQuiet)
|
|
}
|
|
|
|
// engageProbe is one cost-controlled PROBING judgment (design/18 §3): it feeds the
|
|
// group context + persona prompt to the light judge and decides ACTIVE (reply) or
|
|
// IDLE (registered silent). Parsing/LLM failure fails closed to silence.
|
|
func (b *Bridge) engageProbe(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor, sess session.Session, key string, st *engageGroupState, cfg EngageConfig, inQuiet bool) (bool, string) {
|
|
now := b.now()
|
|
st.state = engagePROBING
|
|
d, err := b.judgeEngage(ctx, msg, actor, sess, cfg, inQuiet)
|
|
if err != nil {
|
|
b.toIdle(st)
|
|
b.log.Warn("engage probe failed; silenced", "group", key, "error", err)
|
|
return false, "engage: probe failed; silenced"
|
|
}
|
|
if d.AvoidTopic {
|
|
b.toIdle(st)
|
|
b.log.Debug("engage probe avoid topic, staying idle", "group", key, "reason", 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.toIdle(st)
|
|
b.log.Debug("engage probing → idle, no interest", "group", key, "reason", d.Reason)
|
|
return false, "engage: no interest"
|
|
}
|
|
|
|
// engageFollowUp is the ACTIVE-window follow-up review (design/18 §4): she still
|
|
// lightly judges the discussion but leans toward continuing. A run of no-interest or a
|
|
// finished window drops her out of ACTIVE; a negative attitude was already caught
|
|
// earlier by the cold-avoid rule.
|
|
func (b *Bridge) engageFollowUp(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor, sess session.Session, key string, st *engageGroupState, cfg EngageConfig, inQuiet bool) (bool, string) {
|
|
st.msgsInWindow++
|
|
now := b.now()
|
|
d, err := b.judgeEngage(ctx, msg, actor, sess, cfg, inQuiet)
|
|
if err != nil {
|
|
b.countNoInterest(st)
|
|
b.log.Warn("engage follow-up judge failed; counted as no interest", "group", key, "error", err)
|
|
return b.engageAfterFollowUp(st, cfg, now, key)
|
|
}
|
|
if d.AvoidTopic || (!d.WorthReply && !d.Interesting) {
|
|
b.countNoInterest(st)
|
|
b.log.Debug("engage follow-up no interest / avoid", "group", key, "reason", 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
|
|
}
|
|
|
|
// engageAfterFollowUp resolves the outcome of a no-interest/avoid follow-up probe.
|
|
func (b *Bridge) engageAfterFollowUp(st *engageGroupState, cfg EngageConfig, now time.Time, key string) (bool, string) {
|
|
if st.consecutiveNoInterest >= consecutiveNoInterestThreshold || st.activityExpired(now, cfg) {
|
|
b.toIdle(st)
|
|
b.log.Debug("engage exited active (no interest / window ended)", "group", key)
|
|
return false, "engage: left active"
|
|
}
|
|
return false, "engage: not responding"
|
|
}
|
|
|
|
// countNoInterest bumps the consecutive no-interest counter for a follow-up probe.
|
|
func (b *Bridge) countNoInterest(st *engageGroupState) {
|
|
st.consecutiveNoInterest++
|
|
}
|
|
|
|
// judgeEngage renders the LightInput for a cost-controlled participation judgment and
|
|
// consults the injected LightJudge. It feeds the group's recent context and the
|
|
// persona prompt only here — never on an untriggered message.
|
|
func (b *Bridge) judgeEngage(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor, sess session.Session, cfg EngageConfig, inQuiet bool) (LightDecision, error) {
|
|
if b.light == nil {
|
|
return LightDecision{}, errNoEngageJudge
|
|
}
|
|
return b.light.Judge(ctx, LightInput{
|
|
Msg: msg, Actor: actor, SenderName: senderDisplayName(msg, actor),
|
|
InQuietHours: inQuiet, QuietStart: b.quietStartHour, QuietEnd: b.quietEndHour,
|
|
Engage: true,
|
|
PersonaPrompt: b.personaPrompt,
|
|
TriggerWords: b.triggerWords,
|
|
RecentContext: b.buildEngageContext(ctx, sess, cfg),
|
|
})
|
|
}
|
|
|
|
// buildEngageContext renders the conversation's most recent N messages with sender
|
|
// names, oldest-first, for the engagement judge. It is a no-op (empty string) when no
|
|
// history store is wired or the store has no rows yet, so the judge degrades to a
|
|
// single-trigger-message read.
|
|
func (b *Bridge) buildEngageContext(ctx context.Context, sess session.Session, cfg EngageConfig) string {
|
|
if b.history == nil || cfg.ContextMessages <= 0 {
|
|
return ""
|
|
}
|
|
msgs, err := b.history.Recent(ctx, sess.SessionID, cfg.ContextMessages)
|
|
if err != nil || len(msgs) == 0 {
|
|
return ""
|
|
}
|
|
var sb strings.Builder
|
|
for _, m := range msgs {
|
|
name := m.SenderName
|
|
if name == "" {
|
|
name = "(未标注)"
|
|
}
|
|
role := m.Role
|
|
if role == "" {
|
|
role = "msg"
|
|
}
|
|
sb.WriteString(fmt.Sprintf("%s %s: %s\n", role, name, truncateText(m.Content, 80)))
|
|
}
|
|
if strings.TrimSpace(sb.String()) == "" {
|
|
return ""
|
|
}
|
|
return sb.String()
|
|
}
|
|
|
|
// engageProbabilityHit reports whether this message independently draws the
|
|
// role-name-reminder-free probability trigger (design/18 §2 触发③). It reads the
|
|
// injectable EngagRand source so tests are deterministic; nil randomness keeps the
|
|
// probability path off (fail-closed).
|
|
func (b *Bridge) engageProbabilityHit(cfg EngageConfig) bool {
|
|
if !cfg.Enabled || cfg.Probability <= 0 || b.engageRand == nil {
|
|
return false
|
|
}
|
|
return b.engageRand() < cfg.Probability
|
|
}
|
|
|
|
// negativeAttitude reports whether the group is telling her to be quiet (design/18 §5
|
|
// cold-avoid). It is a cheap substring rule that backs off anything the LLM might miss
|
|
// — being quieter is the safe direction (不打扰为第一原则). Never used for identity.
|
|
func (b *Bridge) negativeAttitude(text string) bool {
|
|
if text == "" {
|
|
return false
|
|
}
|
|
lower := strings.ToLower(text)
|
|
for _, k := range negativeAttitudeKeywords {
|
|
if strings.Contains(lower, k) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// negativeAttitudeKeywords is the cold-avoid rule list (design/18 §5 词表兜底). It is a
|
|
// fallback; the same signal is read by the follow-up review in the judge via avoidTopic.
|
|
var negativeAttitudeKeywords = []string{
|
|
"太吵", "吵死", "闭嘴", "别吵", "别刷屏", "别刷", "刷屏", "不想听",
|
|
"安静点", "少说两句", "别说了", "别bb", "别逼逼", "烦死了", "好烦",
|
|
"打住", "住嘴", "shut up", "too loud", "stop spamming",
|
|
}
|
|
|
|
// contentSnippet shortens a message to a bounded topic token for the state machine.
|
|
func contentSnippet(text string) string {
|
|
runes := []rune(strings.TrimSpace(text))
|
|
if len(runes) <= 40 {
|
|
return string(runes)
|
|
}
|
|
return string(runes[:40]) + "…"
|
|
}
|
|
|
|
func (st *engageGroupState) inCooldown(now time.Time) bool {
|
|
return st.state == engageCOOLDOWN && now.Before(st.cooldownUntil)
|
|
}
|
|
|
|
func (st *engageGroupState) activityExpired(now time.Time, cfg EngageConfig) bool {
|
|
return now.After(st.activeUntil) || st.msgsInWindow >= cfg.ActivityMessages
|
|
}
|
|
|
|
// toIdle resets the conversation back to IDLE (design/18: exit ACTIVE → IDLE).
|
|
func (b *Bridge) toIdle(st *engageGroupState) {
|
|
st.state = engageIDLE
|
|
st.msgsInWindow = 0
|
|
st.consecutiveNoInterest = 0
|
|
st.lastTopic = ""
|
|
}
|
|
|
|
// enterActive opens the activity window (design/18 §4). The caller holds st.mu.
|
|
func (b *Bridge) enterActive(st *engageGroupState, cfg EngageConfig, now time.Time, msg adapter.UnifiedMessage) {
|
|
st.state = engageACTIVE
|
|
st.activeUntil = now.Add(cfg.ActivityWindow)
|
|
st.msgsInWindow = 1
|
|
st.consecutiveNoInterest = 0
|
|
st.lastInteract = now
|
|
st.lastTopic = contentSnippet(msg.Content)
|
|
}
|
|
|
|
// enterCooldown parks the conversation in a quiet period (design/18 §5). The caller
|
|
// holds st.mu.
|
|
func (b *Bridge) enterCooldown(st *engageGroupState, cfg EngageConfig, now time.Time) {
|
|
st.state = engageCOOLDOWN
|
|
st.cooldownUntil = now.Add(cfg.Cooldown)
|
|
st.msgsInWindow = 0
|
|
st.consecutiveNoInterest = 0
|
|
st.lastTopic = ""
|
|
}
|