356 lines
16 KiB
Python
356 lines
16 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
memory-system-self-upgrade.py — 记忆系统自我升级(方案A统一架构)
|
||
每天凌晨 4:00 由 cron 触发
|
||
|
||
职责(L7 统一层优先):
|
||
1. L7 llm_context.json v2:验证 9 字段完整,distill_status 检查
|
||
2. 织忆(L1/L2):健康度 + pattern 节点增长 + 索引
|
||
3. Soulful(L5/L6):过期 cares 清理 + distilled_rules 补充 + 心迹去重
|
||
4. 统一写入优化报告到飞书
|
||
"""
|
||
import json, os, sys, subprocess
|
||
from datetime import datetime, timezone, timedelta
|
||
|
||
HERMES = os.path.expanduser("~/.hermes")
|
||
ZHIYI_URL = "http://127.0.0.1:7821"
|
||
ZHIYI_KEY = "zhiyi-dev-key-2026"
|
||
FEISHU_WEBHOOK = "https://open.feishu.cn/open-apis/bot/v2/hook/446db983-e392-4d2c-bfb8-f9060e5df3ad"
|
||
REPORT = [] # 收集升级报告
|
||
|
||
def log(msg):
|
||
ts = datetime.now().strftime("%H:%M:%S")
|
||
print(f"[{ts}] {msg}")
|
||
|
||
def feishu_alert(title, content):
|
||
try:
|
||
payload = json.dumps({
|
||
"msg_type": "interactive",
|
||
"card": {
|
||
"header": {"title": {"tag": "plain_text", "content": title}, "template": "red"},
|
||
"elements": [{"tag": "markdown", "content": content}]
|
||
}
|
||
}).encode("utf-8")
|
||
req = urllib.request.Request(FEISHU_WEBHOOK, data=payload,
|
||
headers={"Content-Type": "application/json"}, method="POST")
|
||
with urllib.request.urlopen(req, timeout=10): pass
|
||
except Exception as e:
|
||
log(f"飞书通知失败: {e}")
|
||
|
||
import urllib.request
|
||
|
||
# ── 1. 织忆自我检查 ──────────────────────────────────────────────────────────
|
||
def upgrade_zhiyi():
|
||
log("=== 织忆自检 ===")
|
||
actions = []
|
||
|
||
# Health check
|
||
try:
|
||
req = urllib.request.Request(
|
||
f"{ZHIYI_URL}/api/v1/health",
|
||
headers={"X-API-Key": ZHIYI_KEY},
|
||
method="GET"
|
||
)
|
||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||
health = json.loads(resp.read().decode())
|
||
log(f" health: {health.get('status')}")
|
||
except Exception as e:
|
||
REPORT.append(f"❌ 织忆健康检查失败: {e}")
|
||
return actions
|
||
|
||
# Stats
|
||
try:
|
||
req = urllib.request.Request(
|
||
f"{ZHIYI_URL}/api/v1/stats",
|
||
headers={"X-API-Key": ZHIYI_KEY},
|
||
method="GET"
|
||
)
|
||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||
stats = json.loads(resp.read().decode())
|
||
episodes = stats.get("total_episodes", 0)
|
||
memories = stats.get("total_memories", 0)
|
||
tombstones = stats.get("tombstones", 0)
|
||
log(f" episodes={episodes} memories={memories} tombstones={tombstones}")
|
||
actions.append(f"episodes={episodes} memories={memories} tombstones={tombstones}")
|
||
|
||
# Tombstone 增长检测(和昨天比)
|
||
state_file = f"{HERMES}/.hermes/watchdog/zhiyi_stats.json"
|
||
os.makedirs(os.path.dirname(state_file), exist_ok=True)
|
||
prev = {}
|
||
if os.path.exists(state_file):
|
||
prev = json.load(open(state_file))
|
||
prev_tomb = prev.get("tombstones", 0)
|
||
prev_mem = prev.get("memories", 0)
|
||
if prev_tomb:
|
||
delta = tombstones - prev_tomb
|
||
if delta > 50:
|
||
REPORT.append(f"⚠️ 织忆 tombstone 单日增长 {delta},注意清理")
|
||
# 保存当天快照
|
||
json.dump({"tombstones": tombstones, "memories": memories, "episodes": episodes}, open(state_file, "w"))
|
||
except Exception as e:
|
||
REPORT.append(f"❌ 织忆 stats 获取失败: {e}")
|
||
|
||
# Metrics 健康度
|
||
try:
|
||
req = urllib.request.Request(
|
||
f"{ZHIYI_URL}/api/v1/metrics",
|
||
headers={"X-API-Key": ZHIYI_KEY},
|
||
method="GET"
|
||
)
|
||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||
metrics = json.loads(resp.read().decode())
|
||
recall_hit = metrics.get("recall_hit_rate", 0)
|
||
gap_closures = metrics.get("gap_closure_rate", 0)
|
||
log(f" recall_hit={recall_hit:.2f} gap_closure={gap_closures:.2f}")
|
||
if recall_hit and recall_hit < 0.6:
|
||
REPORT.append(f"⚠️ 织忆 recall_hit={recall_hit:.2f} 偏低,建议补充相关记忆")
|
||
except Exception as e:
|
||
log(f" metrics API 不存在或失败(非致命): {e}")
|
||
|
||
# P2 离线整合(LightMem UPDATE_PROMPT):每日自动合并相似记忆
|
||
# POST /api/v1/consolidate/memory — 找出相似记忆对 → LLM 三选一(update/delete/ignore)
|
||
try:
|
||
req = urllib.request.Request(
|
||
f"{ZHIYI_URL}/api/v1/consolidate/memory",
|
||
data=json.dumps({"namespace": "hermes-main", "limit": 50}).encode("utf-8"),
|
||
headers={"X-API-Key": ZHIYI_KEY, "Content-Type": "application/json"},
|
||
method="POST"
|
||
)
|
||
with urllib.request.urlopen(req, timeout=120) as resp:
|
||
cons = json.loads(resp.read().decode())
|
||
log(f" consolidate: processed={cons.get('processed',0)} updated={cons.get('updated',0)} deleted={cons.get('deleted',0)} ignored={cons.get('ignored',0)}")
|
||
if cons.get("processed", 0) > 0:
|
||
actions.append(f"🧹 P2 记忆整合: 处理 {cons.get('processed')} 对相似记忆 (更新 {cons.get('updated')} / 删除 {cons.get('deleted')} / 忽略 {cons.get('ignored')})")
|
||
except Exception as e:
|
||
log(f" consolidate 失败(非致命): {e}")
|
||
|
||
return actions
|
||
|
||
# ── 2. Soulful 自我升级 ─────────────────────────────────────────────────────
|
||
def upgrade_soulful():
|
||
log("=== Soulful 自检 ===")
|
||
actions = []
|
||
soulful_dir = f"{HERMES}/soulful"
|
||
|
||
# 2a. 清理 30 天以上的过期/done cares
|
||
cq_path = f"{soulful_dir}/cares-queue.json"
|
||
removed = 0
|
||
if os.path.exists(cq_path):
|
||
try:
|
||
d = json.load(open(cq_path))
|
||
today = datetime.now().date()
|
||
new_cares = []
|
||
for c in d.get("cares", []):
|
||
due = c.get("follow_up_date", "")
|
||
status = c.get("status", "pending")
|
||
if status == "done":
|
||
# done 项保留 7 天后删
|
||
if due:
|
||
try:
|
||
due_date = datetime.strptime(due, "%Y-%m-%d").date()
|
||
if (today - due_date).days > 7:
|
||
removed += 1
|
||
continue
|
||
except: pass
|
||
elif due:
|
||
try:
|
||
due_date = datetime.strptime(due, "%Y-%m-%d").date()
|
||
if (today - due_date).days > 30:
|
||
removed += 1 # 30 天前已过期
|
||
continue
|
||
except: pass
|
||
new_cares.append(c)
|
||
if removed > 0:
|
||
d["cares"] = new_cares
|
||
json.dump(d, open(cq_path, "w"), ensure_ascii=False, indent=2)
|
||
actions.append(f"清理 {removed} 条过期/旧 cares")
|
||
log(f" 清理 {removed} 条过期 cares")
|
||
except Exception as e:
|
||
REPORT.append(f"❌ Soulful cares 清理失败: {e}")
|
||
|
||
# 2b. 心迹去重
|
||
ht_path = f"{soulful_dir}/heart-traces.jsonl"
|
||
if os.path.exists(ht_path):
|
||
lines = open(ht_path, encoding="utf-8").readlines()
|
||
seen = set()
|
||
new_lines = []
|
||
dupes = 0
|
||
for line in lines:
|
||
if not line.strip():
|
||
continue
|
||
try:
|
||
e = json.loads(line)
|
||
key = e.get("content", "")[:60] # 按前60字去重
|
||
if key in seen:
|
||
dupes += 1
|
||
continue
|
||
seen.add(key)
|
||
new_lines.append(line)
|
||
except: new_lines.append(line)
|
||
if dupes > 0:
|
||
with open(ht_path, "w", encoding="utf-8") as f:
|
||
f.writelines(new_lines)
|
||
actions.append(f"心迹去重移除 {dupes} 条")
|
||
log(f" 心迹去重: 移除 {dupes} 条")
|
||
|
||
# 2c. 画像字段检查
|
||
profile_path = f"{soulful_dir}/user-profile.json"
|
||
if os.path.exists(profile_path):
|
||
profile = json.load(open(profile_path, encoding="utf-8"))
|
||
empty_fields = [k for k, v in profile.items() if not v or (isinstance(v, dict) and not any(v.values()))]
|
||
if empty_fields:
|
||
actions.append(f"画像 {len(empty_fields)} 个空字段待补充")
|
||
log(f" 画像空字段: {empty_fields}")
|
||
|
||
log(f" Soulful 升级动作: {actions or '无需操作'}")
|
||
return actions
|
||
|
||
# ── Phase 5: Consolidation 引擎 ─────────────────────────────────────────────
|
||
def _consolidate_memories():
|
||
"""
|
||
YantrikDB-style consolidation:
|
||
1. 合并相似记忆(LLM判断重复>80%)
|
||
2. 挖掘跨域模式(journal里跨category的关联)
|
||
3. 生成主动触发器写入 triggers.jsonl
|
||
"""
|
||
import urllib.request as ureq, re, hashlib
|
||
triggers = []
|
||
log("=== Phase 5: Consolidation ===")
|
||
|
||
# 1. 检测织忆重复记忆(通过 time_decay_recall 结果)
|
||
try:
|
||
# 获取织忆 recall 结果,找时间接近+内容相似的
|
||
req_body = json.dumps({"query": "牧尘 工作 项目 决策", "top_k": 10,
|
||
"agent_id": "hermes-a06", "use_rerank": True}).encode()
|
||
req = ureq.Request(f"{ZHIYI_URL}/api/v1/recall", data=req_body,
|
||
headers={"X-API-Key": ZHIYI_KEY, "Content-Type": "application/json"},
|
||
method="POST")
|
||
with ureq.urlopen(req, timeout=8) as resp:
|
||
results = json.loads(resp.read().decode()).get("results", [])
|
||
# 检查时间接近的记忆对(5天内,内容相似度阈值通过 LLM 检测)
|
||
dupes = 0
|
||
for i, r1 in enumerate(results):
|
||
for r2 in results[i+1:]:
|
||
# 简单:内容前50字符相同 → 重复
|
||
if r1.get("content","")[:50] == r2.get("content","")[:50]:
|
||
dupes += 1
|
||
if dupes > 0:
|
||
triggers.append({"type": "dedup_trigger", "severity": "info",
|
||
"content": f"织忆发现 {dupes} 对重复记忆,建议去重",
|
||
"detected_at": datetime.now().isoformat()})
|
||
except Exception as e:
|
||
log(f" Consolidation step1 failed: {e}")
|
||
|
||
# 2. 读 journal,挖掘高频行为模式
|
||
journal_path = f"{HERMES}/daemon/journal.jsonl"
|
||
if os.path.exists(journal_path):
|
||
try:
|
||
lines = open(journal_path).readlines()
|
||
entries = [json.loads(l) for l in lines[-50:] if json.loads(l).get("type") != "startup"]
|
||
# 统计 type 出现频率
|
||
from collections import Counter
|
||
type_counts = Counter(e.get("type","") for e in entries)
|
||
if type_counts:
|
||
top_type, top_count = type_counts.most_common(1)[0]
|
||
if top_count >= 5:
|
||
triggers.append({"type": "pattern_trigger", "severity": "info",
|
||
"content": f"最近50条日志中 '{top_type}' 出现 {top_count} 次,频率较高",
|
||
"detected_at": datetime.now().isoformat()})
|
||
except Exception as e:
|
||
log(f" Consolidation step2 failed: {e}")
|
||
|
||
# 3. 写触发器到文件
|
||
trigger_path = f"{HERMES}/daemon/triggers.jsonl"
|
||
existing = []
|
||
if os.path.exists(trigger_path):
|
||
try:
|
||
existing = [json.loads(l) for l in open(trigger_path) if l.strip()]
|
||
except: pass
|
||
new_triggers = [t for t in triggers
|
||
if not any(ex.get("content") == t.get("content") for ex in existing)]
|
||
if new_triggers:
|
||
existing.extend(new_triggers)
|
||
open(trigger_path, "w").writelines(json.dumps(t, ensure_ascii=False) + "\n" for t in existing[-20:])
|
||
log(f" 新增 {len(new_triggers)} 个触发器,总 {len(existing)} 个")
|
||
# 飞书通知
|
||
try:
|
||
lines = [f"• [{t['type']}] {t['content']}" for t in new_triggers[:5]]
|
||
if lines:
|
||
feishu_alert("🧠 记忆系统触发器提醒",
|
||
"\n".join(lines) + f"\n\n(最近 {len(existing)} 个触发器待处理)")
|
||
except: pass
|
||
else:
|
||
log(" 无新触发器")
|
||
|
||
# 返回可读字符串(与其它子系统 actions 契约一致:list[str])。
|
||
# 2026-09-07 修复:此前直接返回 dict 列表,Phase5 一旦产生新触发器,
|
||
# 汇总报告 '; '.join(acts) 就会 TypeError(sequence item 0: expected str instance, dict found)。
|
||
return [f"[{t.get('type', 'trigger')}] {t.get('content', '')}" for t in triggers]
|
||
|
||
|
||
# ── 主流程 ──────────────────────────────────────────────────────────────────
|
||
if __name__ == "__main__":
|
||
log("记忆系统自我升级开始")
|
||
|
||
# ── L7 统一层:llm_context.json v2 验证 ──────────────────
|
||
try:
|
||
ctx_path = f"{HERMES}/llm_context.json"
|
||
ctx = json.load(open(ctx_path))
|
||
required = ["updated_at", "uptime_minutes", "daemon_status", "distill_status",
|
||
"user_profile", "scenes", "observations", "cares", "recent_moments"]
|
||
missing = [f for f in required if f not in ctx]
|
||
if missing:
|
||
REPORT.append(f"❌ llm_context.json 缺少字段: {missing}")
|
||
else:
|
||
log(f" L7 统一层: ✅ {len(required)} 字段完整")
|
||
st = ctx.get("observations", {})
|
||
if isinstance(st, list):
|
||
log(f" L7 short_term: {len(st)} 条")
|
||
elif isinstance(st, dict):
|
||
log(f" L7 short_term: {len(st)} 组")
|
||
else:
|
||
log(f" L7 short_term: {type(st).__name__}")
|
||
# 检查 distill_status
|
||
ds = ctx.get("distill_status", "unknown")
|
||
log(f" L7 distill_status: {ds}")
|
||
except Exception as e:
|
||
REPORT.append(f"❌ llm_context.json 读取失败: {e}")
|
||
all_actions = {}
|
||
all_actions["织忆"] = upgrade_zhiyi()
|
||
all_actions["Soulful"] = upgrade_soulful()
|
||
all_actions["Consolidation"] = _consolidate_memories()
|
||
|
||
# 汇总报告
|
||
summary = []
|
||
for sys_, acts in all_actions.items():
|
||
if acts:
|
||
summary.append(f"**{sys_}**: {'; '.join(acts)}")
|
||
|
||
if REPORT:
|
||
title = "🔴 记忆系统升级异常"
|
||
content = "\n\n".join(REPORT)
|
||
feishu_alert(title, content)
|
||
print("异常已通知飞书")
|
||
|
||
# ── 每日人话报告(2026-09-05 改造:不再只输出调试日志,输出可读日报)──
|
||
# 组装自进化/记忆质量日报,正常也输出(cron deliver 到飞书群即成为"记忆日报")
|
||
print("\n📊 记忆系统日报 " + datetime.now().strftime("%Y-%m-%d"))
|
||
print("=" * 40)
|
||
for sys_, acts in all_actions.items():
|
||
if acts:
|
||
print(f"• {sys_}: {'; '.join(acts)}")
|
||
else:
|
||
print(f"• {sys_}: 无需动作")
|
||
if not REPORT and not summary:
|
||
print("• 状态: 三套记忆系统健康,自进化引擎运行中")
|
||
# 自进化指标行(若织忆自检收集到)
|
||
if "织忆" in all_actions and all_actions["织忆"]:
|
||
zacts = all_actions["织忆"]
|
||
zhits = [a for a in zacts if a.startswith("recall_hit") or a.startswith("episodes=")]
|
||
if zhits:
|
||
print("• 织忆数据: " + " | ".join(zhits))
|
||
print("=" * 40)
|
||
if REPORT:
|
||
print(f"⚠️ 发现 {len(REPORT)} 项异常(已飞书告警)")
|
||
else:
|
||
print("✅ 无异常") |