feat(runtime): 文档摄取①(抽取器 + sidecar + 装配期 resolveDocuments)(design/24 S3-A)
- 抽取器零新依赖纯函数: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:
1 parent
834ef3683e
commit
a8851dc0a2
12 files changed
+2008
-7
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package documents
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// 这些测试钉住抽取器的对外事实(design/24 §4.1/§4.3):纯文本无损、CSV/TSV 带
|
||||
// 行数/列名摘要、超预算**诚实截断**、非法 UTF-8 不 panic 也不报废、PDF/Office
|
||||
// 与二进制**诚实拒绝**(哨兵错误,用 errors.Is 判,绝不按错误文本匹配)。
|
||||
|
||||
// csvName / tsvName 是合法的 S1 存储名形态(32 位十六进制 + 白名单扩展名),
|
||||
// 使抽取器之外的行为(sidecar 键、路径校验)与真实上传同形。
|
||||
const (
|
||||
csvName = "0123456789abcdef0123456789abcdef.csv"
|
||||
tsvName = "0123456789abcdef0123456789abcdef.tsv"
|
||||
txtName = "0123456789abcdef0123456789abcdef.txt"
|
||||
)
|
||||
|
||||
// TestExtract_PlainTextIsLossless 验证纯文本抽取逐字无损、统计与版本随结果返回。
|
||||
func TestExtract_PlainTextIsLossless(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := "第一行\n第二行\n"
|
||||
res, err := Extract(txtName, "text/plain", []byte(body))
|
||||
if err != nil {
|
||||
t.Fatalf("Extract: %v", err)
|
||||
}
|
||||
if res.Text != body {
|
||||
t.Fatalf("Text = %q, want %q", res.Text, body)
|
||||
}
|
||||
if res.Chars != utf8.RuneCountInString(body) {
|
||||
t.Fatalf("Chars = %d, want %d", res.Chars, utf8.RuneCountInString(body))
|
||||
}
|
||||
if res.Truncated {
|
||||
t.Fatal("Truncated = true, want false")
|
||||
}
|
||||
if res.Kind != kindText {
|
||||
t.Fatalf("Kind = %q, want %q", res.Kind, kindText)
|
||||
}
|
||||
if res.ExtractorVersion != ExtractorVersion {
|
||||
t.Fatalf("ExtractorVersion = %q, want %q", res.ExtractorVersion, ExtractorVersion)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtract_CSVPrependsRowAndColumnSummary 钉住 CSV 特例(design/24 §4.3):
|
||||
// 正文前必须有「行数/列名」摘要,否则模型会拿抽到的片段回答「一共多少行」。
|
||||
func TestExtract_CSVPrependsRowAndColumnSummary(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := "id,name\n1,alice\n2,bob\n"
|
||||
res, err := Extract(csvName, "text/plain", []byte(body))
|
||||
if err != nil {
|
||||
t.Fatalf("Extract: %v", err)
|
||||
}
|
||||
if res.Kind != kindCSV {
|
||||
t.Fatalf("Kind = %q, want %q", res.Kind, kindCSV)
|
||||
}
|
||||
summary, rest, ok := strings.Cut(res.Text, "\n")
|
||||
if !ok {
|
||||
t.Fatalf("Text 缺少摘要行: %q", res.Text)
|
||||
}
|
||||
for _, want := range []string{"共 3 行(含表头)", "2 列", "id", "name"} {
|
||||
if !strings.Contains(summary, want) {
|
||||
t.Fatalf("摘要 %q 缺少 %q", summary, want)
|
||||
}
|
||||
}
|
||||
if rest != body {
|
||||
t.Fatalf("摘要之后的正文 = %q, want %q", rest, body)
|
||||
}
|
||||
if res.Chars != utf8.RuneCountInString(res.Text) {
|
||||
t.Fatalf("Chars = %d, want %d", res.Chars, utf8.RuneCountInString(res.Text))
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtract_TSVPrependsSummary 验证 TSV 与 CSV 同一条特例路径(制表符切列)。
|
||||
func TestExtract_TSVPrependsSummary(t *testing.T) {
|
||||
t.Parallel()
|
||||
res, err := Extract(tsvName, "text/plain", []byte("a\tb\n1\t2\n"))
|
||||
if err != nil {
|
||||
t.Fatalf("Extract: %v", err)
|
||||
}
|
||||
if res.Kind != kindTSV {
|
||||
t.Fatalf("Kind = %q, want %q", res.Kind, kindTSV)
|
||||
}
|
||||
if !strings.HasPrefix(res.Text, "[TSV 摘要] 共 2 行(含表头),2 列:a, b\n") {
|
||||
t.Fatalf("Text = %q, want 以 TSV 摘要行开头", res.Text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtract_TruncatesHonestly 钉住「绝不静默截断」(design/24 §4.3):超预算时
|
||||
// 截到预算、置 Truncated,且截断是 rune 安全的(多字节字符不被切成两半)。
|
||||
func TestExtract_TruncatesHonestly(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := strings.Repeat("你好", MaxExtractChars)
|
||||
res, err := Extract(txtName, "text/plain", []byte(body))
|
||||
if err != nil {
|
||||
t.Fatalf("Extract: %v", err)
|
||||
}
|
||||
if !res.Truncated {
|
||||
t.Fatal("Truncated = false, want true")
|
||||
}
|
||||
if res.Chars != MaxExtractChars {
|
||||
t.Fatalf("Chars = %d, want %d", res.Chars, MaxExtractChars)
|
||||
}
|
||||
if want := strings.Repeat("你好", MaxExtractChars/2); res.Text != want {
|
||||
t.Fatalf("Text 不是预算内的 rune 前缀(len=%d)", len(res.Text))
|
||||
}
|
||||
if !utf8.ValidString(res.Text) {
|
||||
t.Fatal("截断后的正文不是合法 UTF-8")
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtract_SanitizesInvalidUTF8 钉住「非法 UTF-8 要处理,不可 panic」:
|
||||
// 非法字节被替换为 U+FFFD,正文其余部分照常抽出。
|
||||
func TestExtract_SanitizesInvalidUTF8(t *testing.T) {
|
||||
t.Parallel()
|
||||
data := append([]byte("前缀"), 0xff, 0xfe)
|
||||
data = append(data, []byte("后缀")...)
|
||||
res, err := Extract(txtName, "text/plain", data)
|
||||
if err != nil {
|
||||
t.Fatalf("Extract: %v", err)
|
||||
}
|
||||
if !utf8.ValidString(res.Text) {
|
||||
t.Fatalf("Text 不是合法 UTF-8: %q", res.Text)
|
||||
}
|
||||
if !strings.Contains(res.Text, utf8Replacement) {
|
||||
t.Fatalf("Text = %q, want 含替换字符 %q", res.Text, utf8Replacement)
|
||||
}
|
||||
if !strings.HasPrefix(res.Text, "前缀") || !strings.HasSuffix(res.Text, "后缀") {
|
||||
t.Fatalf("Text = %q, want 保留前后缀", res.Text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtract_EmptyFileIsNotAnError 钉住「空正文」与「读不到」的区别:一份真正
|
||||
// 空的可读文本不是错误(错误会让上游把「读到了空文件」报成 unreadable)。
|
||||
func TestExtract_EmptyFileIsNotAnError(t *testing.T) {
|
||||
t.Parallel()
|
||||
res, err := Extract(txtName, "text/plain", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Extract(empty) = %v, want nil", err)
|
||||
}
|
||||
if res.Text != "" || res.Chars != 0 || res.Truncated {
|
||||
t.Fatalf("Result = %+v, want 空正文且未截断", res)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtract_RefusesUnsupportedContent 钉住主路径①的诚实拒绝:二进制内容
|
||||
// (探测窗口里有 NUL)返回 ErrUnsupported,而不是空正文 + nil 错误。
|
||||
func TestExtract_RefusesUnsupportedContent(t *testing.T) {
|
||||
t.Parallel()
|
||||
data := []byte{'P', 'K', 0x03, 0x04, 0x00, 0x00, 'x'}
|
||||
if _, err := Extract(txtName, "application/zip", data); !errors.Is(err, ErrUnsupported) {
|
||||
t.Fatalf("Extract(zip) = %v, want ErrUnsupported", err)
|
||||
}
|
||||
if _, err := Extract(txtName, "text/plain", data); !errors.Is(err, ErrUnsupported) {
|
||||
t.Fatalf("Extract(binary) = %v, want ErrUnsupported", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtract_RefusesNoTextLayer 钉住 PDF/Office 的**诚实拒绝**(design/24
|
||||
// §8-1 的默认裁决):扩展名或嗅探所得 MIME 任一指向该类格式即 ErrNoTextLayer。
|
||||
func TestExtract_RefusesNoTextLayer(t *testing.T) {
|
||||
t.Parallel()
|
||||
pdf := []byte("%PDF-1.7\n1 0 obj\n")
|
||||
if _, err := Extract("report.pdf", "text/plain", pdf); !errors.Is(err, ErrNoTextLayer) {
|
||||
t.Fatalf("Extract(.pdf) = %v, want ErrNoTextLayer", err)
|
||||
}
|
||||
if _, err := Extract("report.docx", "text/plain", []byte("docx")); !errors.Is(err, ErrNoTextLayer) {
|
||||
t.Fatalf("Extract(.docx) = %v, want ErrNoTextLayer", err)
|
||||
}
|
||||
// 扩展名看着像文本、嗅探结果却是 PDF:MIME 只作否决参考,照样拒绝。
|
||||
if _, err := Extract(txtName, "application/pdf", []byte("hi")); !errors.Is(err, ErrNoTextLayer) {
|
||||
t.Fatalf("Extract(pdf mime) = %v, want ErrNoTextLayer", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCountLines 钉住行数口径:空正文 0 行、结尾换行不额外算一行。
|
||||
func TestCountLines(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
in string
|
||||
want int
|
||||
}{
|
||||
{"", 0},
|
||||
{"a", 1},
|
||||
{"a\n", 1},
|
||||
{"a\nb", 2},
|
||||
{"a\nb\n", 2},
|
||||
{"\n", 1},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := countLines(c.in); got != c.want {
|
||||
t.Fatalf("countLines(%q) = %d, want %d", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
package documents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// 本文件归属抽取 sidecar 与摄取层的装配期接缝(design/24 §4.3、§4.4):
|
||||
// 抽取结果落 `<DataDir>/extracted/<存储名>.txt`,**只抽一次**,后续回合读
|
||||
// sidecar 而不再解析原文件(Open WebUI 的 re-index 语义;同时避开 H1 的
|
||||
// 「每回合重算」)。
|
||||
//
|
||||
// sidecar 的形状(本切片的取舍):**单个文件**,第一行是紧凑 JSON 元数据
|
||||
// (chars/truncated/kind/status/reason/抽取器版本),其后是抽取正文。选单文件
|
||||
// 而不是「正文 + 同目录 .meta」两个文件,是因为「只抽一次」需要一个**原子**的
|
||||
// 存在性判据:一个文件 = 一次 rename 就落定,不会留下「正文写了、元数据没写」
|
||||
// 的半截状态。元数据行永远以 `{` 开头,读侧因此能一眼区分元数据与正文。
|
||||
//
|
||||
// 文件名规则是「存储名 + .txt」(`abc.csv` → `abc.csv.txt`),而不是「32 位
|
||||
// 哈希 + .txt」:抽取结论依赖扩展名(只有 .csv/.tsv 才有行数/列名摘要),
|
||||
// 同一份字节以 .txt 与 .csv 两个存储名上传时抽取结果并不相同,只按哈希做键
|
||||
// 会把两者混成一条缓存。
|
||||
const (
|
||||
// sidecarDirName 是 sidecar 子目录名:`<DataDir>/extracted`。
|
||||
sidecarDirName = "extracted"
|
||||
|
||||
// sidecarSuffix 是 sidecar 文件名的后缀。
|
||||
sidecarSuffix = ".txt"
|
||||
|
||||
// sidecarHeaderEnd 分隔元数据行与正文。
|
||||
sidecarHeaderEnd = "\n"
|
||||
|
||||
// sidecarDirMode / sidecarFileMode 与 S1 的落盘权限一致(目录 0700、
|
||||
// 文件 0600):抽取正文与原文同等敏感。
|
||||
sidecarDirMode = 0o700
|
||||
sidecarFileMode = 0o600
|
||||
|
||||
// maxSidecarBytes 是读 sidecar 时的硬上限(正文预算 64K 字符,UTF-8 最多
|
||||
// 4 字节/字符,再留元数据余量)。超过即判为损坏并重抽,绝不把一整份畸形
|
||||
// 文件读进内存。
|
||||
maxSidecarBytes = MaxExtractChars*4 + 1<<10
|
||||
|
||||
// sidecarTempPattern 是原子写入用的临时文件前缀。
|
||||
sidecarTempPattern = ".sidecar-*"
|
||||
|
||||
// storageHashHexLen 是 S1 内容寻址主名的十六进制位数(32 位 = 128 位)。
|
||||
storageHashHexLen = 32
|
||||
|
||||
// storageExtMaxLen 是存储名扩展名长度的上限(最长的是 .markdown)。
|
||||
storageExtMaxLen = 12
|
||||
|
||||
// documentsLogSource 是摄取层的日志来源标签。日志只记 name/size/mime/
|
||||
// kind/chars/truncated,**绝不记正文**(design/24 §3.2 的日志口径)。
|
||||
documentsLogSource = "runtime/documents"
|
||||
)
|
||||
|
||||
// 抽取失败时注入模型的诚实原因文案(design/24 §4.4)。它们会被模型看到,
|
||||
// 因此必须是「人能读懂、不含路径与内部细节」的短句;具体错误只进日志。
|
||||
const (
|
||||
reasonNoTextLayer = "无法抽取正文:本期不支持 PDF/Office 解析(无文本层)"
|
||||
reasonUnsupported = "无法抽取正文:内容不在可抽取的纯文本范围内"
|
||||
reasonMissing = "无法抽取正文:文档文件不存在或已被清理"
|
||||
reasonReadFailed = "无法抽取正文:读取文档失败"
|
||||
reasonNameInvalid = "无法抽取正文:文档引用形态非法"
|
||||
reasonNoUploadDir = "无法抽取正文:文档上传目录未配置"
|
||||
)
|
||||
|
||||
// ErrInvalidName 报告一个不是 S1 存储名形态的名字被拿去做 sidecar 键或读盘
|
||||
// 路径。它不该发生(请求边界由网关校验),因此是防御性的哨兵错误。
|
||||
var ErrInvalidName = errors.New("documents: 非法文档存储名")
|
||||
|
||||
// cachedExtract 是一条 sidecar 记录:抽取结论(或失败结论)与它的正文/原因。
|
||||
type cachedExtract struct {
|
||||
// Status 是抽取结论,取值见 history.DocumentStatus*。
|
||||
Status string
|
||||
// Reason 是 Status == unreadable 时注入模型的诚实原因文案。
|
||||
Reason string
|
||||
// Kind 是抽取所得内容的种类(text / csv / tsv)。
|
||||
Kind string
|
||||
// Chars 是注入正文的字符数。
|
||||
Chars int
|
||||
// Truncated 报告正文是否被预算截断。
|
||||
Truncated bool
|
||||
// Text 是 Status == extracted 时的注入正文。
|
||||
Text string
|
||||
}
|
||||
|
||||
// sidecarMeta 是 sidecar 第一行的元数据。它只承载**统计与结论**,正文在
|
||||
// 第二行之后(design/24 §2.4:历史与引用里绝不出现正文,但 sidecar 就是
|
||||
// 抽取正文本身,那是它的全部意义)。
|
||||
type sidecarMeta struct {
|
||||
Version string `json:"v"`
|
||||
Status string `json:"status"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
Chars int `json:"chars,omitempty"`
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
}
|
||||
|
||||
// sidecarStore 是 `<DataDir>/extracted` 下的抽取缓存。dataDir 为空时它是 nil
|
||||
// (缓存关闭,每次装配重抽),因此每个方法都容忍 nil 接收者。
|
||||
type sidecarStore struct {
|
||||
dir string
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// newSidecarStore 构造 sidecar 存储;dataDir 为空返回 nil(缓存关闭)。
|
||||
func newSidecarStore(dataDir string, log *slog.Logger) *sidecarStore {
|
||||
if strings.TrimSpace(dataDir) == "" {
|
||||
return nil
|
||||
}
|
||||
return &sidecarStore{dir: filepath.Join(dataDir, sidecarDirName), log: log}
|
||||
}
|
||||
|
||||
// pathFor 返回一份文档的 sidecar 路径;名字形态非法时返回 false。它做两道
|
||||
// 检查:名字必须是单个路径段(无分隔符、无 `..`、非绝对路径),拼出的路径
|
||||
// 必须仍在 sidecar 目录内(与 S1 读侧的双检同一手法)。
|
||||
func (s *sidecarStore) pathFor(name string) (string, bool) {
|
||||
if s == nil || !validStorageName(name) {
|
||||
return "", false
|
||||
}
|
||||
path := filepath.Join(s.dir, name+sidecarSuffix)
|
||||
if filepath.Dir(path) != filepath.Clean(s.dir) {
|
||||
return "", false
|
||||
}
|
||||
return path, true
|
||||
}
|
||||
|
||||
// load 读一条 sidecar 记录。不存在、读不出、形态损坏、抽取器版本不匹配都
|
||||
// 返回 false(调用方据此重抽一次),并留下带来源的日志 —— 缓存失效是正常
|
||||
// 路径,但绝不静默吞掉「文件在那儿却读不出来」这类信号。
|
||||
func (s *sidecarStore) load(name string) (cachedExtract, bool) {
|
||||
if s == nil {
|
||||
return cachedExtract{}, false
|
||||
}
|
||||
path, ok := s.pathFor(name)
|
||||
if !ok {
|
||||
return cachedExtract{}, false
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
s.log.Warn("sidecar open", "name", name, "error", err)
|
||||
}
|
||||
return cachedExtract{}, false
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
data, err := io.ReadAll(io.LimitReader(f, maxSidecarBytes+1))
|
||||
if err != nil || len(data) > maxSidecarBytes {
|
||||
s.log.Warn("sidecar unreadable", "name", name, "bytes", len(data), "error", err)
|
||||
return cachedExtract{}, false
|
||||
}
|
||||
header, body, ok := strings.Cut(string(data), sidecarHeaderEnd)
|
||||
if !ok || !strings.HasPrefix(header, "{") {
|
||||
s.log.Warn("sidecar malformed", "name", name)
|
||||
return cachedExtract{}, false
|
||||
}
|
||||
meta := sidecarMeta{}
|
||||
if err := json.Unmarshal([]byte(header), &meta); err != nil {
|
||||
s.log.Warn("sidecar meta decode", "name", name, "error", err)
|
||||
return cachedExtract{}, false
|
||||
}
|
||||
if meta.Version != ExtractorVersion {
|
||||
// 抽取器升级:旧缓存按失效处理并重抽一次(记版本号的意义所在)。
|
||||
s.log.Info("sidecar stale", "name", name, "version", meta.Version)
|
||||
return cachedExtract{}, false
|
||||
}
|
||||
return cachedExtract{
|
||||
Status: meta.Status,
|
||||
Reason: meta.Reason,
|
||||
Kind: meta.Kind,
|
||||
Chars: meta.Chars,
|
||||
Truncated: meta.Truncated,
|
||||
Text: body,
|
||||
}, true
|
||||
}
|
||||
|
||||
// save 把一条结论原子地写进 sidecar。它**只写一次**:已存在且版本匹配的记录
|
||||
// 一律不覆盖(连 mtime 都不动),因此「后续回合不再碰原文件」在文件系统上是
|
||||
// 可观测的。写入用「临时文件 + rename」,绝不留下半截 sidecar。
|
||||
func (s *sidecarStore) save(name string, rec cachedExtract, size int64) error {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
path, ok := s.pathFor(name)
|
||||
if !ok {
|
||||
return fmt.Errorf("documents: sidecar %q: %w", name, ErrInvalidName)
|
||||
}
|
||||
if _, exists := s.load(name); exists {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(s.dir, sidecarDirMode); err != nil {
|
||||
return fmt.Errorf("documents: sidecar dir: %w", err)
|
||||
}
|
||||
header, err := json.Marshal(sidecarMeta{
|
||||
Version: ExtractorVersion,
|
||||
Status: rec.Status,
|
||||
Reason: rec.Reason,
|
||||
Kind: rec.Kind,
|
||||
Chars: rec.Chars,
|
||||
Truncated: rec.Truncated,
|
||||
Name: name,
|
||||
Size: size,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("documents: sidecar meta encode: %w", err)
|
||||
}
|
||||
tmp, err := os.CreateTemp(s.dir, sidecarTempPattern)
|
||||
if err != nil {
|
||||
return fmt.Errorf("documents: sidecar temp: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
if _, err := tmp.WriteString(string(header) + sidecarHeaderEnd + rec.Text); err != nil {
|
||||
_ = tmp.Close()
|
||||
_ = os.Remove(tmpName)
|
||||
return fmt.Errorf("documents: sidecar write: %w", err)
|
||||
}
|
||||
if err := tmp.Chmod(sidecarFileMode); err != nil {
|
||||
_ = tmp.Close()
|
||||
_ = os.Remove(tmpName)
|
||||
return fmt.Errorf("documents: sidecar chmod: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
_ = os.Remove(tmpName)
|
||||
return fmt.Errorf("documents: sidecar close: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
_ = os.Remove(tmpName)
|
||||
return fmt.Errorf("documents: sidecar commit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validStorageName 报告 name 是不是 S1 的合法文档存储名形态:32 位小写十六
|
||||
// 进制主名 + 一个小写字母数字扩展名。它**不**重复 S1 的扩展名白名单(那是
|
||||
// 存储侧的裁决),只保证这个名字不会把路径带出目录、也不会把 sidecar 写到
|
||||
// 别处 —— 名字可能来自数据库里的历史引用,因此这里不假设它已被校验过。
|
||||
func validStorageName(name string) bool {
|
||||
if name == "" || name != filepath.Base(name) || strings.ContainsAny(name, `/\`) {
|
||||
return false
|
||||
}
|
||||
stem, ext, ok := strings.Cut(name, ".")
|
||||
if !ok || len(stem) != storageHashHexLen || ext == "" || len(ext) > storageExtMaxLen {
|
||||
return false
|
||||
}
|
||||
for _, c := range stem {
|
||||
if !isLowerHex(c) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, c := range ext {
|
||||
if !isLowerAlphaNum(c) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// isLowerHex 报告 c 是不是小写十六进制数字。
|
||||
func isLowerHex(c rune) bool {
|
||||
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')
|
||||
}
|
||||
|
||||
// isLowerAlphaNum 报告 c 是不是小写字母或数字。
|
||||
func isLowerAlphaNum(c rune) bool {
|
||||
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z')
|
||||
}
|
||||
|
||||
// Config 是摄取层的接线参数。路径口径由组合根给出,本包不猜数据目录布局。
|
||||
type Config struct {
|
||||
// DataDir 是数据目录;sidecar 落在它的 extracted/ 子目录下。
|
||||
DataDir string
|
||||
// UploadsDir 是 S1 的文档落盘目录(`<DataDir>/uploads/documents`)。它与
|
||||
// gateway/documents.go 的 documentSubDir 同源:改其一时两处必须同步,
|
||||
// 否则装配期会去读一个不存在的目录(症状是「附件全都读不到」)。
|
||||
UploadsDir string
|
||||
// Log 是基础 logger;为 nil 时替换为标记来源 runtime/documents 的丢弃 logger。
|
||||
Log *slog.Logger
|
||||
}
|
||||
|
||||
// Resolver 是摄取层的装配期实现。它**结构上**满足 loop.DocumentResolver
|
||||
// (不依赖 loop 包,与 vision.MessageResolver 满足 loop.ImageResolver 的方式
|
||||
// 相同),因此循环也不必依赖本包。
|
||||
type Resolver struct {
|
||||
uploadsDir string
|
||||
store *sidecarStore
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// NewResolver 构造摄取层解析器。路径缺失不会让构造失败:缓存目录为空时退化为
|
||||
// 「每次重抽」,上传目录为空时每份文档都得到诚实的 unreadable 结论(而不是
|
||||
// 假装读到),两种情况都留一条带来源的 WARN。
|
||||
func NewResolver(cfg Config) *Resolver {
|
||||
log := sourcedLog(cfg.Log)
|
||||
r := &Resolver{
|
||||
uploadsDir: strings.TrimSpace(cfg.UploadsDir),
|
||||
store: newSidecarStore(cfg.DataDir, log),
|
||||
log: log,
|
||||
}
|
||||
if r.store == nil {
|
||||
log.Warn("document sidecar disabled: data dir not configured")
|
||||
}
|
||||
if r.uploadsDir == "" {
|
||||
log.Warn("document uploads dir not configured: extractions will be unreadable")
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// sourcedLog 返回标记模块来源 "runtime/documents" 的 logger,把 nil logger
|
||||
// 替换为丢弃 logger,使摄取层总能安全地记录日志。
|
||||
func sourcedLog(l *slog.Logger) *slog.Logger {
|
||||
if l == nil {
|
||||
l = slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
return logger.WithSource(l, documentsLogSource)
|
||||
}
|
||||
|
||||
// Resolve 解析 msg 里出现的所有文档部件,返回两份以**服务端存储名**为键的
|
||||
// 映射:结论(形状即 history.DocumentRef —— 与落库/DTO 同一个结构体)与要注入
|
||||
// 模型可见上下文的正文。
|
||||
//
|
||||
// 与 loop.ImageResolver「就地改写一条消息」同族,但回的是两份映射而不是一条
|
||||
// 消息:清单行、截断标记与「清单恒在」必须由循环**统一**渲染(未接线解析器
|
||||
// 或解析失败时也要成立),而 status/chars/truncated 要经循环的 Result 回给
|
||||
// 客户端(design/24 §4.4/§6.1)。用中立类型(string + history.DocumentRef)
|
||||
// 让摄取层不必依赖 loop 包。
|
||||
//
|
||||
// 单份文档的任何失败(不存在、读不出、类型不支持)都**不是** error:它是
|
||||
// unreadable 结论 + 诚实的原因文案(§4.4「失败必须可见」)。error 只表示整条
|
||||
// 消息都解析不了(例如回合已被取消),由循环降级为诚实的兜底渲染。
|
||||
func (r *Resolver) Resolve(ctx context.Context, msg model.Message) (map[string]history.DocumentRef, map[string]string, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, nil, fmt.Errorf("documents: resolve: %w", err)
|
||||
}
|
||||
parts := documentPartsOf(msg)
|
||||
if len(parts) == 0 {
|
||||
return nil, nil, nil
|
||||
}
|
||||
refs := make(map[string]history.DocumentRef, len(parts))
|
||||
bodies := make(map[string]string, len(parts))
|
||||
for _, part := range parts {
|
||||
if _, done := refs[part.DocumentName]; done {
|
||||
// 同一条消息里对同一份文档的重复引用只解析一次。
|
||||
continue
|
||||
}
|
||||
ref, body := r.resolveOne(part)
|
||||
refs[part.DocumentName] = ref
|
||||
bodies[part.DocumentName] = body
|
||||
}
|
||||
return refs, bodies, nil
|
||||
}
|
||||
|
||||
// documentPartsOf 返回 msg 里的文档部件(按出现顺序)。
|
||||
func documentPartsOf(msg model.Message) []model.Content {
|
||||
out := make([]model.Content, 0, len(msg.Content))
|
||||
for _, part := range msg.Content {
|
||||
if part.Type == model.ContentTypeDocument {
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// resolveOne 解析一份文档部件:先读 sidecar(design/24 §4.3「只抽一次」),
|
||||
// 未命中才读原文件并抽取。任何一种失败都折成 unreadable 结论 + 诚实原因,
|
||||
// 绝不会让整条消息失败。
|
||||
func (r *Resolver) resolveOne(part model.Content) (history.DocumentRef, string) {
|
||||
ref := history.DocumentRef{Name: part.DocumentName, MIME: part.DocumentMIME, Size: part.DocumentSize}
|
||||
if !validStorageName(part.DocumentName) {
|
||||
// 形态非法(请求边界本已由网关校验)→ 绝不按它读盘,但也不静默。
|
||||
r.log.Warn("document name rejected", "name", part.DocumentName)
|
||||
ref.Status = history.DocumentStatusUnreadable
|
||||
return ref, reasonNameInvalid
|
||||
}
|
||||
if rec, ok := r.store.load(part.DocumentName); ok {
|
||||
r.log.Debug("document sidecar hit", "name", part.DocumentName, "status", rec.Status)
|
||||
return documentRefFrom(rec, ref), recordBody(rec)
|
||||
}
|
||||
rec := r.extract(part)
|
||||
if err := r.store.save(part.DocumentName, rec, part.DocumentSize); err != nil {
|
||||
// 写 sidecar 失败只意味着下回合要重抽,绝不影响本回合的注入。
|
||||
r.log.Warn("sidecar save", "name", part.DocumentName, "error", err)
|
||||
}
|
||||
return documentRefFrom(rec, ref), recordBody(rec)
|
||||
}
|
||||
|
||||
// extract 读原文件并抽取,把任何失败折成 unreadable 记录(含诚实原因)。
|
||||
// 失败同样会被写进 sidecar:20 MiB 的 PDF 不该每回合重读一遍。
|
||||
func (r *Resolver) extract(part model.Content) cachedExtract {
|
||||
if r.uploadsDir == "" {
|
||||
r.log.Warn("document extraction skipped: uploads dir not configured", "name", part.DocumentName)
|
||||
return unreadable(reasonNoUploadDir)
|
||||
}
|
||||
path := filepath.Join(r.uploadsDir, part.DocumentName)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
r.log.Warn("document read", "name", part.DocumentName, "size", part.DocumentSize, "error", err)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return unreadable(reasonMissing)
|
||||
}
|
||||
return unreadable(reasonReadFailed)
|
||||
}
|
||||
res, err := Extract(part.DocumentName, part.DocumentMIME, data)
|
||||
if err != nil {
|
||||
// 抽取器只报哨兵错误;日志记具体错误与名字/大小,**绝不记正文**。
|
||||
r.log.Info("document extraction refused", "name", part.DocumentName, "size", len(data), "error", err)
|
||||
return unreadable(reasonForExtractError(err))
|
||||
}
|
||||
r.log.Info("document extracted", "name", part.DocumentName, "size", len(data),
|
||||
"kind", res.Kind, "chars", res.Chars, "truncated", res.Truncated)
|
||||
return cachedExtract{
|
||||
Status: history.DocumentStatusExtracted,
|
||||
Kind: res.Kind,
|
||||
Chars: res.Chars,
|
||||
Truncated: res.Truncated,
|
||||
Text: res.Text,
|
||||
}
|
||||
}
|
||||
|
||||
// documentRefFrom 把 sidecar 记录折进文档引用:身份(存储名、MIME、字节数)
|
||||
// 来自部件本身,status/chars/truncated 来自抽取结论。空状态归为 listed
|
||||
// (「清单已给出、正文未注入」是事实,而不是「已抽取」)。
|
||||
func documentRefFrom(rec cachedExtract, ref history.DocumentRef) history.DocumentRef {
|
||||
ref.Status = rec.Status
|
||||
ref.Chars = rec.Chars
|
||||
ref.Truncated = rec.Truncated
|
||||
if ref.Status == "" {
|
||||
ref.Status = history.DocumentStatusListed
|
||||
ref.Chars, ref.Truncated = 0, false
|
||||
}
|
||||
return ref
|
||||
}
|
||||
|
||||
// recordBody 返回要注入的正文:抽到正文就是正文,读不到就是诚实的原因。
|
||||
func recordBody(rec cachedExtract) string {
|
||||
if rec.Status == history.DocumentStatusUnreadable {
|
||||
return rec.Reason
|
||||
}
|
||||
return rec.Text
|
||||
}
|
||||
|
||||
// unreadable 构造一条「读不到正文」的记录。
|
||||
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
|
||||
}
|
||||
return reasonUnsupported
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
package documents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// 这些测试钉住摄取层的落地事实(design/24 §4.3/§4.4):**只抽一次**(第二次读
|
||||
// sidecar,原文件被删也照样出正文)、失败可见(unreadable + 诚实原因,且失败也
|
||||
// 只抽一次)、名字形态非法绝不碰文件系统、抽取器版本变化让缓存失效。
|
||||
|
||||
// testLayout 是一份隔离的临时目录布局:数据目录 + S1 的上传目录。
|
||||
type testLayout struct {
|
||||
dataDir string
|
||||
uploads string
|
||||
}
|
||||
|
||||
func newTestLayout(t *testing.T) testLayout {
|
||||
t.Helper()
|
||||
data := t.TempDir()
|
||||
uploads := filepath.Join(data, "uploads", "documents")
|
||||
if err := os.MkdirAll(uploads, 0o700); err != nil {
|
||||
t.Fatalf("mkdir uploads: %v", err)
|
||||
}
|
||||
return testLayout{dataDir: data, uploads: uploads}
|
||||
}
|
||||
|
||||
// writeUpload 把一份文档放进上传目录,返回它的存储名。
|
||||
func (l testLayout) writeUpload(t *testing.T, name string, data []byte) string {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(l.uploads, name), data, 0o600); err != nil {
|
||||
t.Fatalf("write upload: %v", err)
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// sidecarPath 是某份文档的 sidecar 路径。
|
||||
func (l testLayout) sidecarPath(name string) string {
|
||||
return filepath.Join(l.dataDir, sidecarDirName, name+sidecarSuffix)
|
||||
}
|
||||
|
||||
// resolveOne 用给定解析器解析一份文档消息,返回结论与正文(测试专用的窄入口)。
|
||||
func resolveOne(t *testing.T, r *Resolver, name string) (history.DocumentRef, string) {
|
||||
t.Helper()
|
||||
msg := model.DocumentMessage(model.RoleUser, "", model.Document{Name: name, MIME: "text/plain", Size: 0})
|
||||
refs, bodies, err := r.Resolve(context.Background(), msg)
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
return refs[name], bodies[name]
|
||||
}
|
||||
|
||||
// TestResolver_ExtractsOnceAndThenReadsSidecar 钉住「只抽一次」(design/24 §4.3):
|
||||
// 首次解析写 sidecar,之后**原文件被删**也能从 sidecar 出正文,且 sidecar 不被
|
||||
// 重写(mtime 不变 —— 「后续回合不再碰原文件」在文件系统上可观测)。
|
||||
func TestResolver_ExtractsOnceAndThenReadsSidecar(t *testing.T) {
|
||||
t.Parallel()
|
||||
layout := newTestLayout(t)
|
||||
body := "报告正文:三个要点。\n"
|
||||
name := layout.writeUpload(t, txtName, []byte(body))
|
||||
r := NewResolver(Config{DataDir: layout.dataDir, UploadsDir: layout.uploads})
|
||||
|
||||
ref, got := resolveOne(t, r, name)
|
||||
if ref.Status != history.DocumentStatusExtracted || got != body {
|
||||
t.Fatalf("首次解析 = (%+v, %q), want extracted + 原文", ref, got)
|
||||
}
|
||||
first, err := os.Stat(layout.sidecarPath(name))
|
||||
if err != nil {
|
||||
t.Fatalf("sidecar 未写出: %v", err)
|
||||
}
|
||||
|
||||
if err := os.Remove(filepath.Join(layout.uploads, name)); err != nil {
|
||||
t.Fatalf("删除原文件: %v", err)
|
||||
}
|
||||
ref2, got2 := resolveOne(t, r, name)
|
||||
if ref2.Status != history.DocumentStatusExtracted || got2 != body {
|
||||
t.Fatalf("第二次解析 = (%+v, %q), want 从 sidecar 得到同一正文", ref2, got2)
|
||||
}
|
||||
second, err := os.Stat(layout.sidecarPath(name))
|
||||
if err != nil {
|
||||
t.Fatalf("sidecar 消失: %v", err)
|
||||
}
|
||||
if !second.ModTime().Equal(first.ModTime()) {
|
||||
t.Fatalf("sidecar 被重写:mtime %v → %v(「只写一次」被破坏)", first.ModTime(), second.ModTime())
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolver_RecordsTruncationInSidecar 钉住截断统计随 sidecar 往返:第二次
|
||||
// 读 sidecar(原文件已删)仍然带着 truncated 与截断后的字符数。
|
||||
func TestResolver_RecordsTruncationInSidecar(t *testing.T) {
|
||||
t.Parallel()
|
||||
layout := newTestLayout(t)
|
||||
name := layout.writeUpload(t, txtName, []byte(strings.Repeat("宽", MaxExtractChars+10)))
|
||||
r := NewResolver(Config{DataDir: layout.dataDir, UploadsDir: layout.uploads})
|
||||
|
||||
ref, _ := resolveOne(t, r, name)
|
||||
if !ref.Truncated || ref.Chars != MaxExtractChars {
|
||||
t.Fatalf("首次解析 = %+v, want truncated + Chars=%d", ref, MaxExtractChars)
|
||||
}
|
||||
if err := os.Remove(filepath.Join(layout.uploads, name)); err != nil {
|
||||
t.Fatalf("删除原文件: %v", err)
|
||||
}
|
||||
ref2, body2 := resolveOne(t, r, name)
|
||||
if !ref2.Truncated || ref2.Chars != MaxExtractChars {
|
||||
t.Fatalf("sidecar 命中 = %+v, want 保留 truncated + Chars", ref2)
|
||||
}
|
||||
if len([]rune(body2)) != MaxExtractChars {
|
||||
t.Fatalf("sidecar 正文 rune 数 = %d, want %d", len([]rune(body2)), MaxExtractChars)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolver_UnreadableIsHonestAndCached 钉住失败可见(design/24 §4.4):PDF
|
||||
// 得到 unreadable + 诚实原因,清单/回传状态有来源;失败同样只抽一次。
|
||||
func TestResolver_UnreadableIsHonestAndCached(t *testing.T) {
|
||||
t.Parallel()
|
||||
layout := newTestLayout(t)
|
||||
name := layout.writeUpload(t, "0123456789abcdef0123456789abcdef.pdf", []byte("%PDF-1.7\n"))
|
||||
r := NewResolver(Config{DataDir: layout.dataDir, UploadsDir: layout.uploads})
|
||||
|
||||
ref, body := resolveOne(t, r, name)
|
||||
if ref.Status != history.DocumentStatusUnreadable {
|
||||
t.Fatalf("Status = %q, want %q", ref.Status, history.DocumentStatusUnreadable)
|
||||
}
|
||||
if body != reasonNoTextLayer {
|
||||
t.Fatalf("body = %q, want %q", body, reasonNoTextLayer)
|
||||
}
|
||||
if _, err := os.Stat(layout.sidecarPath(name)); err != nil {
|
||||
t.Fatalf("失败的抽取也应留下 sidecar(只抽一次同样适用于失败): %v", err)
|
||||
}
|
||||
if err := os.Remove(filepath.Join(layout.uploads, name)); err != nil {
|
||||
t.Fatalf("删除原文件: %v", err)
|
||||
}
|
||||
if _, body2 := resolveOne(t, r, name); body2 != reasonNoTextLayer {
|
||||
t.Fatalf("第二次 body = %q, want 从 sidecar 读回同一原因", body2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolver_MissingFileIsUnreadable 钉住「文件不存在」不是 error、也不是
|
||||
// 静默:它是一条 unreadable 结论 + 诚实原因(回合照常继续)。
|
||||
func TestResolver_MissingFileIsUnreadable(t *testing.T) {
|
||||
t.Parallel()
|
||||
layout := newTestLayout(t)
|
||||
r := NewResolver(Config{DataDir: layout.dataDir, UploadsDir: layout.uploads})
|
||||
|
||||
ref, body := resolveOne(t, r, txtName)
|
||||
if ref.Status != history.DocumentStatusUnreadable || body != reasonMissing {
|
||||
t.Fatalf("解析不存在的文档 = (%+v, %q), want unreadable + %q", ref, body, reasonMissing)
|
||||
}
|
||||
if ref.Name != txtName {
|
||||
t.Fatalf("Name = %q, want 部件里的存储名(身份来自部件)", ref.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolver_RejectsIllegalStorageName 钉住防御性的路径安全:形态非法的名字
|
||||
// 绝不参与读盘或 sidecar 路径,只得到 unreadable + 诚实原因。
|
||||
func TestResolver_RejectsIllegalStorageName(t *testing.T) {
|
||||
t.Parallel()
|
||||
layout := newTestLayout(t)
|
||||
r := NewResolver(Config{DataDir: layout.dataDir, UploadsDir: layout.uploads})
|
||||
|
||||
for _, name := range []string{"../secret.txt", "..", "a.txt", "0123456789abcdef0123456789abcde.txt", "0123456789abcdef0123456789abcdef./x.txt"} {
|
||||
ref, body := resolveOne(t, r, name)
|
||||
if ref.Status != history.DocumentStatusUnreadable || body != reasonNameInvalid {
|
||||
t.Fatalf("解析 %q = (%+v, %q), want unreadable + %q", name, ref, body, reasonNameInvalid)
|
||||
}
|
||||
}
|
||||
entries, err := os.ReadDir(filepath.Join(layout.dataDir, sidecarDirName))
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("read extracted dir: %v", err)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
t.Fatalf("非法名字竟然写出了 %d 个 sidecar", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSidecar_SaveRejectsIllegalName 钉住 sidecar 写入的防御层:形态非法的名字
|
||||
// 绝不落盘,也绝不把路径带出 sidecar 目录。
|
||||
func TestSidecar_SaveRejectsIllegalName(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
store := newSidecarStore(dir, sourcedLog(nil))
|
||||
err := store.save("../escape.txt", cachedExtract{Status: history.DocumentStatusExtracted}, 1)
|
||||
if !errors.Is(err, ErrInvalidName) {
|
||||
t.Fatalf("save(非法名) = %v, want ErrInvalidName", err)
|
||||
}
|
||||
entries, readErr := os.ReadDir(dir)
|
||||
if readErr != nil {
|
||||
t.Fatalf("read dir: %v", readErr)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
t.Fatalf("非法名字竟然落盘了 %d 个文件", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSidecar_StaleVersionIsReextracted 钉住「抽取器版本」的用处(design/24
|
||||
// §4.3):旧版本的 sidecar 判为失效并重抽一次,新版结论落到盘上。
|
||||
func TestSidecar_StaleVersionIsReextracted(t *testing.T) {
|
||||
t.Parallel()
|
||||
layout := newTestLayout(t)
|
||||
body := "正文\n"
|
||||
name := layout.writeUpload(t, txtName, []byte(body))
|
||||
if err := os.MkdirAll(filepath.Join(layout.dataDir, sidecarDirName), sidecarDirMode); err != nil {
|
||||
t.Fatalf("mkdir extracted: %v", err)
|
||||
}
|
||||
header, err := json.Marshal(sidecarMeta{Version: "documents/v0", Status: history.DocumentStatusExtracted, Chars: 2})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal meta: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(layout.sidecarPath(name), append(header, '\n'), 0o600); err != nil {
|
||||
t.Fatalf("write stale sidecar: %v", err)
|
||||
}
|
||||
|
||||
r := NewResolver(Config{DataDir: layout.dataDir, UploadsDir: layout.uploads})
|
||||
ref, got := resolveOne(t, r, name)
|
||||
if ref.Status != history.DocumentStatusExtracted || got != body {
|
||||
t.Fatalf("解析 = (%+v, %q), want 重抽得到 %q", ref, got, body)
|
||||
}
|
||||
raw, err := os.ReadFile(layout.sidecarPath(name))
|
||||
if err != nil {
|
||||
t.Fatalf("read sidecar: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), ExtractorVersion) {
|
||||
t.Fatalf("sidecar 仍是旧版本: %q", string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSidecar_CorruptIsReextracted 钉住「缓存坏了就重抽」:损坏的 sidecar 绝不
|
||||
// 让解析失败,也绝不把畸形内容当正文注入。
|
||||
func TestSidecar_CorruptIsReextracted(t *testing.T) {
|
||||
t.Parallel()
|
||||
layout := newTestLayout(t)
|
||||
body := "好内容\n"
|
||||
name := layout.writeUpload(t, txtName, []byte(body))
|
||||
if err := os.MkdirAll(filepath.Join(layout.dataDir, sidecarDirName), sidecarDirMode); err != nil {
|
||||
t.Fatalf("mkdir extracted: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(layout.sidecarPath(name), []byte("not-json\n垃圾"), 0o600); err != nil {
|
||||
t.Fatalf("write corrupt sidecar: %v", err)
|
||||
}
|
||||
|
||||
r := NewResolver(Config{DataDir: layout.dataDir, UploadsDir: layout.uploads})
|
||||
ref, got := resolveOne(t, r, name)
|
||||
if ref.Status != history.DocumentStatusExtracted || got != body {
|
||||
t.Fatalf("解析 = (%+v, %q), want 重抽得到 %q", ref, got, body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolver_CanceledContextReturnsError 钉住契约里的 error 分支:单份文档的
|
||||
// 失败是 unreadable 结论,而**整条消息解析不了**(回合已取消)才是 error,由
|
||||
// 循环降级为诚实兜底。
|
||||
func TestResolver_CanceledContextReturnsError(t *testing.T) {
|
||||
t.Parallel()
|
||||
layout := newTestLayout(t)
|
||||
name := layout.writeUpload(t, txtName, []byte("正文"))
|
||||
r := NewResolver(Config{DataDir: layout.dataDir, UploadsDir: layout.uploads})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
msg := model.DocumentMessage(model.RoleUser, "", model.Document{Name: name, MIME: "text/plain"})
|
||||
if _, _, err := r.Resolve(ctx, msg); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Resolve(已取消的 ctx) = %v, want context.Canceled", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolver_NoDocumentsIsNoWork 钉住短路:没有文档部件时不做任何工作,也
|
||||
// 不产生结论(使没有附件的回合与接线前逐字节一致)。
|
||||
func TestResolver_NoDocumentsIsNoWork(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := NewResolver(Config{})
|
||||
refs, bodies, err := r.Resolve(context.Background(), model.TextMessage(model.RoleUser, "没有附件"))
|
||||
if err != nil || refs != nil || bodies != nil {
|
||||
t.Fatalf("Resolve(无文档) = (%v, %v, %v), want (nil, nil, nil)", refs, bodies, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolver_UploadsDirMissingIsUnreadable 钉住配置缺失时的诚实:上传目录
|
||||
// 没接上时每份文档都是 unreadable(而不是假装读到、也不是 error)。
|
||||
func TestResolver_UploadsDirMissingIsUnreadable(t *testing.T) {
|
||||
t.Parallel()
|
||||
layout := newTestLayout(t)
|
||||
r := NewResolver(Config{DataDir: layout.dataDir})
|
||||
ref, body := resolveOne(t, r, txtName)
|
||||
if ref.Status != history.DocumentStatusUnreadable || body != reasonNoUploadDir {
|
||||
t.Fatalf("解析 = (%+v, %q), want unreadable + %q", ref, body, reasonNoUploadDir)
|
||||
}
|
||||
}
|
||||
|
||||
// recordingAdapter 是集成测试用的最小模型适配器:记录喂给模型的请求并回一句
|
||||
// 文本,使测试能断言「模型实际看到了什么」。
|
||||
type recordingAdapter struct{ reqs []model.Request }
|
||||
|
||||
func (a *recordingAdapter) Name() string { return "recording" }
|
||||
|
||||
func (a *recordingAdapter) Chat(_ context.Context, req model.Request, _ model.Options) (model.Response, error) {
|
||||
a.reqs = append(a.reqs, req)
|
||||
return model.Response{Model: "stub", Content: "好"}, nil
|
||||
}
|
||||
|
||||
func (a *recordingAdapter) ChatStream(ctx context.Context, req model.Request, _ func(model.StreamEvent)) (model.Response, error) {
|
||||
return a.Chat(ctx, req, model.Options{})
|
||||
}
|
||||
|
||||
// TestResolver_EndToEndWithLoop 是本切片唯一的跨包集成:真实解析器 + 真实循环,
|
||||
// 证明「上传目录里的文档 → sidecar → 装配期注入 → 模型可见正文」这条链闭合,
|
||||
// 且文档部件在模型可见上下文里一个不剩(S2 的编码器对文档 fail-loud)。
|
||||
func TestResolver_EndToEndWithLoop(t *testing.T) {
|
||||
t.Parallel()
|
||||
layout := newTestLayout(t)
|
||||
body := "季度报告:营收增长 12%。\n"
|
||||
name := layout.writeUpload(t, txtName, []byte(body))
|
||||
r := NewResolver(Config{DataDir: layout.dataDir, UploadsDir: layout.uploads})
|
||||
|
||||
ad := &recordingAdapter{}
|
||||
ag, err := agent.New("你是助手。", nil, nil, agent.WithModel(ad, nil))
|
||||
if err != nil {
|
||||
t.Fatalf("agent.New: %v", err)
|
||||
}
|
||||
l := loop.New(loop.Options{Agent: ag, DocumentResolver: r, MaxRounds: 1})
|
||||
msg := model.DocumentMessage(model.RoleUser, "看看附件", model.Document{Name: name, MIME: "text/plain", Size: int64(len(body))})
|
||||
res, err := l.Run(context.Background(), "sess-doc-e2e", []model.Message{msg})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if len(ad.reqs) == 0 {
|
||||
t.Fatal("没有记录到模型请求")
|
||||
}
|
||||
injected := false
|
||||
for _, m := range ad.reqs[0].Messages {
|
||||
for _, part := range m.Content {
|
||||
if part.Type == model.ContentTypeDocument {
|
||||
t.Fatalf("文档部件抵达了模型: %+v", m)
|
||||
}
|
||||
if part.Type == model.ContentTypeText && strings.Contains(part.Text, "营收增长 12%") {
|
||||
injected = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !injected {
|
||||
t.Fatal("抽取正文没有注入模型可见上下文")
|
||||
}
|
||||
if len(res.Documents) != 1 || res.Documents[0].Status != history.DocumentStatusExtracted {
|
||||
t.Fatalf("Result.Documents = %+v, want 一条 extracted", res.Documents)
|
||||
}
|
||||
if _, err := os.Stat(layout.sidecarPath(name)); err != nil {
|
||||
t.Fatalf("sidecar 未写出: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user