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 }