按 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 交叉编译
130 lines
5.4 KiB
Go
130 lines
5.4 KiB
Go
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
|
|
}
|