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

+29
View File
@@ -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
View File
@@ -8,6 +8,14 @@ id: cyrene
name: 昔涟
characterRef: 崩坏:星穹铁道 — 翁法罗斯(昔涟)
# 角色名提词:公开平台群聊里提到这些词,即使不@她也会进入「参与度探测」(设计/18 §2 触发②)。
# 只用于触发「值不值得参与」的轻思考,绝不做身份判定(身份由 channel 层 actor 元数据决定)。
triggerWords:
- 昔涟
- Cyrene
- 有瑕的追忆
- 追忆
identity:
# 人设(你是谁)
role: >-
+5
View File
@@ -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
+18
View File
@@ -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,
+404
View File
@@ -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 ""
}
+72 -8
View File
@@ -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