- 抽取器零新依赖纯函数:UTF-8 清洗、CSV/TSV 行数列名摘要、64K rune 安全截断;PDF/Office/二进制走哨兵错误诚实拒绝(不引解析库)。 - sidecar:<DataDir>/extracted/<存储名>.txt,首行紧凑 JSON 元数据 + 正文;临时文件 + rename 原子落定;只抽一次(删原文件仍出正文);版本不匹配/坏缓存重抽。 - 装配期 resolveDocuments(resolveImages 同位,每步对模型可见视图注入):清单恒在(解析器缺席/报错也在)、截断带续读游标、失败诚实标记 + status=unreadable、回合继续;文档部件永不抵达编码器(负例钉住)。 - 修 S2 移交的两处静默吞掉:checkpoint 比较键纳入文档引用、compactor token 估算计入文档部件(不是免费的)。 - 主会话独立验收:隔离 worktree(HEAD + 本切片)build/vet 绿、全树 59 包 0 FAIL、linux/amd64 + darwin/arm64 交叉编译绿、documents+loop 两包 167 条 PASS;变异独立复现(resolveDocuments 短路 ⇒ 3 条红,含「文档部件仍抵达下游」)。
358 lines
14 KiB
Go
358 lines
14 KiB
Go
package documents
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
|
)
|
|
|
|
// 这些测试钉住摄取层的落地事实(design/24 §4.3/§4.4):**只抽一次**(第二次读
|
|
// sidecar,原文件被删也照样出正文)、失败可见(unreadable + 诚实原因,且失败也
|
|
// 只抽一次)、名字形态非法绝不碰文件系统、抽取器版本变化让缓存失效。
|
|
|
|
// testLayout 是一份隔离的临时目录布局:数据目录 + S1 的上传目录。
|
|
type testLayout struct {
|
|
dataDir string
|
|
uploads string
|
|
}
|
|
|
|
func newTestLayout(t *testing.T) testLayout {
|
|
t.Helper()
|
|
data := t.TempDir()
|
|
uploads := filepath.Join(data, "uploads", "documents")
|
|
if err := os.MkdirAll(uploads, 0o700); err != nil {
|
|
t.Fatalf("mkdir uploads: %v", err)
|
|
}
|
|
return testLayout{dataDir: data, uploads: uploads}
|
|
}
|
|
|
|
// writeUpload 把一份文档放进上传目录,返回它的存储名。
|
|
func (l testLayout) writeUpload(t *testing.T, name string, data []byte) string {
|
|
t.Helper()
|
|
if err := os.WriteFile(filepath.Join(l.uploads, name), data, 0o600); err != nil {
|
|
t.Fatalf("write upload: %v", err)
|
|
}
|
|
return name
|
|
}
|
|
|
|
// sidecarPath 是某份文档的 sidecar 路径。
|
|
func (l testLayout) sidecarPath(name string) string {
|
|
return filepath.Join(l.dataDir, sidecarDirName, name+sidecarSuffix)
|
|
}
|
|
|
|
// resolveOne 用给定解析器解析一份文档消息,返回结论与正文(测试专用的窄入口)。
|
|
func resolveOne(t *testing.T, r *Resolver, name string) (history.DocumentRef, string) {
|
|
t.Helper()
|
|
msg := model.DocumentMessage(model.RoleUser, "", model.Document{Name: name, MIME: "text/plain", Size: 0})
|
|
refs, bodies, err := r.Resolve(context.Background(), msg)
|
|
if err != nil {
|
|
t.Fatalf("Resolve: %v", err)
|
|
}
|
|
return refs[name], bodies[name]
|
|
}
|
|
|
|
// TestResolver_ExtractsOnceAndThenReadsSidecar 钉住「只抽一次」(design/24 §4.3):
|
|
// 首次解析写 sidecar,之后**原文件被删**也能从 sidecar 出正文,且 sidecar 不被
|
|
// 重写(mtime 不变 —— 「后续回合不再碰原文件」在文件系统上可观测)。
|
|
func TestResolver_ExtractsOnceAndThenReadsSidecar(t *testing.T) {
|
|
t.Parallel()
|
|
layout := newTestLayout(t)
|
|
body := "报告正文:三个要点。\n"
|
|
name := layout.writeUpload(t, txtName, []byte(body))
|
|
r := NewResolver(Config{DataDir: layout.dataDir, UploadsDir: layout.uploads})
|
|
|
|
ref, got := resolveOne(t, r, name)
|
|
if ref.Status != history.DocumentStatusExtracted || got != body {
|
|
t.Fatalf("首次解析 = (%+v, %q), want extracted + 原文", ref, got)
|
|
}
|
|
first, err := os.Stat(layout.sidecarPath(name))
|
|
if err != nil {
|
|
t.Fatalf("sidecar 未写出: %v", err)
|
|
}
|
|
|
|
if err := os.Remove(filepath.Join(layout.uploads, name)); err != nil {
|
|
t.Fatalf("删除原文件: %v", err)
|
|
}
|
|
ref2, got2 := resolveOne(t, r, name)
|
|
if ref2.Status != history.DocumentStatusExtracted || got2 != body {
|
|
t.Fatalf("第二次解析 = (%+v, %q), want 从 sidecar 得到同一正文", ref2, got2)
|
|
}
|
|
second, err := os.Stat(layout.sidecarPath(name))
|
|
if err != nil {
|
|
t.Fatalf("sidecar 消失: %v", err)
|
|
}
|
|
if !second.ModTime().Equal(first.ModTime()) {
|
|
t.Fatalf("sidecar 被重写:mtime %v → %v(「只写一次」被破坏)", first.ModTime(), second.ModTime())
|
|
}
|
|
}
|
|
|
|
// TestResolver_RecordsTruncationInSidecar 钉住截断统计随 sidecar 往返:第二次
|
|
// 读 sidecar(原文件已删)仍然带着 truncated 与截断后的字符数。
|
|
func TestResolver_RecordsTruncationInSidecar(t *testing.T) {
|
|
t.Parallel()
|
|
layout := newTestLayout(t)
|
|
name := layout.writeUpload(t, txtName, []byte(strings.Repeat("宽", MaxExtractChars+10)))
|
|
r := NewResolver(Config{DataDir: layout.dataDir, UploadsDir: layout.uploads})
|
|
|
|
ref, _ := resolveOne(t, r, name)
|
|
if !ref.Truncated || ref.Chars != MaxExtractChars {
|
|
t.Fatalf("首次解析 = %+v, want truncated + Chars=%d", ref, MaxExtractChars)
|
|
}
|
|
if err := os.Remove(filepath.Join(layout.uploads, name)); err != nil {
|
|
t.Fatalf("删除原文件: %v", err)
|
|
}
|
|
ref2, body2 := resolveOne(t, r, name)
|
|
if !ref2.Truncated || ref2.Chars != MaxExtractChars {
|
|
t.Fatalf("sidecar 命中 = %+v, want 保留 truncated + Chars", ref2)
|
|
}
|
|
if len([]rune(body2)) != MaxExtractChars {
|
|
t.Fatalf("sidecar 正文 rune 数 = %d, want %d", len([]rune(body2)), MaxExtractChars)
|
|
}
|
|
}
|
|
|
|
// TestResolver_UnreadableIsHonestAndCached 钉住失败可见(design/24 §4.4):PDF
|
|
// 得到 unreadable + 诚实原因,清单/回传状态有来源;失败同样只抽一次。
|
|
func TestResolver_UnreadableIsHonestAndCached(t *testing.T) {
|
|
t.Parallel()
|
|
layout := newTestLayout(t)
|
|
name := layout.writeUpload(t, "0123456789abcdef0123456789abcdef.pdf", []byte("%PDF-1.7\n"))
|
|
r := NewResolver(Config{DataDir: layout.dataDir, UploadsDir: layout.uploads})
|
|
|
|
ref, body := resolveOne(t, r, name)
|
|
if ref.Status != history.DocumentStatusUnreadable {
|
|
t.Fatalf("Status = %q, want %q", ref.Status, history.DocumentStatusUnreadable)
|
|
}
|
|
if body != reasonNoTextLayer {
|
|
t.Fatalf("body = %q, want %q", body, reasonNoTextLayer)
|
|
}
|
|
if _, err := os.Stat(layout.sidecarPath(name)); err != nil {
|
|
t.Fatalf("失败的抽取也应留下 sidecar(只抽一次同样适用于失败): %v", err)
|
|
}
|
|
if err := os.Remove(filepath.Join(layout.uploads, name)); err != nil {
|
|
t.Fatalf("删除原文件: %v", err)
|
|
}
|
|
if _, body2 := resolveOne(t, r, name); body2 != reasonNoTextLayer {
|
|
t.Fatalf("第二次 body = %q, want 从 sidecar 读回同一原因", body2)
|
|
}
|
|
}
|
|
|
|
// TestResolver_MissingFileIsUnreadable 钉住「文件不存在」不是 error、也不是
|
|
// 静默:它是一条 unreadable 结论 + 诚实原因(回合照常继续)。
|
|
func TestResolver_MissingFileIsUnreadable(t *testing.T) {
|
|
t.Parallel()
|
|
layout := newTestLayout(t)
|
|
r := NewResolver(Config{DataDir: layout.dataDir, UploadsDir: layout.uploads})
|
|
|
|
ref, body := resolveOne(t, r, txtName)
|
|
if ref.Status != history.DocumentStatusUnreadable || body != reasonMissing {
|
|
t.Fatalf("解析不存在的文档 = (%+v, %q), want unreadable + %q", ref, body, reasonMissing)
|
|
}
|
|
if ref.Name != txtName {
|
|
t.Fatalf("Name = %q, want 部件里的存储名(身份来自部件)", ref.Name)
|
|
}
|
|
}
|
|
|
|
// TestResolver_RejectsIllegalStorageName 钉住防御性的路径安全:形态非法的名字
|
|
// 绝不参与读盘或 sidecar 路径,只得到 unreadable + 诚实原因。
|
|
func TestResolver_RejectsIllegalStorageName(t *testing.T) {
|
|
t.Parallel()
|
|
layout := newTestLayout(t)
|
|
r := NewResolver(Config{DataDir: layout.dataDir, UploadsDir: layout.uploads})
|
|
|
|
for _, name := range []string{"../secret.txt", "..", "a.txt", "0123456789abcdef0123456789abcde.txt", "0123456789abcdef0123456789abcdef./x.txt"} {
|
|
ref, body := resolveOne(t, r, name)
|
|
if ref.Status != history.DocumentStatusUnreadable || body != reasonNameInvalid {
|
|
t.Fatalf("解析 %q = (%+v, %q), want unreadable + %q", name, ref, body, reasonNameInvalid)
|
|
}
|
|
}
|
|
entries, err := os.ReadDir(filepath.Join(layout.dataDir, sidecarDirName))
|
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("read extracted dir: %v", err)
|
|
}
|
|
if len(entries) != 0 {
|
|
t.Fatalf("非法名字竟然写出了 %d 个 sidecar", len(entries))
|
|
}
|
|
}
|
|
|
|
// TestSidecar_SaveRejectsIllegalName 钉住 sidecar 写入的防御层:形态非法的名字
|
|
// 绝不落盘,也绝不把路径带出 sidecar 目录。
|
|
func TestSidecar_SaveRejectsIllegalName(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
store := newSidecarStore(dir, sourcedLog(nil))
|
|
err := store.save("../escape.txt", cachedExtract{Status: history.DocumentStatusExtracted}, 1)
|
|
if !errors.Is(err, ErrInvalidName) {
|
|
t.Fatalf("save(非法名) = %v, want ErrInvalidName", err)
|
|
}
|
|
entries, readErr := os.ReadDir(dir)
|
|
if readErr != nil {
|
|
t.Fatalf("read dir: %v", readErr)
|
|
}
|
|
if len(entries) != 0 {
|
|
t.Fatalf("非法名字竟然落盘了 %d 个文件", len(entries))
|
|
}
|
|
}
|
|
|
|
// TestSidecar_StaleVersionIsReextracted 钉住「抽取器版本」的用处(design/24
|
|
// §4.3):旧版本的 sidecar 判为失效并重抽一次,新版结论落到盘上。
|
|
func TestSidecar_StaleVersionIsReextracted(t *testing.T) {
|
|
t.Parallel()
|
|
layout := newTestLayout(t)
|
|
body := "正文\n"
|
|
name := layout.writeUpload(t, txtName, []byte(body))
|
|
if err := os.MkdirAll(filepath.Join(layout.dataDir, sidecarDirName), sidecarDirMode); err != nil {
|
|
t.Fatalf("mkdir extracted: %v", err)
|
|
}
|
|
header, err := json.Marshal(sidecarMeta{Version: "documents/v0", Status: history.DocumentStatusExtracted, Chars: 2})
|
|
if err != nil {
|
|
t.Fatalf("marshal meta: %v", err)
|
|
}
|
|
if err := os.WriteFile(layout.sidecarPath(name), append(header, '\n'), 0o600); err != nil {
|
|
t.Fatalf("write stale sidecar: %v", err)
|
|
}
|
|
|
|
r := NewResolver(Config{DataDir: layout.dataDir, UploadsDir: layout.uploads})
|
|
ref, got := resolveOne(t, r, name)
|
|
if ref.Status != history.DocumentStatusExtracted || got != body {
|
|
t.Fatalf("解析 = (%+v, %q), want 重抽得到 %q", ref, got, body)
|
|
}
|
|
raw, err := os.ReadFile(layout.sidecarPath(name))
|
|
if err != nil {
|
|
t.Fatalf("read sidecar: %v", err)
|
|
}
|
|
if !strings.Contains(string(raw), ExtractorVersion) {
|
|
t.Fatalf("sidecar 仍是旧版本: %q", string(raw))
|
|
}
|
|
}
|
|
|
|
// TestSidecar_CorruptIsReextracted 钉住「缓存坏了就重抽」:损坏的 sidecar 绝不
|
|
// 让解析失败,也绝不把畸形内容当正文注入。
|
|
func TestSidecar_CorruptIsReextracted(t *testing.T) {
|
|
t.Parallel()
|
|
layout := newTestLayout(t)
|
|
body := "好内容\n"
|
|
name := layout.writeUpload(t, txtName, []byte(body))
|
|
if err := os.MkdirAll(filepath.Join(layout.dataDir, sidecarDirName), sidecarDirMode); err != nil {
|
|
t.Fatalf("mkdir extracted: %v", err)
|
|
}
|
|
if err := os.WriteFile(layout.sidecarPath(name), []byte("not-json\n垃圾"), 0o600); err != nil {
|
|
t.Fatalf("write corrupt sidecar: %v", err)
|
|
}
|
|
|
|
r := NewResolver(Config{DataDir: layout.dataDir, UploadsDir: layout.uploads})
|
|
ref, got := resolveOne(t, r, name)
|
|
if ref.Status != history.DocumentStatusExtracted || got != body {
|
|
t.Fatalf("解析 = (%+v, %q), want 重抽得到 %q", ref, got, body)
|
|
}
|
|
}
|
|
|
|
// TestResolver_CanceledContextReturnsError 钉住契约里的 error 分支:单份文档的
|
|
// 失败是 unreadable 结论,而**整条消息解析不了**(回合已取消)才是 error,由
|
|
// 循环降级为诚实兜底。
|
|
func TestResolver_CanceledContextReturnsError(t *testing.T) {
|
|
t.Parallel()
|
|
layout := newTestLayout(t)
|
|
name := layout.writeUpload(t, txtName, []byte("正文"))
|
|
r := NewResolver(Config{DataDir: layout.dataDir, UploadsDir: layout.uploads})
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
msg := model.DocumentMessage(model.RoleUser, "", model.Document{Name: name, MIME: "text/plain"})
|
|
if _, _, err := r.Resolve(ctx, msg); !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("Resolve(已取消的 ctx) = %v, want context.Canceled", err)
|
|
}
|
|
}
|
|
|
|
// TestResolver_NoDocumentsIsNoWork 钉住短路:没有文档部件时不做任何工作,也
|
|
// 不产生结论(使没有附件的回合与接线前逐字节一致)。
|
|
func TestResolver_NoDocumentsIsNoWork(t *testing.T) {
|
|
t.Parallel()
|
|
r := NewResolver(Config{})
|
|
refs, bodies, err := r.Resolve(context.Background(), model.TextMessage(model.RoleUser, "没有附件"))
|
|
if err != nil || refs != nil || bodies != nil {
|
|
t.Fatalf("Resolve(无文档) = (%v, %v, %v), want (nil, nil, nil)", refs, bodies, err)
|
|
}
|
|
}
|
|
|
|
// TestResolver_UploadsDirMissingIsUnreadable 钉住配置缺失时的诚实:上传目录
|
|
// 没接上时每份文档都是 unreadable(而不是假装读到、也不是 error)。
|
|
func TestResolver_UploadsDirMissingIsUnreadable(t *testing.T) {
|
|
t.Parallel()
|
|
layout := newTestLayout(t)
|
|
r := NewResolver(Config{DataDir: layout.dataDir})
|
|
ref, body := resolveOne(t, r, txtName)
|
|
if ref.Status != history.DocumentStatusUnreadable || body != reasonNoUploadDir {
|
|
t.Fatalf("解析 = (%+v, %q), want unreadable + %q", ref, body, reasonNoUploadDir)
|
|
}
|
|
}
|
|
|
|
// recordingAdapter 是集成测试用的最小模型适配器:记录喂给模型的请求并回一句
|
|
// 文本,使测试能断言「模型实际看到了什么」。
|
|
type recordingAdapter struct{ reqs []model.Request }
|
|
|
|
func (a *recordingAdapter) Name() string { return "recording" }
|
|
|
|
func (a *recordingAdapter) Chat(_ context.Context, req model.Request, _ model.Options) (model.Response, error) {
|
|
a.reqs = append(a.reqs, req)
|
|
return model.Response{Model: "stub", Content: "好"}, nil
|
|
}
|
|
|
|
func (a *recordingAdapter) ChatStream(ctx context.Context, req model.Request, _ func(model.StreamEvent)) (model.Response, error) {
|
|
return a.Chat(ctx, req, model.Options{})
|
|
}
|
|
|
|
// TestResolver_EndToEndWithLoop 是本切片唯一的跨包集成:真实解析器 + 真实循环,
|
|
// 证明「上传目录里的文档 → sidecar → 装配期注入 → 模型可见正文」这条链闭合,
|
|
// 且文档部件在模型可见上下文里一个不剩(S2 的编码器对文档 fail-loud)。
|
|
func TestResolver_EndToEndWithLoop(t *testing.T) {
|
|
t.Parallel()
|
|
layout := newTestLayout(t)
|
|
body := "季度报告:营收增长 12%。\n"
|
|
name := layout.writeUpload(t, txtName, []byte(body))
|
|
r := NewResolver(Config{DataDir: layout.dataDir, UploadsDir: layout.uploads})
|
|
|
|
ad := &recordingAdapter{}
|
|
ag, err := agent.New("你是助手。", nil, nil, agent.WithModel(ad, nil))
|
|
if err != nil {
|
|
t.Fatalf("agent.New: %v", err)
|
|
}
|
|
l := loop.New(loop.Options{Agent: ag, DocumentResolver: r, MaxRounds: 1})
|
|
msg := model.DocumentMessage(model.RoleUser, "看看附件", model.Document{Name: name, MIME: "text/plain", Size: int64(len(body))})
|
|
res, err := l.Run(context.Background(), "sess-doc-e2e", []model.Message{msg})
|
|
if err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
if len(ad.reqs) == 0 {
|
|
t.Fatal("没有记录到模型请求")
|
|
}
|
|
injected := false
|
|
for _, m := range ad.reqs[0].Messages {
|
|
for _, part := range m.Content {
|
|
if part.Type == model.ContentTypeDocument {
|
|
t.Fatalf("文档部件抵达了模型: %+v", m)
|
|
}
|
|
if part.Type == model.ContentTypeText && strings.Contains(part.Text, "营收增长 12%") {
|
|
injected = true
|
|
}
|
|
}
|
|
}
|
|
if !injected {
|
|
t.Fatal("抽取正文没有注入模型可见上下文")
|
|
}
|
|
if len(res.Documents) != 1 || res.Documents[0].Status != history.DocumentStatusExtracted {
|
|
t.Fatalf("Result.Documents = %+v, want 一条 extracted", res.Documents)
|
|
}
|
|
if _, err := os.Stat(layout.sidecarPath(name)); err != nil {
|
|
t.Fatalf("sidecar 未写出: %v", err)
|
|
}
|
|
}
|