- 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 负例红。
197 lines
6.4 KiB
Go
197 lines
6.4 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|