feat(runtime): 文档摄取①(抽取器 + sidecar + 装配期 resolveDocuments)(design/24 S3-A)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 抽取器零新依赖纯函数: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 条红,含「文档部件仍抵达下游」)。
This commit is contained in:
AskaEth committed 2026-10-04 17:22:05 +08:00
1 parent 834ef3683e
commit a8851dc0a2
12 files changed
+2008 -7

No files matched your search

+280
View File
@@ -0,0 +1,280 @@
// 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
}