Files
Cyrene_v2/internal/runtime/memory/preinsert_t1.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

300 lines
15 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 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
}