- 只读 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 存全文 + 注入预算与抽取预算解耦」根治。
129 lines
6.0 KiB
Go
129 lines
6.0 KiB
Go
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
|
||
}
|