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
@@ -538,6 +538,14 @@ func (a *app) wireChannels() error {
|
||||
// Optional review model prunes low-value queued messages near the per-adapter
|
||||
// cap (design/12 §6 策略四). Nil adapter → the gate falls back to FIFO evict.
|
||||
Review: reviewFor(a.modelAdapter, logger.WithSource(a.log, "services/channelbridge/review")),
|
||||
// Light is the design/18 §3 participation judge: a cost-controlled light LLM
|
||||
// decision on a triggered group/stranger message (role-name mention / probability
|
||||
// / active window). Only an actual trigger feeds the group context.
|
||||
Light: engageLightFor(a.modelAdapter, logger.WithSource(a.log, "services/channelbridge/engage")),
|
||||
TriggerWords: a.defaultTriggerWords(),
|
||||
PersonaPrompt: a.defaultInstructions(),
|
||||
Engage: channelbridge.DefaultEngageConfig(),
|
||||
// EngageFor is left nil (P2a); per-group overrides arrive with P2c.
|
||||
// Factory lets the panel save hot-replace a platform's adapters without a
|
||||
// restart (design/04 §3.1).
|
||||
Factory: factory,
|
||||
@@ -742,3 +750,24 @@ func reviewFor(llm model.Adapter, log *slog.Logger) adapter.Reviewer {
|
||||
}
|
||||
return channelbridge.NewLLMReviewer(llm, log)
|
||||
}
|
||||
|
||||
// engageLightFor builds the design/18 §3 participation judge, or nil when no model
|
||||
// adapter is available so the bridge's participation engine fails closed toward
|
||||
// silence (a triggered message without a judge is never answered).
|
||||
func engageLightFor(llm model.Adapter, log *slog.Logger) channelbridge.LightJudge {
|
||||
if llm == nil {
|
||||
return nil
|
||||
}
|
||||
return channelbridge.NewLLMLightJudge(llm, log)
|
||||
}
|
||||
|
||||
// defaultTriggerWords returns the default persona's role-name reminder words
|
||||
// (design/18 §2) so the channel bridge knows which group messages cause a PROBING.
|
||||
func (a *app) defaultTriggerWords() []string {
|
||||
if c, ok := a.personas.DefaultCharacter(); ok {
|
||||
if p, ok := a.personas.Persona(c.PersonaID); ok {
|
||||
return append([]string(nil), p.TriggerWords...)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -8,6 +8,14 @@ id: cyrene
|
||||
name: 昔涟
|
||||
characterRef: 崩坏:星穹铁道 — 翁法罗斯(昔涟)
|
||||
|
||||
# 角色名提词:公开平台群聊里提到这些词,即使不@她也会进入「参与度探测」(设计/18 §2 触发②)。
|
||||
# 只用于触发「值不值得参与」的轻思考,绝不做身份判定(身份由 channel 层 actor 元数据决定)。
|
||||
triggerWords:
|
||||
- 昔涟
|
||||
- Cyrene
|
||||
- 有瑕的追忆
|
||||
- 追忆
|
||||
|
||||
identity:
|
||||
# 人设(你是谁)
|
||||
role: >-
|
||||
|
||||
@@ -86,6 +86,11 @@ type Persona struct {
|
||||
Emotion EmotionParams `yaml:"emotion"`
|
||||
Affection AffectionParams `yaml:"affection"`
|
||||
Visual PersonaVisual `yaml:"visual,omitempty"`
|
||||
// TriggerWords are the role-name words that, when a public-platform group
|
||||
// message mentions one, route the message into the participation PROBING
|
||||
// stage even without a direct @ (design/18 §2 触发②). Each persona defines its
|
||||
// own reminder words; they never imply identity (that stays structural).
|
||||
TriggerWords []string `yaml:"triggerWords,omitempty"`
|
||||
}
|
||||
|
||||
// EmotionConfig converts this persona's own fields into the params consumed by
|
||||
|
||||
@@ -79,6 +79,24 @@ func TestLoad_ValidYAML(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_TriggerWords(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// A persona without triggerWords yields an empty slice; with them, the list is
|
||||
// preserved (design/18 §2: per-persona role-name reminder words).
|
||||
yaml := "id: xilian\nname: 昔涟\ncharacterRef: c\nidentity:\n role: r\n systemPrompt: s\n" +
|
||||
"triggerWords:\n - 昔涟\n - Cyrene\n - 有瑕的追忆\n"
|
||||
path := writeFile(t, t.TempDir(), "xilian.yaml", yaml)
|
||||
p, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error: %v", err)
|
||||
}
|
||||
if len(p.TriggerWords) != 3 || p.TriggerWords[0] != "昔涟" ||
|
||||
p.TriggerWords[1] != "Cyrene" || p.TriggerWords[2] != "有瑕的追忆" {
|
||||
t.Fatalf("TriggerWords = %v, want [昔涟 Cyrene 有瑕的追忆]", p.TriggerWords)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_MissingRequiredFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
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 = ""
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
package channelbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
|
||||
)
|
||||
|
||||
// engageScriptJudge is a configurable LightJudge for the participation-engine tests:
|
||||
// it replays an ordered list of decisions (and optional per-call errors) and records
|
||||
// how often it was consulted plus the last input it saw.
|
||||
type engageScriptJudge struct {
|
||||
responses []LightDecision
|
||||
errs []error
|
||||
calls int
|
||||
last LightInput
|
||||
}
|
||||
|
||||
func (s *engageScriptJudge) Judge(_ context.Context, in LightInput) (LightDecision, error) {
|
||||
s.calls++
|
||||
s.last = in
|
||||
i := s.calls - 1
|
||||
if i < len(s.errs) && s.errs[i] != nil {
|
||||
return LightDecision{}, s.errs[i]
|
||||
}
|
||||
if i < len(s.responses) {
|
||||
return s.responses[i], nil
|
||||
}
|
||||
return LightDecision{Interesting: true, WorthReply: true, Reason: "默认参与"}, nil
|
||||
}
|
||||
|
||||
// dayNow returns a daytime (non-quiet) wall-clock source so the engine runs outside
|
||||
// the quiet window.
|
||||
func dayNow() func() time.Time {
|
||||
return func() time.Time { return time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) }
|
||||
}
|
||||
|
||||
// engageTestBridge builds a Bridge wired for the participation engine with the given
|
||||
// judge/config/clock/rand source. A zero-valued config is replaced by the defaults so
|
||||
// callers can override just the knob they care about.
|
||||
func engageTestBridge(t *testing.T, judge LightJudge, cfg EngageConfig, clock func() time.Time, rng func() float64) (*Bridge, *stubChatter, *captureHistory) {
|
||||
t.Helper()
|
||||
chat := &stubChatter{res: &loop.Result{Text: "参与"}}
|
||||
sess := &stubSessions{sess: nonOwnerSession()}
|
||||
hist := &captureHistory{}
|
||||
if cfg.Probability == 0 && cfg.ActivityWindow == 0 && cfg.ActivityMessages == 0 && cfg.Cooldown == 0 {
|
||||
cfg = DefaultEngageConfig()
|
||||
}
|
||||
if rng == nil {
|
||||
rng = func() float64 { return 0.5 } // default: never hit the probability trigger
|
||||
}
|
||||
b, err := New(Options{
|
||||
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, History: hist,
|
||||
Light: judge, TriggerWords: []string{"昔涟", "Cyrene"},
|
||||
PersonaPrompt: "你是昔涟。", Engage: cfg, EngageRand: rng, Now: clock,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
return b, chat, hist
|
||||
}
|
||||
|
||||
// cfgShortWindow is a config with a 1h cooldown/activity and a small 2-message
|
||||
// activity window, so count-based window expiry is easy to drive deterministically.
|
||||
func cfgShortWindow() EngageConfig {
|
||||
cfg := DefaultEngageConfig()
|
||||
cfg.ActivityWindow = time.Hour
|
||||
cfg.ActivityMessages = 2
|
||||
cfg.Cooldown = time.Hour
|
||||
return cfg
|
||||
}
|
||||
|
||||
func TestEngageRoleNameMentionProbesAndResponds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
judge := &engageScriptJudge{}
|
||||
b, chat, _ := engageTestBridge(t, judge, DefaultEngageConfig(), dayNow(), nil)
|
||||
|
||||
// 触发②: naming 昔涟 without an @ routes the message into a cost-controlled PROBING.
|
||||
b.process(context.Background(), nonOwnerGroupMsg("大家在聊昔涟的事"))
|
||||
|
||||
if judge.calls != 1 {
|
||||
t.Fatalf("light judge calls = %d, want 1 (role-name mention → PROBING)", judge.calls)
|
||||
}
|
||||
if !judge.last.Engage {
|
||||
t.Fatal("judge input Engage = false, want true for a participation judgment")
|
||||
}
|
||||
if chat.sessionID != "sess-g" {
|
||||
t.Fatalf("loop session = %q, want sess-g (interested → ACTIVE reply)", chat.sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngageRoleNameMentionNotInterestedSilences(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
judge := &engageScriptJudge{responses: []LightDecision{{Interesting: false, WorthReply: false, Reason: "无感"}}}
|
||||
b, chat, hist := engageTestBridge(t, judge, DefaultEngageConfig(), dayNow(), nil)
|
||||
|
||||
b.process(context.Background(), nonOwnerGroupMsg("提一句昔涟而已"))
|
||||
|
||||
if judge.calls != 1 {
|
||||
t.Fatalf("light judge calls = %d, want 1", judge.calls)
|
||||
}
|
||||
if chat.sessionID != "" {
|
||||
t.Fatalf("loop ran after no-interest, session = %q", chat.sessionID)
|
||||
}
|
||||
// Not interested → registered but not answered.
|
||||
if len(hist.records()) != 1 || hist.records()[0].Content != "提一句昔涟而已" {
|
||||
t.Fatalf("records = %+v, want 1 registered user message", hist.records())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngageAvoidTopicStaysIdle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
judge := &engageScriptJudge{responses: []LightDecision{{AvoidTopic: true, Reason: "敏感话题"}}}
|
||||
b, chat, _ := engageTestBridge(t, judge, DefaultEngageConfig(), dayNow(), nil)
|
||||
|
||||
b.process(context.Background(), nonOwnerGroupMsg("聊昔涟怎么办"))
|
||||
|
||||
if judge.calls != 1 {
|
||||
t.Fatalf("light judge calls = %d, want 1", judge.calls)
|
||||
}
|
||||
if chat.sessionID != "" {
|
||||
t.Fatalf("loop ran after avoidTopic, session = %q", chat.sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngageNoTriggerNoProbabilitySkipsJudge(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Cost-control red line: an untriggered, probability-missing message must NOT call
|
||||
// the light judge at all — no context is fed, no LLM turn, just registered silent.
|
||||
judge := &engageScriptJudge{}
|
||||
b, chat, hist := engageTestBridge(t, judge, DefaultEngageConfig(), dayNow(), nil)
|
||||
|
||||
b.process(context.Background(), nonOwnerGroupMsg("今天天气不错"))
|
||||
|
||||
if judge.calls != 0 {
|
||||
t.Fatalf("light judge calls = %d, want 0 (untriggered must not call LLM)", judge.calls)
|
||||
}
|
||||
if chat.sessionID != "" {
|
||||
t.Fatalf("loop ran without a trigger, session = %q", chat.sessionID)
|
||||
}
|
||||
if len(hist.records()) != 1 {
|
||||
t.Fatalf("records = %d, want 1 (registered only)", len(hist.records()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngageProbabilityTriggerResponds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// 触发③: an injected rand below the probability (0.05 < 0.10) routes the message
|
||||
// into PROBING even with no role-name mention.
|
||||
judge := &engageScriptJudge{}
|
||||
b, chat, _ := engageTestBridge(t, judge, DefaultEngageConfig(), dayNow(), func() float64 { return 0.05 })
|
||||
|
||||
b.process(context.Background(), nonOwnerGroupMsg("有件事想问问"))
|
||||
|
||||
if judge.calls != 1 {
|
||||
t.Fatalf("light judge calls = %d, want 1 (probability hit → PROBING)", judge.calls)
|
||||
}
|
||||
if chat.sessionID != "sess-g" {
|
||||
t.Fatalf("loop session = %q, want sess-g (judge interested → reply)", chat.sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngageProbabilityMissSilences(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
judge := &engageScriptJudge{}
|
||||
b, chat, _ := engageTestBridge(t, judge, DefaultEngageConfig(), dayNow(), func() float64 { return 0.5 })
|
||||
|
||||
b.process(context.Background(), nonOwnerGroupMsg("有件事想问问"))
|
||||
|
||||
if judge.calls != 0 {
|
||||
t.Fatalf("light judge calls = %d, want 0 (probability miss → no LLM)", judge.calls)
|
||||
}
|
||||
if chat.sessionID != "" {
|
||||
t.Fatalf("loop ran on probability miss, session = %q", chat.sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngageStateMachineActivityWindowExpiry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
judge := &engageScriptJudge{responses: []LightDecision{
|
||||
{Interesting: true, WorthReply: true, Reason: "感兴趣"}, // probe → ACTIVE
|
||||
{WorthReply: true, Reason: "跟随"}, // follow-up 1 → still reply
|
||||
}}
|
||||
b, chat, _ := engageTestBridge(t, judge, cfgShortWindow(), dayNow(), nil)
|
||||
|
||||
// msg1: role-name mention → probe → interested → ACTIVE (respond).
|
||||
b.process(context.Background(), nonOwnerGroupMsg("聊昔涟"))
|
||||
if chat.sessionID != "sess-g" || judge.calls != 1 {
|
||||
t.Fatalf("after probe: session=%q calls=%d, want sess-g/1", chat.sessionID, judge.calls)
|
||||
}
|
||||
|
||||
// msg2: inside the window → follow-up review, still replies.
|
||||
b.process(context.Background(), nonOwnerGroupMsg("周末去哪玩"))
|
||||
if chat.sessionID != "sess-g" || judge.calls != 2 {
|
||||
t.Fatalf("after follow-up: session=%q calls=%d, want sess-g/2", chat.sessionID, judge.calls)
|
||||
}
|
||||
|
||||
// msg3: the 2-message window is now full → exit ACTIVE to IDLE. No trigger/probability
|
||||
// (rand 0.5) → silent, and crucially the judge is NOT consulted a third time.
|
||||
b.process(context.Background(), nonOwnerGroupMsg("接着聊"))
|
||||
if judge.calls != 2 {
|
||||
t.Fatalf("judge calls = %d, want 2 (window expired; no follow-up, no re-trigger)", judge.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngageStateMachineFollowUpNoInterestExits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := DefaultEngageConfig()
|
||||
cfg.ActivityWindow = time.Hour
|
||||
cfg.ActivityMessages = 100
|
||||
cfg.Cooldown = time.Hour
|
||||
judge := &engageScriptJudge{responses: []LightDecision{
|
||||
{Interesting: true, WorthReply: true, Reason: "感兴趣"}, // probe → ACTIVE
|
||||
{Interesting: false, WorthReply: false, Reason: "无感"}, // follow-up 1 → no interest
|
||||
{Interesting: false, WorthReply: false, Reason: "无感"}, // follow-up 2 → threshold → exit
|
||||
}}
|
||||
b, chat, _ := engageTestBridge(t, judge, cfg, dayNow(), nil)
|
||||
|
||||
b.process(context.Background(), nonOwnerGroupMsg("聊昔涟"))
|
||||
if judge.calls != 1 || chat.sessionID != "sess-g" {
|
||||
t.Fatalf("probe: calls=%d session=%q, want 1/sess-g", judge.calls, chat.sessionID)
|
||||
}
|
||||
// Two consecutive no-interest follow-ups, then a subsequent untriggered message
|
||||
// must no longer call the judge (she has dropped out of ACTIVE).
|
||||
b.process(context.Background(), nonOwnerGroupMsg("说说别的"))
|
||||
b.process(context.Background(), nonOwnerGroupMsg("再说一点"))
|
||||
if judge.calls != 3 {
|
||||
t.Fatalf("judge calls = %d, want 3 (two no-interest follow-ups)", judge.calls)
|
||||
}
|
||||
b.process(context.Background(), nonOwnerGroupMsg("最后随便"))
|
||||
if judge.calls != 3 {
|
||||
t.Fatalf("judge calls = %d, want 3 (exited ACTIVE, no re-trigger)", judge.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngageColdAvoidCooldownSuppressesThenExpires(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
clock := func() time.Time { return now }
|
||||
judge := &engageScriptJudge{} // default: interested (would reply if consulted)
|
||||
b, chat, _ := engageTestBridge(t, judge, DefaultEngageConfig(), clock, nil)
|
||||
|
||||
// 冷避: "别刷屏" is a negative attitude → COOLDOWN, and she is silent (registered only).
|
||||
b.process(context.Background(), nonOwnerGroupMsg("昔涟别刷屏了"))
|
||||
if judge.calls != 0 {
|
||||
t.Fatalf("judge calls = %d, want 0 (negative attitude → cooldown, no LLM)", judge.calls)
|
||||
}
|
||||
if chat.sessionID != "" {
|
||||
t.Fatalf("loop ran after negative attitude, session = %q", chat.sessionID)
|
||||
}
|
||||
|
||||
// During cooldown, even a role-name mention is NOT answered (no proactive reply).
|
||||
b.process(context.Background(), nonOwnerGroupMsg("那聊聊昔涟"))
|
||||
if judge.calls != 0 || chat.sessionID != "" {
|
||||
t.Fatalf("during cooldown: calls=%d session=%q, want 0/\"\" (suppressed)", judge.calls, chat.sessionID)
|
||||
}
|
||||
|
||||
// A direct @ still breaks cooldown (design/18 §5: 除非 @ 她).
|
||||
chat.sessionID = ""
|
||||
b.process(context.Background(), nonOwnerGroupMsg("在吗", adapter.Mention{UserID: "bot"}))
|
||||
if chat.sessionID != "sess-g" {
|
||||
t.Fatalf("mention during cooldown: session=%q, want sess-g (@ always replies)", chat.sessionID)
|
||||
}
|
||||
|
||||
// After the cooldown timer elapses, a role-name mention re-engages (PROBING → ACTIVE).
|
||||
now = now.Add(2 * time.Hour)
|
||||
chat.sessionID = ""
|
||||
b.process(context.Background(), nonOwnerGroupMsg("再提昔涟"))
|
||||
if judge.calls != 1 {
|
||||
t.Fatalf("judge calls = %d, want 1 (cooldown expired → re-probe)", judge.calls)
|
||||
}
|
||||
if chat.sessionID != "sess-g" {
|
||||
t.Fatalf("session = %q, want sess-g (re-engaged after cooldown)", chat.sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngageColdAvoidFollowUpDuringActive(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := DefaultEngageConfig()
|
||||
cfg.ActivityWindow = time.Hour
|
||||
cfg.ActivityMessages = 100
|
||||
judge := &engageScriptJudge{responses: []LightDecision{
|
||||
{Interesting: true, WorthReply: true, Reason: "感兴趣"}, // probe → ACTIVE
|
||||
}}
|
||||
b, chat, _ := engageTestBridge(t, judge, cfg, dayNow(), nil)
|
||||
|
||||
b.process(context.Background(), nonOwnerGroupMsg("聊昔涟"))
|
||||
if chat.sessionID != "sess-g" {
|
||||
t.Fatalf("probe: session=%q, want sess-g", chat.sessionID)
|
||||
}
|
||||
|
||||
// A negative attitude during ACTIVE parks her in COOLDOWN immediately (design/18 §5).
|
||||
b.process(context.Background(), nonOwnerGroupMsg("别说了太吵"))
|
||||
if judge.calls != 1 {
|
||||
t.Fatalf("judge calls = %d, want 1 (negative attitude must not consult the judge)", judge.calls)
|
||||
}
|
||||
b.process(context.Background(), nonOwnerGroupMsg("再聊昔涟"))
|
||||
if judge.calls != 1 {
|
||||
t.Fatalf("judge calls = %d, want 1 (cooldown suppresses re-engagement)", judge.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngageJudgeReceivesContextAndPersonaPrompt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
hist := &contextHistory{
|
||||
recent: []history.Message{
|
||||
{SessionID: "sess-g", Role: history.RoleUser, Content: "有人知道昔涟吗", SenderName: "张三"},
|
||||
{SessionID: "sess-g", Role: history.RoleAssistant, Content: "在呢", SenderName: ""},
|
||||
},
|
||||
}
|
||||
judge := &engageScriptJudge{}
|
||||
chat := &stubChatter{res: &loop.Result{Text: "参与"}}
|
||||
sess := &stubSessions{sess: nonOwnerSession()}
|
||||
b, err := New(Options{
|
||||
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, History: hist,
|
||||
Light: judge, TriggerWords: []string{"昔涟"},
|
||||
PersonaPrompt: "你是昔涟。", Engage: DefaultEngageConfig(), EngageRand: func() float64 { return 0.5 },
|
||||
Now: dayNow(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
|
||||
b.process(context.Background(), nonOwnerGroupMsg("那聊聊昔涟嘛"))
|
||||
|
||||
if judge.calls != 1 {
|
||||
t.Fatalf("judge calls = %d, want 1", judge.calls)
|
||||
}
|
||||
if judge.last.Engage != true || judge.last.PersonaPrompt != "你是昔涟。" {
|
||||
t.Fatalf("judge input Engage=%v prompt=%q, want true/你是昔涟。", judge.last.Engage, judge.last.PersonaPrompt)
|
||||
}
|
||||
if judge.last.RecentContext == "" || judge.last.TriggerWords[0] != "昔涟" {
|
||||
t.Fatalf("judge context=%q triggerWords=%v, want non-empty context and 昔涟", judge.last.RecentContext, judge.last.TriggerWords)
|
||||
}
|
||||
}
|
||||
|
||||
// contextHistory is a captureHistory that returns a fixed recent window, so the
|
||||
// engagement context renderer can be asserted.
|
||||
type contextHistory struct {
|
||||
captureHistory
|
||||
recent []history.Message
|
||||
}
|
||||
|
||||
func (c *contextHistory) Recent(context.Context, string, int) ([]history.Message, error) {
|
||||
return c.recent, nil
|
||||
}
|
||||
|
||||
func TestParseEngageDecision(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want LightDecision
|
||||
err bool
|
||||
}{
|
||||
{name: "pure", in: `{"interested":true,"worthReply":true,"avoidTopic":false,"reason":"想聊"}`, want: LightDecision{Interesting: true, WorthReply: true, Reason: "想聊"}},
|
||||
{name: "fenced", in: "```json\n{\"interested\":false,\"worthReply\":false,\"avoidTopic\":false}\n```", want: LightDecision{}},
|
||||
{name: "prose_wrapped", in: "分析一下:{\"interested\":true,\"worthReply\":false,\"avoidTopic\":true,\"reason\":\"敏感\"} 完毕", want: LightDecision{Interesting: true, WorthReply: false, AvoidTopic: true, Reason: "敏感"}},
|
||||
{name: "malformed", in: "not json at all", err: true},
|
||||
}
|
||||
for _, tt := range cases {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := parseEngageDecision(tt.in)
|
||||
if tt.err {
|
||||
if err == nil {
|
||||
t.Fatalf("parseEngageDecision(%q) want error, got nil", tt.in)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("parseEngageDecision(%q) error: %v", tt.in, err)
|
||||
}
|
||||
if got.Interesting != tt.want.Interesting || got.WorthReply != tt.want.WorthReply ||
|
||||
got.AvoidTopic != tt.want.AvoidTopic || got.Reason != tt.want.Reason {
|
||||
t.Fatalf("parsed = %+v, want %+v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package channelbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// This file holds the production LightJudge used by the participation engine
|
||||
// (design/18 §3). It is deliberately separate from flowgate's Reviewer: the Reviewer
|
||||
// prunes queued messages from a stack; this judge decides, once, at a cost-controlled
|
||||
// entrance gate, whether a triggered group/stranger message is worth participating in.
|
||||
// It only ever decides 值不值得回 — never who the sender is (a project red line).
|
||||
|
||||
const (
|
||||
// engageMaxTokens bounds the judge output so a noisy group context never produces a
|
||||
// giant completion (design/18 §3 cost guard).
|
||||
engageMaxTokens = 200
|
||||
// engageSnippetLen is how many bytes of each recent message the context renderer
|
||||
// shows the judge — enough to weigh the topic without paging the whole group.
|
||||
engageSnippetLen = 80
|
||||
)
|
||||
|
||||
// NewLLMLightJudge wraps a model.Adapter into a LightJudge (design/18 §3). It asks the
|
||||
// LLM, via a single cheap turn, whether the triggered message is worth participating
|
||||
// in, and routes the structured answer back to the engage engine. A nil adapter returns
|
||||
// a judge that always fails closed (so the engine stays silent rather than panicking).
|
||||
func NewLLMLightJudge(llm model.Adapter, log *slog.Logger) LightJudge {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return &llmLightJudge{llm: llm, log: log}
|
||||
}
|
||||
|
||||
type llmLightJudge struct {
|
||||
llm model.Adapter
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// Judge performs one cost-controlled light-turn on a triggered participation message
|
||||
// and returns the structured verdict. Any error (upstream, parse) is returned so the
|
||||
// engage engine fails closed to silence. It never reads sender identity — name is
|
||||
// passed only as a label, identity was already resolved structurally.
|
||||
func (j *llmLightJudge) Judge(ctx context.Context, in LightInput) (LightDecision, error) {
|
||||
if j.llm == nil {
|
||||
return LightDecision{}, errors.New("channelbridge: light judge: nil model adapter")
|
||||
}
|
||||
resp, err := j.llm.Chat(ctx, model.Request{
|
||||
Messages: []model.Message{
|
||||
model.TextMessage(model.RoleSystem, engageJudgeSystemPrompt),
|
||||
model.TextMessage(model.RoleUser, buildEngageUserContent(in)),
|
||||
},
|
||||
Purpose: "engage_judge",
|
||||
MaxTokens: engageMaxTokens,
|
||||
}, model.Options{})
|
||||
if err != nil {
|
||||
return LightDecision{}, fmt.Errorf("channelbridge: light judge: %w", err)
|
||||
}
|
||||
d, err := parseEngageDecision(resp.Content)
|
||||
if err != nil {
|
||||
j.log.Warn("light judge output unparsable, fail-closed to silence", "error", err)
|
||||
return LightDecision{}, err
|
||||
}
|
||||
// Map the participation verdict onto the legacy design/12 §6 Respond field so the
|
||||
// same decision drives both the engage engine (WorthReply/Interesting) and any
|
||||
// legacy consumer that only reads Respond.
|
||||
d.Respond = d.WorthReply || d.Interesting
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// engageJudgeSystemPrompt instructs the judge to weigh only participation value, never
|
||||
// identity (design/18 §0 red line), and to answer with a tight JSON object.
|
||||
const engageJudgeSystemPrompt = "你是群聊参与度轻思考器。只判断「她值不值得参与这条群聊消息」," +
|
||||
"绝不判断消息发送者是谁(谁说的已由系统元数据决定)。" +
|
||||
"只输出一个 JSON 对象,字段为:" +
|
||||
"{\"interested\": bool(她对这话题感兴趣吗), " +
|
||||
"\"worthReply\": bool(值得回吗), " +
|
||||
"\"avoidTopic\": bool(该话题她应回避吗,如敏感/隐私/成人红线), " +
|
||||
"\"reason\": string(一句话理由)}。" +
|
||||
"不要输出其它文字。"
|
||||
|
||||
// buildEngageUserContent renders the persona prompt, the recent group context and the
|
||||
// triggering message for the judge. The persona block carries no owner secret; the
|
||||
// context is bounded by the engage renderer. When Engage is false (a legacy design/12
|
||||
// §6 call) only the message and sender label are present, so the judge degrades to a
|
||||
// plain value judgement.
|
||||
func buildEngageUserContent(in LightInput) string {
|
||||
var b strings.Builder
|
||||
if in.Engage && in.PersonaPrompt != "" {
|
||||
b.WriteString("【人格设定】\n" + in.PersonaPrompt + "\n\n")
|
||||
}
|
||||
if in.Engage && in.TriggerWords != nil {
|
||||
b.WriteString("【角色提醒词】" + strings.Join(in.TriggerWords, " / ") + "\n\n")
|
||||
}
|
||||
if in.Engage && in.RecentContext != "" {
|
||||
b.WriteString("【群聊最近上下文】\n" + in.RecentContext + "\n\n")
|
||||
}
|
||||
b.WriteString("【本轮触发消息】\n")
|
||||
if in.SenderName != "" {
|
||||
b.WriteString(in.SenderName + ": ")
|
||||
}
|
||||
b.WriteString(in.Msg.Content)
|
||||
b.WriteString("\n\n请只输出 JSON 对象,不要其它文字。")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// engageDecisionJSON mirrors the judge's structured verdict for decoding.
|
||||
type engageDecisionJSON struct {
|
||||
Interested bool `json:"interested"`
|
||||
WorthReply bool `json:"worthReply"`
|
||||
AvoidTopic bool `json:"avoidTopic"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// parseEngageDecision decodes the judge's JSON object, tolerating a fenced/code-block
|
||||
// wrapper. A malformed answer returns an error so the caller fails closed to silence.
|
||||
func parseEngageDecision(s string) (LightDecision, error) {
|
||||
trimmed := strings.TrimSpace(s)
|
||||
if strings.HasPrefix(trimmed, "```") {
|
||||
trimmed = strings.TrimPrefix(trimmed, "```")
|
||||
if i := strings.IndexByte(trimmed, '\n'); i >= 0 {
|
||||
trimmed = trimmed[i+1:]
|
||||
}
|
||||
trimmed = strings.TrimSuffix(strings.TrimSpace(trimmed), "```")
|
||||
}
|
||||
// The model may wrap the object with explanatory prose; fall back to the first
|
||||
// balanced JSON object if the whole payload is not pure JSON.
|
||||
var raw engageDecisionJSON
|
||||
if err := json.Unmarshal([]byte(trimmed), &raw); err != nil {
|
||||
if obj := firstJSONObject(trimmed); obj != "" {
|
||||
if err2 := json.Unmarshal([]byte(obj), &raw); err2 == nil {
|
||||
return toEngageDecision(raw), nil
|
||||
}
|
||||
}
|
||||
return LightDecision{}, fmt.Errorf("channelbridge: parse engage judge output: %w", err)
|
||||
}
|
||||
return toEngageDecision(raw), nil
|
||||
}
|
||||
|
||||
func toEngageDecision(raw engageDecisionJSON) LightDecision {
|
||||
return LightDecision{
|
||||
Interesting: raw.Interested,
|
||||
WorthReply: raw.WorthReply,
|
||||
AvoidTopic: raw.AvoidTopic,
|
||||
Reason: raw.Reason,
|
||||
}
|
||||
}
|
||||
|
||||
// firstJSONObject returns the first balanced {...} region of s, or "" when none exists.
|
||||
// It is a cheap tolerance for a model that prepends/appends prose to the JSON answer.
|
||||
func firstJSONObject(s string) string {
|
||||
start := strings.IndexByte(s, '{')
|
||||
if start < 0 {
|
||||
return ""
|
||||
}
|
||||
depth := 0
|
||||
for i := start; i < len(s); i++ {
|
||||
switch s[i] {
|
||||
case '{':
|
||||
depth++
|
||||
case '}':
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return s[start : i+1]
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -50,12 +50,42 @@ type LightInput struct {
|
||||
InQuietHours bool
|
||||
QuietStart int
|
||||
QuietEnd int
|
||||
|
||||
// Engage reports whether this is a cost-controlled participation judgment
|
||||
// (design/18): it is set only when a trigger (role-name mention / probability)
|
||||
// or an active activity window routed the message here, so the caller feeds the
|
||||
// group context and persona prompt ONCE and never on every chat message. A
|
||||
// legacy (design/12 §6) inconclusive judgment leaves it false.
|
||||
Engage bool
|
||||
// PersonaPrompt is the current persona's identity/system-prompt block, so the
|
||||
// judge can weigh the role's tone without ever deciding identity. It carries no
|
||||
// owner secret (a project red line).
|
||||
PersonaPrompt string
|
||||
// RecentContext is the group conversation's recent N messages rendered with each
|
||||
// sender's display name, plus the triggering message, so the judge can tell
|
||||
// whether the topic is worth participating in.
|
||||
RecentContext string
|
||||
// TriggerWords are the current persona's role-name reminder words (design/18 §2),
|
||||
// passed as context so the judge knows which word was mentioned.
|
||||
TriggerWords []string
|
||||
}
|
||||
|
||||
// LightDecision is a LightJudge's verdict.
|
||||
type LightDecision struct {
|
||||
// Respond is the legacy design/12 §6 simple verdict (respond or not). It is kept
|
||||
// for backward compatibility; the design/18 fields below carry the structured
|
||||
// participation verdict that the engage engine reads.
|
||||
Respond bool
|
||||
Reason string
|
||||
|
||||
// Interesting reports whether she finds the topic interesting (design/18) — the
|
||||
// signal to enter/remain ACTIVE.
|
||||
Interesting bool
|
||||
// WorthReply reports whether the message is worth a reply.
|
||||
WorthReply bool
|
||||
// AvoidTopic reports whether the topic should be avoided (sensitive / privet /
|
||||
// red-line), so she does not engage even when otherwise interested.
|
||||
AvoidTopic bool
|
||||
}
|
||||
|
||||
// LightJudge is the optional cost-controlled light-response judge (design/12 §6
|
||||
@@ -123,11 +153,13 @@ func (b *Bridge) ruleLightJudge(msg adapter.UnifiedMessage, actor adapter.Actor,
|
||||
}
|
||||
|
||||
// shouldRespond implements the minimal-disturbance classification for a non-owner
|
||||
// inbound message (设计/12 §6 策略一/二/五). It returns whether the message should
|
||||
// drive the full agent loop and reply, or be registered-and-silenced. Owner
|
||||
// messages never reach here (processFrom gates on !actor.IsOwner), so owner-private
|
||||
// chat and owner group @/high-value replies are untouched.
|
||||
func (b *Bridge) shouldRespond(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor) (bool, string) {
|
||||
// inbound message (设计/12 §6 策略一/二/五 + design/18 §2/§4/§5). It returns whether
|
||||
// the message should drive the full agent loop and reply, or be registered-and-
|
||||
// silenced. Owner messages never reach here (processFrom gates on !actor.IsOwner),
|
||||
// so owner-private chat and owner group @/high-value replies are untouched. sess is
|
||||
// the isolated group/stranger session binding the message resolved to, so the
|
||||
// participation engine can read the group's recent context when it probes.
|
||||
func (b *Bridge) shouldRespond(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor, sess session.Session) (bool, string) {
|
||||
// 策略一: platform/system noise → register only, never the full loop.
|
||||
if isLowValueEvent(actor) {
|
||||
b.log.Debug("non-owner low-value system event, registered only",
|
||||
@@ -135,7 +167,10 @@ func (b *Bridge) shouldRespond(ctx context.Context, msg adapter.UnifiedMessage,
|
||||
return false, "low-value system event; registered only"
|
||||
}
|
||||
|
||||
// 策略二/五: rule-based light judgement, quiet-hours aware.
|
||||
// 策略二/五: rule-based light judgement, quiet-hours aware. A direct @, a mention
|
||||
// of the owner's display name, or a high-value keyword always responds; quiet
|
||||
// hours bias toward silence. The participation engine is only consulted on the
|
||||
// inconclusive remainder.
|
||||
inQuiet := b.inQuietHours(b.now())
|
||||
switch b.ruleLightJudge(msg, actor, inQuiet) {
|
||||
case verdictRespond:
|
||||
@@ -146,8 +181,22 @@ func (b *Bridge) shouldRespond(ctx context.Context, msg adapter.UnifiedMessage,
|
||||
return false, "rule signal: not worth responding"
|
||||
}
|
||||
|
||||
// Inconclusive → consult the optional injected light judge. Fail-closed to
|
||||
// silence on error or absence (不打扰为第一原则).
|
||||
// The cheap rules are inconclusive. When the participation engine (design/18) is
|
||||
// configured it takes over and only consults the light judge on an actual trigger
|
||||
// (role-name mention / probability) or an active activity window — never on every
|
||||
// chat message, so cost stays controlled (a red line). Without engage
|
||||
// configuration the bridge keeps the pre-engage legacy behaviour: consult the
|
||||
// optional injected light judge on every inconclusive message, else fail-closed.
|
||||
if !b.engageConfigured() {
|
||||
return b.legacyLightJudge(ctx, msg, actor, inQuiet)
|
||||
}
|
||||
return b.engageDecision(ctx, msg, actor, sess, inQuiet)
|
||||
}
|
||||
|
||||
// legacyLightJudge is the pre-design/18 inconclusive handling: consult the optional
|
||||
// injected light judge, fail-closed toward silence on error or absence
|
||||
// (不打扰为第一原则). It is only reached when the participation engine is not wired.
|
||||
func (b *Bridge) legacyLightJudge(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor, inQuiet bool) (bool, string) {
|
||||
if b.light != nil {
|
||||
d, err := b.light.Judge(ctx, LightInput{
|
||||
Msg: msg, Actor: actor, SenderName: senderDisplayName(msg, actor),
|
||||
@@ -166,6 +215,21 @@ func (b *Bridge) shouldRespond(ctx context.Context, msg adapter.UnifiedMessage,
|
||||
return false, "no rule signal; silenced (fail-closed)"
|
||||
}
|
||||
|
||||
// mentionsTriggerWord reports whether the message content names the current persona
|
||||
// via one of its trigger words (design/18 §2 触发②). It is a cheap substring match —
|
||||
// never an LLM call, and never used to infer who the sender is.
|
||||
func (b *Bridge) mentionsTriggerWord(text string) bool {
|
||||
if text == "" {
|
||||
return false
|
||||
}
|
||||
for _, w := range b.triggerWords {
|
||||
if w != "" && strings.Contains(text, w) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// registerSilenced persists a silenced non-owner inbound turn into its isolated
|
||||
// session's platform log so the adapter log page still renders the non-owner
|
||||
// stream (design/16 §4.6 §4), then stops — no loop, no reply. a is the account
|
||||
|
||||
Reference in new issue
Block a user