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,129 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// This file owns the reconstruction of a turn's conversation context from the
|
||||
// persisted transcript (design/15 §2.2/§4 "上下文有界:只保留最近 N 条上下文", design/02
|
||||
// §1 主会话承载完整上下文, design/22 §7-5). A turn used to start from the newly sent
|
||||
// message alone, which left the model with no sight of the conversation it was
|
||||
// continuing; the assistant utterances of earlier turns also carried no record of
|
||||
// which role had spoken them, so a role switched into a session could read another
|
||||
// role's words as its own.
|
||||
//
|
||||
// The seeding fixes both halves: a fresh turn carries the session's most recent
|
||||
// persisted messages, and every assistant message is labelled with the role that
|
||||
// spoke it (model.Message.SenderName) so the loop's context assembly can attribute
|
||||
// it (loop/attribution.go). Only labels travel on the messages — no body is
|
||||
// rewritten.
|
||||
//
|
||||
// Scope note: the seeded window is the conversation, so the loop's per-step
|
||||
// background memory extraction now reads those recent messages instead of the
|
||||
// single newly sent one. Extracted items are still filed under the turn's role
|
||||
// (loop.persistMemories, design/22 §4.2) and re-extraction of the same content is
|
||||
// deduplicated by the extractor, but a fact first stated while another role was
|
||||
// active can now be picked up by the current role if it was never extracted at the
|
||||
// time. Narrowing the extraction input to the current turn's messages is a
|
||||
// separate decision and is left as a follow-up.
|
||||
|
||||
// transcriptContextCount bounds how many persisted messages a fresh turn carries
|
||||
// into the model context. It mirrors the orchestrator's continuation tail bound
|
||||
// (defaultMaxTail), so a seeded turn and a resumed turn stay the same size; the
|
||||
// loop's own context budget still governs compaction beyond that.
|
||||
const transcriptContextCount = 20
|
||||
|
||||
// transcriptReader is the narrow history view the seeding needs. *history.Store
|
||||
// satisfies it; a store that does not (a test fake, or a gateway built without
|
||||
// persistence) simply yields no seeded context, so the orchestrator keeps running
|
||||
// exactly as before.
|
||||
type transcriptReader interface {
|
||||
Recent(ctx context.Context, sessionID string, n int) ([]history.Message, error)
|
||||
}
|
||||
|
||||
// startTurnMessages returns the conversation a fresh turn runs on: the session's
|
||||
// seeded transcript followed by the messages the client just sent. When the
|
||||
// transcript is unavailable the caller's messages are returned untouched, which is
|
||||
// the pre-existing behavior.
|
||||
func (o *Orchestrator) startTurnMessages(ctx context.Context, sessionID string, messages []model.Message) []model.Message {
|
||||
seed := o.sessionTranscript(ctx, sessionID)
|
||||
if len(seed) == 0 {
|
||||
return messages
|
||||
}
|
||||
return concatMessages(seed, messages...)
|
||||
}
|
||||
|
||||
// sessionTranscript reads the session's most recent persisted messages and maps
|
||||
// them to model messages (oldest first). A nil/unreadable store or a read error
|
||||
// yields nil so a chat is never blocked by a metadata lookup, and a session with
|
||||
// no history yields nothing — both degrade to the pre-existing "new message only"
|
||||
// context.
|
||||
func (o *Orchestrator) sessionTranscript(ctx context.Context, sessionID string) []model.Message {
|
||||
if o.history == nil || sessionID == "" {
|
||||
return nil
|
||||
}
|
||||
reader, ok := o.history.(transcriptReader)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
recent, err := reader.Recent(ctx, sessionID, transcriptContextCount)
|
||||
if err != nil {
|
||||
o.log.Debug("turn transcript read", "err", err, "session", sessionID)
|
||||
return nil
|
||||
}
|
||||
out := make([]model.Message, 0, len(recent))
|
||||
for _, h := range recent {
|
||||
out = append(out, transcriptMessage(h))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// transcriptMessage maps one persisted chat message onto the model message a turn
|
||||
// continues from. An assistant message carries the role that spoke it (the stored
|
||||
// persona_id of the turn) as its speaker label, so the context can attribute it to
|
||||
// that role even after the session was switched; a non-assistant message keeps its
|
||||
// stored sender label (the third-party display name of design/16 §4.6) and is never
|
||||
// relabelled as a speaking role.
|
||||
func transcriptMessage(h history.Message) model.Message {
|
||||
role := historyRoleToModel(h.Role)
|
||||
msg := model.Message{
|
||||
Role: role,
|
||||
Content: []model.Content{{Type: model.ContentTypeText, Text: h.Content}},
|
||||
}
|
||||
if h.Image != "" {
|
||||
msg.Content = append(msg.Content, model.Content{Type: model.ContentTypeImage, ImageURL: h.Image})
|
||||
}
|
||||
if role == model.RoleAssistant {
|
||||
msg.SenderName = h.PersonaID
|
||||
return msg
|
||||
}
|
||||
msg.SenderName = h.SenderName
|
||||
return msg
|
||||
}
|
||||
|
||||
// historyRoleToModel maps a persisted chat role to the model's role vocabulary,
|
||||
// defaulting an unknown role to the user lane.
|
||||
func historyRoleToModel(role string) model.Role {
|
||||
switch role {
|
||||
case history.RoleAssistant:
|
||||
return model.RoleAssistant
|
||||
case history.RoleSystem:
|
||||
return model.RoleSystem
|
||||
default:
|
||||
return model.RoleUser
|
||||
}
|
||||
}
|
||||
|
||||
// assistantTailMessage builds the continuation-tail entry for one settled turn's
|
||||
// reply, labelled with the role that spoke it (design/22 §7-5): the tail is the
|
||||
// in-memory twin of the seeded transcript, so a role switched in later must be able
|
||||
// to tell an earlier role's reply apart from its own words on the very next queued
|
||||
// turn.
|
||||
func assistantTailMessage(text, personaID string) model.Message {
|
||||
msg := model.TextMessage(model.RoleAssistant, text)
|
||||
msg.SenderName = personaID
|
||||
return msg
|
||||
}
|
||||
Reference in new issue
Block a user