feat(documents): PDF 文本层抽取(S6 第一段;扫描件仍诚实拒绝)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 依赖: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 与新的边界。
This commit is contained in:
AskaEth committed 2026-10-04 19:46:36 +08:00
1 parent c4e09afd38
commit f82c76df27
14 files changed
+926 -94

No files matched your search

+37 -14
View File
@@ -71,13 +71,20 @@ const (
// 抽取失败时注入模型的诚实原因文案(design/24 §4.4)。它们会被模型看到,
// 因此必须是「人能读懂、不含路径与内部细节」的短句;具体错误只进日志。
//
// PDF(切片 B1)的三种失败各有各的下一步动作,因此文案必须分开说:图片型/
// 无文本层的要说清「需要 OCR(当前未做)」,加密的要说清「解除密码后重试」,
// 损坏的就是损坏。
const (
reasonNoTextLayer = "无法抽取正文:本期不支持 PDF/Office 解析(无文本层)"
reasonUnsupported = "无法抽取正文:内容不在可抽取的纯文本范围内"
reasonMissing = "无法抽取正文:文档文件不存在或已被清理"
reasonReadFailed = "无法抽取正文:读取文档失败"
reasonNameInvalid = "无法抽取正文:文档引用形态非法"
reasonNoUploadDir = "无法抽取正文:文档上传目录未配置"
reasonPDFNoTextLayer = "无法抽取正文:这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)"
reasonPDFEncrypted = "无法抽取正文:PDF 已加密(无法用空口令打开),请解除密码后重新上传"
reasonPDFDamaged = "无法抽取正文:PDF 损坏或无法解析"
reasonOfficeNoTextLayer = "无法抽取正文:本期不支持 Word/Excel/PPT 解析(无文本层)"
reasonUnsupported = "无法抽取正文:内容不在可抽取的纯文本范围内"
reasonMissing = "无法抽取正文:文档文件不存在或已被清理"
reasonReadFailed = "无法抽取正文:读取文档失败"
reasonNameInvalid = "无法抽取正文:文档引用形态非法"
reasonNoUploadDir = "无法抽取正文:文档上传目录未配置"
)
// ErrInvalidName 报告一个不是 S1 存储名形态的名字被拿去做 sidecar 键或读盘
@@ -90,8 +97,10 @@ type cachedExtract struct {
Status string
// Reason 是 Status == unreadable 时注入模型的诚实原因文案。
Reason string
// Kind 是抽取所得内容的种类(text / csv / tsv)。
// Kind 是抽取所得内容的种类(text / csv / tsv / pdf)。
Kind string
// Pages 是 PDF 的页数(其它种类为 0)。
Pages int
// Chars 是 sidecar 正文的字符数(= 抽取所得全文的字符数,除非撞上安全上限)。
Chars int
// Truncated 报告抽取期是否撞上了安全上限 MaxSidecarBytes(「正文到此为止」
@@ -109,6 +118,7 @@ type sidecarMeta struct {
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"`
@@ -175,6 +185,7 @@ func (s *sidecarStore) save(name string, rec cachedExtract, size int64) error {
Status: rec.Status,
Reason: rec.Reason,
Kind: rec.Kind,
Pages: rec.Pages,
Chars: rec.Chars,
Truncated: rec.Truncated,
Name: name,
@@ -382,13 +393,14 @@ func (r *Resolver) extract(part model.Content) cachedExtract {
if err != nil {
// 抽取器只报哨兵错误;日志记具体错误与名字/大小,**绝不记正文**。
r.log.Info("document extraction refused", "name", part.DocumentName, "size", len(data), "error", err)
return unreadable(reasonForExtractError(err))
return unreadable(reasonForExtractError(err, ExtOf(part.DocumentName)))
}
r.log.Info("document extracted", "name", part.DocumentName, "size", len(data),
"kind", res.Kind, "chars", res.Chars, "truncated", res.Truncated)
"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,
@@ -427,10 +439,21 @@ func unreadable(reason string) cachedExtract {
return cachedExtract{Status: history.DocumentStatusUnreadable, Reason: reason}
}
// reasonForExtractError 把抽取器的哨兵错误映射为注入模型的诚实原因。
func reasonForExtractError(err error) string {
if errors.Is(err, ErrNoTextLayer) {
return reasonNoTextLayer
// 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
}
return reasonUnsupported
}