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 灰区判定,**零模型调用**)。 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() 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, }, 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() } // 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) }