Files
Cyrene_v2/internal/runtime/documents/sidecar_read.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

131 lines
6.2 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 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
}