- 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 ./... 全绿
256 lines
11 KiB
Go
256 lines
11 KiB
Go
package channelbridge
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
|
)
|
|
|
|
// This file holds the channel bridge's minimal-disturbance inbound classification
|
|
// for public platforms (DevDocs/design/12 §6 策略一/二/五). Non-owner group/stranger
|
|
// messages are judged cheaply before they reach the full agent loop, so a busy
|
|
// public group does not fling every message into an expensive LLM turn. The logic
|
|
// lives here to keep channelbridge.go under the module size bound; it never reads
|
|
// message content to decide identity — identity is already resolved structurally by
|
|
// the session/actor layer (a project red line).
|
|
|
|
// Quiet-hour defaults, mirroring the backthink service so the bridge and the
|
|
// background thinker share one do-not-disturb window (design/12 §6 策略五).
|
|
const (
|
|
defaultQuietStartHour = 23
|
|
defaultQuietEndHour = 7
|
|
)
|
|
|
|
// lightVerdict is the outcome of a rule-based light judgement before the optional
|
|
// injected LightJudge is consulted (design/12 §6 策略二/五).
|
|
type lightVerdict int
|
|
|
|
const (
|
|
// verdictRespond means the rule signals the message is worth a response.
|
|
verdictRespond lightVerdict = iota
|
|
// verdictSilent means the rule signals the message should be registered but not
|
|
// answered (e.g. during quiet hours with no strong signal).
|
|
verdictSilent
|
|
// verdictInconclusive means the rules cannot tell; defer to the LightJudge (or
|
|
// fail-closed to silence when none is wired).
|
|
verdictInconclusive
|
|
)
|
|
|
|
// LightInput is the set of signals a LightJudge receives to decide response
|
|
// worthiness. Identity is already resolved structurally by the session/actor
|
|
// layer, so the judge weighs only 值不值得回 — it never decides who the sender is.
|
|
type LightInput struct {
|
|
Msg adapter.UnifiedMessage
|
|
Actor adapter.Actor
|
|
SenderName string
|
|
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
|
|
// 策略二/五). The bridge consults it only when the built-in rule judge is
|
|
// inconclusive, so a chat message is not repeatedly sent to an expensive LLM. A
|
|
// nil LightJudge (the default) keeps the bridge rule-only and fail-closed toward
|
|
// silence (不打扰为第一原则). The production wiring does not supply one yet, so the
|
|
// seam is ready but not token-consuming until it is deliberately wired.
|
|
type LightJudge interface {
|
|
Judge(ctx context.Context, in LightInput) (LightDecision, error)
|
|
}
|
|
|
|
// highValueKeywords is the small term list the rule judge matches to decide a
|
|
// message is worth a response even when the sender was not directly addressed
|
|
// (设计/12 §6 策略二: 关键词重要度). It is a cheap substring match — never an LLM call,
|
|
// and never used to infer identity.
|
|
var highValueKeywords = []string{
|
|
"紧急", "救命", "故障", "坏掉", "坏了", "报错", "出错", "崩溃",
|
|
"求助", "帮帮我", "help", "urgent", "error", "bug",
|
|
}
|
|
|
|
// isLowValueEvent reports whether a non-owner message is platform/system noise
|
|
// that must be registered but never drives the full agent loop (设计/12 §6 策略一).
|
|
// It is only consulted for non-owner messages: a system/synthetic sender is the
|
|
// canonical low-value signal. Messages with no meaningful chat text are dropped at
|
|
// the bridge head anyway, so this primarily gates system-actor notifications.
|
|
func isLowValueEvent(actor adapter.Actor) bool {
|
|
return actor.IsSystem()
|
|
}
|
|
|
|
// hasHighValueKeyword reports whether text carries a term worth responding to even
|
|
// when the sender was not directly addressed. Kept tiny so the rule path stays O(1)
|
|
// per message.
|
|
func hasHighValueKeyword(text string) bool {
|
|
lower := strings.ToLower(text)
|
|
for _, k := range highValueKeywords {
|
|
if strings.Contains(lower, k) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ruleLightJudge decides response-worthiness from cheap rule signals only
|
|
// (设计/12 §6 策略二/五). Priority: a direct mention, a mention of the owner's display
|
|
// name, a high-value keyword, then a quiet-hours bias toward silence; otherwise the
|
|
// verdict is inconclusive (defer to the injected LightJudge, else silence). It
|
|
// reads only already-resolved actor metadata and message content — never infers
|
|
// identity.
|
|
func (b *Bridge) ruleLightJudge(msg adapter.UnifiedMessage, actor adapter.Actor, inQuiet bool) lightVerdict {
|
|
if len(msg.Mentions) > 0 {
|
|
return verdictRespond
|
|
}
|
|
if b.owner != nil && b.owner.DisplayName != "" &&
|
|
strings.Contains(msg.Content, b.owner.DisplayName) {
|
|
return verdictRespond
|
|
}
|
|
if hasHighValueKeyword(msg.Content) {
|
|
return verdictRespond
|
|
}
|
|
if inQuiet {
|
|
return verdictSilent
|
|
}
|
|
return verdictInconclusive
|
|
}
|
|
|
|
// shouldRespond implements the minimal-disturbance classification for a non-owner
|
|
// 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",
|
|
"platform", msg.Platform, "channel", msg.ChannelID, "actor_role", actor.Role)
|
|
return false, "low-value system event; registered only"
|
|
}
|
|
|
|
// 策略二/五: 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:
|
|
return true, "rule signal: worth responding"
|
|
case verdictSilent:
|
|
b.log.Debug("non-owner message silenced (quiet/no signal), registered only",
|
|
"platform", msg.Platform, "channel", msg.ChannelID, "quiet", inQuiet, "actor_role", actor.Role)
|
|
return false, "rule signal: not worth responding"
|
|
}
|
|
|
|
// 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),
|
|
InQuietHours: inQuiet, QuietStart: b.quietStartHour, QuietEnd: b.quietEndHour,
|
|
})
|
|
if err == nil {
|
|
if d.Respond {
|
|
return true, "light judge: " + d.Reason
|
|
}
|
|
return false, "light judge: " + d.Reason
|
|
}
|
|
b.log.Warn("light judge failed; silencing", "error", err)
|
|
}
|
|
b.log.Debug("non-owner message has no rule signal, silenced",
|
|
"platform", msg.Platform, "channel", msg.ChannelID, "actor_role", actor.Role)
|
|
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
|
|
// adapter that received the message, so the log is tagged with the adapter config.
|
|
// It is the 登记 side of the minimal-disturbance path.
|
|
func (b *Bridge) registerSilenced(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor, sess session.Session, a adapter.PlatformAdapter) {
|
|
b.persistIsolated(ctx, history.RoleUser, msg.Content, deviceIDFor(msg), sess, msg.Platform, senderDisplayName(msg, actor), adapterName(a))
|
|
b.log.Debug("non-owner message silenced, registered only",
|
|
"platform", msg.Platform, "channel", msg.ChannelID, "session", sess.SessionID)
|
|
}
|
|
|
|
// inQuietHours reports whether at falls inside the configured do-not-disturb
|
|
// window (shared semantics with backthink: equal non-zero hours disable it, and a
|
|
// window that wraps midnight is handled). It is a pure method on Bridge so tests
|
|
// drive it deterministically via the Now source.
|
|
func (b *Bridge) inQuietHours(at time.Time) bool {
|
|
h := at.Hour()
|
|
start, end := b.quietStartHour, b.quietEndHour
|
|
if start <= end {
|
|
return h >= start && h < end
|
|
}
|
|
return h >= start || h < end
|
|
}
|