Files
Cyrene_v2/cmd/cyrene/wire_memory_review.go
T
AskaEth 1b4d0a552f
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(memory): T1 灰区模型判定 —— D3 入库前审查补上 LLM 那一半
主人 2026-10-07 裁定「可以做 T1」。此前 D3 只有纯规则 T0,凡灰区(相似但
互不包含)一律保守 create,真机上「同一事实换措辞」类重复结构性治不住
(清完 92 条字面级重复后,active 里含「喵喵七号」的仍有 101 条)。

- 插入点:T0 判定内核只多吐一份结构化 Gray(惰性收集,恒零模型调用);
  T1 挂在异步审查层 Review,同步入库闸不碰它 ⇒ 写入路径零模型调用是
  **数据流结构**而不是承诺
- 只许回 merge / modify / keep + 一句理由;坏 JSON、非法枚举、编号越界
  一律回落 create(绝不猜、绝不挑最近的一条)
- 禁产新内容:模型只给「动作 + 候选编号 + 一句理由」,正文一律取自库里
  既有的两行 ⇒ T1 结构性地不可能把幻觉写进记忆库
- 不越硬门与红线:灰区已滤掉跨角色/作用域/项目/只读来源与时效/数值/冲突/
  低置信;「规则否决一票否决」不可走样,T1 不提供翻案入口
- 有界:每次审查至多一次调用(灰区打包,上限 8 条),子超时 8s 且不超过
  审查总预算;失败只改 reason、决定恒 create、零 UPDATE
- 开关 t1Judge(放 $DATA_DIR/config/memory-tidy.json,零新 env 键),默认开;
  模型出口用闭包晚绑定既有模型栈(零新客户端、零新依赖)
- 留痕 reason 结构化(t1_merge / t1_modify / t1_keep / t1_reply_invalid /
  t1_unavailable),rule_version d3.1 → d3.2;模型的自由文本理由一律丢弃
- preinsert_config.go 为纯搬移(为守 500 行上限,无新逻辑)
2026-10-07 14:10:33 +08:00

153 lines
7.3 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 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)
}