feat(engage): 群聊自主参与度引擎核心(design/18 P2a+P2b)
- 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:
1 parent
2d7a377d74
commit
8eaf81bd0d
9 files changed
+1156
-13
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user