Files
Cyrene_v2/internal/runtime/documents/sidecar_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

129 lines
6.0 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 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
}