package loop import ( "context" "fmt" "strings" "unicode/utf8" "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); // 4. **注入预算在这里生效**(切片 T1 §7.2):正文按本回合的注入预算 // (l.documentInjectChars)截取,ref.Chars = 实际注入的字符数、 // ref.Truncated = 注入是否被截断。sidecar 存的是抽取全文,因此清单行给出的 // 续读游标从此**真的是**「继续读的起点」,与 document-read 的 offset 对齐。 // // 它作用于**装配后的模型可见视图**而不是消息窗口:窗口(checkpoint 快照、 // 记忆提取输入)里只留文档引用(§2.4),正文每次装配时按引用读 sidecar 得到。 const ( // defaultDocumentInjectChars 是单份文档注入上下文的**字符**上限的缺省值 // (64K 字符)。它的实际取值由组合根按推导出的上下文预算给出 // (Options.DocumentMaxInjectChars / DocumentInjectCharsFor):64K 是 // 「小窗口下不退化」的下限,不再兼任抽取期的截断预算 —— 抽取期只受 // documents.MaxSidecarBytes 这个安全上限约束(切片 T1 §7.2)。 // // 它同时是压缩估算里文档部件代价的封顶依据(compactor.go 的 // documentPartTokens):注入多少,就按多少计入 token。 defaultDocumentInjectChars = 64 * 1024 // documentMinTokens 是清单行(名/MIME/大小/结论)的粗略 token 下限:无论 // 文档多小、甚至完全读不到,清单行都进了上下文,因此它不是免费的。 documentMinTokens = 32 // documentUnknownMIME 是清单行里 MIME 缺失时的展示值 —— 宁可写「未知类型」 // 也不留一个空洞让模型误读成「没有问题」。 documentUnknownMIME = "未知类型" // bytesPerKiB / bytesPerMiB 是清单行渲染大小用的单位(仅展示用)。 bytesPerKiB = 1024 bytesPerMiB = 1024 * bytesPerKiB // documentInjectBudgetShare 是单份附件注入正文可占推导预算的份数(1/份数): // 一个回合最多 5 个附件(design/24 §3.4),取 8 意味着「每份最多占预算的 // 1/8」—— 5 份合计不超过 5/8,其余留给系统提示词、历史、工具 schema 与本轮 // 输出。字符与 token 的换算沿用循环的离线估算口径(charsPerToken)。 documentInjectBudgetShare = 8 ) // DocumentInjectCharsFor 由推导出的上下文预算(token)算出**单份附件**注入 // 模型可见上下文的字符预算(design/24 §9.6 连带项;切片 T1 §7.2 第 2 条)。 // // 这是「注入预算随上下文预算推导」这条要求的落点:1M 窗口 × 2/3 ⇒ 699050 token // 的预算 ⇒ 单份附件可注入 349525 字符,而不是旧的 64K 卡死。取值下限是历史默认 // 64K(小窗口下附件行为不退化),并且绝不超过预算本身(预算被显式设得很小时, // 也不该让一份附件就吃掉整个窗口)。 func DocumentInjectCharsFor(budgetTokens int) int { if budgetTokens <= 0 { return defaultDocumentInjectChars } chars := budgetTokens * charsPerToken / documentInjectBudgetShare if chars < defaultDocumentInjectChars { chars = defaultDocumentInjectChars } if chars > budgetTokens { chars = budgetTokens } return chars } // 清单行与结论文案(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/reason 则经 Result.Documents 回给 // 客户端(§4.4、§6.1)。 // // 两份映射都以**服务端存储名**为键,值分别是该文档的结论(形状即 // history.DocumentRef,与落库/DTO 同一个结构体)与要注入的正文(读不到时是 // 诚实的原因文案)。缺少条目的文档由循环降级为「仅清单」。用这些中立类型, // 摄取层不必依赖 loop 包,循环也不必依赖摄取层 —— 与 vision.MessageResolver // 满足 ImageResolver 的方式相同。 // // 契约(v0.63):status == unreadable 时,**正文就是原因** —— 循环把它原样作为 // `documents[].reason` 回给客户端。因此它必须是「人能读懂、不含路径与内部细节」 // 的短句(design/24 §4.4 已是这个口径);原始错误只进日志。 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, l.documentInjectChars) 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(作者「读不到正文」比「只给了清单」更准确,也让失败对客户端可见)。 // // 注入预算(injectChars)在这一步生效:已抽取的正文按 rune 截到 injectChars, // ref.Chars 记**实际注入**的字符数、ref.Truncated 记**注入**是否被截断。抽取侧 // 自己那套 chars/truncated(sidecar 正文长度、是否撞上安全上限)不是客户端关心 // 的东西,读侧另有出口(document-read 的 totalChars / extractTruncated)。 func documentResolution(part model.Content, refs map[string]history.DocumentRef, bodies map[string]string, failed bool, injectChars int) (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 } ref.Reason = documentReason(ref.Status, body) if ref.Status != history.DocumentStatusExtracted { return ref, body } injected, truncated := truncateRunesAt(body, injectChars) ref.Chars = utf8.RuneCountInString(injected) ref.Truncated = truncated return ref, injected } // documentStatusKnown 报告 status 是不是三个已知取值之一。unknown 一律按 // listed 处理:状态不明时最诚实的说法是「只给了清单」。 func documentStatusKnown(status string) bool { switch status { case history.DocumentStatusExtracted, history.DocumentStatusListed, history.DocumentStatusUnreadable: return true default: return false } } // documentReason 给出随结论回给客户端的**原因文案**(契约 v0.63 的 // `documents[].reason`):只在「读不到」时给,其余状态一律为空(该键因此从 DTO // 里省略)。 // // 它就是注入给模型的那份原因文案 —— 装配期把 unreadable 的正文当作原因用 // (解析器读不到时给的就是诚实原因,见 DocumentResolver 的契约;解析器整条报错 // 时是 documentUnreadableBody 这个兜底)。**同一个字符串两处用**是刻意的:客户端 // 看到的理由与模型看到的理由因此不可能分叉,服务端也从不为客户端另编一句。 // // 只有文案:不含路径、不含凭据、不含存储名以外的标识 —— 原始错误只进日志。 func documentReason(status, body string) string { if status != history.DocumentStatusUnreadable { return "" } return body } // 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:截断绝不静默)。因为 sidecar 存的是抽取全文 // (切片 T1 §7.2),这个游标**真的**是「继续读的起点」。 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 }