package gateway import ( "context" "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/loop" ) // 本文件归属**文档抽取结论的回写闭环**(design/24 §4.4/§6.1,S3.5 缺口②)。 // // 用户消息在回合**开始前**落库(session_persist.go 的 persistUserMessages), // 那时文档抽取还没发生,document 列里只有引用、没有结论。抽取结论要等回合跑 // 起来(loop 的装配期解析)才存在,于是本文件负责在**回合结束后**把它补回同一 // 列 —— 两段式是刻意的,也是客户端能看到「昔涟读不到这个文件」的唯一路径。 // // 三条纪律: // - 只补结论(status/chars/truncated 与 unreadable 的 reason),身份与大小逐字保留; // - 按**服务端存储名**匹配,匹配不到就原样留着(凭空给它一个结论就是编造); // - 找不到行、存储未实现该视图、写库失败一律带来源 WARN,绝不静默。 // // 它从 session_persist.go 拆出来,使那两个文件都留在「单文件 ≤500 行」的硬约束 // 内(与 session_events.go / session_tail.go 同一理由)。 // documentStatusLogSource 是文档状态回写的日志来源标签,使这条冷路径上的 // WARN 能被追溯到具体子功能(AGENTS §10 的「日志必须标来源」)。 const documentStatusLogSource = "gateway/session-persist" // turnDocuments 配对一次回合同它已落库的用户消息与所属代次(gen)。代次是 // 必须的:被打断或被新回合取代的陈旧回合在结算时不该去写别人的那一行。 type turnDocuments struct { gen uint64 msgs []history.Message } // documentStatusWriter 是 HistoryStore 的一个**可选**更窄视图(与 // clientMsgIDReader / transcriptReader 同一做法):按消息 id 更新 document 列。 // 未实现该视图的存储替身或后端退回「不回写」并留下带来源的 WARN,不因此让 // 回合失败 —— 状态回传是增强,绝不能反过来卡住会话。 type documentStatusWriter interface { UpdateMessageDocument(ctx context.Context, id, document string) error } // rememberTurnDocuments 记下本路已落库的用户消息(按世代号配对),供回合结束 // 后把抽取结论回写进它们的 document 列。空列表是空操作(这条回合没有落库任何 // 用户消息,也就没有结论可补)。 // // 它按**路**配对(design/15 §2.5.9「历史配对」升为路级):并行时两路各自持有 // 自己那几条已落库的用户消息,任何一路结算都只回写自己那几行,绝不会把结论 // 写进另一路的行。路已失效(被打断清空)时什么都不做 —— 这次迟到结算不属于 // 当前状态。 func (o *Orchestrator) rememberTurnDocuments(sessionID string, gen uint64, msgs []history.Message) { if len(msgs) == 0 { return } o.mu.Lock() defer o.mu.Unlock() if leg := o.legLocked(o.states[sessionID], gen); leg != nil { leg.docs = turnDocuments{gen: gen, msgs: msgs} } } // attachPendingDocuments 把刚落库的用户消息挂到队列里 seq 所指的那条待处理回合 // 上,使它的续跑回合结算时能回写结论(它在入队那一刻就落了库,而那时还没有 // 任何结论)。 // // 队列里已经找不到那条(被中断清空、已被弹出或已被续跑消费)时什么都不做: // 那一行的结论没有谁能再补,也没有必要为此刷一条 WARN —— 它不是缺陷,是中断 // 的正常后果。 func (o *Orchestrator) attachPendingDocuments(sessionID string, seq uint64, msgs []history.Message) { if len(msgs) == 0 { return } o.mu.Lock() defer o.mu.Unlock() st := o.states[sessionID] if st == nil { return } for i := range st.pending { if st.pending[i].seq == seq { st.pending[i].persisted = msgs return } } } // takeTurnDocuments 取走**本路**(世代号配对)已落库的用户消息并清空记录。 // 第二个返回值报告这条路是否仍然在途:不在途说明这是一次失效路的迟到结算 // (被中断清空 / 被取代),调用方据此静默返回 —— 迟到的结算是正常竞态,不是缺陷。 func (o *Orchestrator) takeTurnDocuments(sessionID string, gen uint64) ([]history.Message, bool) { o.mu.Lock() defer o.mu.Unlock() leg := o.legLocked(o.states[sessionID], gen) if leg == nil { return nil, false } msgs := leg.docs.msgs leg.docs = turnDocuments{} return msgs, true } // persistDocumentStatus 在回合结束后把装配期解析到的文档结论回写进该回合用户 // 消息的 document 列(design/24 §4.4/§6.1)。它由 completeTurn 在助手回复落库 // 之后、状态机迁移之前调用,因此结论与该回合的对话内容一起同批入账。 // // 回写的是 status/chars/truncated 与 unreadable 的原因文案(reason,契约 // v0.63)—— 之后所有读取面(历史接口 / 握手回放)拿到的是同一份结论。 // // 找不到对应行(ErrMessageNotFound)、存储未接该视图、列被写坏、写库失败时都 // **不静默**:打一条带来源的 WARN 说明「状态未回写」。日志只带会话、消息 id、 // 条数与错误,**绝不带正文**,也不带列内容。 // // 写入使用全新的后台 context(与 persistAssistantReply 同一理由):被取消回合 // 的 ctx 绝不该中断这次补写。 func (o *Orchestrator) persistDocumentStatus(sessionID string, gen uint64, res *loop.Result) { if res == nil || len(res.Documents) == 0 { return } log := logger.WithSource(o.log, documentStatusLogSource) if o.history == nil { log.Warn("document status not persisted", "reason", "no history store", "session", sessionID, "documents", len(res.Documents)) return } writer, ok := o.history.(documentStatusWriter) if !ok { log.Warn("document status not persisted", "reason", "history store has no document update path", "session", sessionID, "documents", len(res.Documents)) return } msgs, current := o.takeTurnDocuments(sessionID, gen) if !current { return } if len(msgs) == 0 { log.Warn("document status not persisted", "reason", "no persisted user message for this turn", "session", sessionID, "documents", len(res.Documents)) return } ctx := context.Background() for _, m := range msgs { if m.Document == "" { continue } merged, changed, err := mergeDocumentStatus(m.Document, res.Documents) if err != nil { log.Warn("document status merge", "err", err, "session", sessionID, "message", m.ID) continue } if !changed { continue } if err := writer.UpdateMessageDocument(ctx, m.ID, merged); err != nil { log.Warn("document status update", "err", err, "session", sessionID, "message", m.ID) } } } // mergeDocumentStatus 把本回合解析到的结论按存储名并进已落库的引用数组:匹配到 // 的补上 status/chars/truncated/reason,没匹配到的原样保留(那意味着本回合没有 // 解析到它,凭空给它一个结论就是编造)。第二个返回值报告是否真的补进了新结论 —— // 没变化就不必写库、也不必刷一条「什么都没变」的日志。 // // reason 与 status 同批搬运(契约 v0.63):抽取结论的这三项 + 原因文案是同一个 // 回合的同一份事实,分两次写会让客户端看到「unreadable 但没有理由」的中间态。 // // 列被写坏时返回包装了 history 哨兵的错误,由调用方按「不静默」处理。 func mergeDocumentStatus(raw string, resolved []history.DocumentRef) (string, bool, error) { refs, err := history.DecodeDocuments(raw) if err != nil { return "", false, err } conclusion := make(map[string]history.DocumentRef, len(resolved)) for _, ref := range resolved { if ref.Name != "" { conclusion[ref.Name] = ref } } changed := false for i := range refs { got, ok := conclusion[refs[i].Name] if !ok { continue } if refs[i].Status == got.Status && refs[i].Chars == got.Chars && refs[i].Truncated == got.Truncated && refs[i].Reason == got.Reason { continue } refs[i].Status, refs[i].Chars, refs[i].Truncated, refs[i].Reason = got.Status, got.Chars, got.Truncated, got.Reason changed = true } if !changed { return raw, false, nil } merged, err := history.EncodeDocuments(refs) if err != nil { return "", false, err } return merged, true, nil }