feat(runtime): IM 主人私聊补历史转录(与 Web 主会话同源:抽共享 transcript 包 + OwnerEnvFor 单入口)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

This commit is contained in:
AskaEth committed 2026-10-05 11:36:18 +08:00
1 parent df8fc660c0
commit 9b903a89bc
14 files changed
+676 -181

No files matched your search

+21 -159
View File
@@ -2,56 +2,34 @@ package gateway
import (
"context"
"log/slog"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/transcript"
)
// 本文件拥有从持久化转录重建一个回合对话上下文的工作
// (design/15 §2.2/§4「上下文有界:只保留最近 N 条上下文」、design/02
// §1 主会话承载完整上下文、design/22 §7-5)。一个回合此前只从刚
// 发送的消息开始,使模型完全看不到它正在延续的
// 对话;更早回合的助手话语也没有记录
// 是谁说的,因此切换进会话的角色会把另一个
// 角色的话读成自己的。
// 本文件拥有「一个新回合的对话上下文从哪里来」的网关侧**决策与时刻**
// (design/15 §2.2/§4「上下文有界:只保留最近 N 条上下文」、design/02 §1 主会话
// 承载完整上下文、design/22 §7-5)。重建本身(读最近 N 条 + 翻成模型消息)住在
// internal/runtime/transcript —— 它与渠道桥的 IM 主人私聊回合是**同一份实现**,
// 两条车道的模型可见历史因此不会漂移(IM 与 Web 只在呈现形态上不同:IM 只发 chat 文本)。
//
// 播种修好了这两半:新回合携带该会话最近的
// 持久化消息,且每条助手消息都被说话的角色标注
// (model.Message.SenderName),使 loop 的上下文组装能归属它
// (loop/attribution.go)。消息上只走标签 —— 没有任何消息体
// 被改写。
// 一个回合此前只从刚发送的消息开始,使模型完全看不到它正在延续的对话;更早回合的
// 助手话语也没有记录是谁说的,因此切换进会话的角色会把另一个角色的话读成自己的。
//
// 范围说明:被播种的窗口就是该对话,因此 loop 的逐步
// 后台记忆抽取现在读的是这些近期消息,而不是
// 单条刚发送的消息。抽取出的条目仍归到该回合的角色
// 名下(loop.persistMemories,design/22 §4.2),且同一内容的重复抽取
// 会被抽取器去重,但一个在另一角色活跃时首次说出的事实,
// 若当时从未被抽取,现在就可能被当前角色拾取。
// 把抽取输入收窄为当前回合的消息是一个
// 独立的决定,留作后续跟进。
// 播种修好了这两半:新回合携带该会话最近的持久化消息,且每条助手消息都被说话的角色
// 标注(model.Message.SenderName),使 loop 的上下文组装能归属它
// (loop/attribution.go)。消息上只走标签 —— 没有任何消息体被改写。
//
// 范围说明:被播种的窗口就是该对话,因此 loop 的逐步后台记忆抽取现在读的是这些近期
// 消息,而不是单条刚发送的消息。抽取出的条目仍归到该回合的角色名下(loop.persistMemories,
// design/22 §4.2),且同一内容的重复抽取会被抽取器去重,但一个在另一角色活跃时首次说出
// 的事实,若当时从未被抽取,现在就可能被当前角色拾取。把抽取输入收窄为当前回合的消息是
// 一个独立的决定,留作后续跟进。
// transcriptContextCount 限定一个新回合带入模型上下文的持久化
// 消息数。它与编排器的续跑尾部上限
// (defaultMaxTail)一致,使播种回合与续跑回合保持同样大小;
// 超出之后仍由 loop 自身的上下文预算治理压缩。
const transcriptContextCount = 20
// transcriptReader 是播种所需的窄历史视图。*history.Store
// 满足它;未满足的存储(测试替身,或未接持久化
// 构建的网关)只是得不到播种上下文,因此编排器
// 完全照旧继续运行。
type transcriptReader interface {
Recent(ctx context.Context, sessionID string, n int) ([]history.Message, error)
}
// startTurnMessages 返回新回合运行其上的对话:该会话
// 已播种的转录,后跟客户端刚发送的消息。转录
// 不可用时,调用方的消息原样返回,这正是
// 既有行为。
// startTurnMessages 返回新回合运行其上的对话:该会话已播种的转录,后跟客户端刚发送的
// 消息。转录不可用时,调用方的消息原样返回,这正是既有行为。
func (o *Orchestrator) startTurnMessages(ctx context.Context, sessionID string, messages []model.Message) []model.Message {
seed := o.sessionTranscript(ctx, sessionID)
seed := transcript.Read(ctx, o.history, sessionID)
if len(seed) == 0 {
return messages
}
@@ -67,7 +45,7 @@ func (o *Orchestrator) startTurnMessages(ctx context.Context, sessionID string,
// 播种的时刻是刻意的:它在 persistUserMessages 写入新消息**之前**,因此刚发送的
// 消息绝不在上下文中重复(design/15 §2.2/§4、design/22 §7-5)。没有历史(或空
// 会话)的存储让该回合只使用调用方给出的消息。它是在状态锁下的有界索引读取
// (transcriptContextCount 行),而状态锁从不会被运行中的回合持有,因此它无法把
// (transcript.ContextCount 行),而状态锁从不会被运行中的回合持有,因此它无法把
// Submit 堵在回合后面。
func (o *Orchestrator) seedTurnLocked(ctx context.Context, st *sessionState, sessionID string, messages []model.Message) []model.Message {
turn := o.startTurnMessages(ctx, sessionID, messages)
@@ -75,110 +53,6 @@ func (o *Orchestrator) seedTurnLocked(ctx context.Context, st *sessionState, ses
return turn
}
// sessionTranscript 读取该会话最近的持久化消息并把它们
// 映射为模型消息(最旧优先)。nil/不可读的存储或读错误
// 都得到 nil,因此聊天绝不被元数据查找阻塞;没有
// 历史的会话也什么都不产出 —— 二者都退化为既有的
// 「只有新消息」上下文。
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 把一条持久化聊天消息映射为回合延续所依据的
// 模型消息。助手消息携带说话的角色(该回合已存的
// persona_id)作为其说话者标签,因此即使会话被切换过,
// 上下文也能把它归属到那个角色;非助手消息保持其
// 已存发送者标签(design/16 §4.6 的第三方显示名),绝不
// 被重新标为说话角色。文档引用**全部**一并还原(design/24 §9.5):
// 少了它,附件会在发起它的那一回合之后的所有回合里消失。
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})
}
msg.Content = append(msg.Content, transcriptDocumentParts(role, h)...)
if role == model.RoleAssistant {
msg.SenderName = h.PersonaID
return msg
}
msg.SenderName = h.SenderName
return msg
}
// transcriptDocumentParts 把已存 document 列里的**全部**引用还原为该回合的文档
// 部件(design/24 §9.5)。部件只交给 RoleUser 回合(§2.3-③):文档在任何别的
// 角色上都会让编码器 fail-loud,而一行坏数据不该把整个会话卡死。
//
// 列里是引用数组(一个回合最多 5 个附件,缺口①),因此这里逐条还原、保持部件
// 顺序 —— 少还原一条,那个附件就会在发起它的回合之后的所有回合里消失,与
// 「清单恒在」的承诺自相矛盾。
//
// 列被写坏(或附件落在非用户行上)时**不静默**:打一条带来源的 WARN 并省掉
// 该部件,回合照常继续 —— 附件凭空消失正是这条清单要修的缺陷,因此必须留下
// 痕迹。日志只带消息 id 与错误,绝不带正文。
func transcriptDocumentParts(role model.Role, h history.Message) []model.Content {
if h.Document == "" {
return nil
}
if role != model.RoleUser {
transcriptDocumentLog().Warn("transcript document on non-user row", "message", h.ID, "role", h.Role)
return nil
}
refs, err := history.DecodeDocuments(h.Document)
if err != nil {
transcriptDocumentLog().Warn("transcript document decode", "message", h.ID, "err", err)
return nil
}
parts := make([]model.Content, 0, len(refs))
for _, ref := range refs {
if ref.Name == "" {
transcriptDocumentLog().Warn("transcript document without name", "message", h.ID)
continue
}
doc := model.Document{
Name: ref.Name,
MIME: ref.MIME,
URL: documentFilePrefix + ref.Name,
Size: ref.Size,
}
parts = append(parts, model.DocumentMessage(model.RoleUser, "", doc).Content...)
}
return parts
}
// historyRoleToModel 把已存聊天角色映射为模型的角色词汇,
// 未知角色默认归到用户通道。
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 为一个已结算回合的回复构建续跑尾部
// 条目,并用说话的角色标注它(design/22 §7-5):该尾部是
// 播种转录的内存孪生,因此之后切换进来的角色必须能
@@ -189,15 +63,3 @@ func assistantTailMessage(text, personaID string) model.Message {
msg.SenderName = personaID
return msg
}
// transcriptLogSource 是转录重建的日志来源标签。
const transcriptLogSource = "gateway/transcript"
// transcriptDocumentLog 返回标注来源的子日志器。转录重建是纯函数(编排器的
// 播种与转向两条路径都只拿得到 history.Message,没有 logger 在作用域内),
// 因此以进程默认 logger 为基底再盖上来源标签,使坏数据的 WARN 既可见又可
// 追溯(与 internal/auth 的 apikey/jwt 同一手法)。它只用于「数据被写坏」
// 这类冷路径,不在每回合的正常路径上打任何日志。
func transcriptDocumentLog() *slog.Logger {
return logger.WithSource(slog.Default(), transcriptLogSource)
}