- 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 负例红。
102 lines
4.4 KiB
Go
102 lines
4.4 KiB
Go
package history
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// 本文件归属聊天消息携带的**文档引用**(design/24 §2.4),以及 chat_messages
|
|
// 上「建表之后才引入的列」的前滚聚合(client_msg_id 与 document)。
|
|
//
|
|
// document 列只存「引用 + 抽取统计」——存储名、嗅探所得 MIME、字节数、抽取
|
|
// 字符数、是否截断、抽取状态;**绝不存抽取正文**。G3(base64 原样落库 → 每页
|
|
// 历史背着重传)与 H1(每回合重算)两条教训说的都是同一件事:正文一旦进了
|
|
// 历史,每读一页都要为它付一次代价,而且它是提示词注入面(design/11 §10)。
|
|
|
|
// 文档在持久化与历史 DTO 里可能处于的抽取状态(design/24 §6.1),由装配期
|
|
// 与抽取器填入。
|
|
const (
|
|
// DocumentStatusExtracted 表示正文已抽取并进了上下文。
|
|
DocumentStatusExtracted = "extracted"
|
|
// DocumentStatusListed 表示只给了模型一份附件清单,正文没有进上下文。
|
|
DocumentStatusListed = "listed"
|
|
// DocumentStatusUnreadable 表示读不到正文(抽取失败、类型不可解析)。
|
|
DocumentStatusUnreadable = "unreadable"
|
|
)
|
|
|
|
// DocumentRef 是一条消息携带的文档引用与抽取统计。它就是 chat_messages.document
|
|
// 列里的 JSON 小对象,**同时**也是历史 DTO 里 document 对象的形状:两边共用
|
|
// 同一个结构体,所以「不回传正文」是结构上保证的,而不是靠约定或代码审查。
|
|
type DocumentRef struct {
|
|
// Name 是服务端存储名(design/24 §3.2:32 位十六进制 + 白名单扩展名)。
|
|
Name string `json:"name"`
|
|
// MIME 是服务端字节嗅探所得,绝不是客户端的声明。
|
|
MIME string `json:"mime"`
|
|
// Size 是该文档的字节数。
|
|
Size int64 `json:"size"`
|
|
// Chars 是本次抽取进上下文的字符数(已截断者填截断后的值)。
|
|
Chars int `json:"chars,omitempty"`
|
|
// Truncated 报告抽取正文是否被预算截断(design/24 §4.3:截断必须诚实
|
|
// 标注,绝不静默)。
|
|
Truncated bool `json:"truncated,omitempty"`
|
|
// Status 是抽取状态,取值见 DocumentStatus* 常量。
|
|
Status string `json:"status,omitempty"`
|
|
}
|
|
|
|
// EncodeDocument 把文档引用编码成落库用的 JSON 小对象。空引用返回 "",使
|
|
// 「这条消息没有附件」与「有附件」在库里、在 DTO 里都有确定区别。
|
|
func EncodeDocument(ref DocumentRef) (string, error) {
|
|
if ref == (DocumentRef{}) {
|
|
return "", nil
|
|
}
|
|
b, err := json.Marshal(ref)
|
|
if err != nil {
|
|
return "", fmt.Errorf("history: encode document ref: %w", err)
|
|
}
|
|
return string(b), nil
|
|
}
|
|
|
|
// DecodeDocument 解码落库的文档引用。空串(这条消息没有附件)返回 (nil, nil);
|
|
// 非法 JSON 返回错误,由调用方决定如何**可见地**报告 —— 解析失败绝不静默当成
|
|
// 「没有附件」,那正是 F2 要消灭的那种「一方以为传上去了,另一方一无所知」。
|
|
func DecodeDocument(raw string) (*DocumentRef, error) {
|
|
if raw == "" {
|
|
return nil, nil
|
|
}
|
|
var ref DocumentRef
|
|
if err := json.Unmarshal([]byte(raw), &ref); err != nil {
|
|
return nil, fmt.Errorf("history: decode document ref: %w", err)
|
|
}
|
|
return &ref, nil
|
|
}
|
|
|
|
// ensureMessageColumns 把 chat_messages 上「建表之后才引入的列」前滚到既有表。
|
|
// 全新库由 createMessagesTable 一次建全,因此这里对它们都是空操作;早于某列
|
|
// 建出的表则在这里补上(与 memory 包的 ensureProjectIDColumn 同一手法)。
|
|
func ensureMessageColumns(ctx context.Context, db *sql.DB) error {
|
|
if err := ensureClientMsgIDColumn(ctx, db); err != nil {
|
|
return err
|
|
}
|
|
return ensureDocumentColumn(ctx, db)
|
|
}
|
|
|
|
// ensureDocumentColumn 在 chat_messages 早于本列(0015 迁移)建表时把它前滚:
|
|
// 缺列则补加,已有则忽略重复列错误。它是幂等的,因此 NewStore 可在全新 schema
|
|
// 或既有 schema 上调用。
|
|
func ensureDocumentColumn(ctx context.Context, db *sql.DB) error {
|
|
_, err := db.ExecContext(ctx, `ALTER TABLE chat_messages
|
|
ADD COLUMN document TEXT NOT NULL DEFAULT ''`)
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
// 列已存在时(由 createMessagesTable 建出,或上一次运行补过)SQLite 报
|
|
// "duplicate column name: document"。
|
|
if strings.Contains(err.Error(), "duplicate column name") {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("history: roll forward document column: %w", err)
|
|
}
|