Files
Cyrene_v2/internal/runtime/documents/extract.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

322 lines
13 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 实现文档摄取层(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]
}