Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db0845306d | ||
|
|
e9760dddeb | ||
|
|
7c12107967 | ||
|
|
9123bdcbb5 | ||
|
|
0bfe182054 | ||
|
|
7aa562c7c9 |
@@ -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`(脚本侧统一处理,避免多处各改一遍)。
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 补入(不凭空加)")
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
// 边枚举(供社区发现用)
|
||||
|
||||
@@ -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 条不该触发 —— 说明仍在用硬编码阈值")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user