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): // 抽取结果落 `/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 子目录名:`/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 是 `/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 的文档落盘目录(`/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 } }