diff --git a/cmd/cyrene/wire_runtime.go b/cmd/cyrene/wire_runtime.go index 0816bda..23e3c8a 100644 --- a/cmd/cyrene/wire_runtime.go +++ b/cmd/cyrene/wire_runtime.go @@ -14,6 +14,7 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader" personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" @@ -222,6 +223,18 @@ func (a *app) wireRuntime() error { a.dispatcher.Invoke, ) + // 文档摄取(design/24 §2.3-①、§4):装配期把文档部件确定性替换为抽取所得 + // 的正文或「仅清单」条目,文档部件因此永不抵达编码器。路径口径与 S1 的 + // `POST /api/v1/documents` 同源 —— 原文在 `/uploads/documents` + // (gateway 的 documentSubDir),抽取结果缓存到 `/extracted`。 + // 这里复用同一个 resolveDataDir 结果,两处指向同一棵目录树。 + docDataDir := resolveDataDir(a.cfg) + docResolver := documents.NewResolver(documents.Config{ + DataDir: docDataDir, + UploadsDir: filepath.Join(docDataDir, "uploads", "documents"), + Log: a.log, + }) + capDecider := a.buildCapacity() // 人格情绪观测器(design/01 §2)在此构建,因为它是 loop 的 turn 边界依赖: // 每个 turn 落定后,该 turn 绑定人格的情绪机与好感度都会推进,返回的心情标签 @@ -271,6 +284,7 @@ func (a *app) wireRuntime() error { WorkingMemory: memory.NewWorkingMemory(0), OnStream: a.streamChunk, Vision: visionResolver, + DocumentResolver: docResolver, Thinking: thinking, ReasoningEffort: reasoningEffort, ThinkingShowToUser: thinkingShow, diff --git a/internal/runtime/documents/extract.go b/internal/runtime/documents/extract.go new file mode 100644 index 0000000..c23c421 --- /dev/null +++ b/internal/runtime/documents/extract.go @@ -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 +} diff --git a/internal/runtime/documents/extract_test.go b/internal/runtime/documents/extract_test.go new file mode 100644 index 0000000..62f620f --- /dev/null +++ b/internal/runtime/documents/extract_test.go @@ -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) + } + } +} diff --git a/internal/runtime/documents/sidecar.go b/internal/runtime/documents/sidecar.go new file mode 100644 index 0000000..0340c48 --- /dev/null +++ b/internal/runtime/documents/sidecar.go @@ -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): +// 抽取结果落 `/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 子目录名:`/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 是 `/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 的文档落盘目录(`/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 +} diff --git a/internal/runtime/documents/sidecar_test.go b/internal/runtime/documents/sidecar_test.go new file mode 100644 index 0000000..6db9d47 --- /dev/null +++ b/internal/runtime/documents/sidecar_test.go @@ -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) + } +} diff --git a/internal/runtime/loop/checkpoint.go b/internal/runtime/loop/checkpoint.go index db28ebd..7c7278b 100644 --- a/internal/runtime/loop/checkpoint.go +++ b/internal/runtime/loop/checkpoint.go @@ -173,18 +173,24 @@ func lastUserMessageKey(messages []model.Message) string { return "" } -// userMessageKey 返回一条用户消息的比对键:文本与图片引用按出现顺序拼接。 -// 纯图片消息(无文本)因此也能区分——只比文本会把「用户刚发的一张图」判成 -// 空键而漏判。角色与说话人不进键:这里只问「是不是同一条用户输入」。 +// userMessageKey 返回一条用户消息的比对键:文本、图片引用与文档引用按出现顺序 +// 拼接。纯图片消息(无文本)因此也能区分——只比文本会把「用户刚发的一张图」判成 +// 空键而漏判;纯文档消息同理:客户端「超限走文件」发的就是一条只有附件的消息, +// 漏掉它会让续接判定在附件回合上失准(design/24 §9.5 移交的静默吞掉)。角色与 +// 说话人不进键:这里只问「是不是同一条用户输入」。 func userMessageKey(m model.Message) string { var b strings.Builder for _, part := range m.Content { - if part.Type == model.ContentTypeImage { + switch part.Type { + case model.ContentTypeImage: b.WriteString("\x00img:") b.WriteString(part.ImageURL) - continue + case model.ContentTypeDocument: + b.WriteString("\x00doc:") + b.WriteString(part.DocumentName) + default: + b.WriteString(part.Text) } - b.WriteString(part.Text) } return b.String() } diff --git a/internal/runtime/loop/compactor.go b/internal/runtime/loop/compactor.go index 9c92a9d..e1b5174 100644 --- a/internal/runtime/loop/compactor.go +++ b/internal/runtime/loop/compactor.go @@ -174,7 +174,7 @@ func findKeepStart(messages []model.Message, from, keepTokens int) int { } // estimateContextTokens 返回 messages 的保守 token 估算。它是 -// 启发式(字符 ÷ charsPerToken,加上每条消息与每个图像的开销), +// 启发式(字符 ÷ charsPerToken,加上每条消息、每个图像与每个文档的开销), // 因此无依赖且离线,但单调性足够驱动 // 压缩决策。 func estimateContextTokens(messages []model.Message) int { @@ -187,12 +187,37 @@ func estimateContextTokens(messages []model.Message) int { total += estimateStringTokens(part.Text) case model.ContentTypeImage: total += imageTokens + case model.ContentTypeDocument: + total += documentPartTokens(part) } } } return total } +// documentPartTokens 估算一个文档部件在装配后可能占用的 token。窗口里只有 +// 引用(design/24 §2.4),正文要等装配期才注入,因此这里按「原文件字节数 +// (UTF-8 文本下 ≈ 字符数),上限为注入预算」估算。 +// +// 关键是文档部件**不是免费的**:只认 ContentTypeImage 的分支会让纯文档回合 +// 被估成 0 token,在压缩里等同于空内容(design/24 §9.5 移交的静默吞掉)。 +// 大文件(尤其在预算处截断注入、或压根读不到)会被这个估计偏高地计入,方向 +// 是「少放内容进上下文」——保守收紧,而不是溢出模型窗口。 +func documentPartTokens(part model.Content) int { + chars := part.DocumentSize + if chars > documentInjectedCharsCap { + chars = documentInjectedCharsCap + } + if chars < 0 { + chars = 0 + } + t := int(chars) / charsPerToken + if t < documentMinTokens { + return documentMinTokens + } + return t +} + // estimateStringTokens 是单个文本字符串的字符级 token 估算, // 把宽字符(CJK)rune 计为双倍权重再除以 charsPerToken。空字符串 // 不花费任何 token;非空至少花费一个 token。 diff --git a/internal/runtime/loop/document.go b/internal/runtime/loop/document.go new file mode 100644 index 0000000..a46fdf8 --- /dev/null +++ b/internal/runtime/loop/document.go @@ -0,0 +1,289 @@ +package loop + +import ( + "context" + "fmt" + "strings" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" +) + +// 本文件归属循环的**装配期文档解析**(design/24 §2.3-①、§4):把每一步模型 +// 可见视图里的文档部件确定性替换为「清单行 + 正文」文本块。三条硬规则在这里 +// 落地,缺一不可: +// +// 1. **绝不把文档部件交给编码器**:替换是无条件的(解析器缺席或失败也一样), +// 因此编码器那侧的 fail-loud 永远不该被触发 —— 一旦触发就是编程错误; +// 2. **清单恒在**:每份文档至少要留下「名、MIME、大小、结论」这一行, +// 绝不出现「传上去了但模型一无所知」的空上下文(§4.2-3); +// 3. **失败必须可见**:抽取失败解析为诚实的文本标记 + status=unreadable, +// 回合照常继续(§4.4)。 +// +// 它作用于**装配后的模型可见视图**而不是消息窗口:窗口(checkpoint 快照、 +// 记忆提取输入)里只留文档引用(§2.4),正文每次装配时按引用读 sidecar 得到。 +const ( + // documentInjectedCharsCap 是单份文档注入上下文的字符上限(design/24 §3.4 + // 的 64K 字符)。它与抽取器的 documents.MaxExtractChars **同值同义**:这里 + // 不重复实现截断(截断由抽取器完成并写进 sidecar,供后续回合复用),只在 + // 压缩估算里按同一口径计入文档部件的代价。两个包各自独立,避免反向依赖。 + documentInjectedCharsCap = 64 * 1024 + + // documentMinTokens 是清单行(名/MIME/大小/结论)的粗略 token 下限:无论 + // 文档多小、甚至完全读不到,清单行都进了上下文,因此它不是免费的。 + documentMinTokens = 32 + + // documentUnknownMIME 是清单行里 MIME 缺失时的展示值 —— 宁可写「未知类型」 + // 也不留一个空洞让模型误读成「没有问题」。 + documentUnknownMIME = "未知类型" + + // bytesPerKiB / bytesPerMiB 是清单行渲染大小用的单位(仅展示用)。 + bytesPerKiB = 1024 + bytesPerMiB = 1024 * bytesPerKiB +) + +// 清单行与结论文案(design/24 §4.3、§4.4)。清单行恒在,结论文案随结论变化: +// 已抽取(可含截断与续读游标)、仅清单、不可读。 +const ( + documentListPrefix = "[附件 " + documentListSeparator = " · " + documentListSuffix = "]" + + documentNoteExtracted = "已注入正文 %d 字符" + documentNoteTruncated = "已注入正文 %d 字符(已截断,续读游标 offset=%d;可用 document-read 继续读取)" + documentNoteListed = "仅清单(本次未注入正文)" + documentNoteUnreadable = "不可读" + + // documentUnreadableBody 是解析器没能给出原因时的诚实兜底文案:不静默, + // 也不编造一个具体原因。 + documentUnreadableBody = "无法抽取正文:读取或解析失败" +) + +// DocumentResolver 是循环通向文档摄取层的接缝(design/24 §2.3-①、§4)。 +// +// 形状与 ImageResolver 同族:注入式依赖、装配期按消息调用一次、失败时回合 +// 继续。差别只在返回值 —— 图像解析就地改写一条消息,而文档解析把「结论」与 +// 「正文」分开交回:清单行、截断标记与「清单恒在」由循环统一渲染(解析器缺席 +// 或失败时也必须成立),status/chars/truncated 则经 Result.Documents 回给 +// 客户端(§4.4、§6.1)。 +// +// 两份映射都以**服务端存储名**为键,值分别是该文档的结论(形状即 +// history.DocumentRef,与落库/DTO 同一个结构体)与要注入的正文(读不到时是 +// 诚实的原因文案)。缺少条目的文档由循环降级为「仅清单」。用这些中立类型, +// 摄取层不必依赖 loop 包,循环也不必依赖摄取层 —— 与 vision.MessageResolver +// 满足 ImageResolver 的方式相同。 +type DocumentResolver interface { + // Resolve 解析 msg 里的文档部件。error 表示整条消息都解析不了(例如回合 + // 已被取消);**单份文档**的失败不是 error,而是 unreadable 结论。 + Resolve(ctx context.Context, msg model.Message) (map[string]history.DocumentRef, map[string]string, error) +} + +// resolveDocuments 把每条消息里的文档部件确定性替换为「清单行 + 正文」文本块, +// 并汇总本次解析到的结论。没有文档部件时原样返回输入切片(与 resolveImages +// 的短路同一口径)。 +func (l *Loop) resolveDocuments(ctx context.Context, messages []model.Message) ([]model.Message, []history.DocumentRef) { + if !anyDocumentPart(messages) { + return messages, nil + } + out := make([]model.Message, 0, len(messages)) + refs := make([]history.DocumentRef, 0, len(messages)) + for _, m := range messages { + if !messageHasDocument(m) { + out = append(out, m) + continue + } + resolved, docRefs := l.resolveDocumentMessage(ctx, m) + out = append(out, resolved) + refs = append(refs, docRefs...) + } + return out, refs +} + +// resolveDocumentMessage 解析一条消息:文档部件就地换成文本块,其余部件 +// (文本、图片、工具结果)逐字保留,相对顺序不变。 +func (l *Loop) resolveDocumentMessage(ctx context.Context, m model.Message) (model.Message, []history.DocumentRef) { + count := countDocumentParts(m) + if m.Role != model.RoleUser { + // 红线(design/11 §10.2、design/24 §2.3-③):文档部件只允许挂在用户 + // 回合上。这里不静默:留下带来源的日志,并把部件确定性中和掉 —— + // 中和而不是丢弃,清单因此仍然出现在上下文里。 + l.log.Warn("document part on non-user message", "role", string(m.Role), "documents", count) + } + refs, bodies, err := l.documentResolutions(ctx, m) + failed := err != nil + if failed { + // 解析失败在这里留下可诊断信号:原消息保留、回合继续,但绝不静默 —— + // 下面的降级渲染给出诚实标记,清单照旧。日志只记数量,绝不记正文。 + l.log.Warn("document resolve", "error", err, "documents", count) + refs, bodies = nil, nil + } + content := make([]model.Content, 0, len(m.Content)) + out := make([]history.DocumentRef, 0, count) + for _, part := range m.Content { + if part.Type != model.ContentTypeDocument { + content = append(content, part) + continue + } + ref, body := documentResolution(part, refs, bodies, failed) + content = append(content, model.Content{Type: model.ContentTypeText, Text: documentBlock(part, ref, body)}) + out = append(out, ref) + } + m.Content = content + return m, out +} + +// documentResolutions 向解析器要一条消息的结论与正文。未接线解析器时返回空 +// 映射:每份文档仍会渲染出「仅清单」块(§4.2-3 清单恒在,绝不空上下文)。 +func (l *Loop) documentResolutions(ctx context.Context, m model.Message) (map[string]history.DocumentRef, map[string]string, error) { + if l.documentResolver == nil { + return nil, nil, nil + } + refs, bodies, err := l.documentResolver.Resolve(ctx, m) + if err != nil { + return nil, nil, fmt.Errorf("loop: resolve documents: %w", err) + } + return refs, bodies, nil +} + +// documentResolution 收拢一份文档部件的展示身份与解析结论。身份(存储名、 +// MIME、字节数)一律取自**部件本身**:它由服务端签发,解析器即使填了别的 +// 名字也不参与展示与回传,避免身份被换掉。 +// +// 状态口径:解析器给出的三态照用;缺失时 —— 解析器整个缺席(功能未接线)算 +// listed(「只给了清单」是事实),而解析器**报了错**(整条消息解析不了)算 +// unreadable(作者「读不到正文」比「只给了清单」更准确,也让失败对客户端可见)。 +func documentResolution(part model.Content, refs map[string]history.DocumentRef, bodies map[string]string, failed bool) (history.DocumentRef, string) { + ref := history.DocumentRef{Name: part.DocumentName, MIME: part.DocumentMIME, Size: part.DocumentSize} + if got, ok := refs[part.DocumentName]; ok { + ref.Status, ref.Chars, ref.Truncated = got.Status, got.Chars, got.Truncated + } + if !documentStatusKnown(ref.Status) { + ref.Status = history.DocumentStatusListed + if failed { + ref.Status = history.DocumentStatusUnreadable + } + ref.Chars, ref.Truncated = 0, false + } + body := bodies[part.DocumentName] + if ref.Status == history.DocumentStatusUnreadable && body == "" { + body = documentUnreadableBody + } + return ref, body +} + +// documentStatusKnown 报告 status 是不是三个已知取值之一。unknown 一律按 +// listed 处理:状态不明时最诚实的说法是「只给了清单」。 +func documentStatusKnown(status string) bool { + switch status { + case history.DocumentStatusExtracted, history.DocumentStatusListed, history.DocumentStatusUnreadable: + return true + default: + return false + } +} + +// documentBlock 渲染一份文档的注入块:清单行**恒在**(名、MIME、大小、结论), +// 其后是可注入的正文,或读不到时的诚实原因(design/24 §4.2-3、§4.4)。 +func documentBlock(part model.Content, ref history.DocumentRef, body string) string { + var b strings.Builder + b.WriteString(documentListPrefix) + b.WriteString(part.DocumentName) + b.WriteString(documentListSeparator) + b.WriteString(documentMIMEOf(part)) + b.WriteString(documentListSeparator) + b.WriteString(formatDocumentSize(part.DocumentSize)) + b.WriteString(documentListSeparator) + b.WriteString(documentStatusNote(ref)) + b.WriteString(documentListSuffix) + if body != "" { + b.WriteString("\n") + b.WriteString(body) + } + return b.String() +} + +// documentStatusNote 渲染清单行里的结论部分。截断时给出**续读游标**(已注入 +// 的字符数即抽取正文里的偏移量),使模型与后续的 document-read 工具知道从哪 +// 继续(design/24 §4.3:截断绝不静默)。 +func documentStatusNote(ref history.DocumentRef) string { + switch ref.Status { + case history.DocumentStatusExtracted: + if ref.Truncated { + return fmt.Sprintf(documentNoteTruncated, ref.Chars, ref.Chars) + } + return fmt.Sprintf(documentNoteExtracted, ref.Chars) + case history.DocumentStatusUnreadable: + return documentNoteUnreadable + default: + return documentNoteListed + } +} + +// documentMIMEOf 返回清单行里展示的 MIME:缺失时写「未知类型」而不是留空。 +func documentMIMEOf(part model.Content) string { + if part.DocumentMIME == "" { + return documentUnknownMIME + } + return part.DocumentMIME +} + +// formatDocumentSize 把字节数渲染为人类可读的大小,仅用于清单行展示。 +func formatDocumentSize(size int64) string { + switch { + case size >= bytesPerMiB: + return fmt.Sprintf("%.1f MiB", float64(size)/float64(bytesPerMiB)) + case size >= bytesPerKiB: + return fmt.Sprintf("%.1f KiB", float64(size)/float64(bytesPerKiB)) + case size < 0: + return fmt.Sprintf("%d B", 0) + default: + return fmt.Sprintf("%d B", size) + } +} + +// recordDocuments 把本次装配解析到的结论合并进 Result(按存储名去重、保留首次 +// 出现顺序)。它是「status 回给客户端」的唯一出口:调用方拿 Result.Documents +// 落进用户消息的 document 列或随响应回传(design/24 §4.4、§6.1)。 +func (r *Result) recordDocuments(refs []history.DocumentRef) { + for _, ref := range refs { + if ref.Name == "" { + continue + } + if r.docSeen == nil { + r.docSeen = make(map[string]int, len(refs)) + } + if i, ok := r.docSeen[ref.Name]; ok { + r.Documents[i] = ref + continue + } + r.docSeen[ref.Name] = len(r.Documents) + r.Documents = append(r.Documents, ref) + } +} + +// messageHasDocument 报告 msg 是否有任何内容部件是文档部件。 +func messageHasDocument(m model.Message) bool { + return countDocumentParts(m) > 0 +} + +// countDocumentParts 数一条消息里的文档部件个数(既用于短路,也用于日志)。 +func countDocumentParts(m model.Message) int { + n := 0 + for _, part := range m.Content { + if part.Type == model.ContentTypeDocument { + n++ + } + } + return n +} + +// anyDocumentPart 报告 messages 里有没有文档部件:没有就整体短路,使没有附件 +// 的回合与接线前逐字节一致。 +func anyDocumentPart(messages []model.Message) bool { + for _, m := range messages { + if messageHasDocument(m) { + return true + } + } + return false +} diff --git a/internal/runtime/loop/document_test.go b/internal/runtime/loop/document_test.go new file mode 100644 index 0000000..bfc776b --- /dev/null +++ b/internal/runtime/loop/document_test.go @@ -0,0 +1,338 @@ +package loop + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" +) + +// 本文件钉住装配期文档解析的四条硬规则(design/24 §2.3-①、§4): +// ① 文档部件被确定性替换为「清单行 + 正文」;② 清单恒在(解析器缺席或失败也 +// 一样);③ 抽取失败是可见的(诚实标记 + status=unreadable + 回合继续); +// ④ 文档部件绝不抵达编码器。另加两处「静默吞掉」的回归(design/24 §9.5): +// checkpoint 的用户消息比较键、压缩的 token 估算。 + +const ( + // docA / docB 是合法的 S1 存储名形态(32 位十六进制 + 扩展名)。 + docA = "0123456789abcdef0123456789abcdef.txt" + docB = "fedcba9876543210fedcba9876543210.log" +) + +// errDocResolve 是测试用的解析器整条消息失败错误(单份文档失败不走 error)。 +var errDocResolve = errors.New("文档解析服务不可用") + +// fakeDocumentResolver 是测试替身:直接给出结论与正文,不触盘、不依赖真实文件。 +type fakeDocumentResolver struct { + refs map[string]history.DocumentRef + bodies map[string]string + err error + calls int +} + +func (f *fakeDocumentResolver) Resolve(_ context.Context, _ model.Message) (map[string]history.DocumentRef, map[string]string, error) { + f.calls++ + if f.err != nil { + return nil, nil, f.err + } + return f.refs, f.bodies, nil +} + +// documentMessage 构造一条只带附件的用户消息。 +func documentMessage(name, mime string, size int64) model.Message { + return model.DocumentMessage(model.RoleUser, "", model.Document{Name: name, MIME: mime, Size: size}) +} + +// messageTextOf 拼出消息里所有文本部件,供断言注入块的内容。 +func messageTextOf(m model.Message) string { + var b strings.Builder + for _, part := range m.Content { + if part.Type == model.ContentTypeText { + b.WriteString(part.Text) + } + } + return b.String() +} + +// TestResolveDocuments_InjectsListAndBody 钉住主路径:清单行恒在,正文紧随其后, +// 文档部件全部被替换掉(同一位置)。 +func TestResolveDocuments_InjectsListAndBody(t *testing.T) { + t.Parallel() + res := &fakeDocumentResolver{ + refs: map[string]history.DocumentRef{docA: {Status: history.DocumentStatusExtracted, Chars: 5}}, + bodies: map[string]string{docA: "正文内容"}, + } + l := New(Options{DocumentResolver: res}) + in := []model.Message{model.DocumentMessage(model.RoleUser, "看看这个", model.Document{Name: docA, MIME: "text/plain", Size: 2048})} + + out, refs := l.resolveDocuments(context.Background(), in) + if len(out) != 1 || countDocumentParts(out[0]) != 0 { + t.Fatalf("解析后仍有文档部件: %+v", out) + } + text := messageTextOf(out[0]) + if !strings.Contains(text, documentListPrefix+docA) || !strings.Contains(text, "text/plain") { + t.Fatalf("注入块缺清单行: %q", text) + } + if !strings.Contains(text, "正文内容") { + t.Fatalf("注入块缺正文: %q", text) + } + if !strings.HasPrefix(text, "看看这个") { + t.Fatalf("用户原文顺序被改变: %q", text) + } + if len(refs) != 1 || refs[0].Status != history.DocumentStatusExtracted || refs[0].Chars != 5 { + t.Fatalf("结论 = %+v, want extracted/chars=5", refs) + } + if refs[0].Name != docA || refs[0].MIME != "text/plain" || refs[0].Size != 2048 { + t.Fatalf("结论的身份字段 = %+v, want 取自部件", refs[0]) + } +} + +// TestResolveDocuments_KeepsListWithoutResolver 钉住「清单恒在」:没有解析器 +// (忘了接线)时文档部件照样被中和为「仅清单」块,绝不出现空上下文。 +func TestResolveDocuments_KeepsListWithoutResolver(t *testing.T) { + t.Parallel() + l := New(Options{}) + out, refs := l.resolveDocuments(context.Background(), []model.Message{documentMessage(docA, "text/plain", 10)}) + if len(out) != 1 || countDocumentParts(out[0]) != 0 { + t.Fatalf("没有解析器时文档部件仍抵达下游: %+v", out) + } + text := messageTextOf(out[0]) + if !strings.Contains(text, documentListPrefix+docA) || !strings.Contains(text, documentNoteListed) { + t.Fatalf("注入块 = %q, want 仅清单条目", text) + } + if len(refs) != 1 || refs[0].Status != history.DocumentStatusListed || refs[0].Chars != 0 { + t.Fatalf("结论 = %+v, want listed/chars=0", refs) + } +} + +// TestResolveDocuments_TruncatedGivesCursor 钉住截断诚实(design/24 §4.3): +// 截断时清单行给出续读游标(已注入字符数),绝不静默截断。 +func TestResolveDocuments_TruncatedGivesCursor(t *testing.T) { + t.Parallel() + res := &fakeDocumentResolver{ + refs: map[string]history.DocumentRef{docA: {Status: history.DocumentStatusExtracted, Chars: 700, Truncated: true}}, + bodies: map[string]string{docA: "前 700 字符"}, + } + l := New(Options{DocumentResolver: res}) + out, _ := l.resolveDocuments(context.Background(), []model.Message{documentMessage(docA, "text/plain", 99)}) + text := messageTextOf(out[0]) + for _, want := range []string{"已截断", "offset=700"} { + if !strings.Contains(text, want) { + t.Fatalf("注入块 %q 缺 %q", text, want) + } + } +} + +// TestResolveDocuments_UnreadableIsHonest 钉住失败可见(design/24 §4.4): +// unreadable 结论 + 诚实原因文案 + 清单仍在,且解析不产生 error(回合继续)。 +func TestResolveDocuments_UnreadableIsHonest(t *testing.T) { + t.Parallel() + res := &fakeDocumentResolver{ + refs: map[string]history.DocumentRef{docB: {Status: history.DocumentStatusUnreadable}}, + bodies: map[string]string{docB: "无法抽取正文:无文本层"}, + } + l := New(Options{DocumentResolver: res}) + out, refs := l.resolveDocuments(context.Background(), []model.Message{documentMessage(docB, "application/pdf", 1024)}) + text := messageTextOf(out[0]) + if !strings.Contains(text, documentListPrefix+docB) || !strings.Contains(text, documentNoteUnreadable) { + t.Fatalf("注入块 = %q, want 清单行标不可读", text) + } + if !strings.Contains(text, "无文本层") { + t.Fatalf("注入块缺诚实原因: %q", text) + } + if len(refs) != 1 || refs[0].Status != history.DocumentStatusUnreadable { + t.Fatalf("结论 = %+v, want unreadable", refs) + } +} + +// TestResolveDocuments_ResolverErrorFallsBackHonestly 钉住解析器整体失败时的 +// 降级:诚实兜底原因 + 清单仍在 + unreadable 状态,绝不空上下文。 +func TestResolveDocuments_ResolverErrorFallsBackHonestly(t *testing.T) { + t.Parallel() + l := New(Options{DocumentResolver: &fakeDocumentResolver{err: errDocResolve}}) + out, refs := l.resolveDocuments(context.Background(), []model.Message{documentMessage(docA, "text/plain", 10)}) + text := messageTextOf(out[0]) + if !strings.Contains(text, documentListPrefix+docA) || !strings.Contains(text, documentUnreadableBody) { + t.Fatalf("注入块 = %q, want 清单行 + 诚实兜底", text) + } + if len(refs) != 1 || refs[0].Status != history.DocumentStatusUnreadable { + t.Fatalf("结论 = %+v, want unreadable", refs) + } +} + +// TestResolveDocuments_NeutralizesNonUserParts 钉住红线(design/11 §10.2):文档 +// 部件落到非用户回合上是编程错误,但绝不静默丢弃 —— 它照样被中和成清单块。 +func TestResolveDocuments_NeutralizesNonUserParts(t *testing.T) { + t.Parallel() + l := New(Options{}) + in := []model.Message{model.DocumentMessage(model.RoleAssistant, "", model.Document{Name: docA, MIME: "text/plain"})} + out, refs := l.resolveDocuments(context.Background(), in) + if countDocumentParts(out[0]) != 0 || !strings.Contains(messageTextOf(out[0]), documentListPrefix+docA) { + t.Fatalf("非用户回合的文档部件未被中和: %+v", out[0]) + } + if len(refs) != 1 { + t.Fatalf("结论数 = %d, want 1", len(refs)) + } +} + +// TestRun_DocumentPartsNeverReachEncoder 是同名不变式的负例:无论解析器缺席、 +// 解析器报错还是解析成功,喂给模型的每条请求里都不得出现文档部件(S2 的编码器 +// 对文档 fail-loud,漏一个就是一次 400)。 +func TestRun_DocumentPartsNeverReachEncoder(t *testing.T) { + t.Parallel() + cases := []struct { + name string + resolver DocumentResolver + }{ + {name: "无解析器", resolver: nil}, + {name: "解析器报错", resolver: &fakeDocumentResolver{err: errDocResolve}}, + {name: "解析成功", resolver: &fakeDocumentResolver{ + refs: map[string]history.DocumentRef{docA: {Status: history.DocumentStatusExtracted, Chars: 3}}, + bodies: map[string]string{docA: "正文"}, + }}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Model: "m", Content: "Done."}}}} + l := New(Options{Agent: newTestAgent(t, ad, nil), MaxRounds: 4, DocumentResolver: c.resolver}) + + in := []model.Message{ + documentMessage(docA, "text/plain", 12), + model.DocumentMessage(model.RoleAssistant, "", model.Document{Name: docB, MIME: "text/plain"}), + } + if _, err := l.Run(context.Background(), "sess-doc", in); err != nil { + t.Fatalf("Run: %v", err) + } + assertNoDocumentParts(t, ad.reqs) + }) + } +} + +// assertNoDocumentParts 断言每条模型请求都不含文档部件,并且能通过编码器入口 +// 的同一道校验(model.ValidateDocumentParts)。 +func assertNoDocumentParts(t *testing.T, reqs []model.Request) { + t.Helper() + if len(reqs) == 0 { + t.Fatal("没有记录到任何模型请求") + } + for _, req := range reqs { + for _, m := range req.Messages { + if countDocumentParts(m) != 0 { + t.Fatalf("文档部件抵达了模型: %+v", m) + } + } + if err := model.ValidateDocumentParts(req.Messages); err != nil { + t.Fatalf("编码器边界校验失败: %v", err) + } + } +} + +// TestRun_RecordsDocumentOutcomesOnResult 钉住 status 的回传出口:结论随 Result +// 返回(按存储名去重、保留首次出现顺序),使客户端能看到每份附件的结果。 +func TestRun_RecordsDocumentOutcomesOnResult(t *testing.T) { + t.Parallel() + res := &fakeDocumentResolver{ + refs: map[string]history.DocumentRef{ + docA: {Status: history.DocumentStatusExtracted, Chars: 3}, + docB: {Status: history.DocumentStatusUnreadable}, + }, + bodies: map[string]string{docA: "正文", docB: "无法抽取正文:不存在"}, + } + ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Model: "m", Content: "Done."}}}} + l := New(Options{Agent: newTestAgent(t, ad, nil), MaxRounds: 4, DocumentResolver: res}) + + in := []model.Message{documentMessage(docA, "text/plain", 12), documentMessage(docB, "text/plain", 8)} + out, err := l.Run(context.Background(), "sess-doc", in) + if err != nil { + t.Fatalf("Run: %v", err) + } + if len(out.Documents) != 2 { + t.Fatalf("Documents = %+v, want 2 条(去重后)", out.Documents) + } + if out.Documents[0].Name != docA || out.Documents[0].Status != history.DocumentStatusExtracted { + t.Fatalf("Documents[0] = %+v, want %s/extracted", out.Documents[0], docA) + } + if out.Documents[1].Name != docB || out.Documents[1].Status != history.DocumentStatusUnreadable { + t.Fatalf("Documents[1] = %+v, want %s/unreadable", out.Documents[1], docB) + } +} + +// TestUserMessageKey_IncludesDocumentRef 是 design/24 §9.5 移交的回归:纯文档 +// 用户消息(客户端「超限走文件」发的就是它)必须在比较键里有内容,否则续接判定 +// 会把附件回合当成「没有可比内容」而漏判。 +func TestUserMessageKey_IncludesDocumentRef(t *testing.T) { + t.Parallel() + docOnly := documentMessage(docA, "text/plain", 10) + if key := userMessageKey(docOnly); key == "" { + t.Fatal("纯文档消息的比较键为空") + } + if lastUserMessageKey([]model.Message{docOnly}) == "" { + t.Fatal("纯文档回合的最后一条用户消息键为空") + } + if userMessageKey(docOnly) == userMessageKey(documentMessage(docB, "text/plain", 10)) { + t.Fatal("两份不同附件得到了同一个比较键") + } +} + +// TestEstimateContextTokens_CountsDocuments 是 design/24 §9.5 移交的回归:文档 +// 部件必须在 token 估算里占分量,否则纯文档回合被估成 0 token,在压缩里等同于 +// 空内容。 +func TestEstimateContextTokens_CountsDocuments(t *testing.T) { + t.Parallel() + small := []model.Message{documentMessage(docA, "text/plain", 1024)} + big := []model.Message{documentMessage(docA, "text/plain", 4<<20)} + if got := estimateContextTokens(small); got <= messageOverheadTokens { + t.Fatalf("纯文档回合估算 = %d, want > %d(文档部件不是免费的)", got, messageOverheadTokens) + } + if estimateContextTokens(big) <= estimateContextTokens(small) { + t.Fatalf("大附件估算未变大:%d vs %d", estimateContextTokens(big), estimateContextTokens(small)) + } + if got := documentPartTokens(model.Content{Type: model.ContentTypeDocument}); got != documentMinTokens { + t.Fatalf("documentPartTokens(未知字节数) = %d, want %d", got, documentMinTokens) + } + if got := documentPartTokens(model.Content{Type: model.ContentTypeDocument, DocumentSize: documentInjectedCharsCap * 4}) / + 1; got != documentInjectedCharsCap/charsPerToken { + t.Fatalf("documentPartTokens(超大附件) = %d, want 按注入预算封顶 %d", got, documentInjectedCharsCap/charsPerToken) + } +} + +// TestFormatDocumentSize 钉住清单行里的展示口径(只用于人读,不做任何判定)。 +func TestFormatDocumentSize(t *testing.T) { + t.Parallel() + cases := []struct { + in int64 + want string + }{ + {0, "0 B"}, + {512, "512 B"}, + {2048, "2.0 KiB"}, + {3 << 20, "3.0 MiB"}, + {-1, "0 B"}, + } + for _, c := range cases { + if got := formatDocumentSize(c.in); got != c.want { + t.Fatalf("formatDocumentSize(%d) = %q, want %q", c.in, got, c.want) + } + } +} + +// TestDocumentBlockShape 钉住注入块的稳定形状(清单行在前、正文换行在后), +// 因为客户端渲染与「清单恒在」都依赖它。 +func TestDocumentBlockShape(t *testing.T) { + t.Parallel() + part := model.Content{Type: model.ContentTypeDocument, DocumentName: docA, DocumentMIME: "text/plain", DocumentSize: 2048} + block := documentBlock(part, history.DocumentRef{Name: docA, Status: history.DocumentStatusExtracted, Chars: 5}, "正文") + want := fmt.Sprintf("%s%s · text/plain · 2.0 KiB · 已注入正文 5 字符]\n正文", documentListPrefix, docA) + if block != want { + t.Fatalf("documentBlock = %q, want %q", block, want) + } + empty := documentBlock(model.Content{Type: model.ContentTypeDocument, DocumentName: docA}, history.DocumentRef{Status: history.DocumentStatusListed}, "") + if strings.Contains(empty, "\n") || !strings.Contains(empty, documentNoteListed) { + t.Fatalf("无正文时不该有换行: %q", empty) + } +} diff --git a/internal/runtime/loop/loop.go b/internal/runtime/loop/loop.go index e1ec3e0..4a0d8b1 100644 --- a/internal/runtime/loop/loop.go +++ b/internal/runtime/loop/loop.go @@ -22,6 +22,7 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/guardrail" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool" @@ -78,6 +79,15 @@ type Result struct { // OnTurnEnd 钩子返回(未接线钩子时为 "")。持久化层把它 // 带到 assistant 消息上(design/16 §3.3)。 Mood string + // Documents 是本回合**装配期解析到的文档结论**(design/24 §4.4、§6.1): + // 按服务端存储名去重、保留首次出现顺序,字段形状即 history.DocumentRef + // (与落库/DTO 同一个结构体)。调用方据此把 status/chars/truncated 落进 + // 用户消息的 document 列或随响应回传,使客户端能看到「这份附件读到了 / + // 读不到」—— 没有它,抽取失败对客户端就是不可见的。 + Documents []history.DocumentRef + // docSeen 是 Documents 的去重索引(存储名 → 下标),作用域限于本次 + // Result,绝不跨 Run 共享。 + docSeen map[string]int } // Loop 是默认的 agent 执行引擎。它经 New 一次性配置, @@ -121,6 +131,7 @@ type Loop struct { compactor Compactor transformContext func(context.Context, []model.Message, int) []model.Message imageResolver ImageResolver + documentResolver DocumentResolver log *slog.Logger } @@ -182,6 +193,7 @@ func New(opts Options) *Loop { compactor: compactor, transformContext: opts.TransformContext, imageResolver: opts.Vision, + documentResolver: opts.DocumentResolver, log: sourcedLog(opts.Log), } } diff --git a/internal/runtime/loop/options.go b/internal/runtime/loop/options.go index e8a319a..716579c 100644 --- a/internal/runtime/loop/options.go +++ b/internal/runtime/loop/options.go @@ -190,6 +190,15 @@ type Options struct { // vision/OCR 工具产出的文字描述("先看图再对话")。为 nil 时 // 图像内容原样保留,直接透传给支持视觉的主模型。 Vision ImageResolver + // DocumentResolver 非 nil 时,在**每步装配模型可见上下文**时把文档部件 + // 确定性替换为抽取所得的正文或「仅清单」条目(design/24 §2.3-①、§4)。 + // 文档部件因此永不抵达编码器(编码器那侧对文档 fail-loud);解析失败只 + // 降级为诚实标记 + status=unreadable,绝不让回合失败(§4.4)。 + // + // 为 nil 时循环仍会把文档部件替换为「仅清单」块(清单恒在,§4.2-3), + // 只是没有任何正文被注入 —— 因此即使忘了接线,也不会出现「文档部件抵达 + // 编码器」这种 400,更不会出现空上下文。 + DocumentResolver DocumentResolver // TransformContext 非 nil 时,在每个模型步之前立即被调用, // 传入组装好的消息列表(记忆注入与任何压缩 // 之后)以及当前轮次号(从 1 开始)。它可以投影、过滤 diff --git a/internal/runtime/loop/run.go b/internal/runtime/loop/run.go index e629a0d..a8536ee 100644 --- a/internal/runtime/loop/run.go +++ b/internal/runtime/loop/run.go @@ -126,6 +126,14 @@ func (l *Loop) driveTurn(ctx context.Context, sessionID string, msgs []model.Mes if len(steered) > 0 { view = append(view, steered...) } + // 装配期解析文档部件(design/24 §2.3-①、§4):每条携带文档部件的消息在 + // 这里被**确定性**换成「清单行 + 正文」文本块,因此文档部件绝不抵达编码器 + // (S2 的编码器对文档 fail-loud,漏一个就是一次 400)。位置与 Run 开头的 + // resolveImages 同族,区别是它作用于**本步的模型可见视图**而不是消息窗口: + // 窗口只留引用(§2.4),正文每次按引用读 sidecar 注入,checkpoint 因此不会 + // 被正文撑大;回合中途经 steer 并入的消息(含附件)也在同一处被解析。 + view, docRefs := l.resolveDocuments(ctx, view) + res.recordDocuments(docRefs) rc, roundSpan := l.span(ctx, "loop.step", trace.SpanKindStateTransition) outcome, err := l.step(rc, sessionID, view)