xiaowei-system/scripts/memory_recall.py

354 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
统一记忆入口 (memory_recall.py)
同时查询织忆、TencentDB、Soulful 三套记忆系统,合并去重后输出统一上下文。
"""
import argparse
import json
import threading
import urllib.error
import urllib.request
from datetime import datetime
from pathlib import Path
# ── 常量 ──────────────────────────────────────────────────────────────────────
ZHIYI_URL = "http://localhost:7821/api/v1/recall"
ZHIYI_HEADERS = {
"X-API-Key": "zhiyi-dev-key-2026",
"Content-Type": "application/json",
}
TENCENTDB_URL = "http://localhost:8420"
SOULFUL_DIR = Path.home() / ".hermes" / "soulful"
DEFAULT_TOP_K = 3
TIMEOUT_SEC = 5
# ── 工具函数 ──────────────────────────────────────────────────────────────────
def http_post(url: str, payload: dict, headers: dict | None = None, timeout: int = TIMEOUT_SEC) -> dict | None:
"""POST JSON返回解析后的 dict失败返回 None 并打印 warning。"""
try:
req = urllib.request.Request(
url,
data=json.dumps(payload).encode("utf-8"),
headers=headers or {},
method="POST",
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
except Exception as e:
print(f" [warning] {url} -> {e}", flush=True)
return None
def read_json_file(path: Path) -> dict | list | None:
"""读取 JSON 文件,失败返回 None 并打印 warning。"""
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception as e:
print(f" [warning] read {path} -> {e}", flush=True)
return None
def read_jsonl(path: Path):
"""逐行读取 JSONLyeild dict失败打印 warning 并跳过。"""
try:
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line:
continue
try:
yield json.loads(line)
except json.JSONDecodeError:
pass
except Exception as e:
print(f" [warning] read {path} -> {e}", flush=True)
# ── 查询函数(供线程调用) ──────────────────────────────────────────────────────
def query_zhiyi(query: str, top_k: int) -> dict:
result = http_post(ZHIYI_URL, {"query": query, "top_k": top_k}, ZHIYI_HEADERS)
if result is None:
return {"error": True, "results": []}
return {"error": False, "results": result.get("results", [])}
def query_tencentdb_l1(query: str, top_k: int) -> dict:
result = http_post(
f"{TENCENTDB_URL}/search/memories",
{"query": query, "top_k": top_k},
)
if result is None:
return {"error": True, "results": []}
return {"error": False, "results": result.get("results", [])}
def query_tencentdb_l0(query: str, top_k: int) -> dict:
result = http_post(
f"{TENCENTDB_URL}/search/conversations",
{"query": query, "top_k": top_k},
)
if result is None:
return {"error": True, "results": []}
return {"error": False, "results": result.get("results", [])}
# ── Soulful 读取 ───────────────────────────────────────────────────────────────
def load_soulful() -> dict:
"""加载 Soulful 三件套,返回聚合 dict各文件缺失不报错。"""
profile = read_json_file(SOULFUL_DIR / "user-profile.json") or {}
cares_data = read_json_file(SOULFUL_DIR / "cares-queue.json") or {}
heart_traces = list(read_jsonl(SOULFUL_DIR / "heart-traces.jsonl"))
return {
"error": False,
"profile": profile,
"cares": cares_data.get("cares", []) if isinstance(cares_data, dict) else [],
"heart_traces": heart_traces,
}
# ── 并行查询 ───────────────────────────────────────────────────────────────────
def parallel_query(query: str, top_k: int):
"""用三个线程同时查询三个后端 + Soulful 文件,返回 (zhiyi, l1, l0, soulful)。"""
def run(query: str, top_k: int, fn):
return fn(query, top_k)
threads = []
zhiyi_result = {}
l1_result = {}
l0_result = {}
soulful_result = {}
t1 = threading.Thread(target=lambda: zhiyi_result.update(run(query, top_k, query_zhiyi)))
t2 = threading.Thread(target=lambda: l1_result.update(run(query, top_k, query_tencentdb_l1)))
t3 = threading.Thread(target=lambda: l0_result.update(run(query, top_k, query_tencentdb_l0)))
t4 = threading.Thread(target=lambda: soulful_result.update(load_soulful()))
for t in (t1, t2, t3, t4):
t.start()
for t in (t1, t2, t3, t4):
t.join()
return zhiyi_result, l1_result, l0_result, soulful_result
# ── 格式化输出 ────────────────────────────────────────────────────────────────
import re
def _fmt_val(v):
"""格式化 profile 中的 valuedict/list 走 JSON.dumps其余直接转 str。"""
if isinstance(v, (dict, list)):
return json.dumps(v, ensure_ascii=False)
return str(v)
def format_pretty(zhiyi, l1, l0, soulful):
"""pretty 模式:分区块显示。"""
lines = []
# ── 语义记忆(织忆) ──
if zhiyi.get("error"):
lines.append("## 语义记忆(织忆)")
lines.append("暂不可用")
else:
lines.append("## 语义记忆(织忆)")
for item in zhiyi.get("results", []):
if isinstance(item, dict):
content = item.get("content", "")
score = item.get("score", 0.0)
cat = item.get("category", "")
ts = item.get("timestamp", "")
else:
content = str(item)
score, cat, ts = 0.0, "", ""
cat_str = f" [{cat}]" if cat else ""
ts_str = f" ({ts})" if ts else ""
lines.append(f"[{content}]{cat_str}{ts_str} (score: {score:.2f})")
lines.append("")
# ── 人格记忆TencentDB L1 ──
if l1.get("error"):
lines.append("## 人格记忆TencentDB")
lines.append("暂不可用\n")
else:
lines.append("## 人格记忆TencentDB")
results = l1.get("results", [])
# TencentDB 返回的 results 是 markdown 字符串,不是 JSON 数组
if isinstance(results, str):
# 整段输出,不拆分
lines.append(results)
else:
for item in results:
if isinstance(item, dict):
content = item.get("content", "")
score = item.get("score", 0.0)
ptype = item.get("type", "instruction")
scene = item.get("scene", "")
else:
content = str(item)
score, ptype, scene = 0.0, "instruction", ""
scene_str = f" [{scene}]" if scene else ""
lines.append(f"[type: {ptype}]{scene_str}")
lines.append(f"[{content}] (score: {score:.2f})")
lines.append("")
# ── 对话记忆TencentDB L0 ──
if l0.get("error"):
lines.append("## 对话记忆TencentDB L0")
lines.append("暂不可用\n")
else:
lines.append("## 对话记忆TencentDB L0")
results = l0.get("results", [])
# TencentDB 返回的 results 是 markdown 字符串,不是 JSON 数组
if isinstance(results, str):
lines.append(results)
else:
for item in results:
if isinstance(item, dict):
msg = item.get("message", "")
score = item.get("score", 0.0)
ts = item.get("timestamp", "")
session = item.get("session", "")
else:
msg = str(item)
score, ts, session = 0.0, "", ""
session_str = f"[{session}]" if session else ""
ts_str = f" {ts}" if ts else ""
lines.append(f"{session_str}{ts_str}")
lines.append(f"[{msg}] (score: {score:.2f})")
lines.append("")
# ── 关系感知Soulful ──
lines.append("## 关系感知Soulful")
if soulful.get("error"):
lines.append("暂不可用\n")
else:
# 画像
lines.append("### 画像")
profile = soulful.get("profile", {})
if profile:
for key, value in profile.items():
lines.append(f"{key}: {_fmt_val(value)}")
else:
lines.append("(无)")
# 牵挂
lines.append("\n### 待关注(牵挂)")
cares = soulful.get("cares", [])
if cares:
for item in cares:
content = item.get("content", "")
due = item.get("follow_up_date") or item.get("due") or "null"
lines.append(f"- [{content}] (due: {due})")
else:
lines.append("(无)")
# 心迹
lines.append("\n### 心迹")
heart_traces = soulful.get("heart_traces", [])
if heart_traces:
for item in heart_traces:
ts = item.get("timestamp", "")
content = item.get("content", "")
lines.append(f"[{ts}] [{content}]")
else:
lines.append("(无)")
return "\n".join(lines)
def format_compact(zhiyi, l1, l0, soulful):
"""compact 模式:单行动态摘要。"""
parts = []
# 织忆
if not zhiyi.get("error"):
results = zhiyi.get("results", [])
if results:
top = results[0]
parts.append(f"织忆/{top.get('content', '')[:40]}(s={top.get('score',0):.2f})")
# L1 - TencentDB returns string for markdown, list for parsed
if not l1.get("error"):
results = l1.get("results", [])
if isinstance(results, str):
# Markdown string - extract content from first meaningful line
# L1 format: "- **[persona/instruction]** ..." then blank then " content"
# or " content" appears on 2nd or 3rd non-empty line
lines = [l for l in results.split("\n") if l.strip() and not l.startswith("Found") and not l.startswith("---")]
if len(lines) >= 2:
# Second line has the actual content text (after blank line)
content = lines[1].strip().lstrip("-* []").strip()
parts.append(f"人格:{content[:35]}...")
elif lines:
content = lines[0].strip().lstrip("-* []").strip()
parts.append(f"人格:{content[:35]}")
elif results:
top = results[0]
ptype = top.get("type", "?")
parts.append(f"人格/{ptype}:{top.get('content', '')[:30]}(s={top.get('score',0):.2f})")
# L0 - TencentDB returns string for markdown, list for parsed
if not l0.get("error"):
results = l0.get("results", [])
if isinstance(results, str):
# L0 format: "**[user]** Session: ... (score: X)\n\nMESSAGE CONTENT\n\n---..."
# Match header line + blank line + first non-empty content line
m = re.search(r'\*\*\[(user|assistant)\]\*\*.*?\n\n(.+?)(?:\n\n|---\Z)', results, re.DOTALL)
if m:
msg = m.group(2).strip()[:40]
parts.append(f"对话:{msg}")
elif "**[user]**" in results:
for line in results.split("\n"):
if "**[user]**" in line or "**[assistant]**" in line:
parts.append(f"对话:{line[:50]}")
break
elif results:
top = results[0]
msg = top.get("message", "")[:40]
parts.append(f"对话:{msg}(s={top.get('score',0):.2f})")
# Soulful 牵挂
if not soulful.get("error"):
cares = soulful.get("cares", [])
if cares:
parts.append(f"牵挂:{cares[0].get('content', '')[:30]}")
if not parts:
return "[memory] no results"
return "[memory] " + " | ".join(parts)
# ── 入口 ──────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description="统一记忆入口")
parser.add_argument("query", help="查询文本")
parser.add_argument("--top-k", type=int, default=DEFAULT_TOP_K, help=f"每系统返回条数 (default: {DEFAULT_TOP_K})")
parser.add_argument("--format", choices=["pretty", "compact"], default="pretty", help="输出格式 (default: pretty)")
args = parser.parse_args()
# 实际并行查询
zhiyi, l1, l0, soulful = parallel_query(args.query, args.top_k)
if args.format == "compact":
print(format_compact(zhiyi, l1, l0, soulful))
else:
print(format_pretty(zhiyi, l1, l0, soulful))
# 三个系统全挂 → 退出码 1
if zhiyi.get("error") and l1.get("error") and l0.get("error") and soulful.get("error"):
raise SystemExit(1)
if __name__ == "__main__":
main()