package main import ( "context" "errors" "fmt" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // errT1ModelNotReady 在 T1 需要模型而模型栈尚未就绪时返回。 // // 它是 **fail-safe** 信号,不是故障:审查据此回落 `create` + WARN,绝不阻塞、 // 绝不丢记忆(design/23 §15 D3-5)。之所以可能存在这一刻:本服务在 // `wireMemory` 阶段装配,模型栈在更晚的 `wireRuntime` 阶段才建好。 var errT1ModelNotReady = errors.New("memory review: t1 judge: model adapter not ready") // 本文件把 D3「入库前审查」(DevDocs/design/23-记忆整理与去重 §15.1 D3)接进 // 组合根。三件事只在这里定: // // - **形态**:记忆管理会话(复用既有的 sess-memorytidy 装配,见 // memory.TidySessionID)的有界后台队列,不是会话内派 subagent。它与整理 // 心跳并列启动、在 close 里先于关库停止; // - **依赖面**:只给它记忆存储、embedder,以及 **T1 的一次性模型调用口** // (memoryT1Judge,§15.1 D3-2 的灰区判定)。服务侧拿不到任何会话/历史读数, // 因此「独立会话、零上下文、不碰主会话上下文」(§15 D3)是结构性成立的, // 而不是靠自觉; // - **审计**:接到网关的 AuditEvent(口径 memory.review.ingest)。出口在调用 // 时才解析 a.gw —— 网关比本服务晚组装(wireFrontend),与 memoryTidy 同 // 一手法。未就绪时只记日志,绝不假装写过审计。 // // 关闭(MEMORY_REVIEW_ENABLED=false)时**不建、不接**:store 的写入路径因此 // 逐字回到本次改动之前(纯增量、无破坏性变更)。 // // 🔴 两个开关都不在 env 里(`.env` 已冻结,design/13 §2.1),它们读 // `$DATA_DIR/config/memory-tidy.json`,与整理自动档同一个文件、**零新 env 键**: // - `{"ingestGate": false}` ⇒ 一键回到「只插不审」(同步入库闸); // - `{"t1Judge": false}` ⇒ 一键回到纯规则(T1 灰区判定,**零模型调用**); // - `{"t1PerWindow": 120, "t1WindowSec": 3600}` ⇒ T1 的**全局限速**额度 // (design/23 §15.4);缺位 = 代码默认 120 次/60 分钟; // - `{"tempTtlSec": 604800}` ⇒ 第五态 `defer` 的**临时记忆存活秒数** // (design/03 §9.6);缺位/坏值 = 代码默认 7 天。**它没有 `.env` 档** // (`.env` 已冻结,本键在那里从来没有出处)⇒ 实际是「面板 > 代码默认」。 func (a *app) wireMemoryReview() error { cfg, err := config.LoadMemoryReviewConfig(envPath) if err != nil { return fmt.Errorf("memory review config: %w", err) } if !cfg.Enabled { logger.WithSource(a.log, "memory/review").Info( "memory ingest review disabled by configuration", "env", "MEMORY_REVIEW_ENABLED") return nil } gateOff := a.memoryIngestGateOff() // T1 灰区模型判定(design/23 §15.1 D3-2):代码默认**开**,`{"t1Judge": false}` // 一键回到纯规则(零模型调用)。与入库闸同一个文件、同一套「没配 = 保持默认」 // 口径,**零新 env 键**。 t1Off := a.memoryT1JudgeOff() // T1 的全局限速额度(design/23 §15.4):同样读那个文件,缺位/坏文件一律 // 交零值给 normalizeReviewConfig 回落**代码默认值**(120 次/60 分钟)—— // 坏配置绝不能让一处限速静默变成「无上限」。 t1PerWindow, t1WindowSec := a.memoryT1RateLimit() // 临时记忆 TTL(design/03 §9.6):面板 `tempTtlSec` 优先,缺位/坏值回落代码 // 默认 **7 天**(经 normalizeReviewConfig 落地)。**中间那一档(`.env`)在本键 // 上结构性为空** —— `.env` 已冻结(design/13 §2.1),这个键在那里从来没有出处。 tempTTL := a.memoryTempTTL() reviewer, err := memory.NewPreInsertReviewer(memory.ReviewConfig{ Enabled: cfg.Enabled, QueueSize: cfg.QueueSize, ScanLimit: cfg.ScanLimit, Timeout: time.Duration(cfg.TimeoutSec) * time.Second, // 召回门复用记忆整理的既有阈值(design/23 §13 开放点 2 = 0.80): // 入库前审查与夜间整理必须用同一把尺子,否则同一对条目会有两个结论。 Threshold: a.cfg.MemoryTidy.RecallThreshold, // 同步入库闸:`$DATA_DIR/config/memory-tidy.json` 的 `ingestGate: false` // 一键回到「只插不审」(= 本次改动之前的异步行为)。预算与扫描上限走 // 代码默认值(闸的总墙钟预算必须有界,但它是**实现参数**不是运维旋钮)。 DisableIngestGate: gateOff, // T1 的时长走代码默认值(8s,且被单条审查的 Timeout 收紧取小): // 它是**实现参数**不是运维旋钮 —— 要让 T1 完全不花钱,就关掉它。 DisableT1: t1Off, // T1 的**全局限速**额度(design/23 §15.4):数值来自 // `$DATA_DIR/config/memory-tidy.json` 的 `t1PerWindow` / `t1WindowSec`, // 缺位或坏配置时是零值 ⇒ 由 normalizeReviewConfig 回落代码默认 // (120 次/60 分钟)。**没有「无上限」这个状态**:要零花费请关 t1Judge。 T1PerWindow: t1PerWindow, T1Window: time.Duration(t1WindowSec) * time.Second, // 第五态 `defer` 的落点(design/23 §18.1):`expires_at` = 判定时刻 + 它。 // 零值 = 没配 ⇒ 由 normalizeReviewConfig 回落代码默认 7 天(tempTtlSec)。 TempTTL: tempTTL, }, memory.ReviewDeps{ Store: a.memStore, Embedder: a.embedder, Audit: a.auditMemoryReview, Log: a.log, Judge: a.memoryT1Judge(), }) if err != nil { return fmt.Errorf("memory ingest review: %w", err) } a.memStore.AttachIngestReviewer(reviewer) a.memoryReview = reviewer return nil } // memoryIngestGateOff 报告同步入库闸是否被配置显式关掉。读不到/解不开 // `memory-tidy.json` 时返回 false(= 保持代码默认的「开」)并记 WARN —— // 坏配置绝不能让一处防护静默失效,也绝不能让主人以为它关了却其实开着。 func (a *app) memoryIngestGateOff() bool { tuning, path, err := a.readMemoryTidyTuning() if err != nil { logger.WithSource(a.log, "memory/review").Warn( "memory ingest gate tuning unreadable, falling back to code default (enabled)", "path", path, "error", err) return false } return tuning.IngestGateOff() } // memoryT1JudgeOff 报告 **T1 灰区模型判定**是否被配置显式关掉。口径与 // memoryIngestGateOff 逐字相同:读不到/解不开 `memory-tidy.json` 时返回 false // (= 保持代码默认的「开」)并记 WARN —— 坏配置绝不能让一处防护静默失效, // 也绝不能让主人以为它关了却其实开着。 func (a *app) memoryT1JudgeOff() bool { tuning, path, err := a.readMemoryTidyTuning() if err != nil { logger.WithSource(a.log, "memory/review").Warn( "memory t1 judge tuning unreadable, falling back to code default (enabled)", "path", path, "error", err) return false } return tuning.T1JudgeOff() } // memoryT1RateLimit 读 **T1 全局限速**的两项旋钮:窗口额度(次)与窗口长度(秒)。 // // 口径与 memoryIngestGateOff / memoryT1JudgeOff 逐字相同:读不到/解不开 // `memory-tidy.json` 时返回零值(= 不覆盖)并记 WARN —— 零值经 // normalizeReviewConfig 回落**代码默认值**(120 次/60 分钟),因此坏配置既不会 // 让限速静默变成「无上限」,也不会让 T1 静默失效。 // // 只有**两项都写了且为正**才报一条 Info(自解释的配置回执);只写了一项时静默 // 回落缺的那一项,不报「已配置」以免报出一个与生效值不符的印象。 func (a *app) memoryT1RateLimit() (perWindow int, windowSec int) { tuning, path, err := a.readMemoryTidyTuning() if err != nil { logger.WithSource(a.log, "memory/review").Warn( "memory t1 rate limit tuning unreadable, falling back to code defaults", "path", path, "error", err) return 0, 0 } if tuning.T1PerWindow > 0 && tuning.T1WindowSec > 0 { logger.WithSource(a.log, "memory/review").Info( "memory t1 rate limit loaded", "path", path, "t1_per_window", tuning.T1PerWindow, "t1_window_sec", tuning.T1WindowSec) } return tuning.T1PerWindow, tuning.T1WindowSec } // memoryTempTTL 返回**临时记忆 TTL**(design/03 §9.6 的 `tempTtlSec`):面板 // `$DATA_DIR/config/memory-tidy.json` 的值优先,缺位 / 坏值一律回落**代码默认 // 7 天**(返回零值即「不覆盖」,由 NewPreInsertReviewer 的 normalizeReviewConfig // 落地 —— 与 memoryT1RateLimit 返回零值的口径逐字相同)。 // // 解析与坏值 WARN 都交给 `memory.ApplyReviewTuning`(与 tidy_tuning.go 对 // TidyConfig 是同一手法):runtimeconfig 只管**文件形状**,而「缺位 / 坏值怎么 // 回落」属于 ReviewConfig 的归一化语义 —— 那里是**唯一**实现点,组合根不再抄一遍 // (抄一遍就意味着两条路径各自决定「什么是坏值」)。 // // 读不到 / 解不开那个文件时返回零值 + WARN:坏配置绝不能让 TTL 静默变成 // 「不过期」,也不能让每条临时记忆**落库即过期**(一个 ≤0 的 TTL 正是那个形态)。 // // 🔴 中间那一档(`.env`)在本键上**结构性为空**:`.env` 已冻结 // (design/13 §2.1「不加、不改、不删」),而这个键在那里**从来没有出处**。 // 因此本键的实际生效链是「**面板 > 代码默认**」——不是漏配,设计原文 // (design/03 §9.6)写的就是这一档。 func (a *app) memoryTempTTL() time.Duration { tuning, path, err := a.readMemoryTidyTuning() log := logger.WithSource(a.log, "memory/review") if err != nil { log.Warn("memory temp ttl tuning unreadable, falling back to code default (7 days)", "path", path, "error", err) } return memory.ApplyReviewTuning(memory.ReviewConfig{}, tuning, log).TempTTL } // memoryT1Judge 把「调用时才解析的模型适配器」包成 T1 的一次性调用口。 // // 为什么是闭包而不是直接塞 `a.modelAdapter`:组合根的顺序是 **wireMemory(建 // 记忆栈,含本服务)→ wireRuntime(建模型栈)**,装配这一刻 `a.modelAdapter` // 还是 nil。闭包把解析推迟到调用时,于是既保住了「记忆模块只拿到一个调用口、 // 拿不到模型栈」这条依赖面(§15 D3:独立会话、零上下文),又不必改动既有装配 // 顺序 —— 与 a.gw 的晚组装是同一手法(见本文件顶部)。 // // 用的是**既有**的模型栈本身(含按角色路由与主→备→本地降级),不新造 LLM // 客户端;`PersonaID` 留空即「无角色绑定」,由生效组的默认链服务它。模型栈仍 // 未就绪时返回 errT1ModelNotReady:那是 fail-safe(回落 create + WARN)。 // // 读 `a.modelAdapter` 无锁是安全的:它在 wireRuntime 里被**写一次**,而任何 // 读到它的路径(审查工作协程)都在 main 里 `memoryReview.Start` 之后才开始, // 写入严格先于读取。 func (a *app) memoryT1Judge() memory.T1Judge { return func(ctx context.Context, req model.Request, opts model.Options) (model.Response, error) { adapter := a.modelAdapter if adapter == nil { return model.Response{}, errT1ModelNotReady } return adapter.Chat(ctx, req, opts) } } // auditMemoryReview 把一条审查结论写进网关审计链。它**只带决定、依据与 id**, // 绝不带任何记忆正文(design §15 D3-8「记忆正文不进日志」)。 func (a *app) auditMemoryReview(who, what, result string) { if a.gw != nil { a.gw.AuditEvent(who, what, result) return } logger.WithSource(a.log, "memory/review").Info( "memory ingest review audit dropped: gateway not ready", "who", who, "what", what, "result", result) }