- 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 负例红。
74 lines
3.2 KiB
Go
74 lines
3.2 KiB
Go
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
|
|
}
|