feat(tools): document-read 按需读取工具(design/24 S4)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 只读 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 存全文 + 注入预算与抽取预算解耦」根治。
This commit is contained in:
AskaEth committed 2026-10-04 17:36:56 +08:00
1 parent a4789ec13e
commit 20c1edb36a
6 files changed
+842 -50

No files matched your search

+6 -46
View File
@@ -139,53 +139,13 @@ func (s *sidecarStore) pathFor(name string) (string, bool) {
return path, true
}
// load 读一条 sidecar 记录。不存在、读不出、形态损坏、抽取器版本不匹配都
// 返回 false(调用方据此重抽一次),并留下带来源的日志 —— 缓存失效是正常
// 路径,但绝不静默吞掉「文件在那儿却读不出来」这类信号。
// load 读一条 sidecar 记录,报告是否命中。**未命中一律返回 false**(调用方据此
// 重抽一次):不存在、读不出、形态损坏、抽取器版本不匹配都算未命中 —— 缓存失效是
// 正常路径。读取本体与带原因的错误由 loadSidecar 提供(sidecar_read.go),
// 供不重抽的读侧(`document-read` 工具)区分「到底为什么读不到」。
func (s *sidecarStore) load(name string) (cachedExtract, bool) {
if s == nil {
return cachedExtract{}, false
}
path, ok := s.pathFor(name)
if !ok {
return cachedExtract{}, false
}
f, err := os.Open(path)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
s.log.Warn("sidecar open", "name", name, "error", err)
}
return cachedExtract{}, false
}
defer func() { _ = f.Close() }()
data, err := io.ReadAll(io.LimitReader(f, maxSidecarBytes+1))
if err != nil || len(data) > maxSidecarBytes {
s.log.Warn("sidecar unreadable", "name", name, "bytes", len(data), "error", err)
return cachedExtract{}, false
}
header, body, ok := strings.Cut(string(data), sidecarHeaderEnd)
if !ok || !strings.HasPrefix(header, "{") {
s.log.Warn("sidecar malformed", "name", name)
return cachedExtract{}, false
}
meta := sidecarMeta{}
if err := json.Unmarshal([]byte(header), &meta); err != nil {
s.log.Warn("sidecar meta decode", "name", name, "error", err)
return cachedExtract{}, false
}
if meta.Version != ExtractorVersion {
// 抽取器升级:旧缓存按失效处理并重抽一次(记版本号的意义所在)。
s.log.Info("sidecar stale", "name", name, "version", meta.Version)
return cachedExtract{}, false
}
return cachedExtract{
Status: meta.Status,
Reason: meta.Reason,
Kind: meta.Kind,
Chars: meta.Chars,
Truncated: meta.Truncated,
Text: body,
}, true
rec, err := s.loadSidecar(name)
return rec, err == nil
}
// save 把一条结论原子地写进 sidecar。它**只写一次**:已存在且版本匹配的记录
+128
View File
@@ -0,0 +1,128 @@
package documents
import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
)
// 本文件是 sidecar 的**读侧出口**(design/24 §4.2 路线②)。装配期的
// `resolveDocuments` 走 `sidecarStore.load`(未命中就重抽一次),而
// `document-read` 工具要的是「已经落盘的那份正文」:它既不该重抽(design/24 §4.3
// 「只抽一次」,否则 status/chars 的持久统计会漂移),也不该把 sidecar 形状
// (首行元数据 + 正文自第二行起)再实现一遍。这里因此把读路径收成一个小出口,
// 形状、名字形态校验、抽取器版本判据仍**只有一份实现**。
// ErrSidecarUnavailable 报告一份文档**没有可读的抽取结果**:数据目录未配置、
// sidecar 不存在、读不出、形态损坏或抽取器版本不匹配。不重抽的读侧据此给出诚实
// 错误,绝不返回空正文假装成功(design/24 §4.4)。一律用 errors.Is 判定。
var ErrSidecarUnavailable = errors.New("没有可读的抽取结果")
// Sidecar 是一条已落盘 sidecar 的读取结果 —— 纯读取,不含任何抽取动作。
type Sidecar struct {
// Status 是抽取结论,取值见 history.DocumentStatus*。
Status string
// Reason 是 Status == unreadable 时的诚实原因(正文位置为空)。
Reason string
// Kind 是抽取所得内容的种类(text / csv / tsv)。
Kind string
// Chars 是抽取期写盘时的正文字符数(rune)。
Chars int
// Truncated 报告抽取期的正文是否已按预算截断(design/24 §3.4 的 64K 字符)。
Truncated bool
// Text 是正文本身:sidecar 第一行元数据**之后**的部分(含 CSV/TSV 的摘要行)。
Text string
}
// Readable 报告这条记录是否带着可读正文:只有抽取结论为 extracted 才有正文
// (unreadable 时 sidecar 的正文位置是空的,原因在 Reason 里)。
func (s Sidecar) Readable() bool { return s.Status == history.DocumentStatusExtracted }
// ReadSidecar 读取 name 的 sidecar:**只读 sidecar,绝不重抽、绝不读原文件**。
//
// 名字形态非法(非单个路径段、非 32 位小写十六进制主名、扩展名超长)返回包装
// ErrInvalidName 的错误,并且**在触达文件系统之前**就拒绝;其余失败(数据目录
// 未配置、sidecar 不存在、读不出、损坏、版本失效)返回包装 ErrSidecarUnavailable
// 的错误,具体原因在错误链的文案里。dataDir 是**数据目录**(不是 extracted 目录):
// sidecar 落在它的 extracted/ 子目录下,与装配期解析器同一口径。
func ReadSidecar(dataDir, name string) (Sidecar, error) {
if !validStorageName(name) {
return Sidecar{}, fmt.Errorf("documents: read sidecar %q: %w", name, ErrInvalidName)
}
store := newSidecarStore(dataDir, sourcedLog(nil))
if store == nil {
return Sidecar{}, fmt.Errorf("documents: read sidecar %q: 数据目录未配置: %w", name, ErrSidecarUnavailable)
}
rec, err := store.loadSidecar(name)
if err != nil {
return Sidecar{}, fmt.Errorf("documents: read sidecar %q: %w", name, err)
}
return Sidecar{
Status: rec.Status,
Reason: rec.Reason,
Kind: rec.Kind,
Chars: rec.Chars,
Truncated: rec.Truncated,
Text: rec.Text,
}, nil
}
// loadSidecar 是 sidecar 读路径的唯一实现。命中返回记录与 nil;任何失败返回包装了
// ErrSidecarUnavailable 的错误(名字形态非法时为 ErrInvalidName),并把**具体原因**
// (不存在 / 读不出 / 形态损坏 / 版本失效)写进错误链 —— 装配期的 load 只关心「是否
// 命中」(未命中就重抽一次),而不重抽的读侧需要知道到底为什么读不到。
//
// 日志口径与既有读路径一致:缓存未命中是正常路径,「文件不在」因此不吵;其余失败
// 留一条带来源的 WARN/INFO,绝不静默吞掉「文件在那儿却读不出来」这类信号(日志只记
// name/字节数/版本,**绝不记正文**)。
func (s *sidecarStore) loadSidecar(name string) (cachedExtract, error) {
if s == nil {
return cachedExtract{}, fmt.Errorf("sidecar 存储未配置: %w", ErrSidecarUnavailable)
}
path, ok := s.pathFor(name)
if !ok {
return cachedExtract{}, fmt.Errorf("sidecar 名字形态非法: %w", ErrInvalidName)
}
f, err := os.Open(path)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
s.log.Warn("sidecar open", "name", name, "error", err)
return cachedExtract{}, fmt.Errorf("sidecar 打开失败: %w", ErrSidecarUnavailable)
}
return cachedExtract{}, fmt.Errorf("sidecar 不存在: %w", ErrSidecarUnavailable)
}
defer func() { _ = f.Close() }()
data, err := io.ReadAll(io.LimitReader(f, maxSidecarBytes+1))
if err != nil || len(data) > maxSidecarBytes {
s.log.Warn("sidecar unreadable", "name", name, "bytes", len(data), "error", err)
return cachedExtract{}, fmt.Errorf("sidecar 读不出或超出上限(%d 字节): %w", len(data), ErrSidecarUnavailable)
}
header, body, ok := strings.Cut(string(data), sidecarHeaderEnd)
if !ok || !strings.HasPrefix(header, "{") {
s.log.Warn("sidecar malformed", "name", name)
return cachedExtract{}, fmt.Errorf("sidecar 形态损坏(首行不是元数据): %w", ErrSidecarUnavailable)
}
meta := sidecarMeta{}
if err := json.Unmarshal([]byte(header), &meta); err != nil {
s.log.Warn("sidecar meta decode", "name", name, "error", err)
return cachedExtract{}, fmt.Errorf("sidecar 元数据解码失败: %w", ErrSidecarUnavailable)
}
if meta.Version != ExtractorVersion {
// 抽取器升级:旧缓存按失效处理并重抽一次(记版本号的意义所在)。
s.log.Info("sidecar stale", "name", name, "version", meta.Version)
return cachedExtract{}, fmt.Errorf("sidecar 抽取器版本不匹配(%s): %w", meta.Version, ErrSidecarUnavailable)
}
return cachedExtract{
Status: meta.Status,
Reason: meta.Reason,
Kind: meta.Kind,
Chars: meta.Chars,
Truncated: meta.Truncated,
Text: body,
}, nil
}