feat(memory): T1 灰区模型判定 —— D3 入库前审查补上 LLM 那一半
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

主人 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 行上限,无新逻辑)
This commit is contained in:
AskaEth committed 2026-10-07 14:10:33 +08:00
1 parent 5b8c7a3ef3
commit 1b4d0a552f
11 files changed
+1233 -91

No files matched your search

+299
View File
@@ -0,0 +1,299 @@
package memory
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件是 D3「入库前审查」的 **T1(灰区模型判定)**(design/23 §15.1 D3-2)。
//
// 它补上 D3-2 一直缺的那一半:规则层(T0,preinsert_judge.go)判不出结论时 ——
// 候选**召回到了、互不包含、且未命中任何规则否决** —— 调**一次**轻量模型,把
// 「一律新建」变成「有依据地并入 / 改写 / 保持新建」。真机实证的那一类
// (「摄取通道验收暗号为『喵喵七号』」vs「验收暗号为『喵喵七号』,用于验证摄取
// 通道」:同一事实换措辞、互不包含)正是它要收掉的。
//
// 五条不可走样(逐条对应本目录既有的不变量,改坏任何一条都是架构偏离):
//
// 1. **只许回三态 + 理由**:`merge` / `modify` / `keep`。坏 JSON、非法枚举、
// 编号越界、任何别的词 —— 一律回落 `create`(保守,**绝不猜**)。
// 2. **禁产新内容**:模型只提供「选哪一条 + 什么动作 + 一句理由」,它的正文 /
// 摘要 / 改写文本**一个字都不采纳**。`merge` 落地时存活方是既有行(正文
// 一字不改);`modify` 落地时存活方是**新行自己的原文**、既有行置为被合并
// (§15 D3-6)—— 因此 T1 结构性地不可能把幻觉写进记忆库。
// 3. **不越硬门、不越红线**:T1 只能在 `judgeIngest` 交出来的**灰区**候选里选,
// 而灰区已经滤掉了跨作用域/跨角色/跨项目/只读来源(硬门)与时效/数值/冲突/
// 低置信(§5.2 的绝不合并清单)。`preinsert_judge.go` 文件头那条
// 「**规则否决一票否决**」是**不可走样**项,T1 不提供任何翻案入口。
// 4. **有界 + 不阻塞写入**:每次审查**最多一次**模型调用(灰区候选打包进同一个
// prompt,最多 `defaultT1MaxGray` 条),且有自己的子超时(≤ 单条审查总预算)。
// 超时 / 报错 / 非法回复 ⇒ `create` + WARN + **零改写**,绝不阻塞、绝不丢记忆。
// T1 只挂在**异步审查层**(`PreInsertReviewer.Review`);同步入库闸
// (preinsert_gate.go)不碰它 ⇒ 写入路径仍是零模型调用。
// 5. **不新造 LLM 客户端**:注入的是**既有** `model.Adapter` 的调用口(组合根
// 装配的就是模型栈本身,含按角色路由与主→备→本地降级),本文件不认识任何
// 厂商协议,也不新增依赖。
//
// 可观测(§15 D3-8):T1 是否参与、结论是什么,在留痕 `reason` 列上如实可辨
// (`t1_merge` / `t1_modify` / `t1_keep` / `t1_reply_invalid` / `t1_unavailable`),
// 规则版本随之升到 `IngestRuleVersion`。模型的自由文本理由**既不落库也不落日志**:
// 留痕表按设计不存正文,而模型的理由可能改述正文 ⇒ 一律丢弃(它在 prompt 里的
// 唯一作用,是逼模型先表态,从而把动作判得更稳)。
const (
// defaultT1MaxGray 是打包进**一次** prompt 的灰区候选上限。灰区候选按召回
// 分数降序,超出即截断(更相关的排在前面),因此截断不改变「谁最像」。
defaultT1MaxGray = 8
// defaultT1Timeout 是单次 T1 模型调用的墙钟上限。它比单条审查的总预算
// (defaultReviewTimeout = 20s)**小**:T1 是审查里的一步,不是审查本身,
// 它的超时只该让这一步失败并回落 create,不该吃掉整条审查的预算。
defaultT1Timeout = 8 * time.Second
// t1Purpose 是该次模型调用的计账标签(model.Request.Purpose),
// 让用量面板能把「灰区判定」与「对话」分开算账。
t1Purpose = "memory_t1_judge"
// T1 允许的动作枚举(模型只许回这三个,别的一律回落 create)。
t1ActionMerge = "merge"
t1ActionModify = "modify"
t1ActionKeep = "keep"
// T1 的留痕原因码(进 `reason` 列,结构化、可机器判定、与既有码同族)。
t1ReasonMerge = "t1_merge"
t1ReasonModify = "t1_modify"
t1ReasonKeep = "t1_keep"
t1ReasonReplyInvalid = "t1_reply_invalid"
t1ReasonUnavailable = "t1_unavailable"
)
// ErrT1ReplyInvalid 是模型回复解不出三态枚举(或候选编号越界)时的分类错误。
// 它不是「模型挂了」,而是「这一次的回复不能用」—— 两者的回落结果相同
// (create),但留痕原因码不同,运维才分得清该修 prompt 还是该查网络。
var ErrT1ReplyInvalid = errors.New("memory/review: t1 reply is not a valid action")
// T1Judge 是 T1 的一次性模型调用口。它是 `model.Adapter` 的**单方法视图**
// (只取 Chat):T1 只要一次非流式补全,不流式、不重试、不并发 —— 窄接口让
// 这三条成为结构性事实,也让测试注入桩不必实现用不到的方法。
//
// 它是一个函数类型而不是接口:与 ReviewDeps 既有的 Now / Audit / GateCandidates
// 同一形状,且组合根因此可以用**闭包**把「模型栈晚于本服务装配」这件事就地解决
// (见 cmd/cyrene/wire_memory_review.go 的 memoryT1Judge)。
type T1Judge func(ctx context.Context, req model.Request, opts model.Options) (model.Response, error)
// t1Reply 是 T1 要求的线上形状:动作 + 候选编号(1 起;keep 写 0)+ 一句理由。
// 编号而不是 id 是刻意的 —— 模型不该、也无法**编造**一个记忆 id。
type t1Reply struct {
Action string `json:"action"`
Target int `json:"target"`
Reason string `json:"reason"`
}
// t1Enabled 报告 T1 是否可用并已开启:总开关开着、T1 没被显式关掉、
// 且模型出口**已接**。少任何一条都恒为零模型调用 —— 这就是
// 「T1 关闭 ⇒ 零模型调用」从承诺变成结构的那条线。
func (r *PreInsertReviewer) t1Enabled() bool {
return r != nil && r.cfg.Enabled && !r.cfg.DisableT1 && r.deps.Judge != nil
}
// t1Timeout 返回单次 T1 调用的墙钟上限:非正取代码默认,且**不超过**单条审查
// 的总预算(T1 超时只该让这一步回落,不该把审查本身拖长)。
func (r *PreInsertReviewer) t1Timeout() time.Duration {
d := r.cfg.T1Timeout
if d <= 0 {
d = defaultT1Timeout
}
if r.cfg.Timeout > 0 && d > r.cfg.Timeout {
return r.cfg.Timeout
}
return d
}
// maybeJudgeT1 在「T0 判不出结论 + 有灰区候选」时,用**一次**模型调用改判。
//
// 任何非「模型给出合法三态」的情形都**原样返回 v**:未启用、没有模型出口、
// 没有灰区候选、调用超时/报错、回复非法。因此「失败 ⇒ create + 零改写」是
// 结构性成立的 —— 这条路径上根本没有改写的代码。
func (r *PreInsertReviewer) maybeJudgeT1(ctx context.Context, item MemoryItem, v ingestVerdict) ingestVerdict {
if !r.t1Enabled() || len(v.Gray) == 0 {
return v
}
gray := v.Gray
if len(gray) > defaultT1MaxGray {
gray = gray[:defaultT1MaxGray]
}
reply, err := r.callT1(ctx, item, gray)
if err != nil {
// 区分「模型没答上」(超时/网络/上游错)与「答了但回复不能用」:
// 两者都回落 create,但留痕原因码必须分开,运维才分得清该查网络
// 还是该修 prompt。
if errors.Is(err, ErrT1ReplyInvalid) {
return r.t1Fallback(ctx, item, v, t1ReasonReplyInvalid, err)
}
return r.t1Fallback(ctx, item, v, t1ReasonUnavailable, err)
}
verdict, ok := t1Verdict(reply, item, gray, v)
if !ok {
return r.t1Fallback(ctx, item, v, t1ReasonReplyInvalid, ErrT1ReplyInvalid)
}
r.bump(func(s *ReviewStats) { s.T1Decided++ })
return verdict
}
// callT1 发起**唯一**的那次 T1 调用(每次审查至多一次)。它自带子超时,并把
// 同一个预算也交给适配器(model.Options.Timeout)—— 进程内与 HTTP 侧各把一道,
// 一次灰区判定绝不可能拖长审查。模型正文(prompt 与响应)**不进日志**。
func (r *PreInsertReviewer) callT1(ctx context.Context, item MemoryItem, gray []ingestCandidate) (t1Reply, error) {
budget := r.t1Timeout()
cctx, cancel := context.WithTimeout(ctx, budget)
defer cancel()
r.bump(func(s *ReviewStats) { s.T1Called++ })
resp, err := r.deps.Judge(cctx, model.Request{
Messages: buildT1Prompt(item, gray),
Purpose: t1Purpose,
}, model.Options{Timeout: budget})
if err != nil {
return t1Reply{}, fmt.Errorf("memory/review: t1 judge call: %w", err)
}
return parseT1Reply(resp.Content)
}
// t1Fallback 组装回落结论:决定**仍然是 create**(与 T0 逐字同结论),只把
// `reason` 换成如实的原因码,并记一条 WARN。
//
// 为什么失败也要改 reason:留痕要能回答「这一次 T1 到底有没有参与」
// (§15 D3-8)。把失败悄悄写成 T0 的原因码,等于让 T1 的缺席与失败不可分辨。
// 日志只带 id / 作用域 / 原因 / 错误,**绝不带记忆正文**。
func (r *PreInsertReviewer) t1Fallback(ctx context.Context, item MemoryItem, v ingestVerdict, reason string, cause error) ingestVerdict {
v.Reason = reason
r.log.Warn("memory ingest review t1 judge fell back to create",
"item_id", item.ID, "scope", string(item.Scope), "reason", reason,
"canceled", ctx.Err() != nil, "error", cause)
return v
}
// t1Verdict 把模型回复映射成判定结论。ok=false 表示回复不可用(非法枚举 /
// 编号越界),调用方据此回落 create —— **绝不猜、绝不挑一个最近的候选**。
func t1Verdict(reply t1Reply, item MemoryItem, gray []ingestCandidate, base ingestVerdict) (ingestVerdict, bool) {
action := strings.ToLower(strings.TrimSpace(reply.Action))
if action == t1ActionKeep {
// 保持新建:结论与 T0 完全一致(create),只留下「T1 看过并判 keep」
// 这条证据 —— 它与「压根没调模型」是两件事。
base.Reason = t1ReasonKeep
base.Gray = nil
return base, true
}
if action != t1ActionMerge && action != t1ActionModify {
return ingestVerdict{}, false
}
if reply.Target < 1 || reply.Target > len(gray) {
return ingestVerdict{}, false
}
c := gray[reply.Target-1]
if action == t1ActionMerge {
// 并入既有:存活方是既有行(正文一字不改),被吸收的是刚落库的新行。
return ingestVerdict{
Decision: IngestMerge, Existing: c.item, Absorbed: item,
Score: c.score, Mode: c.mode, Reason: t1ReasonMerge,
}, true
}
// 改写既有(§15 D3-6):**新行承载更全的内容**、既有行置为被合并。
// 两行的正文都取自各自的既有原文 —— 模型给不出任何正文。
return ingestVerdict{
Decision: IngestModify, Existing: c.item, Absorbed: c.item,
Score: c.score, Mode: c.mode, Reason: t1ReasonModify,
}, true
}
// buildT1Prompt 渲染这一次判定的两段消息:固定指令 + 「新记忆 vs 编号候选」。
// 候选用**编号**(1 起)而不是 id:模型编不出、也不需要记忆 id。
func buildT1Prompt(item MemoryItem, gray []ingestCandidate) []model.Message {
var b strings.Builder
b.WriteString("新记忆:")
b.WriteString(item.Content)
b.WriteString("\n\n既有候选:\n")
for i, c := range gray {
fmt.Fprintf(&b, "%d. %s\n", i+1, c.item.Content)
}
return []model.Message{
model.TextMessage(model.RoleSystem, t1SystemPrompt),
model.TextMessage(model.RoleUser, b.String()),
}
}
// t1SystemPrompt 是 T1 的固定指令。它把三件事写死:
// - 只许回三态 + 一句理由(别的词一律不许);
// - **禁产新内容**(不许输出记忆正文/摘要/改写文本);
// - 判据与**负例**(design/23 §15 D3-2「负例进 prompt」,Mem0 口径):
// 「相似但并列」必须 keep,拿不准必须 keep。
//
// 负例刻意取自 design/23 §1.3 与 §5.2 的点名反例(红牛 vs 冰美式是「同主题但
// 不同事实」,X vs 戒了X 是「新事实覆盖旧事实」),它们是 T1 最容易被相似度
// 带偏的两处。
const t1SystemPrompt = `你是记忆库的入库审查员。给你一条刚入库的新记忆,以及同一分区里若干条既有记忆候选。
判断新记忆与既有候选的关系,只回一个 JSON 对象,不要任何其他文字、不要代码块。
动作只许取这三个值之一:
- "merge":新记忆与某条既有记忆说的是**同一件事、只是换了措辞**,且新记忆没有那条既有记忆之外的任何新信息 ⇒ 新记忆并入那条既有记忆(既有记忆的原文保持不变)。
- "modify":某条既有记忆是**新记忆的不完整版本**(新记忆覆盖它、并含它没有的细节)⇒ 那条既有记忆退役,由新记忆承载。
- "keep":其余一切情形(不同事实、不同偏好、不同对象、只是同一主题)⇒ 新记忆独立保留。
必须遵守的判据:
- 只有「同一件事换措辞」才可以 merge;「相似但并列」的两件不同事实必须 keep。反例:"主人喜欢喝红牛来提神" 与 "主人喜欢喝冰美式来提神" 是两种不同的偏好,必须 keep,即便它们很相似。
- 一方带否定、另一方没有("X" 与 "已经戒了 X"),那是新事实覆盖旧事实、不是重复,必须 keep。
- 描述某一次具体事件、或带具体日期/时间的记忆,不要与长期习惯互相合并,必须 keep。
- 只是「都在说同一个领域」不算同一件事。
- **拿不准就 keep**。宁可多留一条,也绝不把两件不同的事并成一条。
- **绝对不要输出任何记忆正文、摘要或改写文本**,只回动作、候选编号和一句理由。
输出格式(严格 JSON,无代码块):{"action":"merge 或 modify 或 keep","target":候选编号,"reason":"一句话理由"}
keep 时 target 写 0。`
// parseT1Reply 解析模型的 JSON 回复。被围栏包裹的 ```json 块会被剥离,前后夹带
// 的解说文字也被忽略(只取第一个 `{` 到最后一个 `}`)—— 容忍格式噪声,但**不猜**:
// 仍取不到 JSON 对象就返回 ErrT1ReplyInvalid,由调用方回落 create。
//
// 错误里**不带模型原文**:回复可能改述记忆正文,而错误会被记进日志。
func parseT1Reply(s string) (t1Reply, error) {
body, ok := extractT1JSON(s)
if !ok {
return t1Reply{}, fmt.Errorf("%w: no json object in reply", ErrT1ReplyInvalid)
}
var reply t1Reply
if err := json.Unmarshal([]byte(body), &reply); err != nil {
return t1Reply{}, fmt.Errorf("%w: decode: %v", ErrT1ReplyInvalid, err)
}
return reply, nil
}
// extractT1JSON 从模型回复里取出 JSON 对象文本:先剥 ``` 围栏,再取第一个 `{`
// 到最后一个 `}`。取不到成对花括号即视为格式非法。
//
// 开头的 `[` 也是非法:本协议的线上形状是**对象**,而"取第一个 `{` 到最后一个
// `}`"会把 `[{"action":"merge"}]` 这类数组回复里嵌的对象抠出来当成合法作答 ——
// 那正是「解析不出枚举就必须回落」要挡住的情形,因此显式拒掉。
func extractT1JSON(s string) (string, bool) {
s = strings.TrimSpace(s)
if strings.HasPrefix(s, "```") {
s = strings.TrimPrefix(s, "```")
s = strings.TrimSuffix(s, "```")
s = strings.TrimSpace(s)
s = strings.TrimPrefix(s, "json")
s = strings.TrimSpace(s)
}
if strings.HasPrefix(s, "[") {
return "", false
}
start := strings.Index(s, "{")
end := strings.LastIndex(s, "}")
if start < 0 || end <= start {
return "", false
}
return s[start : end+1], true
}