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

+50 -22
View File
@@ -60,26 +60,32 @@ func modeFrom(ctx context.Context) string {
// ctx 上若带着客户端的幂等键(G4,clientMsgHintFrom),第一条用户消息会带上它
// 与编排器预分配的消息 id:写入因此可被 (session_id, client_msg_id) 的唯一索引
// 判重。省略幂等键时 ctx 上没有该值,写入与加入这一列之前逐字相同。
func (o *Orchestrator) persistUserMessages(ctx context.Context, sessionID string, messages []model.Message, deviceID, mode, personaID string) {
//
// 它返回刚落库的那些消息(含存储分配的 id 与**只有引用**的 document 列),
// 使调用方能把它们挂到本回合上:抽取结论要等回合跑起来才有,只能在回合结束后
// 回写进同一列(design/24 §6.1,见 session_document_status.go)。这就是那条
// 「回合前落引用、回合后补状态」的两段式。
func (o *Orchestrator) persistUserMessages(ctx context.Context, sessionID string, messages []model.Message, deviceID, mode, personaID string) []history.Message {
if o.history == nil {
return
return nil
}
hint := clientMsgHintFrom(ctx)
persisted := make([]history.Message, 0, len(messages))
for _, m := range messages {
if m.Role != model.RoleUser {
continue
}
text, image, document, err := messageTextImageDocument(m)
text, image, documents, err := messageTextImageDocument(m)
if err != nil {
// 文档引用编码失败(理论上不可达:引用只含字符串与整数)只丢那一列
// 并留下带来源的日志,整条消息照常落库 —— 丢一条消息远比丢一个附件
// 引用严重,而且这里绝不静默。
// 文档引用编码失败(只可能是坏数据或超限)只丢那一列并留下带来源的
// 日志,整条消息照常落库 —— 丢一条消息远比丢一个附件引用严重,而且
// 这里绝不静默。
o.log.Error("orchestrator persist document", "err", err, "session", sessionID, "device_id", deviceID)
}
if text == "" && image == "" && document == "" {
if text == "" && image == "" && documents == "" {
continue
}
msg := history.Message{SessionID: sessionID, Role: history.RoleUser, Content: text, Image: image, Document: document, DeviceID: deviceID, Mode: modeOrDefault(mode), PersonaID: personaID}
msg := history.Message{SessionID: sessionID, Role: history.RoleUser, Content: text, Image: image, Document: documents, DeviceID: deviceID, Mode: modeOrDefault(mode), PersonaID: personaID}
// 幂等键只标注**第一条**用户消息:一个回合至多只有一条用户消息,而把
// 同一个键盖到多条上会让它们互相冲突(唯一索引把它们判成同一条)。
if hint.ClientMsgID != "" {
@@ -96,6 +102,7 @@ func (o *Orchestrator) persistUserMessages(ctx context.Context, sessionID string
}
continue
}
persisted = append(persisted, full)
if msg.ClientMsgID != "" && full.ID != msg.ID {
// 存储侧唯一索引裁决出既有行(例如本进程重启后另一次写入已落库):
// 把权威 id 覆盖进认领表,别让后续重复请求拿到一个悬空的 messageId。
@@ -103,6 +110,7 @@ func (o *Orchestrator) persistUserMessages(ctx context.Context, sessionID string
}
o.notifyPersisted(full)
}
return persisted
}
// persistAssistantReply 把已结算回合的最终助手文本写入
@@ -128,15 +136,21 @@ func (o *Orchestrator) persistAssistantReply(sessionID string, res *loop.Result,
o.notifyPersisted(full)
}
// messageTextImageDocument 返回消息内容部分拼接后的文本、首个图片 URL 与首个
// 文档引用(已编码为落库用的 JSON 小对象),供持久化进 chat_messages 各列。
// messageTextImageDocument 返回消息内容部分拼接后的文本、首个图片 URL 与**全部**
// 文档引用(已编码为落库用的 JSON 数组),供持久化进 chat_messages 各列。
//
// 文档那一半只落**引用与统计**(design/24 §2.4):装配期已经把抽取所得的正文
// 替换成文本部件,因此这里绝不把正文写进 document 列 —— 正文一旦落库,每翻
// 一页历史都要背着它重传(G3 的教训)。抽取统计(chars/truncated/status)属于
// 装配期与抽取器,模型层只携带引用本身;编码失败时返回错误,由调用方决定如何
// 可见地报告,而不是在这里静默当成「没有附件」。
func messageTextImageDocument(m model.Message) (text, image, document string, err error) {
// 装配期与抽取器,模型层只携带引用本身,它们由回合结束后的回写补进同一列
// (见 session_document_status.go)。
//
// 一个回合可以挂多个附件(design/24 §6.1 的上限 5),因此这里按**部件顺序**收集
// 全部引用:此前「只留第一个」的写法会让其余几个从历史与后续回合的上下文里凭空
// 消失(缺口①)。编码失败(含超限)时返回错误,由调用方决定如何可见地报告,
// 而不是在这里静默当成「没有附件」。
func messageTextImageDocument(m model.Message) (text, image, documents string, err error) {
refs := make([]history.DocumentRef, 0, len(m.Content))
for _, c := range m.Content {
switch c.Type {
case model.ContentTypeText:
@@ -146,17 +160,14 @@ func messageTextImageDocument(m model.Message) (text, image, document string, er
image = c.ImageURL
}
case model.ContentTypeDocument:
if document != "" {
continue
}
encoded, err := history.EncodeDocument(documentRefOf(c))
if err != nil {
return text, image, "", err
}
document = encoded
refs = append(refs, documentRefOf(c))
}
}
return text, image, document, nil
encoded, err := history.EncodeDocuments(refs)
if err != nil {
return text, image, "", err
}
return text, image, encoded, nil
}
// documentRefOf 把文档内容片段收成落库用的引用。四个字段全部来自服务端
@@ -359,3 +370,20 @@ func (o *Orchestrator) releaseClientMsg(sessionID, clientMsgID, msgID string) {
}
}
}
// deviceIDContextKey 是携带消息客户端 deviceID 的 context 键
// (design/16 §4.5)从 HTTP/WS 边界引入编排器的持久化。
type deviceIDContextKey struct{}
// withDeviceID 返回携带 deviceID 的 context,使编排器能标注
// 持久化的用户消息。空 deviceID 是空操作(不写入标签)。
func withDeviceID(ctx context.Context, deviceID string) context.Context {
return context.WithValue(ctx, deviceIDContextKey{}, deviceID)
}
// deviceIDFrom 返回 ctx 携带的 deviceID,未设置时返回 ""。它
// 只返回格式良好的非空值;其他一律视为缺失。
func deviceIDFrom(ctx context.Context) string {
v, _ := ctx.Value(deviceIDContextKey{}).(string)
return v
}