Files
Cyrene_v2/internal/services/channelbridge/engagejudge.go
T
AskaEth 093f38d986
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(comments): 注释中文化 · internal/services/channelbridge(纯替换)
- 只翻注释/文档字符串:剥掉注释后代码行逐字节不变(机械校验只剩允许的行尾注释形态)
- 术语对齐既有译法(owner→主人、persona→角色、engage→参与度判定、quiet hours→安静时段、backoff→退避)
- 复核:go build 绿 + channelbridge 包测试绿;gofmt 干净
2026-10-04 13:33:06 +08:00

175 lines
6.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package channelbridge
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件承载参与度引擎使用的生产版 LightJudge
// (design/18 §3)。它刻意与 flowgate 的 Reviewer 分开:Reviewer
// 从堆栈中裁剪队列消息;本判定器则在受成本控制的
// 入口闸门处一次性判断被触发的群聊/陌生人消息是否值得参与。
// 它只判断值不值得回 —— 绝不判断发送者是谁(项目红线)。
const (
// engageMaxTokens 限定判定器输出,使冗长的群上下文绝不会产出
// 超长补全(design/18 §3 成本护栏)。
engageMaxTokens = 200
// engageSnippetLen 是上下文渲染器展示给判定器的每条近期消息
// 字节数——足以权衡话题,又不必翻完整个群。
engageSnippetLen = 80
)
// NewLLMLightJudge 把 model.Adapter 包装成 LightJudge(design/18 §3)。它用一次
// 廉价单轮问 LLM 被触发的消息是否值得参与,
// 并把结构化答案回送给参与度引擎。适配器为 nil 时返回的
// 判定器总是失败关闭(引擎保持静默,而不是 panic)。
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 对被触发的参与度消息执行一次受成本控制的轻量单轮,
// 并返回结构化裁决。任何错误(上游、解析)都会返回,
// 使参与度引擎失败关闭为静默。它绝不读取发送者身份——name
// 只作为标签传入,身份早已由结构解析完毕。
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
}
// 把参与度裁决映射到旧的 design/12 §6 Respond 字段,使同一
// 判定既驱动参与度引擎(WorthReply/Interesting),也驱动任何
// 只读 Respond 的旧消费者。
d.Respond = d.WorthReply || d.Interesting
return d, nil
}
// engageJudgeSystemPrompt 指示判定器只权衡参与价值,绝不涉及
// 身份(design/18 §0 红线),并以紧凑的 JSON 对象作答。
const engageJudgeSystemPrompt = "你是群聊参与度轻思考器。只判断「她值不值得参与这条群聊消息」," +
"绝不判断消息发送者是谁(谁说的已由系统元数据决定)。" +
"只输出一个 JSON 对象,字段为:" +
"{\"interested\": bool(她对这话题感兴趣吗), " +
"\"worthReply\": bool(值得回吗), " +
"\"avoidTopic\": bool(该话题她应回避吗,如敏感/隐私/成人红线), " +
"\"reason\": string(一句话理由)}。" +
"不要输出其它文字。"
// buildEngageUserContent 为判定器渲染角色提示词、近期群上下文与
// 触发消息。角色块不含主人敏感信息;
// 上下文由参与度渲染器限定长度。Engage 为 false(旧的 design/12
// §6 调用)时只有消息与发送者标签,判定器因此退化为
// 朴素的价值判断。
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 镜像判定器的结构化裁决,便于解码。
type engageDecisionJSON struct {
Interested bool `json:"interested"`
WorthReply bool `json:"worthReply"`
AvoidTopic bool `json:"avoidTopic"`
Reason string `json:"reason"`
}
// parseEngageDecision 解码判定器的 JSON 对象,容忍被围栏/代码块
// 包裹。答案畸形时返回错误,使调用方失败关闭为静默。
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), "```")
}
// 模型可能用解释性文字包裹对象;若整个载荷不是纯 JSON,
// 则回退到第一个配对完整的 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 返回 s 中第一个配对完整的 {...} 区域,没有则返回 ""。
// 这是对在 JSON 答案前后加文字的模型的廉价容忍。
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 ""
}