- 依赖: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 与新的边界。
460 lines
19 KiB
Go
460 lines
19 KiB
Go
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
|
||
}
|
||
}
|