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

+98
View File
@@ -0,0 +1,98 @@
// Package transcript 拥有「把持久化的聊天转录重建为一个回合的模型上下文」这一件事
// (design/15 §2.2/§4 的上下文有界、design/02 §1 主会话承载完整上下文、design/22 §7-5)。
//
// 两条编排路径都要在回合开始时带上该会话最近的若干条已落库消息:网关的会话编排器
// (internal/services/gateway/session_transcript.go)与渠道桥的 IM 回合
// (internal/services/channelbridge)。IM 与 Web 只有**呈现形态**不同(IM 只发 chat 文本),
// 模型看到的对话历史必须是同一份 —— 两边各写一套映射,迟早会漂移成「消息在同一条会话流
// 里,模型看到的历史却不同」,也就是同一个她记得的事不一样。
//
// 本包只做纯映射:读窄接口 Recent,把 history.Message 翻成 model.Message。**该不该播种、
// 播在哪条车道**由调用方按各自的会话与身份边界判定(网关按会话,渠道桥按 private/受限),
// 因此本包不持有任何编排状态,也不做身份判断(项目红线:身份在 channel 层的 actor 元数据上)。
package transcript
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"
)
// ContextCount 限定一个新回合带入模型上下文的持久化消息数。它与编排器的续跑尾部上限
// (网关的 defaultMaxTail)一致,使播种回合与续跑回合保持同样大小;超出之后仍由 loop
// 自身的上下文预算治理压缩。**两条车道都从这里取**:这是条数的唯一来源,不许各自另立常量。
const ContextCount = 20
// Reader 是播种所需的窄历史视图。*history.Store 满足它;未满足的存储(测试替身,或
// 未接持久化构建的调用方)只是得不到播种上下文,因此回合完全照旧继续运行。
type Reader interface {
Recent(ctx context.Context, sessionID string, n int) ([]history.Message, error)
}
// Read 返回 sessionID 最近的持久化消息映射成的模型消息(最旧优先)。store 为 nil、
// 未实现 Reader 或读取出错都得到 nil,因此聊天绝不被一次元数据查找阻塞;没有历史的
// 会话同样什么都不产出 —— 这些情形都退化为既有的「只有本次消息」上下文。
func Read(ctx context.Context, store Reader, sessionID string) []model.Message {
if store == nil || sessionID == "" {
return nil
}
recent, err := store.Recent(ctx, sessionID, ContextCount)
if err != nil {
transcriptLog().Debug("turn transcript read", "err", err, "session", sessionID)
return nil
}
out := make([]model.Message, 0, len(recent))
for _, h := range recent {
out = append(out, Message(h))
}
return out
}
// Message 把一条持久化聊天消息映射为回合延续所依据的模型消息。助手消息携带说话的角色
// (该回合已存的 persona_id)作为其说话者标签,因此即使会话被切换过,上下文也能把它归属
// 到那个角色;非助手消息保持其已存发送者标签(design/16 §4.6 的第三方显示名),绝不
// 被重新标为说话角色。文档引用**全部**一并还原(design/24 §9.5):少了它,附件会在发起
// 它的那一回合之后的所有回合里消失。
func Message(h history.Message) model.Message {
role := RoleOf(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, documentParts(role, h)...)
if role == model.RoleAssistant {
msg.SenderName = h.PersonaID
return msg
}
msg.SenderName = h.SenderName
return msg
}
// RoleOf 把已存聊天角色映射为模型的角色词汇,未知角色默认归到用户通道。
func RoleOf(role string) model.Role {
switch role {
case history.RoleAssistant:
return model.RoleAssistant
case history.RoleSystem:
return model.RoleSystem
default:
return model.RoleUser
}
}
// logSource 是转录重建的日志来源标签。
const logSource = "runtime/transcript"
// transcriptLog 返回标注来源的子日志器。转录重建是纯函数(两条车道的调用点都只拿得到
// history.Message,作用域里没有各自编排器的 logger),因此以进程默认 logger 为基底再盖上
// 来源标签,使读取失败与坏数据的痕迹既可见又可追溯(与 internal/auth 的 apikey/jwt 同一手法)。
// 它只在冷路径上打日志,不在每回合的正常路径上打任何日志,且绝不记消息正文。
func transcriptLog() *slog.Logger {
return logger.WithSource(slog.Default(), logSource)
}