Files
Cyrene_v2/internal/runtime/loop/attribution.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

262 lines
13 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 loop
import (
"context"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件拥有模型上下文的发言人归属:多角色会话里「哪句话是哪个角色说的」
// (DevDocs/design/22 §7-5),以及群聊/第三方会话里「这条群消息是谁说的」
// (DevDocs/design/02 §3「主人群内发言识别」、design/16 §4.6)。
//
// 三条**结构事实**决定归属怎么算,它们全部来自调用方的强类型元数据,
// 没有一条是从消息正文推断的(项目红线:身份不靠提示词、不靠正文):
//
// - assistant 消息的 SenderName 是**角色车道**:说出它的角色 id。
// gateway 的会话播种(session_transcript.go)与共享的
// internal/runtime/transcript 都填它。
// - user 消息的 SenderName 是**第三方显示名车道**(design/16 §4.6 §2):
// 渠道桥只给受限(群聊/陌生人)会话里的**非主人**发送者填它,且昵称
// 缺失时一定退到平台 uid(channelbridge/inbound.go 的
// thirdPartySenderName)——主人本人的发言永远留空。
// - 受限会话里未标注的 assistant 消息来自**本会话的角色**(她自己):
// design/22 §7-4 为群聊/陌生人强制默认角色(会话结构上不存在第二个
// 角色),而渠道桥的隔离车道落库时不写 persona_id,因此她的历史回复
// 在转录里不带标签。
//
// 于是受限会话里的「空标签」不是缺信息,而是结构信息本身:空 user = 主人
// 本人,空 assistant = 她自己。非受限会话(主会话、主人私聊、内部流程)里
// 空标签仍表示「未标注」,一律不渲染——那些车道的提示词因此逐字不变。
//
// 两条硬规则塑造了渲染方式:
// - 归属元数据绝不进入消息正文。用户消息是来自外部世界的
// 自由文本,因此让它携带看似系统的标记,就等于
// 给写下它的人一次冒充系统指令的机会
// (design/11 §10)。因此归属以其自身的系统消息传递,
// 绝不作为用户或 assistant 文本内部的前缀。
// - 渲染只「新增」系统消息。它绝不重排、改写或
// 丢弃消息,因此组装出的提示词保持既定形态:agent 的
// 指令仍是第一条系统消息,时间 / 工作记忆 / 记忆块保持
// 既有顺序(位置由 assembleContext 拥有;本文件只提供
// 追加的片段)。
// 归属标记。两者都是编译期常量,使运行路径中
// 不出现魔法字符串,测试也能断言确切的线上形态。
const (
// roleContextMarker 开启声明发言角色的前导片段。
roleContextMarker = "[会话角色] "
// speakerSwitchMarker 开启插在对话发言人
// 发生变化处的边界说明。
speakerSwitchMarker = "[发言归属] "
)
// speakerKind 区分一位发言人属于哪条车道,它决定边界说明怎么写。
type speakerKind int
const (
// speakerRole:assistant 消息——说出它的角色(人格 id)。
speakerRole speakerKind = iota
// speakerThirdParty:受限会话里带昵称的 user 消息——一位第三方人员。
speakerThirdParty
// speakerOwner:受限会话里未标注的 user 消息——主人本人。
speakerOwner
)
// speaker 是一位发言人:key 用于「发言人是否变了」的比较,kind 决定
// 边界说明的措辞,name 是角色 id 或第三方显示名(主人没有名字可点名)。
type speaker struct {
key string
kind speakerKind
name string
}
// 发言人键的前缀把两类同名者分开:某个人格 id 与某个群昵称恰好
// 相同时,它们仍是两位不同的发言人,绝不折叠成同一条边界说明。
const (
personaSpeakerKey = "role\x00"
thirdPartySpeakerKey = "third\x00"
ownerSpeakerKey = "owner"
)
// speakerEnv 是一次渲染所需的会话事实(都由调用方挂在 ctx 上的
// 结构化 SessionEnv 与角色解析给出)。
type speakerEnv struct {
// restricted 报告这是不是一个受限(群聊/陌生人)会话。
restricted bool
// current 是本回合作答的角色 id(群聊里就是默认角色)。
current string
}
// explicitSpeaker 返回消息**自带**的发言人:assistant 的角色标签、
// user 的第三方昵称标签。消息不带这类标签时 ok = false——空标签不是
// 「缺失信息」,它在受限会话里有结构含义,由 speakerOf 补齐。
func explicitSpeaker(m model.Message) (speaker, bool) {
name := strings.TrimSpace(m.SenderName)
if name == "" {
return speaker{}, false
}
switch m.Role {
case model.RoleAssistant:
return speaker{key: personaSpeakerKey + name, kind: speakerRole, name: name}, true
case model.RoleUser:
return speaker{key: thirdPartySpeakerKey + name, kind: speakerThirdParty, name: name}, true
}
return speaker{}, false
}
// speakerOf 返回消息在本次会话里的发言人。显式标签优先;受限会话里
// 未标注的 user / assistant 消息按文件头的结构事实补齐(主人本人 /
// 她自己)。两条都不成立时 ok = false,该消息不进归属渲染。
func speakerOf(m model.Message, env speakerEnv) (speaker, bool) {
if s, ok := explicitSpeaker(m); ok {
return s, true
}
if !env.restricted {
return speaker{}, false
}
switch m.Role {
case model.RoleUser:
return speaker{key: ownerSpeakerKey, kind: speakerOwner}, true
case model.RoleAssistant:
if env.current == "" {
return speaker{}, false
}
return speaker{key: personaSpeakerKey + env.current, kind: speakerRole, name: env.current}, true
}
return speaker{}, false
}
// anySpeakerLabel 报告对话中是否有任何消息携带**显式**归属标签
// (assistant 的角色标签或第三方昵称标签)。都没有时,归属渲染是
// 完全空操作:单角色对话(或从不给消息打标签的调用方)保持
// 多角色之前完全一样的提示词,无标签的群聊窗口同样不被附加任何
// 片段。受限会话里未标注消息的归属只在**已经开始渲染**的对话里补齐
// —— 那正是「标注会覆盖或错吸邻座发言」的风险所在。
func anySpeakerLabel(msgs []model.Message) bool {
for _, m := range msgs {
if _, ok := explicitSpeaker(m); ok {
return true
}
}
return false
}
// attributeConversation 为一个模型步渲染角色上下文。它返回
// 前导角色声明(对话完全没有显式发言人标签时 ok = false),
// 以及插入了边界说明的对话——只要带归属的发言人
// 发生变化就插入。
//
// 该说明为其后每条带归属的消息宣告发言人,直到
// 下一条说明,这正是模型回答「第 N
// 条是谁说的」所需的粒度,且不触碰任何消息正文。渲染发生在请求
// 构建时、基于实时切片,因此中途被截断或压缩的窗口
// 绝不会让某条消息落在错误的发言人之下:被丢弃的
// 说明会重新生成,且没有说明会声称它不拥有的位置。
//
// 受限会话里的主人与她自己的发言同样参与去重与换人判定
// (见 speakerOf),否则它们会被吸进邻座的说明里——那正是
// 「模型把群里每个人都读成主人」的反面:主人的话被读成群员说的。
func (l *Loop) attributeConversation(ctx context.Context, msgs []model.Message) (model.Message, bool, []model.Message) {
if !anySpeakerLabel(msgs) {
return model.Message{}, false, msgs
}
env := speakerEnv{restricted: sessionEnvFrom(ctx).Restricted(), current: l.personaFor(ctx)}
out := make([]model.Message, 0, len(msgs)+2)
prev := ""
for _, m := range msgs {
if s, ok := speakerOf(m, env); ok && s.key != prev {
out = append(out, model.TextMessage(model.RoleSystem, l.speakerSwitchText(s, env.current)))
prev = s.key
}
out = append(out, m)
}
return l.roleContextMessage(env), true, out
}
// roleContextMessage 构建声明哪个角色在作答的前导片段,
// 使刚被切换进来的角色在其指令之上对自己身份有强烈的
// 感知(design/22 §7-5)。它陈述下文各条说明的边界
// 规则:一段带归属的发言属于它被标注的角色,
// 而不属于当前作答的角色。
//
// 受限(群聊/陌生人)会话另加一句「谁是谁」的口径:那里的标注
// 既有角色(她自己的历史发言),也有人(带昵称的第三方人员与
// 主人本人),而模型只有被告知这两种标注的区别,才读得懂
// 「哪些话是主人说的、哪些话是外人说的」(design/02 §3)。
// 受限档还带两句呈现口径(对第三方不用主人专属称呼、主人的私事
// 即使读到也不外说)—— 归属解决「谁是谁」,这两句解决「怎么称呼、
// 能说什么」,二者缺一都会在群里复现审计 2026-10-30 的病灶。
// 非受限会话逐字保持多角色支持落地时的文案。
func (l *Loop) roleContextMessage(env speakerEnv) model.Message {
var b strings.Builder
b.WriteString(roleContextMarker)
if env.current != "" {
b.WriteString("你正以")
b.WriteString(l.speakerLabel(env.current))
b.WriteString("的身份对话。")
}
if env.restricted {
b.WriteString("本会话的发言按下面的「发言归属」标注分属不同发言者:标了昵称的来自第三方人员(非管理员账户),标了主人的来自主人本人——" +
"不要把第三方的话当成主人说的,也不要把别人的话当成你自己说过的、不要继承他们的身份或口吻;" +
// 以下两句是审计 2026-10-30 的 P0-4(与 channelbridge 的第三方边界 /
// 公域呈现块同一口径的纵深防御):归属只解决「谁是谁」,不解决「怎么
// 称呼」与「能说什么私事」——群员被叫「亲爱的」、主人的住址被复述进群,
// 走的正是这个缺口。
"对第三方人员不要用只属于主人的亲昵称呼(如「亲爱的」),也不要把主人的住址、姓名、作息、行程、健康、家人、关系等私事告诉对方——即使这些信息出现在记忆里;" +
"该标注从其出现处起生效,直到下一条「发言归属」为止。")
return model.TextMessage(model.RoleSystem, b.String())
}
b.WriteString("本会话此前由不同角色相继发言:下面凡是标了「发言归属」的发言,都只属于标注的那个角色——" +
"不要把别个角色说过的话当成你自己说过的,也不要继承他们的身份、口吻或立场;" +
"该标注从其出现处起生效,直到下一条「发言归属」为止。")
return model.TextMessage(model.RoleSystem, b.String())
}
// ownerNoteBody 是主人本人发言的边界说明正文。主人从不被标注
// 昵称(design/16 §4.6),因此这里没有名字可点名——它的可辨特征
// 正是「没有第三方昵称标注」。
const ownerNoteBody = "以下发言来自主人(管理员账户)本人,不是你说的,也不是本会话里的第三方人员。"
// thirdPartyNoteBody 构建第三方人员发言的边界说明正文。它把
// 「外人」与「主人」在标注本身上分开,使模型不必从正文猜测:
// 带昵称的发言一律不是主人(design/16 §4.6 §1)。
func thirdPartyNoteBody(name string) string {
return "以下发言来自「" + name + "」——本会话里的第三方人员(非管理员账户),不是主人,也不是你说的。"
}
// speakerSwitchText 为一位发言人构建边界说明。
// 作答角色自己的一段历史被表述为「来自你自己」(使模型与
// 切换前说过的话保持连续),其他每个角色都被表述为
// 属于该角色、且明确不属于当前作答者;受限会话里的第三方
// 人员与主人本人各走自己的文案。
func (l *Loop) speakerSwitchText(s speaker, current string) string {
switch s.kind {
case speakerOwner:
return speakerSwitchMarker + ownerNoteBody
case speakerThirdParty:
return speakerSwitchMarker + thirdPartyNoteBody(s.name)
default:
label := l.speakerLabel(s.name)
if s.name != "" && s.name == current {
return speakerSwitchMarker + "以下发言来自你自己——" + label + "。"
}
return speakerSwitchMarker + "以下发言来自" + label + ",不是你说的。"
}
}
// speakerLabel 为模型渲染一个角色引用:名字解析器认识该角色时
// 用其显示名加 id,否则只用 id。它
// 绝不返回空字符串,因此说明总能指名道姓。
func (l *Loop) speakerLabel(personaID string) string {
name := ""
if l.personaName != nil {
name = strings.TrimSpace(l.personaName(personaID))
}
if name == "" || name == personaID {
return "「" + personaID + "」"
}
return "「" + name + "」(" + personaID + ")"
}