Files
Cyrene_v2/internal/runtime/documents/extract_test.go
T
AskaEth a8851dc0a2
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(runtime): 文档摄取①(抽取器 + sidecar + 装配期 resolveDocuments)(design/24 S3-A)
- 抽取器零新依赖纯函数: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 条红,含「文档部件仍抵达下游」)。
2026-10-04 17:22:05 +08:00

198 lines
7.0 KiB
Go
Raw 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 documents
import (
"errors"
"strings"
"testing"
"unicode/utf8"
)
// 这些测试钉住抽取器的对外事实(design/24 §4.1/§4.3):纯文本无损、CSV/TSV 带
// 行数/列名摘要、超预算**诚实截断**、非法 UTF-8 不 panic 也不报废、PDF/Office
// 与二进制**诚实拒绝**(哨兵错误,用 errors.Is 判,绝不按错误文本匹配)。
// csvName / tsvName 是合法的 S1 存储名形态(32 位十六进制 + 白名单扩展名),
// 使抽取器之外的行为(sidecar 键、路径校验)与真实上传同形。
const (
csvName = "0123456789abcdef0123456789abcdef.csv"
tsvName = "0123456789abcdef0123456789abcdef.tsv"
txtName = "0123456789abcdef0123456789abcdef.txt"
)
// TestExtract_PlainTextIsLossless 验证纯文本抽取逐字无损、统计与版本随结果返回。
func TestExtract_PlainTextIsLossless(t *testing.T) {
t.Parallel()
body := "第一行\n第二行\n"
res, err := Extract(txtName, "text/plain", []byte(body))
if err != nil {
t.Fatalf("Extract: %v", err)
}
if res.Text != body {
t.Fatalf("Text = %q, want %q", res.Text, body)
}
if res.Chars != utf8.RuneCountInString(body) {
t.Fatalf("Chars = %d, want %d", res.Chars, utf8.RuneCountInString(body))
}
if res.Truncated {
t.Fatal("Truncated = true, want false")
}
if res.Kind != kindText {
t.Fatalf("Kind = %q, want %q", res.Kind, kindText)
}
if res.ExtractorVersion != ExtractorVersion {
t.Fatalf("ExtractorVersion = %q, want %q", res.ExtractorVersion, ExtractorVersion)
}
}
// TestExtract_CSVPrependsRowAndColumnSummary 钉住 CSV 特例(design/24 §4.3):
// 正文前必须有「行数/列名」摘要,否则模型会拿抽到的片段回答「一共多少行」。
func TestExtract_CSVPrependsRowAndColumnSummary(t *testing.T) {
t.Parallel()
body := "id,name\n1,alice\n2,bob\n"
res, err := Extract(csvName, "text/plain", []byte(body))
if err != nil {
t.Fatalf("Extract: %v", err)
}
if res.Kind != kindCSV {
t.Fatalf("Kind = %q, want %q", res.Kind, kindCSV)
}
summary, rest, ok := strings.Cut(res.Text, "\n")
if !ok {
t.Fatalf("Text 缺少摘要行: %q", res.Text)
}
for _, want := range []string{"共 3 行(含表头)", "2 列", "id", "name"} {
if !strings.Contains(summary, want) {
t.Fatalf("摘要 %q 缺少 %q", summary, want)
}
}
if rest != body {
t.Fatalf("摘要之后的正文 = %q, want %q", rest, body)
}
if res.Chars != utf8.RuneCountInString(res.Text) {
t.Fatalf("Chars = %d, want %d", res.Chars, utf8.RuneCountInString(res.Text))
}
}
// TestExtract_TSVPrependsSummary 验证 TSV 与 CSV 同一条特例路径(制表符切列)。
func TestExtract_TSVPrependsSummary(t *testing.T) {
t.Parallel()
res, err := Extract(tsvName, "text/plain", []byte("a\tb\n1\t2\n"))
if err != nil {
t.Fatalf("Extract: %v", err)
}
if res.Kind != kindTSV {
t.Fatalf("Kind = %q, want %q", res.Kind, kindTSV)
}
if !strings.HasPrefix(res.Text, "[TSV 摘要] 共 2 行(含表头),2 列:a, b\n") {
t.Fatalf("Text = %q, want 以 TSV 摘要行开头", res.Text)
}
}
// TestExtract_TruncatesHonestly 钉住「绝不静默截断」(design/24 §4.3):超预算时
// 截到预算、置 Truncated,且截断是 rune 安全的(多字节字符不被切成两半)。
func TestExtract_TruncatesHonestly(t *testing.T) {
t.Parallel()
body := strings.Repeat("你好", MaxExtractChars)
res, err := Extract(txtName, "text/plain", []byte(body))
if err != nil {
t.Fatalf("Extract: %v", err)
}
if !res.Truncated {
t.Fatal("Truncated = false, want true")
}
if res.Chars != MaxExtractChars {
t.Fatalf("Chars = %d, want %d", res.Chars, MaxExtractChars)
}
if want := strings.Repeat("你好", MaxExtractChars/2); res.Text != want {
t.Fatalf("Text 不是预算内的 rune 前缀(len=%d)", len(res.Text))
}
if !utf8.ValidString(res.Text) {
t.Fatal("截断后的正文不是合法 UTF-8")
}
}
// TestExtract_SanitizesInvalidUTF8 钉住「非法 UTF-8 要处理,不可 panic」:
// 非法字节被替换为 U+FFFD,正文其余部分照常抽出。
func TestExtract_SanitizesInvalidUTF8(t *testing.T) {
t.Parallel()
data := append([]byte("前缀"), 0xff, 0xfe)
data = append(data, []byte("后缀")...)
res, err := Extract(txtName, "text/plain", data)
if err != nil {
t.Fatalf("Extract: %v", err)
}
if !utf8.ValidString(res.Text) {
t.Fatalf("Text 不是合法 UTF-8: %q", res.Text)
}
if !strings.Contains(res.Text, utf8Replacement) {
t.Fatalf("Text = %q, want 含替换字符 %q", res.Text, utf8Replacement)
}
if !strings.HasPrefix(res.Text, "前缀") || !strings.HasSuffix(res.Text, "后缀") {
t.Fatalf("Text = %q, want 保留前后缀", res.Text)
}
}
// TestExtract_EmptyFileIsNotAnError 钉住「空正文」与「读不到」的区别:一份真正
// 空的可读文本不是错误(错误会让上游把「读到了空文件」报成 unreadable)。
func TestExtract_EmptyFileIsNotAnError(t *testing.T) {
t.Parallel()
res, err := Extract(txtName, "text/plain", nil)
if err != nil {
t.Fatalf("Extract(empty) = %v, want nil", err)
}
if res.Text != "" || res.Chars != 0 || res.Truncated {
t.Fatalf("Result = %+v, want 空正文且未截断", res)
}
}
// TestExtract_RefusesUnsupportedContent 钉住主路径①的诚实拒绝:二进制内容
// (探测窗口里有 NUL)返回 ErrUnsupported,而不是空正文 + nil 错误。
func TestExtract_RefusesUnsupportedContent(t *testing.T) {
t.Parallel()
data := []byte{'P', 'K', 0x03, 0x04, 0x00, 0x00, 'x'}
if _, err := Extract(txtName, "application/zip", data); !errors.Is(err, ErrUnsupported) {
t.Fatalf("Extract(zip) = %v, want ErrUnsupported", err)
}
if _, err := Extract(txtName, "text/plain", data); !errors.Is(err, ErrUnsupported) {
t.Fatalf("Extract(binary) = %v, want ErrUnsupported", err)
}
}
// TestExtract_RefusesNoTextLayer 钉住 PDF/Office 的**诚实拒绝**(design/24
// §8-1 的默认裁决):扩展名或嗅探所得 MIME 任一指向该类格式即 ErrNoTextLayer。
func TestExtract_RefusesNoTextLayer(t *testing.T) {
t.Parallel()
pdf := []byte("%PDF-1.7\n1 0 obj\n")
if _, err := Extract("report.pdf", "text/plain", pdf); !errors.Is(err, ErrNoTextLayer) {
t.Fatalf("Extract(.pdf) = %v, want ErrNoTextLayer", err)
}
if _, err := Extract("report.docx", "text/plain", []byte("docx")); !errors.Is(err, ErrNoTextLayer) {
t.Fatalf("Extract(.docx) = %v, want ErrNoTextLayer", err)
}
// 扩展名看着像文本、嗅探结果却是 PDF:MIME 只作否决参考,照样拒绝。
if _, err := Extract(txtName, "application/pdf", []byte("hi")); !errors.Is(err, ErrNoTextLayer) {
t.Fatalf("Extract(pdf mime) = %v, want ErrNoTextLayer", err)
}
}
// TestCountLines 钉住行数口径:空正文 0 行、结尾换行不额外算一行。
func TestCountLines(t *testing.T) {
t.Parallel()
cases := []struct {
in string
want int
}{
{"", 0},
{"a", 1},
{"a\n", 1},
{"a\nb", 2},
{"a\nb\n", 2},
{"\n", 1},
}
for _, c := range cases {
if got := countLines(c.in); got != c.want {
t.Fatalf("countLines(%q) = %d, want %d", c.in, got, c.want)
}
}
}