- ① documents[] 元素增可选 reason(omitempty):装配期在给出 status 的**同一处**填充,取的**就是注入模型清单里那份原因文案**(同一字符串两处用,服务端不为客户端另编一句),只在 unreadable 时下发;随 S3.5 的回合后回写落进 document 列 ⇒ 历史接口 / 握手回放都有。文案只含原因、不含落盘路径/存储名以外内容/凭据(有独特目录名反证断言)。
- ② GET /sessions/{id}/messages:不存在/已删除 ⇒ 404 session_not_found;**存在但无消息仍 200 {data:[]}**(空会话绝不能被答成 404);参数形状校验仍先于 404;存在性判定复用既有窄视图(history.Store.SessionPersona,未知 id 报 ErrSessionNotFound),**未新增 SQL/存储方法**;判不出来(未接该视图)保持旧语义(判不出 ≠ 不存在),判定本身失败回 500 不静默。
- 契约 v0.63(三档:reason 纯增量 / messages 404 行为变更 / 无破坏性):api-client §1 行 6 + §1.2④ 的 10 条逐字文案 + §4.4 错误码 + changelog + banner;api-reference §1.2 行 6 + §3.4 + §3.8 DTO + §8.1 错误码表。
- 主会话独立核对的副作用(隔离会话 sess-* 现在也 404):查活库 chat_sessions 里**根本没有 sess-* 行**(只有 web 与 web:<uuid>),sess-backthink 消息数 0,前端唯一提到 sess-backthink 的是 graph_update 的内存桶测试 ⇒ **没有客户端按 id 读隔离会话的 messages**;且这与 POST /sessions/{id}/read(v0.37 起即 404)一致 ⇒ 接受不加 sess- 例外,并在回执里问安卓确认。
- 主会话独立验收:隔离 worktree(S9 的 13 文件)build/vet 绿、整树连跑 2 次 59 ok / 0 FAIL、reason/404 用例 8 PASS、双平台交叉编译绿;**变异亲自复现**(在调用点短路 404 守卫 ⇒ UnknownSessionIs404 与 DeletedSessionIs404 双红:status = 200, want 404)。
- 如实登记:验收过程中出现过**一次不可复现的子集 FAIL**(restore 之后那次 Session|Reason 子集跑;未记到用例名)—— 随后同一子集、那三条用例 ×5、以及整树 ×2 全绿,判为瞬时;已记入账本,后续留意。
360 lines
16 KiB
Go
360 lines
16 KiB
Go
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
|
||
}
|