Files
Cyrene_v2/internal/services/gateway/session_transcript.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

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
}