Files
Cyrene_v2/internal/services/channelbridge/engagejudge.go
T
AskaEth c34b9d195d feat(channelbridge): 群聊公域呈现块与判定器去人格(M62)
P0-3 受限车道(群聊/陌生人)的系统框架行并列注入【公域呈现】
(publicPresencePrompt):它是呈现层投影(AGENTS §9 行为模式=呈现),
不是人格切换也不是权限门——不称第三方「亲爱的」等主人专属称呼、
不做只对主人的亲近动作、主人的住址/姓名/作息/行程/健康/家人/关系
即使记忆里读到也不外说;主人私聊与 Web 主会话不注入。

P0-4 纵深防御:第三方边界提示词与 loop 受限前导片段各补两条硬规则
(不用主人专属亲昵称呼指代第三方 + 不外说主人私事)。

P1-2 判定器去人格:模型输入只吃「角色名 + 偏好摘要」,整份人格指令
不再进模型(审计 2026-10-30 §2.4 的 reason 亲昵口吻即由此而来)。

P1-3 测试补真:受限期呈现块存在性 + 主人车道不注入 + 判定器输入/
模型请求均不含人格正文;并纠正 memory_filter 的错误注释
(作用域 ≠ 敏感度,审计 §6 R1)。
2026-10-30 08:37:40 +08:00

213 lines
8.1 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(一句话理由)}。" +
"不要输出其它文字。"
// engageJudgePreferenceDigest 是喂给判定器的**角色偏好摘要**:只说「什么话题
// 她会感兴趣、什么话题该回避」,没有一个称呼、没有一句亲昵口吻。
//
// 为什么不用整份人格指令(审计 2026-10-30 §2.4 / P1-2):判定器只做价值判断
// (本文件头注),口吻对它毫无用处,却会被它搬进 reason —— 那句
// 「让亲爱的他们自己聊吧」就是判定器读着「你只唤他『亲爱的』」在看群友的
// 直接后果。人格指令里还有主人关系设定,喂进一个只该算参与度的廉价调用
// 也没有必要。
const engageJudgePreferenceDigest = "她偏爱轻松、温和的日常闲聊,愿意接住别人递来的话题;回避隐私、成人、争吵与人身攻击类话题。"
// judgeRoleName 取判定器要用的角色名:优先用角色名提醒词(design/18 §2 的
// TriggerWords 本就是她的名字/昵称)。没有就留空 —— 判定器不知道名字照样能
// 判,而绝不能为此把整份人格指令塞回去。
func judgeRoleName(words []string) string {
for _, w := range words {
if name := strings.TrimSpace(w); name != "" {
return name
}
}
return ""
}
// judgePersonaBrief 把判定器的角色输入折成「角色名 + 偏好摘要」。
//
// in.PersonaPrompt 是**整份人格指令**(含称呼与关系设定),这里刻意一个字节
// 都不透传:判定器不需要口吻,需要的是「她在乎什么话题」。这一点由
// TestEngageJudgePromptCarriesNoPersonaBody 钉住 —— 把整份指令塞回去它就红。
func judgePersonaBrief(in LightInput) string {
var b strings.Builder
if name := judgeRoleName(in.TriggerWords); name != "" {
b.WriteString("角色名:")
b.WriteString(name)
b.WriteByte('\n')
}
b.WriteString(engageJudgePreferenceDigest)
return b.String()
}
// buildEngageUserContent 为判定器渲染角色摘要、近期群上下文与
// 触发消息。角色块只含「角色名 + 偏好摘要」,**不含**人格指令正文与
// 主人敏感信息(P1-2);上下文由参与度渲染器限定长度。Engage 为 false
// (旧的 design/12 §6 调用)时只有消息与发送者标签,判定器因此退化为
// 朴素的价值判断。
func buildEngageUserContent(in LightInput) string {
var b strings.Builder
if in.Engage {
b.WriteString("【角色设定摘要】\n" + judgePersonaBrief(in) + "\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 ""
}