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` / `defer`。坏 JSON、 // 非法枚举、编号越界、任何别的词 —— 一律回落 `create`(保守,**绝不猜**)。 // `defer`(第五态,§18.2)还多一条不变式:**target 必须为 0** —— 它声称 // 「关系判为真新」,而一个指向某条候选的 defer 自相矛盾,同样判非法回落。 // 2. **禁产新内容**:模型只提供「选哪一条 + 什么动作 + 一句理由」,它的正文 / // 摘要 / 改写文本**一个字都不采纳**。`merge` 落地时存活方是既有行(正文 // 一字不改);`modify` 落地时存活方是**新行自己的原文**、既有行置为被合并 // (§15 D3-6)—— 因此 T1 结构性地不可能把幻觉写进记忆库。 // 3. **不越硬门、不越红线**:T1 只能在 `judgeIngest` 交出来的**灰区**候选里选, // 而灰区已经滤掉了跨作用域/跨角色/跨项目/只读来源(硬门)与时效/数值/冲突/ // 低置信(§5.2 的绝不合并清单)。`preinsert_judge.go` 文件头那条 // 「**规则否决一票否决**」是**不可走样**项,T1 不提供任何翻案入口。 // 4. **有界 + 不阻塞写入**:每次审查**最多一次**模型调用(灰区候选打包进同一个 // prompt,最多 `defaultT1MaxGray` 条),且有自己的子超时(≤ 单条审查总预算)。 // 超时 / 报错 / 非法回复 ⇒ `create` + WARN + **零改写**,绝不阻塞、绝不丢记忆。 // 此外还有一层**全局滑窗限速**(`defaultT1PerWindow` 次 / `defaultT1Window`, // 全进程共享一个窗口)堵住最坏情形:现网每天数百到上千条新记忆,若大半带灰区 // 候选就是每天数百到上千次调用;超限 ⇒ 跳过 T1 + 回落 `create` + // `t1_throttled`(见 preinsert_t1_limit.go)。 // T1 只挂在**异步审查层**(`PreInsertReviewer.Review`);同步入库闸 // (preinsert_gate.go)不碰它 ⇒ 写入路径仍是零模型调用。 // 5. **不新造 LLM 客户端**:注入的是**既有** `model.Adapter` 的调用口(组合根 // 装配的就是模型栈本身,含按角色路由与主→备→本地降级),本文件不认识任何 // 厂商协议,也不新增依赖。 // // 可观测(§15 D3-8):T1 是否参与、结论是什么,在留痕 `reason` 列上如实可辨 // (`t1_merge` / `t1_modify` / `t1_keep` / `t1_defer` / `t1_reply_invalid` / // `t1_unavailable` / `t1_throttled`),规则版本随之升到 `IngestRuleVersion` // (`d3.3` 起含第五态)。模型的自由文本理由 // **既不落库也不落日志**:留痕表按设计不存正文,而模型的理由可能改述正文 ⇒ 一律 // 丢弃(它在 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)。 // // `defer` 是 2026-10-13 加入的**第五态**(design/23 §18.2)—— 它**不是第二次 // 调用**:关系判定(merge/modify/keep)与价值判定(值不值得长期留)被写进 // **同一个 prompt**,由**同一次**补全回答。语义上 `defer` ≡「关系判为**真新** // ∧ 价值判为**短期**」,因此它要求 target=0(真新就没得可指),而**当关系 // 判成 merge/modify 时价值判定不适用**(这条已被既有记忆代表,留不留由既有行 // 决定,§18.2 的「分工」)。 t1ActionMerge = "merge" t1ActionModify = "modify" t1ActionKeep = "keep" t1ActionDefer = "defer" // T1 的留痕原因码(进 `reason` 列,结构化、可机器判定、与既有码同族)。 t1ReasonMerge = "t1_merge" t1ReasonModify = "t1_modify" t1ReasonKeep = "t1_keep" t1ReasonReplyInvalid = "t1_reply_invalid" t1ReasonUnavailable = "t1_unavailable" // t1ReasonDefer 是第五态在留痕 `reason` 列上的编码(design/23 §18.4 的闭集)。 // 它与 t1_keep 分列:两者都是「关系判为真新」,但只有 defer 会写短 // `expires_at` —— 面板要能一眼看出「有多少条被判成了临时」。 t1ReasonDefer = "t1_defer" // t1ReasonThrottled 是**全局限速**命中时的原因码(preinsert_t1_limit.go, // design/23 §15.4)。它与上面两个失败码同族、同样只改 `reason`:决定恒 // `create`、零改写。与它们的唯一区别是语义 —— 失败是「模型没答上」, // 限流是「这一轮先不判、留给以后」,因此不走失败那条 WARN。 t1ReasonThrottled = "t1_throttled" ) // 一处**刻意不实现**(如实登记):design/23 §18.3 的失败原因码 // `t1_value_unavailable` / `t1_keep_unavailable`。关系判定与价值判定被写进 // **同一次**补全(§18.2 要求「不新增第二次调用」),因此「回复解不开」时**结构上 // 无法分辨**是哪一问失败 —— 硬造第二个码只会得到一个自己也不确定的标签。既有 // 的 `t1_reply_invalid` / `t1_unavailable` 已经如实描述了那条路径(且 §18.2 对 // 「非法 defer」明写的处置就是 `t1_reply_invalid`)。真要分开观测,得先有第二次 // 调用,而那正是本条明令禁止的。 // ErrT1ReplyInvalid 是模型回复解不出四态枚举(或候选编号越界,或 defer 带了 // 非 0 的 target)时的分类错误。 // 它不是「模型挂了」,而是「这一次的回复不能用」—— 两者的回落结果相同 // (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 } // 全局限速(design/23 §15.4)摆在**发请求之前**,且排在两个前置守卫**之后**: // 没有灰区候选的审查一次额度都不占(它本来就不调模型),只有真的要花钱时才 // 过闸、才记账 —— 于是「超限」永远等于「本来会多花一次调用」。 if allowed, firstRefusal := r.t1Gate.allow(); !allowed { return r.t1Throttled(item, v, firstRefusal) } 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 } // t1Throttled 组装**限流**回落结论:与 t1Fallback 逐字同结果 —— 决定恒 `create`、 // 只把 `reason` 换成 `t1_throttled`、**零改写**,差分只在外面的口径与日志档位。 // // 为什么日志与失败路径不同:失败(超时 / 报错 / 回复非法)是**故障**,每条都该 // 现形;超限是**预期内的省钱行为**(主人 2026-10-07:「超限不是错误,是这一轮先 // 不判、留给以后」),一天可能几百条 —— 全记 WARN 会把真正的故障淹掉。因此: // 每个窗口只记**一条** WARN(附窗口额度与窗口长度,够运维定位),其余记 Debug。 // 留痕 `reason` 一律如实(那才是机器可查的证据面)。日志只带 id / 作用域 / 额度, // **绝不带记忆正文**。 func (r *PreInsertReviewer) t1Throttled(item MemoryItem, v ingestVerdict, firstRefusal bool) ingestVerdict { v.Reason = t1ReasonThrottled r.bump(func(s *ReviewStats) { s.T1Throttled++ }) args := []any{ "item_id", item.ID, "scope", string(item.Scope), "reason", t1ReasonThrottled, "t1_per_window", r.cfg.T1PerWindow, "t1_window", r.cfg.T1Window.String(), } if firstRefusal { r.log.Warn("memory ingest review t1 judge throttled, gray-zone item kept as create", args...) return v } r.log.Debug("memory ingest review t1 judge throttled, gray-zone item kept as create", args...) 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 == t1ActionDefer { // 第五态(design/23 §18.2):关系判为**真新**、但价值判为**短期** ⇒ // 照常保留这条新记忆,只给它一个短 `expires_at`(落临时层)。 // // 🔴 **target 必须为 0**(与 keep 同形):§18.2 的不变式是 // 「defer ≡ 真新 ∧ 短期」,一个指向某条候选的 defer 自相矛盾(既说 // 「和它像」又说「这条独立」)。出现这种回复 ⇒ 判非法、回落 create // (reason=t1_reply_invalid,§18.2 明写的处置),**绝不猜**。 if reply.Target != 0 { return ingestVerdict{}, false } base.Decision = IngestDefer base.Reason = t1ReasonDefer 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。 // // 2026-10-13 起它同时承载**第五态的价值判定**(design/23 §18.2):prompt 里明写 // 「先问关系、只有关系判为真新时才问价值」,四个动作**一次回答** —— 因此 // 「第五态与 T1 在同一次调用里问」是这条 prompt 的结构性事实,不是约定:**没有 // 第二次模型调用**。 // // 负例刻意取自 design/23 §1.3 与 §5.2 的点名反例(红牛 vs 冰美式是「同主题但 // 不同事实」,X vs 戒了X 是「新事实覆盖旧事实」),它们是 T1 最容易被相似度 // 带偏的两处。 const t1SystemPrompt = `你是记忆库的入库审查员。给你一条刚入库的新记忆,以及同一分区里若干条既有记忆候选。 要回答的是**两个问题**,但只回一个 JSON 对象,不要任何其他文字、不要代码块。 第一问(关系):这条新记忆和某条既有候选,是不是在说同一件事? 第二问(价值):只有当第一问的答案是「谁都不像、它是新的一件事」时,才再问一句——**它值不值得长期留在记忆库里**? 若第一问答的是「和某条候选是同一件事」,第二问就不适用(留不留由那条既有记忆决定),直接回 merge 或 modify。 动作只许取这四个值之一: - "merge":新记忆与某条既有记忆说的是**同一件事、只是换了措辞**,且新记忆没有那条既有记忆之外的任何新信息 ⇒ 新记忆并入那条既有记忆(既有记忆的原文保持不变)。 - "modify":某条既有记忆是**新记忆的不完整版本**(新记忆覆盖它、并含它没有的细节)⇒ 那条既有记忆退役,由新记忆承载。 - "keep":是**新的一件事**,而且值得**长期**记住 ⇒ 新记忆独立长期保留。 - "defer":是**新的一件事**,但**不值得长期记住**(只是一次性的、短期的、过了这阵子就没用的信息)⇒ 新记忆先留着,过一阵子自动消失。 必须遵守的判据: - 只有「同一件事换措辞」才可以 merge;「相似但并列」的两件不同事实必须 keep 或 defer。反例:"主人喜欢喝红牛来提神" 与 "主人喜欢喝冰美式来提神" 是两种不同的偏好,必须 keep,即便它们很相似。 - 一方带否定、另一方没有("X" 与 "已经戒了 X"),那是新事实覆盖旧事实、不是重复,必须 keep 或 defer。 - 描述某一次具体事件、或带具体日期/时间的记忆,不要与长期习惯互相合并。 - 只是「都在说同一个领域」不算同一件事。 - **长期事实、稳定偏好、习惯、约定与承诺、关系里程碑、主人的明确要求 = keep**(它们长期有用)。 - **只对「这一次」有用的信息 = defer**,例如:一次性的安排或请求、当前临时的状态、时效性数据、转瞬即逝的琐事。 - **拿不准就 keep**。宁可多留一条长期记忆,也绝不把两件不同的事并成一条;更不要因为拿不准就把一条真记忆判成临时的。 - **绝对不要输出任何记忆正文、摘要或改写文本**,只回动作、候选编号和一句理由。 输出格式(严格 JSON,无代码块):{"action":"merge 或 modify 或 keep 或 defer","target":候选编号,"reason":"一句话理由"} 回 keep 或 defer 时 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 }