Files
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

93 lines
4.0 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package model
import (
"errors"
"testing"
)
// 本文件是文档内容类型的构造器与两条负例(design/24 §2.2、§2.3):
// ① 文档部件抵达任一编码器 → ErrBadRequest(fail-loud,绝不静默丢块);
// ② 文档部件挂在非 RoleUser 回合上 → ErrDocumentOnNonUser(编程错误)。
// 断言一律用 errors.Is 对哨兵错误,绝不按错误文本子串。
// TestDocumentMessageCarriesReference 验证构造器带上文本与服务端签发的引用。
func TestDocumentMessageCarriesReference(t *testing.T) {
t.Parallel()
doc := Document{Name: "a1b2.txt", MIME: "text/plain", URL: "/api/v1/documents/a1b2.txt", Size: 42}
msg := DocumentMessage(RoleUser, "读一下", doc)
if msg.Role != RoleUser {
t.Fatalf("role = %q, want %q", msg.Role, RoleUser)
}
if len(msg.Content) != 2 || msg.Content[0].Type != ContentTypeText || msg.Content[0].Text != "读一下" {
t.Fatalf("content = %+v, want text + document", msg.Content)
}
part := msg.Content[1]
if part.Type != ContentTypeDocument {
t.Fatalf("part type = %q, want %q", part.Type, ContentTypeDocument)
}
if part.DocumentName != doc.Name || part.DocumentMIME != doc.MIME || part.DocumentURL != doc.URL || part.DocumentSize != doc.Size {
t.Fatalf("document part = %+v, want %+v", part, doc)
}
}
// TestDocumentMessageWithoutText 验证文本为空时只留文档部件(与 ImageMessage
// 同一口径:空文本不造空片段)。
func TestDocumentMessageWithoutText(t *testing.T) {
t.Parallel()
msg := DocumentMessage(RoleUser, "", Document{Name: "a1b2.txt"})
if len(msg.Content) != 1 || msg.Content[0].Type != ContentTypeDocument {
t.Fatalf("content = %+v, want a single document part", msg.Content)
}
}
// TestEncodeContentRejectsDocument 是负例①(OpenAI 侧):未解析的文档部件抵达
// encodeContent 必须返回 ErrBadRequest,而不是被静默跳过(静默丢块会让模型
// 与用户都不知道附件没送到)。
func TestEncodeContentRejectsDocument(t *testing.T) {
t.Parallel()
_, err := encodeContent([]Content{{Type: ContentTypeDocument, DocumentName: "a1b2.txt"}})
if !errors.Is(err, ErrBadRequest) {
t.Fatalf("err = %v, want wrap of ErrBadRequest", err)
}
}
// TestEncodeAnthropicContentRejectsDocument 是负例①(Anthropic 侧)。
func TestEncodeAnthropicContentRejectsDocument(t *testing.T) {
t.Parallel()
_, err := encodeAnthropicContent([]Content{{Type: ContentTypeDocument, DocumentName: "a1b2.txt"}})
if !errors.Is(err, ErrBadRequest) {
t.Fatalf("err = %v, want wrap of ErrBadRequest", err)
}
}
// TestBuildMessagesRejectsDocumentOnNonUser 是负例②:文档部件挂到
// assistant/system 回合上是编程错误(design/11 §10.2 的用户内容归属红线),
// 两个编码器都必须报错。这里刻意用 ErrDocumentOnNonUser 而不是 ErrBadRequest
// 断言,以便区分「角色错了」与「类型没人支持」两种失败。
func TestBuildMessagesRejectsDocumentOnNonUser(t *testing.T) {
t.Parallel()
for _, role := range []Role{RoleAssistant, RoleSystem} {
msg := DocumentMessage(role, "读一下", Document{Name: "a1b2.txt"})
if _, err := buildMessages([]Message{msg}); !errors.Is(err, ErrDocumentOnNonUser) {
t.Fatalf("openai buildMessages(%s): err = %v, want wrap of ErrDocumentOnNonUser", role, err)
}
if _, _, err := buildAnthropicMessages([]Message{msg}); !errors.Is(err, ErrDocumentOnNonUser) {
t.Fatalf("anthropic buildAnthropicMessages(%s): err = %v, want wrap of ErrDocumentOnNonUser", role, err)
}
}
}
// TestValidateDocumentPartsAcceptsUserDocument 验证用户回合上的文档部件通过
// 校验:报错的是「挂错角色」,不是「带了文档」本身。
func TestValidateDocumentPartsAcceptsUserDocument(t *testing.T) {
t.Parallel()
msgs := []Message{
TextMessage(RoleSystem, "你是昔涟"),
DocumentMessage(RoleUser, "读一下", Document{Name: "a1b2.txt"}),
TextMessage(RoleAssistant, "好"),
}
if err := ValidateDocumentParts(msgs); err != nil {
t.Fatalf("ValidateDocumentParts = %v, want nil", err)
}
}