Files
Cyrene_v2/internal/runtime/loop/attribution.go
T
AskaEth 5969bba3bd
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(channel,runtime): 批次2 后端 — 消息级角色归属进上下文 + IM 私聊随主会话角色 + 渠道级绑定
按 design/22 §2/§3/§4.4/§7(3/4/5)。
- ①「消息级角色归属」进入模型上下文(批次 1 只做存储+DTO):历史消息的发送角色现在对模型可见,且**不把系统元数据塞进用户正文**(design/11 §10 红线)
- ② IM owner 私聊镜像解析出的角色 = **主会话绑定的角色**(§7-3)
- ③ 渠道级绑定:ChannelAccount 加 personaId(channels.json 读写/校验/脱敏沿用既有约定)+ 角色解析优先级 **会话 > 渠道 > 默认** + **群聊/陌生人强制默认角色**(§7-4);admin 渠道配置端点支持 personaId
- Docs/api-client 版本 +1
- 验收:go build/vet/test 全绿 + linux/darwin 交叉编译
2026-10-03 21:01:33 +08:00

142 lines
6.4 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"
)
// This file owns the speaker attribution of the model context (DevDocs/design/22
// §7-5). A process now hosts several roles at once and a session keeps its
// conversation when its role is switched, so the model must be able to tell which
// utterances in the context came from which role: the assistant messages of a
// previous role must not read as "something I said", and the role answering now
// must recognize itself.
//
// Two hard rules shape the rendering:
// - The attributed metadata never enters a message body. A user message is
// free text from the outside world, so letting it carry system-looking markup
// would hand whoever writes it a chance to impersonate a system instruction
// (design/11 §10). Attribution therefore travels as its own system messages,
// never as a prefix inside user or assistant text.
// - The rendering only ADDS system messages. It never reorders, rewrites or
// drops a message, so the assembled prompt keeps its established shape: the
// agent's instructions stay the first system message and the time / working
// memory / memory blocks keep their existing order (assembleContext owns the
// placement; this file only supplies the extra segment).
//
// The per-message speaker is read from the structured model.Message.SenderName
// lane, because model/** is frozen in this batch and a Message has no dedicated
// persona field. Only assistant messages are read: for an assistant turn the
// sender IS the role that spoke it, while a labelled user message keeps its
// pre-existing meaning (the third-party sender marker of design/16 §4.6), which
// this file deliberately leaves alone.
// Attribution markers. Both are compile-time constants so no magic string
// appears in the run path and a test can assert the exact wire shape.
const (
// roleContextMarker opens the leading segment that declares the speaking role.
roleContextMarker = "[会话角色] "
// speakerSwitchMarker opens a boundary note inserted where the speaker of the
// conversation changes.
speakerSwitchMarker = "[发言归属] "
)
// speakerOf returns the role id labelled as the speaker of m, or "" when the
// message carries no attribution. Only assistant messages are considered: a user
// message's SenderName is the third-party display name lane of design/16 §4.6 and
// has nothing to do with persona selection.
func speakerOf(m model.Message) string {
if m.Role != model.RoleAssistant {
return ""
}
return strings.TrimSpace(m.SenderName)
}
// anySpeakerLabel reports whether any message of the conversation carries a
// speaker label. When none does, attribution rendering is a complete no-op: a
// single-role conversation (or a caller that never labels its messages) keeps the
// exact pre-multi-character prompt it had before.
func anySpeakerLabel(msgs []model.Message) bool {
for _, m := range msgs {
if speakerOf(m) != "" {
return true
}
}
return false
}
// attributeConversation renders the role context for one model step. It returns
// the leading role declaration (ok = false when the conversation carries no
// speaker label at all), and the conversation with a boundary note inserted
// wherever the labelled speaker changes.
//
// The note announces the speaker for every labelled message that follows it until
// the next note, which is exactly the granularity the model needs to answer "第 N
// 条是谁说的" without touching a single message body. Rendering happens at request
// build time from the live slice, so a window that was truncated or compacted in
// the meantime can never end up with a message under the wrong speaker: a dropped
// note is regenerated, and no note claims a position it does not have.
func (l *Loop) attributeConversation(ctx context.Context, msgs []model.Message) (model.Message, bool, []model.Message) {
if !anySpeakerLabel(msgs) {
return model.Message{}, false, msgs
}
current := l.personaFor(ctx)
out := make([]model.Message, 0, len(msgs)+2)
prev := ""
for _, m := range msgs {
if s := speakerOf(m); s != "" && s != prev {
out = append(out, model.TextMessage(model.RoleSystem, l.speakerSwitchText(s, current)))
prev = s
}
out = append(out, m)
}
return l.roleContextMessage(current), true, out
}
// roleContextMessage builds the leading segment that declares which role is
// answering, so a role that was just switched in has a strong sense of its own
// identity on top of its instructions (design/22 §7-5). It states the boundary
// rule for the notes below: an attributed utterance belongs to the role it was
// labelled with, not to the role answering now.
func (l *Loop) roleContextMessage(current string) model.Message {
var b strings.Builder
b.WriteString(roleContextMarker)
if current != "" {
b.WriteString("你正以")
b.WriteString(l.speakerLabel(current))
b.WriteString("的身份对话。")
}
b.WriteString("本会话此前由不同角色相继发言:下面凡是标了「发言归属」的发言,都只属于标注的那个角色——" +
"不要把别个角色说过的话当成你自己说过的,也不要继承他们的身份、口吻或立场;" +
"该标注从其出现处起生效,直到下一条「发言归属」为止。")
return model.TextMessage(model.RoleSystem, b.String())
}
// speakerSwitchText builds the boundary note for one speaking role. A stretch of
// the answering role's own history is stated as "来自你自己" (so the model keeps
// continuity with what it said before a switch), every other role is stated as
// belonging to that role and explicitly not to the answering one.
func (l *Loop) speakerSwitchText(personaID, current string) string {
label := l.speakerLabel(personaID)
if personaID != "" && personaID == current {
return speakerSwitchMarker + "以下发言来自你自己——" + label + "。"
}
return speakerSwitchMarker + "以下发言来自" + label + ",不是你说的。"
}
// speakerLabel renders a role reference for the model: the role's display name
// with its id when a name resolver knows the role, the id alone otherwise. It
// never returns an empty string, so a note always names someone.
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 + ")"
}