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 交叉编译
This commit is contained in:
1 parent
3732c538cc
commit
5969bba3bd
22 files changed
+1396
-23
No files matched your search
@@ -0,0 +1,141 @@
|
||||
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 + ")"
|
||||
}
|
||||
Reference in new issue
Block a user