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

- 抽取器零新依赖纯函数:UTF-8 清洗、CSV/TSV 行数列名摘要、64K rune 安全截断;PDF/Office/二进制走哨兵错误诚实拒绝(不引解析库)。

- sidecar:<DataDir>/extracted/<存储名>.txt,首行紧凑 JSON 元数据 + 正文;临时文件 + rename 原子落定;只抽一次(删原文件仍出正文);版本不匹配/坏缓存重抽。

- 装配期 resolveDocuments(resolveImages 同位,每步对模型可见视图注入):清单恒在(解析器缺席/报错也在)、截断带续读游标、失败诚实标记 + status=unreadable、回合继续;文档部件永不抵达编码器(负例钉住)。

- 修 S2 移交的两处静默吞掉:checkpoint 比较键纳入文档引用、compactor token 估算计入文档部件(不是免费的)。

- 主会话独立验收:隔离 worktree(HEAD + 本切片)build/vet 绿、全树 59 包 0 FAIL、linux/amd64 + darwin/arm64 交叉编译绿、documents+loop 两包 167 条 PASS;变异独立复现(resolveDocuments 短路 ⇒ 3 条红,含「文档部件仍抵达下游」)。
This commit is contained in:
AskaEth committed 2026-10-04 17:22:05 +08:00
1 parent 834ef3683e
commit a8851dc0a2
12 files changed
+2008 -7

No files matched your search

+12 -6
View File
@@ -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()
}
+26 -1
View File
@@ -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。
+289
View File
@@ -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
}
+338
View File
@@ -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)
}
}
+12
View File
@@ -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),
}
}
+9
View File
@@ -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 开始)。它可以投影、过滤
+8
View File
@@ -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)