feat(gateway): 文档多值化 + 抽取结论回写(design/24 S3.5)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 缺口①:「一回合最多 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 需重新拉取/回放可见)。
This commit is contained in:
AskaEth committed 2026-10-04 17:42:16 +08:00
1 parent 20c1edb36a
commit 5b2b385524
12 files changed
+1255 -166

No files matched your search

+40 -15
View File
@@ -58,6 +58,23 @@ func (o *Orchestrator) startTurnMessages(ctx context.Context, sessionID string,
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,因此聊天绝不被元数据查找阻塞;没有
@@ -88,7 +105,7 @@ func (o *Orchestrator) sessionTranscript(ctx context.Context, sessionID string)
// persona_id)作为其说话者标签,因此即使会话被切换过,
// 上下文也能把它归属到那个角色;非助手消息保持其
// 已存发送者标签(design/16 §4.6 的第三方显示名),绝不
// 被重新标为说话角色。文档引用一并还原(design/24 §9.5):
// 被重新标为说话角色。文档引用**全部**一并还原(design/24 §9.5):
// 少了它,附件会在发起它的那一回合之后的所有回合里消失。
func transcriptMessage(h history.Message) model.Message {
role := historyRoleToModel(h.Role)
@@ -108,9 +125,13 @@ func transcriptMessage(h history.Message) model.Message {
return msg
}
// transcriptDocumentParts 把已存 document 列还原为该回合的文档部件(design/24
// §9.5)。部件只交给 RoleUser 回合(§2.3-③):文档在任何别的角色上都会让编码器
// fail-loud,而一行坏数据不该把整个会话卡死。
// transcriptDocumentParts 把已存 document 列里的**全部**引用还原为该回合的文档
// 部件(design/24 §9.5)。部件只交给 RoleUser 回合(§2.3-③):文档在任何别的
// 角色上都会让编码器 fail-loud,而一行坏数据不该把整个会话卡死。
//
// 列里是引用数组(一个回合最多 5 个附件,缺口①),因此这里逐条还原、保持部件
// 顺序 —— 少还原一条,那个附件就会在发起它的回合之后的所有回合里消失,与
// 「清单恒在」的承诺自相矛盾。
//
// 列被写坏(或附件落在非用户行上)时**不静默**:打一条带来源的 WARN 并省掉
// 该部件,回合照常继续 —— 附件凭空消失正是这条清单要修的缺陷,因此必须留下
@@ -123,22 +144,26 @@ func transcriptDocumentParts(role model.Role, h history.Message) []model.Content
transcriptDocumentLog().Warn("transcript document on non-user row", "message", h.ID, "role", h.Role)
return nil
}
ref, err := history.DecodeDocument(h.Document)
refs, err := history.DecodeDocuments(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
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...)
}
doc := model.Document{
Name: ref.Name,
MIME: ref.MIME,
URL: documentFilePrefix + ref.Name,
Size: ref.Size,
}
return model.DocumentMessage(model.RoleUser, "", doc).Content
return parts
}
// historyRoleToModel 把已存聊天角色映射为模型的角色词汇,