feat(runtime): document 内容类型与持久化(design/24 S2)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 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:
AskaEth committed 2026-10-04 17:07:24 +08:00
1 parent 407c934a05
commit 6b2b07cbef
15 files changed
+895 -101

No files matched your search

+196
View File
@@ -0,0 +1,196 @@
package history
import (
"context"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
)
// 本文件是文档引用列(chat_messages.document,design/24 §2.4)的存储测试:
// 引用与统计往返一致、库里**任何一列都不含抽取正文**、坏 JSON 不静默当成
// 「没有附件」,以及早于 0015 建出的旧表能被幂等前滚。
// TestDocumentRefRoundTrip 验证「引用 + 统计」落库再读出后逐字段一致。该列是
// 不透明 JSON:存储不解释、不裁剪它。
func TestDocumentRefRoundTrip(t *testing.T) {
t.Parallel()
st := newStore(t)
ctx := context.Background()
ref := DocumentRef{
Name: "0123456789abcdef0123456789abcdef.txt",
MIME: "text/plain",
Size: 12345,
Chars: 8000,
Truncated: true,
Status: DocumentStatusExtracted,
}
raw, err := EncodeDocument(ref)
if err != nil {
t.Fatalf("encode: %v", err)
}
if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "总结一下", Document: raw, CreatedAt: 1000}); err != nil {
t.Fatalf("append: %v", err)
}
msgs, err := st.List(ctx, MainSessionID, 0, 10, "")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(msgs) != 1 {
t.Fatalf("len = %d, want 1", len(msgs))
}
got, err := DecodeDocument(msgs[0].Document)
if err != nil {
t.Fatalf("decode: %v", err)
}
if got == nil {
t.Fatal("document ref lost in round trip")
}
if *got != ref {
t.Fatalf("ref = %+v, want %+v", *got, ref)
}
}
// TestDocumentRefEmptyMeansNoAttachment 验证「没有附件」与「有附件」在库里、
// 在 DTO 里都有确定区别:空引用编码成空串,空串解码回 nil。
func TestDocumentRefEmptyMeansNoAttachment(t *testing.T) {
t.Parallel()
raw, err := EncodeDocument(DocumentRef{})
if err != nil {
t.Fatalf("encode zero ref: %v", err)
}
if raw != "" {
t.Fatalf("raw = %q, want empty column", raw)
}
ref, err := DecodeDocument("")
if err != nil {
t.Fatalf("decode empty: %v", err)
}
if ref != nil {
t.Fatalf("ref = %+v, want nil", ref)
}
}
// TestDecodeDocumentRejectsMalformed 验证坏 JSON 返回错误而不是 (nil, nil):
// 解析失败绝不能静默变成「这条消息没有附件」,那正是 F2 要消灭的误解。
func TestDecodeDocumentRejectsMalformed(t *testing.T) {
t.Parallel()
if _, err := DecodeDocument("{not json"); err == nil {
t.Fatal("DecodeDocument(malformed) = nil, want an error")
}
}
// TestPersistedDocumentHoldsNoBody 断言抽取正文绝不落库(design/24 §2.4 的
// 红线):正文留在 <DataDir>/extracted 的 sidecar,历史只存引用与统计。
// 它把 chat_messages 的每一列拼起来扫一遍正文,因此任何一列(包括将来新增的
// 那些)都逃不掉。
func TestPersistedDocumentHoldsNoBody(t *testing.T) {
t.Parallel()
st := newStore(t)
ctx := context.Background()
const body = "抽取正文不可落库·SECRET-BODY-0123456789"
raw, err := EncodeDocument(DocumentRef{
Name: "aaaabbbbccccdddd.txt",
MIME: "text/plain",
Size: int64(len(body)),
Chars: len(body),
Status: DocumentStatusExtracted,
})
if err != nil {
t.Fatalf("encode: %v", err)
}
full, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "这份文档讲了什么", Document: raw})
if err != nil {
t.Fatalf("append: %v", err)
}
const allColumns = `SELECT ifnull(id,'')||ifnull(session_id,'')||ifnull(role,'')||ifnull(content,'')||
ifnull(mode,'')||ifnull(mood,'')||ifnull(image,'')||ifnull(document,'')||ifnull(thinking,'')||
ifnull(platform,'')||ifnull(device_id,'')||ifnull(sender_name,'')||ifnull(adapter_id,'')||
ifnull(persona_id,'')||ifnull(client_msg_id,'')||ifnull(created_at,'')
FROM chat_messages WHERE id = ?`
var all string
if err := st.db.QueryRowContext(ctx, allColumns, full.ID).Scan(&all); err != nil {
t.Fatalf("scan all columns: %v", err)
}
if strings.Contains(all, body) {
t.Fatalf("抽取正文落库了(该行全部列 = %q)", all)
}
if !strings.Contains(all, "aaaabbbbccccdddd.txt") {
t.Fatalf("文档引用应落库,但库里没有(该行全部列 = %q)", all)
}
}
// TestNewStoreRollsForwardDocumentColumn 验证早于 0015 建出的 chat_messages 表
// 会被 NewStore 幂等前滚出 document 列(与 client_msg_id 同一手法):全新库由
// 建表 DDL 一次建全,既有库靠补列,两条路都必须能写能读带引用的消息。
func TestNewStoreRollsForwardDocumentColumn(t *testing.T) {
t.Parallel()
ctx := context.Background()
conn, err := db.Open(ctx, ":memory:", testLogger())
if err != nil {
t.Fatalf("open memory db: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
const legacyTable = `
CREATE TABLE chat_messages (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
mode TEXT,
mood TEXT,
image TEXT,
thinking TEXT,
platform TEXT NOT NULL DEFAULT 'web',
device_id TEXT NOT NULL DEFAULT '',
sender_name TEXT NOT NULL DEFAULT '',
adapter_id TEXT NOT NULL DEFAULT '',
persona_id TEXT NOT NULL DEFAULT '',
client_msg_id TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL
)`
if _, err := conn.ExecContext(ctx, legacyTable); err != nil {
t.Fatalf("create legacy table: %v", err)
}
st, err := NewStore(ctx, conn, testLogger())
if err != nil {
t.Fatalf("new store: %v", err)
}
raw, err := EncodeDocument(DocumentRef{Name: "aaaabbbbccccdddd.txt", MIME: "text/plain", Status: DocumentStatusListed})
if err != nil {
t.Fatalf("encode: %v", err)
}
if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "带附件", Document: raw, CreatedAt: 2000}); err != nil {
t.Fatalf("append after roll forward: %v", err)
}
msgs, err := st.List(ctx, MainSessionID, 0, 10, "")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(msgs) != 1 || msgs[0].Document != raw {
t.Fatalf("msgs = %+v, want the document ref back", msgs)
}
}
// TestDocumentStatusConstants 钉住三个状态取值:它们经持久化直通 DTO,客户端
// 按这些字面量渲染「读过了 / 只有清单 / 读不到」(design/24 §6.1)。
func TestDocumentStatusConstants(t *testing.T) {
t.Parallel()
want := map[string]string{
DocumentStatusExtracted: "extracted",
DocumentStatusListed: "listed",
DocumentStatusUnreadable: "unreadable",
}
for got, expected := range want {
if got != expected {
t.Fatalf("status = %q, want %q", got, expected)
}
}
}