Compare commits

...
Author SHA1 Message Date
xiaoyi db0845306d 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:42:19 +08:00
xiaoyi e9760dddeb 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:05:58 +08:00
xiaoyi 7c12107967 feat(selfcheck): /api/v1/selfcheck 统一取数端点 + tools/selfcheck_full.py 全量快照(牧尘批「三档」) 2026-10-26 13:23:03 +08:00
小唯 9123bdcbb5 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 13:18:39 +08:00
小怡 0bfe182054 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 10:56:48 +08:00
小怡 7aa562c7c9 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 10:47:29 +08:00
18 changed files with 1141 additions and 42 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`(脚本侧统一处理,避免多处各改一遍)。
+6 -3
View File
@@ -545,6 +545,9 @@ 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)
// H4: Default diversity — balance relevance & diversity
@@ -582,7 +585,7 @@ func (a *API) Recall(w http.ResponseWriter, r *http.Request) {
// 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)
kwResults, kwMeta, kwErr := a.Pipeline.RecallWithMetaOpts(req.Query, req.Namespace, req.Limit, req.Diversity, nsDerivedFromAgent)
if kwErr != nil {
// 主路径硬错误(向量编码/rerank 依赖不可用):即便兜底能补结果,也按 502 上报,
// 调用方据此走失败路径而不是把依赖故障误判成"无匹配"
@@ -629,14 +632,14 @@ func (a *API) Recall(w http.ResponseWriter, r *http.Request) {
// Pure semantic — BM25 off (H1 skipped, Pipeline Recall has BM25 built in — handled by switch)
// t_949840b1:RecallWithMeta 额外返回候选池真实大小(ANN 粗检索池,非最终产出数)
var meta storage.RecallMeta
results, meta, err = a.Pipeline.RecallWithMeta(req.Query, req.Namespace, req.Limit, req.Diversity)
results, meta, err = a.Pipeline.RecallWithMetaOpts(req.Query, req.Namespace, req.Limit, req.Diversity, nsDerivedFromAgent)
annScanned = meta.CandidatesScanned
cacheHit = meta.CacheHit // t_1c12e000
truncated = meta.Truncated // t_3aa2a8c1
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.RecallWithMetaOpts(req.Query, req.Namespace, req.Limit, req.Diversity, nsDerivedFromAgent)
annScanned = meta.CandidatesScanned
cacheHit = meta.CacheHit // t_1c12e000
truncated = meta.Truncated // t_3aa2a8c1
+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")
}
}
@@ -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
+4
View File
@@ -613,6 +613,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。
+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()
+48 -4
View File
@@ -584,6 +584,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()
@@ -805,18 +831,20 @@ 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)
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,7 +856,11 @@ 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,
})
}
@@ -837,6 +869,18 @@ func (gs *SQLiteGraphStore) ExpandFromResults(results []models.RecallResult, nam
return expanded
}
// 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,95 @@
// 织忆 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)
}
return gs
}
// 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)
// 边枚举(供社区发现用)
+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)
+43 -4
View File
@@ -318,6 +318,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。默认关 ⇒ 零行为变化。
@@ -394,7 +407,15 @@ 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) {
results, meta, err := p.recallWithMetaInner(query, namespace, topK, diversity, allowNSFallback)
if err != nil {
return results, meta, err
}
@@ -407,7 +428,7 @@ func (p *RecallPipeline) RecallWithMeta(query, namespace string, topK int, diver
return results, meta, err
}
func (p *RecallPipeline) recallWithMetaInner(query, namespace string, topK int, diversity float64) ([]models.RecallResult, RecallMeta, error) {
func (p *RecallPipeline) recallWithMetaInner(query, namespace string, topK int, diversity float64, allowNSFallback bool) ([]models.RecallResult, RecallMeta, error) {
// topK 先归一:缓存键必须与写入侧用的 topK 一致。recallUncached 内部也会归一,
// 若此处不先归一,topK<=0 的调用读到的是"归一前的键"、永远命中不了自己写的条目。
if topK <= 0 {
@@ -428,7 +449,7 @@ func (p *RecallPipeline) recallWithMetaInner(query, namespace string, topK int,
}
}
}
return p.recallUncached(query, namespace, topK, diversity)
return p.recallUncached(query, namespace, topK, diversity, allowNSFallback)
}
// Recall 保持原签名(缓存 + 全量流程),行为不变。
@@ -439,7 +460,7 @@ 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(query, namespace string, topK int, diversity float64, allowFallback bool) ([]models.RecallResult, RecallMeta, error) {
if topK <= 0 {
topK = 10
}
@@ -496,6 +517,24 @@ func (p *RecallPipeline) recallUncached(query, namespace string, topK int, diver
candidates = append(candidates, nsResults...)
}
}
// 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))
@@ -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)
}
+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())