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