- 抽取器零新依赖纯函数:UTF-8 清洗、CSV/TSV 行数列名摘要、64K rune 安全截断;PDF/Office/二进制走哨兵错误诚实拒绝(不引解析库)。 - sidecar:<DataDir>/extracted/<存储名>.txt,首行紧凑 JSON 元数据 + 正文;临时文件 + rename 原子落定;只抽一次(删原文件仍出正文);版本不匹配/坏缓存重抽。 - 装配期 resolveDocuments(resolveImages 同位,每步对模型可见视图注入):清单恒在(解析器缺席/报错也在)、截断带续读游标、失败诚实标记 + status=unreadable、回合继续;文档部件永不抵达编码器(负例钉住)。 - 修 S2 移交的两处静默吞掉:checkpoint 比较键纳入文档引用、compactor token 估算计入文档部件(不是免费的)。 - 主会话独立验收:隔离 worktree(HEAD + 本切片)build/vet 绿、全树 59 包 0 FAIL、linux/amd64 + darwin/arm64 交叉编译绿、documents+loop 两包 167 条 PASS;变异独立复现(resolveDocuments 短路 ⇒ 3 条红,含「文档部件仍抵达下游」)。
281 lines
10 KiB
Go
281 lines
10 KiB
Go
// Package documents 实现文档摄取层(design/24 §4):把一份已落盘的文本类文档
|
||
// 抽成可注入模型可见上下文的正文,并把结果缓存进 sidecar(只抽一次)。
|
||
//
|
||
// 本期只处理纯文本类(design/24 §1.2 的 P0):抽取是**纯函数**、零新依赖、
|
||
// 不触网、不落盘。PDF/Office 按 §8-1 的默认裁决**诚实拒绝**(返回
|
||
// ErrNoTextLayer):绝不假装成功,也绝不返回「空正文 + nil 错误」——后者会让
|
||
// 上游把「读不到」误当成「读到了空文件」,正是 F2 要消灭的那种误解。
|
||
package documents
|
||
|
||
import (
|
||
"bytes"
|
||
"errors"
|
||
"fmt"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"unicode/utf8"
|
||
)
|
||
|
||
// 抽取器的常量。它们全是编译期常量或纯函数分支,使抽取路径里不出现魔法数字。
|
||
const (
|
||
// ExtractorVersion 是抽取器的版本号,写进每条 sidecar 的元数据。抽取逻辑
|
||
// 一旦变化(清洗规则、摘要口径、预算)必须递增:版本不匹配时旧 sidecar
|
||
// 被判为失效并重抽一次,从而不会让「升级前抽出来的正文」永远留在缓存里
|
||
// (design/24 §4.3 要求 sidecar 记「抽取器版本」,这就是它的用处)。
|
||
ExtractorVersion = "documents/v1"
|
||
|
||
// MaxExtractChars 是**单份文档**注入上下文的字符预算(design/24 §3.4、
|
||
// §4.3 的 64K 字符)。超出即按 rune 截断并置 Result.Truncated;正文绝不
|
||
// 静默消失 —— 循环据 result 渲染 truncated 与续读游标。
|
||
MaxExtractChars = 64 * 1024
|
||
|
||
// csvSummaryMaxColumns 限制摘要里列出的列名个数:超宽表只列前若干列,
|
||
// 免得摘要本身挤占注入预算。
|
||
csvSummaryMaxColumns = 20
|
||
|
||
// binaryProbeBytes 是「内容是不是纯文本」的探测窗口。只看开头这一段:
|
||
// 目的是挡住被误存进来的二进制,不是做通用格式识别。
|
||
binaryProbeBytes = 512
|
||
|
||
// kindText / kindCSV / kindTSV 是 Result.Kind 的取值。
|
||
kindText = "text"
|
||
kindCSV = "csv"
|
||
kindTSV = "tsv"
|
||
|
||
// 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/Office):本期
|
||
// 按 design/24 §8-1 诚实拒绝,不引解析库(单二进制体积与失败面)。
|
||
ErrNoTextLayer = errors.New("documents: 无文本层(PDF/Office 本期不支持解析)")
|
||
)
|
||
|
||
// Result 是一次抽取的产物。它是抽取器的全部对外事实:正文、字符数、是否
|
||
// 截断、种类与抽取器版本,sidecar 元数据与注入渲染都只读它。
|
||
type Result struct {
|
||
// Text 是要注入模型可见上下文的文本(CSV/TSV 的摘要行 + 正文,已按 rune
|
||
// 截断到预算内)。
|
||
Text string
|
||
// Chars 是 Text 的字符数(rune 数)。
|
||
Chars int
|
||
// Truncated 报告正文是否因预算被截断(design/24 §4.3:绝不静默截断)。
|
||
Truncated bool
|
||
// Kind 是抽取出内容的种类(text / csv / tsv)。
|
||
Kind string
|
||
// ExtractorVersion 是产出本结果的抽取器版本。
|
||
ExtractorVersion string
|
||
}
|
||
|
||
// Extract 把一份文档的字节抽成可注入的正文。它是**纯函数**:不读盘、不写盘、
|
||
// 不依赖任何外部状态,因此 sidecar 命中与未命中得到逐字相同的结果。
|
||
//
|
||
// name 是服务端存储名(扩展名决定 CSV/TSV 特例与办公格式的诚实拒绝),mime 是
|
||
// 服务端字节嗅探所得:按 design/24 §3.2 的口径它只作**否决**参考,绝不让一份
|
||
// 不被认可的文档变成可抽取。
|
||
//
|
||
// 返回的错误一律是上面两个哨兵错误的包装;绝不 panic,也绝不返回「空正文 +
|
||
// nil 错误」。
|
||
func Extract(name, mime string, data []byte) (Result, error) {
|
||
ext := strings.ToLower(filepath.Ext(name))
|
||
if noTextLayerExt(ext) {
|
||
return Result{}, fmt.Errorf("documents: extract %s: %w", ext, ErrNoTextLayer)
|
||
}
|
||
if base := baseMIME(mime); base == pdfMIME {
|
||
return Result{}, fmt.Errorf("documents: extract mime %q: %w", base, ErrNoTextLayer)
|
||
} else 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 utf8.RuneCountInString(text) > MaxExtractChars {
|
||
text = prefixRunes(text, MaxExtractChars)
|
||
res.Truncated = true
|
||
}
|
||
res.Text = text
|
||
res.Chars = utf8.RuneCountInString(text)
|
||
return res, nil
|
||
}
|
||
|
||
// noTextLayerExt 报告扩展名是否属于**诚实拒绝**的办公/文档格式(design/24
|
||
// §8-1 的默认裁决):它们的正文在压缩流或文本层里,本期不引解析库,因此明确
|
||
// 报告读不到,而不是把二进制塞进上下文。
|
||
func noTextLayerExt(ext string) bool {
|
||
switch ext {
|
||
case ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
|
||
".odt", ".ods", ".odp", ".rtf":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
// pdfMIME 是拒绝对应的嗅探基类型。
|
||
const pdfMIME = "application/pdf"
|
||
|
||
// 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
|
||
}
|
||
|
||
// prefixRunes 返回 s 的前 n 个 rune:rune 安全,绝不把多字节字符切成两半。
|
||
func prefixRunes(s string, n int) string {
|
||
if n <= 0 {
|
||
return ""
|
||
}
|
||
count := 0
|
||
for i := range s {
|
||
if count == n {
|
||
return s[:i]
|
||
}
|
||
count++
|
||
}
|
||
return s
|
||
}
|