Files
Cyrene_v2/internal/runtime/memory/preinsert_t1.go
T
AskaEth 21b906ffb0 feat(memory): D3 第五态 defer(入库审查)+ 提取留存规则闸 + 临时 TTL 配置面
主人原话:「收窄提取器,确实要给他一个主动式的记忆留存规则,不能想到什么都
往记忆里写」「.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)。
2026-10-13 20:22:48 +08:00

395 lines
22 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` / `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
}