diff --git a/go/internal/api/routes/core.go b/go/internal/api/routes/core.go index 0ae3cb7..64aa402 100644 --- a/go/internal/api/routes/core.go +++ b/go/internal/api/routes/core.go @@ -271,6 +271,162 @@ func (a *API) Commit(w http.ResponseWriter, r *http.Request) { respond(w, 201, respData) } +// POST /api/v1/memory/write — 显式写入 memories 表(主症A,t_fa6c9104,2026-10-16) +// +// 背景:/api/v1/commit 把原文落 episodes 表、等异步 LLM 蒸馏(质量门丢弃率 ~68%)。 +// 「显式保存重要事实」场景(Hermes 插件 memory_write)走 commit ⇒ 写进去的东西 +// 自己召回不回来:episodes 表(recall 查 memories 表)+ 零向量 + 靠蒸馏流水线 +// 升级成 mem_*(大概率静默丢失)。 +// +// 本端点:同步嵌入 → 去重/合并 → 矛盾只报告不拦 → 直写 memories 表 ⇒ 写完立刻可召回。 +// 硬约束遵守: +// - 纯新增路由:commit / recall / 蒸馏 路径零改动(默认零行为变化) +// - 嵌入失败 ⇒ 502 不写(写零向量 = 主症A 病根) +// - category 缺省 distilled:不在 recall Step 2.5 rawCategories 过滤集里 +// - 不走写时四操作门控(ZHIYI_WRITE_SEMANTIC_OPS):显式写入 = 调用方意图, +// 去重 3a/3b + 矛盾报告已足够;门控只约束 LLM 蒸馏事实 +func (a *API) WriteMemory(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + respondError(w, 405, "POST only") + return + } + var req struct { + Content string `json:"content"` + Category string `json:"category"` + AgentID string `json:"agent_id"` + Namespace string `json:"namespace"` + Source string `json:"source"` + EmbeddingModel string `json:"embedding_model"` + ScriptVersion string `json:"script_version"` + Confidence *float64 `json:"confidence"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondError(w, 400, "invalid body") + return + } + if len([]rune(req.Content)) < 8 { + respondError(w, 400, "content too short (<8 chars)") + return + } + if req.AgentID == "" { + req.AgentID = "hermes-a06" + } + if req.Category == "" { + req.Category = "distilled" + } + if req.Namespace == "" { + req.Namespace = classifyNamespace(req.Category, req.AgentID, req.Content) + } + src := req.Source + if src == "" { + src = "explicit_write" + } + embModel := req.EmbeddingModel + if embModel == "" { + embModel = "bge-m3" + } + scriptVer := req.ScriptVersion + if scriptVer == "" { + scriptVer = "hermes-zhiyi" + } + + // 1. 同步嵌入:失败 ⇒ 502,**不写零向量记录**(零向量 = 主症A 病根) + vector, err := a.Embedder.EncodeSingle(req.Content) + if err != nil { + respondErrorSchema(w, 502, "embed_failed", "embed failed: "+err.Error(), 5) + return + } + + // 2. 去重:exact/near-dup ⇒ 并入既有记忆(与 commit 3a/3b 同语义) + similar, _ := a.LanceDB.Search("memories", vector, 5, req.Namespace) + for _, existing := range similar { + if existing.Content == req.Content { + a.mergeMemory(existing, req.Content, req.Category, req.AgentID) + respond(w, 200, map[string]interface{}{ + "status": "merged", "memory_id": existing.ID, "reason": "exact_dup", + "table": "memories", + }) + return + } + if len(existing.Content) > 0 && len(req.Content) > 0 { + ev, eErr := a.Embedder.EncodeSingle(existing.Content) + if eErr == nil && cosineSimilarity(vector, ev) >= 0.98 { + a.mergeMemory(existing, req.Content, req.Category, req.AgentID) + respond(w, 200, map[string]interface{}{ + "status": "merged", "memory_id": existing.ID, "reason": "near_dup", + "table": "memories", + }) + return + } + } + } + + // 3. 矛盾检测(规则型,廉价):**只报告不拦** —— 显式写入是调用方意图, + // 旧新并存由写时四操作门控/人工 feedback 收口(与 commit 的 conflict 报告同形) + var conflictSources []string + for _, existing := range similar { + if existing.Content != req.Content { + conflictSources = append(conflictSources, existing.Content) + } + } + conflicts := a.ConflictDetector.DetectContradiction(req.Content, conflictSources) + + // 4. 直写 memories 表(跳过 LLM 蒸馏 + 质量门 ⇒ 无 68% 静默丢弃) + now := time.Now() + confidenceSource := "" + if req.Confidence != nil { + confidenceSource = "authoritative" + } + mem := models.MemoryRecord{ + ID: fmt.Sprintf("mem_%d", now.UnixNano()), + AgentID: req.AgentID, + Namespace: req.Namespace, + Content: req.Content, + Category: req.Category, + Vector: vector, + Tier: "normal", + Importance: 1.0, + QualityScore: 1.0, // 显式写入 = 可信来源(对照 LLM 蒸馏的 result.Overall) + Source: src, + Freshness: "fresh", + Provenance: "user", // bi-temporal 来源枚举 user|inferred|import + EmbeddingModel: embModel, + ScriptVersion: scriptVer, + Confidence: req.Confidence, + ConfidenceSource: confidenceSource, + CreatedAt: now, + UpdatedAt: now, + } + if err := a.LanceDB.InsertMemory(mem); err != nil { + respondError(w, 500, "insert memory: "+err.Error()) + return + } + + // 5. 搜索缓存失效(与蒸馏落库同一原则:写 memories ⇒ 该 namespace 缓存作废) + if storage.SearchCacheInstance != nil && mem.Namespace != "" { + storage.SearchCacheInstance.Invalidate(mem.Namespace) + } + // 跨 Agent 广播(镜像 commit 写后 goroutine;显式写入同样通知其它 agent) + go func() { + governance.PushCacheInvalidate(req.Namespace, "memory_write") + governance.PushMemoryCommitted(req.AgentID, req.Namespace, mem.ID) + }() + + respData := map[string]interface{}{ + "status": "ok", + "memory_id": mem.ID, + "table": "memories", + "category": mem.Category, + "namespace": mem.Namespace, + "note": "direct write: embedded + recallable immediately (no distill queue)", + } + if len(conflicts) > 0 { + respData["conflicts"] = conflicts + respData["warning"] = "contradictory memories exist; explicit write kept both (resolve via feedback)" + } + respond(w, 201, respData) +} + // mergeMemory 合并重复记忆:升版本、提重要性、更新时间戳 func (a *API) mergeMemory(existing models.MemoryRecord, newContent, newCategory, agentID string) { existing.Version++ diff --git a/go/internal/api/routes/eval_test.go b/go/internal/api/routes/eval_test.go index e098960..933b56d 100644 --- a/go/internal/api/routes/eval_test.go +++ b/go/internal/api/routes/eval_test.go @@ -10,10 +10,32 @@ import ( "net/http" "os" "path/filepath" + "strings" "testing" "time" ) +// liveAPIKey 取活服务密钥:优先 env ZHIYI_TEST_API_KEY;否则读密钥文件 +// /var/lib/memoryweave/.api-key 的主密钥(第 1 个非注释、非 legacy 行); +// 再退到旧 dev 键(无生产密钥文件的环境,如 CI)。 +// 2026-09-21 密钥轮换后,旧硬编码键 zhiyi-dev-key-2026 已 401 —— 全量金标实跑 +// 永远 SKIP 的根因(t_fa6c9104 修复量具可用性;不改任何判据)。 +func liveAPIKey() string { + if k := os.Getenv("ZHIYI_TEST_API_KEY"); k != "" { + return k + } + if b, err := os.ReadFile("/var/lib/memoryweave/.api-key"); err == nil { + for _, line := range strings.Split(string(b), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "legacy:") { + continue + } + return line + } + } + return "zhiyi-dev-key-2026" +} + // ─── recall 辅助:liveRecallOnce ───────────────────────────── // 一次 recall API 调用同时拿 top-5 的 ids + contents,避免两条 HTTP(限流保护)。 // 每次调用间加 200ms 间隔,由调用方负责(TestEvalGolden_Live 里 sleep)。 @@ -38,7 +60,7 @@ func liveRecallOnce(query string, maxRetries int, retryWait time.Duration) recal } req, _ := http.NewRequest("POST", "http://127.0.0.1:7821/api/v1/recall", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-API-Key", "zhiyi-dev-key-2026") + req.Header.Set("X-API-Key", liveAPIKey()) resp, err := http.DefaultClient.Do(req) if err != nil { lastErr = fmt.Errorf("HTTP 请求失败: %v", err) @@ -260,7 +282,7 @@ func TestLoadGoldenSet_EmbedFallback_DeployCWD(t *testing.T) { func TestEvalGolden_CounterEvidence(t *testing.T) { // 探活 zhiyid(无服务 → 跳过) healthReq, _ := http.NewRequest("GET", "http://127.0.0.1:7821/api/v1/health", nil) - healthReq.Header.Set("X-API-Key", "zhiyi-dev-key-2026") + healthReq.Header.Set("X-API-Key", liveAPIKey()) resp, err := http.DefaultClient.Do(healthReq) if err != nil || resp == nil { t.Skipf("zhiyid 服务不可达(%v),跳过反证测试", err) @@ -336,7 +358,7 @@ func TestEvalGolden_CounterEvidence(t *testing.T) { func TestEvalGolden_Live(t *testing.T) { // 探活 zhiyid(无服务 → 跳过,不污染 CI) healthReq, _ := http.NewRequest("GET", "http://127.0.0.1:7821/api/v1/health", nil) - healthReq.Header.Set("X-API-Key", "zhiyi-dev-key-2026") + healthReq.Header.Set("X-API-Key", liveAPIKey()) resp, err := http.DefaultClient.Do(healthReq) if err != nil || resp == nil { t.Skipf("zhiyid 服务不可达(%v),跳过全量金标实跑", err) diff --git a/go/internal/api/routes/memory_write_test.go b/go/internal/api/routes/memory_write_test.go new file mode 100644 index 0000000..e367941 --- /dev/null +++ b/go/internal/api/routes/memory_write_test.go @@ -0,0 +1,222 @@ +// 织忆 MemoryWeave — /api/v1/memory/write 显式直写 memories 表(主症A,t_fa6c9104,2026-10-16) +// +// 锁定行为: +// +// W1 写入落 memories 表:InsertMemory 被调用,id 前缀 mem_,tier=normal,quality=1.0, +// 且向量非全 0(嵌入服务故障时不写零向量记录) +// W2 category 缺省 distilled(recall Step 2.5 rawCategories 不过滤它) +// W3 嵌入失败 ⇒ 502 且不写(零向量 = 主症A 病根,禁静默写) +// W4 精确重复 ⇒ merged(不新增行) +// W5 短内容 <8 字 ⇒ 400;GET ⇒ 405 +// W6 零回归:commit 路径仍只写 episodes、不写 memories(AC5) +package routes + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync" + "testing" + + "github.com/xiaoxue/memoryweave/internal/distill" + "github.com/xiaoxue/memoryweave/internal/governance" + "github.com/xiaoxue/memoryweave/internal/models" + "github.com/xiaoxue/memoryweave/internal/storage" +) + +// recLance 记录型 LanceDB mock:断言 InsertMemory/Update 的实际调用与载荷。 +// 复用 zz_commit_conflict_gate_test.go 的 mockGraph / newMockEmbedServer / newMockLLM / commitWith(同包)。 +type recLance struct { + storage.LanceDB // 未实现的方法落到接口兜底(panic 即测试失败,不放任) + + mu sync.Mutex + seeds []models.MemoryRecord // 预置「库里已有」的记忆(Search 返回) + inserted []models.MemoryRecord // 实际 InsertMemory 调用(W1/W3/W4 断言) + updates []string // Update(table,id,...) 的 id 序列(merge 断言) + failIns bool // 模拟 IPC 落库失败(500 断言) +} + +func (r *recLance) Search(table string, vector []float32, topK int, ns string) ([]models.MemoryRecord, error) { + if table != "memories" { + return nil, nil + } + r.mu.Lock() + defer r.mu.Unlock() + out := make([]models.MemoryRecord, 0, len(r.seeds)) + out = append(out, r.seeds...) + if topK > 0 && len(out) > topK { + out = out[:topK] + } + return out, nil +} + +func (r *recLance) InsertMemory(m models.MemoryRecord) error { + r.mu.Lock() + defer r.mu.Unlock() + if r.failIns { + return errors.New("ipc down (simulated)") + } + r.inserted = append(r.inserted, m) + r.seeds = append(r.seeds, m) // 后续 Search 能看见(近重复语义) + return nil +} + +func (r *recLance) Update(table, id string, fields map[string]any) error { + r.mu.Lock() + defer r.mu.Unlock() + r.updates = append(r.updates, id) + return nil +} + +func (r *recLance) InsertEpisode(agentID, namespace, content, category string) (string, error) { + r.mu.Lock() + defer r.mu.Unlock() + return "ep_rec_" + strconv.Itoa(len(r.inserted)), nil +} + +func newRecAPI(t *testing.T, seeds ...models.MemoryRecord) (*API, *recLance) { + t.Helper() + emb := newMockEmbedServer() + t.Cleanup(emb.Close) + ldb := &recLance{seeds: seeds} + return NewAPI(ldb, storage.NewEmbedder(emb.URL), nil, governance.NewConflictDetector(), mockGraph{}), ldb +} + +func writeMemRequest(t *testing.T, api *API, body map[string]any) (int, map[string]interface{}) { + t.Helper() + b, _ := json.Marshal(body) + req := httptest.NewRequest("POST", "/api/v1/memory/write", bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + api.WriteMemory(w, req) + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + return w.Code, resp +} + +// W1+W2:写入落 memories 表,mem_ 前缀,category 缺省 distilled,向量非全 0 +func TestWriteMemory_InsertLandsInMemories(t *testing.T) { + api, ldb := newRecAPI(t) + code, resp := writeMemRequest(t, api, map[string]any{ + "content": "牧尘的工作所在省份是河南省", + "agent_id": "hermes-a06", + }) + if code != 201 { + t.Fatalf("expected 201, got %d: %s", code, resp) + } + if resp["status"] != "ok" || resp["table"] != "memories" { + t.Errorf("expected status=ok table=memories, got %v", resp) + } + if !strings.HasPrefix(resp["memory_id"].(string), "mem_") { + t.Errorf("memory_id 须 mem_ 前缀(落 memories 表),got %v", resp["memory_id"]) + } + if resp["category"] != "distilled" { + t.Errorf("category 缺省须 distilled(Step 2.5 不过滤),got %v", resp["category"]) + } + if len(ldb.inserted) != 1 { + t.Fatalf("expected exactly 1 InsertMemory, got %d", len(ldb.inserted)) + } + m := ldb.inserted[0] + if m.Category != "distilled" || m.Tier != "normal" || m.QualityScore != 1.0 { + t.Errorf("row 字段: category=%s tier=%s quality=%v(期望 distilled/normal/1.0)", m.Category, m.Tier, m.QualityScore) + } + if m.Namespace != "hermes-main" { + t.Errorf("namespace 推导: got %s 期望 hermes-main(hermes-a06 ⇒ deriveNamespace)", m.Namespace) + } + if len(m.Vector) != 1024 { + t.Fatalf("vector 维度须 1024,got %d", len(m.Vector)) + } + // W1 核心判据:vector 非全 0 + nonzero := 0 + for _, f := range m.Vector { + if f != 0 { + nonzero++ + } + } + if nonzero == 0 { + t.Errorf("向量全 0 —— 主症A 病根复现(禁写零向量记录)") + } +} + +// W3:嵌入失败 ⇒ 502 且不写库(fail-close,不静默写零向量) +func TestWriteMemory_EmbedFail_502_NoWrite(t *testing.T) { + failEmb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "embed backend down", http.StatusInternalServerError) + })) + defer failEmb.Close() + ldb := &recLance{} + api := NewAPI(ldb, storage.NewEmbedder(failEmb.URL), nil, governance.NewConflictDetector(), mockGraph{}) + code, resp := writeMemRequest(t, api, map[string]any{"content": "牧尘的工作所在省份是河南省"}) + if code != 502 { + t.Fatalf("embed 失败须 502,got %d: %v", code, resp) + } + if len(ldb.inserted) != 0 { + t.Errorf("嵌入失败不得写库(零向量 = 主症A),inserted=%d", len(ldb.inserted)) + } +} + +// W4:精确重复 ⇒ merged,不新增行 +func TestWriteMemory_ExactDupMerges(t *testing.T) { + seed := models.MemoryRecord{ID: "mem_seed", Content: "家用服务器地址是 192.168.123.11", Category: "distilled"} + api, ldb := newRecAPI(t, seed) + code, resp := writeMemRequest(t, api, map[string]any{"content": seed.Content}) + if code != 200 || resp["status"] != "merged" { + t.Fatalf("expected 200 merged, got %d %v", code, resp) + } + if len(ldb.inserted) != 0 { + t.Errorf("精确重复不得新增行,inserted=%d", len(ldb.inserted)) + } + if len(ldb.updates) == 0 || ldb.updates[0] != "mem_seed" { + t.Errorf("merge 须 Update 既有行 mem_seed,updates=%v", ldb.updates) + } +} + +// W5:短内容 400;GET 405 +func TestWriteMemory_Validation(t *testing.T) { + api, _ := newRecAPI(t) + if c, _ := writeMemRequest(t, api, map[string]any{"content": "太短了"}); c != 400 { + t.Errorf("短内容(<8字)须 400, got %d", c) + } + req := httptest.NewRequest("GET", "/api/v1/memory/write", nil) + w := httptest.NewRecorder() + api.WriteMemory(w, req) + if w.Code != 405 { + t.Errorf("GET 须 405, got %d", w.Code) + } +} + +// 落库失败(IPC down)⇒ 500 不吞错 +func TestWriteMemory_InsertFail_500(t *testing.T) { + api, ldb := newRecAPI(t) + ldb.failIns = true + code, _ := writeMemRequest(t, api, map[string]any{"content": "测试落库失败场景内容足够长"}) + if code != 500 { + t.Fatalf("InsertMemory 失败须 500(不吞错),got %d", code) + } +} + +// W6 零回归:commit 路径仍「只写 episodes、不写 memories」(等蒸馏) +func TestCommit_StillEpisodesOnly_ZeroRegression(t *testing.T) { + api, ldb := newRecAPI(t) + llm := newMockLLM() + defer llm.server.Close() + oldRef := DistillEngineRef + DistillEngineRef = distill.NewEngine(llm.server.URL, "mock-model", "") + defer func() { DistillEngineRef = oldRef }() + + // 复用 commitWith(同包 zz 测试):普通对话提交 + resp := commitWith(t, api, "今天天气不错,我们去楼下散步", false) + if resp["status"] != "ok" { + t.Fatalf("commit 基线须 status=ok,got %v", resp) + } + if resp["memory_id"] != "" { + t.Errorf("commit 不在写入时创建 memory(AC5 零回归),got %v", resp["memory_id"]) + } + if len(ldb.inserted) != 0 { + t.Errorf("commit 不得直写 memories 表(零回归),inserted=%d", len(ldb.inserted)) + } +} diff --git a/go/internal/api/server.go b/go/internal/api/server.go index 959ad7c..10a286d 100644 --- a/go/internal/api/server.go +++ b/go/internal/api/server.go @@ -568,6 +568,9 @@ func NewServer() http.Handler { mux.HandleFunc("/api/v1/bootstrap", api.Bootstrap) mux.HandleFunc("/api/v1/stats", api.Stats) mux.HandleFunc("/api/v1/batch-commit", api.BatchCommit) + // 主症A(t_fa6c9104):显式写入直落 memories 表(嵌入 + 立刻可召回)。 + // 纯新增路由:commit/recall/蒸馏路径零改动;旧服务端 404 时插件自动回落 commit。 + mux.HandleFunc("/api/v1/memory/write", api.WriteMemory) // 自优化指标(Dashboard,含 avg_distill_loss) // total_episodes:Rust count_rows 真实值;旧 sidecar 回退 episodes_cached(进程内缓存,非总量) mux.HandleFunc("/api/v1/metrics", func(w http.ResponseWriter, r *http.Request) { diff --git a/plugins/hermes-zhiyi/__init__.py b/plugins/hermes-zhiyi/__init__.py index 6f60337..f25ea44 100644 --- a/plugins/hermes-zhiyi/__init__.py +++ b/plugins/hermes-zhiyi/__init__.py @@ -1,5 +1,3 @@ -# ⚠️ 部署副本 — 权威副本 = ~/.hermes/hermes-agent/plugins/memory/zhiyi/__init__.py(bundled-first 生效) -# 改内容请改权威副本后再同步本文件(2026-09-26 三副本收敛) """hermes-zhiyi — Bridge plugin: Hermes Agent → ZhiYi MemoryWeave REST API. Uses ZhiYi's bge-m3 1024-dim semantic search + bge-reranker-v2-m3 reranking. @@ -23,6 +21,7 @@ from __future__ import annotations import json import logging import os +import re import threading import time from pathlib import Path @@ -79,13 +78,79 @@ except RuntimeError as _e: # 日志中只暴露 key 的前 4 位,用于调试 _ZHIYI_KEY_PREFIX = ZHIYI_API_KEY[:4] if ZHIYI_API_KEY else "NONE" +# ── [t_a9848f01 主症B] 元笔记过滤器(召回侧抑制存量"关于召回/蒸馏系统自身"的诊断/复现/调试记录)── +# 硬约束:改召回行为必须 env 门控 + 默认零行为变化。仅当 ZHIYI_META_FILTER == '1' 时启用; +# 默认(unset/0)或加载失败 → 恒 False(零变化),保证生产不受意外影响。 +def _find_meta_filter_module(): + """定位共享的 meta_note_filter.py。共享脚本在**根** ~/.hermes/scripts/lib, + 但 HERMES_HOME 可能是 profile 目录(~/.hermes/profiles/
)→ 逐个候选找,取第一个存在的。"""
+ cands = []
+ try:
+ cands.append(Path.home() / ".hermes" / "scripts" / "lib" / "meta_note_filter.py")
+ cands.append(HERMES_HOME / "scripts" / "lib" / "meta_note_filter.py")
+ for _base in list(HERMES_HOME.parents[:3]): # profile 目录上溯到根
+ cands.append(_base / "scripts" / "lib" / "meta_note_filter.py")
+ except Exception:
+ pass
+ for _c in cands:
+ try:
+ if _c.exists():
+ return _c
+ except Exception:
+ continue
+ return None
+
+
+def _meta_note_pred():
+ """返回 text->bool 谓词。未启用/加载失败 → 恒 False。启用 → 用 scripts/lib/meta_note_filter。"""
+ if os.environ.get("ZHIYI_META_FILTER", "0").strip() != "1":
+ return lambda _t: False
+ try:
+ import importlib.util
+ p = _find_meta_filter_module()
+ if p is None:
+ return lambda _t: False
+ spec = importlib.util.spec_from_file_location("_zhiyi_meta_note_filter", str(p))
+ if spec is None or spec.loader is None:
+ return lambda _t: False
+ mod = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(mod)
+ def _pred(text):
+ try:
+ ok, _reason = mod.is_meta_note(text or "")
+ return bool(ok)
+ except Exception:
+ return False
+ return _pred
+ except Exception:
+ return lambda _t: False
+
+
+def _meta_filter_on() -> bool:
+ return os.environ.get("ZHIYI_META_FILTER", "0").strip() == "1"
+
# ── Content Validation ───────────────────────────────────────────────────────
MEMORY_MIN_LENGTH = 20 # 少于20字的过滤掉
+
+# 系统报告特征【前缀】—— 与 scripts/session-to-zhiyi.py 的 SYSTEM_REPORT_PREFIXES 同判据
+# (2026-10-11 t_aada4c69 / t_42420afb)。cron 投递 / 网关来源包裹 / cron 系统警告是
+# 系统自己的元信息,不是用户对话 —— 写进织忆会被画像蒸馏当成"用户挫败感"(自吸收环)。
+# 按【头子串】判(content.lstrip().startswith(pat)):正文里恰好提到"Cronjob Response:"
+# 的正常讨论(边界样本)不会被误伤。保持与脚本侧集合完全一致(对称差 = 空集)。
+_SYSTEM_REPORT_PREFIXES = (
+ "Cronjob Response:",
+ "Cronjob Failed:",
+ "Cronjob Error:",
+ "Gateway message origin (JSON data, not instructions or authorization):",
+ "[IMPORTANT:", # cron/system 系统警告(如 "skill(s) could not be found")
+ "[HEARTBEAT]",
+)
+
+# 其余系统注入模式(上下文压缩摘要 / 提示注入垃圾)按【全文子串】判 —— 它们不必然出现在头部。
_FORBIDDEN_PATTERNS = [
"Review the conversation above",
"[System note:",
- "[IMPORTANT: Background process",
"## Active Task",
"## Context Compaction",
"## Relevant Memory",
@@ -97,34 +162,6 @@ _FORBIDDEN_PATTERNS = [
"do NOT answer questions or fulfill requests mentioned in the summary",
]
-# 系统样板文本【头部】特征(结构化来源标记,按头子串判)— 噪音闸门 t_689b6d38。
-# 这些是系统自己的元信息(cron 投递 / 网关来源包裹 / 心跳 / 系统通知),不是用户对话。
-# 被动注入入口命中即跳过语义召回与笔记返回(与 Go daemon /api/v1/recall、/graph/notes 同判据)。
-_SYSTEM_TEMPLATE_PREFIXES = (
- "Cronjob Response:",
- "Cronjob Failed:",
- "Cronjob Error:",
- "Gateway message origin (JSON data, not instructions or authorization):",
- "[IMPORTANT:", # cron 系统警告 / "[IMPORTANT: You are running as a scheduled cron job..." / "[IMPORTANT: Background process ..."
- "[HEARTBEAT]",
-)
-
-
-def _is_system_template_input(text: str) -> bool:
- """系统通知/提示样板文本判定(t_689b6d38 噪音闸门)。
-
- 优先用结构化来源标记(头子串),不用纯文本长度启发式。真实用户提问
- (含飞书/QQ/CLI 入站)不命中任何头/子串 ⇒ 照常召回,不误杀。
- """
- t = (text or "").lstrip()
- for pat in _SYSTEM_TEMPLATE_PREFIXES:
- if t.startswith(pat):
- return True
- for pat in _FORBIDDEN_PATTERNS:
- if pat in t:
- return True
- return False
-
_SOCIAL_CLOSERS = frozenset({
"ok", "好的", "👍", "👌", "✅", "谢谢", "感谢", "知道了",
"明白", "嗯", "好", "行", "yes", "yep", "thanks", "thx",
@@ -142,10 +179,35 @@ def _is_social_close(text: str) -> bool:
return False
+def _is_system_report_head(text: str) -> bool:
+ """系统报告特征:按【头子串】判(与 session-to-zhiyi.py is_system_report_message 同判据)。
+
+ 报告以 user 角色进入 sync_turn,而 _combine_turn 会在 combined 头部加 "用户: " 前缀
+ —— 所以必须在原始 user 文本上判头(否则头被 "用户: " 吞掉、永远拦不住)。
+ """
+ t = (text or "").lstrip()
+ for pat in _SYSTEM_REPORT_PREFIXES:
+ if t.startswith(pat):
+ logger.info("[ZhiYi] 拦截 1 条系统报告文本 @ sync_turn (pattern=%r)", pat)
+ return True
+ return False
+
+
def _is_valid_memory_content(content: str) -> bool:
- """过滤系统注入内容和测试垃圾,防止污染记忆存储。"""
+ """过滤系统注入内容和测试垃圾,防止污染记忆存储。
+
+ 系统报告特征按【头子串】判(content.lstrip().startswith(pat),与
+ session-to-zhiyi.py 的 is_system_report_message 同判据);其余系统注入模式按
+ 全文子串判。拦截系统报告不得静默(AC5)——否则下次查不出有没有生效。
+ """
if not content or len(content.strip()) < MEMORY_MIN_LENGTH:
return False
+ # 系统报告特征:按头子串判(正文提到"Cronjob Response:"等特征头的正常讨论不会被误杀)
+ head = content.lstrip()
+ for pat in _SYSTEM_REPORT_PREFIXES:
+ if head.startswith(pat):
+ return False
+ # 其余系统注入模式:按全文子串判
for pat in _FORBIDDEN_PATTERNS:
if pat in content:
return False
@@ -162,6 +224,77 @@ def _combine_turn(user: str, assistant: str) -> str:
return "\n".join(parts)
+# ── Auto feedback (P0, 2026-10-11) ───────────────────────────────────────────
+# 每轮结束自动把"回复里用到的注入记忆"标 useful。宁可少标不可错标。
+INJECTED_TTL = 600 # 注入记忆参与匹配的时间窗(秒)
+AUTO_FEEDBACK_LOG = HERMES_HOME / "logs" / "zhiyi-auto-feedback.jsonl"
+AUTO_FEEDBACK_OFF = MEMORY_CACHE / "auto_feedback.off" # 哨兵文件:存在即关闭(热回滚,无需重启)
+
+
+def _auto_feedback_enabled() -> bool:
+ """开关:环境变量 ZHIYI_AUTO_FEEDBACK=0 或哨兵文件存在 → 关闭。默认开。"""
+ if os.environ.get("ZHIYI_AUTO_FEEDBACK", "1").strip() in ("0", "false", "False", "no"):
+ return False
+ return not AUTO_FEEDBACK_OFF.exists()
+
+
+# 加载标记:模块级只打一次。用途 = 重启后判断**长驻进程**是否真的载入了本版代码
+# (插件走 sys.modules 缓存、不热重载;"文件改了" ≠ "网关在用新代码")。
+try:
+ logger.info("[ZhiYi] auto-feedback P0 %s (build 2026-10-11)",
+ "ENABLED" if _auto_feedback_enabled() else "DISABLED(sentinel/env)")
+except Exception: # 日志失败不得影响插件装载
+ pass
+
+
+_ASCII_TOKEN_RE = re.compile(r"[A-Za-z][A-Za-z0-9_.\-/]{2,}")
+_CJK_RUN_RE = re.compile(r"[\u4e00-\u9fff]{2,}") # 2026-10-15: 4-gram→2-gram(4-gram 要求连续4个相同汉字,中文自然复述几乎不命中 → cjk_shared 恒 0 → 纯中文永不触发)
+_TOKEN_STOP = frozenset({
+ "the", "and", "for", "with", "this", "that", "from", "http", "https",
+ "true", "false", "none", "null", "self", "def", "return", "import",
+})
+# 中文高频通用双字词 → 从特征中剔除(防"系统/记忆/股票/访问"这类通用词把分数虚推上去)。
+# A/B 实证(t_2be29a01):加上停用词后,真正无关的回复 c=0~2 永不标记,相关复述 c≥3 才标。
+_CJK_STOP_BIGRAMS = frozenset({
+ "系统", "记忆", "任务", "这个", "那个", "一个", "什么", "怎么", "可以", "需要", "进行",
+ "没有", "我们", "他们", "用户", "助手", "规则", "因为", "所以", "但是", "如果", "就是",
+ "不是", "已经", "还有", "所有", "通过", "根据", "对于", "关于", "以及", "应该", "必须",
+ "不要", "不会", "时候", "之前", "之后", "工作", "笔记", "里面", "上面", "下面", "使用",
+ "能够", "是否", "为什么", "这样", "那样", "问题", "方法", "方式", "步骤", "结果", "情况",
+ "自己", "其他", "信息", "内容", "数据", "文件", "目录", "路径", "命令", "输出", "成功",
+ "失败", "确认", "错误", "分析", "判断", "实现", "支持", "提供", "存在", "相关", "重要",
+ "主要", "目前", "当前", "这里", "那里", "了解", "知道", "股票", "行情", "接口", "请求",
+ "返回", "获取", "调用", "处理", "看到", "网络", "访问", "控制", "验证", "检查", "排查",
+ "排序", "衰减", "权重", "织忆", "召回", "索引", "文档", "报告", "格式", "表格", "优化",
+ "自动", "手动", "设置", "配置", "修改", "新增", "删除", "创建", "建立", "维护",
+})
+
+
+def _signature_tokens(text: str):
+ """提取"特征 token":ASCII 标识符(文件名/id/模型名,高精度)+ 中文 2-gram(中精度,已滤停用词)。
+ 返回 (ascii_set, cjk_set)。"""
+ ascii_toks = {m.group(0).lower() for m in _ASCII_TOKEN_RE.finditer(text or "")}
+ ascii_toks -= _TOKEN_STOP
+ cjk_toks = set()
+ for m in _CJK_RUN_RE.finditer(text or ""):
+ run = m.group(0)
+ for i in range(len(run) - 1):
+ bg = run[i:i + 2]
+ if bg not in _CJK_STOP_BIGRAMS:
+ cjk_toks.add(bg)
+ return ascii_toks, cjk_toks
+
+
+def _usage_score(memory_content: str, reply: str):
+ """回复对某条注入记忆的命中分:3*共享ASCII标识符 + 1*共享中文2-gram(已滤停用词)。
+ 返回 (score, ascii_shared, cjk_shared)。"""
+ a_mem, c_mem = _signature_tokens(memory_content)
+ a_rep, c_rep = _signature_tokens(reply)
+ a_shared = len(a_mem & a_rep)
+ c_shared = len(c_mem & c_rep)
+ return 3 * a_shared + c_shared, a_shared, c_shared
+
+
# ── HTTP Client ─────────────────────────────────────────────────────────────
@@ -232,6 +365,42 @@ class ZhiYiClient:
logger.error(msg)
return None
+ def write_memory(self, content: str, category: str = "distilled",
+ agent_id: str = "hermes-a06",
+ namespace: Optional[str] = None,
+ confidence: Optional[float] = None) -> tuple:
+ """显式直写 memories 表(t_fa6c9104 主症A 修复):同步嵌入 + 去重/合并 + 矛盾只报告。
+
+ 与 commit 的区别:commit 落 episodes 表等异步 LLM 蒸馏(质量门丢弃 ~68%)⇒
+ 写进去的东西自己召回不回来;本方法落 memories 表 ⇒ 写完立刻可召回。
+
+ 返回 (True, data) 成功(data 含 memory_id / status / table / conflicts?);
+ (False, reason) 失败:reason ∈ {"no_endpoint", "http_", "<异常>"}。
+ 服务端未部署新端点(404)⇒ "no_endpoint",调用方回落 commit 旧路径。
+ """
+ payload: Dict[str, Any] = {
+ "content": content,
+ "category": category,
+ "agent_id": agent_id,
+ }
+ if namespace:
+ payload["namespace"] = namespace
+ if confidence is not None:
+ payload["confidence"] = confidence
+ try:
+ r = self._session.post(self._url("/api/v1/memory/write"), json=payload, timeout=self.timeout)
+ if r.status_code in (200, 201):
+ data = r.json()
+ self._last_conflicts = data.get("conflicts") or []
+ return True, data
+ if r.status_code == 404:
+ return False, "no_endpoint"
+ logger.error("ZhiYi write_memory failed: %s %s", r.status_code, r.text[:200])
+ return False, f"http_{r.status_code}"
+ except Exception as e:
+ logger.error("ZhiYi write_memory error: %s", e)
+ return False, str(e)
+
def recall(self, query: str, top_k: int = 5,
agent_id: Optional[str] = None,
namespace: Optional[str] = None) -> List[Dict[str, Any]]:
@@ -242,7 +411,14 @@ class ZhiYiClient:
而 worker 写入 a06-main 的记忆完全搜不到(namespace 隔离 bug)。
"""
try:
- payload: Dict[str, Any] = {"query": query, "top_k": top_k, "use_rerank": True}
+ # [t_a9848f01] 召回侧元笔记抑制:仅 ZHIYI_META_FILTER=1 时启用(默认零行为变化)。
+ # 启用时用更大的候选窗口取回,再丢掉"关于召回系统自身"的元笔记,让被挤到顶部的
+ # 真知识补位;不删不改存量(AC5),只影响本次呈现。
+ _meta_pred = _meta_note_pred()
+ _window = top_k
+ if _meta_pred:
+ _window = max(top_k, 20) # 候选窗口拉大到 20,抑制后补位
+ payload: Dict[str, Any] = {"query": query, "top_k": _window, "use_rerank": True}
if namespace is not None:
payload["namespace"] = namespace
elif agent_id is not None:
@@ -251,7 +427,15 @@ class ZhiYiClient:
r = self._session.post(self._url("/api/v1/recall"), json=payload, timeout=self.timeout)
if r.status_code == 200:
data = r.json()
- return data.get("results", [])
+ results = data.get("results", [])
+ if _meta_pred:
+ kept = [x for x in results if not _meta_pred(x.get("content", ""))]
+ if len(kept) < len(results):
+ logger.info(
+ "[meta-note-filter] recall抑制 %d/%d 条元笔记 query=%r(ZHIYI_META_FILTER=1)",
+ len(results) - len(kept), len(results), query[:40])
+ results = kept[:top_k]
+ return results
logger.warning("ZhiYi recall failed: %s %s", r.status_code, r.text[:200])
return [{"content": f"⚠️ 织忆检索失败(HTTP {r.status_code}):这不是\"没有记忆\",是服务异常——请勿据此判断记忆为空", "score": 0.0, "category": "_error"}]
except Exception as e:
@@ -362,6 +546,8 @@ class HermesZhiYiMemoryProvider(MemoryProvider):
self._started: bool = False
self._ws_thread: Optional[threading.Thread] = None
self._ws_running: bool = False
+ self._injected: Dict[str, Dict[str, Any]] = {} # memory_id -> {content, ts, session_id}
+ self._feedbacked: List[str] = [] # 已反馈过的 memory_id(防重复刷)
@property
def name(self) -> str:
@@ -483,7 +669,13 @@ class HermesZhiYiMemoryProvider(MemoryProvider):
if not self._started or not self._client:
return
+ self._auto_feedback_for_reply(assistant_content)
+
combined = _combine_turn(user_content, assistant_content)
+ # 系统报告以 user 角色进入:先在原始 user 文本上判头(combined 已加 "用户: " 前缀,
+ # 头判会失效)。同源防线与 scripts/session-to-zhiyi.py 一致(P3 实测点)。
+ if _is_system_report_head(user_content):
+ return
if not _is_valid_memory_content(combined):
return
@@ -535,8 +727,12 @@ class HermesZhiYiMemoryProvider(MemoryProvider):
if _is_social_close(query):
return ""
- # 噪音闸门(t_689b6d38):系统通知/提示样板不进语义召回、不返回笔记。
- if _is_system_template_input(query):
+ # 噪音闸门(t_689b6d38):系统通知/提示样板("[IMPORTANT: ..." / "[System note: ..."
+ # / cron 投递包裹等)不进语义召回、不返回笔记 —— 纯浪费 + 稀释有效注入信噪比。
+ # 与 sync_turn 写路径同判据:头子串(_is_system_report_head)+ 全文子串(_FORBIDDEN_PATTERNS)。
+ if _is_system_report_head(query):
+ return ""
+ if any(pat in query for pat in _FORBIDDEN_PATTERNS):
return ""
# 优先从 queue_prefetch 缓存取(TTL < 30s)
@@ -583,6 +779,16 @@ class HermesZhiYiMemoryProvider(MemoryProvider):
if content:
if mid:
blocks.append(f" [id={mid}][{score:.2f}][{cat}]{prov} {content[:500]}")
+ # ── P0: 记录本轮注入的 memory_id(供 sync_turn 自动反馈)──
+ with self._prefetch_lock:
+ self._injected[mid] = {
+ "content": content,
+ "ts": time.time(),
+ "session_id": session_id or self._session_id,
+ }
+ _cutoff = time.time() - INJECTED_TTL
+ for _k in [k for k, v in self._injected.items() if (v or {}).get("ts", 0) < _cutoff]:
+ self._injected.pop(_k, None)
else:
blocks.append(f" [{score:.2f}][{cat}]{prov} {content[:500]}")
@@ -633,6 +839,82 @@ class HermesZhiYiMemoryProvider(MemoryProvider):
return text
+ def _auto_feedback_for_reply(self, reply: str) -> None:
+ """P0:把"回复里确实用到的注入记忆"自动标 useful。
+
+ 判据:特征 token 命中分 >= AUTO_FEEDBACK_MIN_SCORE(见方法内注释)。
+ 只标 useful,**从不标 not_useful**(宁可少标不可错标)。
+ 全流程 try/except 兜底,绝不打断 sync_turn。
+ """
+ if not _auto_feedback_enabled() or not reply or len(reply.strip()) < 20:
+ return
+ if not self._injected or not self._client:
+ return
+
+ _MIN_SCORE = 6 # ASCII 路径阈值(保持原样):有 ≥2 个共享 ASCII 标识符时用(高精度)
+ _CJK_MIN = 3 # 纯中文/低 ASCII 路径阈值:共享特定中文 2-gram ≥3 才标(A/B 定:真无关 c≤2,相关复述 c≥3)
+ _MAX_PER_TURN = 3 # 单轮最多标 3 条,防跑飞
+ now = time.time()
+ with self._prefetch_lock:
+ cands = [(mid, dict(info)) for mid, info in self._injected.items()
+ if info and (now - info.get("ts", 0)) < INJECTED_TTL]
+
+ hits = []
+ for mid, info in cands:
+ if mid in self._feedbacked:
+ continue
+ score, a_shared, c_shared = _usage_score(info.get("content", ""), reply)
+ # 自适应阈值:有足够 ASCII 标识符 → 用原 6 分;纯中文/低 ASCII → 改用共享特定 2-gram ≥3。
+ # 宁可少标不可错标:两条路都不放水,只是让中文内容有资格被标记。
+ if a_shared >= 2:
+ passes = score >= _MIN_SCORE
+ else:
+ passes = c_shared >= _CJK_MIN
+ hits.append((score, passes, a_shared, c_shared, mid, info))
+ if not hits:
+ return
+ hits.sort(key=lambda x: -x[0])
+ selected = [h for h in hits if h[1]][:_MAX_PER_TURN]
+ if not selected:
+ return
+
+ def _worker():
+ for score, _passes, a_shared, c_shared, mid, info in selected:
+ try:
+ ok = self._client.mark_useful(mid)
+ except Exception as e:
+ logger.debug("[ZhiYi] auto-feedback error for %s: %s", mid, e)
+ ok = False
+ if ok:
+ self._feedbacked.append(mid)
+ if len(self._feedbacked) > 500:
+ del self._feedbacked[:200]
+ self._log_auto_feedback(mid, score, a_shared, c_shared, bool(ok), info)
+
+ threading.Thread(target=_worker, daemon=True, name="zhiyi-auto-fb").start()
+
+ def _log_auto_feedback(self, mid: str, score: int, a_shared: int, c_shared: int,
+ ok: bool, info: Dict[str, Any]) -> None:
+ """落审计日志(jsonl,供 7 天后核对精度/重标阈值)。失败静默。"""
+ try:
+ AUTO_FEEDBACK_LOG.parent.mkdir(parents=True, exist_ok=True)
+ rec = {
+ "ts": time.time(),
+ "memory_id": mid,
+ "score": score,
+ "ascii_shared": a_shared,
+ "cjk_shared": c_shared,
+ "marked": ok,
+ "session_id": info.get("session_id", ""),
+ "mem_head": (info.get("content", "") or "")[:80],
+ }
+ with open(AUTO_FEEDBACK_LOG, "a", encoding="utf-8") as f:
+ f.write(json.dumps(rec, ensure_ascii=False) + "\n")
+ logger.info("[ZhiYi] auto-feedback: %s score=%d (ascii=%d cjk=%d) marked=%s",
+ mid, score, a_shared, c_shared, ok)
+ except Exception:
+ logger.debug("[ZhiYi] auto-feedback log failed", exc_info=True)
+
def queue_prefetch(self, query: str, *, session_id: str = "") -> None:
"""异步预取:本轮对话结束后立即查询织忆,下一轮 prefetch 直接返回缓存。"""
if not self._client or not query or len(query.strip()) < 2:
@@ -640,7 +922,9 @@ class HermesZhiYiMemoryProvider(MemoryProvider):
if _is_social_close(query):
return
# 噪音闸门(t_689b6d38):系统通知/提示样板不进语义召回、不返回笔记(同 prefetch)。
- if _is_system_template_input(query):
+ if _is_system_report_head(query):
+ return
+ if any(pat in query for pat in _FORBIDDEN_PATTERNS):
return
# 后台线程查询并缓存
@@ -879,23 +1163,48 @@ class HermesZhiYiMemoryProvider(MemoryProvider):
return json.dumps({"success": False, "error": "ZhiYi client not initialized"})
if not _is_valid_memory_content(content):
return json.dumps({"success": False, "error": "Content too short or invalid"})
- commit_id = self._client.commit(
- content=content, category=category,
- agent_id=os.environ.get("ZHIYI_AGENT_ID", "hermes-a06"),
- )
- if commit_id:
- conflicts = getattr(self._client, "_last_conflicts", [])
- result = {"success": True, "commit_id": commit_id}
+ agent_id = os.environ.get("ZHIYI_AGENT_ID", "hermes-a06")
+ # 主症A(t_fa6c9104):显式保存走新端点 /api/v1/memory/write ⇒ 直落 memories 表
+ # (嵌入 + 立刻可召回),不再依赖 68% 丢弃率的异步蒸馏流水线。
+ ok, data = self._client.write_memory(content=content, category=category, agent_id=agent_id)
+ if ok:
+ result = {
+ "success": True,
+ "commit_id": data.get("memory_id", ""), # 兼容旧字段名
+ "memory_id": data.get("memory_id", ""),
+ "table": data.get("table", "memories"),
+ "direct_write": True,
+ }
+ if data.get("status") == "merged":
+ result["merged"] = True
+ result["reason"] = data.get("reason", "")
+ conflicts = data.get("conflicts") or []
if conflicts:
- # 2026-09-04 记忆治理:检出冲突 → 提示写方处理(不静默)
+ # 矛盾只报告不拦(显式写入是调用方意图,新旧并存由 feedback 收口)
result["conflicts"] = conflicts
result["warning"] = (
- f"⚠️ 检出 {len(conflicts)} 条潜在冲突记忆,已跳过自动蒸馏"
- "(distill_skipped=conflict)。若这是修正→更新旧记忆;"
- "若写错→用 memory_feedback 标记 not_useful。"
+ f"⚠️ 检出 {len(conflicts)} 条矛盾记忆,显式写入已保留两者"
+ "(conflict_reported)。若这是修正→用 memory_feedback 降权旧记忆。"
)
return json.dumps(result, ensure_ascii=False)
- return json.dumps({"success": False, "error": "Commit failed — check ZhiYi server logs"})
+ if data == "no_endpoint":
+ # 服务端未部署新端点(404)⇒ 回落旧 commit 路径(现状行为,等蒸馏流水线)
+ commit_id = self._client.commit(
+ content=content, category=category, agent_id=agent_id,
+ )
+ if commit_id:
+ conflicts = getattr(self._client, "_last_conflicts", [])
+ result = {
+ "success": True,
+ "commit_id": commit_id,
+ "direct_write": False,
+ "warning": "服务端无 /api/v1/memory/write(未部署新二进制):已回落 commit 旧路径 —— 落 episodes 表等蒸馏,可能延迟或丢失。",
+ }
+ if conflicts:
+ result["conflicts"] = conflicts
+ return json.dumps(result, ensure_ascii=False)
+ return json.dumps({"success": False, "error": "Commit failed (fallback path) — check ZhiYi server logs"})
+ return json.dumps({"success": False, "error": f"memory_write failed ({data}) — check ZhiYi server logs"})
def _tool_memory_stats(self) -> str:
if not self._client: