Files
Cyrene_v2/internal/runtime/model/document.go
T
AskaEth 6b2b07cbef
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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 负例红。
2026-10-04 17:07:24 +08:00

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
}