Files
Cyrene_v2/internal/runtime/loop/document.go
T
AskaEth 902b6eecfd
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(api): documents[].reason + messages 对不存在会话回 404(安卓回执九两条;契约 v0.63)
- ① 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 全绿,判为瞬时;已记入账本,后续留意。
2026-10-04 21:50:08 +08:00

360 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}