Files
Cyrene_v2/plugins/tools/io/document_read_test.go
T
AskaEth 72b95df22b
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(config,runtime): 上下文预算按模型窗口推导 + usage/缓存可观测 + sidecar 存全文(T1)
- 窗口声明: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)。
2026-10-04 17:57:08 +08:00

408 lines
17 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 io
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"unicode/utf8"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
)
// docName 造一个形态合法的服务端存储名:32 位小写十六进制主名 + 扩展名。
func docName(ext string) string { return strings.Repeat("ab12", 8) + ext }
// docReadFixture 是一份隔离的数据目录 + **真实上游解析器**。夹具里的 sidecar 一律由
// documents.Resolver(S3-A 的生产者)写出,测试因此不复制「首行元数据 + 正文自第二行
// 起」的第二份实现:生产者与消费者在同一份夹具里对接,sidecar 形状一漂移测试就红。
type docReadFixture struct {
dataDir string
uploads string
resolver *documents.Resolver
}
func newDocReadFixture(t *testing.T) *docReadFixture {
t.Helper()
dataDir := t.TempDir()
uploads := filepath.Join(dataDir, "uploads", "documents")
if err := os.MkdirAll(uploads, 0o700); err != nil {
t.Fatalf("建上传目录: %v", err)
}
return &docReadFixture{
dataDir: dataDir,
uploads: uploads,
resolver: documents.NewResolver(documents.Config{DataDir: dataDir, UploadsDir: uploads}),
}
}
// extract 落一份上传件并让装配期解析器跑一次(写出 sidecar),返回结论与**注入正文**:
// 后者就是模型在清单行下面看到的正文,也是工具 offset/limit 的坐标系。
func (f *docReadFixture) extract(t *testing.T, name, content string) (history.DocumentRef, string) {
t.Helper()
if err := os.WriteFile(filepath.Join(f.uploads, name), []byte(content), 0o600); err != nil {
t.Fatalf("写上传件: %v", err)
}
msg := model.DocumentMessage(model.RoleUser, "", model.Document{Name: name, MIME: "text/plain", Size: int64(len(content))})
refs, bodies, err := f.resolver.Resolve(context.Background(), msg)
if err != nil {
t.Fatalf("解析 %q: %v", name, err)
}
return refs[name], bodies[name]
}
// sidecarPath 是某份文档的 sidecar 路径(与上游同一命名规则,供断言用)。
func (f *docReadFixture) sidecarPath(name string) string {
return filepath.Join(f.dataDir, "extracted", name+".txt")
}
// tool 返回指向同一份数据目录的 document-read 工具。
func (f *docReadFixture) tool(t *testing.T) *documentReadTool {
t.Helper()
got, ok := findTool(t, buildTools(t, &config.Config{DataDir: f.dataDir}), documentReadToolName).(*documentReadTool)
if !ok {
t.Fatalf("%q 工具类型不符", documentReadToolName)
}
return got
}
// callDocRead 执行一次 document-read,并把返回的 JSON 文本解回结果结构。工具返回的是
// **字符串**(循环的 formatToolResult 对字符串直通,模型看到裸 JSON),这里因此必须反
// 序列化才能断言字段 —— 它同时钉住「返回值是 JSON 文本」这条约定。
func callDocRead(t *testing.T, tl *documentReadTool, args map[string]any) (documentReadResult, error) {
t.Helper()
raw, err := tl.Execute(context.Background(), args)
if err != nil {
return documentReadResult{}, err
}
text, ok := raw.(string)
if !ok {
t.Fatalf("返回值类型 = %T, want string(JSON 文本)", raw)
}
var out documentReadResult
if err := json.Unmarshal([]byte(text), &out); err != nil {
t.Fatalf("返回值不是合法 JSON: %v(%q)", err, text)
}
return out, nil
}
// TestDocumentReadTool_PaginatesWithNextOffset 钉住①:首读 + 用 nextOffset 续读,两段
// 拼接逐字等于装配期注入的正文(两边同一坐标系),游标单位为字符而非字节。
func TestDocumentReadTool_PaginatesWithNextOffset(t *testing.T) {
t.Parallel()
fx := newDocReadFixture(t)
name := docName(".txt")
body := "季度报告:营收增长 12%。\n" + strings.Repeat("第二段:成本下降 3%。\n", 12)
_, injected := fx.extract(t, name, body)
tool := fx.tool(t)
total := utf8.RuneCountInString(injected)
half := total / 2
first, err := callDocRead(t, tool, map[string]any{"name": name, "limit": half})
if err != nil {
t.Fatalf("首读: %v", err)
}
if first.Status != "extracted" || first.Offset != 0 || first.TotalChars != total {
t.Fatalf("首读 = %+v, want extracted / offset 0 / totalChars %d", first, total)
}
if !first.Truncated || first.NextOffset != half || utf8.RuneCountInString(first.Text) != half {
t.Fatalf("首读截断信息 = %+v, want truncated + nextOffset %d + 正文 %d 字符", first, half, half)
}
second, err := callDocRead(t, tool, map[string]any{"name": name, "offset": first.NextOffset})
if err != nil {
t.Fatalf("续读: %v", err)
}
if second.Offset != half || second.Truncated || second.NextOffset != 0 {
t.Fatalf("续读 = %+v, want 读到末尾(offset %d、无续读游标)", second, half)
}
if first.Text+second.Text != injected {
t.Fatalf("两段拼接 %q + %q != 注入正文 %q", first.Text, second.Text, injected)
}
if second.Text != string([]rune(injected)[half:]) {
t.Fatalf("续读正文 = %q, want 注入正文的后半段", second.Text)
}
}
// TestDocumentReadTool_ContinuesAcrossPages 钉住「续读之后**仍然**被 limit 截断」这条
// 路径:游标必须一页页往前走(既不原地打转,也不吞掉中间的内容),多页拼接逐字等于
// 注入正文。
func TestDocumentReadTool_ContinuesAcrossPages(t *testing.T) {
t.Parallel()
fx := newDocReadFixture(t)
name := docName(".txt")
_, injected := fx.extract(t, name, strings.Repeat("一行正文,用于多页续读。\n", 20))
tool := fx.tool(t)
total := utf8.RuneCountInString(injected)
step := total/4 + 1 // 至少 3 页,因此中间那些页后面还有内容
var joined string
offset, pages := 0, 0
for {
if pages++; pages > 8 {
t.Fatalf("分页不收敛:offset 停在 %d", offset)
}
page, err := callDocRead(t, tool, map[string]any{"name": name, "offset": offset, "limit": step})
if err != nil {
t.Fatalf("第 %d 页: %v", pages, err)
}
if page.Offset != offset {
t.Fatalf("第 %d 页 offset = %d, want %d", pages, page.Offset, offset)
}
// limit 是**上限**:任何一页都不得超出(多给会让模型的上下文预算失控)。
if n := utf8.RuneCountInString(page.Text); n > step {
t.Fatalf("第 %d 页返回 %d 字符, 超过 limit %d", pages, n, step)
}
joined += page.Text
if page.Truncated && utf8.RuneCountInString(page.Text) != step {
t.Fatalf("第 %d 页被截断却只给了 %d 字符, want %d", pages, utf8.RuneCountInString(page.Text), step)
}
if !page.Truncated {
if page.NextOffset != 0 {
t.Fatalf("末页仍带续读游标 %d", page.NextOffset)
}
break
}
if want := offset + utf8.RuneCountInString(page.Text); page.NextOffset != want {
t.Fatalf("第 %d 页续读游标 = %d, want %d", pages, page.NextOffset, want)
}
offset = page.NextOffset
}
if pages < 3 {
t.Fatalf("只读到 %d 页,夹具没有覆盖「续读后仍被截断」", pages)
}
if joined != injected {
t.Fatalf("多页拼接 = %d 字符, want 注入正文 %d 字符", utf8.RuneCountInString(joined), total)
}
}
// TestDocumentReadTool_LimitDefaultAndCap 钉住 limit 的缺省与上限:缺省取
// defaultDocumentReadChars,超过上限的请求夹到 maxDocumentReadChars —— 正文本身就在同一
// 上限内(抽取期预算),因此夹取只会削掉「多要了」的部分,不会少给已存在的正文。
func TestDocumentReadTool_LimitDefaultAndCap(t *testing.T) {
t.Parallel()
fx := newDocReadFixture(t)
name := docName(".txt")
_, injected := fx.extract(t, name, "短正文:只有这一行。\n")
tool := fx.tool(t)
got, err := callDocRead(t, tool, map[string]any{"name": name})
if err != nil {
t.Fatalf("缺省读取: %v", err)
}
if got.Limit != defaultDocumentReadChars || got.Truncated || got.NextOffset != 0 || got.Text != injected {
t.Fatalf("缺省读取 = %+v, want limit %d、无截断、正文 %q", got, defaultDocumentReadChars, injected)
}
capped, err := callDocRead(t, tool, map[string]any{"name": name, "limit": maxDocumentReadChars * 2})
if err != nil {
t.Fatalf("超大 limit 读取: %v", err)
}
if capped.Limit != maxDocumentReadChars {
t.Fatalf("limit 夹取 = %d, want %d", capped.Limit, maxDocumentReadChars)
}
}
// TestDocumentReadTool_OffsetOutOfRange 钉住②:负 offset 在碰盘之前就被拒,超过正文长度
// 的 offset 也不静默给空,而「恰好等于正文长度」是合法的「读到末尾」。
func TestDocumentReadTool_OffsetOutOfRange(t *testing.T) {
t.Parallel()
fx := newDocReadFixture(t)
name := docName(".txt")
_, injected := fx.extract(t, name, "abcdefghij\n")
tool := fx.tool(t)
total := utf8.RuneCountInString(injected)
// 负数:用一份**从未抽取过**的名字证明它在读盘之前就被拒 —— 若实现先读盘,这里得到的
// 会是「没有可读的抽取结果」而不是 offset 错误。
if _, err := callDocRead(t, tool, map[string]any{"name": docName(".log"), "offset": -1}); !errors.Is(err, ErrDocumentOffset) {
t.Fatalf("负 offset → %v, want ErrDocumentOffset(应在碰盘前被拒)", err)
}
if _, err := callDocRead(t, tool, map[string]any{"name": name, "offset": total + 1}); !errors.Is(err, ErrDocumentOffset) {
t.Fatalf("越界 offset → %v, want ErrDocumentOffset", err)
}
last, err := callDocRead(t, tool, map[string]any{"name": name, "offset": total})
if err != nil {
t.Fatalf("末尾读取: %v", err)
}
if last.Text != "" || last.Truncated || last.NextOffset != 0 || last.TotalChars != total {
t.Fatalf("末尾读取 = %+v, want 空 text + 无续读 + totalChars %d", last, total)
}
}
// TestDocumentReadTool_RejectsIllegalName 钉住④(含「不碰盘」):形态非法的名字必须在触达
// 文件系统之前被拒。数据目录在这里被指向一条**不存在**的路径:若实现先读盘,得到的会是
// 「没有可读的抽取结果」,因此 errors.Is(ErrInvalidName) 本身就是「没碰盘」的证据。
func TestDocumentReadTool_RejectsIllegalName(t *testing.T) {
t.Parallel()
dataDir := filepath.Join(t.TempDir(), "missing")
tool, ok := findTool(t, buildTools(t, &config.Config{DataDir: dataDir}), documentReadToolName).(*documentReadTool)
if !ok {
t.Fatalf("%q 工具类型不符", documentReadToolName)
}
illegal := []string{
"", // 空名字
"../secret.txt", // 路径穿越
"..", // 父目录
"a.txt", // 主名不足 32 位
"ab12ab12ab12ab12ab12ab12ab12ab1.txt", // 31 位主名
docName(".TXT"), // 大写扩展名
strings.Repeat("g", 32) + ".txt", // 主名不是十六进制
docName("." + strings.Repeat("x", 13)), // 扩展名超长(>12)
"extracted/" + docName(".txt"), // 含目录分隔符
`C:\Windows\x.txt`, // 绝对路径
"ab12ab12ab12ab12ab12ab12ab12ab12..txt", // 扩展名里带点
}
for _, name := range illegal {
if _, err := callDocRead(t, tool, map[string]any{"name": name}); !errors.Is(err, documents.ErrInvalidName) {
t.Fatalf("name %q → %v, want documents.ErrInvalidName", name, err)
}
}
if _, err := tool.Execute(context.Background(), "not-an-object"); err == nil {
t.Fatal("非对象参数应当报错")
}
entries, err := os.ReadDir(filepath.Join(dataDir, "extracted"))
if err != nil && !errors.Is(err, os.ErrNotExist) {
t.Fatalf("读 extracted 目录: %v", err)
}
if len(entries) != 0 {
t.Fatalf("非法名字竟然产生了 %d 个文件", len(entries))
}
// 扩展名白名单是 S1 存储侧的裁决(design/24 §4.2 末:不重复存储侧校验、不扩大访问面):
// 形态合法但 S1 永不会签发的扩展名因此走到「没有可读的抽取结果」这条诚实错误。
if _, err := callDocRead(t, tool, map[string]any{"name": docName(".exe")}); !errors.Is(err, documents.ErrSidecarUnavailable) {
t.Fatalf("形态合法但无对应 sidecar → %v, want documents.ErrSidecarUnavailable", err)
}
}
// TestDocumentReadTool_MissingSidecarIsHonest 钉住③(缺失)与「只读 sidecar,绝不重抽」:
// 没有 sidecar 时给的是诚实错误,而不是空正文、也不是回头重抽原文件。
func TestDocumentReadTool_MissingSidecarIsHonest(t *testing.T) {
t.Parallel()
fx := newDocReadFixture(t)
name := docName(".txt")
tool := fx.tool(t)
if _, err := callDocRead(t, tool, map[string]any{"name": name}); !errors.Is(err, documents.ErrSidecarUnavailable) {
t.Fatalf("无 sidecar → %v, want documents.ErrSidecarUnavailable", err)
}
// 抽取过、随后 sidecar 被清掉:原文件仍在,但工具**绝不**因此回头重抽。
_, injected := fx.extract(t, name, "正文在 sidecar 里。\n")
if err := os.Remove(fx.sidecarPath(name)); err != nil {
t.Fatalf("删除 sidecar: %v", err)
}
if _, err := callDocRead(t, tool, map[string]any{"name": name}); !errors.Is(err, documents.ErrSidecarUnavailable) {
t.Fatalf("sidecar 被清掉后 → %v, want documents.ErrSidecarUnavailable(不得回退重抽)", err)
}
if injected == "" {
t.Fatal("夹具前提:抽取应当产出正文")
}
}
// TestDocumentReadTool_UnreadableIsHonest 钉住③(unreadable):sidecar 的结论是读不到正文
// 时,工具给的是**带原因的**诚实错误,而不是空正文假装成功。
func TestDocumentReadTool_UnreadableIsHonest(t *testing.T) {
t.Parallel()
fx := newDocReadFixture(t)
name := docName(".pdf")
ref, _ := fx.extract(t, name, "%PDF-1.7\n")
if ref.Status != history.DocumentStatusUnreadable {
t.Fatalf("夹具前提不成立:status = %q, want %q", ref.Status, history.DocumentStatusUnreadable)
}
_, err := callDocRead(t, fx.tool(t), map[string]any{"name": name})
if !errors.Is(err, ErrDocumentUnreadable) {
t.Fatalf("unreadable → %v, want ErrDocumentUnreadable", err)
}
// 抽取侧给的原因短句必须抵达错误文案(那是模型/用户唯一能看到的解释);这里断言的
// 是全部原因文案共有的前缀,不是某一句具体措辞。
if !strings.Contains(err.Error(), "无法抽取正文") {
t.Fatalf("错误文案没带上抽取侧的原因:%v", err)
}
}
// TestDocumentReadTool_TextExcludesMetadataLine 钉住⑥:sidecar 第一行的元数据绝不混进
// text(正文自第二行起),返回的正文与装配期注入的正文逐字相同。
func TestDocumentReadTool_TextExcludesMetadataLine(t *testing.T) {
t.Parallel()
fx := newDocReadFixture(t)
name := docName(".txt")
body := "正文第一行\n正文第二行\n"
_, injected := fx.extract(t, name, body)
if injected != body {
t.Fatalf("夹具前提:注入正文 = %q, want %q", injected, body)
}
got, err := callDocRead(t, fx.tool(t), map[string]any{"name": name})
if err != nil {
t.Fatalf("读取: %v", err)
}
if got.Text != injected {
t.Fatalf("text = %q, want 注入正文 %q", got.Text, injected)
}
// 磁盘上的 sidecar 确实带一行元数据(否则上面的相等只是「本来就没有元数据」的假绿)。
raw, err := os.ReadFile(fx.sidecarPath(name))
if err != nil {
t.Fatalf("读 sidecar: %v", err)
}
if !strings.HasPrefix(string(raw), "{") || !strings.Contains(string(raw), "\n") {
t.Fatalf("sidecar 首行不是元数据:%q", string(raw))
}
for _, leak := range []string{`"status"`, `"v":`, `"chars"`} {
if strings.Contains(got.Text, leak) {
t.Fatalf("text 混进了 sidecar 元数据(含 %s):%q", leak, got.Text)
}
}
}
// TestDocumentReadTool_OwnerOnlyByDefault 钉住⑤:工具以**默认最严**的可达性标记注册
// (tool_policies 无行 = 两者皆 false = owner-only),因此群聊/陌生人会话在默认策略下既
// 看不到也调不到它(design/24 §5 红线)。注册走的是既有工具同一条路径
// (dispatcher.Register → registry.RegisterTool),因此它同样受 CAPACITY_TOOL_SETS 裁剪。
func TestDocumentReadTool_OwnerOnlyByDefault(t *testing.T) {
t.Parallel()
fx := newDocReadFixture(t)
reg := registry.New()
dispatcher, err := tool.New(reg)
if err != nil {
t.Fatalf("tool.New: %v", err)
}
for _, tl := range buildTools(t, &config.Config{DataDir: fx.dataDir}) {
if rerr := dispatcher.Register(tl); rerr != nil {
t.Fatalf("注册 %q: %v", tl.Name(), rerr)
}
}
cap, err := reg.Get(documentReadToolName)
if err != nil {
t.Fatalf("%q 未注册: %v", documentReadToolName, err)
}
if cap.Kind != registry.KindTool {
t.Fatalf("kind = %q, want %q", cap.Kind, registry.KindTool)
}
if cap.Policy.AllowInGroup || cap.Policy.AllowInStranger {
t.Fatalf("默认可达性标记 = %+v, want 两者皆 false(最严)", cap.Policy)
}
for _, tc := range []struct {
name string
env restrict.ActorEnv
want bool
}{
{"owner", restrict.ActorOwner, true},
{"group", restrict.ActorGroupMember, false},
{"stranger", restrict.ActorStranger, false},
} {
if got := restrict.SetContext(reg, tc.env).Allow(documentReadToolName); got != tc.want {
t.Fatalf("%s 环境 Allow = %v, want %v", tc.name, got, tc.want)
}
// 循环真正用的那道门(dispatcher 的策略引擎)必须与策略引擎同一结论。
if got := dispatcher.PolicyEngine(tc.env).Allow(documentReadToolName); got != tc.want {
t.Fatalf("%s 环境 PolicyEngine().Allow = %v, want %v", tc.name, got, tc.want)
}
}
}