Files
Cyrene_v2/plugins/tools/io/document_read_test.go
T
AskaEth 20c1edb36a
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(tools): document-read 按需读取工具(design/24 S4)
- 只读 S3-A 的 sidecar 正文(首行元数据行不计入 text),按字符分页,带 truncated/nextOffset 续读语义与 extractTruncated(区分「正文到此为止」与「原文件到此为止」)。

- 越界三态不静默:offset<0 / offset>totalChars → ErrDocumentOffset(碰盘前拦下,文案带总字符数);offset==totalChars 合法(读到末尾)。名非法/无 sidecar/unreadable → 哨兵错误 + 中文文案,绝不返回空正文假装成功。

- 读侧出口收敛到 documents 包:新增 ReadSidecar(含 Sidecar/Readable/ErrSidecarUnavailable),sidecar.go 的 load 变薄壳且对外行为逐字不变 ⇒ sidecar 形状与名字形态校验全仓只有一份实现。

- 注册与既有 6 个 io 工具同一路径(tools.go),策略默认 owner-only(群聊/陌生人默认调不到)。

- 主会话独立验收:隔离 worktree(HEAD + 本切片 6 个路径)build/vet 绿、全树 59 包 0 FAIL、双平台交叉编译绿、io 包 60 PASS;变异独立复现(工具名常量改成 document_read ⇒ 注册名单两条用例红)。

- 报告里发现的真实断裂(清单游标恒等于 sidecar 正文长度 ⇒ 照它续读必空)已登记,并入 T1 以「sidecar 存全文 + 注入预算与抽取预算解耦」根治。
2026-10-04 17:36:56 +08:00

448 lines
19 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_ManifestCursorAndCSVSummary 钉住与上游的两处成对契约:
// - CSV 的摘要行属于正文坐标系(offset 0 = 摘要行开头),与装配期注入的正文一致;
// - 清单行给的续读游标(= 已注入字符数)可以原样传进来:读到末尾不报错,并如实标注
// 抽取期的截断(extractTruncated)—— 抽取期预算之外的内容**不在 sidecar 里**
// (design/24 §9.6 第 3 条),这条事实必须让模型看得见。
func TestDocumentReadTool_ManifestCursorAndCSVSummary(t *testing.T) {
t.Parallel()
fx := newDocReadFixture(t)
tool := fx.tool(t)
csv := docName(".csv")
_, injectedCSV := fx.extract(t, csv, "a,b\n1,2\n3,4\n")
if !strings.HasPrefix(injectedCSV, "[CSV") {
t.Fatalf("CSV 夹具应当带摘要行:%q", injectedCSV)
}
gotCSV, err := callDocRead(t, tool, map[string]any{"name": csv})
if err != nil {
t.Fatalf("读 CSV: %v", err)
}
if gotCSV.Text != injectedCSV {
t.Fatalf("CSV text = %q, want 含摘要行的注入正文 %q", gotCSV.Text, injectedCSV)
}
big := docName(".txt")
ref, _ := fx.extract(t, big, strings.Repeat("宽", documents.MaxExtractChars+10))
if !ref.Truncated || ref.Chars != documents.MaxExtractChars {
t.Fatalf("夹具前提:ref = %+v, want 抽取期已截断到 %d 字符", ref, documents.MaxExtractChars)
}
atCursor, err := callDocRead(t, tool, map[string]any{"name": big, "offset": ref.Chars})
if err != nil {
t.Fatalf("按清单游标读取: %v", err)
}
if atCursor.TotalChars != ref.Chars || atCursor.Text != "" || atCursor.Truncated || atCursor.NextOffset != 0 {
t.Fatalf("按清单游标读取 = %+v, want 读到抽取正文末尾(totalChars %d)", atCursor, ref.Chars)
}
if !atCursor.ExtractTruncated {
t.Fatal("抽取期的截断事实没有如实回给模型(extractTruncated=false)")
}
}
// 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)
}
}
}