Files
Cyrene_v2/internal/runtime/documents/sidecar.go
T
AskaEth f82c76df27
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(documents): PDF 文本层抽取(S6 第一段;扫描件仍诚实拒绝)
- 依赖:github.com/ledongthuc/pdf(**BSD-3-Clause**,非 MIT —— 子代理核了 LICENSE 并纠正了任务书的写法;零传递依赖;无 cgo/无外部进程;体积 strip 后 +285.5 KiB / +0.74%,在 detached worktree 里量取,产出写到仓库外)。被否候选:pdfcpu 无文本抽取、rsc.io/pdf 停更、pdfium/poppler/mupdf 需 cgo 撞红线。

- 实现:魔数 %PDF- 是唯一入口判定(名字/声明指向 PDF 而字节无魔数 ⇒ ErrUnsupported,绝不退回纯文本抽);逐页 GetPlainText(页间仅去首尾空白);ExtractorVersion v2→v3(所有 sidecar 一次性重抽);页数上限 5000 + 字节安全上限复用;新增哨兵 ErrPDFEncrypted / ErrPDFDamaged。

- 诚实边界:无文本层(扫描件)⇒ ErrNoTextLayer 且文案含「图片型/无文本层 PDF,需要 OCR(当前未做)」;加密/损坏各自可判、绝不 panic、不回空正文假装成功。

- 白名单与成对裁决:.pdf 纳入(22→23),且 ContentAllowed 做双向裁决(.pdf 只收嗅探为 application/pdf;PDF 字节只收在 .pdf 名下);读侧真 PDF 如实下发 application/pdf,仍 attachment + nosniff(不可内联)。

- 1 处 panic 兜底已如实登记:该库对「xref 指向文件外」畸形件会在 NumPage() 直接 panic(已用仓库外程序复现),单二进制常驻后端不能因 587 字节上传件崩掉 ⇒ defer recover 折成 ErrPDFDamaged;损坏类主用例仍走库的真出错路径。

- 主会话独立验收:隔离 worktree(B1 的 14 文件)build/vet 绿、全树 59 包 0 FAIL、PDF 用例 13 PASS、双平台交叉编译绿;变异独立复现(魔数裁决短路 ⇒ 魔数/成对裁决用例红)。

- ⚠️ 契约漂移(已排 C2):Docs 仍写「22 项白名单 / 嗅探仅放行 text/plain」,与本切片的 23 项 + 成对裁决不一致 ⇒ 需升 v0.61 登记 .pdf 与新的边界。
2026-10-04 19:46:36 +08:00

460 lines
19 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 (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件归属抽取 sidecar 与摄取层的装配期接缝(design/24 §4.3、§4.4):
// 抽取结果落 `<DataDir>/extracted/<存储名>.txt`,**只抽一次**,后续回合读
// sidecar 而不再解析原文件(Open WebUI 的 re-index 语义;同时避开 H1 的
// 「每回合重算」)。
//
// sidecar 的形状(本切片的取舍):**单个文件**,第一行是紧凑 JSON 元数据
// (chars/truncated/kind/status/reason/抽取器版本),其后是抽取正文。选单文件
// 而不是「正文 + 同目录 .meta」两个文件,是因为「只抽一次」需要一个**原子**的
// 存在性判据:一个文件 = 一次 rename 就落定,不会留下「正文写了、元数据没写」
// 的半截状态。元数据行永远以 `{` 开头,读侧因此能一眼区分元数据与正文。
//
// 文件名规则是「存储名 + .txt」(`abc.csv` → `abc.csv.txt`),而不是「32 位
// 哈希 + .txt」:抽取结论依赖扩展名(只有 .csv/.tsv 才有行数/列名摘要),
// 同一份字节以 .txt 与 .csv 两个存储名上传时抽取结果并不相同,只按哈希做键
// 会把两者混成一条缓存。
const (
// sidecarDirName 是 sidecar 子目录名:`<DataDir>/extracted`。
sidecarDirName = "extracted"
// sidecarSuffix 是 sidecar 文件名的后缀。
sidecarSuffix = ".txt"
// sidecarHeaderEnd 分隔元数据行与正文。
sidecarHeaderEnd = "\n"
// sidecarDirMode / sidecarFileMode 与 S1 的落盘权限一致(目录 0700、
// 文件 0600):抽取正文与原文同等敏感。
sidecarDirMode = 0o700
sidecarFileMode = 0o600
// maxSidecarBytes 是读 sidecar 时的硬上限:正文安全上限(MaxSidecarBytes)
// 再加元数据行与 JSON 编码的余量。超过即判为损坏并重抽,绝不把一整份畸形
// 文件读进内存。
maxSidecarBytes = MaxSidecarBytes + sidecarReadAllowanceBytes
// sidecarReadAllowanceBytes 是给首行元数据(含 unreadable 的原因短句)与
// JSON 编码留的余量。
sidecarReadAllowanceBytes = 64 * 1024
// sidecarTempPattern 是原子写入用的临时文件前缀。
sidecarTempPattern = ".sidecar-*"
// storageHashHexLen 是 S1 内容寻址主名的十六进制位数(32 位 = 128 位)。
storageHashHexLen = 32
// storageExtMaxLen 是存储名扩展名长度的上限(最长的是 .markdown)。
storageExtMaxLen = 12
// documentsLogSource 是摄取层的日志来源标签。日志只记 name/size/mime/
// kind/chars/truncated,**绝不记正文**(design/24 §3.2 的日志口径)。
documentsLogSource = "runtime/documents"
)
// 抽取失败时注入模型的诚实原因文案(design/24 §4.4)。它们会被模型看到,
// 因此必须是「人能读懂、不含路径与内部细节」的短句;具体错误只进日志。
//
// PDF(切片 B1)的三种失败各有各的下一步动作,因此文案必须分开说:图片型/
// 无文本层的要说清「需要 OCR(当前未做)」,加密的要说清「解除密码后重试」,
// 损坏的就是损坏。
const (
reasonPDFNoTextLayer = "无法抽取正文:这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)"
reasonPDFEncrypted = "无法抽取正文:PDF 已加密(无法用空口令打开),请解除密码后重新上传"
reasonPDFDamaged = "无法抽取正文:PDF 损坏或无法解析"
reasonOfficeNoTextLayer = "无法抽取正文:本期不支持 Word/Excel/PPT 解析(无文本层)"
reasonUnsupported = "无法抽取正文:内容不在可抽取的纯文本范围内"
reasonMissing = "无法抽取正文:文档文件不存在或已被清理"
reasonReadFailed = "无法抽取正文:读取文档失败"
reasonNameInvalid = "无法抽取正文:文档引用形态非法"
reasonNoUploadDir = "无法抽取正文:文档上传目录未配置"
)
// ErrInvalidName 报告一个不是 S1 存储名形态的名字被拿去做 sidecar 键或读盘
// 路径。它不该发生(请求边界由网关校验),因此是防御性的哨兵错误。
var ErrInvalidName = errors.New("documents: 非法文档存储名")
// cachedExtract 是一条 sidecar 记录:抽取结论(或失败结论)与它的正文/原因。
type cachedExtract struct {
// Status 是抽取结论,取值见 history.DocumentStatus*。
Status string
// Reason 是 Status == unreadable 时注入模型的诚实原因文案。
Reason string
// Kind 是抽取所得内容的种类(text / csv / tsv / pdf)。
Kind string
// Pages 是 PDF 的页数(其它种类为 0)。
Pages int
// Chars 是 sidecar 正文的字符数(= 抽取所得全文的字符数,除非撞上安全上限)。
Chars int
// Truncated 报告抽取期是否撞上了安全上限 MaxSidecarBytes(「正文到此为止」
// 不等于「原文件到此为止」)。
Truncated bool
// Text 是 Status == extracted 时的全文正文。
Text string
}
// sidecarMeta 是 sidecar 第一行的元数据。它只承载**统计与结论**,正文在
// 第二行之后(design/24 §2.4:历史与引用里绝不出现正文,但 sidecar 就是
// 抽取正文本身,那是它的全部意义)。
type sidecarMeta struct {
Version string `json:"v"`
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
Kind string `json:"kind,omitempty"`
Pages int `json:"pages,omitempty"`
Chars int `json:"chars,omitempty"`
Truncated bool `json:"truncated,omitempty"`
Name string `json:"name,omitempty"`
Size int64 `json:"size,omitempty"`
}
// sidecarStore 是 `<DataDir>/extracted` 下的抽取缓存。dataDir 为空时它是 nil
// (缓存关闭,每次装配重抽),因此每个方法都容忍 nil 接收者。
type sidecarStore struct {
dir string
log *slog.Logger
}
// newSidecarStore 构造 sidecar 存储;dataDir 为空返回 nil(缓存关闭)。
func newSidecarStore(dataDir string, log *slog.Logger) *sidecarStore {
if strings.TrimSpace(dataDir) == "" {
return nil
}
return &sidecarStore{dir: filepath.Join(dataDir, sidecarDirName), log: log}
}
// pathFor 返回一份文档的 sidecar 路径;名字形态非法时返回 false。它做两道
// 检查:名字必须是单个路径段(无分隔符、无 `..`、非绝对路径),拼出的路径
// 必须仍在 sidecar 目录内(与 S1 读侧的双检同一手法)。
func (s *sidecarStore) pathFor(name string) (string, bool) {
if s == nil || !validStorageName(name) {
return "", false
}
path := filepath.Join(s.dir, name+sidecarSuffix)
if filepath.Dir(path) != filepath.Clean(s.dir) {
return "", false
}
return path, true
}
// load 读一条 sidecar 记录,报告是否命中。**未命中一律返回 false**(调用方据此
// 重抽一次):不存在、读不出、形态损坏、抽取器版本不匹配都算未命中 —— 缓存失效是
// 正常路径。读取本体与带原因的错误由 loadSidecar 提供(sidecar_read.go),
// 供不重抽的读侧(`document-read` 工具)区分「到底为什么读不到」。
func (s *sidecarStore) load(name string) (cachedExtract, bool) {
rec, err := s.loadSidecar(name)
return rec, err == nil
}
// save 把一条结论原子地写进 sidecar。它**只写一次**:已存在且版本匹配的记录
// 一律不覆盖(连 mtime 都不动),因此「后续回合不再碰原文件」在文件系统上是
// 可观测的。写入用「临时文件 + rename」,绝不留下半截 sidecar。
func (s *sidecarStore) save(name string, rec cachedExtract, size int64) error {
if s == nil {
return nil
}
path, ok := s.pathFor(name)
if !ok {
return fmt.Errorf("documents: sidecar %q: %w", name, ErrInvalidName)
}
if _, exists := s.load(name); exists {
return nil
}
if err := os.MkdirAll(s.dir, sidecarDirMode); err != nil {
return fmt.Errorf("documents: sidecar dir: %w", err)
}
header, err := json.Marshal(sidecarMeta{
Version: ExtractorVersion,
Status: rec.Status,
Reason: rec.Reason,
Kind: rec.Kind,
Pages: rec.Pages,
Chars: rec.Chars,
Truncated: rec.Truncated,
Name: name,
Size: size,
})
if err != nil {
return fmt.Errorf("documents: sidecar meta encode: %w", err)
}
tmp, err := os.CreateTemp(s.dir, sidecarTempPattern)
if err != nil {
return fmt.Errorf("documents: sidecar temp: %w", err)
}
tmpName := tmp.Name()
if _, err := tmp.WriteString(string(header) + sidecarHeaderEnd + rec.Text); err != nil {
_ = tmp.Close()
_ = os.Remove(tmpName)
return fmt.Errorf("documents: sidecar write: %w", err)
}
if err := tmp.Chmod(sidecarFileMode); err != nil {
_ = tmp.Close()
_ = os.Remove(tmpName)
return fmt.Errorf("documents: sidecar chmod: %w", err)
}
if err := tmp.Close(); err != nil {
_ = os.Remove(tmpName)
return fmt.Errorf("documents: sidecar close: %w", err)
}
if err := os.Rename(tmpName, path); err != nil {
_ = os.Remove(tmpName)
return fmt.Errorf("documents: sidecar commit: %w", err)
}
return nil
}
// validStorageName 报告 name 是不是 S1 的合法文档存储名形态:32 位小写十六
// 进制主名 + 一个小写字母数字扩展名。它**不**重复 S1 的扩展名白名单(那是
// 存储侧的裁决),只保证这个名字不会把路径带出目录、也不会把 sidecar 写到
// 别处 —— 名字可能来自数据库里的历史引用,因此这里不假设它已被校验过。
func validStorageName(name string) bool {
if name == "" || name != filepath.Base(name) || strings.ContainsAny(name, `/\`) {
return false
}
stem, ext, ok := strings.Cut(name, ".")
if !ok || len(stem) != storageHashHexLen || ext == "" || len(ext) > storageExtMaxLen {
return false
}
for _, c := range stem {
if !isLowerHex(c) {
return false
}
}
for _, c := range ext {
if !isLowerAlphaNum(c) {
return false
}
}
return true
}
// isLowerHex 报告 c 是不是小写十六进制数字。
func isLowerHex(c rune) bool {
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')
}
// isLowerAlphaNum 报告 c 是不是小写字母或数字。
func isLowerAlphaNum(c rune) bool {
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z')
}
// Config 是摄取层的接线参数。路径口径由组合根给出,本包不猜数据目录布局。
type Config struct {
// DataDir 是数据目录;sidecar 落在它的 extracted/ 子目录下。
DataDir string
// UploadsDir 是 S1 的文档落盘目录(`<DataDir>/uploads/documents`)。它与
// gateway/documents.go 的 documentSubDir 同源:改其一时两处必须同步,
// 否则装配期会去读一个不存在的目录(症状是「附件全都读不到」)。
UploadsDir string
// Log 是基础 logger;为 nil 时替换为标记来源 runtime/documents 的丢弃 logger。
Log *slog.Logger
}
// Resolver 是摄取层的装配期实现。它**结构上**满足 loop.DocumentResolver
// (不依赖 loop 包,与 vision.MessageResolver 满足 loop.ImageResolver 的方式
// 相同),因此循环也不必依赖本包。
type Resolver struct {
uploadsDir string
store *sidecarStore
log *slog.Logger
}
// NewResolver 构造摄取层解析器。路径缺失不会让构造失败:缓存目录为空时退化为
// 「每次重抽」,上传目录为空时每份文档都得到诚实的 unreadable 结论(而不是
// 假装读到),两种情况都留一条带来源的 WARN。
func NewResolver(cfg Config) *Resolver {
log := sourcedLog(cfg.Log)
r := &Resolver{
uploadsDir: strings.TrimSpace(cfg.UploadsDir),
store: newSidecarStore(cfg.DataDir, log),
log: log,
}
if r.store == nil {
log.Warn("document sidecar disabled: data dir not configured")
}
if r.uploadsDir == "" {
log.Warn("document uploads dir not configured: extractions will be unreadable")
}
return r
}
// sourcedLog 返回标记模块来源 "runtime/documents" 的 logger,把 nil logger
// 替换为丢弃 logger,使摄取层总能安全地记录日志。
func sourcedLog(l *slog.Logger) *slog.Logger {
if l == nil {
l = slog.New(slog.NewTextHandler(io.Discard, nil))
}
return logger.WithSource(l, documentsLogSource)
}
// Resolve 解析 msg 里出现的所有文档部件,返回两份以**服务端存储名**为键的
// 映射:结论(形状即 history.DocumentRef —— 与落库/DTO 同一个结构体)与要注入
// 模型可见上下文的正文。
//
// 与 loop.ImageResolver「就地改写一条消息」同族,但回的是两份映射而不是一条
// 消息:清单行、截断标记与「清单恒在」必须由循环**统一**渲染(未接线解析器
// 或解析失败时也要成立),而 status/chars/truncated 要经循环的 Result 回给
// 客户端(design/24 §4.4/§6.1)。用中立类型(string + history.DocumentRef)
// 让摄取层不必依赖 loop 包。
//
// 单份文档的任何失败(不存在、读不出、类型不支持)都**不是** error:它是
// unreadable 结论 + 诚实的原因文案(§4.4「失败必须可见」)。error 只表示整条
// 消息都解析不了(例如回合已被取消),由循环降级为诚实的兜底渲染。
func (r *Resolver) Resolve(ctx context.Context, msg model.Message) (map[string]history.DocumentRef, map[string]string, error) {
if err := ctx.Err(); err != nil {
return nil, nil, fmt.Errorf("documents: resolve: %w", err)
}
parts := documentPartsOf(msg)
if len(parts) == 0 {
return nil, nil, nil
}
refs := make(map[string]history.DocumentRef, len(parts))
bodies := make(map[string]string, len(parts))
for _, part := range parts {
if _, done := refs[part.DocumentName]; done {
// 同一条消息里对同一份文档的重复引用只解析一次。
continue
}
ref, body := r.resolveOne(part)
refs[part.DocumentName] = ref
bodies[part.DocumentName] = body
}
return refs, bodies, nil
}
// documentPartsOf 返回 msg 里的文档部件(按出现顺序)。
func documentPartsOf(msg model.Message) []model.Content {
out := make([]model.Content, 0, len(msg.Content))
for _, part := range msg.Content {
if part.Type == model.ContentTypeDocument {
out = append(out, part)
}
}
return out
}
// resolveOne 解析一份文档部件:先读 sidecar(design/24 §4.3「只抽一次」),
// 未命中才读原文件并抽取。任何一种失败都折成 unreadable 结论 + 诚实原因,
// 绝不会让整条消息失败。
func (r *Resolver) resolveOne(part model.Content) (history.DocumentRef, string) {
ref := history.DocumentRef{Name: part.DocumentName, MIME: part.DocumentMIME, Size: part.DocumentSize}
if !validStorageName(part.DocumentName) {
// 形态非法(请求边界本已由网关校验)→ 绝不按它读盘,但也不静默。
r.log.Warn("document name rejected", "name", part.DocumentName)
ref.Status = history.DocumentStatusUnreadable
return ref, reasonNameInvalid
}
if rec, ok := r.store.load(part.DocumentName); ok {
r.log.Debug("document sidecar hit", "name", part.DocumentName, "status", rec.Status)
return documentRefFrom(rec, ref), recordBody(rec)
}
rec := r.extract(part)
if err := r.store.save(part.DocumentName, rec, part.DocumentSize); err != nil {
// 写 sidecar 失败只意味着下回合要重抽,绝不影响本回合的注入。
r.log.Warn("sidecar save", "name", part.DocumentName, "error", err)
}
return documentRefFrom(rec, ref), recordBody(rec)
}
// extract 读原文件并抽取,把任何失败折成 unreadable 记录(含诚实原因)。
// 失败同样会被写进 sidecar:20 MiB 的 PDF 不该每回合重读一遍。
func (r *Resolver) extract(part model.Content) cachedExtract {
if r.uploadsDir == "" {
r.log.Warn("document extraction skipped: uploads dir not configured", "name", part.DocumentName)
return unreadable(reasonNoUploadDir)
}
path := filepath.Join(r.uploadsDir, part.DocumentName)
data, err := os.ReadFile(path)
if err != nil {
r.log.Warn("document read", "name", part.DocumentName, "size", part.DocumentSize, "error", err)
if errors.Is(err, os.ErrNotExist) {
return unreadable(reasonMissing)
}
return unreadable(reasonReadFailed)
}
res, err := Extract(part.DocumentName, part.DocumentMIME, data)
if err != nil {
// 抽取器只报哨兵错误;日志记具体错误与名字/大小,**绝不记正文**。
r.log.Info("document extraction refused", "name", part.DocumentName, "size", len(data), "error", err)
return unreadable(reasonForExtractError(err, ExtOf(part.DocumentName)))
}
r.log.Info("document extracted", "name", part.DocumentName, "size", len(data),
"kind", res.Kind, "pages", res.Pages, "chars", res.Chars, "truncated", res.Truncated)
return cachedExtract{
Status: history.DocumentStatusExtracted,
Kind: res.Kind,
Pages: res.Pages,
Chars: res.Chars,
Truncated: res.Truncated,
Text: res.Text,
}
}
// documentRefFrom 把 sidecar 记录折进文档引用:身份(存储名、MIME、字节数)
// 来自部件本身,status/chars/truncated 来自抽取结论。空状态归为 listed
// (「清单已给出、正文未注入」是事实,而不是「已抽取」)。
//
// 注意这里给出的 chars/truncated 是**抽取侧**的事实(sidecar 正文的字符数、
// 是否撞上安全上限)。装配期(loop.resolveDocuments)会按本回合的注入预算把
// 它们改写成「实际注入了多少字符 / 注入是否被截断」—— 清单行的续读游标因此
// 真的是「继续读的起点」(切片 T1 §7.2)。
func documentRefFrom(rec cachedExtract, ref history.DocumentRef) history.DocumentRef {
ref.Status = rec.Status
ref.Chars = rec.Chars
ref.Truncated = rec.Truncated
if ref.Status == "" {
ref.Status = history.DocumentStatusListed
ref.Chars, ref.Truncated = 0, false
}
return ref
}
// recordBody 返回要注入的正文:抽到正文就是正文,读不到就是诚实的原因。
func recordBody(rec cachedExtract) string {
if rec.Status == history.DocumentStatusUnreadable {
return rec.Reason
}
return rec.Text
}
// unreadable 构造一条「读不到正文」的记录。
func unreadable(reason string) cachedExtract {
return cachedExtract{Status: history.DocumentStatusUnreadable, Reason: reason}
}
// reasonForExtractError 把抽取器的哨兵错误映射为注入模型的诚实原因。ext 是存储名
// 的扩展名:同一个 `ErrNoTextLayer` 在 PDF 与 Office 上是两件不同的事(前者需要
// OCR,后者是本期不做解析),因此必须分开说(切片 B1)。
func reasonForExtractError(err error, ext string) string {
switch {
case errors.Is(err, ErrNoTextLayer):
if ext == PDFExt {
return reasonPDFNoTextLayer
}
return reasonOfficeNoTextLayer
case errors.Is(err, ErrPDFEncrypted):
return reasonPDFEncrypted
case errors.Is(err, ErrPDFDamaged):
return reasonPDFDamaged
default:
return reasonUnsupported
}
}