Files
Cyrene_v2/internal/services/channelbridge/engagejudge.go
T
Aska 8eaf81bd0d
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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 ./... 全绿
2026-09-07 18:16:55 +08:00

175 lines
6.4 KiB
Go

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