Compare commits

...
Author SHA1 Message Date
hermes-prof-dev 99cb22ba93 fix(pattern): 二轮判据收紧——白名单唯一来源+通用词黑名单+hash/数值/目录碎片过滤+conf≥0.5写入门槛+组合≥2成员 (t_639c5a03)
小怡 2026-10-30 二轮裁定 5 条全落:
1. ExtractTools 移除'命令式标识'兜底(hash/uuid/目录/文件名碎片的进入路径),成员=knownTools 白名单唯一来源
2. genericTools 黑名单(git/go/curl/grep/bot-chat/mcp/api 等)即使形态合格也拒
3. isValidTool 新增 isHashFragment/isNumericUnitFragment/isNumberedDirName 形态闸
4. UpsertPatterns conf<0.5 一律不写(MinWriteConfidence)
5. tool_combo 需≥2成员(组合语义);IsValidPatternName 同步收紧
单测:junk 类别逐项回归 + 写入门槛 + 幂等/门限值钉死
2026-10-30 20:59:04 +08:00
小唯 5709af0bfa fix(pattern): detect 判据拒收垃圾工具组合 (.md/日期/标点) + 门槛≥2来源 + dry-run + 写入门闸 (t_639c5a03) 2026-10-30 20:04:31 +08:00
小唯 f178594402 fix(usage): 消费侧熔断根治——无窗积压计数改增量游标 (t_9143c968)
根因:CheckCommittedForUse 扫描前用无窗 count(consumed=0)>cap500 作熔断,
写入量涨至 40~200 万行/日后恒触发 ⇒ 10-19 起每 commit skip,consumed=0 已 8 天。

方案 A(增量游标,按卡体最终版):
- 删除无窗全表计数与整窗扫描;单轮代价 O(batch) 与表总量解耦
- expire 通道:出窗 pending 在线标 consumed=2(skipped_expired 留证据)
- retry 通道:退避到期失败行优先重试(t_20bfbd9a 退避语义不变)
- tail 游标:每次 commit 消费游标之上 ≤ZHIYI_USAGE_BATCH(默认200)行(DESC 先吃最新,因果对齐)
- sweep 游标:轮巡复查未消费行,到表尾回绕 0 并本轮补扫(保一次召回多 commit 逐个命中语义)
- TTL:consumed>=1 且 recalled_at<7d 分块删除(ZHIYI_USAGE_TTL_CHUNK 默认5000)
- ZHIYI_USAGE_BACKLOG_MAX 语义收窄:全表积压跳过 → 单次调用失败熔断
- usage_cursor 单行表 + 3 条部分索引(id,recalled_at WHERE consumed=0 等)
- 红线遵守:不动 FEEDBACK_BIAS/POS_MODE/K;不关 ZHIYI_USAGE_TRACK;commit P99 无涉(钩子本为异步)

测试:usage_cursor_test.go 5 例(AC5 窗口外1000+窗内1 / tail 有界+sweep 补齐 /
失败熔断替代 skip 的反证 / retry 通道 / expire+TTL);storage 包全量 ok
2026-10-30 18:47:09 +08:00
小唯 ca6be0063b feat(distill): 程序性步骤判据——how-to 蒸馏答案无步骤则降级 observe (t_3636b872 阶段4)
- IsHowToContent/IsOperationContent:运维/修复/排查/重启类触发词覆盖真实问法
- HasExecutableSteps:事实含命令/路径/序号/端口/动词短语即算有步骤(宁可宽不错杀)
- ProceduralStepVerdict:how-to 命中但事实无步骤 ⇒ 降级 observe;影子默认开(只记不真降)
- 接入 distillOne:lane 判定后对 how-to 内容做步骤校验,keep→observe 记录
- 测试:6 用例(纯元描述降级/含命令放行/含序号放行/非 how-to 不受影响/空答案降级/纯元长句降级)+ 影子语义 + 触发词覆盖

判据动机(t_3636b872 实测):库 83% <60 字、仅 10.2% 含可执行内容,how-to top5 全元描述;
本闸防后续回灌/蒸馏再产出纯元描述。
2026-10-28 07:12:29 +08:00
小唯 40c27f4a95 chore(deploy): 批准 ff92b05 步骤卡 feature(部署清单机读,零代码) 2026-10-28 07:06:04 +08:00
小唯 ff92b0578a feat(standing): 程序性记忆层步骤卡 schema + trigger 变体召回,命中即短路 (t_3636b872)
阶段1: schema + 存储/召回路
- StandingAnswer 增 Op *OpCard(trigger/steps/pre_check/verify/rollback/risk),
  存量库 ALTER ADD COLUMN op(幂等)
- Upsert 签名加 op;op=nil 改写答案不丢结构卡(COALESCE op,op)
- LookupStandingAnswer 按 op.trigger 变体归一化精确匹配(零 fuzzy)
- RecallWithMetaTraced 前置短路:常设答案命中 ⇒ 零检索零 LLM 直接返回
- Upsert 后 refreshStandingAnswers 清缓存 ⇒ 写后新卡即时可见
- 测试:OpCardTrigger 命中/多变体/原句并存/无 fuzzy;Upsert 清缓存即时可见

验收实跑:op 落库 41 条,trigger 变体召回 live,评测 40/40 三连复现
2026-10-28 07:05:08 +08:00
小唯 7f7171ac14 feat(recall): 召回契约字段 + trace 回溯 + 生命周期/实体质量判据(工作树收口)
这批是 10-26~27 已在生产二进制(zhiyid-protectrails-20261027)里运行、
但一直留在工作树未提交的改动。收口原因:三件改造要开分支,工作树脏 =
分支基准与回滚点都不存在。

主要内容:
- api/routes/contract.go + trace.go:召回响应契约字段(trace_id/status/scope/
  continuation/channel)与 /api/v1/trace 回溯端点
- governance/entity_quality.go:实体质量闸
- storage/trace.go + recall.go/recall_write_buffer.go:召回写入缓冲与轨迹
- 测试:contract/recall_contract_fields/recall_lifecycle/stale_lifecycle/
  entity_quality/trace

同时修一个 flaky:recall_lifecycle_test 原本硬编码「今天 11:00 已过」,
凌晨跑就会把「已失效」fixture 当成仍有效。改成相对时间(过期=now-1h),
生命周期判据与运行时刻无关。修完 go test ./internal/api/routes ./internal/storage 全绿。
2026-10-28 02:09:19 +08:00
小唯 0b2b4f01d8 fix(storage): recall_seen_count 批更新加 COALESCE —— NULL 行不再吞召回观测值 (t_5aa7cb77)
批路径 .column("recall_seen_count", "recall_seen_count + delta") 下推 DataFusion,
SQL 语义 NULL+n=NULL ⇒ 17717 行(43.8%)观测值静默丢失。改 COALESCE(recall_seen_count,0)+delta。
逐条 fallback 路径本就 NULL-aware(读现值 unwrap_or(0))且其 split_whitespace 3 段
解析不容 COALESCE,故不动并加注释防后人误改。
2026-10-27 04:34:48 +08:00
prof-dev 37dcebfce4 fix(recall): 修 16:28 topkcap 引入的 pattern 截断回归 (t_e98cb1d6)
- capRecallResults 把 category=pattern/peer 的条目先摘出(recall.go Step 6.2/6.3
  追加在 results 末尾),主结果截断到 limit 后再把附带条目放回末尾;
  主结果仍收口,pattern/peer 不再被蒸馏结果挤掉。
- 新增 TestCapRecallResults_PatternAndPeerSurviveCap:断言 limit=50 时
  pattern 计数 ≥1、peer 不被截断、主结果仍不超过 limit。

方案取舍:选 A(cap 内先摘出),不选 B(注入移到 cap 之后)——
注入在 storage.Recall() 而 cap 在 routes 层,B 需跨 storage/routes 重排
调用链并改缓存路径;A 是纯函数、最小、且与原 topkcap 修正在同一位置。
2026-10-26 19:42:57 +08:00
小唯 d7a73d7e6c fix(recall): top_k 收口 + 恢复 main 可构建性
1) top_k 收口(t_9025b0eb)
   /api/v1/recall 无视 top_k:top_k=5 实测返回 341 条(5 distilled + 336 graph_expanded),
   top_k=10 返回 521 条。因 ExpandFromResults 扩展结果 append 无上限。
   后果:插件记忆预取被撑爆(journal 实测单次 flush 合并 900/780/613/549/351 条),
   真相关条目落在被截断段;count 失真;使用追踪按千级记账稀释指标。
   修法:capRecallResults —— results = 主结果 ≤top_k;扩展单独走 graph_expanded 字段
   (上限 top_k,能力仍可观测);主结果不足时用扩展补齐(稀疏查询仍受益)。
   + core_topk_test.go 5 例 6 断言钉住契约。

2) 恢复 main 可构建性(P0,非本卡范围但阻塞一切构建)
   rebase 丢了 metrics.ConsolLastSuccessTime / ConsolConsecutiveFailures /
   ConsolidateBusyWaitTotal / MetaNoteRejectTotal 的定义(只在 f96c828),
   而调用方已进 main,且 consolidation_track.go 整个文件不在树上 ⇒ go build 失败。
   从 f96c828 原样恢复 4 个指标定义并注册;从 2275b83 恢复 consolidation_track.go。
2026-10-26 16:29:43 +08:00
xiaoyi 9a4a734c19 fix(metrics): 计数器/率指标语义分清 + t8 阈值改由 Condition 承载(t_da03e910)
- 审计误读订正:deprecated_per_day 是计数器(无分母),不给 _samples/_valid 是对的
- 新增 deprecated_total:区分「今天 0 条」与「从未统计过」(0 的二义性)
- executor t8:原 Condition:0.1 从不生效(判据硬编码 >5)→ Condition:5.0 + 读 t.Condition,行为不变
- 单测钉住:RecordDeprecation 双计数、t8 阈值真的可配、计数器不带率键
- docs/metrics-semantics.md:counter/rate/mean 三种读法 + 0 的二义性铁律
2026-10-26 14:55:01 +08:00
xiaoyi 38d7ef147d feat(selfcheck): pattern 段可见化 —— 零产出可被机器读到(写入端 0 生产者事故)
- governance.GraphStore 增 CountNodesByType(total, withID, lastUpdated),SQLite/内存/缓存三实现
- selfcheck cheap 增 pattern 段:rows / rows_with_id / last_produced_at / verdict
  verdict ∈ producing|zero_output|no_id|unknown_age|no_graph_store|unsupported
- 实测:rows=0 verdict=zero_output(与 graph.db 只读复核一致),cheap 22ms
- 动机:pattern 召回 0 命中时只能靠翻 7 天日志才发现「实现了≠出过数」
2026-10-26 14:55:01 +08:00
xiaoyi ab1452d06b feat(selfcheck): /api/v1/selfcheck 统一取数端点 + tools/selfcheck_full.py 全量快照(牧尘批「三档」) 2026-10-26 14:55:01 +08:00
小唯 1c7882ce34 fix(graph): SQLiteGraphStore.ExpandFromResults 恒 0 产出 — from/to 是节点ID却拿裸名比对(t_724f31ad)
816/819 行把 Navigate 返回的节点 ID(n_织忆)与裸实体名 entity 做 == ⇒ 恒不相等 ⇒ 路径全被 continue ⇒ 结构性 0 条附加。
修:比对改用 nodeID(normalizeEntityID(entity));附加结果补 Category=graph_expanded + ID=邻居节点ID(非空,修复 recall.go ID 去重与审计计数);Content 用可读显示名(displayNodeName)。
补单测:正向(n_织忆→n_图谱 链+1条recall ⇒ 补入 graph_expanded 且 ID 非空) + 反向(图外实体 ⇒ 0 补入不凭空加)。
2026-10-26 14:55:01 +08:00
小怡 3cf3858f09 fix(peer): 画像推理器支持 peer 专用 LLM 端点,避免被全局 LLM_ENDPOINT 拖去鉴权腿
起因 t_a5ef04a5:zhiyid 的 LLM_ENDPOINT 由 90-llm.conf 定为 newapi 3000 + agnes-2.5-flash
(供蒸馏/skillgen/consolidate 共用),而 NewLocal2BReasoner 按设计不发 Authorization
⇒ peer refresh 必然 401、peers 表恒 0 行、peer 召回零产出。

不采用卡面建议的「把全局 LLM_ENDPOINT 改成 8081」——那会把蒸馏同时降级成 2B。
改法:新增 ZHIYI_PEER_LLM_ENDPOINT / ZHIYI_PEER_LLM_MODEL 优先读取,未设时行为与改动前逐位一致。
回滚:删 91-peer-llm.conf 即恢复旧行为。
2026-10-26 14:55:01 +08:00
小怡 33e16c1a61 fix(recall): agent_id 派生作用域候选过少时补一次全库搜索(ZHIYI_NS_FALLBACK_MIN,默认20,0=关)
问题:请求带 agent_id ⇒ resolveRecallNamespace 派生 <agent>-main,实测该 ns 只有
default-main 6 / xiaoyi-main 6 / prof-cloud-main 7 条,不传 agent_id 则 200 条
⇒ 带 agent_id 的调用静默劣化到个位数,不报错(09-23 只修了调用方,没修被调用方)。

改法:新增 RecallWithMetaOpts(..., allowNSFallback);仅 agent_id 派生作用域传 true,
显式 namespace 调用方传 false(防跨域泄漏)。候选 < 阈值时补一次全库搜索并合并 + 打日志。
回滚:ZHIYI_NS_FALLBACK_MIN=0。单测 4 条锁死:兜底生效/显式ns不兜底/env关/候选充足不兜底。
2026-10-26 09:56:38 +08:00
58 changed files with 5105 additions and 357 deletions
+30
View File
@@ -0,0 +1,30 @@
# 织忆指标语义:counter / rate / mean(`/api/v1/metrics`)
> 2026-10-26 建立。起因:有效性审计把 `deprecated_per_day`(计数器)当成率指标,
> 报出「0 样本却判 valid=None ⇒ 缺最小样本门」。计数器没有分母,「样本门」对它无定义
> —— 属**探针模型错配**,不是指标缺陷。本文件是给下游(审计脚本 / 看板 / 人)的唯一口径。
## 三类指标,三种读法
| 类型 | 命名特征 | 自带字段 | 怎么用 | 例子 |
|---|---|---|---|---|
| **counter 计数器** | `*_per_day` / `*_total` / `*_count` | **无** `_samples` / `_valid` | 直接读绝对值,跨时间比大小;不要算比率、不要报「样本不足」 | `deprecated_per_day`、`deprecated_total`、`distill_success_count` |
| **rate 比率** | `*_rate` | `_samples`(分母)+ `_valid`(1=可判 / 0=不可判 / -1=N/A) | `_valid != 1` 时**不得**当结论;`_samples` 小于门槛时只报「待积累」 | `auto_resolve_rate`、`recall_usefulness_rate` |
| **mean 均值** | `avg_*` | `_samples` + `_valid` | 同上:无分母就不下判断 | `avg_distill_loss` |
**铁律**:`0` 永远有两种含义 —— 「今天确实没有」和「根本没在统计」。
- 计数器必须配一个累计值来区分:`deprecated_per_day=0` + `deprecated_total=0` ⇒ 从未统计过;
`per_day=0` + `total=137` ⇒ 统计活着,只是今天没发生。
- 率指标必须配 `_valid`/`_samples`,分母退化时返回 `metricNA(-1)`,不给 1(给 1 会被读成满分)。
## 触发器阈值也用同一套语义
`internal/selfoptimize/executor.go` 的 `t8`(`TDecayCritical` → `archive_or_boost`):
- 判据读的是**计数器** `deprecated_per_day`(条/天),阈值由 `t8.Condition` 承载,**默认 5 条/天**。
- 2026-10-26 修正:原先 `Condition: 0.1` 但判据函数里硬编码 `> 5`,配置写了从不生效。
现改为 `Condition: 5.0` + 判据读 `t.Condition`(行为不变,但配置真的能被改)。
- 若要改成「率」语义,需要先有分母(当日活跃写入量),否则别动 —— 那会变成另一个探针错配。
## 已知遗留
- 审计脚本侧对计数器发「样本不足」告警的白名单修复,归 `t_44c07eca`(脚本侧统一处理,避免多处各改一遍)。
@@ -0,0 +1,163 @@
// 织忆 MemoryWeave — 整合链路健康跟踪(t_10757bd5)
//
// 背景(2026-10-24 小怡体检实测):
// - t_distill(15min) 与 t_consolidation 同时触发 consolPipe,
// Rust 侧互斥整合槽被占用时 Go 立即降级 Go 启发式 ⇒ consolidate_busy ~55 次/日静默堆积
// - /health 只看 sidecar TCP 连通性 ⇒ 整合链路持续失败也假绿
//
// 本文件提供:
// 1. ConsolidationTracker — 记录连续失败次数 + 最近一次成功时间(供 /health 读)
// 2. busyWaitRetry — Rust sidecar 报 busy 时等待 3s×3 次再降级(整合全程 ~126s)
// 3. 引用 metrics 包新增的 5 个指标(AC5:失败路径带计数)
package routes
import (
"strings"
"sync"
"time"
"github.com/xiaoxue/memoryweave/internal/consolidate"
"github.com/xiaoxue/memoryweave/internal/metrics"
)
// ─── ConsolidationTracker ────────────────────────────────────
var (
consTracker = &ConsolidationTracker{}
consTrackerOnce sync.Once
)
// ConsolidationTracker 记录整合链路健康状态(内存态,重启归零;
// 重启后健康端点读到 ConsecutiveFailures=0 ⇒ 正确,因为该链路确实刚起步)。
type ConsolidationTracker struct {
mu sync.Mutex
LastSuccess time.Time
LastAttempt time.Time
startup time.Time
ConsecutiveFailures int
Skips int64 // t_10757bd5:需要"跳过"而非失败的次数(并发触发 / sidecar busy)
lastSkip string // 最近一次跳过原因
// lastError 最近一次失败原因(供 /health 暴露,便于人工诊断)
lastError string
}
// GetConsolTracker 返回全局 tracker 单例
func GetConsolTracker() *ConsolidationTracker {
if consTracker.startup.IsZero() {
consTracker.mu.Lock()
if consTracker.startup.IsZero() {
consTracker.startup = time.Now()
}
consTracker.mu.Unlock()
}
return consTracker
}
// RecordSuccess 整合成功(归零)
func (t *ConsolidationTracker) RecordSuccess() {
t.mu.Lock()
defer t.mu.Unlock()
t.LastAttempt = time.Now()
t.LastSuccess = time.Now()
t.ConsecutiveFailures = 0
t.lastError = ""
metrics.ConsolLastSuccessTime.SetToCurrentTime()
metrics.ConsolConsecutiveFailures.Set(0)
}
// RecordSkip 本次触发被**跳过**(不是失败)。
//
// t_10757bd5 关键语义修正(2026-10-24 22:45 实测发现):
// `consolidate_busy` 的真实含义是「另一个整合正在跑」,不是链路坏。
// 把它记成失败会让 /health 报 degraded —— 明明整合正在正常工作,却又是一次假红。
// 只有真正的错误(sidecar 崩、DB 错、超时)才算 failure。
func (t *ConsolidationTracker) RecordSkip(reason string) {
t.mu.Lock()
defer t.mu.Unlock()
t.LastAttempt = time.Now()
t.Skips++
t.lastSkip = reason
}
// RecordFailure 整合失败(+1)
func (t *ConsolidationTracker) RecordFailure(err string) {
t.mu.Lock()
defer t.mu.Unlock()
t.LastAttempt = time.Now()
t.ConsecutiveFailures++
t.lastError = err
metrics.ConsolConsecutiveFailures.Set(float64(t.ConsecutiveFailures))
}
// HealthStatus 返回健康端点应报的状态:
// - 连续**真失败** ≥ 3 ⇒ "degraded"
// - 进程启动已超 2 小时却从未成功过 ⇒ "degraded"(跳过不算成功,也不算失败)
// - 上次成功距今 > 2 小时(15min 周期 × 8)⇒ "degraded"
// - 否则 "ok"
func (t *ConsolidationTracker) HealthStatus() (string, int, time.Duration, string) {
t.mu.Lock()
defer t.mu.Unlock()
fails := t.ConsecutiveFailures
var sinceSuccess time.Duration
if t.LastSuccess.IsZero() {
sinceSuccess = 0 // 从未成功
} else {
sinceSuccess = time.Since(t.LastSuccess)
}
status := "ok"
if fails >= 3 {
status = "degraded"
}
if t.LastSuccess.IsZero() {
if !t.startup.IsZero() && time.Since(t.startup) > 2*time.Hour {
status = "degraded"
}
} else if sinceSuccess > 2*time.Hour {
status = "degraded"
}
return status, fails, sinceSuccess, t.lastError
}
// Snapshot 供 /health 暴露完整字段(含跳过次数,便于区分「忙」与「坏」)
func (t *ConsolidationTracker) Snapshot() (fails int, skips int64, lastSkip string, lastErr string) {
t.mu.Lock()
defer t.mu.Unlock()
return t.ConsecutiveFailures, t.Skips, t.lastSkip, t.lastError
}
// ─── busyWaitRetry ───────────────────────────────────────────
// busyWaitRetry 对 Rust sidecar consolidate_busy 做有限等待重试。
//
// 原理:Rust 整合全程约 126s(DBSCAN + 衰减校准),触发器每 15min 一次,
// 两个触发器同秒到达时第二个撞 busy。等待 3s × 3 次(共 9s);
// 9s 后仍 busy 说明 sidecar 真有长任务(或正在做 full 模式),
// 降级 Go 启发式并记录 tracker(由调用方负责)。
//
// 返回语义:
// - (report, false, nil) = 成功拿到报告
// - (nil, true, nil) = 重试耗尽仍 busy(调用方应走降级 + RecordFailure)
// - (nil, false, err) = 非 busy 错误(调用方直接返回)
func busyWaitRetry(dataDir, sqlitePath, mode string) (report *consolidate.Result, stillBusy bool, err error) {
const (
maxRetries = 3
waitDur = 3 * time.Second
)
for i := 0; i <= maxRetries; i++ {
if i > 0 {
metrics.ConsolidateBusyWaitTotal.Inc()
time.Sleep(waitDur)
}
report, err = consolidate.Run(dataDir, sqlitePath, mode)
if err == nil {
return report, false, nil
}
if strings.Contains(err.Error(), "consolidate_busy") {
continue // 仍 busy,重试
}
// 非 busy 错误直接返回
return nil, false, err
}
// 重试耗尽仍 busy
return nil, true, nil
}
+119
View File
@@ -0,0 +1,119 @@
// 织忆 MemoryWeave — 召回响应契约辅助(P0-3,2026-10-26)
//
// 背景(CS249r Vol III Ch6 检索契约):响应只有 cache_hit/candidates_scanned/count/mode/graph_expanded,
// 空结果分不清「不存在」与「扫了一半就超时」——这是 Air Canada 类静默幻觉的结构性入口。
//
// 本文件提供最小可用契约三件:
// ① status/partial_reason:complete 的空结果才允许解读为「不存在」;
// ② scope{namespace,snapshot,...}:说明这次答案基于哪个数据视图(本服务无索引版本号,
// 故 snapshot = 观测时点 + 缓存状态,缓存命中代表 60s 内旧快照 —— 语义诚实,不假装有版本号);
// ③ continuation:不透明翻页令牌(query 指纹 + ns 绑定,防止跨 query 串页)。
package routes
import (
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"strconv"
"time"
"github.com/xiaoxue/memoryweave/internal/models"
"github.com/xiaoxue/memoryweave/internal/storage"
)
// maxContinuationOffset 硬上限:防止把 topK 放大到爆内存/延迟。
const maxContinuationOffset = 500
type pageToken struct {
V int `json:"v"`
Q string `json:"q"` // query 指纹(sha256 前 8 hex)
NS string `json:"ns"` // 作用域绑定
Off int `json:"o"` // 下一页起点
Lim int `json:"l"` // 页大小
// Prev 已返回过的 ID(去重锚点)。活索引下排序会随 fetchLimit 变化而轻微漂移,
// 只靠 offset 会出现跨页重复;token 携带已返回 ID 才能机械保证「不重」。
Prev []string `json:"p,omitempty"`
}
// maxPrevIDs 令牌里最多携带的去重锚点数(3~5 页内足够;超长则丢最旧,令牌不会无限膨胀)。
const maxPrevIDs = 100
// queryFingerprint 8 hex 的 query 指纹(足够防串页,不泄露正文)。
func queryFingerprint(q string) string {
sum := sha256.Sum256([]byte(q))
return hex.EncodeToString(sum[:4])
}
// encodeContinuation 生成下一页令牌;没有下一页时返回空串。
func encodeContinuation(query, namespace string, offset, limit int, prev []string) string {
if limit <= 0 {
return ""
}
if len(prev) > maxPrevIDs {
prev = prev[len(prev)-maxPrevIDs:]
}
tok := pageToken{V: 1, Q: queryFingerprint(query), NS: namespace, Off: offset, Lim: limit, Prev: prev}
b, err := json.Marshal(tok)
if err != nil {
return ""
}
return base64.RawURLEncoding.EncodeToString(b)
}
// decodeContinuation 解析令牌。错误信息面向调用方(可据此修请求)。
func decodeContinuation(raw string) (pageToken, error) {
var tok pageToken
b, err := base64.RawURLEncoding.DecodeString(raw)
if err != nil {
return tok, errors.New("continuation 不是合法 base64url 令牌")
}
if err := json.Unmarshal(b, &tok); err != nil {
return tok, errors.New("continuation 内容不是合法 JSON")
}
if tok.V != 1 {
return tok, errors.New("continuation 版本不支持:" + strconv.Itoa(tok.V))
}
if tok.Off < 0 || tok.Lim <= 0 {
return tok, errors.New("continuation 字段非法(offset/limit)")
}
if tok.Off > maxContinuationOffset {
return tok, errors.New("continuation offset 超出上限 " + strconv.Itoa(maxContinuationOffset))
}
return tok, nil
}
// buildScope 组装本次召回的数据视图声明。
func buildScope(namespace string, topK int, mode string, cacheHit, truncated bool, started time.Time) map[string]interface{} {
snap := "t" + strconv.FormatInt(started.UnixMilli(), 10)
if cacheHit {
snap += "-cache" // 缓存命中:读的是 60s 内旧快照,不是本次实时扫描
} else {
snap += "-scan"
}
if truncated {
snap += "-truncated"
}
return map[string]interface{}{
"namespace": namespace,
"snapshot": snap,
"cache_hit": cacheHit,
"truncated": truncated,
"top_k": topK,
"mode": mode,
}
}
// traceItems 把最终返回集映射成 trace 的 item 归因(rank 从 1 起)。
func traceItems(results []models.RecallResult) []storage.TraceItem {
items := make([]storage.TraceItem, 0, len(results))
for i, r := range results {
ch := r.Channel
if ch == "" {
ch = "vector" // 老调用方/旧缓存条目无 channel ⇒ 主通道默认值
}
items = append(items, storage.TraceItem{Rank: i + 1, ID: r.ID, Channel: ch, Score: r.Score})
}
return items
}
+116
View File
@@ -0,0 +1,116 @@
package routes
import (
"encoding/json"
"testing"
"time"
"github.com/xiaoxue/memoryweave/internal/models"
)
func TestContinuationRoundTrip(t *testing.T) {
tok := encodeContinuation("织忆怎么召回", "hermes-main", 10, 5, []string{"mem_1", "mem_2"})
if tok == "" {
t.Fatal("有下一页时令牌不应为空")
}
got, err := decodeContinuation(tok)
if err != nil {
t.Fatalf("解码失败:%v", err)
}
if got.Off != 10 || got.Lim != 5 {
t.Fatalf("令牌字段错误:%+v", got)
}
if got.Q != queryFingerprint("织忆怎么召回") {
t.Fatalf("query 指纹不一致")
}
if got.NS != "hermes-main" {
t.Fatalf("namespace 不一致:%s", got.NS)
}
if len(got.Prev) != 2 || got.Prev[0] != "mem_1" {
t.Fatalf("去重锚点未随令牌传递:%v", got.Prev)
}
}
func TestContinuationRejectsBadToken(t *testing.T) {
if _, err := decodeContinuation("not-base64!!"); err == nil {
t.Fatal("非法 base64 应报错")
}
if _, err := decodeContinuation("eyJ2Ijo5fQ"); err == nil { // {"v":9}
t.Fatal("未知版本应报错")
}
tok := encodeContinuation("q", "ns", 9999, 5, nil)
if _, err := decodeContinuation(tok); err == nil {
t.Fatal("offset 超上限应报错")
}
}
func TestQueryFingerprintStable(t *testing.T) {
a := queryFingerprint("同样的问句")
b := queryFingerprint("同样的问句")
c := queryFingerprint("不同的问句")
if a != b {
t.Fatal("同 query 指纹应稳定")
}
if a == c {
t.Fatal("不同 query 指纹应不同")
}
if len(a) != 8 {
t.Fatalf("指纹应为 8 hex,实际 %d", len(a))
}
}
func TestBuildScope(t *testing.T) {
started := time.UnixMilli(1700000000000)
sc := buildScope("hermes-main", 5, "hybrid", false, false, started)
for _, k := range []string{"namespace", "snapshot", "cache_hit", "truncated", "top_k", "mode"} {
if _, ok := sc[k]; !ok {
t.Fatalf("scope 缺字段 %s", k)
}
}
snap := sc["snapshot"].(string)
if snap != "t1700000000000-scan" {
t.Fatalf("实时扫描 snapshot 形态错误:%s", snap)
}
cached := buildScope("ns", 5, "hybrid", true, true, started)["snapshot"].(string)
if cached != "t1700000000000-cache-truncated" {
t.Fatalf("缓存+截断 snapshot 形态错误:%s", cached)
}
}
func TestTraceItems(t *testing.T) {
rs := []models.RecallResult{
{ID: "a", Channel: "vector"},
{ID: "b"}, // 老调用方无 channel ⇒ 默认 vector
{ID: "c", Channel: "graph"},
}
items := traceItems(rs)
if len(items) != 3 || items[0].Rank != 1 || items[2].Rank != 3 {
t.Fatalf("rank 编号错误:%+v", items)
}
if items[1].Channel != "vector" {
t.Fatalf("缺省 channel 应回落 vector,实际 %s", items[1].Channel)
}
}
// TestRecallResultContractFields 契约字段必须真的出现在 JSON 里(不是「加了结构体没接线」)。
func TestRecallResultContractFields(t *testing.T) {
r := models.RecallResult{
ID: "mem_1", Channel: "fts", Stale: true, Version: 3,
ValidFrom: "2026-10-26T10:00:00+08:00", ValidTo: "2026-10-26T11:00:00+08:00",
Supersedes: []string{"mem_0"}, SupersededBy: []string{"mem_2"}, Superseded: true,
}
b, err := json.Marshal(r)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var m map[string]interface{}
if err := json.Unmarshal(b, &m); err != nil {
t.Fatalf("unmarshal: %v", err)
}
for _, k := range []string{"channel", "stale", "generation", "valid_from", "valid_to",
"supersedes", "superseded_by", "superseded"} {
if _, ok := m[k]; !ok {
t.Fatalf("响应缺字段 %s(JSON=%s)", k, string(b))
}
}
}
+301 -73
View File
@@ -2,6 +2,7 @@
package routes
import (
"context"
"encoding/json"
"fmt"
"log"
@@ -16,9 +17,9 @@ import (
"github.com/xiaoxue/memoryweave/internal/distill"
"github.com/xiaoxue/memoryweave/internal/governance"
"github.com/xiaoxue/memoryweave/internal/metanote"
"github.com/xiaoxue/memoryweave/internal/metrics"
"github.com/xiaoxue/memoryweave/internal/models"
"github.com/xiaoxue/memoryweave/internal/metanote"
"github.com/xiaoxue/memoryweave/internal/selfoptimize"
"github.com/xiaoxue/memoryweave/internal/storage"
)
@@ -106,6 +107,8 @@ func (a *API) Commit(w http.ResponseWriter, r *http.Request) {
// 判定与 Python 口径真相源同源(internal/metanote,parity/差分测试锁死);
// 判 meta ⇒ 422 拒绝,不写 episode、不进蒸馏(卡面:把 meta 挡在咽喉点)。
if skip, reason := metanote.ShouldSkip(req.Content); skip {
// t_10757bd5 AC5:metanote 拒绝 ⇒ 计数器 +1(与日志 "[metanote] commit rejected" 行对齐)
metrics.MetaNoteRejectTotal.Inc()
log.Printf("[metanote] commit rejected (gate ON, meta-note): agent=%s ns=%s reason=%s content=%.60s",
req.AgentID, req.Namespace, reason, req.Content)
respondError(w, 422, "meta-note rejected: "+reason)
@@ -506,13 +509,15 @@ func cosineSimilarity(a, b []float32) float64 {
// 用途:区分「真无匹配」(results=[] 但候选数>0)与「依赖故障」(非 2xx)。
func (a *API) Recall(w http.ResponseWriter, r *http.Request) {
var req struct {
Query string `json:"query"`
Limit int `json:"limit"`
TopK int `json:"top_k"` // 兼容插件 top_k 参数
Namespace string `json:"namespace"`
AgentID string `json:"agent_id"` // 用于推导默认 namespace
Diversity float64 `json:"diversity"`
Mode string `json:"mode"` // "hybrid"(default), "semantic", "keyword"
Query string `json:"query"`
Limit int `json:"limit"`
TopK int `json:"top_k"` // 兼容插件 top_k 参数
Namespace string `json:"namespace"`
AgentID string `json:"agent_id"` // 用于推导默认 namespace
Diversity float64 `json:"diversity"`
Mode string `json:"mode"` // "hybrid"(default), "semantic", "keyword"
DeadlineMS int `json:"deadline_ms"` // P0-3:本次召回的硬超时(毫秒),超时返回 partial
Continuation string `json:"continuation"` // P0-3:上一页返回的不透明翻页令牌
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, 400, "invalid body")
@@ -545,8 +550,48 @@ func (a *API) Recall(w http.ResponseWriter, r *http.Request) {
req.Limit = 10
}
// 2026-09-21 五修(t_24eceed0 返修):无 ns 且无 agent_id → namespace="" → 全库跨 ns 召回
// 2026-10-26:区分「作用域来源」——只有 agent_id 派生的 ns 才允许兜底,
// 显式传 namespace 的调用方不兜底(避免跨域泄漏)。
nsDerivedFromAgent := req.Namespace == "" && req.AgentID != ""
req.Namespace = resolveRecallNamespace(req.Namespace, req.AgentID, req.Query)
// ── P0-3 契约 + P0-1 追踪(2026-10-26)──────────────────────────────
// 翻页令牌:绑定 query 指纹 + 作用域,跨 query 复用一律 400(防串页)。
offset := 0
var prevIDs []string
if req.Continuation != "" {
pt, perr := decodeContinuation(req.Continuation)
if perr != nil {
respondErrorSchema(w, 400, "bad_continuation", perr.Error(), 0)
return
}
if pt.Q != queryFingerprint(req.Query) {
respondErrorSchema(w, 400, "continuation_query_mismatch",
"continuation 属于另一个 query,不能跨 query 复用", 0)
return
}
if pt.NS != req.Namespace {
respondErrorSchema(w, 400, "continuation_namespace_mismatch",
"continuation 的 namespace 与本次请求不一致", 0)
return
}
offset = pt.Off
prevIDs = pt.Prev
}
// 取数上限 = 页尾(offset+limit);MMR 的 k 随之上抬,保证前缀稳定。
// 多取 len(prevIDs):用于过滤跨页重复项,过滤后仍能凑满一页(否则「不重」要靠「漏」换)。
fetchLimit := req.Limit + offset + len(prevIDs)
if fetchLimit > maxContinuationOffset+req.Limit {
fetchLimit = maxContinuationOffset + req.Limit
}
ctx := r.Context()
if req.DeadlineMS > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, time.Duration(req.DeadlineMS)*time.Millisecond)
defer cancel()
}
// H4: Default diversity — balance relevance & diversity
// 2026-10-14:改读 storage 的推送值(默认 0.3,与下方原硬编码一致 ⇒ 行为不变),
// 让调参器能真正影响召回 —— 此前调参改了 Tuner.Diversity 但没人读(空转)。
@@ -574,72 +619,68 @@ func (a *API) Recall(w http.ResponseWriter, r *http.Request) {
if req.Mode == "" {
req.Mode = "hybrid"
}
// P0-1:trace 在 mode 归一之后创建(记录真实 mode);所有路径共用同一条 trace。
tr := storage.NewRecallTrace("", req.Query, req.Namespace, req.Mode)
var lastMeta storage.RecallMeta
switch req.Mode {
case "keyword":
// Pure keyword search: LanceDB + BM25 re-rank, or graph.db fallback
// t_2d4e3b92: keyword 主路径依赖向量编码;编码失败(依赖不可用)⇒ 非 2xx,
// 不再把错误伪装成 200 + 错误体。
// t_1c12e000:keyword 路径同样收进 RecallWithMeta(Pipeline.Recall 内部
// 走同一缓存键),只为把 cache_hit 带回响应——旧版用 Recall 丢掉了 meta。
var kwMeta storage.RecallMeta
kwResults, kwMeta, kwErr := a.Pipeline.RecallWithMeta(req.Query, req.Namespace, req.Limit, req.Diversity)
// t_2d4e3b92: keyword 主路径依赖向量编码;编码失败(依赖不可用)⇒ 非 2xx。
stKw := time.Now()
kwResults, kwMeta, kwErr := a.Pipeline.RecallWithMetaTraced(ctx, req.Query, req.Namespace, fetchLimit, req.Diversity, nsDerivedFromAgent, tr)
if kwErr != nil {
// 主路径硬错误(向量编码/rerank 依赖不可用):即便兜底能补结果,也按 502 上报,
// 调用方据此走失败路径而不是把依赖故障误判成"无匹配"
tr.StageNote("keyword_path", stKw, 0, kwErr.Error())
tr.Error = kwErr.Error()
respondErrorSchema(w, 502, "recall_backend_error",
"keyword recall failed: "+kwErr.Error(), 5)
log.Printf("[recall:keyword] failed: %v", kwErr)
return
}
lastMeta = kwMeta
cacheHit = kwMeta.CacheHit
truncated = kwMeta.Truncated // t_3aa2a8c1
truncated = kwMeta.Truncated
if len(kwResults) == 0 {
// 真无匹配(正常业务):成功 schema,results=[] + candidates_scanned 供区分
fallbackResults := a.GraphStore.FallbackTextSearch(req.Query, req.Namespace, req.Limit)
results = convertFallbackResults(fallbackResults)
respond(w, 200, map[string]interface{}{
"results": results,
"count": len(results),
"candidates_scanned": 0, // keyword 路径未做 ANN 扫描
"mode": "keyword",
"cache_hit": false, // graph 兜底结果不来自 search cache
})
return
// 真无匹配(正常业务)或纯词面路径空:graph.db 兜底
results = convertFallbackResults(a.GraphStore.FallbackTextSearch(req.Query, req.Namespace, fetchLimit))
if len(results) > 0 {
tr.StageNote("keyword_fallback", stKw, len(results), "主路径 0 命中 ⇒ graph.db 兜底")
} else {
tr.Stage("keyword_path", stKw, 0)
}
cacheHit = false // 兜底结果不来自 search cache
} else {
// Re-rank by BM25 only
for i := range kwResults {
kwResults[i].Score = storage.ComputeBM25Score(req.Query, kwResults[i].Content)
}
sort.Slice(kwResults, func(i, j int) bool { return kwResults[i].Score > kwResults[j].Score })
if len(kwResults) > fetchLimit {
kwResults = kwResults[:fetchLimit]
}
for i := range kwResults {
if kwResults[i].Channel == "" {
kwResults[i].Channel = "keyword"
}
}
results = kwResults
tr.Stage("keyword_path", stKw, len(results))
}
// Re-rank by BM25 only
for i := range kwResults {
kwResults[i].Score = storage.ComputeBM25Score(req.Query, kwResults[i].Content)
}
sort.Slice(kwResults, func(i, j int) bool { return kwResults[i].Score > kwResults[j].Score })
if len(kwResults) > req.Limit {
kwResults = kwResults[:req.Limit]
}
kwResp := map[string]interface{}{
"results": kwResults, "count": len(kwResults), "mode": "keyword",
"candidates_scanned": 0,
"cache_hit": cacheHit, // t_1c12e000:keyword 主路径命中 search cache 时显式标注
}
// t_3aa2a8c1:truncated 只在 true 时暴露(省略 = 未截断,omitempty 语义)。
if truncated {
kwResp["truncated"] = true
}
respond(w, 200, kwResp)
return
case "semantic":
// Pure semantic — BM25 off (H1 skipped, Pipeline Recall has BM25 built in — handled by switch)
// t_949840b1:RecallWithMeta 额外返回候选池真实大小(ANN 粗检索池,非最终产出数)
// Pure semantic — BM25 off(Pipeline 内部按 mode 处理)
var meta storage.RecallMeta
results, meta, err = a.Pipeline.RecallWithMeta(req.Query, req.Namespace, req.Limit, req.Diversity)
results, meta, err = a.Pipeline.RecallWithMetaTraced(ctx, req.Query, req.Namespace, fetchLimit, req.Diversity, nsDerivedFromAgent, tr)
lastMeta = meta
annScanned = meta.CandidatesScanned
cacheHit = meta.CacheHit // t_1c12e000
truncated = meta.Truncated // t_3aa2a8c1
cacheHit = meta.CacheHit
truncated = meta.Truncated
case "hybrid":
// BM25 + vector combined (H1: BM25 scoring inside Pipeline.Recall)
var meta storage.RecallMeta
results, meta, err = a.Pipeline.RecallWithMeta(req.Query, req.Namespace, req.Limit, req.Diversity)
results, meta, err = a.Pipeline.RecallWithMetaTraced(ctx, req.Query, req.Namespace, fetchLimit, req.Diversity, nsDerivedFromAgent, tr)
lastMeta = meta
annScanned = meta.CandidatesScanned
cacheHit = meta.CacheHit // t_1c12e000
truncated = meta.Truncated // t_3aa2a8c1
cacheHit = meta.CacheHit
truncated = meta.Truncated
}
if err != nil {
// P0: 降级到 graph.db 关键词搜索(仅 hybrid/semantic 主路径依赖向量/rerank)
@@ -647,11 +688,13 @@ func (a *API) Recall(w http.ResponseWriter, r *http.Request) {
if len(fallbackResults) > 0 {
results = convertFallbackResults(fallbackResults)
annScanned = len(fallbackResults)
cacheHit = false // t_1c12e000:graph 兜底结果不来自 search cache
cacheHit = false // t_1c12e000:graph 兜底结果不来自 search cache
truncated = false // t_3aa2a8c1:graph.db 兜底结果不经 IPC 粗检索 ⇒ 无截断可言
w.Header().Set("X-Fallback", "graph")
} else {
// t_2d4e3b92: 主路径与兜底都失败 ⇒ 非 2xx + 统一错误体(绝不在 200 里塞错误)
tr.MarkPartial("stage_error")
tr.Error = err.Error()
respondErrorSchema(w, 502, "recall_backend_error",
"recall failed: "+err.Error()+"; graph fallback also empty", 5)
log.Printf("[recall] failed: %v; fallback empty", err)
@@ -693,13 +736,20 @@ func (a *API) Recall(w http.ResponseWriter, r *http.Request) {
vv = storage.VValueProvider(m.ID)
}
results = append(results, models.RecallResult{
ID: m.ID,
Content: m.Content,
Category: m.Category,
Score: 0.5, // shared 结果降权,使用默认分
QualityScore: m.QualityScore,
VValue: vv,
ID: m.ID,
Content: m.Content,
Category: m.Category,
Score: 0.5, // shared 结果降权,使用默认分
QualityScore: m.QualityScore,
VValue: vv,
HumanUsefulCount: m.HumanUsefulCount, // A10 (t_d618dc7c)
Channel: "shared", // P0-1:来源通道
Version: m.Version,
ValidFrom: m.ValidFrom,
ValidTo: m.ValidTo,
Supersedes: m.Supersedes,
SupersededBy: m.SupersededBy,
Superseded: len(m.SupersededBy) > 0,
})
}
}
@@ -737,13 +787,20 @@ func (a *API) Recall(w http.ResponseWriter, r *http.Request) {
vv = storage.VValueProvider(m.ID)
}
results = append(results, models.RecallResult{
ID: m.ID,
Content: m.Content,
Category: m.Category,
Score: 0.45, // 笔记知识降权:不挤掉主记忆,但能被看到
QualityScore: m.QualityScore,
VValue: vv,
ID: m.ID,
Content: m.Content,
Category: m.Category,
Score: 0.45, // 笔记知识降权:不挤掉主记忆,但能被看到
QualityScore: m.QualityScore,
VValue: vv,
HumanUsefulCount: m.HumanUsefulCount, // A10 (t_d618dc7c)
Channel: "wiki", // P0-1:来源通道
Version: m.Version,
ValidFrom: m.ValidFrom,
ValidTo: m.ValidTo,
Supersedes: m.Supersedes,
SupersededBy: m.SupersededBy,
Superseded: len(m.SupersededBy) > 0,
})
}
}
@@ -773,6 +830,28 @@ func (a *API) Recall(w http.ResponseWriter, r *http.Request) {
results = ordered
}
// ── 2026-10-26(P1-3 生命周期 fixture 抓到的真 bug)──────────────────
// 主候选池在 pipeline 里已做 bi-temporal 过滤,但 **shared / wiki 补充通道与图谱注入
// 不过那道闸** ⇒ 已失效事实会从补充通道回流进 top-k(实测:valid_to=11:00 的记录
// 被 shared 通道以 score=0.5 重新塞回)。这里在「所有注入完成之后、cap 之前」统一过滤,
// 保证读侧只有一条有效期口径。全失效时保留原结果(不把召回清空,non-lossy)。
if storage.BitemporalEnabled() {
now := time.Now()
live := make([]models.RecallResult, 0, len(results))
expired := 0
for _, r := range results {
if storage.ValidityExpired(r.ValidTo, now) {
expired++
continue
}
live = append(live, r)
}
if len(live) > 0 && expired > 0 {
log.Printf("[recall] 补充通道 bi-temporal 过滤 %d 条失效事实(统一读侧口径)", expired)
results = live
}
}
// Record recall hit rate (has results = hit, empty = miss)
selfoptimize.Dash.RecordRecall(len(results) > 0)
// Gap auto-close: recall 命中后自动关闭该 query 对应的 open gap
@@ -814,23 +893,110 @@ func (a *API) Recall(w http.ResponseWriter, r *http.Request) {
}
storage.RecordRecalled(recs)
}
// ── 2026-10-26 (t_9025b0eb) top_k 收口:图谱扩展不得撑爆返回集 ──
// 现象:/api/v1/recall 完全无视 top_k —— top_k=5 实测返回 1905/1625 条、top_k=10 返回
// 2227/1859 条,其中 99% 是 category=graph_expanded(图谱扩展 append 无上限)。
// 后果:插件记忆预取被撑到 103,299 chars 截断(真相关条目落在被截断段)、count 失真、
// ⑤使用追踪按千级结果记账。
// 契约修正(主结果优先):
// results = 主检索结果(category != graph_expanded),截断到 req.Limit;
// 主结果不足 req.Limit 时用扩展结果按原顺序补齐(稀疏查询仍有扩展收益)。
// graph_expanded = 扩展结果**单独成字段**(上限 req.Limit)——能力仍可观测、可统计,
// 但不再挤占主结果、不再污染 results/count。
results, graphExpanded := capRecallResults(results, fetchLimit)
if results == nil {
results = []models.RecallResult{}
}
// ── P0-3 翻页:从 fetchLimit 结果里切出 [offset, offset+limit) ──
// 说明:翻页重算整段前 offset+limit 条再切片,保证 MMR 前缀稳定;
// 令牌绑定 query 指纹 + namespace,跨 query/跨域复用一律 400(防串页)。
totalFetched := len(results)
if offset > 0 {
if offset >= len(results) {
results = []models.RecallResult{}
} else {
results = results[offset:]
}
}
if len(prevIDs) > 0 {
seenPrev := make(map[string]bool, len(prevIDs))
for _, id := range prevIDs {
seenPrev[id] = true
}
kept := results[:0]
for _, r := range results {
if !seenPrev[r.ID] {
kept = append(kept, r)
}
}
results = kept
}
if len(results) > req.Limit {
results = results[:req.Limit]
}
nextCont := ""
if totalFetched >= fetchLimit && len(results) == req.Limit && offset+req.Limit < maxContinuationOffset {
returned := make([]string, 0, len(prevIDs)+len(results))
returned = append(returned, prevIDs...)
for _, r := range results {
returned = append(returned, r.ID)
}
nextCont = encodeContinuation(req.Query, req.Namespace, offset+req.Limit, req.Limit, returned)
}
// ── P0-1/P0-3:契约字段 + trace 落盘 ──
tr.SetItems(traceItems(results))
status := lastMeta.Status
if status == "" {
status = "complete"
}
partialReason := lastMeta.PartialReason
if tr.Status == "partial" && status == "complete" {
status = "partial"
partialReason = tr.PartialReason
}
if truncated && status == "complete" {
status = "partial"
partialReason = "row_cap_truncated"
}
tr.Status = status
tr.PartialReason = partialReason
tr.CacheHit = cacheHit
tr.Truncated = truncated
tr.CandidatesScanned = annScanned
tr.Namespace = req.Namespace
tr.Mode = req.Mode
tr.Scope = buildScope(req.Namespace, req.Limit, req.Mode, cacheHit, truncated, tr.StartedAt)
if offset > 0 {
tr.Scope["offset"] = offset
}
storage.SaveTrace(tr)
resp := map[string]interface{}{
"results": results,
"count": len(results),
"candidates_scanned": annScanned,
"mode": req.Mode,
// 2026-10-26 (t_9025b0eb):扩展结果单独成字段,避免撑爆 results。
// 键恒存在(可为空数组),便于探针/统计区分「无扩展」与「字段缺失」。
"graph_expanded": graphExpanded,
// t_1c12e000:AC3 —— 缓存命中/回源扫表必须可区分。
// true ⇒ candidates_scanned=0 是「读了 60s 内的旧快照」(含 L1/L2);
// false ⇒ 本次真扫了表(candidates_scanned 为真实候选池大小)。
"cache_hit": cacheHit,
// 2026-10-26(P0-1/P0-3 契约):
// trace_id ⇒ GET /api/v1/trace/<id> 可重建本次调用链(阶段耗时 + 每通道贡献);
// status ⇒ complete 的空结果才可解读为「不存在」;partial 必须看 partial_reason;
// scope ⇒ 本次答案基于哪个数据视图(namespace/snapshot/cache_hit/truncated);
// continuation ⇒ 空串=无下一页,非空=拼回请求体取下一页。
"trace_id": tr.TraceID,
"status": status,
"scope": tr.Scope,
"continuation": nextCont,
}
if partialReason != "" {
resp["partial_reason"] = partialReason
}
// t_3aa2a8c1:truncated —— 只在 true 时暴露(键缺失 = 未截断,等价 omitempty 语义)。
// true = 本次 recall 的粗检索/补充搜至少一次命中 Rust 侧单次重读行数上限
// (默认 20000,ZHIYI_HEAVY_READ_MAX_ROWS)被截断为部分结果 ⇒ count/
// candidates_scanned 是截断后状态,调用方不得当作「全量」。
if truncated {
resp["truncated"] = true
}
@@ -1471,3 +1637,65 @@ func (a *API) EdgeFeedback(w http.ResponseWriter, r *http.Request) {
func (a *API) PopWatch(w http.ResponseWriter, r *http.Request) {
respond(w, 200, storage.PopWatchSnapshot())
}
// capRecallResults —— 2026-10-26 (t_9025b0eb) top_k 收口语义(纯函数,可单测)。
//
// 输入是 pipeline 返回的原始结果(主检索在前、图谱扩展 append 在后,扩展无上限)。
// 输出:
//
// prim = 主检索结果(category != graph_expanded),截断到 limit;
// 若主结果不足 limit,用扩展结果按原顺序补齐(稀疏查询仍有扩展收益)。
// expanded = 剩余扩展结果,单独成字段返回,上限 limit(能力可观测,但不挤占主结果)。
//
// 为什么不是「直接 results[:limit]」:扩展结果全部排在主结果之后,直接截断会让
// category=graph_expanded 恒为 0 ⇒ 能力虽在跑但对外永久失明(本仓 2026-10 已因同类
// 「接线≠出数」栽过一次)。所以既收口、又保留可观测通道。
func capRecallResults(results []models.RecallResult, limit int) (prim, expanded []models.RecallResult) {
if limit <= 0 {
limit = 10
}
prim = make([]models.RecallResult, 0, limit)
if len(results) == 0 {
return prim, []models.RecallResult{}
}
exp := make([]models.RecallResult, 0, len(results))
// 2026-10-26 (t_e98cb1d6):pattern / peer 是「附带注入」的召回条目(recall.go Step 6.2/6.3),
// 追加在 results 末尾 ⇒ 若并入 prim 一起截断,distilled 占满 limit 时它们恒被切光
// (16:28 topkcap 引入的 pattern 截断回归,limit=50 时 pattern=0 / limit=200 才现形 3 条)。
// 正确语义:主检索结果(distilled 等)照常收口到 limit;pattern/peer 作为附带条目
// 恒保留(不挤占主结果配额),避免「命中即注入」被 cap 吞掉。graph_expanded 仍走独立字段。
var carry []models.RecallResult
for _, r := range results {
switch r.Category {
case "graph_expanded":
exp = append(exp, r)
case "pattern", "peer":
carry = append(carry, r)
default:
prim = append(prim, r)
}
}
// 先收口主结果
if len(prim) > limit {
prim = prim[:limit]
}
// 主结果不足 limit 时用扩展结果按原顺序补齐(稀疏查询仍有扩展收益)
if len(exp) > 0 {
if room := limit - len(prim); room > 0 {
if room > len(exp) {
room = len(exp)
}
prim = append(prim, exp[:room]...)
exp = exp[room:]
}
if len(exp) > limit {
exp = exp[:limit]
}
}
// pattern / peer 附带条目恒保留在 results 末尾(独立于主结果配额)
prim = append(prim, carry...)
if expanded == nil {
expanded = exp
}
return prim, expanded
}
+153
View File
@@ -0,0 +1,153 @@
package routes
import (
"testing"
"github.com/xiaoxue/memoryweave/internal/models"
)
// 2026-10-26 (t_9025b0eb):top_k 收口契约测试。
// 起因:/api/v1/recall 完全无视 top_k —— top_k=5 实测返回 1905/1625 条(99% graph_expanded),
// 插件记忆预取被撑到 103,299 chars 截断、count 失真、使用追踪按千级结果记账。
// 钉住四件事:① results 不超过 limit ② 主结果不被扩展挤掉 ③ 扩展仍可见且不超 limit
// ④ 稀疏查询(主结果 < limit)时扩展能补齐。
func TestCapRecallResults(t *testing.T) {
mk := func(n int, cat string) []models.RecallResult {
out := make([]models.RecallResult, n)
for i := 0; i < n; i++ {
out[i] = models.RecallResult{ID: cat + string(rune('a'+i%26)), Category: cat, Content: cat}
}
return out
}
cases := []struct {
name string
prim, exp int
limit int
wantPrim int
wantExp int
primCategory string
}{
{"主结果足额_扩展不得进results", 10, 1900, 5, 5, 5, "distilled"},
{"主结果只有2条_扩展补齐到limit", 2, 900, 5, 5, 5, "distilled"},
{"无扩展_纯截断", 30, 0, 10, 10, 0, "distilled"},
{"扩展少于limit", 1, 3, 5, 4, 0, "distilled"},
{"空结果", 0, 0, 5, 0, 0, ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
all := append(mk(c.prim, "distilled"), mk(c.exp, "graph_expanded")...)
prim, exp := capRecallResults(all, c.limit)
if len(prim) != c.wantPrim {
t.Fatalf("prim=%d want %d", len(prim), c.wantPrim)
}
if len(exp) != c.wantExp {
t.Fatalf("expanded=%d want %d", len(exp), c.wantExp)
}
if len(prim)+len(exp) > c.limit*2 {
t.Fatalf("总量失控: prim=%d exp=%d", len(prim), len(exp))
}
// 主结果条数不得超过原始主结果数(不得凭空造)
n := 0
for _, r := range prim {
if r.Category != "graph_expanded" {
n++
}
}
if n > c.prim {
t.Fatalf("主结果 %d 条,超过输入 %d 条(凭空造)", n, c.prim)
}
if c.primCategory != "" && c.prim > 0 && n == 0 {
t.Fatalf("主结果被扩展挤光:%+v", prim)
}
})
}
}
// limit 非法(0/负数)时按默认 10 处理,不做无界返回。
func TestCapRecallResultsDefaultLimit(t *testing.T) {
all := make([]models.RecallResult, 50)
for i := range all {
all[i] = models.RecallResult{Category: "distilled"}
}
prim, _ := capRecallResults(all, 0)
if len(prim) != 10 {
t.Fatalf("limit=0 应回落 10,得到 %d", len(prim))
}
}
// ── t_e98cb1d6 回归:pattern / peer 被 cap 截断 ────────────────────────────
// 病灶:capRecallResults 把非 graph_expanded 全部并入 prim 再截断到 limit;
// pattern/peer 是 recall.go Step 6.2/6.3 追加到 results 末尾的附带条目,
// distilled 占满 limit 时它们被整段切掉(16:28 topkcap 引入,limit=50 时 pattern=0)。
// 修后:pattern/peer 恒保留在 results 末尾(独立于主结果配额),主结果仍照常收口。
func TestCapRecallResults_PatternAndPeerSurviveCap(t *testing.T) {
mk := func(n int, cat string) []models.RecallResult {
out := make([]models.RecallResult, n)
for i := 0; i < n; i++ {
out[i] = models.RecallResult{ID: cat + string(rune('a'+i%26)), Category: cat, Content: cat}
}
return out
}
count := func(rs []models.RecallResult, cat string) int {
n := 0
for _, r := range rs {
if r.Category == cat {
n++
}
}
return n
}
cases := []struct {
name string
d, p, pe, e int // distilled / pattern / peer / graph_expanded
limit int
wantD, wantP, wantPe int
}{
// ① 金标场景:主结果占满 limit,pattern/peer 追加在末尾 ⇒ 修前恒为 0
{"limit=50_主结果占满_pattern 恒保留", 50, 3, 1, 0, 50, 50, 3, 1},
{"limit=200_主结果占满_pattern 仍保留", 100, 3, 1, 97, 200, 100, 3, 1},
// ② peer 注入同样不被截断
{"limit=50_peer 注入不被截断", 50, 0, 2, 0, 50, 50, 0, 2},
// ③ 主结果不足 limit:扩展先补齐主结果,carry 仍保留在末尾
{"limit=50_主结果不足_扩展补齐+carry 保留", 30, 2, 1, 10, 50, 30, 2, 1},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
all := make([]models.RecallResult, 0, c.d+c.p+c.pe+c.e)
all = append(all, mk(c.d, "distilled")...)
all = append(all, mk(c.p, "pattern")...)
all = append(all, mk(c.pe, "peer")...)
all = append(all, mk(c.e, "graph_expanded")...)
prim, exp := capRecallResults(all, c.limit)
d := count(prim, "distilled")
p := count(prim, "pattern")
pe := count(prim, "peer")
e := count(prim, "graph_expanded")
if d != c.wantD {
t.Errorf("distilled=%d want %d", d, c.wantD)
}
// AC:limit=50 时 pattern 必须 ≥1(金标 query 复现终审现象)
if c.p > 0 && p < 1 {
t.Errorf("pattern=%d,应 ≥1(截断回归未修)", p)
}
if p != c.wantP {
t.Errorf("pattern=%d want %d", p, c.wantP)
}
if pe != c.wantPe {
t.Errorf("peer=%d want %d", pe, c.wantPe)
}
// cap 仍然生效:主结果(distilled + 扩展补齐)不得超过 limit
if d+e > c.limit {
t.Fatalf("主结果超 cap: distilled=%d graph_expanded=%d limit=%d", d, e, c.limit)
}
if len(exp) > c.limit {
t.Fatalf("graph_expanded 字段超 cap: %d > %d", len(exp), c.limit)
}
// 附带条目不得被塞进 graph_expanded 字段
if count(exp, "pattern") > 0 || count(exp, "peer") > 0 {
t.Fatalf("pattern/peer 被误放入 graph_expanded 字段")
}
})
}
}
+11
View File
@@ -76,6 +76,7 @@ func (pa *PatternAPI) Detect(w http.ResponseWriter, r *http.Request) {
var req struct {
Limit int `json:"limit"`
Namespace string `json:"namespace"`
DryRun bool `json:"dry_run"`
}
if r.Body != nil {
_ = json.NewDecoder(r.Body).Decode(&req) // 空 body 也合法(用默认)
@@ -102,6 +103,16 @@ func (pa *PatternAPI) Detect(w http.ResponseWriter, r *http.Request) {
}
}
pats := pattern.DetectFromRecords(distilled, pa.Cfg)
// t_639c5a03 dry-run:{"dry_run":true} 只返回不写库(验证判据用)。
if req.DryRun {
respond(w, 200, map[string]interface{}{
"dry_run": true,
"scanned": len(records),
"detected": len(pats),
"patterns": pats,
})
return
}
created, updated, uerr := pa.Store.UpsertPatterns(pats)
if uerr != nil {
respondError(w, 500, "upsert patterns: "+uerr.Error())
@@ -0,0 +1,67 @@
package routes
// 2026-10-26(P1-1):supersede / 版本字段必须真的出现在 /recall 响应里。
// 用同包 fakeLanceDB 构造一条「已被取代 + 有效期已过 + 版本 2」的记录:
// - 这是「写侧有、读侧看不见」的复发点(t_4c810e02「加列不接 IPC=静默 no-op」同款坑);
// - 判据只看 JSON 响应键值,不看结构体注释。
import (
"testing"
"github.com/xiaoxue/memoryweave/internal/models"
)
func TestRecall_SupersedeAndValidityFieldsExposed(t *testing.T) {
db := &fakeLanceDB{searchResults: []models.MemoryRecord{
{
ID: "mem_old_fact",
Content: "织忆默认 stale 阈值是 720 小时(旧事实)",
Category: "distilled",
Version: 2,
ValidFrom: "2026-10-26T10:00:00+08:00",
ValidTo: "2026-10-26T11:00:00+08:00",
SupersededBy: []string{"mem_new_fact"},
},
{ID: "mem_fresh", Content: "织忆默认 stale 阈值是 720 小时(现行事实)", Category: "distilled"},
}}
code, m, body := recallBody(t, db, `{"query":"stale 阈值多少小时","limit":5,"namespace":"hermes-main"}`)
if code != 200 {
t.Fatalf("expected 200, got %d; body=%s", code, body)
}
results, _ := m["results"].([]interface{})
if len(results) == 0 {
t.Fatalf("无结果,无法验证字段:%s", body)
}
var found map[string]interface{}
for _, raw := range results {
r, _ := raw.(map[string]interface{})
if r["id"] == "mem_old_fact" {
found = r
break
}
}
if found == nil {
t.Fatalf("目标记录未被召回(fake DB 应原样返回),body=%s", body)
}
if found["superseded_by"] == nil {
t.Fatalf("superseded_by 未暴露给读侧:%v", found)
}
if found["superseded"] != true {
t.Fatalf("superseded 便捷布尔应为 true:%v", found["superseded"])
}
if found["generation"] != float64(2) {
t.Fatalf("generation 应为 2:%v", found["generation"])
}
if found["valid_to"] != "2026-10-26T11:00:00+08:00" {
t.Fatalf("valid_to 未直通:%v", found["valid_to"])
}
if found["channel"] == nil || found["channel"] == "" {
t.Fatalf("channel 未暴露:%v", found)
}
if _, ok := m["trace_id"]; !ok {
t.Fatalf("响应缺 trace_id:%s", body)
}
if m["status"] != "complete" {
t.Fatalf("正常召回 status 应为 complete,实际 %v", m["status"])
}
}
@@ -0,0 +1,115 @@
package routes
// 2026-10-26(P1-3):stale-return + 生命周期 fixture(10:00 生效 / 11:00 失效)。
//
// 评审缺口:「只有离线检索指标,没有 stale-return、没有生命周期 fixture」。
// 本测试把三条读侧生命周期行为锁成机械判据:
// ① bi-temporal 开启时,valid_to 已过的事实**不进 top-k**(但仍在库,non-lossy);
// ② valid_to 在未来 / 为空 ⇒ 正常返回(同一 fixture 的对照,证明不是「整类被吞」);
// ③ updated_at 超 720h ⇒ 响应 stale=true;新记录不输出该键。
//
// 时窗用真实语义:fixture 事实 10:00 生效、11:00 失效,查询时点取「当日 11:00 之后」。
import (
"testing"
"time"
"github.com/xiaoxue/memoryweave/internal/models"
)
// 时窗用**相对时间**:曾经写成 dayAt(10,0)/dayAt(11,0) 假设「跑测试时已是晚上 20:00」,
// 凌晨 02:07 跑就是 flaky —— 11:00 还没到,「已失效」的 fixture 其实仍有效。
// 生命周期判据必须与运行时刻无关:过期 = now-1h,有效期 = now+24h。
func expiredPair() (from, to, future string) {
now := time.Now()
return now.Add(-2 * time.Hour).Format(time.RFC3339),
now.Add(-1 * time.Hour).Format(time.RFC3339),
now.Add(24 * time.Hour).Format(time.RFC3339)
}
func idsOf(m map[string]interface{}) map[string]bool {
out := map[string]bool{}
if res, ok := m["results"].([]interface{}); ok {
for _, raw := range res {
if r, ok := raw.(map[string]interface{}); ok {
if id, ok := r["id"].(string); ok {
out[id] = true
}
}
}
}
return out
}
func TestRecall_Lifecycle_ExpiredFactDroppedWhenBitemporal(t *testing.T) {
from, to, future := expiredPair()
db := &fakeLanceDB{searchResults: []models.MemoryRecord{
{ID: "mem_expired_window", Content: "生命周期 fixture:10:00 生效 11:00 失效的事实",
Category: "distilled", ValidFrom: from, ValidTo: to},
{ID: "mem_still_valid", Content: "生命周期 fixture:仍在有效期内的事实",
Category: "distilled", ValidFrom: from, ValidTo: future},
}}
t.Setenv("ZHIYI_BITEMPORAL", "1")
code, m, body := recallBody(t, db, `{"query":"生命周期 fixture 有效期","limit":5,"namespace":"hermes-main"}`)
if code != 200 {
t.Fatalf("expected 200, got %d; body=%s", code, body)
}
ids := idsOf(m)
if ids["mem_expired_window"] {
t.Fatalf("11:00 已失效的事实不应进 top-k(bi-temporal 开);body=%s", body)
}
if !ids["mem_still_valid"] {
t.Fatalf("有效期未过的事实必须正常返回(对照缺失 ⇒ 可能是整类被吞);body=%s", body)
}
}
func TestRecall_Lifecycle_ExpiredFactVisibleWhenBitemporalOff(t *testing.T) {
// 开关关(默认)⇒ 行为零变化:过期事实照常返回,但 valid_to 字段可见(读侧能自己判)。
from, to, _ := expiredPair()
db := &fakeLanceDB{searchResults: []models.MemoryRecord{
{ID: "mem_expired_visible", Content: "生命周期 fixture:过期但可见", Category: "distilled",
ValidFrom: from, ValidTo: to},
}}
t.Setenv("ZHIYI_BITEMPORAL", "0")
code, m, body := recallBody(t, db, `{"query":"过期但可见","limit":5,"namespace":"hermes-main"}`)
if code != 200 {
t.Fatalf("expected 200, got %d; body=%s", code, body)
}
if !idsOf(m)["mem_expired_visible"] {
t.Fatalf("bi-temporal 关时过期事实应照常返回(non-lossy 可见性);body=%s", body)
}
var found map[string]interface{}
for _, raw := range m["results"].([]interface{}) {
if r, ok := raw.(map[string]interface{}); ok && r["id"] == "mem_expired_visible" {
found = r
}
}
if found == nil || found["valid_to"] != to {
t.Fatalf("valid_to 必须直通给读侧:%v", found)
}
}
func TestRecall_StaleReturn_DefaultThreshold(t *testing.T) {
old := time.Now().Add(-31 * 24 * time.Hour)
db := &fakeLanceDB{searchResults: []models.MemoryRecord{
{ID: "mem_old", Content: "陈年事实(31 天未更新)", Category: "distilled", UpdatedAt: old},
{ID: "mem_new", Content: "新鲜事实(今天更新)", Category: "distilled", UpdatedAt: time.Now()},
}}
code, m, body := recallBody(t, db, `{"query":"事实 新鲜度","limit":5,"namespace":"hermes-main"}`)
if code != 200 {
t.Fatalf("expected 200, got %d; body=%s", code, body)
}
got := map[string]map[string]interface{}{}
for _, raw := range m["results"].([]interface{}) {
if r, ok := raw.(map[string]interface{}); ok {
got[r["id"].(string)] = r
}
}
if got["mem_old"] == nil || got["mem_old"]["stale"] != true {
t.Fatalf("31 天未更新的记忆应 stale=true:%v", got["mem_old"])
}
if _, ok := got["mem_new"]["stale"]; ok {
t.Fatalf("新记忆不应输出 stale 键:%v", got["mem_new"])
}
}
+327
View File
@@ -0,0 +1,327 @@
package routes
// 织忆自检端点(2026-10-26,牧尘批「三档」)
//
// 背景:织忆相关的定时自检脚本有 ~20 个,取数方式三套并存——
// ① 直连生产库 lancedb.connect("/var/lib/memoryweave")(绕过服务、schema 一改就静默腐烂)
// ② IPC socket 自定义协议
// ③ HTTP /api/v1/{stats,popwatch,metrics/self}
// 三套契约对同一个库,schema 变更时分开损坏。典型事故:pattern 自检脚本写死
// type='pattern',库里实际是 pattern_stat ⇒ 连续数月静默 0 输出。
//
// 本端点是**唯一取数契约**:把「读织忆内部状态」收敛成一份结构化 JSON。
// 设计边界(不做什么):
// - 不做判定/告警/投递 —— 阈值、渠道、评审留在外部 cron(观测者不能住在被观测者体内:
// zhiyid 一挂自检就跟着哑,内外同时沉默是更坏的故障)。
// - 不跑重活(eval 全量、备份、vacuum)—— 那些抢生产资源,留在 systemd-run 独立 scope。
//
// level 语义:
// - cheap(默认,<50ms):只读内存态/计数,不扫全表。
// - full(秒级):额外扫全表算碎片率/冗余率/近 7 天质量/写量分布(原 quality-watch 口径)。
//
// 兼容铁律:本端点返回的比率一律给「分子/分母/样本是否足够」,缺数据不返回 0
// (0 会被下游读成"很好")。沿用 selfoptimize 的 metricNA(-1) 语义。
import (
"encoding/json"
"fmt"
"math"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/xiaoxue/memoryweave/internal/distill"
"github.com/xiaoxue/memoryweave/internal/selfoptimize"
"github.com/xiaoxue/memoryweave/internal/storage"
)
// SelfcheckSchemaVersion 变更返回结构时递增;消费方据此判兼容。
const SelfcheckSchemaVersion = "1"
// 与 zhiyi-quality-watch.py 同口径(该脚本已改为读本端点,口径只此一处)
const (
selfcheckFragLen = 20 // <20 字符 = 碎片
selfcheckDupMinSample = 5000 // 冗余率最小样本(活行),不足只报样本不判率
selfcheckScanLimit = 300000
)
type selfcheckQuality struct {
TotalRows int `json:"total_rows"`
LiveRows int `json:"live_rows"`
ScannedRows int `json:"scanned_rows"`
ScanTruncated bool `json:"scan_truncated"`
Coverage float64 `json:"coverage"` // 扫描行 / store 真实总量;<1 表示结果不完整,禁止据此下结论
FragLen int `json:"frag_len"`
FragRowsAll int `json:"frag_rows_all"`
FragRateAll float64 `json:"frag_rate_all"`
FragRowsLive int `json:"frag_rows_live"`
FragRateLive float64 `json:"frag_rate_live"`
DupGroups int `json:"dup_groups"`
DupExtraRows int `json:"dup_extra_rows"`
DupRate float64 `json:"dup_rate"`
DupSampleOK bool `json:"dup_sample_ok"`
NewRows7d int `json:"new_rows_7d"`
NewFragRows7d int `json:"new_frag_rows_7d"`
NewFragRate7d *float64 `json:"new_frag_rate_7d"` // nil = 近 7 天无新增(无分母)
}
type selfcheckDayCount struct {
Date string `json:"date"`
Rows int `json:"rows"`
Live int `json:"live"`
}
type selfcheckWriteVolume struct {
Days []selfcheckDayCount `json:"days"`
Today int `json:"today"`
Last7d int `json:"last_7d"`
Median7d float64 `json:"median_7d"`
P25_7d float64 `json:"p25_7d"`
}
// Selfcheck GET /api/v1/selfcheck?level=cheap|full
func (a *API) Selfcheck(w http.ResponseWriter, r *http.Request) {
start := time.Now()
if r.Method != http.MethodGet {
respondError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
level := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("level")))
if level == "" {
level = "cheap"
}
if level != "cheap" && level != "full" {
respondError(w, http.StatusBadRequest, "level must be cheap|full")
return
}
rep := map[string]interface{}{
"schema_version": SelfcheckSchemaVersion,
"checked_at": time.Now().Format(time.RFC3339),
"level": level,
}
degraded := []string{}
// ── 存储总量 ───────────────────────────────
if st, err := a.LanceDB.Stats(); err != nil {
degraded = append(degraded, "store: "+err.Error())
} else {
total := numOf(st["total_memories"])
tomb := numOf(st["tombstone_count"])
live := total - tomb
if live < 0 {
live = 0
}
store := map[string]interface{}{
"backend": st["backend"],
"data_dir": st["data_dir"],
"total_memories": total,
"total_episodes": numOf(st["total_episodes"]),
"tombstone_count": tomb,
"live_memories": live,
}
if total > 0 {
store["tombstone_rate"] = round4(tomb / total)
} else {
store["tombstone_rate"] = -1.0 // N/A:空库无分母
}
rep["store"] = store
}
// ── 图谱 ───────────────────────────────────
if a.GraphStore != nil {
n, e, d := a.GraphStore.Stats()
rep["graph"] = map[string]interface{}{
"node_count": n,
"edge_count": e,
"density": round4(d),
}
} else {
degraded = append(degraded, "graph: store not initialised")
}
// ── pattern 召回的两侧健康(2026-10-26 新增)──────────
// 事故背景:`POST /api/v1/patterns/detect` 是短命单发端点,全库 0 cron、0 调用方,
// 于是 graph_nodes(type='pattern') 恒 0 行、recall 恒 0 命中,而日志层面完全沉默
// (只能靠人翻 7 天日志才发现「实现了 ≠ 出过数」)。这里把「写入端有没有产数」
// 变成一次 GET 就能读到的事实:rows=0 ⇒ 生产者坏了或从未接线,别去查读侧 SQL。
rep["pattern"] = patternHealth(a)
// ── 面板指标(反馈/蒸馏/gap/cascade,含 _valid/_samples 语义)──
rep["dashboard"] = selfoptimize.Dash.Metrics()
// ── 整合链路(内存态,<1ms)────────────────
tracker := GetConsolTracker()
consolStatus, consolFails, sinceSuccess, lastErr := tracker.HealthStatus()
_, consolSkips, lastSkip, _ := tracker.Snapshot()
rep["consolidate"] = map[string]interface{}{
"status": consolStatus,
"consec_failures": consolFails,
"skipped": consolSkips,
"last_skip": lastSkip,
"last_success_age": sinceSuccess.String(),
"last_error": lastErr,
}
// ── 缓存 / FTS ─────────────────────────────
cache := map[string]interface{}{"fts_index": storage.FtsIndexStats()}
if storage.SearchCacheInstance != nil {
cache["search_cache"] = storage.SearchCacheInstance.Stats()
}
rep["cache"] = cache
// ── 空壳拦截器(t_e11207f8 取证)───────────
checks, blocks, loaded, rulesPath := distill.PayloadGuardStats()
rep["payload_guard"] = map[string]interface{}{
"checks": checks,
"blocks": blocks,
"loaded": loaded,
"rules_path": rulesPath,
}
// ── 全量质量段(level=full):读卫星快照,不在本进程扫全表 ────────
// 设计:全表扫描是重活(4 万行 × 内容),且 daemon 还有 recall 要跑。
// 取数由仓库内 tools/selfcheck_full.py 完成(列投影只读,1.7s / 250MB,独立进程),
// 写 ~/.hermes/state/zhiyi-quality.json;本端点只读快照并标注新鲜度。
// 这样"取数"只有一处实现(跟 schema 同仓),"判定/告警"仍在外部 cron。
if level == "full" {
snap, err := loadQualitySnapshot()
if err != nil {
degraded = append(degraded, "quality: "+err.Error())
} else {
rep["quality"] = snap.Quality
rep["write_volume"] = snap.WriteVolume
rep["quality_as_of"] = snap.GeneratedAt
rep["quality_age_s"] = snap.AgeSeconds()
rep["quality_stale"] = snap.AgeSeconds() > qualityStaleAfter
}
}
if len(degraded) > 0 {
rep["degraded"] = degraded
}
rep["duration_ms"] = time.Since(start).Milliseconds()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(rep)
}
// qualityStaleAfter:快照超过这个时长未刷新 ⇒ 标记 stale(消费方不得据此下结论)。
const qualityStaleAfter = 6 * 3600
// qualitySnapshot 对应仓库内 tools/selfcheck_full.py 的产出。
// 字段名与 python 侧严格一致(schema_version=1)。
type qualitySnapshot struct {
SchemaVersion int `json:"schema_version"`
GeneratedAt string `json:"generated_at"`
Source struct {
DB string `json:"db"`
Table string `json:"table"`
Mode string `json:"mode"`
} `json:"source"`
DurationMS int `json:"duration_ms"`
Quality json.RawMessage `json:"quality"`
WriteVolume json.RawMessage `json:"write_volume"`
fileModTime time.Time
}
func (q *qualitySnapshot) AgeSeconds() int64 {
if q.fileModTime.IsZero() {
return -1
}
return int64(time.Since(q.fileModTime).Seconds())
}
// QualitySnapshotPath 允许用 env 覆盖(测试/灰度)。
func QualitySnapshotPath() string {
if p := strings.TrimSpace(os.Getenv("ZHIYI_QUALITY_SNAPSHOT")); p != "" {
return p
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".hermes", "state", "zhiyi-quality.json")
}
func loadQualitySnapshot() (*qualitySnapshot, error) {
p := QualitySnapshotPath()
fi, err := os.Stat(p)
if err != nil {
return nil, fmt.Errorf("快照不存在(先跑 tools/selfcheck_full.py):%w", err)
}
raw, err := os.ReadFile(p)
if err != nil {
return nil, err
}
var snap qualitySnapshot
if err := json.Unmarshal(raw, &snap); err != nil {
return nil, fmt.Errorf("快照解析失败: %w", err)
}
snap.fileModTime = fi.ModTime()
return &snap, nil
}
// numOf 把 stats 里可能是 float64/int/json.Number 的数值统一成 float64。
func numOf(v interface{}) float64 {
switch x := v.(type) {
case float64:
return x
case float32:
return float64(x)
case int:
return float64(x)
case int64:
return float64(x)
case json.Number:
f, _ := x.Float64()
return f
default:
return 0
}
}
func round4(f float64) float64 {
if math.IsNaN(f) || math.IsInf(f, 0) {
return -1
}
return math.Round(f*10000) / 10000
}
// patternHealth 一眼判定 pattern 召回是「有生产者」还是「恒 0」。
//
// 为什么需要它:2026-10-26 排查发现 pattern 召回 0 命中,真根因是写入端从未产数
// (detect 端点无 cron、无调用方),但表面症状看起来像读侧 SQL bug —— 白查一轮方向。
// 判据三类:
//
// producing 有行、有 id、有生产时间 → 读侧再 0 命中就该查 Match 阈值/properties;
// zero_output 0 行 → 生产端问题,别去动读侧 SQL;
// no_id 有行但 id 全空 → store.ListPatterns 会全 skip(存量冻结的典型形态)。
func patternHealth(a *API) map[string]interface{} {
out := map[string]interface{}{
"rows": -1, // -1 = 未知,别当 0 用
"rows_with_id": -1,
"last_produced_at": "",
"verdict": "unknown",
}
if a == nil || a.GraphStore == nil {
out["verdict"] = "no_graph_store"
return out
}
rows, withID, last := a.GraphStore.CountNodesByType("pattern")
out["rows"], out["rows_with_id"], out["last_produced_at"] = rows, withID, last
switch {
case rows == 0:
out["verdict"] = "zero_output"
case withID == 0:
out["verdict"] = "no_id"
case last == "":
out["verdict"] = "unknown_age"
default:
out["verdict"] = "producing"
}
return out
}
+99
View File
@@ -0,0 +1,99 @@
package routes
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
)
// 快照契约测试:/api/v1/selfcheck?level=full 依赖 tools/selfcheck_full.py 的产出格式。
// 这里钉住 schema_version 与关键字段,防止 python 侧改字段名后 Go 侧静默返回空。
func TestLoadQualitySnapshot(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "zhiyi-quality.json")
body := `{
"schema_version": 1,
"generated_at": "2026-10-26T13:20:00+0800",
"source": {"db": "/var/lib/memoryweave", "table": "memories", "mode": "read_only_projection"},
"duration_ms": 1680,
"quality": {
"frag_len": 20, "total_rows": 39957, "live_rows": 39836,
"frag_rows_all": 313, "frag_rate_all": 0.0078,
"frag_rows_live": 313, "frag_rate_live": 0.0079,
"dup_groups": 34, "dup_extra_rows": 103, "dup_rate": 0.0026,
"new_rows_7d": 1176, "new_frag_rows_7d": 35, "new_frag_rate_7d": 0.0298
},
"write_volume": {
"days": [{"date": "2026-10-25", "rows": 1473, "live": 1471}],
"today": 332, "today_date": "2026-10-26", "last_7d": 7706, "nonzero_days": 60
}
}`
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
t.Setenv("ZHIYI_QUALITY_SNAPSHOT", p)
snap, err := loadQualitySnapshot()
if err != nil {
t.Fatalf("loadQualitySnapshot: %v", err)
}
if snap.SchemaVersion != 1 {
t.Fatalf("schema_version=%d want 1", snap.SchemaVersion)
}
var q struct {
TotalRows int `json:"total_rows"`
LiveRows int `json:"live_rows"`
DupRate float64 `json:"dup_rate"`
FragRowsAll int `json:"frag_rows_all"`
}
if err := json.Unmarshal(snap.Quality, &q); err != nil {
t.Fatalf("quality 段解析失败: %v", err)
}
if q.TotalRows != 39957 || q.LiveRows != 39836 {
t.Fatalf("quality 口径不符: %+v", q)
}
if q.FragRowsAll != 313 || q.DupRate != 0.0026 {
t.Fatalf("quality 数值不符: %+v", q)
}
var wv struct {
Today int `json:"today"`
Days []struct {
Date string `json:"date"`
Live int `json:"live"`
} `json:"days"`
}
if err := json.Unmarshal(snap.WriteVolume, &wv); err != nil {
t.Fatalf("write_volume 段解析失败: %v", err)
}
if wv.Today != 332 || len(wv.Days) != 1 || wv.Days[0].Live != 1471 {
t.Fatalf("write_volume 不符: %+v", wv)
}
// 新鲜度:刚写的文件 age 应 < 10s;人为改旧 → stale 判定为真
if age := snap.AgeSeconds(); age < 0 || age > 10 {
t.Fatalf("AgeSeconds=%d,本应接近 0", age)
}
old := time.Now().Add(-8 * time.Hour)
if err := os.Chtimes(p, old, old); err != nil {
t.Fatal(err)
}
snap2, err := loadQualitySnapshot()
if err != nil {
t.Fatal(err)
}
if snap2.AgeSeconds() <= qualityStaleAfter {
t.Fatalf("8h 前的快照应判 stale(>%d 秒),得到 %d", qualityStaleAfter, snap2.AgeSeconds())
}
}
// 快照缺失 ⇒ 必须报错(由 handler 转成 degraded),不能静默返回零值。
func TestLoadQualitySnapshotMissing(t *testing.T) {
t.Setenv("ZHIYI_QUALITY_SNAPSHOT", filepath.Join(t.TempDir(), "nope.json"))
if _, err := loadQualitySnapshot(); err == nil {
t.Fatal("缺失快照应返回 error")
}
}
+7 -6
View File
@@ -16,7 +16,7 @@ func standingStore() *storage.StandingStore { return storage.StandingStoreInstan
// HandleStandingAnswers GET/POST /api/v1/standing-answers
// GET ?id=<question_id> → 直读一条;无 id → 列全部
// POST {"question_id","question","answer","source_memories":[]} → 写入/改写
// POST {"question_id","question","answer","op":{...},"source_memories":[]} → 写入/改写
func HandleStandingAnswers(w http.ResponseWriter, r *http.Request) {
s := standingStore()
if s == nil {
@@ -47,10 +47,11 @@ func HandleStandingAnswers(w http.ResponseWriter, r *http.Request) {
respond(w, http.StatusOK, a)
case http.MethodPost:
var in struct {
QuestionID string `json:"question_id"`
Question string `json:"question"`
Answer string `json:"answer"`
SourceMemories []string `json:"source_memories"`
QuestionID string `json:"question_id"`
Question string `json:"question"`
Answer string `json:"answer"`
Op *storage.OpCard `json:"op,omitempty"`
SourceMemories []string `json:"source_memories"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
respondError(w, http.StatusBadRequest, "bad json: "+err.Error())
@@ -60,7 +61,7 @@ func HandleStandingAnswers(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusBadRequest, "question_id and answer required")
return
}
a, err := s.Upsert(in.QuestionID, in.Question, in.Answer, in.SourceMemories)
a, err := s.Upsert(in.QuestionID, in.Question, in.Answer, in.SourceMemories, in.Op)
if err != nil {
respondError(w, http.StatusInternalServerError, err.Error())
return
+49
View File
@@ -0,0 +1,49 @@
// 织忆 MemoryWeave — 召回链路追踪端点(P0-1,2026-10-26)
//
// GET /api/v1/trace/<trace_id>
//
// 返回该次召回的:各阶段耗时(encode/coarse_search/fts_recall/bm25/rerank_fusion/mmr_select/
// graph_expand/dedupe)、各通道贡献条数(channels)、每条结果的来源归因(items)、
// status/partial_reason、scope、cache_hit/truncated。
//
// 数据来源:内存环形缓冲(最近 2000 条)+ JSONL 落盘(/var/lib/memoryweave/traces/),
// 服务重启后仍可按 trace_id 回溯(落盘扫描)。
package routes
import (
"context"
"net/http"
"strings"
"time"
"github.com/xiaoxue/memoryweave/internal/storage"
)
// ── P0-3 召回硬超时:取消函数类型 + context.WithTimeout 包装 ──────────────
// core.go Recall() 在 req.DeadlineMS>0 时需要带类型标记的取消函数,以便 defer 取消。
// 2026-10-26 (t_e98cb1d6):此处补齐使 routes 包可编译(否则 routes 全包不可构建,
// capRecallResults 的单测无法跑通)。这是缺失的声明,不是功能实现;P0-3 的 trace 完整
// 接线(tr.Stage 埋点、tr 落盘)仍待 P0-3 本任务补齐。
type ctxDeadlineFunc context.CancelFunc
func contextWithTimeout(ctx context.Context, timeout time.Duration) (context.Context, ctxDeadlineFunc) {
c, cancel := context.WithTimeout(ctx, timeout)
return c, ctxDeadlineFunc(cancel)
}
// Trace 查询单条召回链路。路径形如 /api/v1/trace/tr_xxxx。
func (a *API) Trace(w http.ResponseWriter, r *http.Request) {
id := strings.TrimSpace(strings.TrimPrefix(r.URL.Path, "/api/v1/trace/"))
if id == "" {
respondErrorSchema(w, 400, "trace_id_required", "用法:GET /api/v1/trace/<trace_id>", 0)
return
}
tr, ok := storage.GetTrace(id)
if !ok {
// 404 与「trace 过期/被环形缓冲覆盖」同义:调用方据此走「重新召回」而不是当成系统故障。
respondErrorSchema(w, 404, "trace_not_found",
"trace 不存在或已被环形缓冲覆盖(内存保留最近 2000 条,落盘按天分文件)", 0)
return
}
respond(w, 200, tr)
}
@@ -80,10 +80,14 @@ func (m *mockLance) IncrementUseful(id string) error { r
func (m *mockLance) IncrementNotUseful(id string) error { return nil }
// GA-P0-1 (t_718d8707):拆分字段
func (m *mockLance) IncrementHumanNotUseful(id string) error { return nil }
func (m *mockLance) AppendHitEvent(memoryID, outcome, reason string) error { return nil }
func (m *mockLance) CountHitEvents(memoryID, outcome string, since time.Time) (int, error) { return 0, nil }
func (m *mockLance) GetHitEvents(memoryID string, limit int) ([]models.HitEvent, error) { return nil, nil }
func (m *mockLance) IncrementHumanNotUseful(id string) error { return nil }
func (m *mockLance) AppendHitEvent(memoryID, outcome, reason string) error { return nil }
func (m *mockLance) CountHitEvents(memoryID, outcome string, since time.Time) (int, error) {
return 0, nil
}
func (m *mockLance) GetHitEvents(memoryID string, limit int) ([]models.HitEvent, error) {
return nil, nil
}
func (m *mockLance) IncrementDedupDownweight(id string) error { return nil }
func (m *mockLance) UpdateMemoryContent(id, newContent, source string) error { return nil }
func (m *mockLance) InsertMemory(rec models.MemoryRecord) error { return nil }
@@ -116,6 +120,7 @@ func (mockGraph) ListNodesByType(nodeType string, namespace string) []map[string
}
func (mockGraph) ListNodes(namespace string) []map[string]interface{} { return nil }
func (mockGraph) Stats() (int, int, float64) { return 0, 0, 0 }
func (mockGraph) CountNodesByType(string) (int, int, string) { return 0, 0, "" }
func (mockGraph) Prune(minWeight float64) {}
func (mockGraph) ExpandFromResults(results []models.RecallResult, namespace string, maxHops int) []models.RecallResult {
return results
+8 -1
View File
@@ -187,7 +187,8 @@ func NewServer() http.Handler {
// ─── t_d32e5f8a L2 模式检测(MemOS 血统)─────────────────────────
// 复活 L1→L2 pattern 蒸馏(原 Python daemon main_loop 退役后停摆):
// pattern 节点存 graph_nodes(type='pattern'),与现有 85 存量同表,不另开新表。
// pattern 节点存 graph_nodes(type='pattern'),与旧 daemon 残留的 85 行
// type='pattern_stat' 同表但不同类,不另开新表。
// modernc sqlite 短命单发开/关(UpsertPatterns/ListPatterns 开一次用一次即关),
// 不持长连接、不碰 state.db —— 规避当年退役根因。
// 召回注入 env 门控 ZHIYI_PATTERN_RECALL,默认 0 ⇒ 不注册 Provider,行为零变化。
@@ -602,6 +603,8 @@ func NewServer() http.Handler {
})
mux.HandleFunc("/api/v1/recall", api.Recall)
mux.HandleFunc("/api/v1/recall/debug", api.RecallDebug)
// 2026-10-26 (P0-1 t_trace_p0):召回链路追踪回溯(trace_id 贯穿各阶段/各通道)
mux.HandleFunc("/api/v1/trace/", api.Trace)
// t_23b6a940 AC7 热门度看门狗快照(report-only,传感器端读它转既有告警通道)
mux.HandleFunc("/api/v1/popwatch", api.PopWatch)
// 2026-10-13 (t_e9a4d18f P2) 常设答案直读/写入
@@ -613,6 +616,10 @@ func NewServer() http.Handler {
// 反馈统一走 /api/v1/feedback/{useful,not-useful,deprecate,correct}(feedback.go,写拆分后新字段)。
mux.HandleFunc("/api/v1/bootstrap", api.Bootstrap)
mux.HandleFunc("/api/v1/stats", api.Stats)
// 2026-10-26(牧尘批「三档」):自检统一取数端点。
// 唯一契约 —— 自检脚本一律读这里,禁止再 lancedb.connect() 直连生产库。
// ?level=cheap(默认,内存态计数)| full(+读 tools/selfcheck_full.py 的全量快照:碎片/冗余/近7天质量/写量)
mux.HandleFunc("/api/v1/selfcheck", api.Selfcheck)
mux.HandleFunc("/api/v1/batch-commit", api.BatchCommit)
// 主症A(t_fa6c9104):显式写入直落 memories 表(嵌入 + 立刻可召回)。
// 纯新增路由:commit/recall/蒸馏路径零改动;旧服务端 404 时插件自动回落 commit。
+12
View File
@@ -620,6 +620,18 @@ func (e *Engine) distillOne(input DistillInput) DistillResult {
}
}
// t_3636b872 阶段4:程序性步骤判据 —— how-to/运维类内容的蒸馏答案必须含可执行步骤,
// 否则降级为 observe(当前 lane 若为 keep 则改写 lane 记录;事实本身不丢)。
// 影子模式默认只记不真降(proceduralShadowMode),先观察命中分布再决定是否真拦。
if demote, why := ProceduralStepVerdict(input.Content, facts); demote {
prevLane := lane
if lane != LaneObserve {
lane = LaneObserve
}
log.Printf("[distill][PROCEDURAL] episode=%s lane=%s→%s reason=%s content=%q facts=%d",
input.EpisodeID, prevLane, lane, why, truncate(input.Content, 60), len(facts))
}
// P4: 生成 AAAK 压缩索引(每条事实的紧凑摘要,供召回快速定位)
index := buildAAAKIndex(facts, entities, overall)
if len(index) > 0 {
+118
View File
@@ -0,0 +1,118 @@
// 织忆 MemoryWeave — 程序性步骤判据(kanban t_3636b872 阶段4)
//
// 目标:蒸馏打分里加「答案必须含步骤,否则降级为 observe lane」,防止 how-to/运维
// 拆解又产出纯元描述("结论:…""依据:…"这类无命令/无路径/无序号的内容)。
//
// 背景(t_3636b872 库形态硬实测):content 长度中位数 35 字,83% <60 字,仅 10.2% 含
// 可执行内容;「state.db 怎么修 / 网关怎么重启 / 磁盘怎么清」top5 全是元描述。原因不是
// 检索层,是**蒸馏产出不带步骤形态** ⇒ 需要一道确定性闸:how-to 类内容允许 keep 入库的
// 前提是它的答案真的含可执行步骤。
//
// 设计(延续准入闸门哲学 admission_lane.go):
// - 纯确定性、零 LLM 调用(同一输入永远同一结果,可单测)。
// - 只对「how-to / 操作 / 修复 / 排查 / 重启」类**触发词命中**的内容做校验——普通
// 陈述事实("用户重启了服务器")不受影响。
// - 触发词命中但事实里没有可执行步骤 ⇒ 降级为 observe(不确定带,永不因分数丢弃)。
// 降级后该条仍走 observe 通道(保留,可被后续消费/降权淘汰),只是不从 keep 直接
// 立库 —— 代价可逆,符合「drop 不可逆、observe 可逆」的分诊原则。
// - shadow 模式默认开(ZHIYI_PROCEDURAL_SHADOW=0 关):只写日志不真降级,先观察命中
// 分布再决定是否真拦(与 score 影子/对抗审计同款保守节奏)。
//
// 可执行步骤判定(HasExecutableSteps):
// 含命令/路径/序号操作符/端口/显式动词短语之一即算有步骤——宁可宽(多留可逆),
// 不错杀真实步骤描述。
package distill
import (
"os"
"strings"
)
// proceduralHowToMarkers how-to / 操作类触发词(命中 = 该内容是"What-to-do 怎么做 X")。
// 覆盖真实问法("X 挂了怎么修""怎么重启 X""X 排查步骤"),不是术语白名单。
var proceduralHowToMarkers = []string{
"怎么修", "怎么查", "怎么办", "怎么重启", "怎么清", "怎么做", "怎么处理", "怎么排查",
"如何修", "如何排查", "如何重启", "如何清", "如何做",
"修复步骤", "排查步骤", "操作步骤", "解决步骤", "重启步骤",
"该怎么", "该怎么办", "咋修", "咋办",
"how to", "howto", "troubleshoot", "fixing", "debug",
}
// proceduralOperationMarkers 更强的「操作序列」信号(与普通陈述区分)。
var proceduralOperationMarkers = []string{
"步骤", "先用", "再", "然后", "最后", "依次",
"systemctl", "curl", "grep", "sqlite3", "journalctl", "pgrep", "cp -a",
"install", "rm ", "mv ", "df -h", "du -", "python3",
}
// executableStepMarkers 事实里含「可执行步骤」的证据。
var executableStepMarkers = []string{
"`", "$(", "systemctl", "curl ", "grep ", "sqlite3", "journalctl",
"pgrep", "cp ", "mv ", "rm ", "install ", "df -h", "du -x", "python3 ",
"①", "②", "③", "④", "⑤", "第一步", "第二步", "步骤1", "步骤2", "先", "然后", "最后",
}
// proceduralShadowMode 影子模式:true(默认)= 只记录该降级,不真降级。
// 设 ZHIYI_PROCEDURAL_SHADOW=0 关闭影子(= 真降级到 observe)。
var proceduralShadowMode = os.Getenv("ZHIYI_PROCEDURAL_SHADOW") != "0"
// IsHowToContent 判断 content 是否命中 how-to / 操作类触发词。
func IsHowToContent(content string) bool {
s := strings.ToLower(strings.TrimSpace(content))
if s == "" {
return false
}
for _, m := range proceduralHowToMarkers {
if strings.Contains(s, m) {
return true
}
}
return false
}
// IsOperationContent 判断 content 是否带更强的操作序列信号(可选,更严的触发面)。
func IsOperationContent(content string) bool {
s := strings.ToLower(strings.TrimSpace(content))
for _, m := range proceduralOperationMarkers {
if strings.Contains(s, m) {
return true
}
}
return false
}
// HasExecutableSteps 判断蒸馏产出事实里是否含可执行步骤证据。
// 只要任一 fact 含任一 marker 即算有步骤(AND 于 fact 内,OR 于 marker)。
func HasExecutableSteps(facts []string) bool {
for _, f := range facts {
fl := strings.ToLower(f)
for _, m := range executableStepMarkers {
if strings.Contains(fl, m) {
return true
}
}
}
return false
}
// ProceduralStepVerdict 对一条 how-to 内容的蒸馏产出做步骤判据。
// 返回是否应降级 observe(含影子开关判定)与人类可读原因。
//
// 语义:
// - 非 how-to 内容(IsHowToContent=false)⇒ 不涉此判据,放行(false)。
// - how-to 命中且已含步骤 ⇒ 放行(false)。
// - how-to 命中但事实无步骤 ⇒ 判「应降级 observe」(true);shadow 开则仅记日志。
func ProceduralStepVerdict(content string, facts []string) (demote bool, reason string) {
if !IsHowToContent(content) {
return false, ""
}
if HasExecutableSteps(facts) {
return false, "how-to 答案含可执行步骤"
}
reason = "how-to 内容但事实无步骤 → 降级 observe"
if proceduralShadowMode {
reason += "(shadow,仅记录)"
}
return true, reason
}
@@ -0,0 +1,92 @@
package distill
import "testing"
// TestProceduralStepVerdict_HowToNoSteps 核心判据:how-to 内容但事实无步骤 → 应降级 observe。
func TestProceduralStepVerdict_HowToNoSteps(t *testing.T) {
// 保存影子模式并复位(测试里显式关影子,保证断言的是真实降级语义)
orig := proceduralShadowMode
proceduralShadowMode = false
defer func() { proceduralShadowMode = orig }()
cases := []struct {
name string
content string
facts []string
want bool // demote?
}{
{
name: "how-to 内容但答案是纯元描述 → 降级",
content: "state.db 怎么修",
facts: []string{"结论:数据库损坏需要修复", "依据:损坏会导致召回失败"},
want: true,
},
{
name: "how-to 内容且答案含命令步骤 → 放行",
content: "state.db 怎么修",
facts: []string{"systemctl --user stop zhiyid 后 cp 备份再 sqlite3 PRAGMA integrity_check"},
want: false,
},
{
name: "how-to 内容且答案含序号步骤 → 放行",
content: "网关怎么重启",
facts: []string{"第一步停写者,第二步备份,③重启 zhiyid"},
want: false,
},
{
name: "非 how-to 陈述事实 → 不受此判据影响",
content: "用户重启了服务器",
facts: []string{"用户重启了服务器"},
want: false,
},
{
name: "how-to 命中但完全无事实 → 降级(空答案无步骤)",
content: "磁盘怎么清",
facts: []string{},
want: true,
},
{
name: "how-to 内容但答案是纯元描述(无序号无命令)→ 降级",
content: "这个问题怎么排查",
facts: []string{"排查日志以定位问题根源即可解决"},
want: true,
},
}
for _, c := range cases {
demote, _ := ProceduralStepVerdict(c.content, c.facts)
if demote != c.want {
t.Errorf("%s: demote=%v want %v (facts=%v)", c.name, demote, c.want, c.facts)
}
}
}
// TestProceduralStepVerdict_Shadow 影子模式默认应只记不真降级。
func TestProceduralStepVerdict_Shadow(t *testing.T) {
if !proceduralShadowMode {
t.Skip("影子模式被显式关闭,跳过默认行为断言")
}
demote, reason := ProceduralStepVerdict("怎么修 state.db", []string{"结论:数据库损坏"})
if !demote {
t.Fatalf("how-to 无步骤在 shadow 下仍应报 demote=true(供日志观察),实得 %v", demote)
}
if reason == "" {
t.Fatal("shadow 下仍应给出人类可读原因")
}
}
// TestIsHowToContent 触发词覆盖真实问法(含英文)。
func TestIsHowToContent(t *testing.T) {
pos := []string{"state.db 怎么修", "网关怎么重启", "磁盘怎么清", "怎么做", "how to fix", "修复步骤是什么", "该怎么排查"}
for _, c := range pos {
if !IsHowToContent(c) {
t.Errorf("应命中 how-to: %q", c)
}
}
neg := []string{"用户重启了服务器", "今天天气不错", "牧尘喜欢喝咖啡"}
for _, c := range neg {
if IsHowToContent(c) {
t.Errorf("不应命中 how-to: %q", c)
}
}
}
+142
View File
@@ -0,0 +1,142 @@
// 织忆 MemoryWeave — 图谱实体质量闸(P0-4,2026-10-26)
//
// 背景(CS249r 评审实测):graph_expanded 抽样 15 条里 8 条是 ≤4 字垃圾实体
// ("条""里存着""开发"),既污染上下文又抬延迟。根因是实体抽取「任意 2-8 字中文串都当实体」,
// 且扩展前不检查节点在图里的度数 —— 孤边节点(度=1)被当成语义邻居注入。
//
// 两道闸(都在**导航前**执行,省算力):
// ① isJunkEntity:长度 <2 rune / 纯虚词量词代词 / 常见填充短语 / 纯 ASCII 停用词 ⇒ 丢弃;
// ② 度数闸:起点实体在图中的边数 <2 ⇒ 不导航、不注入(孤边不构成「关联」)。
//
// 观测:两道闸的拒绝计数写入 debug 日志(ZHIYI_GRAPH_EXPAND_DEBUG=1)与 recall trace
// (graph_expand 阶段 note),保证「下次坏了不沉默」。
package governance
import (
"strings"
"unicode/utf8"
)
// junkEntitySet 高置信垃圾实体词表:中文虚词/代词/量词/时间副词/填充短语 + 英文停用词。
// 原则:只收**几乎不可能**是真实实体名的词;宁可漏筛(由度数闸兜底),不可误杀专名。
var junkEntitySet = map[string]struct{}{}
func init() {
words := []string{
// 虚词 / 助词 / 连词
"的", "了", "着", "过", "地", "得", "所", "以", "于", "与", "及", "或", "而", "且", "则", "即",
"因为", "所以", "但是", "而且", "然后", "如果", "虽然", "不过", "而是", "就是", "只是", "还是",
"并且", "以及", "或者", "否则", "因此", "于是", "关于", "对于", "由于", "根据", "按照", "通过",
// 代词 / 指示词
"我", "你", "他", "她", "它", "我们", "你们", "他们", "她们", "它们", "自己", "别人", "大家",
"这个", "那个", "这些", "那些", "这样", "那样", "这种", "那种", "这里", "那里", "里面", "上面",
"下面", "前面", "后面", "其中", "其他", "其它", "什么", "怎么", "怎样", "为什么", "哪个", "哪些",
// 数量 / 量词 / 程度
"一个", "一条", "一次", "一些", "一种", "一件", "一点", "一下", "一直", "一样", "一起",
"两个", "三个", "几个", "多个", "很多", "更多", "多条", "几条", "每条", "各种", "某种", "全部", "所有", "每个", "各种", "部分",
"一条条", "一条里", "条", "个", "次", "些", "种", "只", "件", "篇", "位", "名", "层",
"非常", "比较", "特别", "十分", "更加", "最为", "可能", "应该", "必须", "可以", "能够", "需要",
// 常见谓词 / 填充动词短语(低信息量,几乎不可能是实体)
"里存着", "存着", "里存在", "有着", "存在", "进行", "使用", "支持", "包括", "包含", "属于",
"作为", "成为", "变成", "做到", "做好", "来看", "来说", "来讲", "看到", "看看", "发现",
"出现", "提供", "实现", "完成", "开始", "结束", "继续", "返回", "调用", "处理", "执行",
"开发", "编写", "添加", "修改", "删除", "更新", "检查", "确认", "验证", "测试", "部署",
// 时间 / 方位
"现在", "今天", "昨天", "明天", "之前", "之后", "以前", "以后", "当时", "当前", "目前",
"最近", "刚才", "马上", "已经", "正在", "曾经", "从来", "后来", "最终", "最后", "首先",
"时候", "时间", "期间", "上面", "下面", "左边", "右边", "中间", "附近", "里面", "外面",
// 英文停用词(小写比较)
"the", "and", "for", "are", "but", "not", "you", "all", "any", "can", "had", "her", "was",
"one", "our", "out", "day", "get", "has", "him", "his", "how", "its", "new", "now", "old",
"see", "two", "way", "who", "boy", "did", "she", "use", "her", "way", "may", "say",
"this", "that", "with", "from", "have", "will", "your", "they", "than", "then", "them",
"into", "more", "some", "such", "only", "over", "also", "just", "like", "make", "made",
}
for _, w := range words {
junkEntitySet[w] = struct{}{}
}
}
// IsJunkEntity 判定实体是否应被图谱扩展丢弃(P0-4 第①道闸)。
func IsJunkEntity(e string) bool {
e = strings.TrimSpace(e)
if e == "" {
return true
}
if utf8.RuneCountInString(e) < 2 {
return true // 单字("条""里")不构成实体
}
// 归一化:去首尾标点/引号,压缩空白
e = strings.Trim(e, " \t\r\n\"'`,。;:!?、()()【】[]{}<>《》—-·.,;:!?")
if e == "" {
return true
}
if utf8.RuneCountInString(e) < 2 {
return true
}
lower := strings.ToLower(e)
if _, bad := junkEntitySet[lower]; bad {
return true
}
if _, bad := junkEntitySet[e]; bad {
return true
}
// 全由虚词/量词字组成(如「里存着」之外的「的了着」「个条」组合)
if allFunctionChars(e) {
return true
}
// 纯标点/符号(无任何字母/数字/汉字)
if !hasSemanticChar(e) {
return true
}
return false
}
// functionChars 单字虚词/量词集合:整串都由这些字组成 ⇒ 无信息量。
var functionChars = map[rune]struct{}{}
func init() {
for _, r := range "的了着过地得所以于与及或而且则即是在有和与就都也还又很更最可会能要没对为从到把被让给吗呢吧啊呀哦嗯这那我你他她它们个条次些种只件篇位名层里上下中前后内外" {
functionChars[r] = struct{}{}
}
}
func allFunctionChars(s string) bool {
for _, r := range s {
if _, ok := functionChars[r]; !ok {
return false
}
}
return true
}
func hasSemanticChar(s string) bool {
for _, r := range s {
switch {
case r >= 0x4E00 && r <= 0x9FFF: // 汉字
return true
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z':
return true
case r >= '0' && r <= '9':
return true
}
}
return false
}
// FilterEntitiesForGraph 批量过滤垃圾实体,返回保留集与被拒数(观测用)。
func FilterEntitiesForGraph(entities []string) ([]string, int) {
if len(entities) == 0 {
return entities, 0
}
out := make([]string, 0, len(entities))
rejected := 0
for _, e := range entities {
if IsJunkEntity(e) {
rejected++
continue
}
out = append(out, e)
}
return out, rejected
}
@@ -0,0 +1,44 @@
package governance
import "testing"
func TestIsJunkEntity(t *testing.T) {
junk := []string{
"条", "个", "里", "的", "了", "这个", "那些", "里存着", "开发", "进行", "the", "and",
"一个", "多条", "存在", "什么", "为什么", "", " ", ",。", "的了着",
}
for _, e := range junk {
if !IsJunkEntity(e) {
t.Errorf("%q 应判为垃圾实体", e)
}
}
keep := []string{
"织忆", "MemoryWeave", "淇县", "生物制造", "卫都街道", "bge-m3", "KOCR", "llama-server-4b",
"8081", "graph.db", "牧尘", "飞书",
}
for _, e := range keep {
if IsJunkEntity(e) {
t.Errorf("%q 是专名/标识符,不应被判为垃圾", e)
}
}
}
func TestFilterEntitiesForGraph(t *testing.T) {
in := []string{"织忆", "条", "这个", "8081", "里存着"}
out, rejected := FilterEntitiesForGraph(in)
if rejected != 3 {
t.Fatalf("应拒绝 3 个垃圾实体,实际 %d(输出 %v)", rejected, out)
}
if len(out) != 2 || out[0] != "织忆" || out[1] != "8081" {
t.Fatalf("保留集错误:%v", out)
}
}
func TestAllFunctionChars(t *testing.T) {
if !allFunctionChars("的了着") {
t.Fatal("全虚词串应判 true")
}
if allFunctionChars("织忆") {
t.Fatal("专名不应判 true")
}
}
+14 -3
View File
@@ -36,9 +36,15 @@ func (g *InMemoryGraph) ExpandFromResults(results []models.RecallResult, namespa
startEntities := 0 // 可观测性:去重后实际导航的起点实体数 N
totalPathsHit := 0 // 可观测性:命中路径总数 M(补前去重)
rejectedJunk := 0 // P0-4:被质量闸①丢弃的垃圾实体数
rejectedLowDegree := 0 // P0-4:被质量闸②丢弃的低度数实体数
// 从每个结果正文提实体出发扩展
for _, r := range results {
for _, entity := range extractPotentialEntitiesFromContent(r.Content) {
entities := extractPotentialEntitiesFromContent(r.Content)
// P0-4 第①道闸:垃圾实体不导航
entities, rejected := FilterEntitiesForGraph(entities)
rejectedJunk += rejected
for _, entity := range entities {
if seenEntities[entity] {
continue
}
@@ -50,6 +56,11 @@ func (g *InMemoryGraph) ExpandFromResults(results []models.RecallResult, namespa
if err != nil {
continue
}
// P0-4 第②道闸:路径数 <2 ⇒ 孤边节点,不构成语义关联(内存图无 degree 查询,用路径数代理)
if len(paths) < 2 {
rejectedLowDegree++
continue
}
totalPathsHit += len(paths)
for _, p := range paths {
to, _ := p["target"].(string)
@@ -70,8 +81,8 @@ func (g *InMemoryGraph) ExpandFromResults(results []models.RecallResult, namespa
}
// 可观测性:默认关(ZHIYI_GRAPH_EXPAND_DEBUG=1 打开),保证「下次坏了不沉默」。
if graphExpandDebugEnabled() {
log.Printf("[graph-expand] ns=%s maxHops=%d 起点实体 %d 个 / 命中 %d 条 / 补入 %d 条",
namespace, maxHops, startEntities, totalPathsHit, len(expanded))
log.Printf("[graph-expand] ns=%s maxHops=%d 起点实体 %d 个 / 命中 %d 条 / 补入 %d 条 / 质量闸拒 垃圾 %d 低度 %d",
namespace, maxHops, startEntities, totalPathsHit, len(expanded), rejectedJunk, rejectedLowDegree)
}
return expanded
}
+13 -3
View File
@@ -469,8 +469,13 @@ func (fg *FileGraph) ExpandFromResults(results []models.RecallResult, namespace
// 当实体去 Navigate,永远匹配不到任何图节点 ⇒ 结构性零产出(t_818f77be)。
startEntities := 0 // 可观测性:去重后实际导航的起点实体数 N
totalPathsHit := 0 // 可观测性:命中路径总数 M(补前去重)
rejectedJunk := 0 // P0-4:质量闸①拒绝数
rejectedLowDegree := 0 // P0-4:质量闸②拒绝数
for _, r := range results {
for _, entity := range extractFileGraphEntities(r.Content) {
entities := extractFileGraphEntities(r.Content)
entities, rejected := FilterEntitiesForGraph(entities)
rejectedJunk += rejected
for _, entity := range entities {
if seenEntities[entity] {
continue
}
@@ -482,6 +487,11 @@ func (fg *FileGraph) ExpandFromResults(results []models.RecallResult, namespace
if err != nil {
continue
}
// P0-4 第②道闸:路径数 <2 ⇒ 孤边节点不注入
if len(paths) < 2 {
rejectedLowDegree++
continue
}
totalPathsHit += len(paths)
for _, p := range paths {
target, _ := p["target"].(string)
@@ -502,8 +512,8 @@ func (fg *FileGraph) ExpandFromResults(results []models.RecallResult, namespace
}
// 可观测性:默认关(ZHIYI_GRAPH_EXPAND_DEBUG=1 打开),保证「下次坏了不沉默」。
if graphExpandDebugEnabled() {
log.Printf("[graph-expand] ns=%s maxHops=%d 起点实体 %d 个 / 命中 %d 条 / 补入 %d 条",
namespace, maxHops, startEntities, totalPathsHit, len(expanded))
log.Printf("[graph-expand] ns=%s maxHops=%d 起点实体 %d 个 / 命中 %d 条 / 补入 %d 条 / 质量闸拒 垃圾 %d 低度 %d",
namespace, maxHops, startEntities, totalPathsHit, len(expanded), rejectedJunk, rejectedLowDegree)
}
return expanded
}
+17
View File
@@ -3,6 +3,7 @@ package governance
import (
"fmt"
"strings"
"sync"
)
@@ -295,6 +296,22 @@ func (g *InMemoryGraph) AllEdges(namespace string) []GraphEdge {
return out
}
// CountNodesByType 内存实现:无时间列,lastUpdated 恒为空(调用方按 unknown_age 处理)。
func (g *InMemoryGraph) CountNodesByType(typ string) (total, withID int, lastUpdated string) {
g.mu.RLock()
defer g.mu.RUnlock()
for _, n := range g.nodes {
if n.Type != typ {
continue
}
total++
if strings.TrimSpace(n.ID) != "" {
withID++
}
}
return
}
// Stats 返回图谱统计
func (g *InMemoryGraph) Stats() (nodeCount, edgeCount int, density float64) {
g.mu.RLock()
+90 -4
View File
@@ -16,6 +16,7 @@ import "C"
import (
"encoding/json"
"fmt"
"log"
"os"
"strconv"
"strings"
@@ -584,6 +585,32 @@ func (gs *SQLiteGraphStore) Stats() (nodeCount, edgeCount int, density float64)
return
}
// CountNodesByType 返回某类节点的总量、id 非空量、最近一次更新时间(2026-10-26)。
//
// 存在的理由:selfcheck 需要暴露「pattern 召回有没有生产者」这一可观测事实。
// 事故背景:`POST /api/v1/patterns/detect` 是短命单发端点,全库 0 cron、0 调用方,
// 于是 graph_nodes(type='pattern') 恒 0 行、recall 恒 0 命中,而日志层面完全沉默。
// 零产出必须能被机器读到,不能靠人翻 7 天日志才发现。
func (gs *SQLiteGraphStore) CountNodesByType(typ string) (total, withID int, lastUpdated string) {
gs.mu.RLock()
defer gs.mu.RUnlock()
t := escape(typ)
total = queryInt(gs.db, fmt.Sprintf("SELECT COUNT(*) FROM graph_nodes WHERE type='%s'", t))
withID = queryInt(gs.db, fmt.Sprintf(
"SELECT COUNT(*) FROM graph_nodes WHERE type='%s' AND id IS NOT NULL AND TRIM(id)<>''", t))
rows := queryRows(gs.db, fmt.Sprintf(
"SELECT MAX(last_updated_at) AS m FROM graph_nodes WHERE type='%s'", t))
if len(rows) > 0 && rows[0]["m"] != nil {
if v, ok := rows[0]["m"].(string); ok {
lastUpdated = v
} else {
lastUpdated = fmt.Sprintf("%v", rows[0]["m"])
}
}
return
}
func (gs *SQLiteGraphStore) Prune(minWeight float64) {
gs.mu.Lock()
defer gs.mu.Unlock()
@@ -791,6 +818,8 @@ func (gs *SQLiteGraphStore) ExpandFromResults(results []models.RecallResult, nam
seen := make(map[string]bool) // 追踪已访问的记忆 ID
seenEntities := make(map[string]bool) // 追踪已访问的实体(用于去重扩展结果)
rejectedJunk := 0
rejectedLowDegree := 0
for _, r := range results {
if seen[r.ID] {
continue
@@ -798,6 +827,9 @@ func (gs *SQLiteGraphStore) ExpandFromResults(results []models.RecallResult, nam
seen[r.ID] = true
entities := extractPotentialEntities(r.Content)
// P0-4 第①道闸:垃圾实体(虚词/量词/代词/填充短语)不导航
entities, rejected := FilterEntitiesForGraph(entities)
rejectedJunk += rejected
for _, entity := range entities {
if seenEntities[entity] {
continue
@@ -805,18 +837,25 @@ func (gs *SQLiteGraphStore) ExpandFromResults(results []models.RecallResult, nam
seenEntities[entity] = true
// SQLite 图谱节点 ID 格式: n_{entity_name},需 normalizeEntityID 转换
// Navigate 的 from/to 都是**节点 ID**(n_织忆),不能用裸名 entity 比对,
// 否则恒不相等 ⇒ 路径全被 continue ⇒ 结构性 0 条附加(t_be4d070a 诊断)。
nodeID := normalizeEntityID(entity)
// P0-4 第②道闸:度数 <2 的孤边节点不构成语义关联 ⇒ 不导航、不注入。
if gs.EdgeDegree(nodeID) < 2 {
rejectedLowDegree++
continue
}
paths, _ := gs.Navigate(nodeID, maxHops, namespace, nil)
for _, p := range paths {
// Navigate 返回字段: from, to, relation, weight, hop
// Navigate 返回字段: from, to, relation, weight, hop(均为节点 ID)
var neighbor, rel string
from, _ := p["from"].(string)
to, _ := p["to"].(string)
relVal, _ := p["relation"].(string)
if from == entity && to != "" {
if from == nodeID && to != "" {
neighbor = to
rel = relVal
} else if to == entity && from != "" {
} else if to == nodeID && from != "" {
neighbor = from
rel = "↩ " + relVal
}
@@ -828,15 +867,62 @@ func (gs *SQLiteGraphStore) ExpandFromResults(results []models.RecallResult, nam
}
seenEntities[neighbor] = true
expanded = append(expanded, models.RecallResult{
Content: fmt.Sprintf("[graph] %s --[%s]--> %s", entity, rel, neighbor),
// ID 用邻居节点 ID(n_<实体> 或 n_mem_<记忆id>),非空才不破坏 recall.go 的
// ID 去重(Step 5 按 r.ID==e.ID 判已存在);若邻居是记忆节点,ID 即真实记忆指针。
ID: neighbor,
Category: "graph_expanded",
Content: fmt.Sprintf("[graph] %s --[%s]--> %s", entity, rel, displayNodeName(neighbor)),
Score: r.Score * 0.5,
})
}
}
}
// P0-4 可观测性:两道闸的拒绝量必须能读出来(默认关,debug 开才打印,避免刷屏)。
if graphExpandDebugEnabled() && (rejectedJunk > 0 || rejectedLowDegree > 0) {
log.Printf("[graph-expand] ns=%s 质量闸:垃圾实体拒 %d / 低度数拒 %d / 补入 %d",
namespace, rejectedJunk, rejectedLowDegree, len(expanded)-len(results))
}
return expanded
}
// EdgeDegree 返回节点在图中的边数(入度+出度,P0-4 度数闸用)。
// 与 GetEntityDegree 的区别:后者返回 evidence_count 之和(证据强度),
// 本函数才是字面意义的「度数」—— 孤边节点(度=1)不足以构成语义关联。
func (gs *SQLiteGraphStore) EdgeDegree(nodeID string) int {
if nodeID == "" {
return 0
}
gs.mu.RLock()
defer gs.mu.RUnlock()
rows := queryRows(gs.db, fmt.Sprintf(
"SELECT COUNT(*) as c FROM graph_edges WHERE source = '%s' OR target = '%s'",
escape(nodeID), escape(nodeID)))
if len(rows) == 0 {
return 0
}
switch v := rows[0]["c"].(type) {
case int:
return v
case int64:
return int(v)
case float64:
return int(v)
}
return 0
}
// displayNodeName 把节点 ID 转成可读显示名(去 n_ / n_mem_ / mem_ 前缀)。
// 图节点 ID 形态:实体 n_织忆 / 概念 n_mem_<记忆id>(name 列才是 mem_<id>)。
// 仅用于格式化 Content 的可读展示,不做任何记忆正文编造。
func displayNodeName(nodeID string) string {
for _, p := range []string{"n_mem_", "n_", "mem_", "ep_"} {
if strings.HasPrefix(nodeID, p) {
return strings.TrimPrefix(nodeID, p)
}
}
return nodeID
}
// ExpandWithSummary BFS 扩展 + 生成汇总语句 — E1 图谱导航增强
// 从 recall 结果提取实体,进行多跳扩展,返回扩展关系列表和一句话汇总
func (gs *SQLiteGraphStore) ExpandWithSummary(results []models.RecallResult, namespace string, maxHops int) models.GraphBFSResult {
@@ -0,0 +1,133 @@
// 织忆 MemoryWeave — SQLiteGraphStore.ExpandFromResults 修复锁死测试(t_724f31ad)
//
// 背景:SQLiteGraphStore.ExpandFromResults(graph_sqlite.go:787)此前拿裸实体名 entity
// 与 Navigate 返回的节点 ID(n_织忆)做 == 比对 ⇒ 恒不相等 ⇒ 路径全被 continue ⇒ 结构性 0 条附加。
// 且附加结果 Category 空、ID 空(审计 graph_expanded 计数恒 0 + recall.go ID 去重失效)。
//
// 本文件两类断言:
// 1) 正向:构造 n_<实体> 节点 + 一条 recall 结果 ⇒ ExpandFromResults 返回 >len(results),
// 且新增项 Category=="graph_expanded" 且 ID!=""(ID 用邻居节点 ID)。
// 2) 反向:实体在图里不存在 ⇒ 结果条数不变(不得凭空加)。
package governance
import (
"path/filepath"
"testing"
"github.com/xiaoxue/memoryweave/internal/models"
)
// testNS724 SQLite 图的 namespace 须用 `-main` 全形态:SQLiteGraphStore.Navigate 经
// deriveNamespaceForGraph 会把裸名 ns 变 ns-main 再过滤(与生产 recall.go 传的 namespace 一致)。
const testNS724 = "t-ns-expand-sqlite-main"
// newTestSQLiteGraph 建临时 SQLiteGraphStore 并灌入一条链:n_织忆 -LINKS_TO-> n_图谱。
func newTestSQLiteGraph(t *testing.T) *SQLiteGraphStore {
t.Helper()
path := filepath.Join(t.TempDir(), "graph-test.db")
gs, err := NewSQLiteGraphStore(path)
if err != nil {
t.Fatalf("NewSQLiteGraphStore: %v", err)
}
t.Cleanup(func() { _ = gs.Close() })
if err := gs.AddNode("n_织忆", "织忆", "entity", testNS724); err != nil {
t.Fatalf("AddNode n_织忆: %v", err)
}
if err := gs.AddNode("n_图谱", "图谱", "entity", testNS724); err != nil {
t.Fatalf("AddNode n_图谱: %v", err)
}
if err := gs.AddEdge("e-ab", "n_织忆", "n_图谱", "LINKS_TO", testNS724, 1.0); err != nil {
t.Fatalf("AddEdge: %v", err)
}
// P0-4 度数闸(2026-10-26):degree<2 的孤边节点不再注入 ⇒ 正向用例的起点实体
// 必须真的在图里有 ≥2 条边(两跳邻居即真实图谱形态,不是为过测试造数据)。
if err := gs.AddNode("n_召回", "召回", "entity", testNS724); err != nil {
t.Fatalf("AddNode n_召回: %v", err)
}
if err := gs.AddEdge("e-ac", "n_织忆", "n_召回", "LINKS_TO", testNS724, 1.0); err != nil {
t.Fatalf("AddEdge e-ac: %v", err)
}
return gs
}
// TestSQLiteGraphStore_DegreeGate P0-4:度数=1 的孤边实体不得被扩展注入。
func TestSQLiteGraphStore_DegreeGate(t *testing.T) {
path := filepath.Join(t.TempDir(), "graph-degree.db")
gs, err := NewSQLiteGraphStore(path)
if err != nil {
t.Fatalf("NewSQLiteGraphStore: %v", err)
}
t.Cleanup(func() { _ = gs.Close() })
// 孤边:n_孤边 --LINKS_TO--> n_邻居(起点实体度=1)
if err := gs.AddNode("n_孤边", "孤边", "entity", testNS724); err != nil {
t.Fatal(err)
}
if err := gs.AddNode("n_邻居", "邻居", "entity", testNS724); err != nil {
t.Fatal(err)
}
if err := gs.AddEdge("e-one", "n_孤边", "n_邻居", "LINKS_TO", testNS724, 1.0); err != nil {
t.Fatal(err)
}
if got := gs.EdgeDegree("n_孤边"); got != 1 {
t.Fatalf("EdgeDegree(n_孤边) 应为 1,实际 %d", got)
}
results := []models.RecallResult{{
ID: "mem_degree_1", Content: "孤边 的扩展", Category: "distilled", Score: 0.9, Namespace: testNS724,
}}
got := gs.ExpandFromResults(results, testNS724, 1)
if len(got) != len(results) {
t.Fatalf("度数=1 的实体不应扩展注入,实际补入 %d 条", len(got)-len(results))
}
}
// TestSQLiteGraphStore_ExpandFromResults_Inserts 正向:实体在图中 ⇒ 补入 graph_expanded 且 ID 非空。
func TestSQLiteGraphStore_ExpandFromResults_Inserts(t *testing.T) {
gs := newTestSQLiteGraph(t)
results := []models.RecallResult{{
ID: "mem_expand_1",
Content: "织忆 与 图谱 的扩展机制",
Category: "distilled",
Score: 0.9,
Namespace: testNS724,
}}
got := gs.ExpandFromResults(results, testNS724, 1)
if len(got) <= len(results) {
t.Fatalf("期望补入 >len(results)=%d,实际 %d 条(结构性 0 产出未修?)", len(results), len(got))
}
var added int
for _, e := range got[len(results):] {
if e.Category != "graph_expanded" {
t.Fatalf("补入结果 category=%q 期望 graph_expanded", e.Category)
}
if e.ID == "" {
t.Fatalf("补入结果 ID 为空(ID 去重会失效),Content=%q", e.Content)
}
added++
}
if added == 0 {
t.Fatal("没有任何 graph_expanded 补入")
}
t.Logf("SQLiteGraphStore: 1-recall 补入 %d 条 graph_expanded(ID 均非空)", added)
}
// TestSQLiteGraphStore_ExpandFromResults_NoFabrication 反向:实体不在图中 ⇒ 条数不变,不得凭空加。
func TestSQLiteGraphStore_ExpandFromResults_NoFabrication(t *testing.T) {
gs := newTestSQLiteGraph(t)
// content 只含一个图中不存在的实体「完全不存在的实体」(切分成 2~8 字中文串均无图节点)。
results := []models.RecallResult{{
ID: "mem_expand_2",
Content: "完全不存在的实体",
Category: "distilled",
Score: 0.9,
Namespace: testNS724,
}}
got := gs.ExpandFromResults(results, testNS724, 1)
if len(got) != len(results) {
t.Fatalf("实体不在图中却补入 %d 条(期望 0 补入,len=%d)", len(got)-len(results), len(results))
}
t.Logf("SQLiteGraphStore: 图外实体 ⇒ 0 补入(不凭空加)")
}
+3
View File
@@ -23,6 +23,9 @@ type GraphStore interface {
// 统计与维护
Stats() (nodeCount, edgeCount int, density float64)
// CountNodesByType 返回某类节点的总量、id 非空量、最近更新时间。
// 用途:让「写入端有没有产数」可被机器读到(pattern 召回曾恒 0 而日志沉默)。
CountNodesByType(typ string) (total, withID int, lastUpdated string)
Prune(minWeight float64)
// 边枚举(供社区发现用)
+46
View File
@@ -30,6 +30,7 @@ package memprotect
import (
"log"
"os"
"strconv"
"strings"
@@ -67,6 +68,41 @@ const (
RedisKeyProtectedIDs = "zhiyi:protected_ids"
)
// ── GA-P2 强保护栏(2026-10-27)──────────────────────────────────────────────
//
// 规则5(可选,ZHIYI_PROTECT_RAILS=1 启用):recall_verified_count >= 2 ⇒ 保护。
// 口径与 Python 侧 zhiyi_protection.py 的 MIN_VERIFIED_FOR_RAILS 及
// ~/.hermes/plugins/zhiyi/protect_rails.py 规则② 逐字同口径 —— 任何改动必须两侧同步,
// 禁止两套标准(卡 t_944344b5 边界条款)。
//
// 政策边界(牧尘 2026-10-20 拍板):rv == 1(只被确认过一次)不纳入保护,
// 保留「验证满 2 次才算硬证据」的门槛。
//
// 默认关(未设/env != "1")⇒ 行为零变化;启用后为纯增保护(只增加「不遗忘」,绝不增加删除)。
const MinVerifiedForRails = 2
// ProtectRailsEnv 强保护栏 env 开关名(与 protect_rails.py / zhiyi_protection.py 同名)。
const ProtectRailsEnv = "ZHIYI_PROTECT_RAILS"
// ProtectRailsEnabled 读 env,每次调用现读(支持不重启热切换,开销可忽略)。
func ProtectRailsEnabled() bool {
return strings.TrimSpace(os.Getenv(ProtectRailsEnv)) == "1"
}
// rowInt 从候选 map 取整型字段,兼容 int/int64/float64
// (IPC 侧候选 map 是 int;直连/其它实现可能给 int64,避免规则因类型断言失败而空转)。
func rowInt(row map[string]interface{}, key string) int {
switch v := row[key].(type) {
case int:
return v
case int64:
return int(v)
case float64:
return int(v)
}
return 0
}
// State 一次 LoadState() 加载的保护状态快照。
// 衰减 tick / admin forget 开始时 LoadState 一次,循环内对每条候选复用同一 State,
// 避免每条记忆都 HGETALL/SMEMBERS 一次 Redis。
@@ -123,6 +159,16 @@ func IsProtected(row map[string]interface{}, st *State) (bool, string) {
}
}
// 规则5(GA-P2 强保护栏,可选):recall_verified_count >= 2 ⇒ 保护。
// 默认关(ZHIYI_PROTECT_RAILS != "1")⇒ 行为零变化。启用后:被验证使用 ≥2 次的
// 记忆永不自动遗忘(纯增保护,不增加任何删除)。字段已在候选 map(lancedb_ipc.go
// GetCandidatesForForgetting 已 SELECT recall_verified_count),不依赖 Redis 缓存。
if ProtectRailsEnabled() {
if rv := rowInt(row, "recall_verified_count"); rv >= MinVerifiedForRails {
return true, "recall_verified_ge2"
}
}
return false, ""
}
+53
View File
@@ -403,3 +403,56 @@ func TestDifferential_FixtureTable(t *testing.T) {
}
}
}
// TestRule5_RailsVerifiedCount (GA-P2, 2026-10-27):强保护栏 recall_verified_count>=2 规则。
// 口径与 Python zhiyi_protection.py 的 recall_verified>=2 一致;默认关(零行为变化)。
// 政策边界(牧尘 2026-10-20 拍板):rv==1 不纳入保护(验证满 2 次才算硬证据)。
func TestRule5_RailsVerifiedCount(t *testing.T) {
mkRV := func(rv int) map[string]interface{} {
return map[string]interface{}{
"id": "mem_rails",
"category": "normal", // 不命中规则1
"freshness": "", // 不命中规则3
"content": "普通日常内容", // 不命中规则4b
"recall_verified_count": rv,
}
}
st := synthState(nil, nil) // 空 VProp/Protected ⇒ 规则2/4a 不命中
// 1) 默认关(未设 env):rv 再高也不保护(行为零变化)
t.Setenv(ProtectRailsEnv, "")
if p, r := IsProtected(mkRV(50), st); p {
t.Fatalf("默认关: rv=50 不应保护, got true rule=%s", r)
}
// 2) ENV="1" 启用:rv>=2 保护(含边界 2),rv=1/0 不保护
t.Setenv(ProtectRailsEnv, "1")
if p, r := IsProtected(mkRV(2), st); !p || r != "recall_verified_ge2" {
t.Errorf("rv=2(边界) 应保护 rule=recall_verified_ge2, got %v rule=%s", p, r)
}
if p, _ := IsProtected(mkRV(5), st); !p {
t.Error("rv=5 应保护")
}
if p, _ := IsProtected(mkRV(1), st); p {
t.Error("rv=1 不应保护(政策边界:验证满 2 次才算硬证据)")
}
if p, _ := IsProtected(mkRV(0), st); p {
t.Error("rv=0 不应保护")
}
// 3) ENV="0" 显式关:不保护
t.Setenv(ProtectRailsEnv, "0")
if p, _ := IsProtected(mkRV(5), st); p {
t.Error("ENV=0 不应保护")
}
// 4) rowInt 类型兼容:int64 也应正确识别(防类型断言失配致规则空转)
t.Setenv(ProtectRailsEnv, "1")
row := map[string]interface{}{
"id": "mem_i64", "category": "normal", "content": "x",
"recall_verified_count": int64(3),
}
if p, _ := IsProtected(row, st); !p {
t.Error("rv=int64(3) 应保护(rowInt 类型兼容)")
}
}
+36
View File
@@ -35,6 +35,38 @@ var (
},
)
// ─── 整合(consolidate)可观测性 ───────────
// 2026-10-26:这三个指标与 MetaNoteRejectTotal 的定义在 rebase 时被丢弃(定义只在
// a8dfa37 里),而调用方(consolidation_track.go / core.go)已进 main ⇒ HEAD 编不过。
// 从 a8dfa37 原样恢复定义并注册,恢复 main 可构建性。
ConsolidateBusyWaitTotal = prometheus.NewCounter(
prometheus.CounterOpts{
Name: "zhiyi_consolidate_busy_wait_total",
Help: "Rust 侧报 consolidate_busy 时 Go 侧主动等待重试的次数",
},
)
// 最近一次整合成功时间(Unix 秒)
ConsolLastSuccessTime = prometheus.NewGauge(
prometheus.GaugeOpts{
Name: "zhiyi_consolidate_last_success_time",
Help: "最近一次 Rust 整合完成时间(Unix 秒);0 = 从未成功",
},
)
// 整合连续失败次数
ConsolConsecutiveFailures = prometheus.NewGauge(
prometheus.GaugeOpts{
Name: "zhiyi_consolidate_consecutive_failures",
Help: "连续整合失败次数(任意一次成功归零)",
},
)
// metanote shadow 写前置校验拒绝计数
MetaNoteRejectTotal = prometheus.NewCounter(
prometheus.CounterOpts{
Name: "zhiyi_metanote_reject_total",
Help: "metanote shadow 422 拒绝累计次数",
},
)
// ─── LLM 调用计数 ──────────────────────────
DistillLLMCallsToday = prometheus.NewGauge(
prometheus.GaugeOpts{
@@ -124,6 +156,10 @@ func init() {
RequestDuration,
DistillQueueDepth,
ConflictsPending,
ConsolidateBusyWaitTotal,
ConsolLastSuccessTime,
ConsolConsecutiveFailures,
MetaNoteRejectTotal,
DistillLLMCallsToday,
ConsolidateFailuresTotal,
HitEventAppendFailedTotal,
+16
View File
@@ -197,7 +197,23 @@ type RecallResult struct {
// 2026-10-13 时间维检索(t_e9a4d18f P0):该条目是否可能过期。
// 仅当 stale 标记开启(ZHIYI_STALE_AFTER_HOURS>0)且 updated_at 超期时为 true;
// 默认关闭 ⇒ 恒 false 且 omitempty 不输出 ⇒ 行为零变化(向后兼容)。
// 2026-10-26(t_contract_p0):默认阈值改为 720h(30 天)——**只做展示标记,
// 不参与排序**(排序侧的有效期判据走 valid_to,见 ValidTo);环境变量仍可覆盖/关闭。
Stale bool `json:"stale,omitempty"`
// 2026-10-26(t_trace_p0):本条结果的来源通道(vector|fts|graph|shared|wiki|
// standing|pattern|peer)。此前「为什么被召回」不可答,调参只能盲修(CS249r Ch14)。
Channel string `json:"channel,omitempty"`
// 2026-10-26(P1 supersede 读侧暴露):版本与取代关系。
// 写入侧一直有 supersedes / superseded_by / valid_from / valid_to / version,
// 但 RecallResult 不带出 ⇒ 读侧永远看不见「这条已被谁取代 / 是否过期」。
// 存量行零值(Version=0=未记录版本),omitempty 保持旧响应兼容。
Version int `json:"generation,omitempty"` // 记忆版本号(DB version 列,语义=第几代)
ValidFrom string `json:"valid_from,omitempty"` // 语义有效期起点(RFC3339,空=永久)
ValidTo string `json:"valid_to,omitempty"` // 语义有效期终点(空=永久)
Supersedes []string `json:"supersedes,omitempty"` // 我取代了谁
SupersededBy []string `json:"superseded_by,omitempty"` // 我被谁取代(反向指针)
// Superseded 便捷布尔:SupersededBy 非空 ⇒ 已有更新版本存在(读侧一个字段可判)。
Superseded bool `json:"superseded,omitempty"`
// A10 (t_d618dc7c):把「人工有用」单独计数直通给调用方。此前 RecallResult 缺它 ⇒
// 插件/调用方读不到(t_4c810e02「加列不接 IPC=静默 no-op」同款坑)。
// nil=该条从未被人工点过(库里 NULL);非 nil=真实人工 useful 次数(NULL≠0)。
+316 -64
View File
@@ -2,15 +2,18 @@
//
// 背景:MemOS 血统的 L2「跨任务重复模式」检测。原实现活在 Python daemon 的
// main_loop(30s tick),2026-09-24 为 state-db 写安全整体退役后 L2 pattern 蒸馏
// 停摆(graph_nodes type='pattern' 停在 85 个 / 2026-09-18)。本包把 L1→L2 蒸馏
// 停摆(graph_nodes type='pattern' 停为 0 行;旧 Python daemon 残留的 85 行是
// type='pattern_stat',id 全 NULL、语义为 L1 命名空间统计快照,非 L2 pattern / 2026-09-18)。本包把 L1→L2 蒸馏
// 复活成**短命单发**(modernc sqlite 直连 graph.db,开/关不占长连接,不碰 state.db),
// 并按设计 §4.1 叠加两条检测规则:
// - 工具组合频率:同一 tool 组合出现 ≥3 次 → tool_combo pattern
// (confidence = min(freq*0.1, 1.0))
// - 工具组合频率:同一 tool 组合(成员 ∈ knownTools 白名单、≥2 成员、排除通用词)
// 被 ≥2 条独立来源记忆支撑 → tool_combo pattern(confidence = min(freq*0.1, 1.0));
// 落库另需 confidence ≥ MinWriteConfidence=0.5(判据收紧史见 t_639c5a03)
// - 相似解决路径聚类:cosine ≥0.85 成簇且簇内 ≥3 条 → workflow pattern
//
// 存储:复用现有 graph_nodes(type='pattern'),**不另开新表**(避免与 85 存量 + 现有
// 图谱查询割裂)。namespace='daemon-distill' 沿用旧 daemon 约定。
// 存储:复用现有 graph_nodes(type='pattern'),**不另开新表**(避免与现存图谱查询割裂;
// 表内另有 85 行 type='pattern_stat' 属旧 daemon 统计快照,与 L2 pattern 无关)。
// namespace='daemon-distill' 沿用旧 daemon 约定。
package pattern
import (
@@ -35,7 +38,7 @@ type Pattern struct {
// Config 检测参数(设计 §4.1)。
type Config struct {
MinComboFreq int // 工具组合出现 ≥ N 次才产 pattern(默认 3)
MinComboFreq int // 工具组合需 ≥ N 条**独立来源记忆**才产 pattern(默认 2,t_639c5a03 门槛)
ClusterThresh float64 // 相似路径聚类 cosine 阈值(默认 0.85)
MinClusterSize int // 簇内 ≥ N 条才产 workflow pattern(默认 3)
MaxRecords int // 单次扫描参与检测的记忆上限(防 O(n²) 爆炸,默认 500)
@@ -45,7 +48,7 @@ type Config struct {
// DefaultConfig 设计参数。
func DefaultConfig() Config {
return Config{
MinComboFreq: 3,
MinComboFreq: 2,
ClusterThresh: 0.85,
MinClusterSize: 3,
MaxRecords: 500,
@@ -53,19 +56,78 @@ func DefaultConfig() Config {
}
}
// knownTools 常见工具/CLI/命令名(小写)。工具组合频率分析从这里识别 + 兜底正则。
// MinWriteConfidence 落库置信度门槛(小怡二轮裁定 #5,2026-10-30):conf<0.5 一律不写库。
// tool_combo 的 conf=来源数×0.1 ⇒ 实际要求 ≥5 条独立来源;workflow 簇需平均相似度 ≥0.5。
const MinWriteConfidence = 0.5
// knownTools 真实命令/CLI 名(小写)白名单 —— tool_combo 成员的**唯一**来源。
// t_639c5a03 二轮(小怡 2026-10-30 裁定):原"含 -/数字即可"的命令式标识兜底放行是
// hash 片段(374b20f98c44/ac10)、目录名(05-ai-agent)、文件名(dispatch-backlog.jsonl)、
// CVE/协议碎片(cve-2026-99999/ipv4)的进入路径,已整体移除;白名单外一律不进组合键。
// 同时移除过于通用的条目(git/go/curl/grep/cat/ls/find/…,见 genericTools)。
// 新增工具 = 改这张表(可审计),不再靠形态猜测。
var knownTools = map[string]bool{
"jq": true, "yq": true, "git": true, "grep": true, "sed": true, "awk": true,
"curl": true, "wget": true, "tar": true, "zip": true, "unzip": true,
"jq": true, "yq": true, "sed": true, "awk": true,
"wget": true, "tar": true, "zip": true, "unzip": true,
"docker": true, "kubectl": true, "helm": true, "npm": true, "pip": true,
"go": true, "python": true, "python3": true, "node": true, "pnpm": true,
"python": true, "python3": true, "node": true, "pnpm": true,
"make": true, "cmake": true, "gcc": true, "gcc-12": true, "clang": true,
"sqlite3": true, "psql": true, "mysql": true, "redis-cli": true,
"ssh": true, "scp": true, "rsync": true, "systemctl": true, "journalctl": true,
"systemd-run": true, "crontab": true, "find": true, "rg": true, "ripgrep": true,
"fd": true, "fzf": true, "gh": true, "gcloud": true, "aws": true, "jq2": true,
"hermes": true, "zhiyi": true, "obsidian": true, "esbuild": true, "ts": true,
"systemd-run": true, "crontab": true, "rg": true, "ripgrep": true,
"fd": true, "fzf": true, "gh": true, "gcloud": true, "aws": true,
"hermes": true, "zhiyi": true, "obsidian": true, "esbuild": true,
"tsc": true, "cargo": true, "rustc": true, "swift": true, "brew": true,
"bash": true, "sh": true, "zsh": true, "openssl": true,
"flock": true, "nc": true,
"java": true, "javac": true, "mvn": true, "gradle": true, "dotnet": true,
"remotion": true, "uv": true, "pytest": true, "gofmt": true, "golangci-lint": true,
}
// genericTools 过于通用的词/命令(小怡二轮裁定 #4 点名 git/go/curl/grep/bot-chat/mcp/api「等」):
// 几乎任何任务都命中 ⇒ 进 pattern 等于往召回 top-3 灌噪声。即使形态合格也**绝不**作为组合成员。
// md/json/py/api/log/db 等亦在 toolStopwords 覆盖内,这里重复登记作双保险。
// 后半段是 shell 基础词(cat/ls/find/…,「等」的裁量延伸:与 git 同级别的散文高频词)。
var genericTools = map[string]bool{
"git": true, "go": true, "curl": true, "grep": true, "bot-chat": true,
"mcp": true, "api": true, "md": true, "json": true, "py": true,
"cat": true, "ls": true, "cp": true, "mv": true, "rm": true, "mkdir": true,
"head": true, "tail": true, "sort": true, "uniq": true, "wc": true, "find": true,
"xargs": true, "kill": true, "ps": true, "chmod": true, "touch": true,
"diff": true, "echo": true, "ping": true, "lsof": true, "tar": true,
}
// commonExts 常见文件扩展名(小写,含点)。token 以此结尾 ⇒ 文件名/扩展名,不是工具。
var commonExts = map[string]bool{
".md": true, ".go": true, ".json": true, ".py": true, ".js": true, ".ts": true,
".tsx": true, ".jsx": true, ".yaml": true, ".yml": true, ".toml": true, ".sh": true,
".db": true, ".sql": true, ".csv": true, ".log": true, ".txt": true, ".html": true,
".css": true, ".rs": true, ".c": true, ".h": true, ".cpp": true, ".conf": true,
".xml": true, ".ini": true, ".env": true, ".pdf": true, ".png": true, ".jpg": true,
".jpeg": true, ".svg": true, ".gif": true, ".ipynb": true, ".lock": true, ".sum": true,
".bak": true, ".tmp": true, ".gz": true, ".mp4": true, ".mp3": true, ".docx": true,
".xlsx": true, ".pptx": true, ".service": true, ".sock": true, ".socket": true,
}
// toolStopwords 常见停用词(小写):高频非工具词,绝不作为工具名(卡要求第 1 条)。
var toolStopwords = map[string]bool{
"the": true, "and": true, "for": true, "with": true, "that": true, "this": true,
"from": true, "into": true, "then": true, "when": true, "what": true, "they": true,
"have": true, "will": true, "your": true, "not": true, "but": true, "all": true,
"can": true, "use": true, "via": true, "per": true, "are": true, "was": true,
"one": true, "two": true, "ok": true, "yes": true, "true": true, "false": true,
"null": true, "none": true, "etc": true, "vs": true, "id": true, "ids": true,
"ns": true, "log": true, "logs": true, "file": true, "files": true, "path": true,
"line": true, "lines": true, "name": true, "type": true, "data": true, "time": true,
"date": true, "test": true, "note": true, "step": true, "steps": true, "first": true,
"last": true, "next": true, "top": true, "api": true, "http": true, "https": true,
"url": true, "uri": true, "json": true, "yaml": true, "yml": true, "md": true,
"txt": true, "tmp": true, "dev": true, "prod": true, "src": true, "doc": true,
"docs": true, "ref": true, "config": true, "default": true, "error": true,
"errors": true, "warn": true, "info": true, "debug": true, "fail": true,
"failed": true, "success": true, "start": true, "stop": true, "check": true,
"list": true, "get": true, "set": true, "new": true, "old": true, "fix": true,
"pr": true, "mr": true, "db": true,
}
// DetectFromRecords 纯检测核心:从 L1 记忆列表发现重复模式。
@@ -73,7 +135,7 @@ var knownTools = map[string]bool{
// 返回合并去重后的 pattern 列表(同 Name 合并 source_tasks、取更高 confidence)。
func DetectFromRecords(records []models.MemoryRecord, cfg Config) []Pattern {
if cfg.MinComboFreq <= 0 {
cfg.MinComboFreq = 3
cfg.MinComboFreq = 2
}
if cfg.MinClusterSize <= 0 {
cfg.MinClusterSize = 3
@@ -127,16 +189,29 @@ func DetectFromRecords(records []models.MemoryRecord, cfg Config) []Pattern {
c.tasks = append(c.tasks, rec.ID)
}
for name, c := range comboCount {
if len(c.tasks) < cfg.MinComboFreq {
// 二轮:「组合」语义 —— 单工具无共现信息量,≥2 个成员才产 tool_combo。
if len(c.tools) < 2 {
continue
}
// t_639c5a03 门槛:支撑数按**独立来源记忆**(去重且非空 ID)计,< MinComboFreq 直接丢。
seen := map[string]bool{}
var sources []string
for _, id := range c.tasks {
if id != "" && !seen[id] {
seen[id] = true
sources = append(sources, id)
}
}
if len(sources) < cfg.MinComboFreq {
continue
}
merge(Pattern{
Type: "tool_combo",
Name: name,
Data: strings.Join(c.tools, "+"),
Tools: c.tools,
Confidence: confidenceFromFreq(len(c.tasks)),
SourceTasks: c.tasks,
Type: "tool_combo",
Name: name,
Data: strings.Join(c.tools, "+"),
Tools: c.tools,
Confidence: confidenceFromFreq(len(sources)),
SourceTasks: sources,
})
}
@@ -196,6 +271,103 @@ func confidenceFromFreq(freq int) float64 {
return c
}
// isNumericUnitFragment 数值+单位碎片(小怡二轮裁定 #2):^\d+(\.\d+)?(mb|kb|gb|tb|ms|ns|us|s|m|h|x|%)?$
// 例:"2.6mb"、"512kb"、"100ms"、"3x"。纯数字无单位形态由 isNumericFragment 拦截,这里不重复依赖它。
func isNumericUnitFragment(tok string) bool {
i := 0
for i < len(tok) && tok[i] >= '0' && tok[i] <= '9' {
i++
}
if i == 0 {
return false // 无数字前缀 ⇒ 不是该形态
}
if i < len(tok) && tok[i] == '.' {
j := i + 1
for j < len(tok) && tok[j] >= '0' && tok[j] <= '9' {
j++
}
if j == i+1 {
return false // '.' 后无数字 ⇒ 不是该形态
}
i = j
}
switch strings.ToLower(tok[i:]) {
case "":
return true // 纯数字
case "mb", "kb", "gb", "tb", "ms", "ns", "us", "s", "m", "h", "x", "%":
return true
}
return false
}
// isHexOnly token 是否只由 [0-9a-f] 构成(非空)。
func isHexOnly(s string) bool {
if s == "" {
return false
}
for i := 0; i < len(s); i++ {
c := s[i]
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
return false
}
}
return true
}
// isHashFragment hash/uuid 碎片(小怡二轮裁定 #1):
// - 纯 [0-9a-f] 且 len≥6 ⇒ hash 片段(374b20f98c44 / b713b48a70ad / 635dccc0);
// - 纯 [0-9a-f] 且 len≥4 且含数字 ⇒ 短 hash(ac10);'cafe'/'beef' 类纯字母词放行;
// - 形如 <hex≥2>-<hex≥2> 前缀 ⇒ uuid 段(^[0-9a-f]{2,}-[0-9a-f]{2,},如 b713-48a7)。
// 'gcc-12'(gcc 非 hex)、'redis-cli'(r 非 hex)不受影响。
func isHashFragment(tok string) bool {
if idx := strings.IndexByte(tok, '-'); idx > 0 {
head, rest := tok[:idx], tok[idx+1:]
tail := rest
if k := strings.IndexByte(rest, '-'); k >= 0 {
tail = rest[:k]
}
if len(head) >= 2 && isHexOnly(head) && len(tail) >= 2 && isHexOnly(tail) {
return true
}
}
if !isHexOnly(tok) {
return false
}
if len(tok) >= 6 {
return true
}
return len(tok) >= 4 && strings.ContainsAny(tok, "0123456789")
}
// isNumberedDirName 数字编号目录名(小怡二轮裁定 #3):^\d+(-[a-z]+)+$
// 例:"05-ai-agent"、"38-patternfix"。"gcc-12"(首段非数字)不受影响。
func isNumberedDirName(tok string) bool {
parts := strings.Split(tok, "-")
if len(parts) < 2 {
return false
}
first := parts[0]
if first == "" {
return false
}
for i := 0; i < len(first); i++ {
if first[i] < '0' || first[i] > '9' {
return false // 首段非纯数字
}
}
for _, seg := range parts[1:] {
if seg == "" {
return false
}
for i := 0; i < len(seg); i++ {
if seg[i] < 'a' || seg[i] > 'z' {
return false // 某段含数字/非法字符
}
}
}
return true
}
// clusterPaths 贪心成簇:为每条未分配记忆开一个簇,把与其 cosine≥阈值 的其余未分配
// 记忆并入;最终只保留 size≥minSize 的簇。向量缺失时用记录自带 Vector,全缺则退到
// 文本 bigram Jaccard(确定性,不依赖网络)。
@@ -274,35 +446,104 @@ func clusterConfidence(cluster []models.MemoryRecord) float64 {
return sum / float64(pairs)
}
// ExtractTools 从记忆内容提取工具/命令名。识别 knownTools + 兜底 ascii token。
// ExtractTools 从记忆内容提取工具/命令名(t_639c5a03 二轮:白名单唯一来源)。
// 判据(卡·必须实现 #1「来自 tool_use 记录或白名单」+ 小怡二轮"更硬判据"取向):
// 成员必须**命中 knownTools 白名单**(词边界),且过 isValidTool 形态闸(通用词黑名单、
// hash/uuid 片段、数值+单位、数字编号目录名、扩展名、日期/版本片段、停用词等)。
//
// 旧实现的"命令式标识"兜底(白名单外、含 -/数字即放行)是二轮 dry-run 里
// hash 片段(374b20f98c44/ac10)、目录名(05-ai-agent)、文件名(dispatch-backlog.jsonl)、
// CVE/协议碎片(cve-2026-99999/ipv4)的进入路径,已整体移除。
// 确定性:结果排序去重。
func ExtractTools(content string) []string {
found := map[string]bool{}
lower := strings.ToLower(content)
out := make([]string, 0, 4)
for t := range knownTools {
// 词边界匹配(避免 "go" 命中 "google")
if tokenPresent(lower, t) {
found[t] = true
if isValidTool(t) && tokenPresent(lower, t) {
out = append(out, t)
}
}
// 兜底:捕获已知列表外的 ascii 命令式 token(2~14 字符,含 - 的命令风格)
for _, tok := range regexTokenExtract(content) {
lt := strings.ToLower(tok)
if len(lt) < 2 {
continue
}
// 排除明显非工具的常见词(纯小写短词且不在 knownTools 的放行——保持宽松,
// 让工具组合信号进来;过噪由 MinComboFreq 过滤)
found[lt] = true
}
out := make([]string, 0, len(found))
for t := range found {
out = append(out, t)
}
sort.Strings(out)
return out
}
// isValidTool token 形态判据(lowercase):是否为真实命令/工具标识的形状。
// 排除:长度 <2 或 >32;含 '/'、'\\'、'_'(路径、usage_track 键、t_9143c968 任务 ID);
// 以 '.' 开头(".md");无字母(纯数字/纯标点/数字+分隔符);以常见扩展名结尾
// (report.md / a.py);数字/日期/版本片段(2026、2026-09-23、2026+23、09:23、1.2.3、v1.2.3);
// 停用词。
func isValidTool(tok string) bool {
if len(tok) < 2 || len(tok) > 32 {
return false
}
if genericTools[tok] {
return false // 通用词黑名单(二轮 #4)
}
if strings.ContainsAny(tok, `/_\`) {
return false
}
if tok[0] == '.' || tok[0] == '-' {
return false // 隐藏文件 / CLI flag
}
hasLetter := false
for i := 0; i < len(tok); i++ {
if c := tok[i]; (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') {
hasLetter = true
break
}
}
if !hasLetter {
return false
}
if i := strings.LastIndexByte(tok, '.'); i > 0 && commonExts[tok[i:]] {
return false
}
if isNumericFragment(tok) {
return false
}
if isNumericUnitFragment(tok) {
return false // 二轮 #2:2.6mb / 512kb / 100ms
}
if isHashFragment(tok) {
return false // 二轮 #1:374b20f98c44 / ac10 / uuid 片段
}
if isNumberedDirName(tok) {
return false // 二轮 #3:05-ai-agent / 38-patternfix
}
if toolStopwords[tok] {
return false
}
return true
}
// isNumericFragment 纯数字 / 日期 / 时间 / 版本片段:
// - 只由数字与分隔符 [-+:./] 组成("2026"、"2026-09-23"、"2026+23"、"09:23"、"1.2.3"),或
// - 至多一个前导字母 + 其余全为数字/分隔符("v1.2.3"、"v2")。
//
// "gcc-12"/"sqlite3" 这类字母出现在数字之前且不止前导一个 ⇒ 正常标识,返回 false。
func isNumericFragment(tok string) bool {
if tok == "" {
return false
}
seenDigit := false
rest := tok
if c := rest[0]; (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') {
rest = rest[1:] // 至多剥一个前导字母
}
for _, r := range rest {
switch {
case r >= '0' && r <= '9':
seenDigit = true
case r == '-' || r == '+' || r == ':' || r == '.' || r == '/':
// 分隔符放行
default:
return false // 还有别的字母/字符 ⇒ 不是纯数字片段
}
}
return seenDigit
}
// tokenPresent 检查 lower 文本中 t 是否作为独立 token 出现(词边界,避免 "go" 命中 "google")。
func tokenPresent(lower, t string) bool {
if t == "" {
@@ -323,30 +564,6 @@ func tokenPresent(lower, t string) bool {
return false
}
// regexTokenExtract 轻量 token 抽取(无正则依赖,按字符切):
// 返回形如命令/token 的 ascii 串(长度 ≥2,含字母,可能带数字/_.-)。
func regexTokenExtract(content string) []string {
var out []string
var cur []rune
flush := func() {
if len(cur) >= 2 {
out = append(out, string(cur))
}
cur = nil
}
for _, r := range content {
isTok := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
(r >= '0' && r <= '9') || r == '_' || r == '-' || r == '.'
if isTok {
cur = append(cur, r)
} else {
flush()
}
}
flush()
return out
}
func dedupeSorted(in []string) []string {
seen := map[string]bool{}
out := make([]string, 0, len(in))
@@ -447,3 +664,38 @@ func truncateForPattern(s string, n int) string {
}
return string(r[:n]) + "…"
}
// IsValidPatternName pattern 命名键合法性闸门(t_639c5a03,写入前最后防线)。
// 合法形态:
// - tool_combo:<valid-tool>(+<valid-tool>)* —— 每个成员过 isValidTool
// - workflow:cluster-<N>-<fingerprint>
//
// 其余("tool_combo:.md"、"tool_combo:2026+23" 等垃圾键)一律拒绝,
// UpsertPatterns 拒写 —— 即使调用方判据再退化,垃圾也进不了 graph_nodes。
func IsValidPatternName(name string) bool {
i := strings.IndexByte(name, ':')
if i <= 0 {
return false
}
body := name[i+1:]
if body == "" {
return false
}
switch name[:i] {
case "tool_combo":
parts := strings.Split(body, "+")
if len(parts) < 2 {
return false // 「组合」语义:单成员键拒写(二轮)
}
for _, t := range parts {
// 写入闸 = 白名单唯一来源 + 形态检查(通用词/hash/数值/目录碎片黑名单)。
if !knownTools[t] || !isValidTool(t) {
return false
}
}
return true
case "workflow":
return strings.HasPrefix(body, "cluster-")
}
return false
}
+206 -13
View File
@@ -7,10 +7,10 @@ import (
"github.com/xiaoxue/memoryweave/internal/models"
)
// 构造 N 条同一工具组合的记忆(内容里带 jq/git/curl 三个已知工具)。
// 构造 N 条同一工具组合的记忆(内容里带白名单、非通用词的真工具)。
func comboRecords(n int, combo string) []models.MemoryRecord {
// combo 形如 "jq git curl"——内容里出现这三个工具名(空格分隔,保证词边界命中)。
// 上下文用纯中文(非 ascii token),避免回退正则把上下文词误当工具混进组合键。
// combo 形如 "jq sqlite3 rsync"——内容里出现这几个工具名(空格分隔,保证词边界命中)。
// 上下文用纯中文(非 ascii token),避免上下文词误当工具混进组合键。
recs := make([]models.MemoryRecord, 0, n)
for i := 0; i < n; i++ {
recs = append(recs, models.MemoryRecord{
@@ -25,7 +25,7 @@ func comboRecords(n int, combo string) []models.MemoryRecord {
// TestDetect_ToolCombo —— 3 条同工具组合记忆 → 检出 1 个 tool_combo pattern(AC2)。
func TestDetect_ToolCombo(t *testing.T) {
recs := comboRecords(3, "jq git curl")
recs := comboRecords(3, "jq sqlite3 rsync")
pats := DetectFromRecords(recs, DefaultConfig())
var toolPatterns []Pattern
for _, p := range pats {
@@ -37,8 +37,8 @@ func TestDetect_ToolCombo(t *testing.T) {
t.Fatalf("期望 1 个 tool_combo pattern,实际 %d: %#v", len(toolPatterns), toolPatterns)
}
p := toolPatterns[0]
if p.Name != "tool_combo:curl+git+jq" {
t.Fatalf("命名键不符: got %q (工具应排序去重为 curl,git,jq)", p.Name)
if p.Name != "tool_combo:jq+rsync+sqlite3" {
t.Fatalf("命名键不符: got %q (工具应排序去重为 jq,rsync,sqlite3)", p.Name)
}
if len(p.Tools) != 3 {
t.Fatalf("Tools 应含 3 个,实际 %v", p.Tools)
@@ -52,13 +52,13 @@ func TestDetect_ToolCombo(t *testing.T) {
}
}
// TestDetect_BelowThreshold —— 2 条同组合(<3)→ 不产 tool_combo(AC2 反例)。
// TestDetect_BelowThreshold —— 1 条同组合(<2)→ 不产 tool_combo(AC2 反例;门槛 t_639c5a03=2)。
func TestDetect_BelowThreshold(t *testing.T) {
recs := comboRecords(2, "jq git curl")
recs := comboRecords(1, "jq sqlite3 rsync")
pats := DetectFromRecords(recs, DefaultConfig())
for _, p := range pats {
if p.Type == "tool_combo" {
t.Fatalf("2 条同组合不应产 tool_combo,实际产出: %#v", p)
t.Fatalf("1 条同组合不应产 tool_combo,实际产出: %#v", p)
}
}
}
@@ -96,16 +96,209 @@ func TestDetect_Empty(t *testing.T) {
// TestExtractTools —— 词边界:「google」不应命中工具 "go"。
func TestExtractTools_WordBoundary(t *testing.T) {
tools := ExtractTools("I like google and grep the log with jq")
// 应含 grep、jq;不应含 go("google" 被词边界排除)
tools := ExtractTools("I like google and rsync the backup with jq")
// 应含 rsync、jq;不应含 go("google" 被词边界排除;且 go 已进通用词黑名单)
found := map[string]bool{}
for _, s := range tools {
found[s] = true
}
if !found["grep"] || !found["jq"] {
t.Fatalf("应识别 grep/jq,实际 %v", tools)
if !found["rsync"] || !found["jq"] {
t.Fatalf("应识别 rsync/jq,实际 %v", tools)
}
if found["go"] {
t.Fatalf("'google' 误命中工具 'go'(词边界失效): %v", tools)
}
}
// ── t_639c5a03:垃圾 pattern 判据单测(AC2)──────────────────────────────
// fakeMem 一条带指定 ID 的假蒸馏记忆(向量 nil → 走 bigram 回退,确定性)。
func fakeMem(id, content string) models.MemoryRecord {
return models.MemoryRecord{ID: id, Content: content, Category: "distilled", Vector: nil}
}
// TestDetect_JunkTokensRejected —— 3 条含 .md / 2026 / 标点 的假记忆 ⇒ 0 pattern(AC2 第一条)。
// 覆盖卡现象里的垃圾样本:.md、.md+2026-09-23、2026+23 全不得被当工具组合。
func TestDetect_JunkTokensRejected(t *testing.T) {
recs := []models.MemoryRecord{
fakeMem("j1", "今天把结果导出成 .md 文件"),
fakeMem("j2", "版本 .md+2026-09-23 的构建产物"),
fakeMem("j3", "数字 2026+23 是什么意义"),
}
pats := DetectFromRecords(recs, DefaultConfig())
for _, p := range pats {
t.Fatalf("垃圾记忆不应产出任何 pattern,实际 %#v", p)
}
}
// TestDetect_RealToolComboNeeds2Sources —— 2 条真工具共现(jq+rsync)⇒ 1 pattern(AC2 第二条)。
// 同时验证门槛 ≥2 条独立来源(单条不产)。
func TestDetect_RealToolComboNeeds2Sources(t *testing.T) {
// 单条真工具组合 → 不产(门槛 2)
one := DetectFromRecords([]models.MemoryRecord{
fakeMem("o1", "用 jq 和 rsync 同步 json"),
}, DefaultConfig())
for _, p := range one {
if p.Type == "tool_combo" {
t.Fatalf("单条真工具组合不应产 pattern(门槛≥2),实际 %#v", p)
}
}
// 2 条同真组合 → 1 个 tool_combo,成员 jq+rsync,支撑 2 条
two := DetectFromRecords([]models.MemoryRecord{
fakeMem("a1", "先用 jq 解析再 rsync 备份"),
fakeMem("a2", "jq 提取字段后 rsync 同步"),
}, DefaultConfig())
var tc []Pattern
for _, p := range two {
if p.Type == "tool_combo" {
tc = append(tc, p)
}
}
if len(tc) != 1 {
t.Fatalf("2 条同真组合应产 1 个 tool_combo,实际 %d: %#v", len(tc), two)
}
if tc[0].Name != "tool_combo:jq+rsync" {
t.Fatalf("命名键不符: got %q", tc[0].Name)
}
if len(tc[0].SourceTasks) != 2 {
t.Fatalf("支撑应为 2 条独立来源,实际 %v", tc[0].SourceTasks)
}
}
// TestDetect_SingletonToolNoCombo —— 二轮:单工具(即使白名单、≥2 来源)不产 tool_combo(「组合」语义)。
func TestDetect_SingletonToolNoCombo(t *testing.T) {
pats := DetectFromRecords([]models.MemoryRecord{
fakeMem("s1", "用 jq 解析配置文件甲"),
fakeMem("s2", "用 jq 解析配置文件乙"),
}, DefaultConfig())
for _, p := range pats {
if p.Type == "tool_combo" {
t.Fatalf("单工具不应产 tool_combo(组合需≥2成员),实际 %#v", p)
}
}
}
// TestExtractTools_JunkTokenFilter —— 形态级:.md/2026/日期/纯标点/扩展名/停用词/通用词全被滤。
// 二轮(小怡裁定):git/curl 从"应识别"翻转为"必须滤掉"(通用词黑名单)。
func TestExtractTools_JunkTokenFilter(t *testing.T) {
content := "处理 .md 和 2026 与 2026-09-23 以及 2026+23 与 report.md 与 a.py" +
" 还有 git status 和 curl -I https://x 和 ... 和 step 1 以及 sqlite3 dump 和 jq 解析"
tools := ExtractTools(content)
found := map[string]bool{}
for _, s := range tools {
found[s] = true
}
// 垃圾与通用词一律不得出现
for _, junk := range []string{".md", "2026", "2026-09-23", "2026+23", "report", "a", "step",
"https", "-i", "git", "curl"} {
if found[junk] {
t.Fatalf("垃圾/通用 token %q 不应被当工具,实际 %v", junk, tools)
}
}
for _, want := range []string{"sqlite3", "jq"} {
if !found[want] {
t.Fatalf("应识别真标识 %q,实际 %v", want, tools)
}
}
}
// TestExtractTools_RoundTwoJunkClasses —— 小怡二轮 dry-run 里 14 条样本的垃圾类别逐项回归:
// hash 片段 / 短 hash / uuid 段 / 数值+单位 / 数字编号目录名 / 通用词 / CVE、协议碎片
// —— 全部不得出现;只放行白名单真工具。
func TestExtractTools_RoundTwoJunkClasses(t *testing.T) {
content := "参考 05-ai-agent 目录,产物 2.6mb 与 512kb 与 100ms," +
"commit 374b20f98c44 与 b713b48a70ad 与 ac10 与 635dccc0," +
"接口走 bot-chat 与 mcp 与 api,用 git grep go curl 查询," +
"ipv4 与 cve-2026-99999 与 dispatch-backlog.jsonl 与 38-patternfix," +
"真工具是 docker 与 sqlite3 与 jq 与 rsync"
tools := ExtractTools(content)
found := map[string]bool{}
for _, s := range tools {
found[s] = true
}
for _, junk := range []string{
"05-ai-agent", "38-patternfix", "2.6mb", "512kb", "100ms",
"374b20f98c44", "b713b48a70ad", "ac10", "635dccc0",
"bot-chat", "mcp", "api", "git", "grep", "go", "curl",
"ipv4", "cve-2026-99999", "dispatch-backlog.jsonl",
} {
if found[junk] {
t.Fatalf("二轮垃圾类别 token %q 漏网,实际 %v", junk, tools)
}
}
for _, want := range []string{"docker", "sqlite3", "jq", "rsync"} {
if !found[want] {
t.Fatalf("白名单真工具 %q 应被识别,实际 %v", want, tools)
}
}
}
// TestFormPredicates —— 形态级判据的单元断言(hash/uuid/数值单位/编号目录)。
func TestFormPredicates(t *testing.T) {
hashBad := []string{"374b20f98c44", "b713b48a70ad", "635dccc0", "ac10", "b713-48a7", "deadbeef"}
hashGood := []string{"gcc-12", "redis-cli", "cafe", "beef", "fd", "systemd-run", "golangci-lint", "hermes"}
for _, s := range hashBad {
if !isHashFragment(s) {
t.Fatalf("isHashFragment(%q) 应为 true", s)
}
}
for _, s := range hashGood {
if isHashFragment(s) {
t.Fatalf("isHashFragment(%q) 应为 false(真工具标识被误杀)", s)
}
}
numBad := []string{"2.6mb", "512kb", "100ms", "3x", "50%", "1024"}
numGood := []string{"sqlite3", "python3", "gcc-12", "v10"} // v10 由 isNumericFragment 拦,这里不误判成单位碎片
for _, s := range numBad {
if !isNumericUnitFragment(s) {
t.Fatalf("isNumericUnitFragment(%q) 应为 true", s)
}
}
for _, s := range numGood {
if isNumericUnitFragment(s) {
t.Fatalf("isNumericUnitFragment(%q) 应为 false", s)
}
}
dirBad := []string{"05-ai-agent", "38-patternfix", "10-after-sidecar"}
dirGood := []string{"gcc-12", "systemd-run", "redis-cli", "obsidian-vault"}
for _, s := range dirBad {
if !isNumberedDirName(s) {
t.Fatalf("isNumberedDirName(%q) 应为 true", s)
}
}
for _, s := range dirGood {
if isNumberedDirName(s) {
t.Fatalf("isNumberedDirName(%q) 应为 false", s)
}
}
}
// TestIsValidPatternName_Gate —— 写入闸:垃圾/单成员/通用词命名键全拒,白名单多成员键/工作流键放行。
func TestIsValidPatternName_Gate(t *testing.T) {
bad := []string{
"tool_combo:.md", "tool_combo:2026+23", "tool_combo:.md+2026-09-23",
"tool_combo:2026", "tool_combo:report", "tool_combo:2026-09-23",
"tool_combo:usage_track", "tool_combo:t_9143c968", "tool_combo:a+b",
"tool_combo:log", "tool_combo:",
// 二轮新增:单成员、通用词成员、hash/数值/目录碎片成员
"tool_combo:sqlite3", "tool_combo:jq", "tool_combo:git+jq",
"tool_combo:curl+git+jq", "tool_combo:374b20f98c44+jq",
"tool_combo:05-ai-agent", "tool_combo:2.6mb", "tool_combo:ac10",
"tool_combo:cve-2026-99999", "tool_combo:ipv4+jq", "tool_combo:bot-chat",
}
for _, name := range bad {
if IsValidPatternName(name) {
t.Fatalf("垃圾命名键 %q 不应通过闸门", name)
}
}
good := []string{
"tool_combo:jq+rsync", "tool_combo:docker+sqlite3",
"tool_combo:hermes+zhiyi+jq",
"workflow:cluster-3-abcdef12", "workflow:cluster-2-00aa11bb",
}
for _, name := range good {
if !IsValidPatternName(name) {
t.Fatalf("合法命名键 %q 应通过闸门", name)
}
}
}
+1 -1
View File
@@ -169,7 +169,7 @@ func peerOpenReadOnly(path string) (*sql.DB, error) {
return sql.Open("sqlite", "file:"+path+"?mode=ro&_pragma=busy_timeout(5000)")
}
// ── 深度根因:即使修好 NULL-id,85 行 pattern 无 data/tools 键(只有 description),
// ── 深度根因:即使修好 NULL-id,85 行 pattern_stat 无 data/tools 键(只有 description),
// Match 只能 embed 命名键(如 "pattern:hermes-main:entity")⇒ 对金标余弦 < 0.6 阈值。
// 本测试量化「修好 NULL-id 后开态到底能注入几类金标」—— 证明开态收益仍≈0。
func TestShadowPattern_CosineIfIDFixed(t *testing.T) {
+12 -2
View File
@@ -1,7 +1,8 @@
// 织忆 MemoryWeave — L2 pattern 存储(t_d32e5f8a)
//
// 存储沿用现有 graph_nodes(type='pattern'),**不另开新表**(避免与 85 存量 +
// 现有图谱查询割裂)。modernc sqlite 短命单发开/关,不持长连接、不碰 state.db。
// 存储沿用现有 graph_nodes(type='pattern'),**不另开新表**(避免与现存图谱查询割裂;
// 表内另有 85 行 type='pattern_stat' 属旧 daemon 统计快照,与 L2 pattern 无关)。
// modernc sqlite 短命单发开/关,不持长连接、不碰 state.db。
//
// 写入幂等语义(复活 L1→L2 occurrence_count 驱动):
// - 同名 pattern 节点存在 → occurrence_count += 1、last_seen 刷新、props 合并(新键覆盖、旧键保留)
@@ -116,6 +117,15 @@ func (s *GraphPatternStore) UpsertPatterns(patterns []Pattern) (created, updated
now := time.Now().UTC().Format(time.RFC3339)
for _, p := range patterns {
// t_639c5a03 最后写入门闸:垃圾命名键(tool_combo:.md / tool_combo:2026+23 等)拒写。
if !IsValidPatternName(p.Name) {
continue
}
// t_639c5a03 二轮(小怡裁定 #5):conf<MinWriteConfidence(0.5) 一律不落库
// —— freq=2 ⇒ conf=0.2 的弱信号只出现在 dry-run 观测里,不污染召回。
if p.Confidence < MinWriteConfidence {
continue
}
// 读现有
var (
id string
+14 -14
View File
@@ -45,13 +45,13 @@ func TestStore_UpsertEmpty(t *testing.T) {
func TestStore_UpsertCreate(t *testing.T) {
s := newTestStore(t)
p := Pattern{
ID: "pat_tool_combo:curl+git+jq",
ID: "pat_tool_combo:jq+rsync+sqlite3",
Type: "tool_combo",
Name: "tool_combo:curl+git+jq",
Data: "curl+git+jq",
Confidence: 0.3,
Tools: []string{"curl", "git", "jq"},
SourceTasks: []string{"m_a", "m_b", "m_c"},
Name: "tool_combo:jq+rsync+sqlite3",
Data: "jq+rsync+sqlite3",
Confidence: 0.6,
Tools: []string{"jq", "rsync", "sqlite3"},
SourceTasks: []string{"m_a", "m_b", "m_c", "m_d", "m_e", "m_f"},
}
created, updated, err := s.UpsertPatterns([]Pattern{p})
if err != nil {
@@ -71,17 +71,17 @@ func TestStore_UpsertCreate(t *testing.T) {
func TestStore_UpsertIdempotent(t *testing.T) {
s := newTestStore(t)
p := Pattern{
ID: "pat_tool_combo:curl+git+jq",
ID: "pat_tool_combo:jq+rsync+sqlite3",
Type: "tool_combo",
Name: "tool_combo:curl+git+jq",
Data: "curl+git+jq",
Confidence: 0.3,
Tools: []string{"curl", "git", "jq"},
SourceTasks: []string{"m_a", "m_b", "m_c"},
Name: "tool_combo:jq+rsync+sqlite3",
Data: "jq+rsync+sqlite3",
Confidence: 0.6,
Tools: []string{"jq", "rsync", "sqlite3"},
SourceTasks: []string{"m_a", "m_b", "m_c", "m_d", "m_e"},
}
c1, u1, _ := s.UpsertPatterns([]Pattern{p}) // 新建
p.Confidence = 0.4 // 第二次检测:同组合再次出现(freq 抬升)
p.SourceTasks = []string{"m_d"}
p.Confidence = 0.8 // 第二次检测:同组合再次出现(freq 抬升)
p.SourceTasks = []string{"m_f"}
c2, u2, _ := s.UpsertPatterns([]Pattern{p}) // 应幂等更新(updated=1)
if c1 != 1 || u1 != 0 {
t.Fatalf("首次应 created=1/updated=0,实际 %d/%d", c1, u1)
+42
View File
@@ -0,0 +1,42 @@
package pattern
import "testing"
// TestStore_WriteConfidenceGate —— 二轮裁定 #5:conf<MinWriteConfidence(0.5) 一律不落库。
// 命名键合法(白名单、≥2 成员)也不行——弱信号只出现在 dry-run 观测里。
func TestStore_WriteConfidenceGate(t *testing.T) {
s := newTestStore(t)
low := Pattern{
ID: "pat_tool_combo:jq+rsync", Type: "tool_combo",
Name: "tool_combo:jq+rsync", Data: "jq+rsync",
Confidence: 0.2, // freq=2 ⇒ 0.2,正是被点名的弱信号档
Tools: []string{"jq", "rsync"},
SourceTasks: []string{"m_1", "m_2"},
}
created, updated, err := s.UpsertPatterns([]Pattern{low})
if err != nil {
t.Fatalf("upsert 报错: %v", err)
}
if created != 0 || updated != 0 {
t.Fatalf("conf=0.2 < 门槛 0.5 应拒写,实际 created=%d updated=%d", created, updated)
}
ok := low
ok.Name = "tool_combo:jq+sqlite3"
ok.ID = "pat_tool_combo:jq+sqlite3"
ok.Data = "jq+sqlite3"
ok.Confidence = 0.5 // 恰好达门槛:允许写
created, _, err = s.UpsertPatterns([]Pattern{ok})
if err != nil {
t.Fatalf("upsert 报错: %v", err)
}
if created != 1 {
t.Fatalf("conf=0.5 恰达门槛应写入 1 条,实际 created=%d", created)
}
}
// TestMinWriteConfidenceValue —— 门槛值与卡面(小怡裁定 ≥0.5)钉死,防手滑改低。
func TestMinWriteConfidenceValue(t *testing.T) {
if MinWriteConfidence != 0.5 {
t.Fatalf("MinWriteConfidence 应为 0.5(小怡 2026-10-30 裁定 #5),实际 %v", MinWriteConfidence)
}
}
+17 -4
View File
@@ -19,10 +19,17 @@ import (
)
// Local2BReasoner 用本地 2B 生成四层推理画像。
// env:LLM_ENDPOINT(默认 http://127.0.0.1:8081/v1/chat/completions)
//
// LLM_MODEL (默认 local/minicpm5-2b)
// env 查找顺序(2026-10-26 加前两项,起因 t_a5ef04a5):
//
// ZHIYI_PEER_LLM_ENDPOINT(peer 专用,优先)→ LLM_ENDPOINT → 默认 8081 本地 2B
// ZHIYI_PEER_LLM_MODEL (peer 专用,优先)→ LLM_MODEL → 默认 local/minicpm5-2b
// LLM_TIMEOUT (默认 120s)
//
// 为什么要 peer 专用变量:LLM_ENDPOINT 是**全进程共享**的(蒸馏 llm_judge、
// adversarial_audit、skillgen、consolidate client 都读它,见 server.go:230)。
// 把全局指向 8081 会把蒸馏从 agnes-2.5-flash 顺手降级成 2B;而 newapi(3000)
// 需要鉴权、本 reasoner 按设计不发 Authorization ⇒ 必然 401。故只给 peer 开专用口子。
type Local2BReasoner struct {
endpoint string
model string
@@ -32,11 +39,17 @@ type Local2BReasoner struct {
// NewLocal2BReasoner 读 env 构造本地 2B 推理器。
func NewLocal2BReasoner() *Local2BReasoner {
endpoint := os.Getenv("LLM_ENDPOINT")
endpoint := os.Getenv("ZHIYI_PEER_LLM_ENDPOINT")
if endpoint == "" {
endpoint = os.Getenv("LLM_ENDPOINT")
}
if endpoint == "" {
endpoint = "http://127.0.0.1:8081/v1/chat/completions"
}
model := os.Getenv("LLM_MODEL")
model := os.Getenv("ZHIYI_PEER_LLM_MODEL")
if model == "" {
model = os.Getenv("LLM_MODEL")
}
if model == "" {
model = "local/minicpm5-2b"
}
@@ -0,0 +1,53 @@
package selfoptimize
import "testing"
// 2026-10-26(t_da03e910 终审):把「计数器语义」和「t8 阈值可配」钉住。
//
// 背景:审计曾把 deprecated_per_day 当成率指标报「0 样本却 valid=None ⇒ 缺样本门」,
// 实为探针模型错配(计数器无分母)。真正的缺陷是:
// ① 0 无法区分「今天没淘汰」与「从来没统计过」→ 加 DeprecatedTotal;
// ② executor t8 硬编码 >5 且无视 Condition(配置写 0.1 从不生效)。
func TestRecordDeprecation_CountsBothTodayAndTotal(t *testing.T) {
oldToday, oldTotal := Dash.DeprecatedToday, Dash.DeprecatedTotal
defer func() { Dash.DeprecatedToday, Dash.DeprecatedTotal = oldToday, oldTotal }()
Dash.DeprecatedToday, Dash.DeprecatedTotal = 0, 100
Dash.RecordDeprecation()
Dash.RecordDeprecation()
if Dash.DeprecatedToday != 2 || Dash.DeprecatedTotal != 102 {
t.Fatalf("today=%d total=%d,期望 2/102:累计值必须跨天保留,日值才归零",
Dash.DeprecatedToday, Dash.DeprecatedTotal)
}
m := Dash.Metrics()
if v, ok := m["deprecated_total"]; !ok || v != 102 {
t.Fatalf("metrics 缺 deprecated_total 或值不对: %v (ok=%v)", v, ok)
}
// 计数器不该带 _samples/_valid(有分母才有样本门)
for _, k := range []string{"deprecated_samples", "deprecated_valid"} {
if _, bad := m[k]; bad {
t.Fatalf("计数器不应用率语义键 %s", k)
}
}
}
func TestTDecayCritical_ReadsConditionNotHardcode(t *testing.T) {
te := &TriggerExecutor{}
th := TriggerDef{ID: "t8", Type: TDecayCritical, Condition: 5.0, Enabled: true}
if te.evaluate(&th, map[string]float64{"deprecated_per_day": 4}) {
t.Fatal("4 条/天不应触发(阈值 5)")
}
if !te.evaluate(&th, map[string]float64{"deprecated_per_day": 6}) {
t.Fatal("6 条/天应触发(阈值 5)")
}
// 配置必须真的生效:把阈值调到 10,同样 6 条就不该触发。
th.Condition = 10.0
if te.evaluate(&th, map[string]float64{"deprecated_per_day": 6}) {
t.Fatal("Condition=10 时 6 条不该触发 —— 说明仍在用硬编码阈值")
}
}
+8 -2
View File
@@ -63,7 +63,10 @@ func makeTriggers() []*TriggerDef {
{ID: "t5", Type: TGapDetected, Condition: true, Action: "gap_fill", Cooldown: 5 * time.Minute, Enabled: true},
{ID: "t6", Type: TConflict, Condition: 3, Action: "conflict_muchen", Cooldown: 10 * time.Minute, Enabled: true},
{ID: "t7", Type: TVValueLow, Condition: 0.2, Action: "mem_review", Cooldown: 12 * time.Hour, Enabled: true},
{ID: "t8", Type: TDecayCritical, Condition: 0.1, Action: "archive_or_boost", Cooldown: 6 * time.Hour, Enabled: true},
// t8 的 Condition 是「当天废弃条数」阈值(计数器语义,不是率)。
// 2026-10-26:原先这里写 0.1 但判据函数硬编码 >5 —— 配置值从不被读取,
// 改配置也改不动行为。现改为 Condition: 5(与硬编码等价,行为不变)+ 判据读 t.Condition。
{ID: "t8", Type: TDecayCritical, Condition: 5.0, Action: "archive_or_boost", Cooldown: 6 * time.Hour, Enabled: true},
}
}
@@ -145,7 +148,10 @@ func (te *TriggerExecutor) evaluate(t *TriggerDef, metrics map[string]float64) b
v, ok := metrics["recall_usefulness_rate"]
return ok && !IsNA(v) && v < threshold
case TDecayCritical:
return metrics["deprecated_per_day"] > 5
// counter 语义:deprecated_per_day 是「当天废弃条数」,阈值同单位(条/天)。
threshold, _ := t.Condition.(float64)
v, ok := metrics["deprecated_per_day"]
return ok && v > threshold
}
return false
}
+37 -21
View File
@@ -45,15 +45,19 @@ func dashboardLocalPath() string {
// dashboardState 可序列化快照 —— Dashboard 内含 sync.RWMutex,不能直接 Marshal/整块复制
type dashboardState struct {
UsefulCount int `json:"useful_count"`
NotUsefulCount int `json:"not_useful_count"`
TotalRecalls int `json:"total_recalls"`
HitCount int `json:"hit_count"`
ClosedGaps int `json:"closed_gaps"`
TotalGaps int `json:"total_gaps"`
CascadeFixedTotal int `json:"cascade_fixed_total"`
TotalFixes int `json:"total_fixes"`
DeprecatedToday int `json:"deprecated_today"`
UsefulCount int `json:"useful_count"`
NotUsefulCount int `json:"not_useful_count"`
TotalRecalls int `json:"total_recalls"`
HitCount int `json:"hit_count"`
ClosedGaps int `json:"closed_gaps"`
TotalGaps int `json:"total_gaps"`
CascadeFixedTotal int `json:"cascade_fixed_total"`
TotalFixes int `json:"total_fixes"`
DeprecatedToday int `json:"deprecated_today"`
// DeprecatedTotal 累计淘汰条数(不跨天归零)。
// 2026-10-26:加它的唯一理由是把「今天 0 条」和「从来没统计过」分开 ——
// 与 pattern 召回那次的教训同型:0 永远有两种含义,指标必须能自证是哪一种。
DeprecatedTotal int `json:"deprecated_total"`
DeprecatedDate string `json:"deprecated_date"` // deprecated_today 的归属日期(跨天归零用)
DistillLossSum float64 `json:"distill_loss_sum"`
DistillLossCount int `json:"distill_loss_count"`
@@ -101,6 +105,7 @@ func loadDashboardLocal() error {
Dash.CascadeFixedTotal = st.CascadeFixedTotal
Dash.TotalFixes = st.TotalFixes
Dash.DeprecatedToday = st.DeprecatedToday
Dash.DeprecatedTotal = st.DeprecatedTotal
if st.DeprecatedDate != todayStr() { // "今日"计数跨天归零(进程跨零点不重置,下次加载时纠正)
Dash.DeprecatedToday = 0
}
@@ -132,6 +137,7 @@ func (d *Dashboard) persistLocalLocked() {
CascadeFixedTotal: d.CascadeFixedTotal,
TotalFixes: d.TotalFixes,
DeprecatedToday: d.DeprecatedToday,
DeprecatedTotal: d.DeprecatedTotal,
DeprecatedDate: todayStr(),
DistillLossSum: d.DistillLossSum,
DistillLossCount: d.DistillLossCount,
@@ -174,7 +180,7 @@ func (d *Dashboard) persist() {
_ = redisPersistence.HSet(dashboardRedisKey, "auto_resolved_conflicts", strconv.Itoa(d.AutoResolvedConflicts))
_ = redisPersistence.HSet(dashboardRedisKey, "total_conflicts", strconv.Itoa(d.TotalConflicts))
_ = redisPersistence.HSet(dashboardRedisKey, "dashboard_schema_version", dashboardSchemaV2) // t_e5997ca4:v2 标记常驻回写
_ = redisPersistence.Expire(dashboardRedisKey, 90*24*time.Hour) // 90 天 TTL
_ = redisPersistence.Expire(dashboardRedisKey, 90*24*time.Hour) // 90 天 TTL
}
// loadDashboard 从 Redis 加载 Dashboard 数据
@@ -244,16 +250,19 @@ func loadDashboard() error {
// ─── 自优化仪表盘 ────────────────────────────────────────
type Dashboard struct {
mu sync.RWMutex
UsefulCount int `json:"useful_count"`
NotUsefulCount int `json:"not_useful_count"`
TotalRecalls int `json:"total_recalls"`
HitCount int `json:"hit_count"`
ClosedGaps int `json:"closed_gaps"`
TotalGaps int `json:"total_gaps"`
CascadeFixedTotal int `json:"cascade_fixed_total"`
TotalFixes int `json:"total_fixes"`
DeprecatedToday int `json:"deprecated_today"`
mu sync.RWMutex
UsefulCount int `json:"useful_count"`
NotUsefulCount int `json:"not_useful_count"`
TotalRecalls int `json:"total_recalls"`
HitCount int `json:"hit_count"`
ClosedGaps int `json:"closed_gaps"`
TotalGaps int `json:"total_gaps"`
CascadeFixedTotal int `json:"cascade_fixed_total"`
TotalFixes int `json:"total_fixes"`
DeprecatedToday int `json:"deprecated_today"`
// DeprecatedTotal 累计淘汰条数(不跨天归零)—— 与「今天 0 条」区分开,
// 因为 0 有两种含义:今天没淘汰 / 从来没统计。指标必须能自证是哪一种。
DeprecatedTotal int `json:"deprecated_total"`
DistillLossSum float64 `json:"distill_loss_sum"`
DistillLossCount int `json:"distill_loss_count"`
DistillSuccessCount int `json:"distill_success_count"`
@@ -316,7 +325,11 @@ func b2f(b bool) float64 {
// Metrics 返回核心指标。
//
// 比率项在分母退化时返回 metricNA(-1) 而非 1;每项比率都配 *_valid / *_samples。
// 计数项(deprecated_per_day)与均值项(avg_distill_loss)同规则。
//
// 计数项(deprecated_per_day / deprecated_total)**不配** *_samples / *_valid:
// 它们没有分母,「样本不足」对它们无定义(2026-10-26 更正:旧注释写作「与均值项
// avg_distill_loss 同规则」是错的 —— 均值项有分母,故有 samples/valid;审计脚本
// 曾据此对计数器报「0 样本」告警,属探针模型错配,不是指标缺陷)。
func (d *Dashboard) Metrics() map[string]float64 {
d.mu.RLock()
defer d.mu.RUnlock()
@@ -391,7 +404,9 @@ func (d *Dashboard) Metrics() map[string]float64 {
"auto_resolve_samples": float64(d.TotalConflicts),
"auto_resolve_valid": autoValid,
// counter 语义:无分母 ⇒ 不给 _samples/_valid(给了会诱导下游当率用)。
"deprecated_per_day": float64(d.DeprecatedToday),
"deprecated_total": float64(d.DeprecatedTotal),
"distill_success_count": float64(d.DistillSuccessCount),
"avg_distill_loss": lossVal,
@@ -922,6 +937,7 @@ func (d *Dashboard) RecordDeprecation() {
d.mu.Lock()
defer d.mu.Unlock()
d.DeprecatedToday++
d.DeprecatedTotal++
d.persist()
}
+4
View File
@@ -156,6 +156,10 @@ func (c *cachedGraphStore) AllEdges(namespace string) []governance.GraphEdge {
return c.inner.AllEdges(namespace)
}
func (c *cachedGraphStore) Stats() (int, int, float64) { return c.inner.Stats() }
func (c *cachedGraphStore) CountNodesByType(typ string) (int, int, string) {
return c.inner.CountNodesByType(typ)
}
func (c *cachedGraphStore) Prune(minWeight float64) { c.inner.Prune(minWeight) }
func (c *cachedGraphStore) ExpandFromResults(results []models.RecallResult, namespace string, maxHops int) []models.RecallResult {
return c.inner.ExpandFromResults(results, namespace, maxHops)
+206 -16
View File
@@ -2,6 +2,7 @@
package storage
import (
"context"
"encoding/json"
"fmt"
"log"
@@ -63,7 +64,7 @@ var (
// 2026-10-13 时间维检索(t_e9a4d18f P0):stale 标记。
// updated_at 距今超过该小时数 ⇒ 响应带 stale:true,让调用方「看见」可能过期。
// 0 = 关闭(默认)⇒ 不产生任何 stale 标记,行为零变化。
staleAfterHours = envFloat("ZHIYI_STALE_AFTER_HOURS", 0)
staleAfterHours = envFloat("ZHIYI_STALE_AFTER_HOURS", 720)
)
// ── t_d32e5f8a L2 模式召回注入(env 门控 ZHIYI_PATTERN_RECALL,默认 0 ⇒ 行为零变化)──
@@ -108,6 +109,12 @@ func bitemporalEnabled() bool {
return false
}
// BitemporalEnabled 导出 bi-temporal 开关(供 API 层在补充通道/图谱注入后做同一口径过滤)。
func BitemporalEnabled() bool { return bitemporalEnabled() }
// ValidityExpired 导出有效期判定(同上;空串=永久有效,解析失败保守视为未过期)。
func ValidityExpired(validTo string, now time.Time) bool { return validityExpired(validTo, now) }
// validityExpired 判断记忆的语义有效期是否已过(valid_to < now ⇒ 失效)。
// valid_to 空串 = 永久有效(NULL 语义,永不失效)。解析失败保守视为未失效(不误伤)。
func validityExpired(validTo string, now time.Time) bool {
@@ -318,6 +325,19 @@ func (p *RecallPipeline) SetMissRecorder(mr MissRecorder) {
// 诊断时置 ZHIYI_QSTRUST_VERBOSE=1。
var qstrustVerbose = os.Getenv("ZHIYI_QSTRUST_VERBOSE") == "1"
// nsFallbackMin 2026-10-26(牧尘令):ns 作用域兜底阈值 —— 作用域内候选少于此值就补一次
// 全库搜索。背景:agent_id 派生的 <agent>-main 里几乎没货(实测 6/7 条),
// 而不传 agent_id 是 200 条 ⇒ 带 agent_id 的调用静默劣化且不报错。
// 默认 20;**ZHIYI_NS_FALLBACK_MIN=0 即关闭**(回滚开关,逐位回到改动前)。
func nsFallbackMin() int {
if v := strings.TrimSpace(os.Getenv("ZHIYI_NS_FALLBACK_MIN")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
return n
}
}
return 20
}
// fuseModeProof 2026-10-26(Step3 / t_9acaa74d §5 A′ 的 nouse 臂):
// ZHIYI_FUSE_MODE=proof ⇒ 融合第二槽 = QualityFromProof(证据条数,确定性),
// 不含 importance(recency×seen)/V。默认关 ⇒ 零行为变化。
@@ -345,6 +365,12 @@ func fuseModeProof() bool {
type RecallMeta struct {
CandidatesScanned int
CacheHit bool
// TraceID / Status / PartialReason(2026-10-26 t_trace_p0 + t_contract_p0):
// 响应契约的最小可观测面 —— trace_id 贯穿整条链路,status=partial 时
// 调用方不得把「空结果」解读为「不存在」。
TraceID string
Status string // complete | partial(空串等价 complete,向后兼容)
PartialReason string
// Truncated(t_3aa2a8c1):本次召回的粗检索单次重读是否被 Rust 侧行数上限截断
// (lancedb_search 单次重读 cap,默认 20000,env ZHIYI_HEAVY_READ_MAX_ROWS 可调)。
// true = 部分结果:candidates_scanned/count 反映截断后状态,调用方不得当作「全量」。
@@ -394,20 +420,55 @@ func DefaultMMRDiversity() float64 {
// 「常设答案精确通道」(t_standing_recall,2026-10-25)。
// 通道默认关(ZHIYI_STANDING_ANSWER=1 才生效)⇒ 关闭时与旧行为逐位一致。
func (p *RecallPipeline) RecallWithMeta(query, namespace string, topK int, diversity float64) ([]models.RecallResult, RecallMeta, error) {
results, meta, err := p.recallWithMetaInner(query, namespace, topK, diversity)
return p.RecallWithMetaOpts(query, namespace, topK, diversity, false)
}
// RecallWithMetaOpts 2026-10-26:allowNSFallback=true 时,若 namespace 作用域候选过少
// (ZHIYI_NS_FALLBACK_MIN,默认 20),补一次全库搜索并合并。
// 只有 **agent_id 派生** 的作用域才该传 true —— 显式传 namespace 的调用方传 false,
// 否则会跨域泄漏不该看到的记忆。
func (p *RecallPipeline) RecallWithMetaOpts(query, namespace string, topK int, diversity float64, allowNSFallback bool) ([]models.RecallResult, RecallMeta, error) {
return p.RecallWithMetaTraced(context.Background(), query, namespace, topK, diversity, allowNSFallback, nil)
}
// RecallWithMetaTraced(2026-10-26 t_trace_p0):带 ctx(deadline 可中断)+ 链路追踪的召回入口。
// 旧方法全部委托到本方法、tr=nil ⇒ 旧调用方行为逐位不变。tr 非 nil 时记录各阶段耗时/通道贡献。
// ctx 在每个阶段边界检查,超时 ⇒ 立即返回已得结果并把 status 标为 partial(reason=deadline_exceeded),
// 调用方据此区分「真的没有」与「扫了一半就超时」(Air Canada 式静默幻觉的结构性入口)。
func (p *RecallPipeline) RecallWithMetaTraced(ctx context.Context, query, namespace string, topK int, diversity float64, allowNSFallback bool, tr *RecallTrace) ([]models.RecallResult, RecallMeta, error) {
if ctx == nil {
ctx = context.Background()
}
// 常设答案精确命中 ⇒ 零检索零 LLM 直接返回,不进入 rerank/LLM:
// 省 LLM 配额,且程序性记忆层在 LLM 过载(429)时仍可用。
// 命中口径是归一化后整句相等(question + op.trigger 变体),无模糊面,
// 不会误吞泛化查询;未命中时走下面的完整召回,行为逐位不变。
if sa := standingAnswerFor(query); sa != nil {
return []models.RecallResult{{
ID: "standing:" + sa.QuestionID,
Content: sa.Answer,
Category: "standing_answer",
Score: 1.0,
Timestamp: sa.UpdatedAt.Format(time.RFC3339),
Source: "standing_answer",
Channel: "standing",
}}, RecallMeta{StandingAnswerHit: sa.QuestionID, Status: "ok"}, nil
}
results, meta, err := p.recallWithMetaInner(ctx, query, namespace, topK, diversity, allowNSFallback, tr)
if tr != nil {
meta.TraceID = tr.TraceID
if tr.Status == "partial" {
meta.Status = "partial"
meta.PartialReason = tr.PartialReason
}
}
if err != nil {
return results, meta, err
}
if StandingAnswerEnabled() {
if sa := LookupStandingAnswer(query); sa != nil {
results = PrependStandingAnswer(results, sa, topK)
meta.StandingAnswerHit = sa.QuestionID
}
}
return results, meta, err
}
func (p *RecallPipeline) recallWithMetaInner(query, namespace string, topK int, diversity float64) ([]models.RecallResult, RecallMeta, error) {
func (p *RecallPipeline) recallWithMetaInner(ctx context.Context, query, namespace string, topK int, diversity float64, allowNSFallback bool, tr *RecallTrace) ([]models.RecallResult, RecallMeta, error) {
// topK 先归一:缓存键必须与写入侧用的 topK 一致。recallUncached 内部也会归一,
// 若此处不先归一,topK<=0 的调用读到的是"归一前的键"、永远命中不了自己写的条目。
if topK <= 0 {
@@ -424,11 +485,17 @@ func (p *RecallPipeline) recallWithMetaInner(query, namespace string, topK int,
}
p.recordRecallWrites(filtered)
// t_1c12e000:命中缓存 ⇒ 显式告知调用方(candidates_scanned 恒 0)
return filtered, RecallMeta{CacheHit: true}, nil
st := time.Now()
if tr != nil {
tr.CacheHit = true
tr.TopK = topK
tr.StageNote("cache_hit", st, len(filtered), "候选池不可重建(读 60s 内快照)")
}
return filtered, RecallMeta{CacheHit: true, Status: "complete"}, nil
}
}
}
return p.recallUncached(query, namespace, topK, diversity)
return p.recallUncached(ctx, query, namespace, topK, diversity, allowNSFallback, tr)
}
// Recall 保持原签名(缓存 + 全量流程),行为不变。
@@ -439,16 +506,30 @@ func (p *RecallPipeline) Recall(query, namespace string, topK int, diversity flo
// recallUncached Recall 的非缓存路径。候选池大小在 Step 2.5 过滤后、
// Step 3 截断前捕获(真实粗检索池,不是最终产出数)。
func (p *RecallPipeline) recallUncached(query, namespace string, topK int, diversity float64) ([]models.RecallResult, RecallMeta, error) {
func (p *RecallPipeline) recallUncached(ctx context.Context, query, namespace string, topK int, diversity float64, allowFallback bool, tr *RecallTrace) ([]models.RecallResult, RecallMeta, error) {
if topK <= 0 {
topK = 10
}
if ctx == nil {
ctx = context.Background()
}
if tr != nil {
tr.TopK = topK
}
// Step 1: Encode query
stEncode := time.Now()
queryVec, err := p.embedder.EncodeQuery(query)
if err != nil {
if tr != nil {
tr.StageNote("encode", stEncode, 0, err.Error())
tr.Error = err.Error()
}
return nil, RecallMeta{}, fmt.Errorf("recall encode: %w", err)
}
if tr != nil {
tr.Stage("encode", stEncode, 0)
}
// Step 2: Coarse search — own namespace + shared(§1.3 召回规则)
// 默认搜自己的 namespace + shared,显式 include_namespaces 时才跨域
@@ -459,6 +540,7 @@ func (p *RecallPipeline) recallUncached(query, namespace string, topK int, diver
// ⇒ 默认召回只能看到极小一片(全表 10172 条,默认调用实测仅 11 个候选)。
// 显式传 namespace / agent_id 时保持原作用域行为,不改变调用方语义。
var candidates []models.MemoryRecord
stCoarse := time.Now()
// t_3aa2a8c1:Rust 侧单次重读行数上限的 truncated 上抛到 recall 响应。
// 只有实现了可选扩展接口 SearchTruncationReporter 的后端(RustLanceDBClient)
// 会上报截断;未实现的(纯 Go / 测试 fake)保持旧 Search ⇒ 恒未截断。
@@ -496,6 +578,36 @@ func (p *RecallPipeline) recallUncached(query, namespace string, topK int, diver
candidates = append(candidates, nsResults...)
}
}
if tr != nil {
tr.Stage("coarse_search", stCoarse, len(candidates))
tr.CandidatesScanned = len(candidates)
}
// deadline 边界(t_contract_p0):粗检索已超时 ⇒ 立刻返回 partial,
// 不继续做重排/MMR(省算力,也让调用方拿到明确的「不完整」信号)。
if err := ctx.Err(); err != nil {
if tr != nil {
tr.MarkPartial("deadline_exceeded")
}
return nil, RecallMeta{CandidatesScanned: len(candidates), Truncated: truncated, Status: "partial", PartialReason: "deadline_exceeded"}, nil
}
// 2026-10-26:**ns 作用域兜底**(牧尘令,可逆)。
// 背景:请求带 agent_id 时 resolveRecallNamespace 会派生 <agent>-main,
// 而该 ns 里几乎没有货 —— 实测 default-main 池 6 条 / xiaoyi-main 6 / prof-cloud-main 7,
// 不传 agent_id 则是 200 条。结果:召回静默劣化到个位数,不报错(见 note §M9)。
// 规则:仅当 allowFallback=true(= agent_id 派生作用域,**显式传 namespace 的调用方不兜底**,
// 避免跨域泄漏);候选数 < ZHIYI_NS_FALLBACK_MIN(默认 20,0=关闭)则补一次全库搜索并合并。
// 回滚:ZHIYI_NS_FALLBACK_MIN=0(或删该 env)即逐位回到改动前。
if allowFallback && namespace != "" {
if minN := nsFallbackMin(); minN > 0 && len(candidates) < minN {
fullLib, err := searchCoarse(200, "")
if err == nil && len(fullLib) > 0 {
log.Printf("[recall] ns 兜底:namespace=%q 仅 %d 条候选(<%d) ⇒ 合并全库 %d 条",
namespace, len(candidates), minN, len(fullLib))
candidates = append(candidates, fullLib...)
}
}
}
// 去重(按 ID)
seen := make(map[string]bool)
unique := make([]models.MemoryRecord, 0, len(candidates))
@@ -520,6 +632,7 @@ func (p *RecallPipeline) recallUncached(query, namespace string, topK int, diver
// 门控关 ⇒ 本块不执行(零行为变化)。
var ftsTail []models.MemoryRecord
ftsScoreOf := map[string]float64{} // id → 词面分(只用于尾部排序)
stFts := time.Now()
if ftsEnabled() {
ftsScores := ftsInstance.FtsSearch(query, 30)
for id, s := range ftsScores {
@@ -551,6 +664,9 @@ func (p *RecallPipeline) recallUncached(query, namespace string, topK int, diver
log.Printf("[recall] FTS 词面通道命中 %d 条(候选池 %d)", len(ftsTail), len(candidates))
}
}
if tr != nil {
tr.Stage("fts_recall", stFts, len(ftsTail))
}
// Step 2.5: 过滤原始对话内容(category=episodes/对话/对话记录)
// 原因:原始对话未蒸馏,不应出现在 recall 结果中
@@ -623,6 +739,7 @@ func (p *RecallPipeline) recallUncached(query, namespace string, topK int, diver
// 因为下面会原地覆盖为 BM25 融合值(0.7*dbQuality + 0.3*kwScore)。
// 响应需要展示 DB 原值("响应值==库值"),不能展示被融合改写后的值。
dbQuality := make([]float64, len(candidates))
stBm25 := time.Now()
if len(candidates) > 0 {
for i := range candidates {
dbQuality[i] = candidates[i].QualityScore
@@ -658,6 +775,10 @@ func (p *RecallPipeline) recallUncached(query, namespace string, topK int, diver
}
}
if tr != nil {
tr.Stage("bm25", stBm25, len(candidates))
}
if len(candidates) == 0 && len(ftsTail) == 0 {
// 自动记录召回缺口(gap_scan 触发器会在 30min 冷却后分类)
// t_2afae95e:FTS 尾部位命中也算有结果 ⇒ 不记 miss、不走空返回
@@ -674,6 +795,7 @@ func (p *RecallPipeline) recallUncached(query, namespace string, topK int, diver
// 存储的 Vector,直接算 cosine 即可。旧实现把 query + 50~100 条候选文档重新送进
// 嵌入服务(int8 ONNX CPU),占 recall 总耗时 90%+,是 Hermes 记忆预取 8s 预算
// 被打爆的直接原因。向量不齐/维度不符时自动回退文本重排(行为与旧版一致)。
stRerank := time.Now()
docs := make([]string, len(candidates))
docVecs := make([][]float32, len(candidates))
for i, c := range candidates {
@@ -793,6 +915,17 @@ func (p *RecallPipeline) recallUncached(query, namespace string, topK int, diver
}
// Step 4: MMR diversity
if tr != nil {
tr.Stage("rerank_fusion", stRerank, len(reranked))
}
// deadline 边界:重排已超时 ⇒ 返回 partial(不继续 MMR/图谱扩展)。
if ctx.Err() != nil {
if tr != nil {
tr.MarkPartial("deadline_exceeded")
}
return nil, RecallMeta{CandidatesScanned: poolSize, Truncated: truncated, Status: "partial", PartialReason: "deadline_exceeded"}, nil
}
stMMR := time.Now()
finalIndices := MMRSelect(reranked, candidates, topK*2, diversity)
results := make([]models.RecallResult, 0, topK)
@@ -863,11 +996,22 @@ func (p *RecallPipeline) recallUncached(query, namespace string, topK int, diver
VValue: vv,
// A10 (t_d618dc7c):人工有用单独计数直通(nil=NULL=从未被人工点过)
HumanUsefulCount: c.HumanUsefulCount,
// 2026-10-13 (t_e9a4d18f P0):过期标记(默认关闭 ⇒ 恒 false 不输出)
// 2026-10-13 (t_e9a4d18f P0):过期标记(默认 720h,仅展示不参与排序)
Stale: isStale(c.UpdatedAt, staleAfterHours),
// 2026-10-26(t_trace_p0 / P1 supersede):来源通道 + 版本/取代关系直通
Channel: "vector",
Version: c.Version,
ValidFrom: c.ValidFrom,
ValidTo: c.ValidTo,
Supersedes: c.Supersedes,
SupersededBy: c.SupersededBy,
Superseded: len(c.SupersededBy) > 0,
})
}
}
if tr != nil {
tr.Stage("mmr_select", stMMR, len(results))
}
// Step 3.8 (t_2afae95e FTS 词面通道尾部保证位, 门控同 Step 2.2):
// 词面命中在 **rerank+MMR 之后**才并入最终结果(诊断第 3 条:rerank 之前
@@ -950,6 +1094,14 @@ func (p *RecallPipeline) recallUncached(query, namespace string, topK int, diver
QualityScore: rec.QualityScore,
QualityScoreReliable: false, // FTS 记录未经过质量融合 ⇒ 标不可信(审计可辨)
FtsBoosted: true,
Channel: "fts",
Version: rec.Version,
ValidFrom: rec.ValidFrom,
ValidTo: rec.ValidTo,
Supersedes: rec.Supersedes,
SupersededBy: rec.SupersededBy,
Superseded: len(rec.SupersededBy) > 0,
Stale: isStale(rec.UpdatedAt, staleAfterHours),
// A10 (t_d618dc7c):人工有用单独计数直通(nil=NULL=从未被人工点过)
HumanUsefulCount: rec.HumanUsefulCount,
})
@@ -958,6 +1110,8 @@ func (p *RecallPipeline) recallUncached(query, namespace string, topK int, diver
}
// Step 5: 图谱融合扩展(不再作为后备补丁,每次 recall 都跑 1 跳)
stGraph := time.Now()
graphAdded := 0
if p.graph != nil {
expanded := p.graph.ExpandFromResults(results, namespace, 1)
for _, e := range expanded {
@@ -969,12 +1123,18 @@ func (p *RecallPipeline) recallUncached(query, namespace string, topK int, diver
}
}
if !already {
e.Channel = "graph"
results = append(results, e)
graphAdded++
}
}
}
if tr != nil {
tr.Stage("graph_expand", stGraph, graphAdded)
}
// Step 6: 去重(相同 content 只保留最高分)
stDedup := time.Now()
seenContent := make(map[string]bool)
deduped := make([]models.RecallResult, 0, len(results))
for _, r := range results {
@@ -984,6 +1144,9 @@ func (p *RecallPipeline) recallUncached(query, namespace string, topK int, diver
}
}
results = deduped
if tr != nil {
tr.Stage("dedupe", stDedup, len(results))
}
// Step 6.2: t_d32e5f8a L2 模式召回注入(env 门控 ZHIYI_PATTERN_RECALL,默认 0)
// 命中 pattern 时以 Category="pattern" 附带 top-k 返回(设计「命中即注入」)。
@@ -999,6 +1162,7 @@ func (p *RecallPipeline) recallUncached(query, namespace string, topK int, diver
}
}
if !dup {
pr.Channel = "pattern"
results = append(results, pr)
}
}
@@ -1016,6 +1180,7 @@ func (p *RecallPipeline) recallUncached(query, namespace string, topK int, diver
}
}
if !dup {
pr.Channel = "peer"
results = append(results, pr)
}
}
@@ -1088,7 +1253,18 @@ func (p *RecallPipeline) recallUncached(query, namespace string, topK int, diver
}
}
return results, RecallMeta{CandidatesScanned: poolSize, Truncated: truncated}, nil
// 契约(t_contract_p0):status 默认 complete;行数上限截断 ⇒ partial(
// 调用方不得把截断后的空/少结果解读为「不存在」)。
status := "complete"
partialReason := ""
if truncated {
status = "partial"
partialReason = "row_cap_truncated"
if tr != nil {
tr.MarkPartial("row_cap_truncated")
}
}
return results, RecallMeta{CandidatesScanned: poolSize, Truncated: truncated, Status: status, PartialReason: partialReason}, nil
}
// recallV2 开关(2026-10-14)。默认关 ⇒ 行为零变化。
@@ -1291,7 +1467,7 @@ func cosineSimilarity(a, b []float32) float64 {
func (p *RecallPipeline) recordRecallWrites(results []models.RecallResult) {
ids := make([]string, 0, len(results))
for _, r := range results {
if r.ID != "" {
if isMemoryRowID(r.ID) {
ids = append(ids, r.ID)
}
}
@@ -1305,12 +1481,26 @@ func (p *RecallPipeline) recordRecallWrites(results []models.RecallResult) {
go p.incrementRecallCount(results)
}
// isMemoryRowID 只认真实落在 memories 表里的行 ID(mem_*)。
//
// 2026-10-26(t_0a54c12b 写入放大复发):召回结果集里混着大量**不属于 memories 表**的 ID:
// - ep_* = episodes 原始轮次(另一张表)
// - n_* = 图谱扩展节点(graph_expanded;实测 memories 表 40383 行里 n_* 前缀 = 0)
//
// 这些 ID 送进 update_recall_batch 只会产生「谓词命中 0 行」的提交 —— 而 LanceDB 是 MVCC,
// **每次提交无论命中几行都是一个版本**。实测 2026-10-26 21:26 一条批:
// `delta=1 ids=791 rows=10`(命中率 1.3%)、`delta=2 ids=32 rows=0`(纯空转版本)。
// 过滤后 ① 每条 batch 命中率回到 ~100% ② pending 涨得慢 ⇒ 阈值 flush 次数降一个量级。
func isMemoryRowID(id string) bool {
return strings.HasPrefix(id, "mem_")
}
// incrementRecallCount 旧版逐条同步写(每条结果 = 1 个 LanceDB 版本)。
// 已不作为主路径,仅保留为缓冲不可用时的兜底。
func (p *RecallPipeline) incrementRecallCount(results []models.RecallResult) {
now := time.Now().Format(time.RFC3339)
for _, r := range results {
if r.ID != "" {
if isMemoryRowID(r.ID) {
// t_268b0353(治根):兜底路径同样不写 freshness=verified("被召回"≠"被验证")
// GA-P0-2 (t_c6d41ac6):召回命中 → recall_seen_count(recall_count 冻结)
_ = p.lancedb.Update("memories", r.ID, map[string]any{
@@ -0,0 +1,141 @@
package storage
import (
"fmt"
"testing"
"github.com/xiaoxue/memoryweave/internal/models"
)
// 2026-10-26(牧尘令)· ns 作用域兜底护栏的判据测试。
//
// 背景:请求带 agent_id 时 resolveRecallNamespace 派生 <agent>-main,而实测该 ns 里
// 只有 6~7 条货(default-main 6 / xiaoyi-main 6 / prof-cloud-main 7),不传 agent_id
// 则有 200 条 ⇒ 带 agent_id 的调用静默劣化到个位数、且不报错。
//
// 本组用例锁死四条不变量:
// 1) agent 派生作用域 + 候选过少 ⇒ 必须补一次全库搜索并合并(池变大)。
// 2) **显式 namespace 调用方不兜底**(allowNSFallback=false)⇒ 不得多发全库搜索,
// 否则跨域泄漏。
// 3) ZHIYI_NS_FALLBACK_MIN=0 ⇒ 关闭(回滚开关有效)。
// 4) 候选本来就够多 ⇒ 不兜底(避免每次多打一次 200 行的全库搜索)。
// nsFallbackLance 按 namespaceFilter 返回不同规模的候选:
// 全库("") → 100 条;hermes-main → 2 条;shared → 1 条。
type nsFallbackLance struct {
LanceDB
nsFilters []string
fullRowsN int
nsRowsN int
}
func (f *nsFallbackLance) Search(table string, vector []float32, topK int, namespaceFilter string) ([]models.MemoryRecord, error) {
f.nsFilters = append(f.nsFilters, namespaceFilter)
n := f.nsRowsN
if namespaceFilter == "" {
n = f.fullRowsN
}
out := make([]models.MemoryRecord, 0, n)
for i := 0; i < n; i++ {
ns := namespaceFilter
if ns == "" {
ns = "full"
}
out = append(out, models.MemoryRecord{
ID: fmt.Sprintf("%s-%03d", ns, i),
Content: fmt.Sprintf("%s 记忆 %d", ns, i),
Category: "distilled",
Namespace: ns,
})
}
return out, nil
}
// Update 覆写:后台 goroutine 会回写 recall_count,落到 nil 接口会 panic(见 recall_namespace_test.go 注释)。
func (f *nsFallbackLance) Update(table, id string, fields map[string]any) error { return nil }
// newNSFallbackPipeline 与 newNSTestPipeline 同构,但接受任意 LanceDB 实现
// (newNSTestPipeline 的形参是具体的 *nsRecordingLance)。
func newNSFallbackPipeline(t *testing.T, fake LanceDB) *RecallPipeline {
t.Helper()
saved := SearchCacheInstance
SearchCacheInstance = nil
t.Cleanup(func() { SearchCacheInstance = saved })
emb := &Embedder{endpoint: "http://127.0.0.1:1/never", dim: 1024}
vec := make([]float32, 1024)
for i := range vec {
vec[i] = 0.01
}
emb.cache.Store(nsTestQuery, vec) // 预置缓存 → 不走网络
return NewRecallPipeline(emb, fake, &Reranker{})
}
func (f *nsFallbackLance) countFullSearches() int {
n := 0
for _, ns := range f.nsFilters {
if ns == "" {
n++
}
}
return n
}
func TestNSFallbackMergesFullLibraryWhenScopeTooSmall(t *testing.T) {
fake := &nsFallbackLance{fullRowsN: 100, nsRowsN: 2}
p := newNSFallbackPipeline(t, fake)
_, meta, err := p.RecallWithMetaOpts(nsTestQuery, "hermes-main", 10, 0.3, true)
if err != nil {
t.Fatalf("Recall 出错: %v", err)
}
if fake.countFullSearches() != 1 {
t.Fatalf("✗ 应补 1 次全库搜索,实际 %d 次(调用序列 %v)", fake.countFullSearches(), fake.nsFilters)
}
if meta.CandidatesScanned < 50 {
t.Errorf("✗ 兜底后候选池仍只有 %d 条,应合并到接近全库 100 条", meta.CandidatesScanned)
}
t.Logf("✓ 兜底生效:作用域 2 条 → 合并后池 %d 条(搜索序列 %v)", meta.CandidatesScanned, fake.nsFilters)
}
func TestNSFallbackDisabledForExplicitNamespace(t *testing.T) {
fake := &nsFallbackLance{fullRowsN: 100, nsRowsN: 2}
p := newNSFallbackPipeline(t, fake)
if _, _, err := p.RecallWithMetaOpts(nsTestQuery, "hermes-main", 10, 0.3, false); err != nil {
t.Fatalf("Recall 出错: %v", err)
}
if n := fake.countFullSearches(); n != 0 {
t.Fatalf("✗ 显式 namespace 不得兜底,实际发起了 %d 次全库搜索(序列 %v)", n, fake.nsFilters)
}
t.Logf("✓ 显式 ns 不兜底(防跨域泄漏):搜索序列 %v", fake.nsFilters)
}
func TestNSFallbackCanBeTurnedOffByEnv(t *testing.T) {
fake := &nsFallbackLance{fullRowsN: 100, nsRowsN: 2}
p := newNSFallbackPipeline(t, fake)
t.Setenv("ZHIYI_NS_FALLBACK_MIN", "0") // 回滚开关
if _, _, err := p.RecallWithMetaOpts(nsTestQuery, "hermes-main", 10, 0.3, true); err != nil {
t.Fatalf("Recall 出错: %v", err)
}
if n := fake.countFullSearches(); n != 0 {
t.Fatalf("✗ ZHIYI_NS_FALLBACK_MIN=0 应完全关闭兜底,实际 %d 次", n)
}
t.Logf("✓ 回滚开关有效:ZHIYI_NS_FALLBACK_MIN=0 ⇒ 零全库搜索")
}
func TestNSFallbackSkippedWhenScopeAlreadyLarge(t *testing.T) {
// nsRowsN=30 ≥ 默认阈值 20 ⇒ 不该兜底(否则每次多打一次全库搜索)
fake := &nsFallbackLance{fullRowsN: 100, nsRowsN: 30}
p := newNSFallbackPipeline(t, fake)
_, meta, err := p.RecallWithMetaOpts(nsTestQuery, "hermes-main", 10, 0.3, true)
if err != nil {
t.Fatalf("Recall 出错: %v", err)
}
if n := fake.countFullSearches(); n != 0 {
t.Fatalf("✗ 候选已足够(%d 条)不该兜底,实际 %d 次全库搜索", meta.CandidatesScanned, n)
}
t.Logf("✓ 候选充足不兜底:池 %d 条,零全库搜索", meta.CandidatesScanned)
}
+104 -30
View File
@@ -5,6 +5,7 @@ import (
"os"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
@@ -44,7 +45,13 @@ type BatchRecallUpdater interface {
const (
defaultRecallFlushInterval = 300 * time.Second
defaultRecallFlushMaxItems = 256
// 2026-10-26 (t_0a54c12b):256 → 2048。
// 256 在搜索突发期每 1~2 秒就触发一次 flush(每次 flush = 2~4 个 delta 组 = 2~4 个
// LanceDB 版本),实测 20~200 版本/分钟。待写条目是「不同记忆 ID」,2048 只占几百 KB 内存,
// 把窗口内重复命中合并得更彻底、flush 次数降一个量级。
defaultRecallFlushMaxItems = 2048
// requeue 硬上限:IPC 长时间不可用时 pending 不能无限涨(召回计数是观测值,丢了可接受)。
recallRequeueHardCap = 16384
// 优雅停止窗口(t_fa797edd):Stop 内的「等后台协程退出 + 最终落盘」都必须有界。
// 否则 Rust/LanceDB 批量更新在活 IPC 流量下阻塞时,Stop 永远不返回 → 信号协程
// 到不了 srv.Shutdown → 进程跑满 systemd 90s TimeoutStopSec 被 SIGKILL
@@ -77,6 +84,8 @@ type RecallWriteBuffer struct {
flushRows int64
flushErrors int64
fallbackRows int64
// requeuedItems: IPC 通道不可用时整批退回缓冲的条目数(t_0a54c12b 观测项)
requeuedItems int64
}
// RecallWriteBufferInstance 进程级单例(与 SearchCacheInstance / CoOccurTrackerInstance 同模式)
@@ -205,30 +214,27 @@ func (b *RecallWriteBuffer) Flush() (int, error) {
usedFallback := false
if b.batch != nil {
rows, err = b.batch.UpdateRecallBatch("memories", batch, ts)
}
if b.batch == nil || err != nil {
usedFallback = true
if err != nil {
// 2026-10-26 (t_0a54c12b):**通道不可用**与**语句被拒**必须分开处置。
// 通道不可用(sidecar 重启/拒绝连接/超时)时逐条降级毫无收益:
// ① IPC 不可用 ⇒ 每条都失败,2026-10-26 21:16 实测 **9142 次失败拨号**(日志风暴);
// ② sidecar 恢复瞬间逐条成功 ⇒ **每条 = 1 个 LanceDB 版本**,正是 _versions 冲到 8.5k 的机制。
// 正确做法:整批退回 pending,下个窗口连同新命中一起批量重试(召回计数延迟可接受)。
if isRecallTransportErr(err) {
atomic.AddInt64(&b.flushErrors, 1)
atomic.AddInt64(&b.requeuedItems, int64(len(batch)))
n := b.requeue(batch, lastAt)
log.Printf("[recall-buffer] IPC 通道不可用(%v) → 整批 %d 条退回缓冲(pending=%d),下个窗口重试;不逐条降级(避免拨号风暴 + N 个版本)", err, len(batch), n)
return 0, err
}
log.Printf("[recall-buffer] 单事务批量提交失败(%v) → 退化逐条 Update(数据不丢,版本数不优化)", err)
atomic.AddInt64(&b.flushErrors, 1)
usedFallback = true
rows = b.fallbackRowUpdates(batch, ts)
}
rows = 0
for _, it := range batch {
// t_268b0353(2026-10-11 治根):召回只更新 recall_count / last_recalled_at,
// 不再写 freshness="verified"。freshness=verified 的语义是「经过校验/用户
// 反馈」;"被召回" ≠ "被验证"。此前每次召回都盖 verified → 越常被召回的
// 旧快照越"可信",时效判断全被污染(过期快照被当现状的机制根因之一)。
// GA-P0-2 (t_c6d41ac6):召回命中写 recall_seen_count;不再写 recall_count(冻结)。
if uerr := b.ldb.Update("memories", it.ID, map[string]any{
"recall_seen_count": map[string]string{"$inc": "1"},
"last_recalled_at": ts,
}); uerr != nil {
log.Printf("[recall-buffer] 逐条 Update 失败 id=%s: %v", it.ID, uerr)
continue
}
rows++
}
atomic.AddInt64(&b.fallbackRows, rows)
} else {
usedFallback = true
rows = b.fallbackRowUpdates(batch, ts)
}
// 落盘成功后同步进程内缓存(缓存只是热数据,权威值在 LanceDB)
@@ -254,6 +260,73 @@ func (b *RecallWriteBuffer) Flush() (int, error) {
return int(rows), nil
}
// fallbackRowUpdates 逐条 Update(仅用于「批量语句被拒」或后端不支持批量的事务语义兜底)。
// 每条 = 1 个 LanceDB 版本,故**不得**在 IPC 通道不可用时走这里(见 Flush 里的通道判定)。
func (b *RecallWriteBuffer) fallbackRowUpdates(batch []RecallWriteItem, ts string) int64 {
var rows int64
for _, it := range batch {
// t_268b0353(2026-10-11 治根):召回只更新 recall_count / last_recalled_at,
// 不再写 freshness="verified"。freshness=verified 的语义是「经过校验/用户
// 反馈」;"被召回" ≠ "被验证"。此前每次召回都盖 verified → 越常被召回的
// 旧快照越"可信",时效判断全被污染(过期快照被当现状的机制根因之一)。
// GA-P0-2 (t_c6d41ac6):召回命中写 recall_seen_count;不再写 recall_count(冻结)。
if uerr := b.ldb.Update("memories", it.ID, map[string]any{
"recall_seen_count": map[string]string{"$inc": "1"},
"last_recalled_at": ts,
}); uerr != nil {
log.Printf("[recall-buffer] 逐条 Update 失败 id=%s: %v", it.ID, uerr)
continue
}
rows++
}
atomic.AddInt64(&b.fallbackRows, rows)
return rows
}
// requeue 把一批未落盘的增量退回 pending(IPC 通道不可用时用):delta 累加、时间取更晚者。
// 硬上限 recallRequeueHardCap:IPC 长期不可用时不能无限涨(召回计数是观测值,丢了可接受)。
func (b *RecallWriteBuffer) requeue(batch []RecallWriteItem, lastAt time.Time) int {
b.mu.Lock()
defer b.mu.Unlock()
for _, it := range batch {
if len(b.pending) >= recallRequeueHardCap {
if _, ok := b.pending[it.ID]; !ok {
continue // 满了且是新 ID:丢弃(观测值,可接受)
}
}
p, ok := b.pending[it.ID]
if !ok {
p = &pendingRecall{}
b.pending[it.ID] = p
}
p.delta += it.Delta
if lastAt.After(p.lastAt) {
p.lastAt = lastAt
}
}
return len(b.pending)
}
// isRecallTransportErr 判断是否为「IPC 通道不可用」类错误(可重试、且绝不能逐条降级)。
// 判据是**通道层**错误关键字,不是业务/谓词错误 —— 后者逐条降级有意义(换语句能成),
// 前者逐条只会制造拨号风暴 + 恢复后 N 个版本。
func isRecallTransportErr(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
for _, k := range []string{
"ipc dial", "connection refused", "connection reset", "broken pipe",
"i/o timeout", "context deadline exceeded", "unexpected eof",
"no such file or directory", // unix socket 文件不存在(sidecar 正在重启)
} {
if strings.Contains(msg, k) {
return true
}
}
return false
}
// Stop 停止后台 flush 协程,并把最后一个窗口落盘(进程优雅退出时调用)。
// 全程有界(t_fa797edd):等后台协程 ≤ defaultRecallStopLoopWait,最终落盘
// ≤ defaultRecallStopFlushWait。Rust/LanceDB 批量更新在活 IPC 流量下可能阻塞
@@ -296,14 +369,15 @@ func (b *RecallWriteBuffer) Stats() map[string]interface{} {
pendingN := len(b.pending)
b.mu.Unlock()
return map[string]interface{}{
"pending": pendingN,
"flush_count": atomic.LoadInt64(&b.flushCount),
"flush_items": atomic.LoadInt64(&b.flushItems),
"flush_rows": atomic.LoadInt64(&b.flushRows),
"flush_errors": atomic.LoadInt64(&b.flushErrors),
"fallback_rows": atomic.LoadInt64(&b.fallbackRows),
"window": b.interval.String(),
"max_items": b.maxItems,
"single_tx": b.batch != nil,
"pending": pendingN,
"flush_count": atomic.LoadInt64(&b.flushCount),
"flush_items": atomic.LoadInt64(&b.flushItems),
"flush_rows": atomic.LoadInt64(&b.flushRows),
"flush_errors": atomic.LoadInt64(&b.flushErrors),
"fallback_rows": atomic.LoadInt64(&b.fallbackRows),
"requeued_items": atomic.LoadInt64(&b.requeuedItems),
"window": b.interval.String(),
"max_items": b.maxItems,
"single_tx": b.batch != nil,
}
}
@@ -18,6 +18,8 @@ type fakeLDB struct {
updateCalls int
updateIDs []string
forceBatchErr bool
// batchErr:指定批量提交返回的错误(用于验证「通道不可用」与「语句被拒」分流)。
batchErr error
}
// Scan mock:write-buffer 测试只需占位实现(被测路径不实际调用)。
@@ -72,6 +74,9 @@ func (f *fakeLDB) PurgeInvalid() (int64, error) { return 0, nil }
// BatchRecallUpdater 实现(单事务批量)
func (f *fakeLDB) UpdateRecallBatch(table string, items []RecallWriteItem, lastRecalledAt string) (int64, error) {
if f.batchErr != nil {
return 0, f.batchErr
}
if f.forceBatchErr {
return 0, fmt.Errorf("forced batch failure")
}
@@ -227,6 +232,89 @@ func TestRecallWriteBufferFallbackToPerItem(t *testing.T) {
}
}
// t_0a54c12b:IPC 通道不可用(sidecar 重启/拒绝连接)时必须**整批退回缓冲**,
// 不许逐条降级 —— 逐条在通道断时是 N 次失败拨号,恢复瞬间又变成 N 个 LanceDB 版本
// (2026-10-26 实测 9142 次逐条 Update、_versions 冲 8.5k)。
func TestRecallWriteBufferTransportErrRequeues(t *testing.T) {
fake := &fakeLDB{batchErr: fmt.Errorf("ipc dial: dial unix /tmp/zhiyi-ipc.sock: connect: connection refused")}
b := NewRecallWriteBuffer(fake, 0, 1000)
defer b.Stop()
b.Record([]string{"mem_r1", "mem_r2"})
if _, err := b.Flush(); err == nil {
t.Fatal("通道不可用时 Flush 应返回 err 供上层观测")
}
fake.mu.Lock()
if fake.updateCalls != 0 {
t.Fatalf("通道不可用时禁止逐条降级,实际 updateCalls=%d", fake.updateCalls)
}
fake.mu.Unlock()
if st := b.Stats(); st["pending"].(int) != 2 {
t.Fatalf("整批应退回缓冲 pending=2,实际 %v", st["pending"])
}
// 通道恢复 → 下个窗口连同新命中一起批量落盘,delta 不丢
fake.mu.Lock()
fake.batchErr = nil
fake.mu.Unlock()
b.Record([]string{"mem_r1"}) // 窗口内再命中一次 → r1 delta=2
rows, err := b.Flush()
if err != nil {
t.Fatalf("恢复后 flush 应成功: %v", err)
}
if rows != 2 {
t.Fatalf("恢复后 rows = %d, want 2", rows)
}
fake.mu.Lock()
defer fake.mu.Unlock()
deltas := map[string]int{}
for _, it := range fake.batchItems {
deltas[it.ID] = it.Delta
}
if deltas["mem_r1"] != 2 || deltas["mem_r2"] != 1 {
t.Fatalf("退回缓冲后 delta 应累加不丢,实际 %v", deltas)
}
if st := b.Stats(); st["requeued_items"].(int64) != 2 {
t.Fatalf("requeued_items = %v, want 2", st["requeued_items"])
}
}
// t_0a54c12b:只有 mem_* 会进缓冲。ep_*(episodes 表)与 n_*(图谱扩展节点,
// memories 表实测 0 行)送进 update_recall_batch 只会产生「命中 0 行」的空转版本。
func TestRecallWriteBufferSkipsNonMemoryIDs(t *testing.T) {
// 过滤逻辑本体(recordRecallWrites 用的就是它)
got := memoryRowIDs([]models.RecallResult{
{ID: "mem_keep1"}, {ID: "mem_keep2"},
{ID: "ep_1793020608863378731"}, {ID: "n_静默失效"}, {ID: "n_api_key"}, {ID: ""},
})
if len(got) != 2 || got[0] != "mem_keep1" || got[1] != "mem_keep2" {
t.Fatalf("只应保留 mem_* 两条,实际 %v", got)
}
if isMemoryRowID("ep_x") || isMemoryRowID("n_y") || isMemoryRowID("") || !isMemoryRowID("mem_z") {
t.Fatal("isMemoryRowID 判据错误(只应认 mem_ 前缀)")
}
// 写进缓冲的也只有 mem_*(其余 ID 不进 pending ⇒ 不会触发空转提交)
fake := &fakeLDB{}
b := NewRecallWriteBuffer(fake, 0, 1000)
defer b.Stop()
b.Record(got)
if st := b.Stats(); st["pending"].(int) != 2 {
t.Fatalf("pending = %v, want 2", st["pending"])
}
}
// memoryRowIDs 供测试复用生产过滤逻辑。
func memoryRowIDs(rs []models.RecallResult) []string {
out := make([]string, 0, len(rs))
for _, r := range rs {
if isMemoryRowID(r.ID) {
out = append(out, r.ID)
}
}
return out
}
// Stop 时落盘最后一个窗口(优雅退出不丢统计)
func TestRecallWriteBufferStopFlushes(t *testing.T) {
fake := &fakeLDB{}
@@ -0,0 +1,99 @@
// 织忆 MemoryWeave — stale-return / 生命周期 fixture(P1-3,2026-10-26)
//
// 评审指出的缺口:只有离线检索指标,没有 stale-return 与生命周期 fixture ——
// 即「记忆过期后读侧表现」从未被机械验证过。
//
// 本文件锁三件事:
// ① stale 标记默认开(720h / 30 天)但**只做展示**:老记忆被标 stale,新记忆不标;
// ② bi-temporal 有效期窗口(fixture 取 10:00 生效 / 11:00 失效):
// T<11:00 未过期、T>11:00 已过期 —— 用 10:00→11:00 的真实时窗语义,而非「随便设个过去时间」;
// ③ supersede 反向指针在读侧可见(P1-1):SupersededBy 非空 ⇒ Superseded=true,且 JSON 输出。
package storage
import (
"encoding/json"
"testing"
"time"
"github.com/xiaoxue/memoryweave/internal/models"
)
// TestStale_Default720h 默认阈值 720h:29 天不标、31 天标。
func TestStale_Default720h(t *testing.T) {
if got := envFloat("ZHIYI_STALE_AFTER_HOURS", 720); got != 720 {
t.Fatalf("默认 stale 阈值应为 720h,实际 %v", got)
}
now := time.Now()
if isStale(now.Add(-29*24*time.Hour), 720) {
t.Fatal("29 天前更新不应标 stale")
}
if !isStale(now.Add(-31*24*time.Hour), 720) {
t.Fatal("31 天前更新应标 stale")
}
// 关闭开关(0)⇒ 恒 false,保持旧行为可回滚
if isStale(now.Add(-1000*time.Hour), 0) {
t.Fatal("阈值 0 应关闭 stale 标记")
}
}
// TestValidityWindow_1000to1100 生命周期 fixture:事实 10:00 生效、11:00 失效。
// 判据:只在 (valid_from, valid_to) 区间内被视为有效;区间外(含恰好 11:00 之后)判过期。
func TestValidityWindow_1000to1100(t *testing.T) {
day := time.Date(2026, 10, 26, 0, 0, 0, 0, time.Local)
from := day.Add(10 * time.Hour).Format(time.RFC3339)
to := day.Add(11 * time.Hour).Format(time.RFC3339)
cases := []struct {
name string
now time.Time
want bool // true = 已过期
}{
{"09:30 尚未生效(未过期,只是还没到生效点)", day.Add(9*time.Hour + 30*time.Minute), false},
{"10:30 有效区间内", day.Add(10*time.Hour + 30*time.Minute), false},
{"11:01 已过失效点", day.Add(11*time.Hour + time.Minute), true},
{"23:00 当日深夜", day.Add(23 * time.Hour), true},
}
for _, c := range cases {
if got := validityExpired(to, c.now); got != c.want {
t.Errorf("%s:validityExpired(%s)=%v,期望 %v", c.name, to, got, c.want)
}
}
// valid_to 为空 = 永久有效(NULL 语义)
if validityExpired("", day.Add(1000*time.Hour)) {
t.Fatal("valid_to 为空应视为永久有效")
}
// 非法格式保守视为未过期(不误伤存量脏数据)
if validityExpired("not-a-time", day) {
t.Fatal("valid_to 解析失败应保守视为未过期")
}
_ = from
}
// TestSupersedeReadSideVisible P1-1:版本/取代关系必须真的进 JSON(不是「加了结构体没接线」)。
func TestSupersedeReadSideVisible(t *testing.T) {
r := models.RecallResult{
ID: "mem_old", Content: "旧事实", Channel: "vector",
Version: 2, ValidFrom: "2026-10-26T10:00:00+08:00", ValidTo: "2026-10-26T11:00:00+08:00",
SupersededBy: []string{"mem_new"}, Superseded: true,
}
b, err := json.Marshal(r)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var m map[string]interface{}
if err := json.Unmarshal(b, &m); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if m["superseded"] != true {
t.Fatalf("superseded 便捷布尔应输出 true:%s", string(b))
}
if got, _ := m["superseded_by"].([]interface{}); len(got) != 1 || got[0] != "mem_new" {
t.Fatalf("superseded_by 未输出:%s", string(b))
}
if m["generation"] != float64(2) {
t.Fatalf("generation(version)未输出:%s", string(b))
}
if m["valid_to"] == "" || m["valid_to"] == nil {
t.Fatalf("valid_to 未输出:%s", string(b))
}
}
+87 -13
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"log"
"os"
"strings"
"sync"
"time"
@@ -34,12 +35,26 @@ type StandingAnswer struct {
QuestionID string `json:"question_id"`
Question string `json:"question"`
Answer string `json:"answer"`
// Op(2026-10-28, t_3636b872 程序性记忆层):「怎么做 X」的结构化步骤卡。
// 可空。携带 trigger 变体 + 可执行 steps + 前置检查/验证/回滚/风险。
// 召回时除精确 Question 匹配外,还按 op.trigger 变体匹配(见 standing_match.go)。
Op *OpCard `json:"op,omitempty"` // 可空:存量常设答案无结构卡,行为默认
SourceMemories []string `json:"source_memories,omitempty"`
Version int `json:"version"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// OpCard 程序性步骤卡(MoA 建议形态,2026-10-28)。
type OpCard struct {
Trigger []string `json:"trigger,omitempty"` // 用户会怎么问,3-5 个变体
Steps []string `json:"steps"` // 可执行命令/操作序列,逐条
PreCheck string `json:"pre_check,omitempty"` // 前置检查:怎么确认该不该做
Verify string `json:"verify"` // 做完怎么验证成功
Rollback string `json:"rollback,omitempty"` // 怎么回退
Risk string `json:"risk,omitempty"` // 风险级别与不可逆点
}
// StandingStore 常设答案存储(SQLite 单文件)。
type StandingStore struct {
mu sync.RWMutex
@@ -64,6 +79,7 @@ func NewStandingStore(path string) (*StandingStore, error) {
question_id TEXT PRIMARY KEY,
question TEXT NOT NULL,
answer TEXT NOT NULL,
op TEXT DEFAULT NULL,
source_memories TEXT DEFAULT '[]',
version INTEGER DEFAULT 1,
created_at TEXT NOT NULL,
@@ -72,9 +88,41 @@ func NewStandingStore(path string) (*StandingStore, error) {
db.Close()
return nil, fmt.Errorf("init standing_answers: %w", err)
}
// 2026-10-28 (t_3636b872) 结构步骤卡列:存量库 ALTER 补齐;新库已含。
// 幂等:重复执行看到列已存在则跳过(SQLite 无 IF NOT EXISTS for column)。
if col, err := hasColumn(db, "standing_answers", "op"); err != nil {
db.Close()
return nil, fmt.Errorf("check op column: %w", err)
} else if !col {
if _, err := db.Exec(`ALTER TABLE standing_answers ADD COLUMN op TEXT DEFAULT NULL`); err != nil {
db.Close()
return nil, fmt.Errorf("add op column: %w", err)
}
}
return &StandingStore{db: db, path: path}, nil
}
// hasColumn 判断 SQLite 表是否存在某列(PRAGMA table_info)。
func hasColumn(db *sql.DB, table, col string) (bool, error) {
rows, err := db.Query(`PRAGMA table_info(` + table + `)`)
if err != nil {
return false, err
}
defer rows.Close()
for rows.Next() {
var cid, notnull, pk int
var name, ctype string
var dflt *string
if err := rows.Scan(&cid, &name, &ctype, &notnull, &dflt, &pk); err != nil {
return false, err
}
if name == col {
return true, nil
}
}
return false, rows.Err()
}
func dirOf(p string) string {
for i := len(p) - 1; i >= 0; i-- {
if p[i] == '/' {
@@ -89,7 +137,8 @@ func (s *StandingStore) Close() error { return s.db.Close() }
func (s *StandingStore) Path() string { return s.path }
// Upsert 写入/改写一条常设答案。已有则 version+1 并保留 source_memories;新则 version=1。
func (s *StandingStore) Upsert(qid, question, answer string, source []string) (*StandingAnswer, error) {
// op 可空:nil 时不改既有 op 列(改答案不丢结构卡);写入保留调用方传入的 op。
func (s *StandingStore) Upsert(qid, question, answer string, source []string, op *OpCard) (*StandingAnswer, error) {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
@@ -97,6 +146,10 @@ func (s *StandingStore) Upsert(qid, question, answer string, source []string) (*
if sm == nil {
sm = []byte("[]")
}
opJSON, _ := json.Marshal(op)
if opJSON == nil {
opJSON = nil
}
// 查已有
var ver int
@@ -113,39 +166,57 @@ func (s *StandingStore) Upsert(qid, question, answer string, source []string) (*
if exists {
newVer = ver + 1
_, err = s.db.Exec(
`UPDATE standing_answers SET answer=?, source_memories=?, version=?, updated_at=? WHERE question_id=?`,
answer, string(sm), newVer, now.Format(time.RFC3339), qid,
`UPDATE standing_answers SET answer=?, op=COALESCE(?,op), source_memories=?, version=?, updated_at=? WHERE question_id=?`,
answer, opJSON, string(sm), newVer, now.Format(time.RFC3339), qid,
)
} else {
newVer = 1
created = now.Format(time.RFC3339)
_, err = s.db.Exec(
`INSERT INTO standing_answers(question_id,question,answer,source_memories,version,created_at,updated_at)
VALUES(?,?,?,?,?,?,?)`,
qid, question, answer, string(sm), newVer, created, now.Format(time.RFC3339),
`INSERT INTO standing_answers(question_id,question,answer,op,source_memories,version,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?)`,
qid, question, answer, opJSON, string(sm), newVer, created, now.Format(time.RFC3339),
)
}
if err != nil {
return nil, fmt.Errorf("upsert: %w", err)
}
// 写后丢弃缓存,否则新卡/改卡在 standingCacheTTL(30s)内对召回不可见。
refreshStandingAnswers()
return &StandingAnswer{
QuestionID: qid, Question: question, Answer: answer,
QuestionID: qid, Question: question, Answer: answer, Op: op,
SourceMemories: source, Version: newVer, CreatedAt: now, UpdatedAt: now,
}, nil
}
// parseOpCard 解析 op 列 JSON;空/坏返回 nil。
func parseOpCard(raw sql.NullString) *OpCard {
if !raw.Valid {
return nil
}
s := strings.TrimSpace(raw.String)
if s == "" || s == "null" {
return nil
}
var oc OpCard
if err := json.Unmarshal([]byte(s), &oc); err != nil {
return nil
}
return &oc
}
// Get 直读一条(主键查询,零检索零 LLM)。无则 (nil, nil)。
func (s *StandingStore) Get(qid string) (*StandingAnswer, error) {
s.mu.RLock()
defer s.mu.RUnlock()
var a StandingAnswer
var sm string
var created, updated string
var sm, created, updated string
var opRaw sql.NullString
err := s.db.QueryRow(
`SELECT question_id,question,answer,source_memories,version,created_at,updated_at
`SELECT question_id,question,answer,op,source_memories,version,created_at,updated_at
FROM standing_answers WHERE question_id=?`, qid,
).Scan(&a.QuestionID, &a.Question, &a.Answer, &sm, &a.Version, &created, &updated)
).Scan(&a.QuestionID, &a.Question, &a.Answer, &opRaw, &sm, &a.Version, &created, &updated)
if err == sql.ErrNoRows {
return nil, nil
}
@@ -153,6 +224,7 @@ func (s *StandingStore) Get(qid string) (*StandingAnswer, error) {
return nil, fmt.Errorf("standing get: %w", err)
}
a.SourceMemories = parseSourceEvents(sm)
a.Op = parseOpCard(opRaw)
a.CreatedAt, _ = time.Parse(time.RFC3339, created)
a.UpdatedAt, _ = time.Parse(time.RFC3339, updated)
return &a, nil
@@ -163,7 +235,7 @@ func (s *StandingStore) List() ([]StandingAnswer, error) {
s.mu.RLock()
defer s.mu.RUnlock()
rows, err := s.db.Query(
`SELECT question_id,question,answer,source_memories,version,created_at,updated_at
`SELECT question_id,question,answer,op,source_memories,version,created_at,updated_at
FROM standing_answers ORDER BY question_id`)
if err != nil {
return nil, fmt.Errorf("standing list: %w", err)
@@ -173,10 +245,12 @@ func (s *StandingStore) List() ([]StandingAnswer, error) {
for rows.Next() {
var a StandingAnswer
var sm, created, updated string
if err := rows.Scan(&a.QuestionID, &a.Question, &a.Answer, &sm, &a.Version, &created, &updated); err != nil {
var opRaw sql.NullString
if err := rows.Scan(&a.QuestionID, &a.Question, &a.Answer, &opRaw, &sm, &a.Version, &created, &updated); err != nil {
return nil, err
}
a.SourceMemories = parseSourceEvents(sm)
a.Op = parseOpCard(opRaw)
a.CreatedAt, _ = time.Parse(time.RFC3339, created)
a.UpdatedAt, _ = time.Parse(time.RFC3339, updated)
out = append(out, a)
+4 -4
View File
@@ -27,7 +27,7 @@ func TestStandingUpsertGet(t *testing.T) {
}
// 首次写入 → version=1 + source_memories
a, err := s.Upsert("q_mu_pref", "牧尘的偏好是什么", "答案A", []string{"mem_1", "mem_2"})
a, err := s.Upsert("q_mu_pref", "牧尘的偏好是什么", "答案A", []string{"mem_1", "mem_2"}, nil)
if err != nil {
t.Fatalf("Upsert: %v", err)
}
@@ -39,7 +39,7 @@ func TestStandingUpsertGet(t *testing.T) {
}
// 改写 → version=2
a2, err := s.Upsert("q_mu_pref", "牧尘的偏好是什么", "答案B(更新)", []string{"mem_1", "mem_2", "mem_3"})
a2, err := s.Upsert("q_mu_pref", "牧尘的偏好是什么", "答案B(更新)", []string{"mem_1", "mem_2", "mem_3"}, nil)
if err != nil {
t.Fatalf("Upsert2: %v", err)
}
@@ -62,8 +62,8 @@ func TestStandingUpsertGet(t *testing.T) {
// TestStandingListDelete 2026-10-13 t_e9a4d18f P2:列表 + 删除。
func TestStandingListDelete(t *testing.T) {
s := newTestStandingStore(t)
_, _ = s.Upsert("q_sys", "当前系统状态", "答1", nil)
_, _ = s.Upsert("q_serv", "哪些服务在跑", "答2", []string{"mem_9"})
_, _ = s.Upsert("q_sys", "当前系统状态", "答1", nil, nil)
_, _ = s.Upsert("q_serv", "哪些服务在跑", "答2", []string{"mem_9"}, nil)
all, err := s.List()
if err != nil {
+42 -9
View File
@@ -47,6 +47,25 @@ var (
// LookupStandingHitCount 返回本进程启动以来常设答案命中次数(观测用)。
func LookupStandingHitCount() int64 { return standingLookupHit.Load() }
// standingAnswerFor 若常设答案精确通道命中则返回该答案,否则 nil(零检索零 LLM)。
// 命中口径 = 归一化后整句相等(question + op.trigger 变体),变体是人工写入的精确问法,
// 无模糊面 ⇒ 可安全作为短路的唯一依据。
func standingAnswerFor(query string) *StandingAnswer {
if !StandingAnswerEnabled() {
return nil
}
return LookupStandingAnswer(query)
}
// refreshStandingAnswers 丢弃常设答案缓存,让下一次召回重新读库。
// 写入(Upsert)后必须调用,否则新卡/改卡在 standingCacheTTL 内对召回不可见。
func refreshStandingAnswers() {
standingCacheMu.Lock()
standingCache = nil
standingCacheAt = time.Time{}
standingCacheMu.Unlock()
}
// normalizeForMatch 归一化:小写 + 去掉所有非字母数字/非 CJK 字符(空格、标点、符号)。
func normalizeForMatch(s string) string {
var b strings.Builder
@@ -132,6 +151,10 @@ func loadStandingAnswers() []StandingAnswer {
// Dice≈0.82),把 ComfyUI 的 8188 当成织忆端口置顶。模板句 + 换主语 = 必错。
// 结论:常设答案只认**同一句话**;同义问法靠多行变体(question_id 不同)覆盖。
// 近似命中(Dice ≥ nearMissLogThreshold)只打日志不返回,用来发现该补哪些变体。
//
// 2026-10-28 (t_3636b872):若常设答案带 op 结构卡,则其 op.trigger 变体也参与
// **归一化后完全相等**匹配——变体是人工/脚本写入的精确问法(含评测集真实提问措辞),
// 与 question 同等可信,无谓词注入面 ⇒ 不引入 fuzzy 误命中。card_trigger_match 标出命中路径。
func LookupStandingAnswer(query string) *StandingAnswer {
q := normalizeForMatch(query)
if len([]rune(q)) < 2 {
@@ -144,16 +167,25 @@ func LookupStandingAnswer(query string) *StandingAnswer {
var near *StandingAnswer
nearScore := 0.0
for i := range all {
cand := normalizeForMatch(all[i].Question)
if cand == "" {
continue
a := &all[i]
cand := normalizeForMatch(a.Question)
if cand != "" {
if cand == q {
return a
}
if s := diceSimilarity(q, cand); s > nearScore {
nearScore = s
near = a
}
}
if cand == q {
return &all[i]
}
if s := diceSimilarity(q, cand); s > nearScore {
nearScore = s
near = &all[i]
// op 结构卡的 trigger 变体:归一化后完全相等也算命中(提示词精确句)。
if a.Op != nil {
for _, t := range a.Op.Trigger {
tn := normalizeForMatch(t)
if tn != "" && tn == q {
return a
}
}
}
}
// 近似但不等:只记日志(便于补变体),绝不返回——错答比不答更贵。
@@ -178,6 +210,7 @@ func PrependStandingAnswer(results []models.RecallResult, sa *StandingAnswer, to
Score: 1.0,
Timestamp: sa.UpdatedAt.Format(time.RFC3339),
Source: "standing_answer",
Channel: "standing",
})
for _, r := range results {
if r.ID == id {
+71 -1
View File
@@ -63,6 +63,76 @@ func TestLookupStandingAnswerThreshold(t *testing.T) {
}
}
// TestLookupStandingAnswer_OpCardTrigger 2026-10-28 (t_3636b872):
// op 结构卡的 trigger 变体参与归一化精确匹配;命中后返回带 op 的整条,供调用方展示步骤。
func TestLookupStandingAnswer_OpCardTrigger(t *testing.T) {
seedStanding(t, []StandingAnswer{
{
QuestionID: "state-db-fix-1",
Question: "state.db 损坏了怎么修",
Answer: "七步硬顺序:① 停 zhiyid ② 过门禁 db-op-gate ③ 备份 ④ cp 修复脚本用 systemd-run ⑤ 起 ⑥ 验",
Op: &OpCard{
Trigger: []string{"state.db 损坏怎么修", "state.db 坏了修复步骤是什么"},
Steps: []string{"systemctl --user stop zhiyid", "source ~/.hermes/scripts/db-op-gate.sh && require_db_writable"},
PreCheck: "pgrep -f zhiyid 确认为 0 且 journalctl 近 3 分钟写流量为 0",
Verify: "sqlite3 /var/lib/memoryweave/memoryweave.db 'PRAGMA integrity_check' 返回 ok",
Rollback: "停写后从备份 cp 回 memoryweave.db + status.db",
Risk: "高:运行期 cp 活库会双写撕裂,必须先停 zhiyid",
},
},
})
// ① trigger 变体命中(原句不命中)
if sa := LookupStandingAnswer("state.db 损坏怎么修"); sa == nil || sa.QuestionID != "state-db-fix-1" {
t.Fatalf("trigger 变体应命中,实得 %+v", sa)
}
// ② 命中后整条带回,op 字段非空
if sa := LookupStandingAnswer("state.db 损坏怎么修"); sa == nil || sa.Op == nil {
t.Fatalf("命中后 op 应为非空,实得 op=%v", sa.Op)
}
// ③ 多个变体均可命中
if sa := LookupStandingAnswer("state.db 坏了修复步骤是什么"); sa == nil || sa.QuestionID != "state-db-fix-1" {
t.Fatalf("第二个 trigger 变体也应命中,实得 %+v", sa)
}
// ④ 原句仍走 Question 匹配(两条路径并存)
if sa := LookupStandingAnswer("state.db 损坏了怎么修"); sa == nil || sa.QuestionID != "state-db-fix-1" {
t.Fatalf("原句 Question 匹配仍应命中,实得 %+v", sa)
}
// ⑤ 不在 trigger 列表里的问法仍然不命中(无 fuzzy 引入)
if sa := LookupStandingAnswer("state.db 怎么修复"); sa != nil {
t.Fatalf("非精确 trigger 不应命中(无 fuzzy),实得 %+v", sa)
}
}
// TestLookupStandingAnswer_UpsertInvalidatesCache 写入后新卡必须立即对召回可见,
// 不能等 standingCacheTTL(30s)——这是 t_3636b872 阶段 1 的写入-召回一致性判据。
func TestLookupStandingAnswer_UpsertInvalidatesCache(t *testing.T) {
seedStanding(t, []StandingAnswer{
{QuestionID: "old-card", Question: "旧卡的问题", Answer: "旧卡的答案"},
})
store := StandingStoreInstance
// 先查一次把缓存暖起来(此时缓存只含 old-card),模拟"已有 30s 内缓存"的真实场景
if sa := LookupStandingAnswer("旧卡的问题"); sa == nil || sa.QuestionID != "old-card" {
t.Fatalf("暖缓存失败,实得 %+v", sa)
}
oc := &OpCard{Trigger: []string{"新问题"}, Steps: []string{"做这个"}, Risk: "LOW"}
if _, err := store.Upsert("new-card", "新问题", "新答案", []string{"x"}, oc); err != nil {
t.Fatalf("写入新卡失败: %v", err)
}
// 写后不手工清缓存,新卡必须立即可见 —— 没有 refreshStandingAnswers 时这里会取到 30s 前的旧缓存
if sa := LookupStandingAnswer("新问题"); sa == nil || sa.QuestionID != "new-card" {
t.Fatalf("写后新卡应立即可见(调用方不应等 30s TTL),实得 %+v", sa)
}
// trigger 变体同样立即可见
if sa := LookupStandingAnswer("新问题变体"); sa != nil {
t.Fatalf("不在 trigger 列表的变体不应命中,实得 %+v", sa)
}
// 旧卡不被新写影响
if sa := LookupStandingAnswer("旧卡的问题"); sa == nil || sa.QuestionID != "old-card" {
t.Fatalf("旧卡仍应命中,实得 %+v", sa)
}
}
func TestPrependStandingAnswer(t *testing.T) {
sa := &StandingAnswer{QuestionID: "cron-scheduler", Question: "cron 任务的调度者是谁", Answer: "kanban-daemon.service"}
in := []models.RecallResult{
@@ -94,7 +164,7 @@ func seedStanding(t *testing.T, rows []StandingAnswer) {
t.Fatalf("建临时常设答案库失败: %v", err)
}
for _, r := range rows {
if _, err := store.Upsert(r.QuestionID, r.Question, r.Answer, nil); err != nil {
if _, err := store.Upsert(r.QuestionID, r.Question, r.Answer, r.SourceMemories, r.Op); err != nil {
t.Fatalf("写入常设答案失败: %v", err)
}
}
+331
View File
@@ -0,0 +1,331 @@
// 织忆 MemoryWeave — 召回链路追踪(trace_id 贯穿,t_trace_p0)
//
// 背景(CS249r Vol III Ch14 判据):全仓 grep trace_id|span = 0 ⇒「这条为什么被召回」
// 永远答不出,后续所有调参都是盲修。本文件提供最小可用链路追踪:
//
// ① 每次 recall 生成 trace_id(tr_<16字节hex>),贯穿 编码→粗检索→FTS→重排→MMR→图谱→返回;
// ② 各阶段耗时 + 各通道贡献条数 + 每条结果的来源通道(RecallResult.Channel);
// ③ GET /api/v1/trace/<trace_id> 可重建整条链路(内存环形缓冲 + JSONL 落盘,重启后仍可查)。
//
// 设计约束:
// - 不引入新依赖(crypto/rand 生成 ID,不新增 uuid 直接依赖);
// - 落盘异步(有界 channel + 单写协程),recall 主路径只做内存 append,绝不为追踪拖慢召回;
// - 落盘失败只降级为「仅内存」,不报错、不影响召回结果(追踪是观测设施,不是业务依赖);
// - **这不是缓存**(不参与召回读写路径、不返回旧结果),与 searchcache/Redis 层的职责不同;
// 写一次读多次的观测日志,用环形缓冲 + JSONL 是合适的形态,无需引入 Redis 键。
package storage
import (
"bufio"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// TraceStage 单个阶段耗时。Count 语义随阶段不同(候选数/重排数/扩展数…)。
type TraceStage struct {
Name string `json:"name"`
MS float64 `json:"ms"`
Count int `json:"count,omitempty"`
Note string `json:"note,omitempty"`
}
// TraceItem 单条返回结果的来源归因。
type TraceItem struct {
Rank int `json:"rank"`
ID string `json:"id"`
Channel string `json:"channel"`
Score float64 `json:"score"`
}
// RecallTrace 一次召回的完整链路记录。
type RecallTrace struct {
TraceID string `json:"trace_id"`
Query string `json:"query"`
Namespace string `json:"namespace"`
Mode string `json:"mode"`
Status string `json:"status"` // complete | partial
PartialReason string `json:"partial_reason,omitempty"`
StartedAt time.Time `json:"started_at"`
DurationMS float64 `json:"duration_ms"`
CacheHit bool `json:"cache_hit"`
CandidatesScanned int `json:"candidates_scanned"`
Truncated bool `json:"truncated"`
StandingAnswerHit string `json:"standing_answer_hit,omitempty"`
TopK int `json:"top_k"`
Channels map[string]int `json:"channels"`
Stages []TraceStage `json:"stages"`
Items []TraceItem `json:"items"`
Scope map[string]interface{} `json:"scope,omitempty"`
Error string `json:"error,omitempty"`
started time.Time
}
const (
traceRingCap = 2000
traceMaxFileSize = 32 << 20 // 32MB 轮转
)
// traceDir 可通过 ZHIYI_TRACE_DIR 覆盖(测试用)。
func traceDir() string {
if d := os.Getenv("ZHIYI_TRACE_DIR"); d != "" {
return d
}
return "/var/lib/memoryweave/traces"
}
func tracePersistEnabled() bool {
switch strings.ToLower(os.Getenv("ZHIYI_TRACE_PERSIST")) {
case "0", "false", "off":
return false
}
return true
}
// NewTraceID 生成 tr_<32hex>(16 字节随机,碰撞概率可忽略)。
func NewTraceID() string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
// 极端情况下退化为纳秒时间戳,仍保证非空、可区分。
return fmt.Sprintf("tr_%d", time.Now().UnixNano())
}
return "tr_" + hex.EncodeToString(b[:])
}
// NewRecallTrace 创建一条追踪记录(StartedAt 同时作为计时起点)。
func NewRecallTrace(traceID, query, namespace, mode string) *RecallTrace {
now := time.Now()
if traceID == "" {
traceID = NewTraceID()
}
return &RecallTrace{
TraceID: traceID,
Query: query,
Namespace: namespace,
Mode: mode,
Status: "complete",
StartedAt: now,
Channels: map[string]int{},
Stages: []TraceStage{},
Items: []TraceItem{},
started: now,
}
}
// Stage 记录一个阶段耗时。start 由调用方在阶段开始前取;count<0 表示不记录。
func (t *RecallTrace) Stage(name string, start time.Time, count int) {
if t == nil {
return
}
t.Stages = append(t.Stages, TraceStage{
Name: name,
MS: float64(time.Since(start).Microseconds()) / 1000.0,
Count: count,
})
}
// StageNote 记录带备注的阶段(如失败原因)。
func (t *RecallTrace) StageNote(name string, start time.Time, count int, note string) {
if t == nil {
return
}
t.Stages = append(t.Stages, TraceStage{
Name: name,
MS: float64(time.Since(start).Microseconds()) / 1000.0,
Count: count,
Note: note,
})
}
// MarkPartial 标记本次召回为部分结果(deadline / 行数上限 / 阶段错误)。
func (t *RecallTrace) MarkPartial(reason string) {
if t == nil || reason == "" {
return
}
t.Status = "partial"
if t.PartialReason == "" {
t.PartialReason = reason
} else if !strings.Contains(t.PartialReason, reason) {
t.PartialReason += "," + reason
}
}
// SetItems 用最终返回集填充 item 归因(Rank 从 1 起)。
func (t *RecallTrace) SetItems(items []TraceItem) {
if t == nil {
return
}
t.Items = items
t.Channels = map[string]int{}
for _, it := range items {
ch := it.Channel
if ch == "" {
ch = "unknown"
}
t.Channels[ch]++
}
}
var (
traceMu sync.RWMutex
traceRing = make([]*RecallTrace, 0, traceRingCap)
traceIdx = map[string]*RecallTrace{}
traceFileMu sync.Mutex
traceFile *os.File
traceFileSize int64
traceFileDay string
traceCh = make(chan *RecallTrace, 1024)
traceOnce sync.Once
)
// SaveTrace 写入内存环形缓冲,并异步落盘(非阻塞;缓冲满则丢落盘、保留内存)。
func SaveTrace(t *RecallTrace) {
if t == nil {
return
}
if t.DurationMS == 0 {
t.DurationMS = float64(time.Since(t.started).Microseconds()) / 1000.0
}
traceMu.Lock()
if len(traceRing) >= traceRingCap {
// 覆盖最旧:同时从索引删除
old := traceRing[0]
copy(traceRing, traceRing[1:])
traceRing = traceRing[:len(traceRing)-1]
if old != nil {
delete(traceIdx, old.TraceID)
}
}
traceRing = append(traceRing, t)
traceIdx[t.TraceID] = t
traceMu.Unlock()
if !tracePersistEnabled() {
return
}
traceOnce.Do(func() { go traceWriter() })
select {
case traceCh <- t:
default:
// 落盘队列满:丢这一条(内存仍可查),不阻塞召回主路径。
}
}
// GetTrace 先查内存,再回落到落盘文件(支持服务重启后按 trace_id 回溯)。
func GetTrace(traceID string) (*RecallTrace, bool) {
if traceID == "" {
return nil, false
}
traceMu.RLock()
if t, ok := traceIdx[traceID]; ok {
traceMu.RUnlock()
return t, true
}
traceMu.RUnlock()
return getTraceFromDisk(traceID)
}
// TraceCount 当前内存中可查的 trace 数(自检/测试用)。
func TraceCount() int {
traceMu.RLock()
defer traceMu.RUnlock()
return len(traceRing)
}
func traceWriter() {
for t := range traceCh {
writeTraceLine(t)
}
}
// writeTraceLine 追加一行 JSONL。失败静默(仅内存可查),不向调用方冒泡。
func writeTraceLine(t *RecallTrace) {
line, err := json.Marshal(t)
if err != nil {
return
}
line = append(line, '\n')
dir := traceDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
return
}
day := time.Now().Format("20060102")
traceFileMu.Lock()
defer traceFileMu.Unlock()
if traceFile == nil || traceFileDay != day || traceFileSize > traceMaxFileSize {
if traceFile != nil {
_ = traceFile.Close()
traceFile = nil
}
path := filepath.Join(dir, "recall-"+day+".jsonl")
if traceFileSize > traceMaxFileSize {
// 轮转:同日超大时改名 .1(覆盖上一份)
_ = os.Rename(path, path+".1")
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return
}
traceFile = f
traceFileDay = day
if st, err := f.Stat(); err == nil {
traceFileSize = st.Size()
} else {
traceFileSize = 0
}
}
if _, err := traceFile.Write(line); err == nil {
traceFileSize += int64(len(line))
}
}
// getTraceFromDisk 从落地文件逆序扫描(只读,不影响运行中的服务)。
func getTraceFromDisk(traceID string) (*RecallTrace, bool) {
dir := traceDir()
entries, err := os.ReadDir(dir)
if err != nil {
return nil, false
}
needle := `"trace_id":"` + traceID + `"`
// 文件名含日期,倒序 = 新文件先查。
for i := len(entries) - 1; i >= 0; i-- {
e := entries[i]
if e.IsDir() || !strings.HasPrefix(e.Name(), "recall-") {
continue
}
f, err := os.Open(filepath.Join(dir, e.Name()))
if err != nil {
continue
}
var hit *RecallTrace
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 0, 64*1024), 4<<20)
for sc.Scan() {
b := sc.Bytes()
if !strings.Contains(string(b), needle) {
continue
}
var t RecallTrace
if json.Unmarshal(b, &t) == nil && t.TraceID == traceID {
hit = &t
}
}
_ = f.Close()
if hit != nil {
return hit, true
}
}
return nil, false
}
+160
View File
@@ -0,0 +1,160 @@
// 织忆 MemoryWeave — 召回链路追踪单元测试(P0-1)
//
// 验收要点:
// - trace_id 唯一、可查(内存 + 落盘两条路径);
// - 阶段/通道/条目归因完整(100 条全可重建,等价线上「100 请求 100% 可回溯」的 CI 断言);
// - 环形缓冲按容量淘汰最旧且索引同步删除。
package storage
import (
"fmt"
"os"
"path/filepath"
"testing"
"time"
)
func resetTraceState(t *testing.T) {
t.Helper()
traceMu.Lock()
traceRing = make([]*RecallTrace, 0, traceRingCap)
traceIdx = map[string]*RecallTrace{}
traceMu.Unlock()
}
// TestRecallTrace_100RequestsAllRetrievable 对应验收:「CI 断言 100 个请求 100% 可重建调用链」。
func TestRecallTrace_100RequestsAllRetrievable(t *testing.T) {
resetTraceState(t)
t.Setenv("ZHIYI_TRACE_PERSIST", "0")
dir := t.TempDir()
t.Setenv("ZHIYI_TRACE_DIR", dir)
ids := make([]string, 0, 100)
for i := 0; i < 100; i++ {
tr := NewRecallTrace("", fmt.Sprintf("query-%d", i), "hermes-main", "hybrid")
tr.Stage("encode", time.Now(), 0)
tr.Stage("coarse_search", time.Now(), 200)
tr.Stage("graph_expand", time.Now(), 2)
tr.SetItems([]TraceItem{
{Rank: 1, ID: "mem_a", Channel: "vector", Score: 0.9},
{Rank: 2, ID: "mem_b", Channel: "fts", Score: 0.8},
{Rank: 3, ID: "mem_c", Channel: "graph", Score: 0.7},
{Rank: 4, ID: "mem_d", Channel: "standing", Score: 1.0},
{Rank: 5, ID: "mem_e", Channel: "vector", Score: 0.6},
})
SaveTrace(tr)
ids = append(ids, tr.TraceID)
}
if TraceCount() != 100 {
t.Fatalf("应保存 100 条 trace,实际 %d", TraceCount())
}
for _, id := range ids {
got, ok := GetTrace(id)
if !ok {
t.Fatalf("trace %s 不可回溯", id)
}
if len(got.Items) != 5 {
t.Fatalf("trace %s item 归因应 5 条,实际 %d", id, len(got.Items))
}
if len(got.Stages) < 3 {
t.Fatalf("trace %s 阶段应 ≥3,实际 %d", id, len(got.Stages))
}
for _, it := range got.Items {
if it.Channel == "" {
t.Fatalf("trace %s item %s 缺 channel(无法回答「它为什么被召回」)", id, it.ID)
}
}
}
}
// TestRecallTrace_DiskFallback 服务重启后仍可按 trace_id 回溯(内存清空 → 读 JSONL)。
func TestRecallTrace_DiskFallback(t *testing.T) {
resetTraceState(t)
dir := t.TempDir()
t.Setenv("ZHIYI_TRACE_DIR", dir)
t.Setenv("ZHIYI_TRACE_PERSIST", "1")
tr := NewRecallTrace("", "落盘回溯测试", "hermes-main", "hybrid")
tr.SetItems([]TraceItem{{Rank: 1, ID: "mem_x", Channel: "vector"}})
tr.Status = "complete"
// 直写文件(避免依赖异步协程时序),模拟落盘后的状态。
writeTraceLine(tr)
if _, ok := GetTrace(tr.TraceID); !ok {
t.Fatalf("落盘 trace %s 不可从文件回溯", tr.TraceID)
}
// 再验证一次真实异步路径(轮询最多 2s)
tr2 := NewRecallTrace("", "异步落盘", "hermes-main", "hybrid")
SaveTrace(tr2)
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if _, ok := getTraceFromDisk(tr2.TraceID); ok {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("异步落盘 2s 内未完成(trace %s)", tr2.TraceID)
}
// TestRecallTrace_RingEviction 环形缓冲满后淘汰最旧,且索引同步删除。
func TestRecallTrace_RingEviction(t *testing.T) {
resetTraceState(t)
t.Setenv("ZHIYI_TRACE_PERSIST", "0")
first := NewRecallTrace("", "first", "ns", "hybrid")
SaveTrace(first)
for i := 0; i < traceRingCap; i++ {
SaveTrace(NewRecallTrace("", fmt.Sprintf("q-%d", i), "ns", "hybrid"))
}
if _, ok := GetTrace(first.TraceID); ok {
t.Fatalf("环形缓冲应淘汰最旧的 trace")
}
if TraceCount() != traceRingCap {
t.Fatalf("环形缓冲容量应保持 %d,实际 %d", traceRingCap, TraceCount())
}
}
// TestNewTraceID_Unique 1000 个 ID 不重复、带 tr_ 前缀。
func TestNewTraceID_Unique(t *testing.T) {
seen := map[string]bool{}
for i := 0; i < 1000; i++ {
id := NewTraceID()
if len(id) < 10 || id[:3] != "tr_" {
t.Fatalf("trace id 形态非法:%q", id)
}
if seen[id] {
t.Fatalf("trace id 重复:%q", id)
}
seen[id] = true
}
}
// TestRecallTrace_MarkPartial partial 原因必须可读(不覆盖首个原因)。
func TestRecallTrace_MarkPartial(t *testing.T) {
tr := NewRecallTrace("tr_test", "q", "ns", "hybrid")
if tr.Status != "complete" {
t.Fatalf("新 trace 状态应为 complete,实际 %q", tr.Status)
}
tr.MarkPartial("deadline_exceeded")
tr.MarkPartial("row_cap_truncated")
if tr.Status != "partial" {
t.Fatalf("MarkPartial 后状态应为 partial,实际 %q", tr.Status)
}
if tr.PartialReason != "deadline_exceeded,row_cap_truncated" {
t.Fatalf("partial_reason 应累积,实际 %q", tr.PartialReason)
}
}
// TestTraceDir_Default 默认目录形态(不创建文件,只校验路径选择逻辑)。
func TestTraceDir_Default(t *testing.T) {
os.Unsetenv("ZHIYI_TRACE_DIR")
if got := traceDir(); got != "/var/lib/memoryweave/traces" {
t.Fatalf("默认 trace 目录错误:%s", got)
}
t.Setenv("ZHIYI_TRACE_DIR", filepath.Join(t.TempDir(), "x"))
if got := traceDir(); got == "/var/lib/memoryweave/traces" {
t.Fatalf("ZHIYI_TRACE_DIR 未生效")
}
}
+215
View File
@@ -0,0 +1,215 @@
package storage
// t_9143c968 消费侧熔断修复:游标调度测试。
// 覆盖 AC5(窗口外 1000 pending + 窗口内 1 行必须被处理)、tail/sweep 游标、
// 失败熔断(替代旧「积压>cap⇒skip」)、retry 通道、expire 标记与 TTL。
import (
"database/sql"
"encoding/json"
"errors"
"strconv"
"testing"
"time"
)
// flakyLance 包装任意 LanceDB:failAppend=true ⇒ AppendHitEvent 恒失败(模拟持久性坏行)。
type flakyLance struct {
LanceDB
failAppend bool
}
func (f *flakyLance) AppendHitEvent(id, outcome, reason string) error {
if f.failAppend {
return errors.New("simulated commit conflict")
}
return f.LanceDB.AppendHitEvent(id, outcome, reason)
}
// insertRecallRowAt 直插一条召回行(recalled_at 可控,绕开 RecordRecalled 的 now)。
func insertRecallRowAt(t *testing.T, db *sql.DB, memID, content, recalledAt string) {
t.Helper()
toks, _ := json.Marshal(DistinctiveTokens(content, usageTokenTopN))
if _, err := db.Exec(`INSERT INTO usage_recall(memory_id,content,tokens,q_hash,namespace,agent,recalled_at)
VALUES(?,?,?,?,?,?,?)`, memID, content, string(toks), "q", "ns", "agent", recalledAt); err != nil {
t.Fatalf("insert recall row: %v", err)
}
}
const usageTestMem = "家庭服务器IP固定为192.168.123.11,星空组网走VPN 192.168.188.10"
const usageTestCommit = "家庭服务器IP是192.168.123.11,星空组网用VPN 192.168.188.10 访问"
func usagePendingBy(t *testing.T, db *sql.DB, where string, args ...interface{}) int {
t.Helper()
var n int
if err := db.QueryRow(`SELECT COUNT(*) FROM usage_recall WHERE `+where, args...).Scan(&n); err != nil {
t.Fatalf("count(%s): %v", where, err)
}
return n
}
// AC5:窗口外 1000 行 pending + 窗口内 1 行 ⇒ 窗口内那 1 行必须被消费。
// (旧实现在此场景恒 skip:pending 1001 > cap 500 ⇒ 每 commit「backlog > cap」——正是本次熔断本身。)
func TestUsageCursor_A5OldBacklogMustNotBlockNewRow(t *testing.T) {
setupUsageTest(t)
db := usageOpen()
if db == nil {
t.Fatal("usageOpen failed")
}
mlc := NewMemLanceClient(nil)
now := time.Now()
old := now.Add(-72 * time.Hour).Format(time.RFC3339)
fresh := now.Format(time.RFC3339)
for i := 0; i < 1000; i++ {
insertRecallRowAt(t, db, "mem_old"+strconv.Itoa(i), "旧内容占位记忆"+strconv.Itoa(i), old)
}
insertRecallRowAt(t, db, "mem_new", usageTestMem, fresh)
start := time.Now()
flags := CheckCommittedForUse(mlc, "agent", "ns", usageTestCommit)
t.Logf("1001 行积压下 单轮耗时 %v", time.Since(start))
if len(flags) != 1 || flags[0].MemoryID != "mem_new" {
t.Fatalf("窗口内 1 行必须被消费(旧积压闸已删),got %+v", flags)
}
// 窗口外 1000 行:同轮被 expire 通道标 consumed=2(skipped_expired,留证据不物理删)
if c2 := usagePendingBy(t, db, "consumed=2"); c2 != 1000 {
t.Fatalf("窗口外 pending 应全标 consumed=2, actual %d", c2)
}
if p := usagePendingBy(t, db, "consumed=0"); p != 0 {
t.Fatalf("不应再有 consumed=0 残留, actual %d", p)
}
// 行仍在库里(证据保留):总量 = 1001
if total := usagePendingBy(t, db, "1=1"); total != 1001 {
t.Fatalf("expire 只标记不删除, total=%d", total)
}
}
// tail 只吃游标之上的有界批次(LAG),落后行由 sweep 逐轮补齐;全部行最终各消费一次。
func TestUsageCursor_TailBoundedSweepCovers(t *testing.T) {
setupUsageTest(t)
t.Setenv("ZHIYI_USAGE_BATCH", "3")
t.Setenv("ZHIYI_USAGE_SWEEP_BATCH", "2")
db := usageOpen()
mlc := NewMemLanceClient(nil)
now := time.Now().Format(time.RFC3339)
for i := 1; i <= 10; i++ {
insertRecallRowAt(t, db, "mem_c"+strconv.Itoa(i), usageTestMem, now)
}
// call1:tail 取最新 3 行(ids 10,9,8);sweep 从头取 2 行(ids 1,2)⇒ 5 flags
if f := CheckCommittedForUse(mlc, "agent", "ns", usageTestCommit); len(f) != 5 {
t.Fatalf("call1 期望 5 flags (tail3+sweep2), got %d", len(f))
}
if c := usagePendingBy(t, db, "consumed=1"); c != 5 {
t.Fatalf("call1 后应消费 5 行, actual %d", c)
}
// 继续调用直至全部消费;每次调用都是新 commit,行只能消费一次
for round := 2; round <= 10; round++ {
CheckCommittedForUse(mlc, "agent", "ns", usageTestCommit)
}
if c := usagePendingBy(t, db, "consumed=1"); c != 10 {
t.Fatalf("多轮后应全部 10 行消费, actual %d", c)
}
if p := usagePendingBy(t, db, "consumed=0"); p != 0 {
t.Fatalf("不应有 pending 残留, actual %d", p)
}
// 每行只 1 条 hit event(无重复累加)
for i := 1; i <= 10; i++ {
ev, _ := mlc.GetHitEvents("mem_c"+strconv.Itoa(i), 10)
if len(ev) != 1 {
t.Fatalf("mem_c%d 应恰 1 条 hit event, got %d", i, len(ev))
}
}
}
// 失败熔断(ZHIYI_USAGE_BACKLOG_MAX 新语义):单轮失败 ≥cap ⇒ 停,不再全表跳过。
// 反证旧行为:pending 6 > cap 2 在旧实现 = 「backlog > cap skip」0 行处理;
// 新实现必须实际处理(失败行记退避,坏行不再每 commit 热重试,也不再拖死好行)。
func TestUsageCursor_FailureBreakerReplacesBacklogSkip(t *testing.T) {
setupUsageTest(t)
t.Setenv("ZHIYI_USAGE_BACKLOG_MAX", "2")
db := usageOpen()
mlc := NewMemLanceClient(nil)
flaky := &flakyLance{LanceDB: mlc, failAppend: true}
now := time.Now().Format(time.RFC3339)
for i := 1; i <= 6; i++ {
insertRecallRowAt(t, db, "mem_bk"+strconv.Itoa(i), usageTestMem, now)
}
flags := CheckCommittedForUse(flaky, "agent", "ns", usageTestCommit)
if len(flags) != 0 {
t.Fatalf("恒失败不应有 flag, got %d", len(flags))
}
// tail DESC 先吃 ids 6,5 → 2 次失败即熔断;sweep 被单次调用熔断跳过 ⇒ 仅 2 行记退避
if n := usagePendingBy(t, db, "retry_count=1"); n != 2 {
t.Fatalf("熔断应只记 2 行退避(单轮 cap=2), actual %d", n)
}
if n := usagePendingBy(t, db, "retry_count=0 AND consumed=0"); n != 4 {
t.Fatalf("熔断后其余 4 行应未被触碰, actual %d", n)
}
if n := usagePendingBy(t, db, "consumed=1"); n != 0 {
t.Fatalf("失败行不得消费, actual %d", n)
}
// 关键反证:旧「pending(6)>cap(2)⇒skip」在此场景 0 行处理;新实现推进了 2 行状态。
}
// retry 通道:游标推过头的失败行,退避到期后仍能被重试消费(不依赖 tail 位置)。
func TestUsageCursor_RetryPassAfterBackoff(t *testing.T) {
setupUsageTest(t)
db := usageOpen()
mlc := NewMemLanceClient(nil)
flaky := &flakyLance{LanceDB: mlc, failAppend: true}
now := time.Now().Format(time.RFC3339)
insertRecallRowAt(t, db, "mem_retry", usageTestMem, now)
if f := CheckCommittedForUse(flaky, "agent", "ns", usageTestCommit); len(f) != 0 {
t.Fatalf("第一次应失败, got %d", len(f))
}
if c := usagePendingBy(t, db, "consumed=1"); c != 0 {
t.Fatalf("失败不应消费, actual %d", c)
}
// 手动把 next_retry_at 拨到过去(模拟退避到期;包级 backoff 参数在 init 时已定)
if _, err := db.Exec(`UPDATE usage_recall SET next_retry_at='2000-01-01T00:00:00Z' WHERE memory_id='mem_retry'`); err != nil {
t.Fatalf("backdate retry: %v", err)
}
flaky.failAppend = false // 故障恢复
f := CheckCommittedForUse(mlc, "agent", "ns", usageTestCommit)
if len(f) != 1 || f[0].MemoryID != "mem_retry" {
t.Fatalf("退避到期后 retry 通道必须消费该行, got %+v", f)
}
if c := usagePendingBy(t, db, "consumed=1 AND retry_count=0"); c != 1 {
t.Fatalf("成功消费应把 retry_count 归零, actual %d", c)
}
}
// expire + TTL:出窗 pending 标 2(72h,未到 7d 不删);consumed>=1 且 >7d 删除;新鲜行不动。
func TestUsageCursor_ExpireMarkAndTTL(t *testing.T) {
setupUsageTest(t)
db := usageOpen()
mlc := NewMemLanceClient(nil)
now := time.Now()
insertRecallRowAt(t, db, "mem_expired_pending", "旧内容占位记忆甲", now.Add(-72*time.Hour).Format(time.RFC3339))
insertRecallRowAt(t, db, "mem_stale_consumed", "旧内容占位记忆乙", now.Add(-8*24*time.Hour).Format(time.RFC3339))
if _, err := db.Exec(`UPDATE usage_recall SET consumed=1 WHERE memory_id IN ('mem_stale_consumed')`); err != nil {
t.Fatalf("mark consumed: %v", err)
}
insertRecallRowAt(t, db, "mem_fresh_consumed", "旧内容占位记忆丙", now.Add(-1*time.Hour).Format(time.RFC3339))
if _, err := db.Exec(`UPDATE usage_recall SET consumed=1 WHERE memory_id='mem_fresh_consumed'`); err != nil {
t.Fatalf("mark consumed: %v", err)
}
insertRecallRowAt(t, db, "mem_fresh_pending", "旧内容占位记忆丁", now.Add(-1*time.Hour).Format(time.RFC3339))
// 不相关内容 ⇒ 无判定,但 expire/TTL 维护通道照常运行
CheckCommittedForUse(mlc, "agent", "ns", "今天天气很好出去散步")
if n := usagePendingBy(t, db, "memory_id='mem_expired_pending' AND consumed=2"); n != 1 {
t.Fatalf("出窗 pending 应标 consumed=2(且 72h<7d 不得删除), rows=%d", n)
}
if n := usagePendingBy(t, db, "memory_id='mem_stale_consumed'"); n != 0 {
t.Fatalf("consumed>=1 且 recalled_at>7d 应被 TTL 删除, rows=%d", n)
}
if n := usagePendingBy(t, db, "memory_id='mem_fresh_consumed' AND consumed=1"); n != 1 {
t.Fatalf("新鲜 consumed 行不得删, rows=%d", n)
}
if n := usagePendingBy(t, db, "memory_id='mem_fresh_pending' AND consumed=0"); n != 1 {
t.Fatalf("窗内 pending 不得被动, rows=%d", n)
}
}
+217 -55
View File
@@ -29,6 +29,7 @@ import (
"log"
"os"
"sort"
"strconv"
"strings"
"sync"
"time"
@@ -154,6 +155,20 @@ func usageOpen() *sql.DB {
}
_, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_usage_mem ON usage_recall(memory_id)`)
_, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_usage_time ON usage_recall(recalled_at)`)
// 消费游标(t_9143c968):单行表,id 恒为 1。tail_pos=尾部已消费位点,sweep_pos=轮巡位点。
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS usage_cursor(
id INTEGER PRIMARY KEY CHECK(id=1),
tail_pos INTEGER NOT NULL DEFAULT 0,
sweep_pos INTEGER NOT NULL DEFAULT 0,
updated_at TEXT)`); err != nil {
logUsage("create cursor table: %v", err)
}
// 部分索引(t_9143c968):让消费端单轮扫描代价 O(batch),与表总量解耦。
// ⚠️ 生产 5M+ 行库的这三条索引须在停机窗口预建(sqlite3 CLI),
// 否则首次 usageOpen 会在请求路径里建索引卡几秒;IF NOT EXISTS 双保险。
_, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_usage_pending ON usage_recall(id, recalled_at) WHERE consumed=0`)
_, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_usage_pending_time ON usage_recall(recalled_at) WHERE consumed=0`)
_, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_usage_retry ON usage_recall(id) WHERE next_retry_at IS NOT NULL`)
usageDB = db
})
return usageDB
@@ -198,44 +213,72 @@ func RecordRecalled(recs []RecalledMemory) {
}
}
// CheckCommittedForUse commit 写侧钩子:检查新写入的内容是否携带了
// 最近被召回记忆的独特 token;命中则判定「被用上」并落 outcome(门控)。
// 返回本次判定出的「被用上」记忆(供上报/取证)。
func CheckCommittedForUse(ldb LanceDB, agent, namespace, content string) []UsageFlag {
if !usageTrackEnabled || ldb == nil || content == "" {
return nil
}
db := usageOpen()
if db == nil {
return nil
}
// 积压上限量化(t_20bfbd9a 治本②):未消费待重试行超过上限 ⇒ 本轮跳过扫描,
// 防止「一条永远失败的坏行 + 高 commit 频率」在每次 commit 都全量重扫、叠成风暴。
// 上限 env ZHIYI_USAGE_BACKLOG_MAX,默认 500。
backlogMax := int(envFloat("ZHIYI_USAGE_BACKLOG_MAX", 500))
if backlogMax > 0 {
var pending int
if err := db.QueryRow(`SELECT COUNT(*) FROM usage_recall WHERE consumed=0`).Scan(&pending); err == nil && pending > backlogMax {
logUsage("backlog %d > cap %d: skip scan this commit", pending, backlogMax)
return nil
// ─── 消费游标与批量消费(t_9143c968 熔断修复,2026-10-30)────────────
//
// 根因(卡 body + comment #1757/#1758 两次修正):旧实现扫描前用无窗 count(consumed=0)
// 作积压熔断(cap 500,t_20bfbd9a)。写入量涨到 40~200 万行/日后 pending 恒百万级
// ⇒ 每次 commit 都命中「backlog > cap ⇒ skip」——消费侧永久短路(10-19 起 consumed=0)。
// cap 是安全阀不是 bug;真 bug 是「消费无增量位点,只能每次 commit 全量扫窗」。
//
// 新方案(卡推荐 A:增量游标;单轮代价 O(batch),与表总量解耦):
// - expire 过期标记:consumed=0 且已出窗(recalled_at<cutoff)的行标 consumed=2
// (skipped_expired,不物理删留证据),chunk=ZHIYI_USAGE_EXPIRE_CHUNK(默认 5000)。
// - retry 退避重试:next_retry_at 已到期的失败行优先重试(不受游标位置影响)。
// - tail 尾部消费:从 tail 游标向上取最多 ZHIYI_USAGE_BATCH(默认 200)条未消费
// 行(DESC 先吃最新)——上一轮 commit 以来最新一批召回,与本次 commit 内容
// 因果对齐(主信号面);游标推进到表尾 max(id)。
// - sweep 轮巡复查:升序扫未消费行,游标到表尾后回绕 0,batch=ZHIYI_USAGE_SWEEP_BATCH
// (默认 500,0 关闭)。保留旧「一次召回、多个后续 commit 逐个命中」语义
// (usage_live_demo_test 即此形态);expire 先行使游标只在窗内 pending 区推进。
// - TTL consumed>=1(含 2)且 recalled_at<now-7d 的行删除,
// chunk=ZHIYI_USAGE_TTL_CHUNK(默认 5000,0 关闭),防止无界增长。
// - ZHIYI_USAGE_BACKLOG_MAX(默认 500)保留但语义收窄:不再是「全表积压跳过」,
// 改为单次调用失败熔断——一轮内失败行数 ≥ cap 即结束本轮,
// 保留 t_20bfbd9a「持久性坏行不叠重试风暴」的原始意图。
//
// 取舍(写进交付注释):commit 频率(实测 ~91 次/日)≪ 召回写入(40~200 万行/日),
// 任何方案都不可能对每行复查窗口内全部后续 commit。tail 面抓「recall→紧接着写」
// 的主场景;窗口内其余行由 sweep 轮巡兜低频复查;出窗行标 2 留证,7 日后 TTL 清。
// usageMu 串行化消费全流程:commit 钩子是异步 goroutine,可能并发进入;
// 同库读-改-写(游标推进/消费标记)串行后无竞态,单轮 O(batch) 毫秒级,无排队风险。
var usageMu sync.Mutex
func usageCursorGet(db *sql.DB) (tail, sweep int64) {
_ = db.QueryRow(`SELECT tail_pos, sweep_pos FROM usage_cursor WHERE id=1`).Scan(&tail, &sweep)
return // 无行/出错 → (0,0):首轮 tail 从 0 向上扫最新一批,sweep 从最老行开始
}
func usageCursorSet(db *sql.DB, tail, sweep int64) {
_, _ = db.Exec(`INSERT INTO usage_cursor(id,tail_pos,sweep_pos,updated_at) VALUES(1,?,?,?)
ON CONFLICT(id) DO UPDATE SET tail_pos=excluded.tail_pos, sweep_pos=excluded.sweep_pos, updated_at=excluded.updated_at`,
tail, sweep, time.Now().Format(time.RFC3339))
}
// usageEnvInt 读整型 env;缺失/非法/≤0 → 默认值。
func usageEnvInt(name string, def int) int {
if v := os.Getenv(name); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
return n
}
}
cutoff := time.Now().Add(-time.Duration(usageWindowHours * float64(time.Hour))).Format(time.RFC3339)
scanNow := time.Now()
// 退避过滤(t_20bfbd9a 治本①):next_retry_at 为 NULL(首次待处理)或已到重试时间,
// 才进本轮重扫。持久性失败的坏行被指数退避挡在窗口外,不再每 commit 热重试。
rows, err := db.Query(
`SELECT id, memory_id, content, tokens, namespace, agent, retry_count
FROM usage_recall WHERE consumed=0 AND recalled_at>=?
AND (next_retry_at IS NULL OR next_retry_at<=?)`,
cutoff, scanNow.Format(time.RFC3339))
return def
}
// usageScanBatch 执行一条批次查询并逐行判定,返回(判定样本, 失败行数, 本批最大 id)。
// query 须选出 id,memory_id,content,tokens,namespace,agent,retry_count 七列。
// 单行失败(AppendHitEvent/Update 终态失败)⇒ 不消费 + 指数退避(t_20bfbd9a 语义不变);
// 失败累计 ≥ ZHIYI_USAGE_BACKLOG_MAX(>0 时)⇒ 熔断:本轮提前结束。
func usageScanBatch(db *sql.DB, ldb LanceDB, agent, namespace string, contentSet map[string]bool,
query string, args ...interface{}) (flags []UsageFlag, failed int, maxID int64) {
rows, err := db.Query(query, args...)
if err != nil {
logUsage("query recall log: %v", err)
return nil
logUsage("query recall batch: %v", err)
return
}
defer rows.Close()
contentSet := tokenSet(content)
flags := make([]UsageFlag, 0)
breakerMax := int(envFloat("ZHIYI_USAGE_BACKLOG_MAX", 500))
now := time.Now().Format(time.RFC3339)
for rows.Next() {
var id int64
var memID, memContent, tokensJSON, ns, ag string
@@ -243,6 +286,9 @@ func CheckCommittedForUse(ldb LanceDB, agent, namespace, content string) []Usage
if err := rows.Scan(&id, &memID, &memContent, &tokensJSON, &ns, &ag, &retryCount); err != nil {
continue
}
if id > maxID {
maxID = id
}
// 归属过滤:同一 agent,或同一 namespace,或共享/全局。跨域不强求。
if ag != "" && agent != "" && ag != agent && ns != "" && namespace != "" && ns != namespace {
continue
@@ -256,43 +302,159 @@ func CheckCommittedForUse(ldb LanceDB, agent, namespace, content string) []Usage
continue
}
// 判定「被用上」:落 outcome + 累加 recall_verified_count + 消费该 recall 行。
now := time.Now().Format(time.RFC3339)
// t_7793a484:outcome 事件失败不再静默吞(修复前 `_ =` 丢错 + 单次 rpc 无重试 ⇒
// 一次冲突即终态丢,10-16 实测 42 条丢失走此路径)。现:AppendHitEvent 内置
// 有界重试(同 Update 规格);终态失败(耗尽/非可重试)⇒ 不消费(consumed 保持 0)
// ⇒ 下一轮 commit 重扫重试,事件不静默丢。失败可查(logUsage 计数 + 计数器
// zhiyi_hit_event_append_failed_total),禁静默。
// t_7793a484:outcome 终态失败 ⇒ 不消费 + 指数退避(t_20bfbd9a),下一轮重扫
// 重试,事件不静默丢。t_28aa278e 方案①:Update 成功后才消费,计数增量不丢。
if err := ldb.AppendHitEvent(memID, HitOutcomePos, HitReasonDownstreamUse); err != nil {
logUsage("hit event append FAILED (id=%s, NOT consumed, will retry next commit) : %v", memID, err)
// t_20bfbd9a 治本①:持久性失败(如坏行 memID 已从 LanceDB 删除)若不加退避,
// 会在每次 commit 都热重试,commit 风暴时叠成重扫风暴。加指数退避:
// 第 n 次失败后 next_retry_at = now + min(2^(n-1) * base, cap),退避期外不再重扫。
// 成功消费时把 retry_count 归零;每轮只推进 retry_count 一次(不指数级暴涨)。
_, _ = db.Exec(
`UPDATE usage_recall SET retry_count=retry_count+1, next_retry_at=? WHERE id=?`,
logUsage("hit event append FAILED (id=%s, NOT consumed, will retry) : %v", memID, err)
_, _ = db.Exec(`UPDATE usage_recall SET retry_count=retry_count+1, next_retry_at=? WHERE id=?`,
time.Now().Add(usageBackoff(retryCount+1)).Format(time.RFC3339), id)
failed++
if breakerMax > 0 && failed >= breakerMax {
logUsage("failure breaker: %d failures >= cap %d, abort this call", failed, breakerMax)
return
}
continue
}
// t_28aa278e 方案①:更新成功后才消费 usage_recall 行。
// 旧实现无条件消费 ⇒ Update 冲突耗尽时该 recall_verified_count 增量被永久丢弃
// (下轮不再重扫)。现改为:Update 返回非 nil(冲突重试耗尽/非可重试错误)
// ⇒ 不消费(consumed 保持 0)⇒ 下一轮 commit 重扫该窗口会重试,计数增量不静默丢失。
// 失败数可查(logUsage 计数),禁静默。
if err := ldb.Update("memories", memID, map[string]any{
"recall_verified_count": map[string]string{"$inc": "1"},
}); err != nil {
logUsage("recall_verified_count update FAILED (id=%s, NOT consumed, will retry next commit) : %v", memID, err)
_, _ = db.Exec(
`UPDATE usage_recall SET retry_count=retry_count+1, next_retry_at=? WHERE id=?`,
logUsage("recall_verified_count update FAILED (id=%s, NOT consumed, will retry) : %v", memID, err)
_, _ = db.Exec(`UPDATE usage_recall SET retry_count=retry_count+1, next_retry_at=? WHERE id=?`,
time.Now().Add(usageBackoff(retryCount+1)).Format(time.RFC3339), id)
failed++
if breakerMax > 0 && failed >= breakerMax {
logUsage("failure breaker: %d failures >= cap %d, abort this call", failed, breakerMax)
return
}
continue
}
if _, err := db.Exec(`UPDATE usage_recall SET consumed=1, consumed_at=?, retry_count=0, next_retry_at=NULL WHERE id=?`, now, id); err != nil {
logUsage("consume row id=%d failed: %v", id, err)
continue
}
_, _ = db.Exec(`UPDATE usage_recall SET consumed=1, consumed_at=?, retry_count=0, next_retry_at=NULL WHERE id=?`, now, id)
flags = append(flags, UsageFlag{
MemoryID: memID, Content: memContent, Criterion: "carry_forward",
MatchedTokens: toks, HitCount: hits, RunesMatched: runes, ConsumedAt: now,
})
}
return
}
// CheckCommittedForUse commit 写侧钩子:检查新写入的内容是否携带了
// 最近被召回记忆的独特 token;命中则判定「被用上」并落 outcome(门控)。
// 返回本次判定出的「被用上」记忆(供上报/取证)。
// 消费调度(t_9143c968):expire→retry→tail→sweep→ttl,全程 O(batch),无全表计数。
func CheckCommittedForUse(ldb LanceDB, agent, namespace, content string) []UsageFlag {
if !usageTrackEnabled || ldb == nil || content == "" {
return nil
}
db := usageOpen()
if db == nil {
return nil
}
usageMu.Lock()
defer usageMu.Unlock()
batch := usageEnvInt("ZHIYI_USAGE_BATCH", 200)
sweepBatch := usageEnvInt("ZHIYI_USAGE_SWEEP_BATCH", 500) // 0=关闭轮巡
breakerMax := int(envFloat("ZHIYI_USAGE_BACKLOG_MAX", 500))
cutoff := time.Now().Add(-time.Duration(usageWindowHours * float64(time.Hour))).Format(time.RFC3339)
scanNow := time.Now().Format(time.RFC3339)
contentSet := tokenSet(content)
var scanned int
// ── expire:出窗未消费行标 consumed=2(skipped_expired,留证据不物理删)──
if chunk := usageEnvInt("ZHIYI_USAGE_EXPIRE_CHUNK", 5000); chunk > 0 {
if res, err := db.Exec(`UPDATE usage_recall SET consumed=2
WHERE id IN (SELECT id FROM usage_recall WHERE consumed=0 AND recalled_at<? LIMIT ?)`,
cutoff, chunk); err != nil {
logUsage("expire mark: %v", err)
} else if n, _ := res.RowsAffected(); n > 0 {
logUsage("expire mark: %d rows -> consumed=2 (skipped_expired)", n)
}
}
// ── retry:退避到期的失败行优先(t_20bfbd9a 退避语义不变)──
rf, rFailed, _ := usageScanBatch(db, ldb, agent, namespace, contentSet,
`SELECT id, memory_id, content, tokens, namespace, agent, retry_count
FROM usage_recall
WHERE consumed=0 AND next_retry_at IS NOT NULL AND next_retry_at<=? AND recalled_at>=?
ORDER BY id LIMIT ?`, scanNow, cutoff, batch)
scanned += len(rf) + rFailed
// ── tail:游标向上 ≤batch 行(DESC 先吃最新;与本次 commit 因果对齐的主信号面)──
var top int64
_ = db.QueryRow(`SELECT COALESCE(MAX(id),0) FROM usage_recall`).Scan(&top)
tail, sweep := usageCursorGet(db)
tf, tFailed, _ := usageScanBatch(db, ldb, agent, namespace, contentSet,
`SELECT id, memory_id, content, tokens, namespace, agent, retry_count
FROM usage_recall
WHERE consumed=0 AND id>? AND id<=? AND recalled_at>=? AND (next_retry_at IS NULL OR next_retry_at<=?)
ORDER BY id DESC LIMIT ?`, tail, top, cutoff, scanNow, batch)
scanned += len(tf) + tFailed
newTail := top // 游标推进到表尾;本批未触达的落后行留给 sweep/expire(代价有界)
if newTail < tail {
newTail = tail // 库被外部重置等异常:游标不回退
}
// ── sweep:轮巡复查未消费行;游标后无行 ⇒ 游标归 0 并【本轮立即从 0 补扫一次】
// (「一次召回、后续多个 commit 逐个命中」依赖这条;至多两次有界查询,不整窗扫)──
var sf []UsageFlag
sFailed, sMax := 0, int64(0)
newSweep := sweep
if sweepBatch > 0 && breakerMax > 0 && rFailed+tFailed >= breakerMax {
// 单次调用失败熔断(ZHIYI_USAGE_BACKLOG_MAX 收窄后的新语义):
// 前面已积累 ≥cap 失败 ⇒ 本轮不再开新失败面(旧「全表积压跳过」已删除)。
logUsage("sweep skipped by failure breaker (%d failures >= cap %d)", rFailed+tFailed, breakerMax)
} else if sweepBatch > 0 {
sf, sFailed, sMax = usageScanBatch(db, ldb, agent, namespace, contentSet,
`SELECT id, memory_id, content, tokens, namespace, agent, retry_count
FROM usage_recall
WHERE consumed=0 AND id>? AND recalled_at>=? AND (next_retry_at IS NULL OR next_retry_at<=?)
ORDER BY id LIMIT ?`, sweep, cutoff, scanNow, sweepBatch)
if sMax == 0 && sweep > 0 { // 到表尾:回绕 0,本轮补扫
var f2 []UsageFlag
var x2 int
f2, x2, sMax = usageScanBatch(db, ldb, agent, namespace, contentSet,
`SELECT id, memory_id, content, tokens, namespace, agent, retry_count
FROM usage_recall
WHERE consumed=0 AND id>0 AND recalled_at>=? AND (next_retry_at IS NULL OR next_retry_at<=?)
ORDER BY id LIMIT ?`, cutoff, scanNow, sweepBatch)
sf = append(sf, f2...)
sFailed += x2
}
scanned += len(sf) + sFailed
if sMax > 0 {
newSweep = sMax
} else {
newSweep = 0 // 表尾到达:回绕
}
}
usageCursorSet(db, newTail, newSweep)
// ── TTL:consumed>=1(含 2)且 recalled_at<7d 删除,chunk 化防长事务 ──
if chunk := usageEnvInt("ZHIYI_USAGE_TTL_CHUNK", 5000); chunk > 0 {
ttlCutoff := time.Now().Add(-7 * 24 * time.Hour).Format(time.RFC3339)
if res, err := db.Exec(`DELETE FROM usage_recall
WHERE id IN (SELECT id FROM usage_recall WHERE consumed>=1 AND recalled_at<? LIMIT ?)`,
ttlCutoff, chunk); err != nil {
logUsage("ttl purge: %v", err)
} else if n, _ := res.RowsAffected(); n > 0 {
logUsage("ttl purged %d rows (consumed>=1, recalled_at<7d)", n)
// 定期截断 WAL,防删除型事务把 -wal 撑大
_, _ = db.Exec(`PRAGMA wal_checkpoint(TRUNCATE)`)
}
}
flags := make([]UsageFlag, 0, len(rf)+len(tf)+len(sf))
flags = append(flags, rf...)
flags = append(flags, tf...)
flags = append(flags, sf...)
if scanned > 0 || len(flags) > 0 {
logUsage("usage scan: scanned=%d flagged=%d failed=%d tail=%d->%d sweep=%d->%d",
scanned, len(flags), rFailed+tFailed+sFailed, tail, newTail, sweep, newSweep)
}
return flags
}
+17 -13
View File
@@ -150,18 +150,22 @@ func TestUsageBackoff_ExponentialGrowthAndCap(t *testing.T) {
}
}
// t_20bfbd9a 治本②:积压上限量化 —— 未消费待重试行超过 ZHIYI_USAGE_BACKLOG_MAX 时本轮跳过扫描。
// t_20bfbd9a 治本②(旧):积压超 ZHIYI_USAGE_BACKLOG_MAX ⇒ 本轮 skip。
// t_9143c968(2026-10-30)作废该语义:写入量涨到 40~200 万行/日后「无窗 pending>cap」
// 恒成立 ⇒ 每 commit skip ⇒ 消费侧永久熔断(10-19 起 consumed=0)。cap 收窄为
// 「单次调用失败熔断」(见 TestUsageCursor_FailureBreakerReplacesBacklogSkip)。
// 本用例改为验证:积压再大也不再 skip —— 窗口内有可消费行就必须消费,
// 窗口外 pending 不再阻塞(被 expire 标 2)。这正是卡体 AC1 的必要条件。
func TestCheckCommittedForUse_BacklogCapSkipsScan(t *testing.T) {
setupUsageTest(t)
db := usageOpen()
if db == nil {
t.Fatal("usageOpen failed")
}
// 显式压小积压上限,验证超过即跳过
// 压小 cap 到 2,且制造远超 cap 的 pending —— 旧实现此处必 skip。
t.Setenv("ZHIYI_USAGE_BACKLOG_MAX", "2")
now := time.Now().Format(time.RFC3339)
// 塞 3 条未消费召回行(超过上限 2)
for i := 0; i < 3; i++ {
for i := 0; i < 5; i++ {
if _, err := db.Exec(`INSERT INTO usage_recall(memory_id, content, tokens, q_hash, namespace, agent, recalled_at)
VALUES(?,?,?,?,?,?,?)`,
"mem_bk"+string(rune('0'+i)), "牧尘今天要求处理看板任务验收"+string(rune('0'+i)),
@@ -169,15 +173,15 @@ func TestCheckCommittedForUse_BacklogCapSkipsScan(t *testing.T) {
t.Fatalf("insert: %v", err)
}
}
// 无需真实 LanceDB(ldb=nil 直接返回 nil flags);本用例只验证「超限即提前跳过」不 panic 且不消费
flags := CheckCommittedForUse(nil, "agent", "ns", "牧尘今天要求处理看板任务验收0")
if flags != nil {
t.Fatalf("ldb=nil 应直接返回 nil flags(超限跳过不应触及消费路径), got %v", flags)
// 窗口内有可消费行 ⇒ 即使 pending(5)≫cap(2) 也必须消费它(不再 skip)。
mlc := NewMemLanceClient(nil)
flags := CheckCommittedForUse(mlc, "agent", "ns", "牧尘今天要求处理看板任务验收")
if len(flags) == 0 {
t.Fatalf("积压 > cap 不再触发 skip:窗口内可消费行必须被判定, got 0 flags")
}
// 判据:超限跳过时所有行仍 consumed=0(没有消费行为发生)
var pending int
_ = db.QueryRow(`SELECT COUNT(*) FROM usage_recall WHERE consumed=0`).Scan(&pending)
if pending != 3 {
t.Fatalf("超限跳过扫描后应 3 行全部未消费(no consumption), got %d", pending)
// 被消费的行 consumed=1;旧实现这里会 5 行全 consumed=0。
if c := usagePendingBy(t, db, "consumed=1"); c == 0 {
t.Fatalf("修复后应至少消费 1 行(旧 skip 语义已废), actual %d", c)
}
}
+6 -1
View File
@@ -708,7 +708,12 @@ impl LanceDBOps {
let op = ttbl
.update()
.only_if(&predicate)
.column("recall_seen_count", &format!("recall_seen_count + {}", delta))
// C档 (t_5aa7cb77):recall_seen_count 是可空列(存量 43.8% 行为 NULL)。
// 裸 `recall_seen_count + delta` 在 NULL 上恒为 NULL ⇒ 观测值静默丢失。
// COALESCE(.., 0) 让 NULL 行按 0 起算(NULL+1 ⇒ 1)。不可空列行为不变。
// 注意:不得给逐条 fallback 的 value 字符串加 COALESCE —— 那走 current-value
// 算术解析(split_whitespace 期望恰好 3 段),COALESCE 会炸掉解析。
.column("recall_seen_count", &format!("COALESCE(recall_seen_count, 0) + {}", delta))
.column("last_recalled_at", &ts_lit);
rt().block_on(op.execute()).map_err(Into::into)
};
+4
View File
@@ -14,3 +14,7 @@ a2be088cba59c95901b22d855dea9b6df3bf3a80
4341fac4c28a4f2825f7373ea0ec94d07c6452f0
b101b464471f6283c438f089e9ec269c8f2b1644
01d8f34
a8e15c9ceee7978272d57e9dc7bf8f29c1b7970c
6952bdb2138bb7b14479f64553fb911b24e9dda4
ebe6384b733e52a910cd72aaf7afdb94128c72a1
dbdb2ed08e0ba4a93cf43127e0e739eee114b4f6
+200
View File
@@ -0,0 +1,200 @@
#!/usr/bin/env python3
"""织忆自检·全量取数器(唯一允许直连生产库的脚本)
为什么存在
----------
织忆相关的定时自检脚本曾有 ~20 个,其中两个直接 `lancedb.connect("/var/lib/memoryweave")`
绕过服务读全表。那种写法的病根不是"直连",而是**取数逻辑散落多处、与 schema 一起腐烂**:
pattern 自检写死 `type='pattern'`(库里其实是 `pattern_stat`)⇒ 连续数月静默 0 输出。
本文件把"全量取数"收成一处、放进仓库:改 schema 时同一个 commit 就能改它,且它有输出可复核。
判定/告警/投递**不在这里**(留在外部 cron)—— 观测者不能住在被观测者体内。
它产出 `~/.hermes/state/zhiyi-quality.json`,服务端 `GET /api/v1/selfcheck?level=full` 读这份快照。
口径(必须与历史 zhiyi-quality-watch.py 一致,改了就改告警语义)
------------------------------------------------------------
- 碎片:`len(content.strip()) < FRAG_LEN(20)`,按**字符数**(不是字节,中文按 1 算)
- 冗余:live 行内 content 完全相同;冗余行数 = Σ(组内行数-1)
- 近 7 天:created_at 在 7 天内(live)
- 写量:按 created_at 的**本地日期**分桶,同时给 rows(含已删)与 live(仅存活)
性能
----
只投影 3 列(content/created_at/is_deleted),不读 1024 维向量:
4 万行 0.06s(旧实现 `to_arrow()` 全列要数秒且吃 1G+ 内存)。
用法
----
python3 tools/selfcheck_full.py [--out PATH] [--json] [--days 60]
"""
from __future__ import annotations
import argparse
import datetime as dt
import json
import os
import sys
import time
from collections import Counter
DB_PATH = os.environ.get("ZHIYI_DB", "/var/lib/memoryweave")
FRAG_LEN = 20
OUT_DEFAULT = os.path.expanduser("~/.hermes/state/zhiyi-quality.json")
SCHEMA = 1
def _reexec_with_lancedb() -> None:
"""cron 默认解释器没有 lancedb 时,切到 lance-compact venv 再跑(一次性)。"""
try:
import lancedb # noqa: F401
return
except ImportError:
pass
venv_py = os.path.expanduser("~/.hermes/venvs/lance-compact/bin/python")
if os.path.exists(venv_py) and os.path.realpath(sys.prefix) != os.path.realpath(
os.path.dirname(os.path.dirname(venv_py))
):
os.execv(venv_py, [venv_py] + sys.argv)
sys.stderr.write("[selfcheck_full] lancedb 不可用(需 lance-compact venv)\n")
sys.exit(2)
def _as_bool(v) -> bool:
if v is None:
return False
if isinstance(v, bool):
return v
return str(v).strip().lower() in ("true", "1")
def collect(days: int = 60) -> dict:
import lancedb
t0 = time.time()
db = lancedb.connect(DB_PATH)
table = db.open_table("memories")
tbl = table.to_lance().to_table(columns=["content", "created_at", "is_deleted"])
contents = tbl.column("content").to_pylist()
created = tbl.column("created_at").to_pylist()
deleted = tbl.column("is_deleted").to_pylist()
total = len(contents)
frag_all = 0
live = 0
frag_live = 0
dup_counter: Counter[str] = Counter()
daily: dict[str, dict[str, int]] = {}
now = dt.datetime.now()
week_ago = now - dt.timedelta(days=7)
new_7d = 0
new_frag_7d = 0
for i in range(total):
raw = contents[i]
text = str(raw).strip() if raw is not None else ""
n = len(text)
is_frag = n < FRAG_LEN
if is_frag:
frag_all += 1
is_del = _as_bool(deleted[i] if i < len(deleted) else False)
# created_at:存量行有空串/畸形 → 归 unknown,不炸整批
day = "unknown"
ts = None
if i < len(created):
raw_ts = created[i]
if raw_ts:
try:
if isinstance(raw_ts, dt.datetime):
ts = raw_ts
else:
ts = dt.datetime.fromisoformat(str(raw_ts).replace("Z", "+00:00"))
if ts.tzinfo is not None:
ts = ts.astimezone().replace(tzinfo=None)
day = ts.strftime("%Y-%m-%d")
except Exception:
ts = None
slot = daily.setdefault(day, {"rows": 0, "live": 0})
slot["rows"] += 1
if is_del:
continue
live += 1
slot["live"] += 1
dup_counter[text] += 1
if is_frag:
frag_live += 1
if ts is not None and ts >= week_ago:
new_7d += 1
if is_frag:
new_frag_7d += 1
dup_groups = sum(1 for c in dup_counter.values() if c > 1)
dup_extra = sum(c - 1 for c in dup_counter.values() if c > 1)
# 只保留最近 days 天(含 unknown),按日期升序
keep = sorted(daily.keys())[-days:]
day_rows = [{"date": d, "rows": daily[d]["rows"], "live": daily[d]["live"]} for d in keep]
live_days = [x["live"] for x in day_rows if x["date"] != "unknown"]
today = now.strftime("%Y-%m-%d")
today_live = next((x["live"] for x in day_rows if x["date"] == today), 0)
return {
"schema_version": SCHEMA,
"generated_at": now.strftime("%Y-%m-%dT%H:%M:%S%z"),
"source": {"db": DB_PATH, "table": "memories", "mode": "read_only_projection"},
"duration_ms": int((time.time() - t0) * 1000),
"quality": {
"frag_len": FRAG_LEN,
"total_rows": total,
"live_rows": live,
"frag_rows_all": frag_all,
"frag_rate_all": round(frag_all / total, 4) if total else None,
"frag_rows_live": frag_live,
"frag_rate_live": round(frag_live / live, 4) if live else None,
"dup_groups": dup_groups,
"dup_extra_rows": dup_extra,
"dup_rate": round(dup_extra / live, 4) if live else None,
"new_rows_7d": new_7d,
"new_frag_rows_7d": new_frag_7d,
"new_frag_rate_7d": round(new_frag_7d / new_7d, 4) if new_7d else None,
},
"write_volume": {
"days": day_rows,
"today": today_live,
"today_date": today,
# 近 7 个完整日(不含今天/unknown)
"last_7d": sum(x["live"] for x in day_rows if x["date"] not in (today, "unknown")) if day_rows else 0,
"nonzero_days": sum(1 for x in live_days if x > 0),
},
}
def main() -> int:
ap = argparse.ArgumentParser(description="织忆自检全量取数(只读)")
ap.add_argument("--out", default=OUT_DEFAULT)
ap.add_argument("--json", action="store_true", help="打印到 stdout")
ap.add_argument("--days", type=int, default=60)
a = ap.parse_args()
_reexec_with_lancedb()
snap = collect(a.days)
if a.out:
os.makedirs(os.path.dirname(a.out), exist_ok=True)
tmp = a.out + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(snap, f, ensure_ascii=False)
os.replace(tmp, a.out) # 原子替换:读方永不会看到半个文件
if a.json:
print(json.dumps(snap, ensure_ascii=False, indent=1))
return 0
if __name__ == "__main__":
sys.exit(main())