Files
Cyrene_v2/internal/services/gateway/session_transcript.go
T
AskaEth a4789ec13e
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(gateway): chat.documents 解析 + 修两处静默吞掉(design/24 S3-B)
- REST(同步 + stream:true)与 WS 两条入口同形解析 documents:只收服务端存储名(形态 + 真实存在 + 嗅探 MIME + 本地 URL + 大小),客户端谎报 mime/url/size 一律被服务端覆盖 ⇒ 不重开 SSRF 面(有「真实可命中 URL,命中次数 0」的用例钉住)。

- 拒绝口径对齐 S1:超过 5 条 → 400 too_many_documents(且先于任何 FS 访问);形态非法/穿越/不存在 → 404 document_not_found。

- 挂载:一回合一条 RoleUser 消息,文档部件追加其后(纯附件回合不塞空文本块,空文本会被 provider 拒)。

- 修 S2 移交的两处静默吞掉:session_transcript 用 history.DecodeDocument 把 h.Document 还原为部件(坏列带来源 WARN 不静默、也不卡会话);session_tail 的 messagesEmpty 计入 ContentTypeDocument(纯附件不再被判空回合)。

- 拆文件/拆函数(AGENTS:文件≤500 行、函数≤80 行):new chat_documents.go(rest.go 原 499/500 行塞不下)、handleChat 尾段 54 行原样搬成 submitChatTurn(handleChat 90→40 行),ws_hub 增 documents 依赖(与既有 envResolver 同接法)。

- 主会话独立验收(隔离 worktree = 当前 HEAD + 仅 B 的文件 ⇒ 正是 A+B 合流态):build/vet 绿、全树 59 包 0 FAIL、13 条新用例 PASS、linux/darwin 交叉编译绿;变异独立复现(摘掉条数上限判定 ⇒ 红)。

- 主会话补的测试钉子:TestMaxChatDocumentsMatchesDesign —— 原条数用例是用常量自身算输入条数的,改常量它照样绿(实测 5→1000 全包仍绿);补钉后改 10 时新钉子红、旧用例仍绿。
2026-10-04 17:25:35 +08:00

179 lines
7.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"
)
// 本文件拥有从持久化转录重建一个回合对话上下文的工作
// (design/15 §2.2/§4「上下文有界:只保留最近 N 条上下文」、design/02
// §1 主会话承载完整上下文、design/22 §7-5)。一个回合此前只从刚
// 发送的消息开始,使模型完全看不到它正在延续的
// 对话;更早回合的助手话语也没有记录
// 是谁说的,因此切换进会话的角色会把另一个
// 角色的话读成自己的。
//
// 播种修好了这两半:新回合携带该会话最近的
// 持久化消息,且每条助手消息都被说话的角色标注
// (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 返回新回合运行其上的对话:该会话
// 已播种的转录,后跟客户端刚发送的消息。转录
// 不可用时,调用方的消息原样返回,这正是
// 既有行为。
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 读取该会话最近的持久化消息并把它们
// 映射为模型消息(最旧优先)。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,而一行坏数据不该把整个会话卡死。
//
// 列被写坏(或附件落在非用户行上)时**不静默**:打一条带来源的 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
}
ref, err := history.DecodeDocument(h.Document)
if err != nil {
transcriptDocumentLog().Warn("transcript document decode", "message", h.ID, "err", err)
return nil
}
if ref == nil || ref.Name == "" {
transcriptDocumentLog().Warn("transcript document without name", "message", h.ID)
return nil
}
doc := model.Document{
Name: ref.Name,
MIME: ref.MIME,
URL: documentFilePrefix + ref.Name,
Size: ref.Size,
}
return model.DocumentMessage(model.RoleUser, "", doc).Content
}
// 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):该尾部是
// 播种转录的内存孪生,因此之后切换进来的角色必须能
// 在紧接着的那个排队回合上,把更早角色的回复与自己的话
// 区分开。
func assistantTailMessage(text, personaID string) model.Message {
msg := model.TextMessage(model.RoleAssistant, text)
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)
}