feat(runtime): document 内容类型与持久化(design/24 S2)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- model 新增 ContentTypeDocument + Content 的四字段 + DocumentMessage 构造器 +
  ValidateDocumentParts(文档部件只允许 RoleUser,非 user ⇒ ErrDocumentOnNonUser,fail-loud)。
- 两个编码器入口(buildMessages / buildAnthropicMessages)对文档部件报 ErrBadRequest:
  本期 document 永不抵达编码器,且绝不静默丢块。
- history:拆出 types.go(Message 增 Document 列),新增 document.go(DocumentRef 同时是
  落库 JSON 与 DTO 形状,6 字段含 status ⇒ §4.4 的「读不到要可见」;坏列回 unreadable 不静默)。
- chat_messages 加 document 列(迁移 0015 + NewStore 幂等前滚);historyMessage.Document DTO;
  session_persist 的 messageTextImage → messageTextImageDocument(保留窄视图以免牵连 steer 配对)。
- 只落「引用 + 统计」,绝不落抽取正文(TestPersistedDocumentHoldsNoBody 把全部列拼起来扫正文)。

验收(主会话独立复跑,非自报):隔离 worktree(HEAD + 本切片)go build/vet 绿、
全树 58 包 0 FAIL、linux/amd64 + darwin/arm64 交叉编译绿、18 条 Document* 测试 PASS。
变异独立复现:ValidateDocumentParts 开头 return nil ⇒ 非 user 负例红。
This commit is contained in:
AskaEth committed 2026-10-04 17:07:24 +08:00
1 parent 407c934a05
commit 6b2b07cbef
15 files changed
+895 -101

No files matched your search

+48 -9
View File
@@ -69,11 +69,17 @@ func (o *Orchestrator) persistUserMessages(ctx context.Context, sessionID string
if m.Role != model.RoleUser {
continue
}
text, image := messageTextImage(m)
if text == "" && image == "" {
text, image, document, err := messageTextImageDocument(m)
if err != nil {
// 文档引用编码失败(理论上不可达:引用只含字符串与整数)只丢那一列
// 并留下带来源的日志,整条消息照常落库 —— 丢一条消息远比丢一个附件
// 引用严重,而且这里绝不静默。
o.log.Error("orchestrator persist document", "err", err, "session", sessionID, "device_id", deviceID)
}
if text == "" && image == "" && document == "" {
continue
}
msg := history.Message{SessionID: sessionID, Role: history.RoleUser, Content: text, Image: image, DeviceID: deviceID, Mode: modeOrDefault(mode), PersonaID: personaID}
msg := history.Message{SessionID: sessionID, Role: history.RoleUser, Content: text, Image: image, Document: document, DeviceID: deviceID, Mode: modeOrDefault(mode), PersonaID: personaID}
// 幂等键只标注**第一条**用户消息:一个回合至多只有一条用户消息,而把
// 同一个键盖到多条上会让它们互相冲突(唯一索引把它们判成同一条)。
if hint.ClientMsgID != "" {
@@ -122,16 +128,49 @@ func (o *Orchestrator) persistAssistantReply(sessionID string, res *loop.Result,
o.notifyPersisted(full)
}
// messageTextImage 返回消息内容部分拼接后的文本与首个图片
// URL,供持久化进 chat_messages 各列。
func messageTextImage(m model.Message) (text, image string) {
// 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) {
for _, c := range m.Content {
if c.Type == model.ContentTypeText {
switch c.Type {
case model.ContentTypeText:
text += c.Text
} else if c.Type == model.ContentTypeImage && image == "" {
image = c.ImageURL
case model.ContentTypeImage:
if image == "" {
image = c.ImageURL
}
case model.ContentTypeDocument:
if document != "" {
continue
}
encoded, err := history.EncodeDocument(documentRefOf(c))
if err != nil {
return text, image, "", err
}
document = encoded
}
}
return text, image, document, nil
}
// documentRefOf 把文档内容片段收成落库用的引用。四个字段全部来自服务端
// (存储名、嗅探所得 MIME、本地引用、字节数),客户端的原始文件名与声明
// Content-Type 都不在其中(design/24 §2.2/§3.2)。
func documentRefOf(c model.Content) history.DocumentRef {
return history.DocumentRef{Name: c.DocumentName, MIME: c.DocumentMIME, Size: c.DocumentSize}
}
// messageTextImage 是 messageTextImageDocument 的窄视图:只要文本与首个图片
// URL。session_steer 的转写行配对只按这两项匹配,文档引用不参与;它也就
// 刻意不浮现文档编码的错误(那属于持久化路径的关切)。
func messageTextImage(m model.Message) (text, image string) {
text, image, _, _ = messageTextImageDocument(m)
return text, image
}