裁决 06.4.1 行动项①②落地(主人 2026-10-13 定稿:结构化归属 + system 标注, 不把元数据拼进用户正文)。 - loop/attribution.go:发言人归属从「只认 assistant」扩到 user 侧,按三条 结构事实各出一条边界说明 —— assistant 的角色(她自己 / 别的角色,措辞逐字 不变)、受限会话里带昵称的第三方人员(明标「不是主人」)、以及不带昵称的 主人本人;受限会话里未标注的 assistant 历史按 design/22 §7-4 归到本会话角色。 标注仍只新增 system 消息,正文一字不改。 - channelbridge/inbound_speaker.go(自 inbound.go 拆出):第三方发送者标注车道 thirdPartySenderName —— 主人恒返回空(主人从不被标注),其余昵称缺失时退到 平台 uid 再退到「未署名发送者」,绝不落进「空标注 = 主人」那条车道; thirdPartyBoundaryPrompt 删掉「只有未标注昵称的消息才是管理员(主人)发言」, 改为「谁在说话以「发言归属」标注为准」。 - channelbridge/source.go:来源行删掉「(非管理员账户=第三方人员,未标注昵称=主人)」。 - 主人本人在群里的消息落库不再写昵称(sender_name 空 = 主人),使转录里同一句话 的归属与当回合一致。 验证:go build ./... / go vet ./... / go test ./...(含全树)全绿; 变异自证:把 user 侧归属恢复成不产出 ⇒ 3 条 loop 用例 + 1 条渠道端到端用例变红。
254 lines
12 KiB
Go
254 lines
12 KiB
Go
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)。
|
||
// 非受限会话逐字保持多角色支持落地时的文案。
|
||
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("本会话的发言按下面的「发言归属」标注分属不同发言者:标了昵称的来自第三方人员(非管理员账户),标了主人的来自主人本人——" +
|
||
"不要把第三方的话当成主人说的,也不要把别人的话当成你自己说过的、不要继承他们的身份或口吻;" +
|
||
"该标注从其出现处起生效,直到下一条「发言归属」为止。")
|
||
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 + ")"
|
||
}
|