- 抽取器零新依赖纯函数: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 条红,含「文档部件仍抵达下游」)。
290 lines
12 KiB
Go
290 lines
12 KiB
Go
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
|
||
}
|