- 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 ./... 全绿
175 lines
6.4 KiB
Go
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 ""
|
|
}
|