P0-3 受限车道(群聊/陌生人)的系统框架行并列注入【公域呈现】 (publicPresencePrompt):它是呈现层投影(AGENTS §9 行为模式=呈现), 不是人格切换也不是权限门——不称第三方「亲爱的」等主人专属称呼、 不做只对主人的亲近动作、主人的住址/姓名/作息/行程/健康/家人/关系 即使记忆里读到也不外说;主人私聊与 Web 主会话不注入。 P0-4 纵深防御:第三方边界提示词与 loop 受限前导片段各补两条硬规则 (不用主人专属亲昵称呼指代第三方 + 不外说主人私事)。 P1-2 判定器去人格:模型输入只吃「角色名 + 偏好摘要」,整份人格指令 不再进模型(审计 2026-10-30 §2.4 的 reason 亲昵口吻即由此而来)。 P1-3 测试补真:受限期呈现块存在性 + 主人车道不注入 + 判定器输入/ 模型请求均不含人格正文;并纠正 memory_filter 的错误注释 (作用域 ≠ 敏感度,审计 §6 R1)。
213 lines
8.1 KiB
Go
213 lines
8.1 KiB
Go
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 ""
|
||
}
|