Files
memoryweave/go/internal/api/server.go
T

2368 lines
96 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// HTTP 服务器 — 多 Agent 架构(FileGraph + NetworkEventBus + 跨Agent缓存失效)
package api
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"time"
"github.com/xiaoxue/memoryweave/internal/api/middleware"
"github.com/xiaoxue/memoryweave/internal/api/routes"
"github.com/xiaoxue/memoryweave/internal/distill"
"github.com/xiaoxue/memoryweave/internal/governance"
"github.com/xiaoxue/memoryweave/internal/memprotect"
"github.com/xiaoxue/memoryweave/internal/metrics"
"github.com/xiaoxue/memoryweave/internal/models"
"github.com/xiaoxue/memoryweave/internal/pattern"
"github.com/xiaoxue/memoryweave/internal/peer"
"github.com/xiaoxue/memoryweave/internal/redact"
"github.com/xiaoxue/memoryweave/internal/selfoptimize"
"github.com/xiaoxue/memoryweave/internal/skillgen"
"github.com/xiaoxue/memoryweave/internal/storage"
)
// cachedGraphStoreRef 全局图谱缓存引用(用于 graph write 操作时主动失效)
var cachedGraphStoreRef *storage.GraphCacheRef
// skillByNameHandler GET/DELETE /api/v1/skills/{name} 的 method 分支分发器
func skillByNameHandler(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
routes.Skills.Get(w, r)
case http.MethodDelete:
routes.Skills.Delete(w, r)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func NewServer() http.Handler {
mux := http.NewServeMux()
// ─── 初始化依赖 ──────────────────────────────
emb := storage.NewEmbedder(os.Getenv("VLLM_ENDPOINT"))
rerank := storage.NewReranker(os.Getenv("RERANK_ENDPOINT"), emb)
// 存储后端
ldb := initStorageBackend(os.Getenv("STORAGE_BACKEND"), emb)
// 2026-10-13 (t_e9a4d18f P2) 常设答案库(独立 SQLite,Hindsight mental models)。
// 只读写不主动注入 ⇒ 部署后行为零变化。注入由 zhiyi-standing-answers.py 生成后
// 插件侧决定是否直读。路径可经 ZHIYI_STANDING_DB 覆盖。
storage.InitStandingStore(os.Getenv("ZHIYI_STANDING_DB"))
// 召回元数据「累积延迟更新」缓冲(2026-09-12 优化B)
// 读路径不再逐条写 LanceDB(MVCC 每写一行一个版本 → _versions 膨胀根因),
// 改为窗口内合并 + 单事务批量提交(每批 1 个版本)。
storage.InitRecallWriteBuffer(ldb)
// 2026-10-14 (t_2afae95e) FTS 词面通道:给 TF-IDF 索引挂全量数据源(ScanAll,
// 只读 IPC lancedb_scan_all;不建 LanceDB tantivy 索引文件 ⇒ 零写 LanceDB)。
// 索引本体在 storage 包内后台建(启动 3s 首建 + 6h 周期 + 写路径标脏)。
// 门控 ZHIYI_RECALL_FTS 默认关 ⇒ 接线了也不改召回行为(AC4 零变化)。
if rc, ok := ldb.(*storage.RustLanceDBClient); ok {
storage.FtsProvider.FtsLdb = rc.ScanAll
log.Printf("[fts] 词面通道数据源已挂(ScanAll);门控 ZHIYI_RECALL_FTS=%s(默认关)",
os.Getenv("ZHIYI_RECALL_FTS"))
} else {
log.Printf("[fts] 后端非 RustLanceDBClient(%T)⇒ 词面通道数据源未挂(关态无影响)", ldb)
}
// 软删除累积延迟更新缓冲(2026-09-12 优化C)
// 软删除操作不再逐条写 LanceDB(每写一行一个版本 → _versions 膨胀),
// 改为窗口内合并 + 单事务批量提交(每批 1 个版本)。
storage.InitSoftDeleteWriteBuffer(ldb)
// 启动时数据目录一致性检查(防止路径混乱导致读取废弃数据)
runStartupChecks(os.Getenv("STORAGE_BACKEND"))
// G7.2: 注册 LDB getter(供 skill crystallize 使用)
routes.RegisterLDBGetter(func() storage.LanceDB { return ldb })
// 冲突检测器(E4.1: Commit 时矛盾检测依赖此实例)
// 2026-09-20:改用带持久化的构造 —— 此前 active 是纯内存 map,zhiyid 重启即丢
// (实测 17:17 重启后 pending 冲突全部消失)。落盘失败不致命,降级为内存模式并记日志。
// 2026-10-18 (t_23b6a940):ZHIYI_CONFLICTS_DIR 覆盖落盘目录(副本/评估场景隔离
// telemetry 写);未设 → 生产默认路径(行为不变)
confDir := os.Getenv("ZHIYI_CONFLICTS_DIR")
if confDir == "" {
confDir = "/var/lib/memoryweave"
}
conflictDetector, cdErr := governance.NewPersistentConflictDetector(filepath.Join(confDir, "conflicts.json"), filepath.Join(confDir, "conflict-seen.json"))
if cdErr != nil {
log.Printf("[conflict] 持久化加载失败,降级为内存模式: %v", cdErr)
}
// 注册全局实例,供 selfoptimize 触发器执行器读取(TConflict 判据)
governance.SetGlobalConflictDetector(conflictDetector)
// 图谱(带 Navigate 结果缓存:TTL 5min,容量 500,热点实体缓存命中加速)
graphStore, cachedGraphStoreRef := storage.NewCachedGraphStore(initGraphStore())
// 整句→实体 拆解索引(节点名词表,TTL 10min;供 /api/v1/graph/notes 兜底。
// 起因:调用方把整句提问当 entity 传,整句匹配不到节点 → 0 篇笔记)
entityIndex := routes.NewEntityIndex(func() map[string]float64 {
return graphStore.PageRank(0.85, 20)
})
entityIndex.Degree = func(name string) int { return graphStore.GetEntityDegree(name) }
entityIndex.Warm(45 * time.Second)
api := routes.NewAPI(ldb, emb, rerank, conflictDetector, graphStore)
// G1: Recall 管线挂图谱扩展
api.Pipeline.SetGraphExpander(graphStore)
// 启动时初始化 Prometheus 指标
// total_episodes 来自 Rust count_rows(真实值);旧 sidecar 回退 episodes_cached(非总量)
go func() {
time.Sleep(1 * time.Second) // 等待 Rust sidecar 就绪
if stats, err := ldb.Stats(); err == nil {
if total, ok := stats["total_memories"].(float64); ok {
metrics.TotalMemories.Set(total)
}
if total, ok := stats["total_episodes"].(float64); ok {
metrics.TotalEpisodes.Set(total)
} else if cached, ok := stats["episodes_cached"].(float64); ok {
metrics.TotalEpisodes.Set(cached) // 旧 sidecar 回退,非真实总量
}
log.Printf("[metrics] 初始化指标: memories=%.0f", stats["total_memories"])
}
}()
// t_cf680fad:启动即 drain durable pending-softdelete 队列。
// 上一轮(尤其重启窗口 / 崩掉那轮)因 MVCC 写风暴耗尽而被入队的失败软删,
// 此刻风暴已退、sidecar 刚就绪,重放必落盘 —— 这是「不修则每次重启窗口都留新残留」的根治。
go func() {
time.Sleep(2 * time.Second) // 等 sidecar 完全就绪(比 metrics 的 1s 稍晚,避免抢首提交)
if n := storage.PendingSoftDeleteCount(); n > 0 {
log.Printf("[pending-softdelete] 启动发现 %d 条待重放,开始 drain", n)
storage.DrainPendingSoftDeletes(ldb)
}
}()
// 图谱(带 Navigate 结果缓存:TTL 5min,容量 500,热点实体缓存命中加速)
// 已在上面初始化
graphUpdater := governance.NewAutoGraphUpdater(graphStore)
graphAPI := routes.NewGraphAPI(graphStore)
// G4: 自动蒸馏 → 注入图谱更新器
routes.SetGraphUpdater(graphUpdater)
// 冲突
conflictAPI := routes.NewConflictAPI(conflictDetector)
// ─── 缺口
gapDetector := selfoptimize.NewGapDetector(emb, ldb)
gapDetector.EnableGapRedisPersistence()
selfoptimize.SetGlobalGapDetector(gapDetector) // 注册全局实例供 recall 回调使用
// 因果追踪持久化
routes.CascadeR.Tracker().EnableCausalRedisPersistence()
gapAPI := routes.NewGapAPI(gapDetector)
routes.InitGapRepair(gapDetector, ldb) // 共享同一个 GapDetector + LanceDB
// G1a: 挂缺隙记录器(0 结果 → 自动记录 miss)
api.Pipeline.SetMissRecorder(func(query, namespace string) {
gapDetector.RecordMiss(query + " [" + namespace + "]")
})
feedbackAPI := routes.NewFeedbackAPI(ldb)
// 遗忘器:支持按 Agent 类型设置衰减率
forgetter := governance.NewForgetter()
adminAPI := routes.NewAdminAPI(ldb, forgetter, graphStore)
agentRegistry := routes.NewAgentRegistry(nil)
evalAPI := routes.NewEvalAPI(api.Pipeline, ldb)
l3API := routes.WM
consolPipe := routes.NewConsolidationPipeline(ldb, graphStore, conflictDetector)
consolPipe.SetDataDir("/var/lib/memoryweave/lancedb", "/var/lib/memoryweave/graph.db")
// ─── t_d32e5f8a L2 模式检测(MemOS 血统)─────────────────────────
// 复活 L1→L2 pattern 蒸馏(原 Python daemon main_loop 退役后停摆):
// pattern 节点存 graph_nodes(type='pattern'),与旧 daemon 残留的 85 行
// type='pattern_stat' 同表但不同类,不另开新表。
// modernc sqlite 短命单发开/关(UpsertPatterns/ListPatterns 开一次用一次即关),
// 不持长连接、不碰 state.db —— 规避当年退役根因。
// 召回注入 env 门控 ZHIYI_PATTERN_RECALL,默认 0 ⇒ 不注册 Provider,行为零变化。
graphPath := os.Getenv("GRAPH_PATH")
if graphPath == "" {
graphPath = "/var/lib/memoryweave/graph.db"
}
patternStore := pattern.NewGraphPatternStore(graphPath)
patternProvider := pattern.NewPatternProvider(patternStore, pattern.NewEmbedderProvider(emb))
patternAPI := routes.NewPatternAPI(patternStore, patternProvider, func() storage.LanceDB { return ldb })
// L3 skill 草稿(t_fcadee04):装配生成器 + skills 表(graph.db 同库)+ 落盘根。
// 显式 API(draft/promote)触发,不挂 cron、不改注入行为 ⇒ 默认零行为变化。
patternAPI.SkillStore = skillgen.NewStore(graphPath)
patternAPI.Generator = skillgen.NewLocal2BGenerator()
patternAPI.AttachmentsDir = os.Getenv("ZHIYI_SKILL_ATTACHMENTS")
// t_4594b632:promote 前评审摘要(SkillEvaluator)。本地 2B 重写理由、确定性四维分兜底;
// 默认装配(评审信息参考,非 promote 依据)。2B 失败自动回退确定性理由,不阻塞 promote。
patternAPI.Evaluator = skillgen.NewLLMEvaluator()
if os.Getenv("ZHIYI_PATTERN_RECALL") == "1" {
storage.PatternRecallProvider = patternProvider.Match
log.Printf("[zhiyid] L2 模式召回注入已启用(ZHIYI_PATTERN_RECALL=1)")
} else {
log.Printf("[zhiyid] L2 模式召回注入默认关(ZHIYI_PATTERN_RECALL 未设/≠1 ⇒ 零行为变化)")
}
// ─── t_f8fc97e6 P3 Peer Representation(Honcho 血统,设计 §5)────────────
// 四层推理画像(explicit→deductive→inductive→abductive)存 graph.db 的 peers 表。
// refresh/explain 是显式 API(不挂 cron);召回注入 env 门控 ZHIYI_PEER_RECALL 默认 0
// ⇒ 主召回管线默认零行为变化(AC2)。推理引擎生产复用本地 2B(8081 免费腿)。
peerStore := peer.NewStore(graphPath)
peerAPI := routes.NewPeerAPI(peerStore, peer.NewLocal2BReasoner(), func() storage.LanceDB { return ldb }, graphStore)
if os.Getenv("ZHIYI_PEER_RECALL") == "1" {
storage.PeerRecallProvider = peerAPI.GetRecallResults
log.Printf("[zhiyid] P3 Peer 推理画像召回注入已启用(ZHIYI_PEER_RECALL=1)")
} else {
log.Printf("[zhiyid] P3 Peer 推理画像召回注入默认关(ZHIYI_PEER_RECALL 未设/≠1 ⇒ 零行为变化)")
}
// Obsidian
obsidian := routes.NewObsidianSyncer("", ldb)
// ─── 蒸馏引擎 ───────────────────────────────
llmEndpoint := os.Getenv("LLM_ENDPOINT")
llmModel := os.Getenv("LLM_MODEL")
if llmModel == "" {
llmModel = "deepseek/deepseek-v4-pro"
}
llmAPIKey := os.Getenv("LLM_API_KEY")
if llmAPIKey == "" {
llmAPIKey = os.Getenv("API_KEY")
}
distillEngine := distill.NewEngine(llmEndpoint, llmModel, llmAPIKey)
routes.DistillEngineRef = distillEngine
// t_e11207f8 治源①:加载共享「空壳正文」判据(zhiyi_payload_guard_rules.json,Python/Go 同源)。
// 失败 ⇒ fail-open(distill 照跑、不拦空壳、不崩),/api/v1/payload-guard/stats 暴露 loaded=false。
if ok, lerr := distill.LoadPayloadGuard(""); ok {
_, _, _, rp := distill.PayloadGuardStats()
log.Printf("[distill][payload-guard] 空壳判据已加载 rules=%s", rp)
} else {
log.Printf("[distill][payload-guard] ⚠️ 共享判据未加载(fail-open,不拦空壳): %v", lerr)
}
// 蒸馏完成 → 自动图谱更新 + 冲突检测 + 被动验证 + 回写内存
distill.OnDistillComplete = func(input distill.DistillInput, result distill.DistillResult) {
entityNames := make([]string, len(result.Entities))
for i, e := range result.Entities {
entityNames[i] = e.Name
}
graphUpdater.UpdateFromDistill(&governance.DistillInput{
Content: input.Content, Facts: result.Facts,
Entities: entityNames, Namespace: input.Namespace,
EpisodeID: input.EpisodeID,
Source: input.Source, // 2026-10-12 t_67d0279a 方案A:来源分档透传(auditSource 前缀归一后比对)
})
// 回写蒸馏产物到记忆库(LanceDB)
// 2026-09-07 方案B 质量门槛: 碎片(<20字)/噪声句(状态汇报/桥接/reflection)不入库,
// 与已有记忆完全重复跳过(蒸馏直写不走 commit exact_dup, 需本层查重)。
// 2026-10-13 t_1c12e000:distilled facts 是 memories 表的唯一写入点。
// 只要有任何一条真的 InsertMemory 成功 ⇒ 该 namespace 的 query 缓存作废
//("先写再问" 场景:写完之后立刻可被之前问过但当时还没内容的答案命中)。
factsLanded := false
// 2026-10-13 t_286d1310: 写库闸门同样感知准入分诊结论(lane)。
// 此前这里独立跑 IsQualityFact 长度启发式——lane=keep 的决策类内容,
// 其 <20 字碎片在 insert 前仍被第三次独立丢弃(t_2ae69bb2 只穿透到
// fact 级,没穿透到写库级)⇒ DB 里 derived_from=<episode> 0 行。
// 复用 engine.go 同一函数 FilterQualityFactsWithLane(判据单一入口,
// 不复制):
// LaneKeep → 只保留 纯疑问/状态汇报 两条硬否定,跳过全部长度类规则
// LaneDrop / LaneObserve → 委托 FilterQualityFacts,与 IsQualityFact 逐字一致
// ClassifyAdmission 是确定性分诊(同一输入永远同一 lane),此处重算即
// engine.go:537 在蒸馏时算出的同一条 lane。
lane, laneWhy := distill.ClassifyAdmission(input.Content)
writeFacts := distill.FilterQualityFactsWithLane(result.Facts, lane)
if before := len(result.Facts); before > 0 && len(writeFacts) != before {
log.Printf("[distill][LANE] write-gate dropped %d/%d facts for %s (lane=%s, %s)",
before-len(writeFacts), before, input.EpisodeID, lane, laneWhy)
}
for _, fact := range writeFacts {
// 2026-09-10 P0:直写路径同样脱敏(FilterQualityFactsWithLane 已内部脱敏,
// 这里再脱一次为幂等保险——防止任何中间层注入明文密钥。事故里泄漏进
// 镜像的记忆正是从这条路径入库的)。
fact = redact.RedactSecrets(fact)
// t_e11207f8 治源①:空壳拒写(单一判据 = 共享 zhiyi_payload_guard_rules.json,与 Python 同源)。
// 蒸馏 LLM 偶发把「只有溯源指针/占位/纯日期、无知识载荷」的碎片当 fact(如 `证据:文件:…|日期:…`),
// 长度/实体启发式(IsQualityFact)抓不住 ⇒ 落库前用 payload-guard 拦下(未加载时 fail-open)。
if distill.ShellCheck(fact) {
log.Printf("[distill][payload-guard] 拒写空壳蒸馏 fact episode=%s fact=%.40s", input.EpisodeID, fact)
continue
}
// 查重: 同 namespace 已存在**语义等价**记忆 → 跳过
// 必须用 cosine 近重复判据(≥0.98),不能只比字符串相等——
// 否则 "牧尘在招商局工作" 与 "牧尘在招商局工作。"(多一个句号)
// 会被判为两条不同事实双双入库,造成近重复堆积。
var skip bool
var qvec []float32
var sim []models.MemoryRecord
if v, eErr := emb.EncodeSingle(fact); eErr == nil {
qvec = v
if found, _ := ldb.Search("memories", v, 5, input.Namespace); len(found) > 0 {
sim = found
for _, ex := range found {
if ex.Content == fact || cosineSimilarity(v, ex.Vector) >= 0.98 {
skip = true
break
}
}
}
}
if skip {
continue
}
// 2026-10-14 (t_3e98b65a):写时四操作判定(影子默认 ⇒ 零行为变化)。
// 蒸馏产出的每条候选事实,对照 top-5 相似现有记忆判 ADD/UPDATE/NOOP/CONFLICT:
// shadow:只记影子日志(本文件 write_ops.go),不真改库;apply:按判定执行。
// 复用上面已算出的 qvec/sim(不重复编码),hash 去重兜底在 JudgeWriteOp 前。
// t_a0831457 P0 解耦:门控**只决定标记动作**(supersede/conflict);基础
// InsertMemory 是 pre-feature 基线行为,必须无条件执行(shadow/off 下
// ApplyWriteOp 恒返回 shouldInsert=true ⇒ 下方不再 continue ⇒ 基础插入照跑)。
// 唯一丢弃路径 = apply + NOOP(显式记 warning,含候选内容,禁静默)。
// AC-1.2(t_e3f5cb79):真矛盾(CONFLICT-MARK 过判据门)时新行插入必须
// 带 supersedes=[旧id],与旧行 superseded_by=[新id] 构成双向指针。
var conflictSupersedes []string
if distill.WriteOpsEnabled() {
if dupID, dup := distill.WriteOpHashDedup(fact, sim); dup {
log.Printf("%s", distill.DedupNote(dupID, fact))
continue
}
sims := distill.FromMemoryRecords(sim, qvec)
verdict := distill.JudgeWriteOp(fact, sims.TopN(5), nil, nil)
if verdict != nil {
shouldInsert, err := distill.ApplyWriteOp(*verdict, &ldbWriteOpsStore{ldb: ldb}, memIDFor(fact, input))
if err != nil {
// apply 标记失败(MarkSuperseded/MarkConflict 落库失败):
// 丢弃本条(不做半截标记),显式记 warning(禁静默失败)。
log.Printf("[write-ops] apply 标记失败,本条事实丢弃: %v candidate=%.40s", err, fact)
continue
}
if !shouldInsert {
// 唯一丢弃路径:apply + NOOP(影子/off 不会到这里,见上)
log.Printf("[write-ops] apply NOOP 丢弃事实(判定=NOOP,候选=%.40s)", fact)
continue
}
// AC-1.2(t_e3f5cb79):过门的真矛盾 → 新行带 supersedes=[旧id]
if verdict.Op == distill.OpConflictMark && verdict.TargetID != "" {
conflictSupersedes = []string{verdict.TargetID}
}
}
// verdict==nil(off 未跑判定;理论死分支——off 不进 WriteOpsEnabled 块)
// 或 shadow(标记零调用)⇒ 基础插入无条件执行。
}
// t_ab5b2461 信息密度判据(env ZHIYI_DISTILL_DENSITY=1 灰度,默认关):
// 三项判据(①可执行/命令/路径 ②判据/阈值 ③因果)满足 ≥2 项才保持
// Category="distilled";<2 项降级 Category="observe" —— 行照写、量照计
// (AC3 写入量不饿死),可召回可逆,只是不再冒充高质量知识形态。
// env 关 ⇒ category 恒 "distilled",与现状逐位等价(AC5)。
category := "distilled"
if distill.DensityGateEnabled() && !distill.DensityKeep(fact) {
category = "observe"
log.Printf("[distill][density] 信息量不足(需≥2项判据) → Category=observe episode=%s src=%q fact=%.48s",
input.EpisodeID, input.Source, fact)
}
mem := models.MemoryRecord{
ID: fmt.Sprintf("mem_%d", time.Now().UnixNano()),
AgentID: input.AgentID,
Namespace: input.Namespace,
Content: fact,
Category: category,
Tier: "normal",
QualityScore: result.Overall,
Version: 1,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
DerivedFrom: input.EpisodeID,
// 2026-10-13 (t_e9a4d18f P1) 证据链:全量来源 episode ID(蒸馏直写当前单亲,
// 单条证据;多 episode 聚合由 consolidate 路径补充)
SourceEvents: []string{input.EpisodeID},
Freshness: "fresh",
// AC-1.2(t_e3f5cb79):真矛盾的新行带 supersedes=[旧id](双向指针的
// 新行半边;旧行半边在 ldbWriteOpsStore.MarkConflict 写 superseded_by)。
Supersedes: conflictSupersedes,
Source: srcOrLLMDistill(input.Source), // 2026-09-26:有笔记出处→"wiki:<路径>";否则沿用 llm_distill(2026-09-07 方案C provenance)
// 2026-10-08 (t_7e3577c5):血缘字段——写入方经 metadata 携带;缺省兜底
// embedding_model→bge-m3(实际嵌入模型),script_version→unknown。
EmbeddingModel: orDefault(input.EmbeddingModel, "bge-m3"),
ScriptVersion: orDefault(input.ScriptVersion, "unknown"),
// 2026-10-08 (Gap#2):信任等级透传到产物(nil 保持 NULL,不伪装 0.0)
Confidence: input.Confidence,
ConfidenceSource: input.ConfidenceSource,
// 2026-10-14 (t_3ada332b):bi-temporal 有效期 + provenance。写时识别时间敏感事实
// (含「这周/临时/截止/到 X 之前」等)自动填 valid_to(区间末端);来源归一 provenance
// (user|inferred|import)。开关默认关 ZHIYI_BITEMPORAL=0 ⇒ 读路径行为零变化。
ValidFrom: time.Now().Format(time.RFC3339),
ValidTo: distill.DetectValidTo(fact, time.Now()),
Provenance: distill.DetectProvenance(input.Source),
}
log.Printf("[srcprobe] 蒸馏落库 episode=%s input.Source=%q → mem.Source=%q", input.EpisodeID, input.Source, mem.Source)
if err := ldb.InsertMemory(mem); err != nil {
log.Printf("[distill] commit fact failed: %v", err)
} else {
factsLanded = true // t_1c12e000:确有 distilled fact 落库
}
}
// t_2595b626 经验层(ZHIYI_DISTILL_EXPERIENCE=1 灰度,默认关):EXP 产物落库。
// 🔴 不改排序权重(AC5)——只多写 category="experience" 的行,召回侧零改动。
// 反张冠李戴落库闸:task 引用的对象必须能在 episode 原文找到影子,否则不落库。
// 关态 result.Experiences 恒 nil ⇒ 本循环零次迭代,行为与现状逐字一致。
for _, exp := range result.Experiences {
if !distill.ExperienceGrounded(exp, input.Content) {
log.Printf("[distill][experience] 张冠李戴闸拦截(task 对象在原文无影子)episode=%s task=%.60s",
input.EpisodeID, exp.Task)
continue
}
// 近重复跳过(与 facts 同口径 cosine ≥0.98,复用现成 emb/ldb,不新造设施)
expContent := fmt.Sprintf("经验[%s] 解法:%s 边界:%s", exp.Task, exp.Solution, exp.Boundary)
expContent = redact.RedactSecrets(expContent)
var skip bool
if v, eErr := emb.EncodeSingle(expContent); eErr == nil {
if found, _ := ldb.Search("memories", v, 5, input.Namespace); len(found) > 0 {
for _, ex := range found {
if ex.Content == expContent || cosineSimilarity(v, ex.Vector) >= 0.98 {
skip = true
break
}
}
}
}
if skip {
log.Printf("[distill][experience] 近重复跳过 episode=%s task=%.60s", input.EpisodeID, exp.Task)
continue
}
expMem := models.MemoryRecord{
ID: fmt.Sprintf("mem_%d", time.Now().UnixNano()),
AgentID: input.AgentID,
Namespace: input.Namespace,
Content: expContent,
// 🔴 用现成 category 字段(实测可写入/可召回),不造新表/新列
Category: "experience",
Tier: "normal",
// 质量口径 = 现有五维 overall(VD/RU 已在其中);🔴 不做 confidence 自评
QualityScore: result.Overall,
Version: 1,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
DerivedFrom: input.EpisodeID,
SourceEvents: []string{input.EpisodeID},
Freshness: "fresh",
Source: srcOrLLMDistill(input.Source),
EmbeddingModel: orDefault(input.EmbeddingModel, "bge-m3"),
ScriptVersion: orDefault(input.ScriptVersion, "unknown"),
ValidFrom: time.Now().Format(time.RFC3339),
Provenance: distill.DetectProvenance(input.Source),
}
if err := ldb.InsertMemory(expMem); err != nil {
log.Printf("[distill][experience] 落库失败 episode=%s: %v", input.EpisodeID, err)
} else {
factsLanded = true
log.Printf("[distill][experience] 落库 episode=%s task=%.60s boundary=%.40s",
input.EpisodeID, exp.Task, exp.Boundary)
}
}
// t_1c12e000:写路径缓存失效(方案 A)——distilled facts 是 memories 唯一写点。
// 本进程 Invalidate(ns) 清 L1+L2;跨 Agent 广播复用 commit handler 的
// PushCacheInvalidate(已注册 cache.invalidate 订阅),此处不重复广播。
if factsLanded && storage.SearchCacheInstance != nil {
storage.SearchCacheInstance.Invalidate(input.Namespace)
log.Printf("[cache] 蒸馏落库后失效 namespace=%s(%d 条 facts,episode=%s)",
input.Namespace, len(result.Facts), input.EpisodeID)
// 2026-10-14 (t_2afae95e):FTS 词面索引同步标脏(惰性重建,
// 下次词面查询时后台刷;关态下只是置一个 bool,零成本)
storage.FtsInvalidate()
}
// 冲突检测:加载已有记忆进行比较
// 必须用**新内容的真实向量**做语义检索取近邻;用零向量在语义索引上
// 返回的只是"范数最小"的任意行,真正的矛盾对象不在比对集里 → 永远检不出冲突。
existingMems := []models.MemoryRecord{}
if qv, eErr := emb.EncodeSingle(input.Content); eErr == nil {
if found, _ := ldb.Search("memories", qv, 100, input.Namespace); len(found) > 0 {
existingMems = found
}
}
if len(existingMems) == 0 {
zeroVec := make([]float32, 1024)
existingMems, _ = ldb.Search("memories", zeroVec, 100, input.Namespace)
}
conflictExisting := make([]map[string]interface{}, 0)
for _, m := range existingMems {
conflictExisting = append(conflictExisting, map[string]interface{}{
"content": m.Content,
// 2026-10-09 修:此处原先填的是**新内容的实体**(同一个 entityNames 切片),
// 导致 Scan 的实体预筛 e1==e2 恒真、彻底失效 —— 每条候选都跑全量比对,
// 无关候选被纳入后 IsContradiction 误报(/var/lib/memoryweave/
// conflicts-false-positive-*.json 现存 106 条已判假阳性即此产物)。
// 改为**每条候选自己的实体**(从其正文抽取,与其它已存记忆同口径)。
"entities": governance.ExtractEntities(m.Content),
})
}
for _, c := range conflictDetector.Scan(input.Content, entityNames, conflictExisting) {
if c.Strategy == "latest_wins" {
conflictDetector.AutoResolve(c)
}
}
// 被动验证:使用已加载的记忆进行 P1/P2/P3 匹配
validMems := make([]selfoptimize.MemoryForValidation, len(existingMems))
for i, m := range existingMems {
validMems[i] = selfoptimize.MemoryForValidation{
ID: m.ID, Content: m.Content, QualityScore: m.QualityScore,
}
}
validated := selfoptimize.Validator.Validate(input.Content, validMems)
// 被动验证结果写回 quality_score
// 修复(2026-09-20):原先用 GetRecords() 遍历【全部历史记录】并无条件写回 ——
// 每来一次输入就重写全库(≈6000 条),每次 Update 新增一个 LanceDB 版本快照
// (manifest 引用全部 data 文件 ⇒ O(n²)),导致版本数每日 ~1.9 万、占用每日涨十几 G。
// 现改为:只写【本次真正匹配】且【Confidence 实际发生变化】的记录。
for _, r := range validated {
if r.Confidence > 0 && r.Changed {
_ = ldb.Update("memories", r.MemoryID, map[string]any{
"quality_score": r.Confidence,
})
}
}
}
// ─── CO_OCCURS 追踪器(持久化到 Redis)───
storage.CoOccurTrackerInstance = storage.NewCoOccurTracker(storage.RedisCoOccurPersist())
storage.LoadCoOccurFromRedis()
// ─── Dashboard 持久化到 Redis(restart 不丢指标)───
selfoptimize.EnableRedisPersistence()
// ─── VProp 持久化到 Redis(restart 不丢)───
selfoptimize.VProp.EnableVPropRedisPersistence()
// ─── Skill 持久化到 Redis(G7: restart 不丢 skills)───
routes.BayesianSkills.EnableRedisPersistence()
// ─── V 值传播器(竞争性架构核心)─────────────
vPropagator := selfoptimize.VProp
// 注入 V 值查询函数到存储层(召回排序融合)
storage.VValueProvider = func(memoryID string) float64 {
// 2026-10-13:ep_* 是蒸馏自证(恒 1.0),零区分度且淹没真实 mem_* 信号
// (实测 ep_* 4304 个 vs mem_* 24 个)。召回目标全是 mem_*,ep_* 的 V 值
// 不参与排序即可,无需删 Redis 数据(保留可回溯)。
if strings.HasPrefix(memoryID, "ep_") {
return 0
}
return vPropagator.GetMemVValue(memoryID)
}
// 在蒸馏完成回调中记录 V 值
originalOnComplete := distill.OnDistillComplete
distill.OnDistillComplete = func(input distill.DistillInput, result distill.DistillResult) {
// 调用已有的回调
if originalOnComplete != nil {
originalOnComplete(input, result)
}
// 记录 V 值决策:蒸馏成功 = 有用
if len(result.Facts) > 0 {
vPropagator.RecordDecision(
"distill_"+input.EpisodeID,
[]string{input.EpisodeID},
"distill", "success", "",
)
}
// 更新仪表盘指标:蒸馏成功次数(2026-10-14 t_c06ad4d0 改,勿再灌 UsefulCount)
selfoptimize.Dash.RecordDistillSuccess()
// 记录蒸馏质量损失(avg_distill_loss = 1 - Overall)
// 仅记录真实蒸馏:Overall > 0 表示 LLM 成功返回,过滤 fallback (Overall=0)
if result.Overall > 0 {
loss := 1.0 - result.Overall
if loss < 0 {
loss = 0
}
if loss > 1 {
loss = 1
}
selfoptimize.Dash.RecordDistillLoss(loss)
}
}
// ─── 跨 Agent 缓存失效回调 ───────────────────
// 收到其他 Agent 的广播 → 失效本地缓存
governance.GlobalEventBus.Subscribe("cache.invalidate", "self")
// ─── 路由注册 ──────────────────────────────
// ─── 静态文件服务(Web UI)─────────────────────
// webUIRoot 在函数顶部声明
var webUIRoot string
// 后续在 catch-all 中处理(见文件末尾)
mux.HandleFunc("/health", routes.HandleHealth)
mux.HandleFunc("/api/v1/health", routes.HandleHealth)
// t_e11207f8 治源①:空壳拦截器「确实跑过」的取证端点。
// checks=每个蒸馏 fact 判一次的心跳计数(>0 即拦截器活着);blocks=实际拒写空壳条数;
// loaded=false 表示共享判据没加载上(fail-open,需排查 ZHIYI_PAYLOAD_GUARD_RULES 路径)。
mux.HandleFunc("/api/v1/payload-guard/stats", func(w http.ResponseWriter, r *http.Request) {
checks, blocks, loaded, rp := distill.PayloadGuardStats()
respondJSON(w, 200, map[string]interface{}{
"checks": checks,
"blocks": blocks,
"loaded": loaded,
"rules_path": rp,
})
})
// 图谱缓存统计 + 搜索缓存统计(t_1c12e000:并入 search_cache 段,
// 暴露 hits/misses/invalidated/size/ttl,写频对命中率的影响可观测)
mux.HandleFunc("/api/v1/cache/stats", func(w http.ResponseWriter, r *http.Request) {
resp := map[string]interface{}{
"graph_cache": nil,
}
if cachedGraphStoreRef != nil {
resp["graph_cache"] = cachedGraphStoreRef.Stats()
}
if storage.SearchCacheInstance != nil {
resp["search_cache"] = storage.SearchCacheInstance.Stats()
}
// 2026-10-14 (t_2afae95e):FTS 词面索引状态(docs/tokens/built_at/dirty/门控)
resp["fts_index"] = storage.FtsIndexStats()
respondJSON(w, 200, resp)
})
// 2026-10-14 (t_2afae95e):FTS 手工刷索引(admin;异步重建不阻塞)
mux.HandleFunc("/api/v1/fts/rebuild", func(w http.ResponseWriter, r *http.Request) {
storage.FtsRebuildNow()
respondJSON(w, 200, map[string]interface{}{"status": "rebuild_started"})
})
// 2026-10-14 (t_2afae95e):FTS 词面查询取证端点(AC1 判据命令):
// 返回该 query 的词面 topN 命中(id + 分 + 内容摘录),不走路过 recall 缓存。
mux.HandleFunc("/api/v1/fts/query", func(w http.ResponseWriter, r *http.Request) {
var req struct {
Query string `json:"query"`
TopN int `json:"top_n"`
CheckDocID string `json:"check_doc_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
if req.TopN <= 0 {
req.TopN = 10
}
respondJSON(w, 200, storage.FtsQuery(req.Query, req.TopN, req.CheckDocID))
})
// ─── Prometheus /metrics ─────────────────────
mux.Handle("/metrics", metrics.Handler())
// 核心 API
mux.HandleFunc("/api/v1/commit", func(w http.ResponseWriter, r *http.Request) {
api.Commit(w, r)
// 跨 Agent 广播 + 自动蒸馏
go func() {
var req struct {
AgentID string `json:"agent_id"`
Namespace string `json:"namespace"`
Content string `json:"content"`
}
if r.Body != nil {
body, _ := io.ReadAll(r.Body)
json.Unmarshal(body, &req)
r.Body = io.NopCloser(bytes.NewReader(body))
}
if req.Namespace != "" {
// 通知其他 Agent:缓存失效 + 新记忆事件
governance.PushCacheInvalidate(req.Namespace, "commit")
governance.PushMemoryCommitted(req.AgentID, req.Namespace, "")
}
// t_f8fc97e6 P3 Peer:新记忆进入 → 异步刷新内容里出现的实体画像。
// 默认关(ZHIYI_PEER_RECALL 未设/≠1)⇒ 本分支整体跳过,commit 路径零变化(AC4)。
// 开启时在已有 goroutine 内再起子 goroutine,LLM 推理绝不阻塞 HTTP commit 返回。
if os.Getenv("ZHIYI_PEER_RECALL") == "1" && peerAPI != nil {
peerAPI.RefreshFromContent(req.Content, req.Namespace)
}
}()
})
mux.HandleFunc("/api/v1/recall", api.Recall)
mux.HandleFunc("/api/v1/recall/debug", api.RecallDebug)
// 2026-10-26 (P0-1 t_trace_p0):召回链路追踪回溯(trace_id 贯穿各阶段/各通道)
mux.HandleFunc("/api/v1/trace/", api.Trace)
// t_23b6a940 AC7 热门度看门狗快照(report-only,传感器端读它转既有告警通道)
mux.HandleFunc("/api/v1/popwatch", api.PopWatch)
// 2026-10-13 (t_e9a4d18f P2) 常设答案直读/写入
mux.HandleFunc("/api/v1/standing-answers", routes.HandleStandingAnswers)
// ⑤使用追踪(t_5aa4dcac):上报 + 手动触发。env 门控 ZHIYI_USAGE_TRACK,默认关。
mux.HandleFunc("/api/v1/usage/report", api.UsageReport)
mux.HandleFunc("/api/v1/usage/detect", api.UsageDetect)
// GA-P0-7 (t_dd64345a):裸 /api/v1/feedback 已退役——其 handler 曾写旧字段 not_useful_count。
// 反馈统一走 /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。
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) {
m := selfoptimize.Dash.Metrics()
if stats, err := ldb.Stats(); err == nil {
if total, ok := stats["total_memories"].(float64); ok {
m["total_memories"] = total
}
if total, ok := stats["total_episodes"].(float64); ok {
m["total_episodes"] = total
} else if cached, ok := stats["episodes_cached"].(float64); ok {
// 旧 sidecar 回退:缓存值非真实总量,gauge 仍设值;
// 来源标记写入响应 JSON(/api/v1/metrics 的 m 类型是 map[string]float64,
// 无法直接加字符串字段,改为用 episodes_source=1.0 作为标记位)
m["total_episodes"] = cached
m["episodes_source_cached"] = 1 // 标记:此值来自进程内缓存(非 Rust count_rows)
}
}
respondJSON(w, 200, m)
})
// ─── WebSocket 实时推送 ──────────────────────
mux.HandleFunc("/api/v1/ws/", routes.WSHandler)
// 社区发现(Louvain + 社区摘要)
mux.HandleFunc("/api/v1/community/summarize", api.HandleCommunitySummarize)
// 深度推理(人侧数据 → 三类清单)
mux.HandleFunc("/api/v1/deep-think", api.HandleDeepThink)
mux.HandleFunc("/api/v1/ws", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok","protocol":"websocket","endpoint":"/api/v1/ws/{agent_id}"}`))
})
// 知识图谱
mux.HandleFunc("/api/v1/graph/stats", graphAPI.Stats)
mux.HandleFunc("/api/v1/graph/query", graphAPI.Query)
mux.HandleFunc("/api/v1/graph/navigate", graphAPI.Navigate)
// P2: 边反馈
mux.HandleFunc("/api/v1/graph/edge/feedback", api.EdgeFeedback)
// 新增:添加关系边(POST JSON body: {"from":"实体A","to":"实体B","relation":"关系类型","namespace":""})
mux.HandleFunc("/api/v1/graph/edge", func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "POST only", 405)
return
}
var req struct {
From string `json:"from"`
To string `json:"to"`
Relation string `json:"relation"`
Namespace string `json:"namespace"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), 400)
return
}
if req.From == "" || req.To == "" || req.Relation == "" {
http.Error(w, "from, to, relation required", 400)
return
}
ns := req.Namespace
if ns == "" {
ns = "default"
}
edgeID := fmt.Sprintf("e_%s_%s_%d", req.From, req.Relation, time.Now().UnixNano())
maxRetries := 5
var lastErr error
for attempt := 0; attempt < maxRetries; attempt++ {
lastErr = graphStore.AddEdge(edgeID, req.From, req.To, req.Relation, ns, 1.0)
if lastErr == nil {
break // Success, exit retry loop
}
// Check for rate limit error (429) - zhiyid returns rate_limit_exceeded
if strings.Contains(lastErr.Error(), "rate_limit_exceeded") || strings.Contains(lastErr.Error(), "429") {
// Exponential backoff: 1s, 2s, 4s, 8s, 16s
backoff := time.Duration(1<<uint(attempt)) * time.Second
time.Sleep(backoff)
continue
}
// Any other error - don't retry
break
}
if lastErr != nil {
// Max retries exceeded - write to disk for later retry
failedPath := "/tmp/memoryweave/failed_edges.jsonl"
dir := filepath.Dir(failedPath)
os.MkdirAll(dir, 0755)
failedEdge := map[string]interface{}{
"edge_id": edgeID,
"from": req.From,
"to": req.To,
"relation": req.Relation,
"namespace": ns,
"weight": 1.0,
"timestamp": time.Now().UnixNano(),
"error": lastErr.Error(),
}
jsonData, _ := json.Marshal(failedEdge)
// Append to jsonl file
f, _ := os.OpenFile(failedPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
defer f.Close()
f.Write(jsonData)
f.WriteString("\n")
// Return success with warning that edge was queued for later retry
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"status": "queued_for_retry",
"edge_id": edgeID,
"message": "Edge write failed after max retries; queued to /tmp/memoryweave/failed_edges.jsonl",
})
return
}
// 图谱变更:失效 navigate 缓存
if cachedGraphStoreRef != nil {
cachedGraphStoreRef.InvalidateAll()
}
json.NewEncoder(w).Encode(map[string]interface{}{"status": "ok", "edge_id": edgeID})
})
// 新增:pagerank + evidence_count
mux.HandleFunc("/api/v1/graph/pagerank", func(w http.ResponseWriter, r *http.Request) {
rank := graphStore.PageRank(0.85, 20)
data, _ := json.Marshal(map[string]interface{}{
"pagerank": rank,
"count": len(rank),
})
w.Header().Set("Content-Type", "application/json")
w.Write(data)
})
mux.HandleFunc("/api/v1/graph/evidence/", func(w http.ResponseWriter, r *http.Request) {
entity := r.URL.Path[len("/api/v1/graph/evidence/"):]
count := graphStore.EvidenceCount(entity)
data, _ := json.Marshal(map[string]interface{}{
"entity": entity,
"evidence_count": count,
})
w.Header().Set("Content-Type", "application/json")
w.Write(data)
})
// 图谱可视化导出
mux.HandleFunc("/api/v1/graph/export", func(w http.ResponseWriter, r *http.Request) {
ns := r.URL.Query().Get("namespace")
limitStr := r.URL.Query().Get("limit")
limit := 0
if limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
limit = l
}
}
nodes, edges := graphStore.GetGraph(ns, limit)
respondJSON(w, 200, map[string]interface{}{
"nodes": nodes,
"edges": edges,
"count": map[string]int{"nodes": len(nodes), "edges": len(edges)},
})
})
// 图谱导航 + Obsidian 笔记关联
mux.HandleFunc("/api/v1/graph/notes", func(w http.ResponseWriter, r *http.Request) {
entity := r.URL.Query().Get("entity")
if entity == "" {
entity = r.URL.Query().Get("query") // 也接受整句 query
}
// 噪音闸门(t_689b6d38):系统通知/提示样板文本(如 "[IMPORTANT: You are running
// as a scheduled cron job..."、"[System note: ...")不进语义召回、不返回笔记。
// 判定优先用结构化来源标记头,白名单(真实用户提问)照常召回,不误杀。
if routes.IsSystemTemplateInput(entity) {
log.Printf("[graph/notes] 拦截系统样板入参(system_template):不拆实体、不返回笔记(入参 %q)",
strings.TrimSpace(entity))
respondJSON(w, 200, map[string]interface{}{
"entity": entity,
"graph_paths": []map[string]interface{}{},
"notes": []map[string]interface{}{},
"paths_count": 0,
"notes_count": 0,
"mode": "none",
"skipped": "system_template",
})
return
}
maxHops := 2
if h := r.URL.Query().Get("max_hops"); h != "" {
if parsed, err := strconv.Atoi(h); err == nil && parsed > 0 {
maxHops = parsed
}
}
maxNotes := 10
if n := r.URL.Query().Get("max_notes"); n != "" {
if parsed, err := strconv.Atoi(n); err == nil && parsed > 0 {
maxNotes = parsed
}
}
var noteResults []map[string]interface{}
var paths []map[string]interface{}
mode := "none"
var usedEntities []string
// 响应体积上限:调用方(hermes 插件 / openclaw 客户端 / dashboard)都只读 notes,
// 不读 graph_paths(2026-09-27 全仓核对 3 处调用点)。而单实体 2 跳可爆到 1.9 万条
// ≈1.7MB/次 → 截断,仅作可观测样本。
const maxGraphPaths = 50
// union:同名两层并集展开(id=名字 的旧 entity 层 + id="n_"+名字 的新 concept 层)。
// 图谱重名报告(2026-09-27):Navigate 按 id 匹配 ⇒ 只传名字时新层完全用不上(织忆:
// 旧层 6 出边 vs 新层 1674 出边)。兜底路径(整句提问)默认并集;直查路径用 union=1 显式开
// —— 灰度:直查是稳定路径,先不动,等对比数据再决定默认值。
unionParam := r.URL.Query().Get("union")
union := unionParam == "1" // 直查路径:显式 union=1 才开
unionDefault := unionParam != "0" // 兜底路径:默认开,union=0 可关(验收对照用)
unionApplied := false
var unionIDs []string // 并集展开实际用到的节点 id(可观测:是否真的吃到新层)
addUnionID := func(id string) {
for _, x := range unionIDs {
if x == id {
return
}
}
unionIDs = append(unionIDs, id)
}
if entity != "" {
norm := routes.NormalizeEntity(entity)
p, err := graphStore.Navigate(norm, maxHops, "", nil)
if err == nil {
// union=1:同名跨层并集。必须按**原始名字**解析:norm 已带 n_ 前缀
// (NormalizeEntity 行为),用它解析只得到新 concept 层(含 n_n_ 伪影),
// 恰恰漏掉旧 wiki 语义层(id=名字,7,466 条边,HTTP 上完全不可达)。
if union {
seenP := make(map[string]bool, len(p))
extra := []map[string]interface{}{}
for _, id := range routes.ResolveEntityIDs(graphStore, strings.TrimPrefix(norm, "n_")) {
addUnionID(id)
if id == norm {
continue
}
if p2, err2 := graphStore.Navigate(id, maxHops, "", nil); err2 == nil {
extra = routes.MergePaths(extra, p2, nil, 0)
}
}
// 跨层(旧 wiki 语义层)边放前面:主层 1.9 万条机器边会把 50 条样本窗口
// 占满,排在后面等于被截断——并集就白做了。
p = routes.MergePaths(extra, p, seenP, 0)
unionApplied = true
}
paths = p
// 邻居进笔记匹配:必须带上限 + 去重。2026-09-27 上线实测教训——不加限会把
// 并集展开出的上万个邻居全丢给 SearchNotesByEntities(实体 × 全库笔记扫描),
// CPU 直接打满、HTTP 45s 超时。上限与兜底路径的 maxSearchEntities 同思路。
const maxMatchEntities = 24
entities := make([]string, 0, maxMatchEntities)
entsSeen := map[string]bool{norm: true}
entities = append(entities, norm)
for _, path := range p {
if len(entities) >= maxMatchEntities {
break
}
s, t, _ := routes.PathEnds(path) // 兼容 from/to 与 source/target 两套键名
for _, e := range [2]string{s, t} {
if e == "" || entsSeen[e] {
continue
}
entsSeen[e] = true
entities = append(entities, e)
if len(entities) >= maxMatchEntities {
break
}
}
}
if obsidian != nil {
noteResults = obsidian.SearchNotesByEntities(entities, maxNotes)
}
// 笔记匹配已用完完整路径集 → 响应只留样本(见上方 maxGraphPaths 说明)
if len(paths) > maxGraphPaths {
paths = paths[:maxGraphPaths]
}
}
if len(noteResults) > 0 {
mode = "direct"
} else if cands := entityIndex.Extract(entity, 2); len(cands) > 0 {
// 兜底:entity 是整句自然语言(调用方原样传用户提问)→ 本地把它拆成候选实体
// 2026-10-12 GA-P0-5:概念/实体提取路径接 echo 防线 —— 审计笔记把污染词当证据
// 列出来 → 蒸馏成 episode → 兜底拆实体时会把已删除污染节点(n_galway / n_rothfuss /
// n_the_name_of_the_wind)重建。候选实体若只是 prompt 教学示例的回显残片
// (逐字相等 / illustration 类 ≥4 字子串,判据与蒸馏侧 IsPromptEcho 同源)
// ⇒ 不入图;[ECHO-DROP] 日志供验证防线在跑。
var echoDropped int
kept := cands[:0]
for _, c := range cands {
if distill.IsPromptEchoEntity(c) {
echoDropped++
continue
}
kept = append(kept, c)
}
if echoDropped > 0 {
log.Printf("[graph/notes][ECHO-DROP] 剔除回显残片 %d 个(%q)→ 不参与笔记匹配/不入图", echoDropped, cands)
cands = kept
}
const maxSearchEntities = 8 // 参与笔记匹配的实体上限:候选 + 少量邻居,防噪声淹没
ents := make([]string, 0, maxSearchEntities)
seen := make(map[string]bool, maxSearchEntities)
for _, cand := range cands {
ne := routes.NormalizeEntity(cand)
if ne == "" || seen[ne] {
continue
}
seen[ne] = true
ents = append(ents, ne)
// 并集:同名跨层(旧 wiki 语义层 + 新 concept 层)都展开,
// 按原始名字解析——ne 带 n_ 前缀会漏掉旧层(同直查路径注释)。
ids := []string{ne}
if unionDefault {
ids = routes.ResolveEntityIDs(graphStore, strings.TrimPrefix(ne, "n_"))
unionApplied = true
for _, id := range ids {
addUnionID(id)
}
}
for _, id := range ids {
p, err := graphStore.Navigate(id, maxHops, "", nil)
if err != nil {
continue
}
if len(p) > 20 { // 单跳邻居即可,避免把整个二跳子图塞进响应
p = p[:20]
}
if len(paths) < maxGraphPaths {
paths = append(paths, p...)
}
for _, path := range p {
if len(ents) >= maxSearchEntities {
break
}
s, t, _ := routes.PathEnds(path) // 同上:兼容两套键名
if s != "" && !seen[s] {
seen[s] = true
ents = append(ents, s)
}
if t != "" && !seen[t] {
seen[t] = true
ents = append(ents, t)
}
}
}
}
if obsidian != nil && len(ents) > 0 {
noteResults = obsidian.SearchNotesByEntities(ents, maxNotes)
}
if len(noteResults) > 0 {
mode = "nl_extract"
usedEntities = cands
log.Printf("[graph/notes] 整句兜底拆实体 %v(实际匹配 %d 个)→ %d 篇笔记(入参 %q)", cands, len(ents), len(noteResults), entity)
}
}
}
resp := map[string]interface{}{
"entity": entity,
"graph_paths": paths,
"notes": noteResults,
"paths_count": len(paths),
"notes_count": len(noteResults),
"mode": mode,
}
if len(usedEntities) > 0 {
resp["entities_used"] = usedEntities
}
if unionApplied {
resp["union"] = true // 本次响应走了同名两层并集展开
}
if len(unionIDs) > 0 {
resp["entity_ids_used"] = unionIDs
}
respondJSON(w, 200, resp)
})
// 自然语言图谱查询 /api/v1/graph/nl_query
// 输入: {"query": "织忆和牧尘是什么关系"}
// 返回: 自然语言关系描述
mux.HandleFunc("/api/v1/graph/nl_query", func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
var req struct {
Query string `json:"query"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
query := strings.TrimSpace(req.Query)
if query == "" {
http.Error(w, "query is required", http.StatusBadRequest)
return
}
// 1. 从 query 中提取可能的实体名(去掉问号、语气词)
cleanQuery := strings.TrimSuffix(strings.TrimSuffix(query, "?"), "?")
cleanQuery = strings.TrimPrefix(cleanQuery, "请问")
cleanQuery = strings.TrimPrefix(cleanQuery, "请说")
cleanQuery = strings.TrimSpace(cleanQuery)
// 2. 尝试识别实体对("A和B的关系"、"A与B")
var entityA, entityB string
// 模式1: "A和B是什么关系" / "A和B有什么关系" / "A和B的关系"
hasAnd := strings.Contains(cleanQuery, "和") || strings.Contains(cleanQuery, "与")
if hasAnd && (strings.Contains(cleanQuery, "关系") || strings.Contains(cleanQuery, "关联")) {
delim := "和"
if strings.Contains(cleanQuery, "与") {
delim = "与"
}
parts := strings.Split(cleanQuery, delim)
if len(parts) == 2 {
entityA = strings.TrimSpace(parts[0])
rawB := strings.TrimSpace(parts[1])
// 去掉句末的问句模式得到 entityB
entityB = strings.TrimSuffix(rawB, "是什么关系")
entityB = strings.TrimSuffix(entityB, "有什么关系")
entityB = strings.TrimSuffix(entityB, "的")
entityB = strings.TrimSuffix(entityB, "是什么")
entityB = strings.TrimSpace(entityB)
// 如果去掉问句后为空(如"织忆和牧尘" -> entityB=""),则原始 query 可能是"A和B"
if entityB == "" {
entityB = rawB
}
}
}
var result map[string]interface{}
if entityA != "" && entityB != "" {
// 双向查询:A → B 的关系
paths, _ := graphStore.Navigate(routes.NormalizeEntity(entityA), 2, "", nil)
relB, _ := graphStore.Navigate(routes.NormalizeEntity(entityB), 2, "", nil)
// 找 A → B 的直接边
var directPath map[string]interface{}
for _, p := range paths {
toNorm := routes.NormalizeEntity(p["to"].(string))
targetNorm := routes.NormalizeEntity(entityB)
if toNorm == targetNorm {
directPath = p
break
}
}
result = map[string]interface{}{
"query": query,
"entity_a": entityA,
"entity_b": entityB,
"direct_path": directPath,
"paths_from_a": len(paths),
"paths_from_b": len(relB),
"summary": fmt.Sprintf("%s 与 %s 存在 %d 条关联路径", entityA, entityB, len(paths)),
}
} else {
// 单实体查询:找 entity 的关系网
entity := cleanQuery
paths, _ := graphStore.Navigate(routes.NormalizeEntity(entity), 2, "", nil)
// 按 relation 分组
relCounts := make(map[string]int)
for _, p := range paths {
rel := p["relation"].(string)
if rel == "" {
rel = "unknown"
}
relCounts[rel]++
}
// 生成自然语言描述
var rels []string
for rel, cnt := range relCounts {
rels = append(rels, fmt.Sprintf("%s(%d条)", rel, cnt))
}
sort.Strings(rels)
result = map[string]interface{}{
"query": query,
"entity": entity,
"total_paths": len(paths),
"relation_summary": rels,
"summary": fmt.Sprintf("%s 共有 %d 条关联,分布在 %d 种关系类型", entity, len(paths), len(relCounts)),
}
}
respondJSON(w, 200, result)
})
// 图谱脏数据清理 /api/v1/graph/cleanup
mux.HandleFunc("/api/v1/graph/cleanup", func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" && r.Method != "GET" {
http.Error(w, "GET or POST only", http.StatusMethodNotAllowed)
return
}
dryRun := r.URL.Query().Get("dry_run") != "false" // 默认为 dryRun
count, ids, err := graphStore.CleanupNoiseNodes(dryRun)
if err != nil {
http.Error(w, "cleanup failed: "+err.Error(), http.StatusInternalServerError)
return
}
// 实际执行后失效缓存(dry_run=true 时不执行,无需失效)
if !dryRun && cachedGraphStoreRef != nil && count > 0 {
cachedGraphStoreRef.InvalidateAll()
}
respondJSON(w, 200, map[string]interface{}{
"dry_run": dryRun,
"removed": count,
"node_ids": ids,
"message": fmt.Sprintf("Found %d noise nodes", count),
})
})
// 图谱多 agent 测试污染清理(namespace + 名称子串圈定,不碰全表编码规则)
// 2026-09-08 卫安/A03/A04 371 节点:openclaw-main/a06-main/hermes-main 中测试 agent 产生的概念/事实节点
// GET/POST /api/v1/graph/cleanup/scoped?dry_run=true&namespace=a,b,c&name=a03,a04,卫安
mux.HandleFunc("/api/v1/graph/cleanup/scoped", func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" && r.Method != "GET" {
http.Error(w, "GET or POST only", http.StatusMethodNotAllowed)
return
}
dryRun := r.URL.Query().Get("dry_run") != "false" // 默认为 dryRun
nsParam := r.URL.Query().Get("namespace")
nameParam := r.URL.Query().Get("name")
if nsParam == "" || nameParam == "" {
http.Error(w, "namespace and name (comma separated) are required", http.StatusBadRequest)
return
}
namespaces := strings.Split(nsParam, ",")
nameContains := strings.Split(nameParam, ",")
type scopedCleaner interface {
CleanupScopedNodes(dryRun bool, namespaces, nameContains []string) (int, []string, error)
}
cleaner, ok := graphStore.(scopedCleaner)
if !ok {
http.Error(w, "scoped cleanup not supported by current graph backend", http.StatusNotImplemented)
return
}
count, ids, err := cleaner.CleanupScopedNodes(dryRun, namespaces, nameContains)
if err != nil {
http.Error(w, "cleanup failed: "+err.Error(), http.StatusInternalServerError)
return
}
// 实际执行后失效缓存(dry_run=true 时不执行,无需失效)
if !dryRun && cachedGraphStoreRef != nil && count > 0 {
cachedGraphStoreRef.InvalidateAll()
}
respondJSON(w, 200, map[string]interface{}{
"dry_run": dryRun,
"removed": count,
"node_ids": ids,
"namespaces": namespaces,
"name_contains": nameContains,
"message": fmt.Sprintf("Found %d scoped noise nodes", count),
})
})
// 静态文件服务(知识图谱可视化 HTML)
staticDir := os.Getenv("STATIC_DIR")
if staticDir == "" {
staticDir = "/home/muc/projects/memoryweave/go/static"
}
fileServer := http.FileServer(http.Dir(staticDir))
mux.Handle("/static/", http.StripPrefix("/static/", fileServer))
// 冲突
mux.HandleFunc("/api/v1/conflicts", func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" {
conflictAPI.List(w, r)
} else {
http.NotFound(w, r)
}
})
mux.HandleFunc("/api/v1/conflicts/resolve", conflictAPI.Resolve)
// 反馈 — G5: 修正记忆时填充 version_history
// 2026-10-14 (t_c06ad4d0):删除冗余重复块。MarkUseful 已做 RecordFeedback(true)+
// RecordDecision+QualityMonitor.Check+IncrementUseful+AppendHitEvent;原 handler 里
// 这段二次读 r.Body 的 RecordUseful/RecordDecision/QualityMonitor 全部重复,且
// r.Body 已被 MarkUseful 消费(io.ReadAll 恒空)⇒ 死代码 + 潜在二次计数污染。
mux.HandleFunc("/api/v1/feedback/useful", func(w http.ResponseWriter, r *http.Request) {
feedbackAPI.MarkUseful(w, r)
})
mux.HandleFunc("/api/v1/feedback/not-useful", func(w http.ResponseWriter, r *http.Request) {
feedbackAPI.MarkNotUseful(w, r)
// 质量监控:not-useful → 检查是否需要降权
var req struct {
MemoryID string `json:"memory_id"`
}
if r.Body != nil {
body, _ := io.ReadAll(r.Body)
json.Unmarshal(body, &req)
r.Body = io.NopCloser(bytes.NewReader(body))
if req.MemoryID != "" {
selfoptimize.Dash.RecordNotUseful()
// V 值:not-useful = failure
selfoptimize.VProp.RecordDecision(
"notuseful_"+req.MemoryID,
[]string{req.MemoryID},
"user_feedback", "failure", "",
)
score := selfoptimize.Dash.QualityScore()
if record := selfoptimize.QualityMonitor.Check(req.MemoryID, score, 5); record != nil {
_ = record // WebSocket 通知
}
}
}
})
mux.HandleFunc("/api/v1/feedback/deprecate", feedbackAPI.Deprecate)
mux.HandleFunc("/api/v1/feedback/correct", func(w http.ResponseWriter, r *http.Request) {
feedbackAPI.Correct(w, r)
var req struct {
MemoryID string `json:"memory_id"`
NewContent string `json:"new_content"`
Source string `json:"source"`
}
if r.Body != nil {
body, _ := io.ReadAll(r.Body)
json.Unmarshal(body, &req)
r.Body = io.NopCloser(bytes.NewReader(body))
if req.MemoryID != "" && req.NewContent != "" {
routes.FillVersionHistory(req.MemoryID, "", req.NewContent, req.Source)
// 跨 Agent 通知
governance.PushMemoryUpdated(req.MemoryID, 0, "corrected")
}
}
})
// 缺口
mux.HandleFunc("/api/v1/gaps", gapAPI.List)
mux.HandleFunc("/api/v1/gaps/detect", gapAPI.Detect)
// t_d32e5f8a L2 模式检测(MemOS 血统)
mux.HandleFunc("/api/v1/patterns", patternAPI.List)
mux.HandleFunc("/api/v1/patterns/detect", patternAPI.Detect)
// t_fcadee04 L3 skill 草稿(/patterns/{id}/draft 与 /patterns/{id}/promote)。
// 前缀路由;/patterns 与 /patterns/detect 因更长的精确路由优先,不受影响。
mux.HandleFunc("/api/v1/patterns/", patternAPI.Sub)
// t_f8fc97e6 P3 Peer Representation:显式 refresh/explain + 门控 recall。
// 前缀路由 /api/v1/peer/;/api/v1/recall/peer 独立精确路由优先。
mux.HandleFunc("/api/v1/recall/peer", peerAPI.RecallPeer)
mux.HandleFunc("/api/v1/peer/", peerAPI.Sub)
mux.HandleFunc("/api/v1/gaps/close/", func(w http.ResponseWriter, r *http.Request) {
gapAPI.Close(w, r)
topic := r.URL.Path[len("/api/v1/gaps/close/"):]
routes.PushGapFilled(topic, 1)
governance.PushGapFilled(topic, 1)
})
mux.HandleFunc("/api/v1/gaps/repair", routes.GapRepair.FullRepairHandler)
// Agent 注册
mux.HandleFunc("/api/v1/agents/register", agentRegistry.Register)
mux.HandleFunc("/api/v1/agents", agentRegistry.List)
// ─── 多 Agent EventBus 管理 ──────────────────
mux.HandleFunc("/api/v1/eventbus/subscribe", func(w http.ResponseWriter, r *http.Request) {
var req struct {
EventType string `json:"event_type"`
Callback string `json:"callback_url"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
governance.GlobalEventBus.Subscribe(req.EventType, req.Callback)
respondJSON(w, 200, map[string]string{"status": "subscribed"})
})
mux.HandleFunc("/api/v1/eventbus/unsubscribe", func(w http.ResponseWriter, r *http.Request) {
var req struct {
EventType string `json:"event_type"`
Callback string `json:"callback_url"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
governance.GlobalEventBus.Unsubscribe(req.EventType, req.Callback)
respondJSON(w, 200, map[string]string{"status": "unsubscribed"})
})
mux.HandleFunc("/api/v1/eventbus/list", func(w http.ResponseWriter, r *http.Request) {
subs := governance.GlobalEventBus.ListSubscribers()
respondJSON(w, 200, subs)
})
// 跨 Agent 缓存失效接收端点(供其他 Agent 实例 HTTP 回调)
mux.HandleFunc("/api/v1/cache/invalidate", func(w http.ResponseWriter, r *http.Request) {
var evt struct {
Type string `json:"type"`
Payload map[string]string `json:"payload"`
}
if err := json.NewDecoder(r.Body).Decode(&evt); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
ns := evt.Payload["namespace"]
storage.SearchCacheInstance.Invalidate(ns)
log.Printf("[cache] 跨 Agent 失效接收: %s", ns)
respondJSON(w, 200, map[string]string{"status": "invalidated"})
})
// 管理
// ?task=full 触发完整整合(DBSCAN + 衰减校准 + 质量回溯),默认 cluster_only
mux.HandleFunc("/api/v1/admin/consolidate", func(w http.ResponseWriter, r *http.Request) {
task := r.URL.Query().Get("task")
if task == "" {
task = "cluster_only"
}
// full 模式给 5 分钟;cluster_only 保持 30 秒
timeout := 30 * time.Second
if task == "full" {
timeout = 5 * time.Minute
}
ctx, cancel := context.WithTimeout(r.Context(), timeout)
defer cancel()
done := make(chan *routes.ConsolidationReport, 1)
go func() {
report, _ := consolPipe.RunWithMode(task)
done <- report
}()
select {
case <-ctx.Done():
respondJSON(w, 408, map[string]string{"error": "consolidation timeout", "task": task})
return
case report := <-done:
if report == nil {
respondJSON(w, 500, map[string]string{"error": "consolidation failed", "task": task})
return
}
respondJSON(w, 200, report)
go governance.PushDistillationComplete(report.Merged, report.ConflictsFound)
}
})
mux.HandleFunc("/api/v1/admin/forget", adminAPI.Forget)
mux.HandleFunc("/api/v1/admin/purge-invalid", adminAPI.PurgeInvalid)
mux.HandleFunc("/api/v1/admin/dedup", api.Dedup)
mux.HandleFunc("/api/v1/admin/backup", adminAPI.Backup)
mux.HandleFunc("/api/v1/admin/restore", adminAPI.Restore)
mux.HandleFunc("/api/v1/admin/backups", adminAPI.ListBackups)
mux.HandleFunc("/api/v1/admin/audit", adminAPI.Audit)
// 遗忘器类型管理
mux.HandleFunc("/api/v1/admin/forgetter/type", func(w http.ResponseWriter, r *http.Request) {
var req struct {
AgentType string `json:"agent_type"`
}
if r.Method == "POST" {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
forgetter.SetAgentType(req.AgentType)
}
respondJSON(w, 200, map[string]interface{}{
"agent_type": forgetter.AgentType(),
"decay_rate": AgentTypeDecayOrDefault(forgetter.AgentType()),
})
})
mux.HandleFunc("/api/v1/admin/distill/force", func(w http.ResponseWriter, r *http.Request) {
if routes.DistillEngineRef == nil {
respondJSON(w, 400, map[string]string{"error": "distill engine not initialized"})
return
}
var req struct {
Content string `json:"content"`
Category string `json:"category"`
Namespace string `json:"namespace"`
AgentID string `json:"agent_id"`
// 2026-09-26:手动蒸馏也支持带出处(便于补跑 wiki 批时保留笔记路径)
Source string `json:"source"`
// 2026-10-08 (Gap#2):手动蒸馏同样可显式声明信任等级(补跑存量时用)
Confidence *float64 `json:"confidence"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if req.Content == "" {
respondJSON(w, 400, map[string]string{"error": "content required"})
return
}
if req.Namespace == "" {
req.Namespace = "hermes-main"
}
if req.AgentID == "" {
req.AgentID = "hermes-a06"
}
epID := fmt.Sprintf("ep_manual_%d", time.Now().UnixNano())
routes.AutoDistillTrigger(epID, req.Content, req.Category, req.Namespace, req.AgentID, routes.CommitMeta{
Source: req.Source,
Confidence: req.Confidence,
ConfidenceSource: confidenceSourceOf(req.Confidence),
})
respondJSON(w, 200, map[string]string{"status": "queued", "episode_id": epID})
})
// 蒸馏队列状态端点(G8-G9 配套)
mux.HandleFunc("/api/v1/distill/status", func(w http.ResponseWriter, r *http.Request) {
if routes.DistillEngineRef == nil {
respondJSON(w, 200, map[string]interface{}{"status": "not_initialized", "queue_len": 0})
return
}
respondJSON(w, 200, routes.DistillEngineRef.GetStatus())
})
mux.HandleFunc("/api/v1/distill/queue", func(w http.ResponseWriter, r *http.Request) {
if routes.DistillEngineRef == nil {
respondJSON(w, 200, map[string]interface{}{"queue": []interface{}{}, "count": 0})
return
}
items := routes.DistillEngineRef.QueueItems()
respondJSON(w, 200, map[string]interface{}{"queue": items, "count": len(items)})
})
mux.HandleFunc("/api/v1/distill/quota", func(w http.ResponseWriter, r *http.Request) {
if routes.DistillEngineRef == nil {
respondJSON(w, 200, map[string]interface{}{"remaining": 0, "used": 0, "limit": 0, "status": "not_initialized"})
return
}
respondJSON(w, 200, routes.DistillEngineRef.GetQuota())
})
mux.HandleFunc("/api/v1/distilled/{id}", func(w http.ResponseWriter, r *http.Request) {
if r.Method == "DELETE" {
adminAPI.DeleteDistilled(w, r)
} else {
http.NotFound(w, r)
}
})
// P2: 记忆离线整合(LightMem UPDATE_PROMPT)— 手动触发
// POST /api/v1/consolidate/memory {"namespace":"hermes-main"}
mux.HandleFunc("/api/v1/consolidate/memory", func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "POST only", 405)
return
}
if routes.DistillEngineRef == nil {
respondJSON(w, 400, map[string]string{"error": "distill engine not initialized"})
return
}
var req struct {
Namespace string `json:"namespace"`
Limit int `json:"limit"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
if req.Namespace == "" {
req.Namespace = "hermes-main"
}
if req.Limit <= 0 {
req.Limit = 50
}
// 取全部记忆(零向量搜索)
zeroVec := make([]float32, 1024)
mems, err := ldb.Search("memories", zeroVec, req.Limit, req.Namespace)
if err != nil || len(mems) == 0 {
respondJSON(w, 200, map[string]interface{}{"status": "ok", "processed": 0, "message": "no memories to consolidate"})
return
}
// 两两比较相似度(简单 Jaccard/词重叠启发式,避免调用嵌入)
// 真实场景:应使用向量相似度,此处用词重叠近似
processed := 0
updated := 0
deleted := 0
ignored := 0
errors_ := 0
// 按目标分组整合:外层 i 为目标,内层收集所有高相似候选,
// 每个目标只调一次 LLM(UpdatePrompt 原生支持多候选),避免 O(n²) LLM 调用风暴。
// 2026-09-06 fix(t_04 ④): 原实现每对相似记忆调一次 LLM → limit=100 产生 4950 对
// → 跑 223s+ 未完成 → 每天 processed=0(不是 namespace 过滤 bug)。
for i := 0; i < len(mems); i++ {
// 收集所有与 mems[i] 高相似的候选(j > i 且未处理)
var cands []distill.MemoryCandidate
for j := i + 1; j < len(mems); j++ {
if mems[j].Content == "" {
continue
}
sim := distill.TextSimilarity(mems[i].Content, mems[j].Content)
if sim < 0.5 {
continue
}
cands = append(cands, distill.MemoryCandidate{ID: mems[j].ID, Content: mems[j].Content})
}
if len(cands) == 0 {
continue
}
// 一次 LLM 决策:目标 mems[i] + 全部高相似候选
result, err := routes.DistillEngineRef.ConsolidateMemory(
distill.MemoryCandidate{ID: mems[i].ID, Content: mems[i].Content},
cands,
)
if err != nil {
errors_++
continue
}
processed++
switch result.Action {
case "update":
if result.NewMemory != "" {
if err := ldb.UpdateMemoryContent(mems[i].ID, result.NewMemory, "consolidate"); err != nil {
log.Printf("[consolidate] update failed %s: %v", mems[i].ID, err)
} else {
updated++
}
}
case "delete":
if err := ldb.SoftDelete(mems[i].ID, "consolidate: conflict"); err != nil {
log.Printf("[consolidate] delete failed %s: %v", mems[i].ID, err)
} else {
deleted++
}
default:
ignored++
}
}
respondJSON(w, 200, map[string]interface{}{
"status": "ok",
"processed": processed,
"updated": updated,
"deleted": deleted,
"ignored": ignored,
"errors": errors_,
"total_memories": len(mems),
})
})
// GET /api/v1/memories — list all memories (zero-vector search, for plugin compat)
mux.HandleFunc("/api/v1/memories", adminAPI.ListMemories)
mux.HandleFunc("/api/v1/memories/export", adminAPI.ExportMemoriesMD)
// /api/v1/memory/{id}/versions — existing version history endpoint
// t_0f1d7be9:PathValue("id") 只在模式路由下生效,前缀路由手动解析(旧写法恒取到
// 空 id 是历史遗留,Versions 因此从未按 id 过滤过 —— 本行保持行为不变,仅注释留痕)。
mux.HandleFunc("/api/v1/memory/", func(w http.ResponseWriter, r *http.Request) {
// t_0f1d7be9(原文层):/{id}/raw 下钻分派(GET only,其余仍走 Versions)
if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/raw") {
id := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/api/v1/memory/"), "/raw")
if id != "" && !strings.Contains(id, "/") {
r.SetPathValue("id", id)
api.MemoryRaw(w, r)
return
}
}
if r.Method == "GET" {
adminAPI.Versions(w, r)
} else {
http.NotFound(w, r)
}
})
// L3
mux.HandleFunc("/api/v1/l3/worldmodel", func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" {
l3API.GetHandler(w, r)
} else if r.Method == "POST" {
l3API.UpdateHandler(w, r)
} else {
http.NotFound(w, r)
}
})
// 触发器
mux.HandleFunc("/api/v1/triggers", routes.Triggers.List)
mux.HandleFunc("/api/v1/triggers/fire", routes.Triggers.Fire)
// 触发器 pause/resume — 手动解析路径
mux.HandleFunc("/api/v1/admin/triggers/", func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
// 格式: /api/v1/admin/triggers/{id}/{action}
parts := strings.Split(strings.TrimPrefix(path, "/api/v1/admin/triggers/"), "/")
if len(parts) != 2 {
http.NotFound(w, r)
return
}
id, action := parts[0], parts[1]
// 构造一个新请求,让 Pause/Resume 能通过 PathValue 读取
r.SetPathValue("id", id)
switch action {
case "pause":
routes.Triggers.Pause(w, r)
case "resume":
routes.Triggers.Resume(w, r)
default:
http.NotFound(w, r)
}
})
// Skills(G7)
mux.HandleFunc("/api/v1/skills", routes.Skills.List)
mux.HandleFunc("POST /api/v1/skills", routes.Skills.Register) // 注册新 skill
mux.HandleFunc("/api/v1/skills/stats", routes.Skills.Stats) // skill 统计
mux.HandleFunc("/api/v1/skills/bayes", func(w http.ResponseWriter, r *http.Request) {
list := routes.BayesianSkills.List()
respondJSON(w, 200, list)
})
mux.HandleFunc("/api/v1/skills/{name}", skillByNameHandler) // GET/DELETE /api/v1/skills/{name}
mux.HandleFunc("/api/v1/skills/{name}/trial", routes.Skills.Trial)
mux.HandleFunc("/api/v1/skills/{name}/execute", routes.ExecuteSkill) // G7.3 skill执行
// G7.2: 结晶路由
mux.HandleFunc("/api/v1/crystallize/candidates", routes.GetSkillCandidates) // 获取候选
mux.HandleFunc("/api/v1/crystallize/memory/{id}", routes.CrystallizeSkill) // 对记忆执行结晶
// 评估 + 自调参
mux.HandleFunc("/api/v1/eval/run", evalAPI.Run)
mux.HandleFunc("/api/v1/eval/history", evalAPI.History)
mux.HandleFunc("/api/v1/eval/generate", evalAPI.Generate)
mux.HandleFunc("/api/v1/eval/golden", evalAPI.RunGolden)
mux.HandleFunc("/api/v1/tuning/status", routes.Tuner.StatusHandler)
mux.HandleFunc("/api/v1/tuning/run", routes.Tuner.RunHandler)
mux.HandleFunc("/api/v1/tuning/analytics", routes.Tuner.AnalyticsHandler)
// 仪表盘 + 验证 + V值 + 缓存 + 流水线
mux.HandleFunc("/api/v1/metrics/self", func(w http.ResponseWriter, r *http.Request) {
metrics := selfoptimize.Dash.Metrics()
respondJSON(w, 200, metrics)
})
mux.HandleFunc("/api/v1/validate/passive", func(w http.ResponseWriter, r *http.Request) {
records := selfoptimize.Validator.GetRecords()
respondJSON(w, 200, records)
})
mux.HandleFunc("/api/v1/vvalue/decisions", func(w http.ResponseWriter, r *http.Request) {
decisions := selfoptimize.VProp.ListRecentDecisions(20)
respondJSON(w, 200, decisions)
})
// G5.3: V 值查询 + 触发反向传播
mux.HandleFunc("/api/v1/vvalue/memory/", func(w http.ResponseWriter, r *http.Request) {
memID := strings.TrimPrefix(r.URL.Path, "/api/v1/vvalue/memory/")
if memID == "" {
respondJSON(w, 400, map[string]string{"error": "memory_id required"})
return
}
v := selfoptimize.VProp.GetMemVValue(memID)
respondJSON(w, 200, map[string]interface{}{"memory_id": memID, "v_value": v})
})
mux.HandleFunc("/api/v1/trace/vprop", func(w http.ResponseWriter, r *http.Request) {
var req struct {
MemoryIDs []string `json:"memory_ids"`
Action string `json:"action"`
Outcome string `json:"outcome"`
ParentID string `json:"parent_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err == nil && len(req.MemoryIDs) > 0 {
id := fmt.Sprintf("trace_%d", time.Now().UnixNano())
selfoptimize.VProp.RecordDecision(id, req.MemoryIDs, req.Action, req.Outcome, req.ParentID)
// 更新相关记忆的 quality_score
for _, mid := range req.MemoryIDs {
v := selfoptimize.VProp.GetMemVValue(mid)
lamb := 0.1
if ldb, ok := ldb.(interface {
UpdateQualityScore(id string, delta float64) error
}); ok {
_ = ldb.UpdateQualityScore(mid, lamb*v)
}
}
respondJSON(w, 200, map[string]string{"status": "propagated"})
return
}
respondJSON(w, 400, map[string]string{"error": "memory_ids and outcome required"})
})
mux.HandleFunc("/api/v1/admin/cache", func(w http.ResponseWriter, r *http.Request) {
stats := storage.SearchCacheInstance.Stats()
respondJSON(w, 200, stats)
})
mux.HandleFunc("/api/v1/admin/pipeline", func(w http.ResponseWriter, r *http.Request) {
stats := selfoptimize.Flow.Stats()
respondJSON(w, 200, stats)
})
// G6: ephemeral namespace 管理
mux.HandleFunc("/api/v1/admin/ephemeral/clean", func(w http.ResponseWriter, r *http.Request) {
now := time.Now().UnixMilli()
agents := agentRegistry.ListAgents()
var cleaned []string
for _, agent := range agents {
if now-agent.LastSeen > 5*60*1000 {
ns := agent.AgentID + "-ephemeral"
zeroVec := make([]float32, 1024)
memories, _ := ldb.Search("memories", zeroVec, 1000, ns)
for _, mem := range memories {
if err := ldb.SoftDelete(mem.ID, "ephemeral_expired"); err != nil {
log.Printf("[softdelete] failed id: %v", err)
}
}
if len(memories) > 0 {
cleaned = append(cleaned, ns)
log.Printf("[ephemeral] cleaned %d memories from %s (inactive for %ds)",
len(memories), ns, (now-agent.LastSeen)/1000)
}
}
}
respondJSON(w, 200, map[string]interface{}{
"status": "cleaned",
"cleaned_namespaces": cleaned,
})
})
// Obsidian
mux.HandleFunc("/api/v1/obsidian/push", obsidian.PushHandler)
mux.HandleFunc("/api/v1/obsidian/pull", obsidian.PullHandler)
mux.HandleFunc("/api/v1/obsidian/status", obsidian.StatusHandler)
// 内部分支:跨 Agent 缓存失效回调
mux.HandleFunc("/_internal/cache/invalidate", func(w http.ResponseWriter, r *http.Request) {
var evt struct {
Payload map[string]string `json:"payload"`
}
if err := json.NewDecoder(r.Body).Decode(&evt); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
ns := evt.Payload["namespace"]
storage.SearchCacheInstance.Invalidate(ns)
log.Printf("[cache] 内部跨 Agent 失效: %s", ns)
w.WriteHeader(200)
})
// ─── Redis 组件 ─────────────────────────────
heartbeat := storage.NewHeartbeat("zhiyid-primary")
_ = heartbeat // 后台 goroutine 自动心跳
// ─── 后台引擎启动 ──────────────────────────
selfoptimize.RegisterCommitFlow(selfoptimize.Flow)
selfoptimize.RegisterRecallFlow(selfoptimize.Flow)
selfoptimize.RegisterGapFlow(selfoptimize.Flow)
selfoptimize.RegisterCorrectFlow(selfoptimize.Flow)
selfoptimize.RegisterConsolidateFlow(selfoptimize.Flow)
// 注册真正的 consolidate 处理器:调用 ConsolidationPipeline
selfoptimize.Flow.Register("consolidate", func(task *selfoptimize.PipelineTask) error {
report, err := consolPipe.RunWithMode("full")
if err == nil && report != nil {
// 注入 consolidate 报告 → Dashboard
if report.Merged > 0 {
// 合并启发式: 按合并数比例缩放, 但限定单次贡献 ≤ 0.05, 防止污染 avg_distill_loss
loss := float64(report.Merged) * 0.002
if loss > 0.05 {
loss = 0.05
}
selfoptimize.Dash.RecordDistillLoss(loss)
}
if report.ConflictsFound > 0 {
selfoptimize.Dash.RecordConflictResolved(true)
}
for _, p := range report.Patterns {
if len(p) > 13 && p[:13] == "quality_score" {
// 解析 quality_score=0.50 → 注入 avg_distill_loss
var qs float64
if _, e := fmt.Sscanf(p, "quality_score=%f", &qs); e == nil {
selfoptimize.Dash.RecordDistillLoss(1.0 - qs)
}
}
}
}
return err
})
// 注册 conflict_muchen 处理器:自动裁决低信任差异冲突
selfoptimize.Flow.Register("conflict_muchen", func(task *selfoptimize.PipelineTask) error {
conflicts := conflictAPI.Detector.ListActive()
for _, c := range conflicts {
if c.Status != "pending" {
continue
}
// 按策略自动裁决:latest_wins / primary_wins
resolution := conflictAPI.Detector.AutoResolve(c)
if resolution == "pending" {
// 2026-09-20 修复(链路空转):**不再静默 dismiss**。
//
// 原实现把未匹配策略的冲突改写成 "dismiss" 再 Resolve,且记成
// RecordConflictResolved(true) —— 后果是每条 pending 冲突最多活 10 分钟
// 即消失、auto_resolve_rate 指标虚高,冲突从不进入任何人视野
// (实测:17:19 新建冲突,同进程几分钟后 pending:0,未重启)。
//
// 现改为:72 小时内保留 pending 供人工处置;超期才按 latest_wins 自动收敛
// (这是真裁决,才记指标)。保留窗口内不再有任何自动处置。
if time.Since(c.CreatedAt) < 72*time.Hour {
continue
}
resolution = "latest_wins"
}
if err := conflictAPI.Detector.Resolve(c.ID, resolution, ""); err == nil {
selfoptimize.Dash.RecordConflictResolved(true)
}
}
return nil
})
go selfoptimize.Flow.Start()
selfoptimize.Executor.Start(selfoptimize.Flow)
// G6: ephemeral 定时清理(每 5 分钟扫描一次,清理断开超过 5 分钟的 agent 的 ephemeral 记忆)
go func() {
for {
time.Sleep(5 * time.Minute)
now := time.Now().UnixMilli()
agents := agentRegistry.ListAgents()
for _, agent := range agents {
if now-agent.LastSeen > 5*60*1000 {
ns := agent.AgentID + "-ephemeral"
zeroVec := make([]float32, 1024)
memories, _ := ldb.Search("memories", zeroVec, 1000, ns)
for _, mem := range memories {
if err := ldb.SoftDelete(mem.ID, "ephemeral_expired"); err != nil {
log.Printf("[softdelete] failed id: %v", err)
}
}
if len(memories) > 0 {
log.Printf("[ephemeral] cleaned %d memories from %s (inactive for %ds)",
len(memories), ns, (now-agent.LastSeen)/1000)
}
}
}
}
}()
// 质量下降监控:每 30 分钟扫描所有低质量记忆
go func() {
for {
time.Sleep(30 * time.Minute)
zeroVec := make([]float32, 1024)
memories, err := ldb.Search("memories", zeroVec, 2000, "")
if err != nil || len(memories) == 0 {
continue
}
alerts := 0
for _, m := range memories {
if m.QualityScore > 0 && m.QualityScore < 0.3 {
if record := selfoptimize.QualityMonitor.Check(m.ID, m.QualityScore, 2); record != nil {
alerts++
if record.Status == "deprecating" {
log.Printf("[quality] memory %s quality=%.2f deprecating", m.ID[:20], m.QualityScore)
}
}
}
}
if alerts > 0 {
log.Printf("[quality] scan: %d low-quality memories flagged", alerts)
}
}
}()
// 图持久化 — 确保启动后写入
if saver, ok := graphStore.(interface{ Save() error }); ok {
saver.Save()
}
// ─── 触发器自动执行循环(每 30 秒检查一次)────────
go func() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for range ticker.C {
// t_cf680fad:每个 tick 先 drain durable pending-softdelete 队列(风暴退去后的
// 相对静默窗口重放,必落盘)。队列空 = 廉价 no-op,不打日志;仅在有待处理时出声。
if n := storage.PendingSoftDeleteCount(); n > 0 {
storage.DrainPendingSoftDeletes(ldb)
}
for _, t := range []struct{ id, action string }{
{"t_distill", "distill"},
{"t_merge", "merge"},
{"t_prune", "prune"},
{"t_decay", "decay"},
{"t_backtrack", "backtrack"},
{"t_gap", "gap_scan"},
{"t_consolidation", "consolidation"},
} {
if !routes.Triggers.CanFire(t.id) {
continue
}
routes.Triggers.RecordFire(t.id)
go func(triggerID, action string) {
var err error
switch action {
case "distill":
// distill 保持 cluster_only(高频触发,只做快速聚类)
_, err = consolPipe.Run()
case "consolidation", "backtrack":
// full 模式:含 prune + decay + 质量回溯(Step 4)
// 48h cooldown 保护,不会真正每分钟跑 full(cooldown 内 CanFire 仍返回 false)
_, err = consolPipe.RunWithMode("full")
// 2026-09-21 (t_4ce53193):冲突去重 + 保留策略
// 去重:同 (L,R) 对只留最早一条,累加 dup_count,归档 + 审计
conflictDetector.DedupeActive(governance.DefaultConflictRetentionConfig())
// 保留:pending > 7d 无人处置 → 归档(不删除)
conflictDetector.RetainAndArchive(governance.DefaultConflictRetentionConfig())
case "merge":
_, err = consolPipe.RunMerge()
case "prune":
graphStore.Prune(0.15)
case "decay":
// 扫描所有记忆,按衰减率计算 freshness
// 2026-09-06 P1 fix: 每 tick 限量评估(maxDecayBatch),优先处理
// 最久未访问的(last_recalled_at 最早 → 最该遗忘),防全表单次拉取
// 内存翻倍 + 长时间阻塞触发器循环。
memories, e := ldb.GetCandidatesForForgetting()
decayScanned := 0
decayForgotten := 0
decayProtected := 0
if e == nil {
// 2026-10-16 (t_944344b5) 遗忘保护守卫:本 tick 加载一次保护状态
// (Redis zhiyi:vprop_values + zhiyi:protected_ids),命中任一保护规则的
// 候选永不自动软删(fragment 与 ShouldForget 两条道都拦截)。
// Redis 宕机 → 降级为纯行内规则(category/freshness/关键词),不阻断衰减。
protState := memprotect.LoadState()
const maxDecayBatch = 2000
if len(memories) > maxDecayBatch {
sort.SliceStable(memories, func(a, b int) bool {
return memoryTimeVal(memories[a], "last_recalled_at", "created_at") <
memoryTimeVal(memories[b], "last_recalled_at", "created_at")
})
memories = memories[:maxDecayBatch]
}
for _, m := range memories {
decayScanned++
// 🔒 2026-09-07 修复(误删事故): episodes(原始对话)不参与遗忘——
// 它们是蒸馏原料/审计历史, 不是可遗忘的记忆。曾因未过滤 category
// 导致 67 条 8月对话被 auto_forget 清掉(已恢复)。仅 distilled/general 等记忆可遗忘。
if cat, ok := m["category"].(string); ok && cat == "episodes" {
continue
}
// 🔒 长内容保护: distilled >200 字(有实质信息)不参与 auto 遗忘
// (曾误删 405 字 CNB 修复经验; 长记忆应由蒸馏/整合管理, 非时间遗忘)
if cat, _ := m["category"].(string); cat != "episodes" {
if cs, ok := m["content"].(string); ok && len([]rune(cs)) > 200 {
continue
}
}
// 🔒 2026-10-16 (t_944344b5) 遗忘保护守卫: 命中任一保护规则的候选
// (category∈{decision,preference,pref} / V≥2.0 / freshness=verified /
// protected_ids 名单 / distilled 且内容命中 10 个决策·偏好关键词) 永不自动软删。
// 拦截 point 放在 fragment + ShouldForget 两条 SoftDelete 道之前。
if isProt, protRule := memprotect.IsProtected(m, protState); isProt {
gid, _ := m["id"].(string)
decayProtected++
log.Printf("[decay] protected skip id=%q rule=%s (遗忘保护守卫命中, 跳过自动遗忘)", gid, protRule)
continue
}
// 解析 last_recalled_at
lastAccessed := time.Now().Add(-30 * 24 * time.Hour) // 默认30天前
if t, ok := m["last_recalled_at"].(string); ok && t != "" {
if parsed, err := time.Parse(time.RFC3339, t); err == nil {
lastAccessed = parsed
}
}
// 2026-09-07 方案A 碎片快速道: <30 字且 >20 天未访问 → 直接遗忘
// (绕过 ShouldForget 的 recallCount 保命——碎片 recall_count 可能虚高)。
// 碎片无记忆价值(审计: 30.9% <20字 + 43% <50字 = 蒸馏无门槛产物)。
contentStr := ""
if cs, ok := m["content"].(string); ok {
contentStr = cs
}
if len([]rune(contentStr)) < 30 && time.Since(lastAccessed).Hours()/24 > 20 {
// 🔴 2026-10-06:`ok` 断言会放行空串(DB 存在 id 全空的垃圾行)⇒ SoftDelete("")
// 被 Rust 拒绝、错误被吞 ⇒ 每轮刷错误日志 + 计数虚高。补 id != "" 且失败不计数。
if id, ok := m["id"].(string); ok && id != "" {
if err := ldb.SoftDelete(id, "auto_forget_fragment"); err != nil {
log.Printf("[decay] softdelete failed id=%q: %v", id, err)
} else {
decayForgotten++
}
}
continue
}
// GA-P0-2 (t_c6d41ac6):衰减/遗忘只认 recall_verified_count(正反馈才是 V 值)。
// recall_count 已冻结(历史虚高保命来源),seen 只用于观测。
recallCount := 0
if rc, ok := m["recall_verified_count"].(int); ok {
recallCount = rc
}
tier := "normal"
if ts, ok := m["tier"].(string); ok {
tier = ts
}
// E4.3: 获取实体图谱度(namespace 作为实体)
entity := ""
if ns, ok := m["namespace"].(string); ok {
entity = ns
}
degree := 0
if entity != "" {
degree = graphStore.GetEntityDegree(entity)
}
if forgetter.ShouldForget(lastAccessed, recallCount, tier, degree) {
if id, ok := m["id"].(string); ok && id != "" {
forgetter.ScanAndForget(id)
// 实际执行软删除(持久化到 LanceDB)
if err := ldb.SoftDelete(id, "auto_forget"); err != nil {
log.Printf("[decay] softdelete failed id=%q: %v", id, err)
} else {
decayForgotten++
}
}
}
}
}
// AC-1 验收日志(P1 修复 type 断言后 last_recalled_at 可解析;batch<=2000)
if decayScanned > 0 {
// 2026-10-16 (t_944344b5):protected_skip = 被遗忘保护守卫拦截、未软删的候选数
log.Printf("[decay] scanned=%d forgotten=%d protected_skip=%d (batch<=2000, 最久未访问优先)", decayScanned, decayForgotten, decayProtected)
}
case "gap_scan":
// 检查已有缺口:过期 7 天的自动关闭
gaps := gapDetector.List()
for _, g := range gaps {
if !g.Closed && time.Since(g.CreatedAt) > 7*24*time.Hour {
gapDetector.Close(g.Topic)
}
}
// 自动修复开放的缺口
for _, g := range gaps {
if !g.Closed && routes.GapRepair != nil {
if routes.GapRepair.AutoRepair(g) {
// t_e5997ca4:AutoRepair 只做「修复」不关缺口。旧代码在此
// 无条件 Dash.RecordGapClosed() 却不置 g.Closed ⇒ 同一缺口每轮
// gap_scan 都再 +1(ClosedGaps 无限虚增,线上 9 vs 2 的写侧根因 #2)。
// 现按 RepairHandler 语义:修复成功 = 关闭该缺口;+1 只发生在
// Close 的 open→closed 迁移点(内部),下一轮 !g.Closed 即跳过。
gapDetector.Close(g.Topic)
}
}
}
}
if err != nil {
routes.Triggers.RecordFail(triggerID)
}
// Skill 结晶:每次蒸馏成功后记录
if action == "distill" || action == "consolidation" {
routes.Skills.RecordTrial("auto_distill", true)
}
routes.WSBus.Broadcast("trigger.fired", map[string]string{
"trigger_id": triggerID,
"action": action,
})
}(t.id, t.action)
}
}
}()
// 自优化指标定时采集(每 30 分钟)
go func() {
for {
time.Sleep(30 * time.Minute)
m := selfoptimize.Dash.Metrics()
date := time.Now().Format("2006-01-02")
for k, v := range m {
storage.GlobalMetricsStore.Set(date, k, v)
}
}
}()
// 首次指标采集
m := selfoptimize.Dash.Metrics()
date := time.Now().Format("2006-01-02")
for k, v := range m {
storage.GlobalMetricsStore.Set(date, k, v)
}
// Prometheus 指标定时同步(每 15s 采集一次系统指标)
go func() {
for {
time.Sleep(15 * time.Second)
// 同步 Dashboard → Prometheus gauges
metrics.SyncFromDashboard(selfoptimize.Dash.Metrics())
// 采集 SQLite 数据库文件大小(Linux only)
if runtime.GOOS != "windows" {
gPath := os.Getenv("GRAPH_PATH")
if gPath == "" {
gPath = "/var/lib/memoryweave/graph.db"
}
if fi, err := os.Stat(gPath); err == nil {
metrics.SQLiteDBSizeBytes.Set(float64(fi.Size()))
}
}
// 采集进程 RSS 内存
if data, err := os.ReadFile("/proc/self/status"); err == nil {
for _, line := range strings.Split(string(data), "\n") {
if strings.HasPrefix(line, "VmRSS:") {
// VmRSS: 12345 kB
f := strings.Fields(line)
if len(f) >= 2 {
if kb, err := strconv.ParseFloat(f[1], 64); err == nil {
metrics.MemoryRSSBytes.Set(kb * 1024)
}
}
break
}
}
}
}
}()
log.Println("[zhiyid] 多 Agent 架构 — Redis + FileGraph + EventBus + vLLM — 已启动")
// Web UI 静态文件根目录
webUIRoot = os.Getenv("ZHIYI_WEB_UI_ROOT")
if webUIRoot == "" {
webUIRoot = "../web-ui"
}
// Catch-all: 优先检查静态文件,再 JSON
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
p := r.URL.Path
// 静态文件服务(安全检查,不允许 .. 遍历)
if p != "/" && !strings.Contains(p, "..") {
filePath := webUIRoot + p
if _, err := os.Stat(filePath); err == nil {
http.ServeFile(w, r, filePath)
return
}
}
// 根路径 → index.html
if p == "/" || p == "" {
if _, err := os.Stat(webUIRoot + "/index.html"); err == nil {
http.ServeFile(w, r, webUIRoot+"/index.html")
return
}
respondJSON(w, 200, map[string]interface{}{
"service": "zhiyid", "status": "ok", "version": "0.1.0",
})
return
}
respondJSON(w, 404, map[string]interface{}{
"error": "endpoint not found: " + p,
})
})
return middleware.CORS()(middleware.Auth(mux))
}
// ─── 辅助 ──────────────────────────────────────────
func respondJSON(w http.ResponseWriter, code int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(data)
}
func AgentTypeDecayOrDefault(agentType string) float64 {
if rate, ok := governance.AgentTypeDecay[agentType]; ok {
return rate
}
return 0.015
}
// memoryTimeVal 取记忆时间戳用于排序(主 key 优先,空则回退副 key;均空返回 0)。
// 2026-09-06 P1: decay 分批按"最久未访问优先"排序需要;兼容 RFC3339 string / time.Time / int。
func memoryTimeVal(m map[string]interface{}, primary, fallback string) int64 {
for _, key := range []string{primary, fallback} {
v, ok := m[key]
if !ok || v == nil {
continue
}
switch val := v.(type) {
case string:
if val == "" {
continue
}
if t, err := time.Parse(time.RFC3339, val); err == nil {
return t.Unix()
}
if n, err := strconv.ParseInt(val, 10, 64); err == nil {
return n
}
case time.Time:
if !val.IsZero() {
return val.Unix()
}
case float64:
return int64(val)
case int64:
return val
case int:
return int64(val)
}
}
return 0
}
// runStartupChecks 启动时数据目录一致性检查
// 检测废弃路径(如 /home/muc/data)并警告,防止数据源混乱
func runStartupChecks(backend string) {
canonicalDataDir := "/var/lib/memoryweave"
deprecatedPaths := []string{
"/home/muc/data",
"/home/muc/.local/share/memoryweave",
}
for _, dep := range deprecatedPaths {
if _, err := os.Stat(dep); err == nil {
log.Printf("[WARN] 检测到废弃数据目录 %s,仍有数据残留(当前 canonical: %s)", dep, canonicalDataDir)
}
}
// LanceDB 后端:检查 Rust sidecar socket 是否可达
if backend == "lancedb" {
sockPath := os.Getenv("LANCEDB_SOCKET")
if sockPath == "" {
sockPath = "/tmp/zhiyi-ipc.sock"
}
if _, err := os.Stat(sockPath); os.IsNotExist(err) {
log.Printf("[WARN] LanceDB socket 不存在 (%s),Rust sidecar 可能未运行", sockPath)
} else {
log.Printf("[startup] LanceDB socket 就绪: %s", sockPath)
}
}
// canonical 目录存在性检查
if _, err := os.Stat(canonicalDataDir); os.IsNotExist(err) {
log.Printf("[WARN] canonical 数据目录不存在: %s(首次部署?)", canonicalDataDir)
} else {
log.Printf("[startup] 数据目录正常: %s", canonicalDataDir)
}
}
// srcOrLLMDistill 2026-09-26:蒸馏产物来源判定。
// 有笔记出处(commit 时带 source/metadata.source,如 wiki_curator 传的 .md 路径)→ 标 "wiki:<路径>",
// 使 recall 回来的每条记忆都能追溯到自己来自哪篇笔记;无出处则沿用历史值 llm_distill。
func srcOrLLMDistill(src string) string {
if src != "" {
return "wiki:" + src
}
return "llm_distill"
}
// orDefault 血缘字段缺省兜底:空值 → 默认值。
// 2026-10-08 (t_7e3577c5):embedding_model→bge-m3,script_version→unknown,
// 使未发 metadata 的写入路径(worklog/cbm/会话蒸馏)也落非空血缘。
func orDefault(v, def string) string {
if v == "" {
return def
}
return v
}
// confidenceSourceOf 2026-10-08 (Gap#2):声明了信任等级 ⇒ authoritative;未声明 ⇒ 空
// (不写 "estimated"——estimated 专指「事后回填的猜测值」,语义上不该由写入路径产生)。
func confidenceSourceOf(c *float64) string {
if c != nil {
return "authoritative"
}
return ""
}
// ldbWriteOpsStore 适配 storage.LanceDB 到 distill.WriteOpsStore 接口。
// 供写时四操作的 apply 模式标记 superseded/conflict(t_3e98b65a)。
type ldbWriteOpsStore struct {
ldb storage.LanceDB
}
// MarkSuperseded 旧记忆标记「被 newID 取代」(写 superseded_by + is_deleted=true;
// 历史经 supersedes/superseded_by 保留可追溯,不物理删)。
func (s *ldbWriteOpsStore) MarkSuperseded(oldID, newID string) error {
b, _ := json.Marshal([]string{newID})
return s.ldb.Update("memories", oldID, map[string]any{
"superseded_by": string(b),
"is_deleted": true,
})
}
// MarkConflict 旧记忆标记冲突(t_e3f5cb79 · AC-1.2)。判定门(SameAttrValueConflict,
// 「同主体+同属性+不同取值」)在 distill.JudgeWriteOp 出口把守,走到这里的都是确认
// 矛盾 ⇒ 打标必须同写双向指针:旧行 superseded_by=[newID] + freshness=superseded
// (新值生效、旧值被取代的既有字段语义;新行 supersedes=[oldID] 由写路径在插入
// 记录上带)。不置 is_deleted —— 旧行仍可召回,排序侧降权属方案 A3,不在本卡。
func (s *ldbWriteOpsStore) MarkConflict(oldID, newID string) error {
b, _ := json.Marshal([]string{newID})
return s.ldb.Update("memories", oldID, map[string]any{
"freshness": "superseded",
"superseded_by": string(b),
})
}
// memIDFor 为写时四操作 apply 预生成新记忆 ID(与下方 InsertMemory 同格式)。
func memIDFor(fact string, input distill.DistillInput) string {
_ = fact
return fmt.Sprintf("mem_%d", time.Now().UnixNano())
}