主人原话:「收窄提取器,确实要给他一个主动式的记忆留存规则,不能想到什么都 往记忆里写」「.env 只做兜底用 …这些键优先从面板配置上取」「临时记忆到期删除」。 按 design/23 §18(D3 第五态 defer)、§19(到期处置)、design/03 §9.6 (tempTtlSec 默认 7 天 + 坏值口径)、§10(提取留存规则)落地。 - IngestDecision 闭集加第五态 defer:照常 active 落库 + 写短 expires_at 落 临时层(不删行、不改正文,回滚 = 清空该列);IngestRuleVersion d3.2 → d3.3。 - 落点:ingestGatePlan 增 ExpiresAt + expiresAt()(走 expiryText/formatTime 的 定宽 30 字节 UTC,与读侧时效闸同一套文本口径),由 finishIngest 在 defer 一态 补写 expires_at(INSERT 与补写之间的短暂窗口如实登记,方向 = 宁可多留)。 非 defer 三态一笔都不多写。 - T0 提取留存规则闸(extract_retention.go):design/03 §10.5 的 N1~N5 纯规则, 接进 judgeIngest 的候选比对之前,命中即 defer + reason=t0_low_value,不进 T1; 两道护栏(显式「记住」/ 持久信号)一票放行,fail-safe 方向 = 宁可多留。 - T1 闭集扩为 merge/modify/keep/defer(同一个 prompt、同一次调用,绝不新增 第二次模型调用):只有关系判为真新时才问价值;defer 要求 target=0,否则判 非法回落 create(reason=t1_reply_invalid)。 - 提取器提示词加固(design/03 §10.3 B):正向清单 + 反例清单 + 「允许返回 []」。 - 临时记忆 TTL:代码默认 tempTtlSec = 604800(7 天);面板 $DATA_DIR/config/memory-tidy.json 的 tempTtlSec 优先(ApplyReviewTuning 逐键 解析,坏值回落默认 + WARN,绝不写进 cfg)。该键在 .env 里没有出处且不新增 (.env 已冻结)⇒ 实际生效链是「面板 > 代码默认」。启动播种同步该键(*int + omitempty,nil 省略、绝不写 null),保证播种文件与生效值逐字一致。 - preinsert_store.go 的 listReviewCandidatesSQL 原位注释:这条内部召回 SQL 刻意不过时效闸的裁定与理由(design/03 §11.2 第 3 条)。 - 测试:defer 落库带短 expires_at(闸 + T1 两条路径)、写入口径与时效闸同源、 TTL 坏值与回落、规则版本 d3.3、整理候选幂等键不含 IngestRuleVersion、 提取规则正例 11 条 / 反例 11 条。 验收:go build ./... + go vet ./... + go test ./... 全绿;windows/darwin 交叉 编译通过(GOOS=darwin GOARCH=arm64 / GOOS=windows GOARCH=amd64)。
395 lines
22 KiB
Go
395 lines
22 KiB
Go
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
|
||
}
|