- 窗口声明:models.json 组级 contextWindow + env LLM_CONTEXT_WINDOW(per-group materialize 播种,0 不写盘、负数报错)。 - 预算三态:CONTEXT_MAX_TOKENS 未设置 ⇒ 按窗口推导;>0 显式覆盖;0|负 关闭压缩(不变)。推导 budget = min(窗口×比例, 窗口−输出保留),比例默认 2/3(CONTEXT_BUDGET_RATIO,1M ⇒ 699050 tokens ≈ 683Ki);输出保留 = max(32768, 该组 maxTokens) 且封顶窗口/2(防配置矛盾时退化成 1 token);窗口未声明 ⇒ 回落 131072 + 恰好一条 WARN。接线在组合根 wire_context.go(唯一决策点)。 - usage/缓存可观测(此前**完全不可观测**):Response 增 CacheHit/CacheMiss/Reasoning tokens + CacheHitRate;解析 OpenAI 兼容(prompt_cache_hit_tokens/prompt_cache_miss_tokens/cached_tokens/reasoning_tokens)与 anthropic(cache_read_input_tokens/cache_creation_input_tokens)两种形状;记录点在适配器 record(换入口不丢成本日志),日志无凭据;单次输入 ≥预算 90% 记 WARN(不阻断)。 - 根治清单「续读」假承诺:抽取期不再按注入预算截断(sidecar 存全文,只受 MaxSidecarBytes=8 MiB 安全上限,ExtractorVersion v1→v2 旧 sidecar 失效重抽);注入预算改在装配期生效 ⇒ ref.Chars/Truncated 是注入侧事实、游标真是继续读的起点;document-read 的 limit 上限改为独立具名常量。 - 顺手修越界:config.go 505 行(超 500)拆出 llm.go。 - 主会话独立验收:隔离 worktree(HEAD + 本切片 39 文件,排除在途 gateway)build/vet 绿、全树 59 包 0 FAIL、linux+darwin 交叉编译绿;变异独立复现(比例改 0.25 ⇒ 推导用例红 262144 vs 699050)。
224 lines
8.3 KiB
Go
224 lines
8.3 KiB
Go
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_TruncatesAtSafetyCap 钉住「绝不静默截断」(design/24 §4.3):
|
||
// 撞上**安全上限**(MaxSidecarBytes,8 MiB 文本)时截到上限并置 Truncated,且截断
|
||
// 是 rune 安全的(多字节字符不被切成两半)。
|
||
//
|
||
// 注意它钉的不再是「注入预算」:注入预算由装配期按上下文预算决定(切片 T1 §7.2),
|
||
// 抽取器只保证 sidecar 里存得下全文、且撞上上限时如实记账。
|
||
func TestExtract_TruncatesAtSafetyCap(t *testing.T) {
|
||
t.Parallel()
|
||
body := strings.Repeat("你好", MaxSidecarBytes/len("你好")+16)
|
||
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 len(res.Text) > MaxSidecarBytes {
|
||
t.Fatalf("截断后正文 %d 字节,超出安全上限 %d", len(res.Text), MaxSidecarBytes)
|
||
}
|
||
if res.Chars != utf8.RuneCountInString(res.Text) {
|
||
t.Fatalf("Chars = %d, want 正文 rune 数 %d", res.Chars, utf8.RuneCountInString(res.Text))
|
||
}
|
||
if !utf8.ValidString(res.Text) {
|
||
t.Fatal("截断后的正文不是合法 UTF-8")
|
||
}
|
||
// 上限内的正文逐字无损:截断只砍尾巴,不改写已有内容。
|
||
if !strings.HasPrefix(body, res.Text) {
|
||
t.Fatal("截断后的正文不是原文的前缀")
|
||
}
|
||
}
|
||
|
||
// TestExtract_KeepsFullTextBelowSafetyCap 钉住切片 T1 §7.2 的核心:抽取期**不再**
|
||
// 按注入预算截断,因此一份远超旧 64K 字符、但仍在安全上限内的文档必须整份存下 ——
|
||
// 否则 document-read 永远读不到注入预算之外的正文(S4 实测的断裂)。
|
||
func TestExtract_KeepsFullTextBelowSafetyCap(t *testing.T) {
|
||
t.Parallel()
|
||
body := strings.Repeat("宽", 200*1024) // 20 万字符:远超旧的 64K 注入预算
|
||
res, err := Extract(txtName, "text/plain", []byte(body))
|
||
if err != nil {
|
||
t.Fatalf("Extract: %v", err)
|
||
}
|
||
if res.Truncated {
|
||
t.Fatal("Truncated = true, want false(安全上限内的正文不该被截断)")
|
||
}
|
||
if res.Chars != 200*1024 || res.Text != body {
|
||
t.Fatalf("Chars = %d(want %d),正文是否逐字无损 = %v", res.Chars, 200*1024, res.Text == body)
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
}
|
||
}
|