Files
Cyrene_v2/plugins/tools/io/document_read.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

257 lines
12 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"
"fmt"
"log/slog"
"strings"
"unicode/utf8"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/platform"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// 本文件实现 design/24 §4.2 路线② 的按需读取工具 `document-read`:附件超出注入
// 预算时,装配期只给出「清单行 + 续读游标」(loop/document.go 的
// documentNoteTruncated),模型用本工具按 offset/limit 自己取正文。
//
// 三条口径:
// 1. **只读 sidecar,绝不重抽**(design/24 §4.3):读取走 documents.ReadSidecar,
// 因此「只抽一次」与 status/chars/truncated 的持久统计不会被一次读取打乱;
// 原文件在抽取之后被删除也不影响读取。
// 2. **不扩大文件访问面**(design/24 §4.2 末):本工具只接受**服务端签发的存储名**,
// 不接路径;可触达的范围就是 `<DataDir>/extracted`(本就是 file 工具 roots 的
// 子集),路径拼接与名字形态校验全在摄取层那一份实现里(不重复校验)。
// 3. **失败必须可见**(design/24 §4.4):名非法、没有抽取结果、抽取结论为不可读
// 一律返回诚实错误(哨兵 + 可读文案),绝不返回空正文假装成功。
// Compile-time assertion that documentReadTool implements sdk.Tool.
var _ sdk.Tool = (*documentReadTool)(nil)
const (
// documentReadToolName 是工具名。它与装配期清单行里的字样(loop 的
// documentNoteTruncated:「可用 document-read 继续读取」)**逐字一致** ——
// 这两处是成对契约,改一处必须改两处,否则模型照清单调用会拿到「未知工具」。
documentReadToolName = "document-read"
// defaultDocumentReadChars 是 limit 缺省时的读取字符数。依据:装配期一次注入
// 最多 64K 字符(documents.MaxExtractChars),本工具是**按需**取用,单次 8000
// 字符(≈2–3K token)是「读完整一段」与「别让一次工具往返吃掉整个上下文」之间的
// 折中,与 file(5 MiB)/ web-fetch(2 MiB)同属「有上限的取用」。
defaultDocumentReadChars = 8000
// maxDocumentReadChars 是 limit 的上限 = **抽取期预算**(sidecar 正文的物理上限)。
// 它直接取抽取器的常量而不是再写一个字面量:正文在抽取期已被截到
// documents.MaxExtractChars,再大的请求都读不到更多,因此上限就是它。
maxDocumentReadChars = documents.MaxExtractChars
// documentReadNoReason 是 sidecar 说「不可读」却没给出原因时的兜底文案:不静默,
// 也不编造一个具体原因。
documentReadNoReason = "抽取侧的结论是不可读,且没有给出原因"
)
// ErrDocumentOffset 报告 offset 超出可读范围(负数,或超过抽取正文的字符数)。
// 调用方与测试一律用 errors.Is 判定(项目规范 §10:不按错误文本匹配)。
var ErrDocumentOffset = errors.New("offset 超出可读范围")
// ErrDocumentUnreadable 报告该文档的抽取结论就是「读不到正文」(design/24 §4.4 的
// status=unreadable):sidecar 在,但正文位置是空的,因此没有东西可读。
var ErrDocumentUnreadable = errors.New("文档不可读:抽取侧没有给出正文")
// documentReadTool 按 offset/limit 读一份**已抽取**文档的正文片段。
type documentReadTool struct {
// dataDir 是数据目录;sidecar 在它的 extracted/ 子目录下(与装配期同一口径)。
dataDir string
log *slog.Logger
}
// newDocumentReadTool 构造本工具。数据目录由 documentDataDir 解析 —— 必须与
// cmd/cyrene 的 resolveDataDir 得出同一棵树,否则会出现「装配期写下了 sidecar,
// 工具却在另一个目录里找」的症状(设计上「只抽一次」的前提被破坏)。
func newDocumentReadTool(t *tools) *documentReadTool {
return &documentReadTool{
dataDir: documentDataDir(t.cfg),
// 标来源(项目规范 §10):注入的基 logger 可能不带来源标签,这里补齐。
log: logger.WithSource(t.log, source),
}
}
// Name implements sdk.Tool.
func (d *documentReadTool) Name() string { return documentReadToolName }
// Description implements sdk.Tool. It documents the coordinate system, because the
// model only sees this text plus the attachment list line that pointed it here.
func (d *documentReadTool) Description() string {
return "Read a slice of an already-extracted document (an attachment) by character offset. " +
"Use it with the attachment list line: that line prints a continuation cursor 'offset=N' when the " +
"injected text was cut, and 'offset' here is that same 0-based character offset into the extracted " +
"text (the CSV/TSV summary line, when present, is part of it). Each result reports totalChars and, " +
"when more text is available after this slice, truncated=true plus nextOffset to continue from. " +
"Only server-issued document names from the attachment list can be read; nothing is re-extracted, " +
"so the extracted text is the same text that was injected. Note that the extraction itself is " +
"capped: when extractTruncated is true the extracted text ends there but the original document is longer."
}
// Parameters implements sdk.Tool.
func (d *documentReadTool) Parameters() (map[string]any, error) {
return map[string]any{
"type": "object",
"properties": map[string]any{
"name": map[string]any{
"type": "string",
"description": "Server-issued document name, exactly as printed in the attachment list line (32 hex characters plus an extension).",
},
"offset": map[string]any{
"type": "integer",
"description": "0-based character offset into the extracted text; pass the attachment line's cursor to continue where the injection stopped. Default 0.",
},
"limit": map[string]any{
"type": "integer",
"description": fmt.Sprintf("How many characters to return. Default %d, capped at %d.",
defaultDocumentReadChars, maxDocumentReadChars),
},
},
"required": []any{"name"},
}, nil
}
// documentReadResult 是工具的返回载荷(以 JSON 文本返回)。字段名对齐 design/24
// §4.3 的「续读游标」与本仓 web-fetch 的 truncated 语义。
type documentReadResult struct {
// Name 回显文档存储名,便于模型在多附件时对齐是哪一份。
Name string `json:"name"`
// Status 是抽取结论。读到正文时它恒为 extracted —— 其它结论走诚实错误。
Status string `json:"status"`
// Offset 是本次读取的起始字符偏移(生效值)。
Offset int `json:"offset"`
// Limit 是本次生效的字符上限(缺省与夹取之后的值)。
Limit int `json:"limit"`
// TotalChars 是抽取正文的总字符数,也是 offset 的上界。
TotalChars int `json:"totalChars"`
// Truncated 报告本次读取是否被 limit 截断:为 true 时还有内容,用 NextOffset 续读。
Truncated bool `json:"truncated"`
// NextOffset 是续读游标(= Offset + 本次返回的字符数);没有更多内容时为 0 并省略。
NextOffset int `json:"nextOffset,omitempty"`
// ExtractTruncated 报告**抽取期**是否已按预算截断(sidecar 的 truncated):为 true 时
// 「正文到此为止」不等于「原文件到此为止」—— 预算之外的内容不在 sidecar 里
//(design/24 §9.6 第 3 条:64K 预算落在抽取期)。这条事实必须让模型看得见,
// 否则它会拿「读到末尾」当成「文件只有这么长」。
ExtractTruncated bool `json:"extractTruncated,omitempty"`
// Text 是本次读到的正文(sidecar 第一行元数据之后的部分)。
Text string `json:"text"`
}
// Execute implements sdk.Tool. 返回的是 **JSON 文本**(字符串):循环的
// formatToolResult 对字符串直通,模型因此看到裸 JSON,与 web-fetch 的结果同族。
func (d *documentReadTool) Execute(ctx context.Context, args any) (any, error) {
m, ok := args.(map[string]any)
if !ok {
return nil, fmt.Errorf("io: %s: 参数必须是对象", documentReadToolName)
}
if err := ctx.Err(); err != nil {
return nil, err
}
offset, limit, err := documentReadWindow(m)
if err != nil {
return nil, err
}
name := strings.TrimSpace(argString(m, "name"))
rec, err := documents.ReadSidecar(d.dataDir, name)
if err != nil {
// 名非法 / 没有可读的抽取结果:摄取层的哨兵原样上抛(errors.Is 可达),
// 具体原因在错误链的文案里。日志只记名字与错误,**绝不记正文**。
d.log.Warn("io document-read: extracted document unavailable", "name", name, "error", err)
return nil, fmt.Errorf("io: %s: %w", documentReadToolName, err)
}
if !rec.Readable() {
reason := strings.TrimSpace(rec.Reason)
if reason == "" {
reason = documentReadNoReason
}
d.log.Warn("io document-read: extracted document unreadable", "name", name, "status", rec.Status)
return nil, fmt.Errorf("io: %s: name %q: %s: %w", documentReadToolName, name, reason, ErrDocumentUnreadable)
}
total := utf8.RuneCountInString(rec.Text)
if offset > total {
return nil, fmt.Errorf("io: %s: offset %d 超出正文范围(共 %d 字符): %w",
documentReadToolName, offset, total, ErrDocumentOffset)
}
slice, more := documentReadSlice(rec.Text, offset, limit)
next := 0
if more {
next = offset + utf8.RuneCountInString(slice)
}
raw, err := json.Marshal(documentReadResult{
Name: name,
Status: rec.Status,
Offset: offset,
Limit: limit,
TotalChars: total,
Truncated: more,
NextOffset: next,
ExtractTruncated: rec.Truncated,
Text: slice,
})
if err != nil {
return nil, fmt.Errorf("io: %s: 编码结果: %w", documentReadToolName, err)
}
// 日志只记 name/offset/limit/chars —— **绝不记正文**(design/24 §3.2 的日志口径)。
d.log.Debug("io document-read: served extracted document slice",
"name", name, "offset", offset, "limit", limit,
"chars", utf8.RuneCountInString(slice), "truncated", more)
return string(raw), nil
}
// documentReadWindow 解析 offset 与 limit。offset 缺省 0,**负数在这里就被拒**(早于
// 任何读盘动作);limit 缺省 defaultDocumentReadChars、非正数按缺省处理、超过
// maxDocumentReadChars 夹到上限 —— 夹取只会削掉「多要了」的部分,绝不会少给已存在的
// 正文(正文本身就在同一上限内)。
func documentReadWindow(m map[string]any) (int, int, error) {
offset := 0
if v, ok := argInt(m, "offset"); ok {
offset = v
}
if offset < 0 {
return 0, 0, fmt.Errorf("io: %s: offset %d 无效(0 基,从抽取正文开头算起): %w",
documentReadToolName, offset, ErrDocumentOffset)
}
limit := defaultDocumentReadChars
if v, ok := argInt(m, "limit"); ok && v > 0 {
limit = v
}
if limit > maxDocumentReadChars {
limit = maxDocumentReadChars
}
return offset, limit, nil
}
// documentReadSlice 返回 s 从第 offset 个字符起的至多 limit 个字符,以及其后是否还有
// 内容。单位是**字符(rune)**而不是字节 —— 与清单行的续读游标同一口径(loop 给的
// 是「已注入的字符数」),因此多字节字符既不会被算错,也不会被切成两半。s 的长度受
// 抽取预算封顶(documents.MaxExtractChars),故这里的一次转换是有界的。
func documentReadSlice(s string, offset, limit int) (string, bool) {
runes := []rune(s)
if offset >= len(runes) {
return "", false
}
if end := offset + limit; end < len(runes) {
return string(runes[offset:end]), true
}
return string(runes[offset:]), false
}
// documentDataDir 解析本工具要读的数据目录:配置为空时用平台标准数据目录,与
// cmd/cyrene 的 resolveDataDir(装配期解析器的 DataDir)同一口径。两侧不一致的症状是
// 「附件全都读不到」—— sidecar 写在一棵树里,工具却在另一棵树里找。
func documentDataDir(cfg *config.Config) string {
if cfg != nil && strings.TrimSpace(cfg.DataDir) != "" {
return cfg.DataDir
}
return platform.DefaultDataDir()
}