feat: 日志持久化(6h归档) + purge增强(source_type/source_has_status过滤)

- ActivityRecorder 新增 LogPersister 后台线程,每10s增量写入logs/operations.*.log

- 日志每6小时自动gzip压缩归档

- memory_purge 新增 source_type / target_type / source_has_status 过滤条件

- 支持精准清理已归档任务的残留 HAS_STATE 关系
This commit is contained in:
root
2026-04-30 08:36:02 +08:00
parent 6749811e49
commit c046e6ff6a
7 changed files with 404 additions and 53 deletions

View File

@ -1,5 +1,5 @@
#!/bin/bash #!/bin/bash
set -e set -euo pipefail
echo "===== Building TrulyMEM AppImage =====" echo "===== Building TrulyMEM AppImage ====="
@ -11,39 +11,65 @@ echo "Project root: $PROJECT_ROOT"
APP_NAME="TrulyMEM" APP_NAME="TrulyMEM"
APP_DIR="$PROJECT_ROOT/build/appimage-build" APP_DIR="$PROJECT_ROOT/build/appimage-build"
echo "Cleaning..." # ── Step 1: 复用 build_linux.sh 完成 PyInstaller 构建 ──
rm -rf "$APP_DIR" dist/ build/trulymem/ 2>/dev/null || true echo ""
echo "Step 1: Running build_linux.sh (PyInstaller build)..."
bash "$SCRIPT_DIR/build_linux.sh"
# Step 1: Build with PyInstaller (uses icon from .spec) # ── Step 2: 检查 dist/ 中是否有二进制 ──
echo "Step 1: PyInstaller build..." echo ""
python3 -m PyInstaller --clean build/trulymem.spec --noconfirm echo "Step 2: Checking PyInstaller output..."
if [ ! -f "dist/$APP_NAME" ]; then
echo "Error: dist/$APP_NAME not found after build_linux.sh"; exit 1
fi
echo "✅ Found dist/$APP_NAME ($(ls -lh "dist/$APP_NAME" | awk '{print $5}'))"
# Step 2: Prepare AppDir structure # ── Step 3: 组织 AppDir 结构 ──
echo "Step 2: Preparing AppDir..." echo ""
echo "Step 3: Preparing AppDir structure..."
rm -rf "$APP_DIR" 2>/dev/null || true
mkdir -p "$APP_DIR/usr/bin" mkdir -p "$APP_DIR/usr/bin"
mkdir -p "$APP_DIR/usr/share/applications" mkdir -p "$APP_DIR/usr/share/applications"
mkdir -p "$APP_DIR/usr/share/icons/hicolor/256x256/apps" mkdir -p "$APP_DIR/usr/share/icons/hicolor/256x256/apps"
mkdir -p "$APP_DIR/usr/share/icons/hicolor/48x48/apps"
cp "dist/$APP_NAME" "$APP_DIR/usr/bin/" cp "dist/$APP_NAME" "$APP_DIR/usr/bin/"
# 图标处理
ICON_SOURCE=""
if [ -f "pic/image.png" ]; then if [ -f "pic/image.png" ]; then
cp "pic/image.png" "$APP_DIR/usr/share/icons/hicolor/256x256/apps/${APP_NAME}.png" ICON_SOURCE="pic/image.png"
cp "pic/image.png" "$APP_DIR/${APP_NAME}.png" elif [ -f "pic/TrulyMEM.ico" ]; then
else echo "⚠️ No pic/image.png found; .ico will not display as AppImage icon"
echo -n "" > "$APP_DIR/${APP_NAME}.png" echo " To generate a PNG: convert pic/TrulyMEM.ico pic/image.png"
fi fi
if [ -n "$ICON_SOURCE" ]; then
cp "$ICON_SOURCE" "$APP_DIR/usr/share/icons/hicolor/256x256/apps/${APP_NAME}.png"
cp "$ICON_SOURCE" "$APP_DIR/usr/share/icons/hicolor/48x48/apps/${APP_NAME}.png"
cp "$ICON_SOURCE" "$APP_DIR/${APP_NAME}.png"
echo "✅ Icon: $ICON_SOURCE"
else
echo "⚠️ No icon found, creating placeholder"
touch "$APP_DIR/${APP_NAME}.png"
fi
# .desktop 文件
cat > "$APP_DIR/${APP_NAME}.desktop" <<EOF cat > "$APP_DIR/${APP_NAME}.desktop" <<EOF
[Desktop Entry] [Desktop Entry]
Name=${APP_NAME} Name=${APP_NAME}
Comment=True Human Memory - TUI & Web Mode
Exec=${APP_NAME} Exec=${APP_NAME}
Icon=${APP_NAME} Icon=${APP_NAME}
Type=Application Type=Application
Categories=Utility; Categories=Utility;Office;
Terminal=true Terminal=true
StartupNotify=true
EOF EOF
cp "$APP_DIR/${APP_NAME}.desktop" "$APP_DIR/usr/share/applications/" cp "$APP_DIR/${APP_NAME}.desktop" "$APP_DIR/usr/share/applications/"
# AppRun 入口
cat > "$APP_DIR/AppRun" <<'APPRUN' cat > "$APP_DIR/AppRun" <<'APPRUN'
#!/bin/bash #!/bin/bash
HERE="$(dirname "$(readlink -f "$0")")" HERE="$(dirname "$(readlink -f "$0")")"
@ -51,15 +77,24 @@ exec "$HERE/usr/bin/TrulyMEM" "$@"
APPRUN APPRUN
chmod +x "$APP_DIR/AppRun" chmod +x "$APP_DIR/AppRun"
# Step 3: Build AppImage (requires appimagetool) # ── Step 4: 打包 AppImage ──
echo "Step 3: Building AppImage..." echo ""
echo "Step 4: Building AppImage..."
if command -v appimagetool &> /dev/null; then if command -v appimagetool &> /dev/null; then
appimagetool "$APP_DIR" "dist/${APP_NAME}.AppImage" ARCH="${ARCH:-$(uname -m)}" appimagetool "$APP_DIR" "dist/${APP_NAME}.AppImage"
echo "AppImage: dist/${APP_NAME}.AppImage" echo "✅ AppImage: dist/${APP_NAME}.AppImage"
ls -la "dist/${APP_NAME}.AppImage" ls -lh "dist/${APP_NAME}.AppImage"
else else
echo "appimagetool not found. AppDir ready at: $APP_DIR" echo "⚠️ appimagetool not found. AppDir ready at: $APP_DIR"
echo "Install appimagetool and run: appimagetool '$APP_DIR' 'dist/${APP_NAME}.AppImage'" echo ""
echo "To complete manually, install appimagetool:"
echo " wget https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-$(uname -m).AppImage"
echo " chmod +x appimagetool-*.AppImage"
echo " ./appimagetool-*.AppImage '$APP_DIR' 'dist/${APP_NAME}.AppImage'"
echo ""
echo "AppDir contents:"
find "$APP_DIR" -type f | head -20
fi fi
echo "===== AppImage Build Complete =====" echo ""
echo "===== AppImage Build Complete ====="

View File

@ -1,5 +1,5 @@
#!/bin/bash #!/bin/bash
set -e set -euo pipefail
echo "===== Building TrulyMEM for Linux =====" echo "===== Building TrulyMEM for Linux ====="
@ -8,47 +8,102 @@ PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
cd "$PROJECT_ROOT" cd "$PROJECT_ROOT"
echo "Project root: $PROJECT_ROOT" echo "Project root: $PROJECT_ROOT"
# ── 前置检查 ──
if ! command -v python3 &> /dev/null; then if ! command -v python3 &> /dev/null; then
echo "Error: python3 not found"; exit 1 echo "Error: python3 not found"; exit 1
fi fi
if [ ! -f requirements.txt ]; then
echo "Error: requirements.txt not found in $PROJECT_ROOT"; exit 1
fi
# ── 检测 python3-venv ──
VENV_AVAILABLE=false
if python3 -c "import ensurepip" 2>/dev/null && python3 -m venv --help &>/dev/null; then
VENV_AVAILABLE=true
else
echo "⚠️ python3-venv 未安装(或缺少 ensurepip),建议安装以获得干净构建环境:"
echo " sudo apt install python3-venv # Debian/Ubuntu"
echo " sudo dnf install python3-virtualenv # Fedora"
echo "将使用系统 Python 环境继续(依赖全局包)..."
echo ""
fi
# ── 尝试 venv 隔离构建,失败则用系统环境 ── # ── 尝试 venv 隔离构建,失败则用系统环境 ──
USE_VENV=false USE_VENV=false
if python3 -m venv --help &>/dev/null; then if [ "$VENV_AVAILABLE" = true ]; then
VENV_DIR="$PROJECT_ROOT/.venv_build" VENV_DIR="$PROJECT_ROOT/.venv_build"
echo "Creating virtual environment..."
if python3 -m venv "$VENV_DIR" 2>/dev/null; then if python3 -m venv "$VENV_DIR" 2>/dev/null; then
source "$VENV_DIR/bin/activate" source "$VENV_DIR/bin/activate"
USE_VENV=true USE_VENV=true
echo "Using virtual environment" echo "✅ Using virtual environment: $VENV_DIR"
pip install --upgrade pip -q pip install --upgrade pip -q
pip install -r requirements.txt -q pip install -r requirements.txt -q
else else
echo "Warning: venv creation failed, falling back to system Python" echo "⚠️ venv creation failed, falling back to system Python"
fi fi
else
echo "Warning: python3-venv not available, falling back to system Python"
fi fi
if [ "$USE_VENV" = false ]; then if [ "$USE_VENV" = false ]; then
echo "Installing dependencies (system Python)..."
pip install -r requirements.txt --break-system-packages -q 2>/dev/null || \ pip install -r requirements.txt --break-system-packages -q 2>/dev/null || \
pip install -r requirements.txt -q 2>/dev/null || true pip install -r requirements.txt -q 2>/dev/null || {
echo "⚠️ pip install failed, trying pip3..."
pip3 install -r requirements.txt --break-system-packages -q 2>/dev/null || \
pip3 install -r requirements.txt -q 2>/dev/null || \
echo "⚠️ Some dependencies may be missing; build will proceed anyway"
}
fi fi
# ── 清理旧构建 ──
echo ""
echo "Cleaning previous builds..." echo "Cleaning previous builds..."
rm -rf dist/ build/trulymem/ 2>/dev/null || true rm -rf dist/ build/trulymem/ 2>/dev/null || true
# ── PyInstaller 构建 ──
echo ""
echo "================================" echo "================================"
echo "Building TrulyMEM (TUI + Web embedded)" echo "Building TrulyMEM (TUI + Web embedded)"
echo "================================" echo "================================"
python3 -m PyInstaller --clean build/trulymem.spec --noconfirm python3 -m PyInstaller --clean build/trulymem.spec --noconfirm
# ── 创建 Linux `.desktop` 文件(打包图标不能嵌入 ELF,通过 .desktop 引用)──
echo ""
echo "Generating .desktop file for Linux..."
BINARY_PATH="$(cd dist && pwd)/TrulyMEM"
ICON_PATH="$(cd pic && pwd)/image.png"
cat > "dist/TrulyMEM.desktop" <<EOF
[Desktop Entry]
Name=TrulyMEM
Comment=True Human Memory - TUI & Web Mode
Exec=${BINARY_PATH}
Icon=${ICON_PATH}
Terminal=true
Type=Application
Categories=Utility;Office;
StartupNotify=true
EOF
chmod +x "dist/TrulyMEM.desktop"
echo "✅ dist/TrulyMEM.desktop created (icon: pic/image.png)"
# ── 构建完成 ──
echo ""
echo "================================" echo "================================"
echo "===== Build Complete =====" echo "===== Build Complete ====="
echo "Binary: dist/TrulyMEM" echo "Binary: dist/TrulyMEM"
ls -la dist/ echo "Desktop: dist/TrulyMEM.desktop"
echo "------------------------------"
ls -lh dist/ 2>/dev/null || ls -la dist/
echo "================================"
# ── 清理 venv ──
if [ "$USE_VENV" = true ]; then if [ "$USE_VENV" = true ]; then
deactivate 2>/dev/null || true deactivate 2>/dev/null || true
rm -rf "$VENV_DIR" rm -rf "$VENV_DIR"
echo "Virtual environment cleaned up."
fi fi
echo "Build finished successfully!"
echo "Build finished successfully!"

View File

@ -1,6 +1,26 @@
"""
活动记录器 - 记录 AI 对图数据库的操作
使用 SQLite :memory: 供 WebUI 实时渲染,同时后台线程持久化到日志文件
日志每 6 小时自动压缩归档
"""
import sqlite3 import sqlite3
import time import time
import os
import gzip
import json
import threading
import shutil
from typing import List, Dict, Optional from typing import List, Dict, Optional
from datetime import datetime, timedelta
# 日志目录
LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "logs")
# 归档间隔(秒)
ARCHIVE_INTERVAL = 6 * 3600 # 6 小时
# 轮询间隔(秒)
POLL_INTERVAL = 10
class ActivityRecorder: class ActivityRecorder:
@ -8,18 +28,46 @@ class ActivityRecorder:
def __init__(self): def __init__(self):
self.conn = sqlite3.connect(":memory:", check_same_thread=False) self.conn = sqlite3.connect(":memory:", check_same_thread=False)
self.conn.execute("CREATE TABLE activities (id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp REAL, action TEXT, tool_name TEXT, entity TEXT, detail TEXT)") self.conn.execute(
"CREATE TABLE activities (id INTEGER PRIMARY KEY AUTOINCREMENT, "
"timestamp REAL, action TEXT, tool_name TEXT, entity TEXT, detail TEXT)"
)
self.conn.commit() self.conn.commit()
def record(self, action: str, tool_name: str, entity: str, detail: str = "") -> None: def record(self, action: str, tool_name: str, entity: str, detail: str = "") -> None:
self.conn.execute("INSERT INTO activities (timestamp, action, tool_name, entity, detail) VALUES (?, ?, ?, ?, ?)", self.conn.execute(
(time.time(), action, tool_name, entity, detail)) "INSERT INTO activities (timestamp, action, tool_name, entity, detail) VALUES (?, ?, ?, ?, ?)",
(time.time(), action, tool_name, entity, detail)
)
self.conn.commit() self.conn.commit()
def get_all(self) -> List[Dict]: def get_all(self) -> List[Dict]:
cursor = self.conn.execute("SELECT id, timestamp, action, tool_name, entity, detail FROM activities ORDER BY id") cursor = self.conn.execute(
"SELECT id, timestamp, action, tool_name, entity, detail FROM activities ORDER BY id"
)
rows = cursor.fetchall() rows = cursor.fetchall()
return [{"id": r[0], "timestamp": r[1], "action": r[2], "tool_name": r[3], "entity": r[4], "detail": r[5]} for r in rows] return [
{"id": r[0], "timestamp": r[1], "action": r[2],
"tool_name": r[3], "entity": r[4], "detail": r[5]}
for r in rows
]
def get_since_id(self, last_id: int) -> List[Dict]:
"""获取自 last_id 之后的新记录"""
cursor = self.conn.execute(
"SELECT id, timestamp, action, tool_name, entity, detail FROM activities WHERE id > ? ORDER BY id",
(last_id,)
)
rows = cursor.fetchall()
return [
{"id": r[0], "timestamp": r[1], "action": r[2],
"tool_name": r[3], "entity": r[4], "detail": r[5]}
for r in rows
]
def get_max_id(self) -> int:
cursor = self.conn.execute("SELECT COALESCE(MAX(id), 0) FROM activities")
return cursor.fetchone()[0]
def clear(self) -> None: def clear(self) -> None:
self.conn.execute("DELETE FROM activities") self.conn.execute("DELETE FROM activities")
@ -31,11 +79,107 @@ class ActivityRecorder:
return {r[0]: r[1] for r in rows} return {r[0]: r[1] for r in rows}
# ── 日志文件管理 ──
def _current_log_path() -> str:
"""返回当前日志文件路径(按日期命名)"""
os.makedirs(LOG_DIR, exist_ok=True)
date_str = datetime.now().strftime("%Y%m%d")
return os.path.join(LOG_DIR, f"operations.{date_str}.log")
def _archive_log(filepath: str) -> str:
"""压缩归档日志文件,返回归档文件路径"""
if not os.path.exists(filepath) or os.path.getsize(filepath) == 0:
return ""
archive_path = filepath + ".gz"
try:
with open(filepath, "rb") as f_in:
with gzip.open(archive_path, "wb") as f_out:
shutil.copyfileobj(f_in, f_out)
os.remove(filepath)
return archive_path
except Exception:
return ""
class LogPersister:
"""后台日志持久化线程 - 定期将内存记录写入日志文件并自动归档"""
def __init__(self, recorder: ActivityRecorder):
self.recorder = recorder
self._last_persisted_id = 0
self._last_archive_time = time.time()
self._running = True
self._thread = threading.Thread(target=self._run, daemon=True, name="log-persister")
self._thread.start()
def _run(self):
"""主循环"""
while self._running:
try:
self._persist_new()
self._check_archive()
except Exception:
pass # 不因日志异常影响主进程
time.sleep(POLL_INTERVAL)
def _persist_new(self):
"""增量写入新记录到日志文件"""
records = self.recorder.get_since_id(self._last_persisted_id)
if not records:
return
log_path = _current_log_path()
with open(log_path, "a", encoding="utf-8") as f:
for r in records:
line = json.dumps(r, ensure_ascii=False)
f.write(line + "\n")
# 更新水位
if records:
self._last_persisted_id = records[-1]["id"]
def _check_archive(self):
"""检查是否需要归档"""
elapsed = time.time() - self._last_archive_time
if elapsed < ARCHIVE_INTERVAL:
return
log_path = _current_log_path()
archived = _archive_log(log_path)
if archived:
dt = datetime.fromtimestamp(self._last_archive_time)
print(f"[日志归档] {dt.strftime('%H:%M')} → {os.path.basename(archived)} ({_fmt_size(archived)})")
self._last_archive_time = time.time()
def stop(self):
self._running = False
def _fmt_size(path: str) -> str:
size = os.path.getsize(path)
for unit in ("B", "KB", "MB"):
if size < 1024:
return f"{size:.1f}{unit}"
size /= 1024
return f"{size:.1f}GB"
# ── 单例 ──
_recorder: Optional[ActivityRecorder] = None _recorder: Optional[ActivityRecorder] = None
_persister: Optional[LogPersister] = None
def get_recorder() -> ActivityRecorder: def get_recorder() -> ActivityRecorder:
global _recorder """获取全局 ActivityRecorder(首次调用时自动启动日志持久化线程)"""
global _recorder, _persister
if _recorder is None: if _recorder is None:
_recorder = ActivityRecorder() _recorder = ActivityRecorder()
_persister = LogPersister(_recorder)
return _recorder return _recorder
def get_persister() -> Optional[LogPersister]:
return _persister

View File

@ -443,6 +443,16 @@ class EmbeddedGraphDB:
Args: Args:
criteria: 删除条件 criteria: 删除条件
支持:
- source: 源实体名(精确匹配)
- target: 目标实体名(精确匹配)
- relation: 关系类型
- subject_contains: 源实体名包含(模糊匹配)
- target_contains: 目标实体名包含(模糊匹配)
- relation_type: 关系类型(同 relation)
- source_type: 源实体类型过滤
- target_type: 目标实体类型过滤
- source_has_status: 源实体 mentions_count 状态(支持 type 字段)
mode: 删除模式 (soft/hard) mode: 删除模式 (soft/hard)
new_relation: 替代关系 new_relation: 替代关系
@ -454,39 +464,94 @@ class EmbeddedGraphDB:
# 构建查询条件 # 构建查询条件
conditions = [] conditions = []
params = [] params = []
joins = []
relation_type = criteria.get('relation') or criteria.get('relation_type', '')
if criteria.get('source'): if criteria.get('source'):
cursor.execute("SELECT id FROM entities WHERE name = ?", (criteria['source'],)) cursor.execute("SELECT id FROM entities WHERE name = ?", (criteria['source'],))
row = cursor.fetchone() row = cursor.fetchone()
if row: if row:
conditions.append("source_id = ?") conditions.append("r.source_id = ?")
params.append(row['id']) params.append(row['id'])
if criteria.get('target'): if criteria.get('target'):
cursor.execute("SELECT id FROM entities WHERE name = ?", (criteria['target'],)) cursor.execute("SELECT id FROM entities WHERE name = ?", (criteria['target'],))
row = cursor.fetchone() row = cursor.fetchone()
if row: if row:
conditions.append("target_id = ?") conditions.append("r.target_id = ?")
params.append(row['id']) params.append(row['id'])
if criteria.get('relation'): if relation_type:
conditions.append("relation_type = ?") conditions.append("r.relation_type = ?")
params.append(criteria['relation']) params.append(relation_type)
# 通过子查询支持实体属性过滤
if criteria.get('subject_contains'):
cursor.execute("SELECT id FROM entities WHERE name LIKE ?",
(f'%{criteria["subject_contains"]}%',))
ids = [row['id'] for row in cursor.fetchall()]
if ids:
placeholders = ','.join(['?'] * len(ids))
conditions.append(f"r.source_id IN ({placeholders})")
params.extend(ids)
if criteria.get('target_contains'):
cursor.execute("SELECT id FROM entities WHERE name LIKE ?",
(f'%{criteria["target_contains"]}%',))
ids = [row['id'] for row in cursor.fetchall()]
if ids:
placeholders = ','.join(['?'] * len(ids))
conditions.append(f"r.target_id IN ({placeholders})")
params.extend(ids)
# 源实体类型过滤
if criteria.get('source_type'):
cursor.execute("SELECT id FROM entities WHERE type = ?",
(criteria['source_type'],))
ids = [row['id'] for row in cursor.fetchall()]
if ids:
placeholders = ','.join(['?'] * len(ids))
conditions.append(f"r.source_id IN ({placeholders})")
params.extend(ids)
# 目标实体类型过滤
if criteria.get('target_type'):
cursor.execute("SELECT id FROM entities WHERE type = ?",
(criteria['target_type'],))
ids = [row['id'] for row in cursor.fetchall()]
if ids:
placeholders = ','.join(['?'] * len(ids))
conditions.append(f"r.target_id IN ({placeholders})")
params.extend(ids)
# 源实体状态过滤(检查 entity name 是否以特定后缀结尾等)
if criteria.get('source_has_status'):
status = criteria['source_has_status']
# 匹配 entities 表中 type 字段包含状态信息的节点
cursor.execute("SELECT id FROM entities WHERE type LIKE ?",
(f'%{status}%',))
ids = [row['id'] for row in cursor.fetchall()]
if ids:
placeholders = ','.join(['?'] * len(ids))
conditions.append(f"r.source_id IN ({placeholders})")
params.extend(ids)
if not conditions: if not conditions:
return {"deleted": 0, "message": "无删除条件"} return {"deleted": 0, "message": "无删除条件"}
conditions.append("r.status = 'active'")
where_clause = " AND ".join(conditions) where_clause = " AND ".join(conditions)
if mode == "soft": if mode == "soft":
cursor.execute(f""" cursor.execute(f"""
UPDATE relations UPDATE relations r
SET status = 'deleted', updated_at = CURRENT_TIMESTAMP SET status = 'deleted', updated_at = CURRENT_TIMESTAMP
WHERE {where_clause} AND status = 'active' WHERE {where_clause}
""", params) """, params)
else: else:
cursor.execute(f""" cursor.execute(f"""
DELETE FROM relations DELETE FROM relations r
WHERE {where_clause} WHERE {where_clause}
""", params) """, params)

View File

@ -173,6 +173,9 @@ class Neo4jGraph:
subject_pattern = criteria.get("subject_contains", "") subject_pattern = criteria.get("subject_contains", "")
rel_type = criteria.get("relation_type", "") rel_type = criteria.get("relation_type", "")
target_pattern = criteria.get("target_contains", "") target_pattern = criteria.get("target_contains", "")
source_type = criteria.get("source_type", "")
target_type = criteria.get("target_type", "")
source_status = criteria.get("source_has_status", "")
session_id = criteria.get("session_id", CURRENT_SESSION_ID) session_id = criteria.get("session_id", CURRENT_SESSION_ID)
cond_parts = ["r.status = 'active'"] cond_parts = ["r.status = 'active'"]
@ -187,6 +190,15 @@ class Neo4jGraph:
if rel_type: if rel_type:
cond_parts.append("r.type = $rel_type") cond_parts.append("r.type = $rel_type")
params["rel_type"] = rel_type params["rel_type"] = rel_type
if source_type:
cond_parts.append("s.entity_type = $source_type")
params["source_type"] = source_type
if target_type:
cond_parts.append("t.entity_type = $target_type")
params["target_type"] = target_type
if source_status:
cond_parts.append("s.status = $source_status")
params["source_status"] = source_status
where_clause = " AND ".join(cond_parts) where_clause = " AND ".join(cond_parts)
@ -208,7 +220,7 @@ class Neo4jGraph:
return {"deleted_count": count, "mode": "supersede"} return {"deleted_count": count, "mode": "supersede"}
else: else:
result = session.run(f""" result = session.run(f"""
MATCH ()-[r:RELATES]->() MATCH (s:Entity)-[r:RELATES]->(t:Entity)
WHERE {where_clause} WHERE {where_clause}
SET r.status = 'deleted', r.updated_at = datetime() SET r.status = 'deleted', r.updated_at = datetime()
RETURN count(r) as deleted RETURN count(r) as deleted

View File

@ -146,6 +146,12 @@ MEMORY_TOOLS = [
4. 删除旧记忆: 4. 删除旧记忆:
{"criteria": {"time_before": "2024-01-01"}, "mode": "soft"} {"criteria": {"time_before": "2024-01-01"}, "mode": "soft"}
5. 删除残留在已归档任务上的状态关系:
{"criteria": {"relation_type": "HAS_STATE", "source_type": "TaskNode", "source_has_status": "archived"}, "mode": "soft"}
6. 删除特定类型的节点关系:
{"criteria": {"relation_type": "某种关系", "target_type": "某种类型"}, "mode": "soft"}
【重要】删除原则: 【重要】删除原则:
- 优先使用 supersede 模式修正错误 - 优先使用 supersede 模式修正错误
- 软删除不会物理删除数据 - 软删除不会物理删除数据
@ -159,6 +165,9 @@ MEMORY_TOOLS = [
"subject_contains": {"type": "string"}, "subject_contains": {"type": "string"},
"relation_type": {"type": "string"}, "relation_type": {"type": "string"},
"target_contains": {"type": "string"}, "target_contains": {"type": "string"},
"source_type": {"type": "string", "description": "源实体类型过滤(如 TaskNode)"},
"target_type": {"type": "string", "description": "目标实体类型过滤"},
"source_has_status": {"type": "string", "description": "源实体状态过滤(如 archived)"},
"time_before": {"type": "string"}, "time_before": {"type": "string"},
"session_id": {"type": "string"} "session_id": {"type": "string"}
}, },

View File

@ -913,7 +913,11 @@ function init() {
positions[node.id] = { x, y, z }; positions[node.id] = { x, y, z };
}); });
// 简单力导向模拟(迭代几次) // 简单力导向模拟(迭代几次)— 使用度数加权距离
function getIdealDist(id) {
return 5 + ((nodeDegrees[id] || 0) * 0.8);
}
for (let iter = 0; iter < 30; iter++) { for (let iter = 0; iter < 30; iter++) {
// 斥力:节点之间互相排斥 // 斥力:节点之间互相排斥
Object.keys(positions).forEach(id1 => { Object.keys(positions).forEach(id1 => {
@ -926,8 +930,14 @@ function init() {
const dz = pos1.z - pos2.z; const dz = pos1.z - pos2.z;
const dist = Math.sqrt(dx*dx + dy*dy + dz*dz) + 0.1; const dist = Math.sqrt(dx*dx + dy*dy + dz*dz) + 0.1;
if (dist < 10) { // 动态阈值:度数越高理想距离越大,取两节点较大值
const force = 0.04 / Math.max(dist, 0.5); const idealDist = getIdealDist(id1) + getIdealDist(id2);
const repulsionThreshold = idealDist * 1.5;
if (dist < repulsionThreshold) {
// 斥力强度按度数加权
const degreeWeight = ((nodeDegrees[id1] || 0) + (nodeDegrees[id2] || 0)) * 0.5 + 1;
const force = (0.04 * degreeWeight) / Math.max(dist, 0.5);
const fx = (dx / dist) * force; const fx = (dx / dist) * force;
const fy = (dy / dist) * force; const fy = (dy / dist) * force;
const fz = (dz / dist) * force; const fz = (dz / dist) * force;
@ -953,7 +963,11 @@ function init() {
const dz = pos2.z - pos1.z; const dz = pos2.z - pos1.z;
const dist = Math.sqrt(dx*dx + dy*dy + dz*dz) + 0.1; const dist = Math.sqrt(dx*dx + dy*dy + dz*dz) + 0.1;
if (dist > 15) { // 动态引力阈值:以两节点平均理想距离为基准
const idealDist = (getIdealDist(edge.source) + getIdealDist(edge.target)) * 0.8;
const attractionThreshold = idealDist * 1.5;
if (dist > attractionThreshold) {
const force = 0.05; const force = 0.05;
const fx = (dx / dist) * force; const fx = (dx / dist) * force;
const fy = (dy / dist) * force; const fy = (dy / dist) * force;
@ -1236,6 +1250,18 @@ function smoothReposition() {
}; };
}); });
// 重新计算度数(从当前节点 + 边)
const smoothDegrees = {};
nodeMeshes.forEach(m => { smoothDegrees[m.userData.nodeId] = 0; });
edges.forEach(e => {
smoothDegrees[e.source] = (smoothDegrees[e.source] || 0) + 1;
smoothDegrees[e.target] = (smoothDegrees[e.target] || 0) + 1;
});
function getSmoothIdealDist(id) {
return 5 + ((smoothDegrees[id] || 0) * 0.8);
}
// 用力导向模拟迭代 30 次平滑 // 用力导向模拟迭代 30 次平滑
for (let iter = 0; iter < 30; iter++) { for (let iter = 0; iter < 30; iter++) {
// 斥力 // 斥力
@ -1248,8 +1274,11 @@ function smoothReposition() {
const dy = a.y - b.y; const dy = a.y - b.y;
const dz = a.z - b.z; const dz = a.z - b.z;
const dist = Math.sqrt(dx*dx + dy*dy + dz*dz) + 0.1; const dist = Math.sqrt(dx*dx + dy*dy + dz*dz) + 0.1;
if (dist < 12) { const idealDist = getSmoothIdealDist(ids[i]) + getSmoothIdealDist(ids[j]);
const force = 0.08 / Math.max(dist, 0.5); const repulsionThreshold = idealDist * 1.5;
if (dist < repulsionThreshold) {
const degreeWeight = ((smoothDegrees[ids[i]] || 0) + (smoothDegrees[ids[j]] || 0)) * 0.5 + 1;
const force = (0.08 * degreeWeight) / Math.max(dist, 0.5);
a.x += (dx/dist) * force; a.x += (dx/dist) * force;
a.y += (dy/dist) * force; a.y += (dy/dist) * force;
a.z += (dz/dist) * force; a.z += (dz/dist) * force;
@ -1268,7 +1297,9 @@ function smoothReposition() {
const dy = p2.y - p1.y; const dy = p2.y - p1.y;
const dz = p2.z - p1.z; const dz = p2.z - p1.z;
const dist = Math.sqrt(dx*dx + dy*dy + dz*dz) + 0.1; const dist = Math.sqrt(dx*dx + dy*dy + dz*dz) + 0.1;
if (dist > 8) { const idealDist = (getSmoothIdealDist(e.source) + getSmoothIdealDist(e.target)) * 0.8;
const attractionThreshold = idealDist * 1.5;
if (dist > attractionThreshold) {
const force = 0.03; const force = 0.03;
p1.x += (dx/dist) * force; p1.x += (dx/dist) * force;
p1.y += (dy/dist) * force; p1.y += (dy/dist) * force;