From 8eaf81bd0d214ed109378c56c40a650ce61bce2e Mon Sep 17 00:00:00 2001 From: Aska Date: Mon, 7 Sep 2026 18:16:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(engage):=20=E7=BE=A4=E8=81=8A=E8=87=AA?= =?UTF-8?q?=E4=B8=BB=E5=8F=82=E4=B8=8E=E5=BA=A6=E5=BC=95=E6=93=8E=E6=A0=B8?= =?UTF-8?q?=E5=BF=83(design/18=20P2a+P2b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 ./... 全绿 --- cmd/cyrene/wire.go | 29 ++ configs/personas/cyrene.yaml | 8 + internal/persona/loader/persona.go | 5 + internal/persona/loader/persona_test.go | 18 + .../services/channelbridge/channelbridge.go | 53 ++- internal/services/channelbridge/engage.go | 404 ++++++++++++++++++ .../services/channelbridge/engage_test.go | 398 +++++++++++++++++ .../services/channelbridge/engagejudge.go | 174 ++++++++ internal/services/channelbridge/light.go | 80 +++- 9 files changed, 1156 insertions(+), 13 deletions(-) create mode 100644 internal/services/channelbridge/engage.go create mode 100644 internal/services/channelbridge/engage_test.go create mode 100644 internal/services/channelbridge/engagejudge.go diff --git a/cmd/cyrene/wire.go b/cmd/cyrene/wire.go index e0c1fff..be78576 100644 --- a/cmd/cyrene/wire.go +++ b/cmd/cyrene/wire.go @@ -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 +} diff --git a/configs/personas/cyrene.yaml b/configs/personas/cyrene.yaml index 95f5cf1..09e1a01 100644 --- a/configs/personas/cyrene.yaml +++ b/configs/personas/cyrene.yaml @@ -8,6 +8,14 @@ id: cyrene name: 昔涟 characterRef: 崩坏:星穹铁道 — 翁法罗斯(昔涟) +# 角色名提词:公开平台群聊里提到这些词,即使不@她也会进入「参与度探测」(设计/18 §2 触发②)。 +# 只用于触发「值不值得参与」的轻思考,绝不做身份判定(身份由 channel 层 actor 元数据决定)。 +triggerWords: + - 昔涟 + - Cyrene + - 有瑕的追忆 + - 追忆 + identity: # 人设(你是谁) role: >- diff --git a/internal/persona/loader/persona.go b/internal/persona/loader/persona.go index c55d7e8..e5e2737 100644 --- a/internal/persona/loader/persona.go +++ b/internal/persona/loader/persona.go @@ -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 diff --git a/internal/persona/loader/persona_test.go b/internal/persona/loader/persona_test.go index 2954e40..33b8d8b 100644 --- a/internal/persona/loader/persona_test.go +++ b/internal/persona/loader/persona_test.go @@ -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() diff --git a/internal/services/channelbridge/channelbridge.go b/internal/services/channelbridge/channelbridge.go index 4fd72e6..c204e87 100644 --- a/internal/services/channelbridge/channelbridge.go +++ b/internal/services/channelbridge/channelbridge.go @@ -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, diff --git a/internal/services/channelbridge/engage.go b/internal/services/channelbridge/engage.go new file mode 100644 index 0000000..ff3ba25 --- /dev/null +++ b/internal/services/channelbridge/engage.go @@ -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 = "" +} diff --git a/internal/services/channelbridge/engage_test.go b/internal/services/channelbridge/engage_test.go new file mode 100644 index 0000000..df8fe39 --- /dev/null +++ b/internal/services/channelbridge/engage_test.go @@ -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) + } + }) + } +} diff --git a/internal/services/channelbridge/engagejudge.go b/internal/services/channelbridge/engagejudge.go new file mode 100644 index 0000000..33f62b7 --- /dev/null +++ b/internal/services/channelbridge/engagejudge.go @@ -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 "" +} diff --git a/internal/services/channelbridge/light.go b/internal/services/channelbridge/light.go index 1733663..c85fdb4 100644 --- a/internal/services/channelbridge/light.go +++ b/internal/services/channelbridge/light.go @@ -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