Files
Cyrene_v2/plugins/tools/io/document_read_budget_test.go
T
AskaEth 72b95df22b
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(config,runtime): 上下文预算按模型窗口推导 + usage/缓存可观测 + sidecar 存全文(T1)
- 窗口声明:models.json 组级 contextWindow + env LLM_CONTEXT_WINDOW(per-group materialize 播种,0 不写盘、负数报错)。

- 预算三态:CONTEXT_MAX_TOKENS 未设置 ⇒ 按窗口推导;>0 显式覆盖;0|负 关闭压缩(不变)。推导 budget = min(窗口×比例, 窗口−输出保留),比例默认 2/3(CONTEXT_BUDGET_RATIO,1M ⇒ 699050 tokens ≈ 683Ki);输出保留 = max(32768, 该组 maxTokens) 且封顶窗口/2(防配置矛盾时退化成 1 token);窗口未声明 ⇒ 回落 131072 + 恰好一条 WARN。接线在组合根 wire_context.go(唯一决策点)。

- usage/缓存可观测(此前**完全不可观测**):Response 增 CacheHit/CacheMiss/Reasoning tokens + CacheHitRate;解析 OpenAI 兼容(prompt_cache_hit_tokens/prompt_cache_miss_tokens/cached_tokens/reasoning_tokens)与 anthropic(cache_read_input_tokens/cache_creation_input_tokens)两种形状;记录点在适配器 record(换入口不丢成本日志),日志无凭据;单次输入 ≥预算 90% 记 WARN(不阻断)。

- 根治清单「续读」假承诺:抽取期不再按注入预算截断(sidecar 存全文,只受 MaxSidecarBytes=8 MiB 安全上限,ExtractorVersion v1→v2 旧 sidecar 失效重抽);注入预算改在装配期生效 ⇒ ref.Chars/Truncated 是注入侧事实、游标真是继续读的起点;document-read 的 limit 上限改为独立具名常量。

- 顺手修越界:config.go 505 行(超 500)拆出 llm.go。

- 主会话独立验收:隔离 worktree(HEAD + 本切片 39 文件,排除在途 gateway)build/vet 绿、全树 59 包 0 FAIL、linux+darwin 交叉编译绿;变异独立复现(比例改 0.25 ⇒ 推导用例红 262144 vs 699050)。
2026-10-04 17:57:08 +08:00

108 lines
4.7 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 io
import (
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
)
// 本文件钉住 document-read 与**装配期注入预算**之间的成对契约(切片 T1 §7.2):
// sidecar 存抽取全文,注入只在装配期按预算截断,因此清单行给出的续读游标真的是
// 「继续读的起点」。工具自身的分页/边界/诚实失败用例在 document_read_test.go。
// TestDocumentReadTool_CursorContinuesIntoTextBeyondTheInjectionBudget 钉住切片 T1
// §7.2 修好的断裂(S4 实测:清单游标恒等于 sidecar 正文长度 ⇒ 照它续读必然读到空):
//
// - CSV 的摘要行属于正文坐标系(offset 0 = 摘要行开头),与装配期注入的正文一致;
// - sidecar 存的是**抽取全文**:正文远超旧的 64K 注入预算时,`Chars` 仍是全文长度、
// `Truncated=false`,因此模型照清单给的游标(= 已注入字符数)续读,拿到的是
// 注入预算**之外**的真实正文,而不是空。
func TestDocumentReadTool_CursorContinuesIntoTextBeyondTheInjectionBudget(t *testing.T) {
t.Parallel()
fx := newDocReadFixture(t)
tool := fx.tool(t)
csv := docName(".csv")
_, injectedCSV := fx.extract(t, csv, "a,b\n1,2\n3,4\n")
if !strings.HasPrefix(injectedCSV, "[CSV") {
t.Fatalf("CSV 夹具应当带摘要行:%q", injectedCSV)
}
gotCSV, err := callDocRead(t, tool, map[string]any{"name": csv})
if err != nil {
t.Fatalf("读 CSV: %v", err)
}
if gotCSV.Text != injectedCSV {
t.Fatalf("CSV text = %q, want 含摘要行的注入正文 %q", gotCSV.Text, injectedCSV)
}
// 正文 20 万字符:远超旧的 64K 注入预算,但远在 sidecar 安全上限内。
body := strings.Repeat("正", 200*1024)
big := docName(".txt")
ref, injected := fx.extract(t, big, body)
if ref.Truncated || ref.Chars != len([]rune(body)) {
t.Fatalf("夹具前提:ref = %+v, want 全文 %d 字符、未撞安全上限", ref, len([]rune(body)))
}
if injected != body {
t.Fatalf("注入正文长度 = %d, want 全文 %d", len([]rune(injected)), len([]rune(body)))
}
// 装配期若按旧预算(64K 字符)注入,游标就是 64K;按它续读必须拿到预算之外的正文。
const injectedByOldBudget = 64 * 1024
atCursor, err := callDocRead(t, tool, map[string]any{"name": big, "offset": injectedByOldBudget})
if err != nil {
t.Fatalf("按清单游标读取: %v", err)
}
if atCursor.TotalChars != len([]rune(body)) {
t.Fatalf("totalChars = %d, want sidecar 全文 %d", atCursor.TotalChars, len([]rune(body)))
}
if atCursor.Text == "" {
t.Fatal("按游标续读读到了空正文 —— 清单给的承诺是假的(S4 的断裂没有修好)")
}
if !atCursor.Truncated || atCursor.NextOffset != injectedByOldBudget+len([]rune(atCursor.Text)) {
t.Fatalf("续读窗口 = %+v, want 被 limit 截断且带 nextOffset", atCursor)
}
if want := body[injectedByOldBudget*len("正"):][:len(atCursor.Text)]; atCursor.Text != want {
t.Fatalf("续读正文不是注入预算之外的那一段:前 40 字符 %q", firstRunes(atCursor.Text, 40))
}
if atCursor.ExtractTruncated {
t.Fatal("未撞安全上限的文档不该报 extractTruncated")
}
}
// firstRunes 返回 s 的前 n 个 rune(只用于失败信息,避免把整段正文打进日志)。
func firstRunes(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n]) + "…"
}
// TestDocumentReadTool_SafetyCapTruncationIsHonest 钉住与抽取侧的另一半成对契约:
// 正文撞上 sidecar 安全上限(documents.MaxSidecarBytes)时,sidecar 只有那段前缀,
// 工具照实把「抽取期就到头了」告诉模型(extractTruncated=true),使「读到末尾」
// 不会被误当成「文件只有这么长」。
func TestDocumentReadTool_SafetyCapTruncationIsHonest(t *testing.T) {
t.Parallel()
fx := newDocReadFixture(t)
name := docName(".txt")
ref, injected := fx.extract(t, name, strings.Repeat("宽", documents.MaxSidecarBytes/len("宽")+16))
if !ref.Truncated {
t.Fatalf("夹具前提:ref = %+v, want 撞上安全上限并截断", ref)
}
if ref.Chars != len([]rune(injected)) || ref.Chars > documents.MaxSidecarBytes/len("宽") {
t.Fatalf("撞上限后的字符数 = %d, want 不超过 %d 且与正文一致", ref.Chars, documents.MaxSidecarBytes/len("宽"))
}
atEnd, err := callDocRead(t, fx.tool(t), map[string]any{"name": name, "offset": ref.Chars})
if err != nil {
t.Fatalf("按末尾游标读取: %v", err)
}
if atEnd.TotalChars != ref.Chars || atEnd.Text != "" || atEnd.Truncated {
t.Fatalf("末尾读取 = %+v, want 读到 sidecar 末尾(totalChars %d)", atEnd, ref.Chars)
}
if !atEnd.ExtractTruncated {
t.Fatal("抽取期的安全上限截断没有如实回给模型(extractTruncated=false)")
}
}