feat(runtime): 文档摄取①(抽取器 + sidecar + 装配期 resolveDocuments)(design/24 S3-A)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 抽取器零新依赖纯函数: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 条红,含「文档部件仍抵达下游」)。
This commit is contained in:
AskaEth committed 2026-10-04 17:22:05 +08:00
1 parent 834ef3683e
commit a8851dc0a2
12 files changed
+2008 -7

No files matched your search

+197
View File
@@ -0,0 +1,197 @@
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)
}
}
}