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

+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