- 依赖: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 与新的边界。
322 lines
13 KiB
Go
322 lines
13 KiB
Go
// Package documents 实现文档摄取层(design/24 §4):把一份已落盘的文档
|
||
// 抽成可注入模型可见上下文的正文,并把结果缓存进 sidecar(只抽一次)。
|
||
//
|
||
// 本期处理**纯文本类**(design/24 §1.2 的 P0)与 **PDF 的文本层**(切片 B1,
|
||
// design/24 §7 的 S6 第一段):抽取是**纯函数**、不触网、不落盘(sidecar 由
|
||
// 装配期写)。Office(.doc/.docx/.xls/.xlsx/.ppt/.pptx/.odt/.ods/.odp/.rtf)与
|
||
// **图片型/扫描件 PDF** 按 §8-1 的裁决**诚实拒绝**(返回 `ErrNoTextLayer`,
|
||
// 文案说清「需要 OCR」):绝不假装成功,也绝不返回「空正文 + nil 错误」——后者
|
||
// 会让上游把「读不到」误当成「读到了空文件」,正是 F2 要消灭的那种误解。
|
||
package documents
|
||
|
||
import (
|
||
"bytes"
|
||
"errors"
|
||
"fmt"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"unicode/utf8"
|
||
)
|
||
|
||
// 抽取器的常量。它们全是编译期常量或纯函数分支,使抽取路径里不出现魔法数字。
|
||
const (
|
||
// ExtractorVersion 是抽取器的版本号,写进每条 sidecar 的元数据。抽取逻辑
|
||
// 一旦变化(清洗规则、摘要口径、截断口径)必须递增:版本不匹配时旧 sidecar
|
||
// 被判为失效并重抽一次,从而不会让「升级前抽出来的正文」永远留在缓存里
|
||
// (design/24 §4.3 要求 sidecar 记「抽取器版本」,这就是它的用处)。
|
||
//
|
||
// v2(切片 T1 §7.2):抽取期**不再**按注入预算截断,只在安全上限
|
||
// (MaxSidecarBytes)处截断 —— 旧缓存里的正文可能少了一大截,必须重抽。
|
||
//
|
||
// v3(切片 B1):**PDF 文本层开始能抽**(原先一律诚实拒绝),且失败原因的
|
||
// 分类与文案全变了(无文本层/加密/损坏各自成哨兵)—— 旧缓存里的 unreadable
|
||
// 结论已经是错的,必须重抽。
|
||
ExtractorVersion = "documents/v3"
|
||
|
||
// MaxSidecarBytes 是 sidecar 正文的**安全上限**(字节,8 MiB 文本)。
|
||
//
|
||
// 它取代了旧的 MaxExtractChars(64K 字符):那个数曾经同时兼任「抽取预算」与
|
||
// 「注入预算」,导致 sidecar 里存的就是被注入预算砍过的正文 —— 于是清单行给
|
||
// 出的续读游标(= 已注入字符数)恒等于 sidecar 正文长度,模型照它去
|
||
// `document-read` 必然读到空(切片 T1 §7.1 实测的断裂)。
|
||
//
|
||
// 现在两件事分开:sidecar 存**抽取得到的全文**,只受这个安全上限约束(防止
|
||
// 一份畸形或超大文件把内存/磁盘拖垮);每回合注入多少由**装配期**的注入预算
|
||
// (loop.Options.DocumentMaxInjectChars)决定。命中本上限时 Truncated=true,
|
||
// 如实记进 sidecar 元数据 —— 「正文到此为止」绝不等于「原文件到此为止」。
|
||
MaxSidecarBytes = 8 << 20
|
||
|
||
// csvSummaryMaxColumns 限制摘要里列出的列名个数:超宽表只列前若干列,
|
||
// 免得摘要本身挤占注入预算。
|
||
csvSummaryMaxColumns = 20
|
||
|
||
// binaryProbeBytes 是「内容是不是纯文本」的探测窗口。只看开头这一段:
|
||
// 目的是挡住被误存进来的二进制,不是做通用格式识别。
|
||
binaryProbeBytes = 512
|
||
|
||
// kindText / kindCSV / kindTSV / kindPDF 是 Result.Kind 的取值。
|
||
kindText = "text"
|
||
kindCSV = "csv"
|
||
kindTSV = "tsv"
|
||
kindPDF = "pdf"
|
||
|
||
// csvDelim / tsvDelim 是摘要切列名用的分隔符。
|
||
csvDelim = ","
|
||
tsvDelim = "\t"
|
||
|
||
// csvLabel / tsvLabel 是摘要行的前缀标签。
|
||
csvLabel = "CSV"
|
||
tsvLabel = "TSV"
|
||
|
||
// utf8Replacement 替代非法 UTF-8 字节(与标准库同一口径):非法字节只被
|
||
// 替换,绝不让抽取 panic,也不让整份文档因此报废。
|
||
utf8Replacement = "\uFFFD"
|
||
|
||
// textMIMEPrefix 是「纯文本族」的基类型前缀。
|
||
textMIMEPrefix = "text/"
|
||
)
|
||
|
||
// 抽取器的哨兵错误。调用方一律用 errors.Is 分类,绝不按错误文本匹配。
|
||
var (
|
||
// ErrUnsupported 报告内容不在可抽取的纯文本范围内(二进制、非文本类型)。
|
||
ErrUnsupported = errors.New("documents: 内容不在可抽取的纯文本范围内")
|
||
|
||
// ErrNoTextLayer 报告这份内容的格式需要文本层,而我们读不出文本层:一类是
|
||
// **图片型/扫描件 PDF**(它本就没有文本层,要先栅格化再 OCR,纯 Go 没有可靠
|
||
// 的栅格化实现 —— 切片 B1 的口径),另一类是本期不解析的 **Office**
|
||
// (.doc/.docx/.xls/.xlsx/.ppt/.pptx/.odt/.ods/.odp/.rtf)。
|
||
ErrNoTextLayer = errors.New("documents: 无文本层(图片型/扫描件 PDF 需 OCR;Office 本期不支持解析)")
|
||
)
|
||
|
||
// Result 是一次抽取的产物。它是抽取器的全部对外事实:正文、字符数、是否
|
||
// 截断、种类与抽取器版本,sidecar 元数据与注入渲染都只读它。
|
||
type Result struct {
|
||
// Text 是要落进 sidecar(并在装配期按注入预算取用)的文本(CSV/TSV 的摘要行
|
||
// + 正文,已按安全上限 MaxSidecarBytes 截断)。
|
||
Text string
|
||
// Chars 是 Text 的字符数(rune 数)。
|
||
Chars int
|
||
// Truncated 报告正文是否撞上了**安全上限**(design/24 §4.3:绝不静默截断)。
|
||
// 它与「注入被截断」是两件事:后者由装配期按注入预算判定并写进
|
||
// history.DocumentRef(切片 T1 §7.2)。
|
||
Truncated bool
|
||
// Kind 是抽取出内容的种类(text / csv / tsv / pdf)。
|
||
Kind string
|
||
// Pages 是 PDF 的页数(其它种类恒为 0)。它随 sidecar 元数据落盘,用来让
|
||
// 「这份 PDF 有多大」这件事在缓存里可见(切片 B1)。
|
||
Pages int
|
||
// ExtractorVersion 是产出本结果的抽取器版本。
|
||
ExtractorVersion string
|
||
}
|
||
|
||
// Extract 把一份文档的字节抽成可注入的正文。它是**纯函数**:不读盘、不写盘、
|
||
// 不依赖任何外部状态,因此 sidecar 命中与未命中得到逐字相同的结果。
|
||
//
|
||
// name 是服务端存储名(扩展名决定 CSV/TSV 特例与 Office 的诚实拒绝),mime 是
|
||
// 服务端字节嗅探所得:按 design/24 §3.2 的口径它只作**否决**参考,绝不让一份
|
||
// 不被认可的文档变成可抽取。
|
||
//
|
||
// 分派顺序(切片 B1 起):
|
||
// 1. **魔数 `%PDF-`** → 走 PDF 文本层抽取(`extract_pdf.go`),扩展名与 MIME
|
||
// 都不参与 —— 字节说了算,与存储侧的字节嗅探同源;
|
||
// 2. 名字/嗅探指向 PDF、字节却不是真 PDF → 明确拒绝(绝不退回按纯文本抽);
|
||
// 3. Office 扩展名 → `ErrNoTextLayer`;
|
||
// 4. 非纯文本声明、明显二进制 → `ErrUnsupported`;
|
||
// 5. 其余按纯文本抽(CSV/TSV 另加摘要行)。
|
||
//
|
||
// 返回的错误一律是上面那些哨兵错误的包装;绝不 panic,也绝不返回「空正文 +
|
||
// nil 错误」。
|
||
func Extract(name, mime string, data []byte) (Result, error) {
|
||
ext := strings.ToLower(filepath.Ext(name))
|
||
base := baseMIME(mime)
|
||
if hasPDFMagic(data) {
|
||
return extractPDF(data)
|
||
}
|
||
if ext == PDFExt || base == PDFMIME {
|
||
// 「说得像 PDF、字节却不是」:这是**声明与内容不一致**,按不支持拒绝。
|
||
// 绝不能退回按纯文本抽 —— 那等于让一份改名成 .pdf 的 HTML/脚本以
|
||
// 「文本」身份进模型上下文。
|
||
return Result{}, fmt.Errorf("documents: extract %s (mime %q): 缺少 PDF 魔数: %w", ext, base, ErrUnsupported)
|
||
}
|
||
if noTextLayerExt(ext) {
|
||
return Result{}, fmt.Errorf("documents: extract %s: %w", ext, ErrNoTextLayer)
|
||
}
|
||
if base != "" && !textualMIME(base) {
|
||
return Result{}, fmt.Errorf("documents: extract mime %q: %w", base, ErrUnsupported)
|
||
}
|
||
if looksBinary(data) {
|
||
return Result{}, fmt.Errorf("documents: extract %q: %w", "binary content", ErrUnsupported)
|
||
}
|
||
|
||
body := sanitizeUTF8(string(data))
|
||
summary, kind := documentSummary(ext, body)
|
||
text := body
|
||
if summary != "" {
|
||
text = summary + "\n" + body
|
||
}
|
||
res := Result{Kind: kind, ExtractorVersion: ExtractorVersion}
|
||
if len(text) > MaxSidecarBytes {
|
||
text = prefixBytes(text, MaxSidecarBytes)
|
||
res.Truncated = true
|
||
}
|
||
res.Text = text
|
||
res.Chars = utf8.RuneCountInString(text)
|
||
return res, nil
|
||
}
|
||
|
||
// noTextLayerExt 报告扩展名是否属于**读不到文本层**的格式(design/24 §8-1 的
|
||
// 默认裁决):**Office 一族**的正文在压缩流或文本层里,本期不引解析库,因此明确
|
||
// 报告读不到,而不是把二进制塞进上下文。PDF **不在**这里 —— 它的文本层由
|
||
// `extract_pdf.go` 真抽(抽不到文本层的图片型 PDF 由那条路径给出 ErrNoTextLayer)。
|
||
func noTextLayerExt(ext string) bool {
|
||
switch ext {
|
||
case ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
|
||
".odt", ".ods", ".odp", ".rtf":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
// baseMIME 把一个媒体类型归一为基类型(去参数、小写)。S1 的存储早已只落
|
||
// 基类型,这里只是不让带参数的输入影响判定。
|
||
func baseMIME(mt string) string {
|
||
if i := strings.IndexByte(mt, ';'); i >= 0 {
|
||
mt = mt[:i]
|
||
}
|
||
return strings.ToLower(strings.TrimSpace(mt))
|
||
}
|
||
|
||
// textualMIME 报告一个基类型是否属于纯文本族。它是防御性的第二道门:S1 只会
|
||
// 把嗅探为 text/plain 的内容落盘,因此真实上传永远通过它;它挡的是别处伪造
|
||
// 出来的文档部件 —— 那种情况宁可诚实拒绝。
|
||
func textualMIME(base string) bool {
|
||
if strings.HasPrefix(base, textMIMEPrefix) {
|
||
return true
|
||
}
|
||
switch base {
|
||
case "application/json", "application/yaml", "application/x-yaml":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
// looksBinary 报告字节流是否明显不是纯文本:探测窗口里出现 NUL 字节即判为
|
||
// 二进制。真正的门(扩展名白名单 + 字节嗅探)在 S1 的存储侧,这里只是不让
|
||
// 一份被绕过的二进制进上下文。
|
||
func looksBinary(data []byte) bool {
|
||
n := len(data)
|
||
if n > binaryProbeBytes {
|
||
n = binaryProbeBytes
|
||
}
|
||
return bytes.IndexByte(data[:n], 0) >= 0
|
||
}
|
||
|
||
// sanitizeUTF8 把非法 UTF-8 字节替换为 U+FFFD,使抽取永不因编码问题失败或
|
||
// panic(design/24 §4.1 的「UTF-8 非法字节要处理」)。
|
||
func sanitizeUTF8(s string) string {
|
||
if utf8.ValidString(s) {
|
||
return s
|
||
}
|
||
return strings.ToValidUTF8(s, utf8Replacement)
|
||
}
|
||
|
||
// documentSummary 给出正文前的摘要行与文档种类。CSV/TSV 是唯一的特例
|
||
// (design/24 §4.3:正文前加一行「行数/列名」摘要),否则模型会拿抽到的片段
|
||
// 去回答「一共多少行」。
|
||
func documentSummary(ext, body string) (summary, kind string) {
|
||
switch ext {
|
||
case ".csv":
|
||
return csvSummary(csvLabel, body, csvDelim), kindCSV
|
||
case ".tsv":
|
||
return csvSummary(tsvLabel, body, tsvDelim), kindTSV
|
||
default:
|
||
return "", kindText
|
||
}
|
||
}
|
||
|
||
// csvSummary 渲染「共 N 行(含表头)、M 列:列名…」这一行摘要。行数与列名都
|
||
// 是**朴素口径**:行数按换行数、列名按分隔符直切(不引 encoding/csv)——摘要
|
||
// 只是给模型的量级提示,宁可偶尔把带分隔符的列名切多,也不要因为半行残缺的
|
||
// CSV 让整份文档抽取失败(与 Open WebUI 的 CSV 摘要同一取舍)。
|
||
func csvSummary(label, body, delim string) string {
|
||
if body == "" {
|
||
return ""
|
||
}
|
||
cols := splitColumns(firstLine(body), delim)
|
||
var b strings.Builder
|
||
b.WriteString("[")
|
||
b.WriteString(label)
|
||
b.WriteString(" 摘要] 共 ")
|
||
b.WriteString(strconv.Itoa(countLines(body)))
|
||
b.WriteString(" 行(含表头),")
|
||
if len(cols) == 0 {
|
||
b.WriteString("列名不可解析")
|
||
return b.String()
|
||
}
|
||
b.WriteString(strconv.Itoa(len(cols)))
|
||
if len(cols) > csvSummaryMaxColumns {
|
||
b.WriteString(" 列(仅列前 ")
|
||
b.WriteString(strconv.Itoa(csvSummaryMaxColumns))
|
||
b.WriteString(" 个):")
|
||
cols = cols[:csvSummaryMaxColumns]
|
||
} else {
|
||
b.WriteString(" 列:")
|
||
}
|
||
b.WriteString(strings.Join(cols, ", "))
|
||
return b.String()
|
||
}
|
||
|
||
// countLines 数正文的行数:空正文为 0,以换行结尾的文件不把结尾算作额外一行。
|
||
func countLines(s string) int {
|
||
if s == "" {
|
||
return 0
|
||
}
|
||
n := strings.Count(s, "\n") + 1
|
||
if strings.HasSuffix(s, "\n") {
|
||
n--
|
||
}
|
||
return n
|
||
}
|
||
|
||
// firstLine 返回 s 的第一行(没有换行时就是它自己)。
|
||
func firstLine(s string) string {
|
||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||
return s[:i]
|
||
}
|
||
return s
|
||
}
|
||
|
||
// splitColumns 按分隔符切出列名:去引号与空白、丢掉尾部空列。空行返回 nil,
|
||
// 让调用方有机会说「列名不可解析」,而不是报出 1 个空列名。
|
||
func splitColumns(line, delim string) []string {
|
||
raw := strings.Split(line, delim)
|
||
out := make([]string, 0, len(raw))
|
||
for _, f := range raw {
|
||
out = append(out, strings.Trim(strings.TrimSpace(f), "\""))
|
||
}
|
||
for len(out) > 0 && out[len(out)-1] == "" {
|
||
out = out[:len(out)-1]
|
||
}
|
||
return out
|
||
}
|
||
|
||
// prefixBytes 返回 s 的前若干个**完整 rune**,使其字节数不超过 max:
|
||
// 安全上限按字节设(MaxSidecarBytes),因此截断必须落在 rune 边界上,
|
||
// 绝不把多字节字符切成两半(否则 sidecar 正文会是非法 UTF-8)。
|
||
func prefixBytes(s string, max int) string {
|
||
if max <= 0 {
|
||
return ""
|
||
}
|
||
if len(s) <= max {
|
||
return s
|
||
}
|
||
cut := max
|
||
for cut > 0 && !utf8.RuneStart(s[cut]) {
|
||
cut--
|
||
}
|
||
return s[:cut]
|
||
}
|