feat(runtime): document 内容类型与持久化(design/24 S2)
- 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:
1 parent
407c934a05
commit
6b2b07cbef
15 files changed
+895
-101
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 是单个会话回合,已跨提供商归一化。
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user