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 需重新拉取/回放可见)。
This commit is contained in:
1 parent
20c1edb36a
commit
5b2b385524
12 files changed
+1255
-166
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user