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:
@ -1,5 +1,5 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
set -euo pipefail
|
||||
|
||||
echo "===== Building TrulyMEM AppImage ====="
|
||||
|
||||
@ -11,39 +11,65 @@ echo "Project root: $PROJECT_ROOT"
|
||||
APP_NAME="TrulyMEM"
|
||||
APP_DIR="$PROJECT_ROOT/build/appimage-build"
|
||||
|
||||
echo "Cleaning..."
|
||||
rm -rf "$APP_DIR" dist/ build/trulymem/ 2>/dev/null || true
|
||||
# ── Step 1: 复用 build_linux.sh 完成 PyInstaller 构建 ──
|
||||
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)
|
||||
echo "Step 1: PyInstaller build..."
|
||||
python3 -m PyInstaller --clean build/trulymem.spec --noconfirm
|
||||
# ── Step 2: 检查 dist/ 中是否有二进制 ──
|
||||
echo ""
|
||||
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
|
||||
echo "Step 2: Preparing AppDir..."
|
||||
# ── Step 3: 组织 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/share/applications"
|
||||
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/"
|
||||
|
||||
# 图标处理
|
||||
ICON_SOURCE=""
|
||||
if [ -f "pic/image.png" ]; then
|
||||
cp "pic/image.png" "$APP_DIR/usr/share/icons/hicolor/256x256/apps/${APP_NAME}.png"
|
||||
cp "pic/image.png" "$APP_DIR/${APP_NAME}.png"
|
||||
else
|
||||
echo -n "" > "$APP_DIR/${APP_NAME}.png"
|
||||
ICON_SOURCE="pic/image.png"
|
||||
elif [ -f "pic/TrulyMEM.ico" ]; then
|
||||
echo "⚠️ No pic/image.png found; .ico will not display as AppImage icon"
|
||||
echo " To generate a PNG: convert pic/TrulyMEM.ico pic/image.png"
|
||||
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
|
||||
[Desktop Entry]
|
||||
Name=${APP_NAME}
|
||||
Comment=True Human Memory - TUI & Web Mode
|
||||
Exec=${APP_NAME}
|
||||
Icon=${APP_NAME}
|
||||
Type=Application
|
||||
Categories=Utility;
|
||||
Categories=Utility;Office;
|
||||
Terminal=true
|
||||
StartupNotify=true
|
||||
EOF
|
||||
|
||||
cp "$APP_DIR/${APP_NAME}.desktop" "$APP_DIR/usr/share/applications/"
|
||||
|
||||
# AppRun 入口
|
||||
cat > "$APP_DIR/AppRun" <<'APPRUN'
|
||||
#!/bin/bash
|
||||
HERE="$(dirname "$(readlink -f "$0")")"
|
||||
@ -51,15 +77,24 @@ exec "$HERE/usr/bin/TrulyMEM" "$@"
|
||||
APPRUN
|
||||
chmod +x "$APP_DIR/AppRun"
|
||||
|
||||
# Step 3: Build AppImage (requires appimagetool)
|
||||
echo "Step 3: Building AppImage..."
|
||||
# ── Step 4: 打包 AppImage ──
|
||||
echo ""
|
||||
echo "Step 4: Building AppImage..."
|
||||
if command -v appimagetool &> /dev/null; then
|
||||
appimagetool "$APP_DIR" "dist/${APP_NAME}.AppImage"
|
||||
echo "AppImage: dist/${APP_NAME}.AppImage"
|
||||
ls -la "dist/${APP_NAME}.AppImage"
|
||||
ARCH="${ARCH:-$(uname -m)}" appimagetool "$APP_DIR" "dist/${APP_NAME}.AppImage"
|
||||
echo "✅ AppImage: dist/${APP_NAME}.AppImage"
|
||||
ls -lh "dist/${APP_NAME}.AppImage"
|
||||
else
|
||||
echo "appimagetool not found. AppDir ready at: $APP_DIR"
|
||||
echo "Install appimagetool and run: appimagetool '$APP_DIR' 'dist/${APP_NAME}.AppImage'"
|
||||
echo "⚠️ appimagetool not found. AppDir ready at: $APP_DIR"
|
||||
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
|
||||
|
||||
echo "===== AppImage Build Complete ====="
|
||||
echo ""
|
||||
echo "===== AppImage Build Complete ====="
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
set -euo pipefail
|
||||
|
||||
echo "===== Building TrulyMEM for Linux ====="
|
||||
|
||||
@ -8,47 +8,102 @@ PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
cd "$PROJECT_ROOT"
|
||||
echo "Project root: $PROJECT_ROOT"
|
||||
|
||||
# ── 前置检查 ──
|
||||
if ! command -v python3 &> /dev/null; then
|
||||
echo "Error: python3 not found"; exit 1
|
||||
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 隔离构建,失败则用系统环境 ──
|
||||
USE_VENV=false
|
||||
if python3 -m venv --help &>/dev/null; then
|
||||
if [ "$VENV_AVAILABLE" = true ]; then
|
||||
VENV_DIR="$PROJECT_ROOT/.venv_build"
|
||||
echo "Creating virtual environment..."
|
||||
if python3 -m venv "$VENV_DIR" 2>/dev/null; then
|
||||
source "$VENV_DIR/bin/activate"
|
||||
USE_VENV=true
|
||||
echo "Using virtual environment"
|
||||
echo "✅ Using virtual environment: $VENV_DIR"
|
||||
pip install --upgrade pip -q
|
||||
pip install -r requirements.txt -q
|
||||
else
|
||||
echo "Warning: venv creation failed, falling back to system Python"
|
||||
echo "⚠️ venv creation failed, falling back to system Python"
|
||||
fi
|
||||
else
|
||||
echo "Warning: python3-venv not available, falling back to system Python"
|
||||
fi
|
||||
|
||||
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 -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
|
||||
|
||||
# ── 清理旧构建 ──
|
||||
echo ""
|
||||
echo "Cleaning previous builds..."
|
||||
rm -rf dist/ build/trulymem/ 2>/dev/null || true
|
||||
|
||||
# ── PyInstaller 构建 ──
|
||||
echo ""
|
||||
echo "================================"
|
||||
echo "Building TrulyMEM (TUI + Web embedded)"
|
||||
echo "================================"
|
||||
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 "===== Build Complete ====="
|
||||
echo "Binary: dist/TrulyMEM"
|
||||
ls -la dist/
|
||||
echo "Binary: dist/TrulyMEM"
|
||||
echo "Desktop: dist/TrulyMEM.desktop"
|
||||
echo "------------------------------"
|
||||
ls -lh dist/ 2>/dev/null || ls -la dist/
|
||||
echo "================================"
|
||||
|
||||
# ── 清理 venv ──
|
||||
if [ "$USE_VENV" = true ]; then
|
||||
deactivate 2>/dev/null || true
|
||||
rm -rf "$VENV_DIR"
|
||||
echo "Virtual environment cleaned up."
|
||||
fi
|
||||
echo "Build finished successfully!"
|
||||
|
||||
echo "Build finished successfully!"
|
||||
|
||||
@ -1,6 +1,26 @@
|
||||
"""
|
||||
活动记录器 - 记录 AI 对图数据库的操作
|
||||
使用 SQLite :memory: 供 WebUI 实时渲染,同时后台线程持久化到日志文件
|
||||
日志每 6 小时自动压缩归档
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import time
|
||||
import os
|
||||
import gzip
|
||||
import json
|
||||
import threading
|
||||
import shutil
|
||||
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:
|
||||
@ -8,18 +28,46 @@ class ActivityRecorder:
|
||||
|
||||
def __init__(self):
|
||||
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()
|
||||
|
||||
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 (?, ?, ?, ?, ?)",
|
||||
(time.time(), action, tool_name, entity, detail))
|
||||
self.conn.execute(
|
||||
"INSERT INTO activities (timestamp, action, tool_name, entity, detail) VALUES (?, ?, ?, ?, ?)",
|
||||
(time.time(), action, tool_name, entity, detail)
|
||||
)
|
||||
self.conn.commit()
|
||||
|
||||
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()
|
||||
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:
|
||||
self.conn.execute("DELETE FROM activities")
|
||||
@ -31,11 +79,107 @@ class ActivityRecorder:
|
||||
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
|
||||
_persister: Optional[LogPersister] = None
|
||||
|
||||
|
||||
def get_recorder() -> ActivityRecorder:
|
||||
global _recorder
|
||||
"""获取全局 ActivityRecorder(首次调用时自动启动日志持久化线程)"""
|
||||
global _recorder, _persister
|
||||
if _recorder is None:
|
||||
_recorder = ActivityRecorder()
|
||||
_persister = LogPersister(_recorder)
|
||||
return _recorder
|
||||
|
||||
|
||||
def get_persister() -> Optional[LogPersister]:
|
||||
return _persister
|
||||
|
||||
@ -443,6 +443,16 @@ class EmbeddedGraphDB:
|
||||
|
||||
Args:
|
||||
criteria: 删除条件
|
||||
支持:
|
||||
- source: 源实体名(精确匹配)
|
||||
- target: 目标实体名(精确匹配)
|
||||
- relation: 关系类型
|
||||
- subject_contains: 源实体名包含(模糊匹配)
|
||||
- target_contains: 目标实体名包含(模糊匹配)
|
||||
- relation_type: 关系类型(同 relation)
|
||||
- source_type: 源实体类型过滤
|
||||
- target_type: 目标实体类型过滤
|
||||
- source_has_status: 源实体 mentions_count 状态(支持 type 字段)
|
||||
mode: 删除模式 (soft/hard)
|
||||
new_relation: 替代关系
|
||||
|
||||
@ -454,39 +464,94 @@ class EmbeddedGraphDB:
|
||||
# 构建查询条件
|
||||
conditions = []
|
||||
params = []
|
||||
joins = []
|
||||
|
||||
relation_type = criteria.get('relation') or criteria.get('relation_type', '')
|
||||
|
||||
if criteria.get('source'):
|
||||
cursor.execute("SELECT id FROM entities WHERE name = ?", (criteria['source'],))
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
conditions.append("source_id = ?")
|
||||
conditions.append("r.source_id = ?")
|
||||
params.append(row['id'])
|
||||
|
||||
if criteria.get('target'):
|
||||
cursor.execute("SELECT id FROM entities WHERE name = ?", (criteria['target'],))
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
conditions.append("target_id = ?")
|
||||
conditions.append("r.target_id = ?")
|
||||
params.append(row['id'])
|
||||
|
||||
if criteria.get('relation'):
|
||||
conditions.append("relation_type = ?")
|
||||
params.append(criteria['relation'])
|
||||
if relation_type:
|
||||
conditions.append("r.relation_type = ?")
|
||||
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:
|
||||
return {"deleted": 0, "message": "无删除条件"}
|
||||
|
||||
conditions.append("r.status = 'active'")
|
||||
where_clause = " AND ".join(conditions)
|
||||
|
||||
if mode == "soft":
|
||||
cursor.execute(f"""
|
||||
UPDATE relations
|
||||
UPDATE relations r
|
||||
SET status = 'deleted', updated_at = CURRENT_TIMESTAMP
|
||||
WHERE {where_clause} AND status = 'active'
|
||||
WHERE {where_clause}
|
||||
""", params)
|
||||
else:
|
||||
cursor.execute(f"""
|
||||
DELETE FROM relations
|
||||
DELETE FROM relations r
|
||||
WHERE {where_clause}
|
||||
""", params)
|
||||
|
||||
|
||||
@ -173,6 +173,9 @@ class Neo4jGraph:
|
||||
subject_pattern = criteria.get("subject_contains", "")
|
||||
rel_type = criteria.get("relation_type", "")
|
||||
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)
|
||||
|
||||
cond_parts = ["r.status = 'active'"]
|
||||
@ -187,6 +190,15 @@ class Neo4jGraph:
|
||||
if rel_type:
|
||||
cond_parts.append("r.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)
|
||||
|
||||
@ -208,7 +220,7 @@ class Neo4jGraph:
|
||||
return {"deleted_count": count, "mode": "supersede"}
|
||||
else:
|
||||
result = session.run(f"""
|
||||
MATCH ()-[r:RELATES]->()
|
||||
MATCH (s:Entity)-[r:RELATES]->(t:Entity)
|
||||
WHERE {where_clause}
|
||||
SET r.status = 'deleted', r.updated_at = datetime()
|
||||
RETURN count(r) as deleted
|
||||
|
||||
@ -146,6 +146,12 @@ MEMORY_TOOLS = [
|
||||
4. 删除旧记忆:
|
||||
{"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 模式修正错误
|
||||
- 软删除不会物理删除数据
|
||||
@ -159,6 +165,9 @@ MEMORY_TOOLS = [
|
||||
"subject_contains": {"type": "string"},
|
||||
"relation_type": {"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"},
|
||||
"session_id": {"type": "string"}
|
||||
},
|
||||
|
||||
@ -913,7 +913,11 @@ function init() {
|
||||
positions[node.id] = { x, y, z };
|
||||
});
|
||||
|
||||
// 简单力导向模拟(迭代几次)
|
||||
// 简单力导向模拟(迭代几次)— 使用度数加权距离
|
||||
function getIdealDist(id) {
|
||||
return 5 + ((nodeDegrees[id] || 0) * 0.8);
|
||||
}
|
||||
|
||||
for (let iter = 0; iter < 30; iter++) {
|
||||
// 斥力:节点之间互相排斥
|
||||
Object.keys(positions).forEach(id1 => {
|
||||
@ -926,8 +930,14 @@ function init() {
|
||||
const dz = pos1.z - pos2.z;
|
||||
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 fy = (dy / dist) * force;
|
||||
const fz = (dz / dist) * force;
|
||||
@ -953,7 +963,11 @@ function init() {
|
||||
const dz = pos2.z - pos1.z;
|
||||
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 fx = (dx / 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 次平滑
|
||||
for (let iter = 0; iter < 30; iter++) {
|
||||
// 斥力
|
||||
@ -1248,8 +1274,11 @@ function smoothReposition() {
|
||||
const dy = a.y - b.y;
|
||||
const dz = a.z - b.z;
|
||||
const dist = Math.sqrt(dx*dx + dy*dy + dz*dz) + 0.1;
|
||||
if (dist < 12) {
|
||||
const force = 0.08 / Math.max(dist, 0.5);
|
||||
const idealDist = getSmoothIdealDist(ids[i]) + getSmoothIdealDist(ids[j]);
|
||||
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.y += (dy/dist) * force;
|
||||
a.z += (dz/dist) * force;
|
||||
@ -1268,7 +1297,9 @@ function smoothReposition() {
|
||||
const dy = p2.y - p1.y;
|
||||
const dz = p2.z - p1.z;
|
||||
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;
|
||||
p1.x += (dx/dist) * force;
|
||||
p1.y += (dy/dist) * force;
|
||||
|
||||
Reference in New Issue
Block a user