- 窗口声明: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)。
131 lines
6.2 KiB
Go
131 lines
6.2 KiB
Go
package documents
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"os"
|
||
"strings"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||
)
|
||
|
||
// 本文件是 sidecar 的**读侧出口**(design/24 §4.2 路线②)。装配期的
|
||
// `resolveDocuments` 走 `sidecarStore.load`(未命中就重抽一次),而
|
||
// `document-read` 工具要的是「已经落盘的那份正文」:它既不该重抽(design/24 §4.3
|
||
// 「只抽一次」,否则 status/chars 的持久统计会漂移),也不该把 sidecar 形状
|
||
// (首行元数据 + 正文自第二行起)再实现一遍。这里因此把读路径收成一个小出口,
|
||
// 形状、名字形态校验、抽取器版本判据仍**只有一份实现**。
|
||
|
||
// ErrSidecarUnavailable 报告一份文档**没有可读的抽取结果**:数据目录未配置、
|
||
// sidecar 不存在、读不出、形态损坏或抽取器版本不匹配。不重抽的读侧据此给出诚实
|
||
// 错误,绝不返回空正文假装成功(design/24 §4.4)。一律用 errors.Is 判定。
|
||
var ErrSidecarUnavailable = errors.New("没有可读的抽取结果")
|
||
|
||
// Sidecar 是一条已落盘 sidecar 的读取结果 —— 纯读取,不含任何抽取动作。
|
||
type Sidecar struct {
|
||
// Status 是抽取结论,取值见 history.DocumentStatus*。
|
||
Status string
|
||
// Reason 是 Status == unreadable 时的诚实原因(正文位置为空)。
|
||
Reason string
|
||
// Kind 是抽取所得内容的种类(text / csv / tsv)。
|
||
Kind string
|
||
// Chars 是抽取期写盘时的正文字符数(rune)。
|
||
Chars int
|
||
// Truncated 报告 sidecar 正文在**抽取期**是否撞上了安全上限
|
||
// (documents.MaxSidecarBytes,8 MiB 文本)—— 它与「本回合注入被截断」是两件事:
|
||
// 后者由装配期按注入预算判定并写进 history.DocumentRef。
|
||
Truncated bool
|
||
// Text 是正文本身:sidecar 第一行元数据**之后**的部分(含 CSV/TSV 的摘要行)。
|
||
Text string
|
||
}
|
||
|
||
// Readable 报告这条记录是否带着可读正文:只有抽取结论为 extracted 才有正文
|
||
// (unreadable 时 sidecar 的正文位置是空的,原因在 Reason 里)。
|
||
func (s Sidecar) Readable() bool { return s.Status == history.DocumentStatusExtracted }
|
||
|
||
// ReadSidecar 读取 name 的 sidecar:**只读 sidecar,绝不重抽、绝不读原文件**。
|
||
//
|
||
// 名字形态非法(非单个路径段、非 32 位小写十六进制主名、扩展名超长)返回包装
|
||
// ErrInvalidName 的错误,并且**在触达文件系统之前**就拒绝;其余失败(数据目录
|
||
// 未配置、sidecar 不存在、读不出、损坏、版本失效)返回包装 ErrSidecarUnavailable
|
||
// 的错误,具体原因在错误链的文案里。dataDir 是**数据目录**(不是 extracted 目录):
|
||
// sidecar 落在它的 extracted/ 子目录下,与装配期解析器同一口径。
|
||
func ReadSidecar(dataDir, name string) (Sidecar, error) {
|
||
if !validStorageName(name) {
|
||
return Sidecar{}, fmt.Errorf("documents: read sidecar %q: %w", name, ErrInvalidName)
|
||
}
|
||
store := newSidecarStore(dataDir, sourcedLog(nil))
|
||
if store == nil {
|
||
return Sidecar{}, fmt.Errorf("documents: read sidecar %q: 数据目录未配置: %w", name, ErrSidecarUnavailable)
|
||
}
|
||
rec, err := store.loadSidecar(name)
|
||
if err != nil {
|
||
return Sidecar{}, fmt.Errorf("documents: read sidecar %q: %w", name, err)
|
||
}
|
||
return Sidecar{
|
||
Status: rec.Status,
|
||
Reason: rec.Reason,
|
||
Kind: rec.Kind,
|
||
Chars: rec.Chars,
|
||
Truncated: rec.Truncated,
|
||
Text: rec.Text,
|
||
}, nil
|
||
}
|
||
|
||
// loadSidecar 是 sidecar 读路径的唯一实现。命中返回记录与 nil;任何失败返回包装了
|
||
// ErrSidecarUnavailable 的错误(名字形态非法时为 ErrInvalidName),并把**具体原因**
|
||
// (不存在 / 读不出 / 形态损坏 / 版本失效)写进错误链 —— 装配期的 load 只关心「是否
|
||
// 命中」(未命中就重抽一次),而不重抽的读侧需要知道到底为什么读不到。
|
||
//
|
||
// 日志口径与既有读路径一致:缓存未命中是正常路径,「文件不在」因此不吵;其余失败
|
||
// 留一条带来源的 WARN/INFO,绝不静默吞掉「文件在那儿却读不出来」这类信号(日志只记
|
||
// name/字节数/版本,**绝不记正文**)。
|
||
func (s *sidecarStore) loadSidecar(name string) (cachedExtract, error) {
|
||
if s == nil {
|
||
return cachedExtract{}, fmt.Errorf("sidecar 存储未配置: %w", ErrSidecarUnavailable)
|
||
}
|
||
path, ok := s.pathFor(name)
|
||
if !ok {
|
||
return cachedExtract{}, fmt.Errorf("sidecar 名字形态非法: %w", ErrInvalidName)
|
||
}
|
||
f, err := os.Open(path)
|
||
if err != nil {
|
||
if !errors.Is(err, os.ErrNotExist) {
|
||
s.log.Warn("sidecar open", "name", name, "error", err)
|
||
return cachedExtract{}, fmt.Errorf("sidecar 打开失败: %w", ErrSidecarUnavailable)
|
||
}
|
||
return cachedExtract{}, fmt.Errorf("sidecar 不存在: %w", ErrSidecarUnavailable)
|
||
}
|
||
defer func() { _ = f.Close() }()
|
||
data, err := io.ReadAll(io.LimitReader(f, maxSidecarBytes+1))
|
||
if err != nil || len(data) > maxSidecarBytes {
|
||
s.log.Warn("sidecar unreadable", "name", name, "bytes", len(data), "error", err)
|
||
return cachedExtract{}, fmt.Errorf("sidecar 读不出或超出上限(%d 字节): %w", len(data), ErrSidecarUnavailable)
|
||
}
|
||
header, body, ok := strings.Cut(string(data), sidecarHeaderEnd)
|
||
if !ok || !strings.HasPrefix(header, "{") {
|
||
s.log.Warn("sidecar malformed", "name", name)
|
||
return cachedExtract{}, fmt.Errorf("sidecar 形态损坏(首行不是元数据): %w", ErrSidecarUnavailable)
|
||
}
|
||
meta := sidecarMeta{}
|
||
if err := json.Unmarshal([]byte(header), &meta); err != nil {
|
||
s.log.Warn("sidecar meta decode", "name", name, "error", err)
|
||
return cachedExtract{}, fmt.Errorf("sidecar 元数据解码失败: %w", ErrSidecarUnavailable)
|
||
}
|
||
if meta.Version != ExtractorVersion {
|
||
// 抽取器升级:旧缓存按失效处理并重抽一次(记版本号的意义所在)。
|
||
s.log.Info("sidecar stale", "name", name, "version", meta.Version)
|
||
return cachedExtract{}, fmt.Errorf("sidecar 抽取器版本不匹配(%s): %w", meta.Version, ErrSidecarUnavailable)
|
||
}
|
||
return cachedExtract{
|
||
Status: meta.Status,
|
||
Reason: meta.Reason,
|
||
Kind: meta.Kind,
|
||
Chars: meta.Chars,
|
||
Truncated: meta.Truncated,
|
||
Text: body,
|
||
}, nil
|
||
}
|