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 / pdf)。 Kind string // Pages 是 PDF 的页数(其它种类为 0)。 Pages int // Chars 是抽取期写盘时的正文字符数(rune)。 Chars int // Truncated 报告 sidecar 正文在**抽取期**是否撞上了安全上限 // (documents.MaxSidecarBytes,8 MiB 文本)—— 它与「本回合注入被截断」是两件事: // 后者由装配期按注入预算判定并写进 history.DocumentRef。 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, Pages: rec.Pages, 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, Pages: meta.Pages, Chars: meta.Chars, Truncated: meta.Truncated, Text: body, }, nil }