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 = "" }