423 lines
12 KiB
Go
423 lines
12 KiB
Go
// 织忆 MemoryWeave — 管理端点
|
||
package routes
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"net/http"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/xiaoxue/memoryweave/internal/governance"
|
||
"github.com/xiaoxue/memoryweave/internal/storage"
|
||
)
|
||
|
||
type AdminAPI struct {
|
||
LanceDB storage.LanceDB
|
||
Forgetter *governance.Forgetter
|
||
GraphStore governance.GraphStore // E4.3: 图谱度参与遗忘决策
|
||
}
|
||
|
||
func NewAdminAPI(ldb storage.LanceDB, f *governance.Forgetter, gs governance.GraphStore) *AdminAPI {
|
||
return &AdminAPI{LanceDB: ldb, Forgetter: f, GraphStore: gs}
|
||
}
|
||
|
||
// DELETE /api/v1/distilled/{id}
|
||
func (aa *AdminAPI) DeleteDistilled(w http.ResponseWriter, r *http.Request) {
|
||
id := r.PathValue("id")
|
||
if id == "" {
|
||
respondError(w, 400, "id required")
|
||
return
|
||
}
|
||
if err := aa.LanceDB.SoftDelete(id, "manual_delete"); err != nil {
|
||
respondError(w, 500, "delete failed: "+err.Error())
|
||
return
|
||
}
|
||
respond(w, 200, map[string]string{"status": "deleted", "id": id})
|
||
}
|
||
|
||
// GET /api/v1/memory/{id}/versions
|
||
func (aa *AdminAPI) Versions(w http.ResponseWriter, r *http.Request) {
|
||
id := r.PathValue("id")
|
||
if id == "" {
|
||
respondError(w, 400, "id required")
|
||
return
|
||
}
|
||
versions, err := aa.LanceDB.GetVersionHistory(id)
|
||
if err != nil {
|
||
respondError(w, 500, "get versions failed: "+err.Error())
|
||
return
|
||
}
|
||
respond(w, 200, map[string]interface{}{
|
||
"memory_id": id, "versions": versions, "count": len(versions),
|
||
})
|
||
}
|
||
|
||
// POST /api/v1/admin/forget
|
||
func (aa *AdminAPI) Forget(w http.ResponseWriter, r *http.Request) {
|
||
results, err := aa.LanceDB.GetCandidatesForForgetting()
|
||
if err != nil {
|
||
respondError(w, 500, "list failed: "+err.Error())
|
||
return
|
||
}
|
||
|
||
forgotten := 0
|
||
for _, mem := range results {
|
||
lastAccess := parseTimeStr(mem["last_recalled_at"])
|
||
recallCnt := intVal(mem["recall_count"])
|
||
tier := strVal(mem["tier"])
|
||
content := strVal(mem["content"])
|
||
|
||
// E4.3 修复: 从 content 提取实体(而非读 namespace),取图谱最大度
|
||
degree := extractTopEntityDegree(content, aa.GraphStore)
|
||
|
||
if aa.Forgetter.ShouldForget(lastAccess, recallCnt, tier, degree) {
|
||
aa.LanceDB.SoftDelete(strVal(mem["id"]), "auto_forget")
|
||
forgotten++
|
||
}
|
||
}
|
||
respond(w, 200, map[string]interface{}{
|
||
"status": "ok", "scanned": len(results), "forgotten": forgotten,
|
||
})
|
||
}
|
||
|
||
// POST /api/v1/admin/backup
|
||
func (aa *AdminAPI) Backup(w http.ResponseWriter, r *http.Request) {
|
||
timestamp := time.Now().Format("20060102-150405")
|
||
backupDir := fmt.Sprintf("/home/muc/backups/memoryweave/%s", timestamp)
|
||
dataDir := "/var/lib/memoryweave"
|
||
if err := os.MkdirAll(backupDir, 0755); err != nil {
|
||
respondError(w, 500, "mkdir failed: "+err.Error())
|
||
return
|
||
}
|
||
|
||
// 1. SQLite backup: graph.db
|
||
graphBackup := filepath.Join(backupDir, "graph.db")
|
||
if err := exec.Command("sqlite3", filepath.Join(dataDir, "graph.db"), ".backup "+graphBackup).Run(); err != nil {
|
||
respondError(w, 500, "graph.db backup failed: "+err.Error())
|
||
return
|
||
}
|
||
|
||
// 2. SQLite backup: memoryweave.db
|
||
mwBackup := filepath.Join(backupDir, "memoryweave.db")
|
||
if err := exec.Command("sqlite3", filepath.Join(dataDir, "memoryweave.db"), ".backup "+mwBackup).Run(); err != nil {
|
||
respondError(w, 500, "memoryweave.db backup failed: "+err.Error())
|
||
return
|
||
}
|
||
|
||
// 3. Redis BGSAVE (fire-and-forget)
|
||
exec.Command("redis-cli", "BGSAVE").Run()
|
||
|
||
// 4. Tar LanceDB directories (5 min timeout — 5.5GB takes ~100s)
|
||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||
defer cancel()
|
||
if err := exec.CommandContext(ctx, "tar", "czf",
|
||
filepath.Join(backupDir, "lance-data.tar.gz"),
|
||
"-C", dataDir,
|
||
"episodes.lance", "memories.lance", "tombstones.lance",
|
||
).Run(); err != nil {
|
||
if ctx.Err() == context.DeadlineExceeded {
|
||
respondError(w, 500, "lance backup timed out after 5 minutes")
|
||
return
|
||
}
|
||
respondError(w, 500, "lance backup failed: "+err.Error())
|
||
return
|
||
}
|
||
|
||
respond(w, 200, map[string]string{
|
||
"status": "ok",
|
||
"path": backupDir,
|
||
"timestamp": timestamp,
|
||
})
|
||
}
|
||
|
||
// GET /api/v1/admin/backups — 列出可用备份
|
||
func (aa *AdminAPI) ListBackups(w http.ResponseWriter, r *http.Request) {
|
||
backupRoot := "/home/muc/backups/memoryweave"
|
||
entries, err := os.ReadDir(backupRoot)
|
||
if err != nil {
|
||
respondError(w, 500, "read dir failed: "+err.Error())
|
||
return
|
||
}
|
||
|
||
var backups []map[string]string
|
||
for _, e := range entries {
|
||
if !e.IsDir() {
|
||
continue
|
||
}
|
||
ts := e.Name()
|
||
// 读 mtime 作为备份时间
|
||
info, _ := e.Info()
|
||
modTime := info.ModTime().Format(time.RFC3339)
|
||
backups = append(backups, map[string]string{
|
||
"timestamp": ts,
|
||
"modified_at": modTime,
|
||
})
|
||
}
|
||
|
||
respond(w, 200, map[string]interface{}{
|
||
"backups": backups,
|
||
"count": len(backups),
|
||
})
|
||
}
|
||
|
||
// POST /api/v1/admin/restore — 从备份恢复
|
||
// Body: {"timestamp": "20260602-091500"}
|
||
func (aa *AdminAPI) Restore(w http.ResponseWriter, r *http.Request) {
|
||
var req struct {
|
||
Timestamp string `json:"timestamp"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Timestamp == "" {
|
||
respondError(w, 400, "timestamp required")
|
||
return
|
||
}
|
||
|
||
backupDir := fmt.Sprintf("/home/muc/backups/memoryweave/%s", req.Timestamp)
|
||
if _, err := os.Stat(backupDir); os.IsNotExist(err) {
|
||
respondError(w, 404, "backup not found: "+req.Timestamp)
|
||
return
|
||
}
|
||
|
||
dataDir := "/var/lib/memoryweave"
|
||
|
||
// 1. 停止服务(先 Go 再 sidecar)
|
||
stop := func(svc string) error {
|
||
out, err := exec.Command("systemctl", "--user", "stop", svc).CombinedOutput()
|
||
if err != nil {
|
||
return fmt.Errorf("%s: %s", svc, string(out))
|
||
}
|
||
return nil
|
||
}
|
||
if err := stop("zhiyid.service"); err != nil {
|
||
respondError(w, 500, "stop zhiyid failed: "+err.Error())
|
||
return
|
||
}
|
||
if err := stop("zhiyi-sidecar.service"); err != nil {
|
||
respondError(w, 500, "stop sidecar failed: "+err.Error())
|
||
return
|
||
}
|
||
// 等进程退出
|
||
time.Sleep(2 * time.Second)
|
||
|
||
// 2. 清理旧数据(lances)
|
||
lanceTables := []string{"episodes.lance", "memories.lance", "tombstones.lance"}
|
||
for _, t := range lanceTables {
|
||
p := filepath.Join(dataDir, t)
|
||
os.RemoveAll(p + ".table")
|
||
os.RemoveAll(p)
|
||
}
|
||
|
||
// 3. 解压 LanceDB tar
|
||
tarPath := filepath.Join(backupDir, "lance-data.tar.gz")
|
||
if _, err := os.Stat(tarPath); err == nil {
|
||
cmd := exec.Command("tar", "xzf", tarPath, "-C", dataDir)
|
||
cmd.Dir = dataDir
|
||
if out, err := cmd.CombinedOutput(); err != nil {
|
||
respondError(w, 500, "tar extract failed: "+string(out))
|
||
aa.startServices()
|
||
return
|
||
}
|
||
}
|
||
|
||
// 4. 还原 SQLite
|
||
graphDst := filepath.Join(dataDir, "graph.db")
|
||
mwDst := filepath.Join(dataDir, "memoryweave.db")
|
||
if err := exec.Command("sqlite3", graphDst, ".restore "+filepath.Join(backupDir, "graph.db")).Run(); err != nil {
|
||
respondError(w, 500, "graph.db restore failed: "+err.Error())
|
||
aa.startServices()
|
||
return
|
||
}
|
||
if err := exec.Command("sqlite3", mwDst, ".restore "+filepath.Join(backupDir, "memoryweave.db")).Run(); err != nil {
|
||
respondError(w, 500, "memoryweave.db restore failed: "+err.Error())
|
||
aa.startServices()
|
||
return
|
||
}
|
||
|
||
// 5. 重启服务(先 sidecar 再 Go)
|
||
aa.startServices()
|
||
|
||
respond(w, 200, map[string]string{
|
||
"status": "ok",
|
||
"restored": req.Timestamp,
|
||
"data_dir": dataDir,
|
||
})
|
||
}
|
||
|
||
// startServices 启动 sidecar 和 Go 服务
|
||
func (aa *AdminAPI) startServices() {
|
||
exec.Command("systemctl", "--user", "start", "zhiyi-sidecar.service").Run()
|
||
time.Sleep(1 * time.Second)
|
||
exec.Command("systemctl", "--user", "start", "zhiyid.service").Run()
|
||
// 等待 API 就绪
|
||
for i := 0; i < 10; i++ {
|
||
time.Sleep(1 * time.Second)
|
||
if resp, err := http.Get("http://localhost:7821/api/v1/stats"); err == nil {
|
||
resp.Body.Close()
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
// GET /api/v1/admin/audit
|
||
func (aa *AdminAPI) Audit(w http.ResponseWriter, r *http.Request) {
|
||
limit := 100
|
||
logs, err := aa.LanceDB.GetAuditLog(limit)
|
||
if err != nil {
|
||
respondError(w, 500, "audit failed: "+err.Error())
|
||
return
|
||
}
|
||
respond(w, 200, map[string]interface{}{"audit_logs": logs, "count": len(logs)})
|
||
}
|
||
|
||
// ─── 辅助函数 ───────────────────────────────────────────────────────────────
|
||
|
||
func parseTimeStr(s interface{}) time.Time {
|
||
if s == nil {
|
||
return time.Time{}
|
||
}
|
||
switch v := s.(type) {
|
||
case string:
|
||
t, _ := time.Parse(time.RFC3339, v)
|
||
return t
|
||
case time.Time:
|
||
return v
|
||
}
|
||
return time.Time{}
|
||
}
|
||
|
||
func intVal(v interface{}) int {
|
||
switch n := v.(type) {
|
||
case int: return n
|
||
case int32: return int(n)
|
||
case int64: return int(n)
|
||
case float64: return int(n)
|
||
case json.Number:
|
||
i, _ := n.Int64()
|
||
return int(i)
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func strVal(v interface{}) string {
|
||
if v == nil {
|
||
return ""
|
||
}
|
||
switch s := v.(type) {
|
||
case string: return s
|
||
case json.Number: return s.String()
|
||
}
|
||
return fmt.Sprintf("%v", v)
|
||
}
|
||
|
||
// ─── E4.3: 图谱度提取 ──────────────────────────────────────────────────────
|
||
|
||
// extractTopEntityDegree 从 content 提取实体,返回图中度数最高的实体度数
|
||
// 复用 distill/engine.go 的启发式逻辑(大写单词、中文实体、技术标记)
|
||
func extractTopEntityDegree(content string, gs governance.GraphStore) int {
|
||
if content == "" || gs == nil {
|
||
return 0
|
||
}
|
||
seen := make(map[string]bool)
|
||
var candidates []string
|
||
|
||
words := strings.Fields(content)
|
||
for _, w := range words {
|
||
w = strings.Trim(w, ",.;:!?,。;:!?、\"'()()[]【】")
|
||
if len(w) < 2 {
|
||
continue
|
||
}
|
||
// 大写字母开头的英文词(Hermes, ComfyUI, Redis 等)
|
||
runes := []rune(w)
|
||
if len(runes) >= 2 && runes[0] >= 'A' && runes[0] <= 'Z' {
|
||
normalized := strings.ToLower(w)
|
||
if !seen[normalized] && !isStopWord(normalized) {
|
||
seen[normalized] = true
|
||
candidates = append(candidates, w)
|
||
}
|
||
}
|
||
// 中文实体(2-20 个纯中文字符)
|
||
cleanChinese := stripNonChinese(w)
|
||
if len(cleanChinese) >= 2 && len(cleanChinese) <= 20 {
|
||
if !seen[cleanChinese] {
|
||
seen[cleanChinese] = true
|
||
candidates = append(candidates, cleanChinese)
|
||
}
|
||
}
|
||
// 技术标记(数字+字母组合或纯数字)
|
||
if isTechToken(w) || isAllDigits(w) {
|
||
if !seen[w] {
|
||
seen[w] = true
|
||
candidates = append(candidates, w)
|
||
}
|
||
}
|
||
}
|
||
|
||
maxDegree := 0
|
||
for _, entity := range candidates {
|
||
d := gs.GetEntityDegree(entity)
|
||
if d > maxDegree {
|
||
maxDegree = d
|
||
}
|
||
}
|
||
return maxDegree
|
||
}
|
||
|
||
// isStopWord 停用词表(与 distill/engine.go 保持一致)
|
||
func isStopWord(w string) bool {
|
||
stops := []string{
|
||
"the", "and", "for", "are", "but", "not", "you", "all", "can", "had",
|
||
"her", "was", "one", "our", "out", "this", "that", "with", "from",
|
||
"your", "what", "when", "where", "which", "their", "will", "would",
|
||
"there", "could", "other", "into", "just", "has", "have", "were",
|
||
"they", "been", "more", "than",
|
||
}
|
||
for _, s := range stops {
|
||
if w == s {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// stripNonChinese 提取纯中文字符串
|
||
func stripNonChinese(s string) string {
|
||
var result []rune
|
||
for _, r := range s {
|
||
if r >= 0x4E00 && r <= 0x9FFF {
|
||
result = append(result, r)
|
||
}
|
||
}
|
||
return string(result)
|
||
}
|
||
|
||
// isTechToken 判断是否为技术标记(数字+字母混合)
|
||
func isTechToken(s string) bool {
|
||
hasDigit := false
|
||
hasLetter := false
|
||
for _, r := range s {
|
||
if r >= '0' && r <= '9' {
|
||
hasDigit = true
|
||
}
|
||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
|
||
hasLetter = true
|
||
}
|
||
}
|
||
return hasDigit && hasLetter
|
||
}
|
||
|
||
// isAllDigits 判断是否全为数字
|
||
func isAllDigits(s string) bool {
|
||
if len(s) == 0 {
|
||
return false
|
||
}
|
||
for _, r := range s {
|
||
if r < '0' || r > '9' {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
} |