// 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] }