Files
Cyrene_v2/internal/services/gateway/session_transcript.go
T
AskaEth 5b2b385524
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(gateway): 文档多值化 + 抽取结论回写(design/24 S3.5)
- 缺口①:「一回合最多 5 个附件、落库只留 1 个」——document 列语义升级为 ≤5 个引用的**数组**(列名/字段名不变、无需迁移,旧单对象行可读回);落库收集全部引用、转录还原全部部件。

- 缺口②:抽取结论 status/chars/truncated 经 completeTurn 回合后**回写**该回合用户消息的同一列(id 按代次配对,忙时消息挂 pendingMsg.persisted 并在 drain 时以新 gen 重新配对);历史 DTO 改为 **documents: []**(omitempty ⇒ 0 个附件时省略)。

- 防御:编/解码超 5 都报 ErrTooManyDocuments(报错、绝不裁剪);坏列走既有不静默路径(转录 WARN + DTO unreadable 最小引用);>5 上限值被独立用具例钉死。

- 顺手修 S3-B 遗留的函数越界:submitInternal 81 → 79 行(播种块抽成 seedTurnLocked);session_orchestrator.go 484 行。

- 主会话独立验收:隔离 worktree(HEAD + 本切片 12 文件)build/vet 绿、全树 59 包 0 FAIL、linux+darwin 交叉编译绿、gateway+history 437 PASS;变异独立复现(只留第一个附件 ⇒ 2 条红,含 HTTP 端到端 want 3 条)。

- 遗留(已登记):chat_documents_test.go 510 行(S3-B 遗留越界,本次未拆);中断/被取代的回合不回写结论;回写后不补发实时帧(status 需重新拉取/回放可见)。
2026-10-04 17:42:16 +08:00

204 lines
8.9 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...)
}
// seedTurnLocked 为新回合播种对话(持久化转录在刚发送的消息之前),并把播种后的
// 尾部记进会话状态。**必须在 o.mu 下调用**(它读写 st.tail;与 admitLocked /
// claimClientMsgLocked 同一约定),调用方负责解锁 —— 因此它从
// session_orchestrator.go 的 submitInternal 里拆出来,让那个函数留在「函数 ≤80
// 行」的硬约束内。
//
// 播种的时刻是刻意的:它在 persistUserMessages 写入新消息**之前**,因此刚发送的
// 消息绝不在上下文中重复(design/15 §2.2/§4、design/22 §7-5)。没有历史(或空
// 会话)的存储让该回合只使用调用方给出的消息。它是在状态锁下的有界索引读取
// (transcriptContextCount 行),而状态锁从不会被运行中的回合持有,因此它无法把
// Submit 堵在回合后面。
func (o *Orchestrator) seedTurnLocked(ctx context.Context, st *sessionState, sessionID string, messages []model.Message) []model.Message {
turn := o.startTurnMessages(ctx, sessionID, messages)
st.tail = o.boundedTail(turn)
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):该尾部是
// 播种转录的内存孪生,因此之后切换进来的角色必须能
// 在紧接着的那个排队回合上,把更早角色的回复与自己的话
// 区分开。
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)
}