2368 lines
96 KiB
Go
2368 lines
96 KiB
Go
// 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())
|
||
}
|