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

+10 -2
View File
@@ -9,8 +9,13 @@ import (
// 字符串与其余按 Anthropic 内容块形式表示的消息。所有
// RoleSystem 消息拼接成单个 system 提示词(Anthropic
// 把它放在请求根而非 messages 里);没有内容片段的消息
// 会被丢弃,因为 Anthropic 拒绝空内容。
// 会被丢弃,因为 Anthropic 拒绝空内容。挂在非用户回合上的
// 文档部件在这里就被拒绝(design/24 §2.3-③):文档本期永不
// 抵达编码器,漏网即编程错误,绝不静默丢块。
func buildAnthropicMessages(msgs []Message) (string, []anthropicMessage, error) {
if err := ValidateDocumentParts(msgs); err != nil {
return "", nil, err
}
var sys strings.Builder
out := make([]anthropicMessage, 0, len(msgs))
for _, m := range msgs {
@@ -97,7 +102,10 @@ func encodeAnthropicToolResult(m Message) ([]any, error) {
// encodeAnthropicContent 把归一化内容片段转成 Anthropic 内容块。
// 文本 / 图片 / tool_use(助手)片段在此处理;tool_result
// 片段由 encodeAnthropicToolResult 在消息级渲染。
// 片段由 encodeAnthropicToolResult 在消息级渲染。文档片段不走
// 这里:本期 DeepSeek 的 anthropic 兼容端点明确不支持 document
// 块,所以文档部件落到 default 分支被拒绝(design/24 §2.3-②,
// fail-loud 绝不静默丢块)。
func encodeAnthropicContent(parts []Content) ([]any, error) {
blocks := make([]any, 0, len(parts))
for _, p := range parts {
+73
View File
@@ -0,0 +1,73 @@
package model
import (
"errors"
"fmt"
)
// 本文件归属文档内容片段(design/24 §2.2、§2.3):文档的归一化形状、构造器,
// 以及「文档只允许挂在用户回合上」这条红线的校验。
//
// 本期硬规则(design/24 §2.3):**文档部件永不抵达编码器**。装配期会把它
// 确定性替换为抽取所得的文本部件,或保留为「仅清单」条目;因此任何漏网抵达
// 编码器的文档部件都是编程错误,必须报错,绝不允许静默丢块 —— 静默丢块正是
// 「传上去了但模型读不到,且双方都不知道」的来源。
// Document 描述一份服务端签发的文档引用(design/24 §2.2)。四个字段全部来自
// 服务端:Name 是服务端存储名、MIME 是字节嗅探所得、URL 是服务端本地引用、
// Size 是字节数。客户端的原始文件名与声明的 Content-Type 都不在其中 ——
// 按 design/24 §3.2 的「字节嗅探唯一裁决」口径,它们只能作参考且可被否决。
type Document struct {
// Name 是服务端存储名(32 位十六进制 + 白名单扩展名)。
Name string
// MIME 是服务端嗅探所得的媒体类型。
MIME string
// URL 是服务端签发的本地引用(文档读侧)。
URL string
// Size 是该文档的字节数。
Size int64
}
// ErrDocumentOnNonUser 在文档部件挂到非 RoleUser 回合上时返回。「用户内容
// 只进用户角色消息」是项目红线(design/11 §10.2、design/24 §2.3-③),所以
// 这是编程错误,而不是可以忽略的输入问题。
var ErrDocumentOnNonUser = errors.New("model: document content is only allowed on user messages")
// DocumentMessage 构建一条携带可选文本与一份文档引用的消息。文本为空则省略
// 文本部件(与 ImageMessage 同一口径)。
//
// 构造器刻意不校验角色:它是纯函数,角色红线由 ValidateDocumentParts 在编码
// 边界上统一裁决,从而保证「校验只有一处、漏不掉」。落位到非 user 回合的
// 消息会在那里被拒绝,而不会被静默剥掉那块内容。
func DocumentMessage(role Role, text string, doc Document) Message {
parts := make([]Content, 0, 2)
if text != "" {
parts = append(parts, Content{Type: ContentTypeText, Text: text})
}
parts = append(parts, Content{
Type: ContentTypeDocument,
DocumentName: doc.Name,
DocumentMIME: doc.MIME,
DocumentURL: doc.URL,
DocumentSize: doc.Size,
})
return Message{Role: role, Content: parts}
}
// ValidateDocumentParts 校验文档部件只出现在 RoleUser 回合上(design/24
// §2.3-③)。两个编码器都在入口调用它,因此「文档落到了 system/assistant
// 消息上」会当场报错,而不是在那里被悄悄丢掉(Anthropic 的 system 拼接与
// OpenAI 的文本拼接都会忽略非文本部件,静默丢失是默认行为)。
func ValidateDocumentParts(msgs []Message) error {
for _, m := range msgs {
if m.Role == RoleUser {
continue
}
for _, p := range m.Content {
if p.Type == ContentTypeDocument {
return fmt.Errorf("model: %w: document %q on %s", ErrDocumentOnNonUser, p.DocumentName, m.Role)
}
}
}
return nil
}
+92
View File
@@ -0,0 +1,92 @@
package model
import (
"errors"
"testing"
)
// 本文件是文档内容类型的构造器与两条负例(design/24 §2.2、§2.3):
// ① 文档部件抵达任一编码器 → ErrBadRequest(fail-loud,绝不静默丢块);
// ② 文档部件挂在非 RoleUser 回合上 → ErrDocumentOnNonUser(编程错误)。
// 断言一律用 errors.Is 对哨兵错误,绝不按错误文本子串。
// TestDocumentMessageCarriesReference 验证构造器带上文本与服务端签发的引用。
func TestDocumentMessageCarriesReference(t *testing.T) {
t.Parallel()
doc := Document{Name: "a1b2.txt", MIME: "text/plain", URL: "/api/v1/documents/a1b2.txt", Size: 42}
msg := DocumentMessage(RoleUser, "读一下", doc)
if msg.Role != RoleUser {
t.Fatalf("role = %q, want %q", msg.Role, RoleUser)
}
if len(msg.Content) != 2 || msg.Content[0].Type != ContentTypeText || msg.Content[0].Text != "读一下" {
t.Fatalf("content = %+v, want text + document", msg.Content)
}
part := msg.Content[1]
if part.Type != ContentTypeDocument {
t.Fatalf("part type = %q, want %q", part.Type, ContentTypeDocument)
}
if part.DocumentName != doc.Name || part.DocumentMIME != doc.MIME || part.DocumentURL != doc.URL || part.DocumentSize != doc.Size {
t.Fatalf("document part = %+v, want %+v", part, doc)
}
}
// TestDocumentMessageWithoutText 验证文本为空时只留文档部件(与 ImageMessage
// 同一口径:空文本不造空片段)。
func TestDocumentMessageWithoutText(t *testing.T) {
t.Parallel()
msg := DocumentMessage(RoleUser, "", Document{Name: "a1b2.txt"})
if len(msg.Content) != 1 || msg.Content[0].Type != ContentTypeDocument {
t.Fatalf("content = %+v, want a single document part", msg.Content)
}
}
// TestEncodeContentRejectsDocument 是负例①(OpenAI 侧):未解析的文档部件抵达
// encodeContent 必须返回 ErrBadRequest,而不是被静默跳过(静默丢块会让模型
// 与用户都不知道附件没送到)。
func TestEncodeContentRejectsDocument(t *testing.T) {
t.Parallel()
_, err := encodeContent([]Content{{Type: ContentTypeDocument, DocumentName: "a1b2.txt"}})
if !errors.Is(err, ErrBadRequest) {
t.Fatalf("err = %v, want wrap of ErrBadRequest", err)
}
}
// TestEncodeAnthropicContentRejectsDocument 是负例①(Anthropic 侧)。
func TestEncodeAnthropicContentRejectsDocument(t *testing.T) {
t.Parallel()
_, err := encodeAnthropicContent([]Content{{Type: ContentTypeDocument, DocumentName: "a1b2.txt"}})
if !errors.Is(err, ErrBadRequest) {
t.Fatalf("err = %v, want wrap of ErrBadRequest", err)
}
}
// TestBuildMessagesRejectsDocumentOnNonUser 是负例②:文档部件挂到
// assistant/system 回合上是编程错误(design/11 §10.2 的用户内容归属红线),
// 两个编码器都必须报错。这里刻意用 ErrDocumentOnNonUser 而不是 ErrBadRequest
// 断言,以便区分「角色错了」与「类型没人支持」两种失败。
func TestBuildMessagesRejectsDocumentOnNonUser(t *testing.T) {
t.Parallel()
for _, role := range []Role{RoleAssistant, RoleSystem} {
msg := DocumentMessage(role, "读一下", Document{Name: "a1b2.txt"})
if _, err := buildMessages([]Message{msg}); !errors.Is(err, ErrDocumentOnNonUser) {
t.Fatalf("openai buildMessages(%s): err = %v, want wrap of ErrDocumentOnNonUser", role, err)
}
if _, _, err := buildAnthropicMessages([]Message{msg}); !errors.Is(err, ErrDocumentOnNonUser) {
t.Fatalf("anthropic buildAnthropicMessages(%s): err = %v, want wrap of ErrDocumentOnNonUser", role, err)
}
}
}
// TestValidateDocumentPartsAcceptsUserDocument 验证用户回合上的文档部件通过
// 校验:报错的是「挂错角色」,不是「带了文档」本身。
func TestValidateDocumentPartsAcceptsUserDocument(t *testing.T) {
t.Parallel()
msgs := []Message{
TextMessage(RoleSystem, "你是昔涟"),
DocumentMessage(RoleUser, "读一下", Document{Name: "a1b2.txt"}),
TextMessage(RoleAssistant, "好"),
}
if err := ValidateDocumentParts(msgs); err != nil {
t.Fatalf("ValidateDocumentParts = %v, want nil", err)
}
}
+17
View File
@@ -42,6 +42,10 @@ const (
// ContentTypeToolResult 是工具执行结果片段,挂在 RoleTool
// 回合上并按原生方式编码(Anthropic tool_result / OpenAI tool message)。
ContentTypeToolResult ContentType = "tool_result"
// ContentTypeDocument 是文档内容片段(design/24 §2.2)。文档部件只允许
// 挂在 RoleUser 回合上,且**本期永不抵达编码器**:装配期会把它确定性
// 替换为抽取所得的文本部件或仅清单条目(design/24 §2.3)。
ContentTypeDocument ContentType = "document"
)
// Content 是消息的单个片段,可能是多模态的一部分。
@@ -66,6 +70,19 @@ type Content struct {
// IsError 把 ContentTypeToolResult 片段标记为执行失败,
// 以便原生设置提供商的 tool_result.is_error / error 标志。
IsError bool
// DocumentName 是 Type == ContentTypeDocument 时该文档的服务端存储名
// (design/24 §2.2、§3.2)。它由服务端签发,绝不是客户端传来的原始
// 文件名——原始文件名永不参与落盘路径。
DocumentName string
// DocumentMIME 是服务端**字节嗅探**所得的媒体类型(design/24 §3.2:
// 嗅探是唯一裁决),绝不是客户端的声明。
DocumentMIME string
// DocumentURL 是服务端签发的本地引用(design/24 §6.1 的文档读侧
// URL)。任意 http(s) URL 一律不接受:那会重开 F3 那种「服务端代取
// URL」的 SSRF 面。
DocumentURL string
// DocumentSize 是该文档的字节数。
DocumentSize int64
}
// Message 是单个会话回合,已跨提供商归一化。
+10
View File
@@ -128,7 +128,13 @@ func (a *OpenAIChatAdapter) buildRequest(req Request, stream bool) ([]byte, erro
return buf, nil
}
// buildMessages 把归一化消息列表逐条渲染成 OpenAI 线格式,并在入口拒绝
// 挂在非用户回合上的文档部件(design/24 §2.3-③):文档本期永不抵达编码器,
// 漏网即编程错误,绝不静默丢块。
func buildMessages(msgs []Message) ([]openAIMessage, error) {
if err := ValidateDocumentParts(msgs); err != nil {
return nil, err
}
out := make([]openAIMessage, 0, len(msgs))
for _, m := range msgs {
wire, err := encodeOpenAIMessage(m)
@@ -196,6 +202,10 @@ func encodeToolCalls(parts []Content) []openAIToolCall {
return calls
}
// encodeContent 把归一化内容片段转成 OpenAI 的内容字段(纯文本、或
// 文本/图片片段数组)。文档片段不走这里:本期不向提供商发任何文档块
// (design/24 §2.3-②:形状不对就是 400),因此它落到 default 分支被
// 拒绝 —— fail-loud,绝不静默丢块。
func encodeContent(parts []Content) (any, error) {
if len(parts) == 0 {
return "", nil