diff --git a/.gitignore b/.gitignore index a692c65a2..38b0f3a4c 100755 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,10 @@ htmlcov/ *.sqlite *.sqlite-journal test-results/ + +# eval memory snapshots: PG dump + full Neo4j JSON export (~hundreds of MB +# each, regenerable from a fresh ingest). Same rationale as results/. +evals/snapshots/ test_*.db *.log .persist \ No newline at end of file diff --git a/archive/README.md b/archive/README.md new file mode 100644 index 000000000..8ab006033 --- /dev/null +++ b/archive/README.md @@ -0,0 +1,46 @@ +# Archive — superseded code + +Modules retired from the live tree because nothing in the current design +(graph_version v7.x / v8: hypergraph ingest hooks, V7Retriever with rerank, +gated auto_dream) reaches them. Preserved here — and in full per-version +history at the `graph_revision_pre_squash` tag — rather than deleted. + +## legacy_graph/ + +The pre-v7 graph generations and rejected v7 extraction variants: + +- `episodic_graph_manager.py`, `semantic_graph_manager.py`, `lightrag_merger.py` + — the v5 dual-graph ingest builders (Episode/EpisodicEntity/Concept labels). +- `episodic_graph_retriever.py`, `semantic_graph_retriever.py`, + `lightrag_keyword_extractor.py`, `_graph_retriever_base.py` — the v5 + retrieval pipeline (keyword-LLM call + per-graph retrievers + budget split). +- `graph_memory_manager_v6.py`, `graph_retriever_v6.py` — the v6 lean entity + index. +- `proposition_extractor.py` — v7.3 proposition ingest (rejected: collapsed the + edge structure). +- `gliner_extractor.py` — v7.4 GLiNER extraction (superseded by direction D: + a span tagger cannot abstract concepts). + +The dispatcher and the episodic/semantic ingest hooks now warn-and-skip for +graph versions outside the v7 family instead of running these. + +Also removed from live files (recoverable from git history, documented in +`docs/graph_memory_v7/development_history.md`): the v7.7 PPR and v7.2 coverage +retrieval branches and the v7.9 role-split rendering in `graph_retriever_v7.py`, +and the rejected answerer gates (v7.11 consolidate tool, persona store, +graph-search merge) in `evals/task_agent.py`. A second audited sweep also +removed: the never-measured MIRIX_GRAPH_ROUTED_SEARCH gate and its helper in +rest_api.py, the v5/v6 Neo4j DDL and neo4j_healthcheck, the LightRAG relation +output surface (the live v7/v8 path reads entities only), four orphaned +lightrag prompt symbols, token_tracker.set_phase, the vestigial dispatcher +budget parameters, and EpisodicEventUpdate.source_refs. + +## evals/ + +Eval-side scripts for rejected or superseded experiments: `build_persona.py` +(persona store, QA-negative), `refine_hypergraph.py` (one-off corpus cleanup, +superseded by ingest-time prevention + maintenance passes), +`proposition_ingest.py` (v7.3 driver), `run_v4_sample0.sh`, +`visualize_v6_graph.py`, `draw_hypergraph.py` (viz one-off). (`memory_snapshot.py` was initially archived here too, +then restored — the MAB runner scripts call it for post-run snapshots; the +"unreferenced" scan had only covered *.py, not *.sh.) diff --git a/archive/evals/build_persona.py b/archive/evals/build_persona.py new file mode 100644 index 000000000..5cdd52561 --- /dev/null +++ b/archive/evals/build_persona.py @@ -0,0 +1,61 @@ +"""Build a compact, structured PERSONA profile for a LongMemEval user from the +existing memory store (no re-ingest). Targets the preference-synthesis questions +(Q7/Q11/Q44...) whose gold answers require grounding advice in the user's own +history — hobbies, past successes, stated likes/dislikes, work/social situation. + +The persona is a pre-synthesized "who is this user" summary so the answerer does +not have to reconstruct it from scattered search hits on open-ended "any tips?" +questions. Written to persona_.txt; TaskAgent injects it when MIRIX_PERSONA +is set. LaMP-PAG / PersonaAgent style. +""" +import os +import sys +import psycopg2 +from openai import OpenAI + +USER = sys.argv[1] if len(sys.argv) > 1 else "longmem_s_0" +PG_DB = os.environ.get("MIRIX_PG_DB") or os.environ.get("PG_DB", "mirix_lm114_pm") +OUT = os.path.expanduser(f"~/MIRIX_eval/persona_{USER}.txt") + +conn = psycopg2.connect(host="localhost", port=5432, user="mirix", password="mirix", dbname=PG_DB) +cur = conn.cursor() +# User's own statements (episodic actor='user') + stable facts (semantic) = the +# raw material for a persona. Assistant turns are advice, not the user's identity. +cur.execute("SELECT occurred_at, summary FROM episodic_memory WHERE user_id=%s AND is_deleted=false " + "AND actor='user' ORDER BY occurred_at", (USER,)) +ep = cur.fetchall() +cur.execute("SELECT name, summary FROM semantic_memory WHERE user_id=%s AND is_deleted=false", (USER,)) +sem = cur.fetchall() +cur.close(); conn.close() + +lines = [f"[{str(o)[:10]}] {s}" for o, s in ep if s] + [f"{n}: {s}" for n, s in sem if n] +context = "\n".join(lines) + +PROMPT = ( + "Below are a user's own statements over time plus stable facts about them. Build a " + "concise PERSONA PROFILE that captures WHO THIS USER IS, so an assistant can give " + "personalized advice grounded in their history (not generic tips).\n\n" + "Organize by life domain (e.g. Food & Baking, Art & Creativity, Fitness, Work & " + "Career, Social & Relationships, Travel, Hobbies, Home & Pets, Reading, Finance). " + "For each domain that applies, capture in 1-3 tight bullets:\n" + " - what they DO / are into (recurring activities, tools, brands they use)\n" + " - notable PAST experiences / successes / projects (e.g. 'baked a lemon poppyseed " + "cake that went well', 'did a 30-day painting challenge')\n" + " - stated PREFERENCES: likes, dislikes, constraints, what they value\n" + "Be specific and name concrete things (recipes, apps, accounts, places, people). " + "Skip domains with no signal. Keep the whole profile under ~450 words. Output plain " + "text with domain headers and bullets, no preamble.\n\n" + "USER STATEMENTS & FACTS:\n" + context +) + +client = OpenAI(api_key=os.environ["OPENAI_API_KEY"]) +resp = client.chat.completions.create( + model="gpt-4.1-mini", temperature=0, + messages=[{"role": "user", "content": PROMPT}], +) +persona = resp.choices[0].message.content.strip() +with open(OUT, "w", encoding="utf-8") as f: + f.write(persona) +print(f"wrote {OUT} ({len(persona)} chars, from {len(ep)} user-episodic + {len(sem)} semantic)") +print("=" * 60) +print(persona) diff --git a/archive/evals/draw_hypergraph.py b/archive/evals/draw_hypergraph.py new file mode 100644 index 000000000..93e6ba6d3 --- /dev/null +++ b/archive/evals/draw_hypergraph.py @@ -0,0 +1,89 @@ +"""Render a v7.10 HYPERGRAPH neighborhood: V7Fact nodes (reified triples) as +hyperedges linking a SUBJECT anchor + OBJECT anchor + the source MEMORY, stamped +with role. Shows the distinctive shape — facts are first-class nodes, not edges. +""" +import os +import textwrap +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import matplotlib.patches as mpatches +import networkx as nx +from neo4j import GraphDatabase + +U = "longmem_s_0" +d = GraphDatabase.driver("bolt://localhost:7687", auth=("neo4j", "mirix_neo4j_dev")) + +# Two thematically-clean memories (a travel/flight cluster) so the picture reads. +SEED_MEMS = ["ep_DU49", "ep_EJ78"] +rows = [] +with d.session() as s: + # Pick facts seeded from these memories, then show ALL their source memories so the + # post-refinement multi-citation structure (one fact, N citations) is visible. + q = """ + MATCH (f:V7Fact {user_id:$u})-[:V7_FACT_FROM]->(seed:V7MemoryRef) + WHERE seed.memory_id IN $mids + MATCH (subj:V7Anchor)<-[:V7_FACT_SUBJECT]-(f)-[:V7_FACT_OBJECT]->(obj:V7Anchor) + MATCH (f)-[:V7_FACT_FROM]->(m:V7MemoryRef) + WHERE subj.name IS NOT NULL AND obj.name IS NOT NULL + RETURN subj.name AS s, f.predicate AS p, obj.name AS o, f.role AS role, + collect(DISTINCT m.memory_id) AS mids + """ + for r in s.run(q, u=U, mids=SEED_MEMS): + for mid in r["mids"]: + rows.append((r["s"], r["p"], r["o"], r["role"] or "shared", mid)) +d.close() + +G = nx.Graph() +kind = {} +def short(t, w=16): + return "\n".join(textwrap.wrap(t, w))[:60] + +for s_, p, o, role, mid in rows: + # key the fact by its triple so a fact cited by N memories stays ONE node + fid = f"F::{s_}|{p}|{o}" + sN, oN, mN = f"A::{s_}", f"A::{o}", f"M::{mid}" + for n, k, lbl in [(sN, "anchor", short(s_)), (oN, "anchor", short(o)), + (mN, "memory", mid), (fid, "fact", p)]: + if n not in G: + G.add_node(n, label=lbl); kind[n] = k + G.add_edge(fid, sN, etype="subj") + G.add_edge(fid, oN, etype="obj") + G.add_edge(fid, mN, etype="from") + +pos = nx.spring_layout(G, k=0.9, iterations=200, seed=7) +plt.figure(figsize=(17, 12)) + +col = {"anchor": "#4C78A8", "memory": "#E45756", "fact": "#F2C744"} +size = {"anchor": 1500, "memory": 2600, "fact": 700} +for k in col: + ns = [n for n in G if kind[n] == k] + nx.draw_networkx_nodes(G, pos, nodelist=ns, node_color=col[k], node_size=size[k], + node_shape=("s" if k == "memory" else ("D" if k == "fact" else "o")), + edgecolors="white", linewidths=1.5) +ecol = {"subj": "#59A14F", "obj": "#B279A2", "from": "#BAB0AC"} +for et, c in ecol.items(): + es = [(a, b) for a, b, dd in G.edges(data=True) if dd["etype"] == et] + nx.draw_networkx_edges(G, pos, edgelist=es, edge_color=c, width=1.6, alpha=0.8) + +anchor_lbls = {n: G.nodes[n]["label"] for n in G if kind[n] == "anchor"} +mem_lbls = {n: G.nodes[n]["label"] for n in G if kind[n] == "memory"} +nx.draw_networkx_labels(G, pos, labels=anchor_lbls, font_size=7.5, font_color="white") +nx.draw_networkx_labels(G, pos, labels=mem_lbls, font_size=9, font_color="white", font_weight="bold") +fact_lbls = {n: G.nodes[n]["label"] for n in G if kind[n] == "fact"} +nx.draw_networkx_labels(G, pos, labels=fact_lbls, font_size=6, font_color="#5a4a00") + +legend = [mpatches.Patch(color=col["anchor"], label="V7Anchor (entity/concept)"), + mpatches.Patch(color=col["fact"], label="V7Fact (reified triple = HYPEREDGE)"), + mpatches.Patch(color=col["memory"], label="V7MemoryRef (source memory)"), + mpatches.Patch(color=ecol["subj"], label="FACT_SUBJECT"), + mpatches.Patch(color=ecol["obj"], label="FACT_OBJECT"), + mpatches.Patch(color=ecol["from"], label="FACT_FROM")] +plt.legend(handles=legend, loc="upper left", fontsize=9, framealpha=0.9) +plt.title(f"v7.10 hypergraph neighborhood — {len(rows)} V7Fact hyperedges from 2 memories " + f"(longmem_s_0)\nEach yellow diamond is a fact linking a subject + object anchor to its source memory", + fontsize=12) +plt.axis("off"); plt.tight_layout() +OUT = os.path.expanduser("~/MIRIX_eval/hypergraph_neighborhood.png") +plt.savefig(OUT, dpi=130, bbox_inches="tight") +print(f"wrote {OUT} ({len(rows)} facts, {G.number_of_nodes()} nodes, {G.number_of_edges()} edges)") diff --git a/archive/evals/proposition_ingest.py b/archive/evals/proposition_ingest.py new file mode 100644 index 000000000..951da6f1b --- /dev/null +++ b/archive/evals/proposition_ingest.py @@ -0,0 +1,116 @@ +"""v7.3 ingest: build LongMemEval-S / MAB memory from ATOMIC PROPOSITIONS. + +ONE LLM call per chunk emits both the propositions and their typed entities. Each +proposition becomes a fine-grained semantic_memory (embedded); its entities are +handed straight to the V7 graph build, which consumes only name + entity_type. +LightRAG is never invoked -- it was ~97% of graph-build time, and its relations +and descriptions were discarded by v7 anyway. + +Env: + MIRIX_PG_DB target DB (schema must exist -- start the server against it once) + MIRIX_ENABLE_GRAPH_MEMORY=true, MIRIX_GRAPH_VERSION=v7.3 + SOURCE HF metadata.source (default longmemeval_s*) + MAX_CHUNKS optional cap for a smoke test +Usage: python evals/mab/proposition_ingest.py +""" +import asyncio +import datetime +import os +import sys +import time +import types + +sys.path.insert(0, os.path.dirname(__file__)) +from sqlalchemy import text as sa_text + +import longmem_eval as L +from mirix.constants import MAX_EMBEDDING_DIM +from mirix.database.neo4j_client import get_neo4j_driver, init_neo4j_client +from mirix.schemas.embedding_config import EmbeddingConfig +from mirix.schemas.llm_config import LLMConfig +from mirix.server.server import db_context +from mirix.services._graph_common import embed_batch +from mirix.services.graph_memory_manager_v7 import V7GraphManager +from mirix.services.lightrag_extractor import ExtractedEntity +from mirix.services.proposition_extractor import extract_propositions_with_entities +from mirix.settings import settings + +EMB = EmbeddingConfig( + embedding_endpoint_type="openai", embedding_endpoint="https://api.openai.com/v1", + embedding_model="text-embedding-3-small", embedding_dim=1536, embedding_chunk_size=300) +LLM = LLMConfig(model="gpt-4.1-mini", model_endpoint_type="openai", + model_endpoint="https://api.openai.com/v1", context_window=128000) +AST = types.SimpleNamespace(embedding_config=EMB, llm_config=LLM) +UID, ORG = "longmem_s_0", "mirix-eval-org" +SOURCE = os.environ.get("SOURCE", "longmemeval_s*") +MAXC = int(os.environ.get("MAX_CHUNKS", "0")) or None +API_KEY = os.environ["OPENAI_API_KEY"] + +INSERT = sa_text( + "INSERT INTO semantic_memory (id,name,summary,details,source,source_refs,prior_values," + "last_modify,created_at,is_deleted,user_id,organization_id,summary_embedding) VALUES " + "(:id,:nm,:sm,:dt,:src,'[]','[]','{}',:ts,false,:u,:o,CAST(:emb AS vector))") + + +async def main(): + await init_neo4j_client() + drv = get_neo4j_driver() + async with drv.session(database=settings.neo4j_database) as s: + # consume() so the delete actually completes before we start writing + await (await s.run("MATCH (n) DETACH DELETE n")).consume() + left = (await (await s.run("MATCH (n) RETURN count(n) AS c")).single())["c"] + print(f"neo4j wiped (remaining nodes: {left})", flush=True) + + it = L.load_longmem_s(source=SOURCE, limit=1)[0] + chunks = L.parse_sessions(it["context"]) + if MAXC: + chunks = chunks[:MAXC] + print(f"chunks={len(chunks)} source={SOURCE}", flush=True) + + mgr = V7GraphManager() + total = n_ents = 0 + t0 = time.time() + for ci, ch in enumerate(chunks, 1): + props = await extract_propositions_with_entities(ch["text"], api_key=API_KEY) + if not props: + print(f"chunk {ci}/{len(chunks)}: no propositions", flush=True) + continue + + embs = await embed_batch([p.text for p in props], AST) + rows = [] # (memory_id, Proposition, embedding) + for p, e in zip(props, embs): + if e is None: + continue + total += 1 + rows.append((f"prop_{total:06d}", p, e)) + + async with db_context() as session: + for mid, p, e in rows: + epad = str(list(e) + [0.0] * (MAX_EMBEDDING_DIM - len(e))) + await session.execute(INSERT, { + "id": mid, "nm": p.text[:120], "sm": p.text, "dt": p.text, + "src": f"chunk_{ci - 1}", "ts": datetime.datetime.utcnow(), + "u": UID, "o": ORG, "emb": epad}) + await session.commit() + + for mid, p, _ in rows: + n_ents += len(p.entities) + try: + await mgr.process_memory( + source_kind="semantic", source_id=mid, text=p.text, title=p.text[:80], + summary=p.text, source_meta=None, agent_state=AST, + organization_id=ORG, user_id=UID, + entities=[ExtractedEntity(name=x.name, entity_type=x.type, description="") + for x in p.entities]) + except Exception as ex: # noqa: BLE001 + print(f" graph fail {mid}: {str(ex)[:80]}", flush=True) + + print(f"chunk {ci}/{len(chunks)} props={total} entities={n_ents} " + f"(+{time.time() - t0:.0f}s)", flush=True) + + print(f"DONE: {total} propositions, {n_ents} entity mentions, " + f"{time.time() - t0:.0f}s, {len(chunks)} LLM extraction calls", flush=True) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/archive/evals/refine_hypergraph.py b/archive/evals/refine_hypergraph.py new file mode 100644 index 000000000..b47667426 --- /dev/null +++ b/archive/evals/refine_hypergraph.py @@ -0,0 +1,116 @@ +"""Refine the v7.10 hypergraph: remove redundancy without losing information. + +Four passes (dry-run by default; pass --apply to write): + 1. TAUTOLOGY drop facts whose subject == object ("french -include-> french") + 2. DEDUPE collapse identical (subject, predicate, object) into ONE V7Fact, + keeping every source as an extra V7_FACT_FROM edge — provenance + is preserved, only the duplicated node goes away. This is the + correct hypergraph semantics: a fact is one fact, cited N times. + 3. PREDICATE canonicalize surface variants (includes->include, is located in + -> located in) so the same relation is one relation + 4. PRUNE delete anchors no fact touches AND that link <=1 memory (dead + weight: they can neither answer nor bridge) +""" +import re +import sys +from collections import defaultdict + +from neo4j import GraphDatabase + +APPLY = "--apply" in sys.argv +U = "longmem_s_0" +d = GraphDatabase.driver("bolt://localhost:7687", auth=("neo4j", "mirix_neo4j_dev")) + +AUX = re.compile(r"^(is|are|was|were|be|been|being)\s+", re.I) + + +def canon_pred(p: str) -> str: + """Canonical predicate: lowercase, drop leading copula, singularize the verb.""" + if not p: + return p + p = AUX.sub("", p.lower().strip()) + toks = p.split() + if toks: + w = toks[0] + # includes->include, offers->offer, provides->provide; keep has/is/ss-words + if len(w) > 4 and w.endswith("s") and not w.endswith(("ss", "us", "is", "as")): + toks[0] = w[:-1] + return " ".join(toks) + + +def stats(s, tag): + f = s.run("MATCH (f:V7Fact {user_id:$u}) RETURN count(f) AS c", u=U).single()["c"] + a = s.run("MATCH (a:V7Anchor {user_id:$u}) RETURN count(a) AS c", u=U).single()["c"] + p = s.run("MATCH (f:V7Fact {user_id:$u}) WHERE f.predicate IS NOT NULL " + "RETURN count(DISTINCT f.predicate) AS c", u=U).single()["c"] + e = s.run("MATCH (n {user_id:$u})-[r]->() RETURN count(r) AS c", u=U).single()["c"] + print(f" [{tag}] facts={f} anchors={a} distinct_predicates={p} edges={e}") + return f, a, p, e + + +with d.session() as s: + print("=== BEFORE ===") + stats(s, "before") + + # ---- 1. tautologies ---- + taut = s.run("""MATCH (x)<-[:V7_FACT_SUBJECT]-(f:V7Fact {user_id:$u})-[:V7_FACT_OBJECT]->(y) + WHERE toLower(x.name)=toLower(y.name) RETURN count(f) AS c""", u=U).single()["c"] + print(f"\n1. TAUTOLOGY self-loop facts (subject==object): {taut}") + if APPLY and taut: + s.run("""MATCH (x)<-[:V7_FACT_SUBJECT]-(f:V7Fact {user_id:$u})-[:V7_FACT_OBJECT]->(y) + WHERE toLower(x.name)=toLower(y.name) DETACH DELETE f""", u=U) + + # ---- 2. dedupe identical triples, keeping provenance ---- + dup_rows = s.run(""" + MATCH (su)<-[:V7_FACT_SUBJECT]-(f:V7Fact {user_id:$u})-[:V7_FACT_OBJECT]->(o) + WITH toLower(su.name)+'|'+toLower(coalesce(f.predicate,''))+'|'+toLower(o.name) AS k, + collect(f.id) AS ids + WHERE size(ids) > 1 RETURN k, ids""", u=U).data() + dropped = sum(len(r["ids"]) - 1 for r in dup_rows) + print(f"2. DEDUPE duplicate triples: {len(dup_rows)} groups, {dropped} redundant fact nodes " + f"(sources re-attached to the survivor)") + if APPLY: + for r in dup_rows: + keep, rest = r["ids"][0], r["ids"][1:] + s.run(""" + MATCH (keep:V7Fact {id:$keep}) + UNWIND $rest AS rid + MATCH (dupe:V7Fact {id:rid})-[:V7_FACT_FROM]->(m:V7MemoryRef) + MERGE (keep)-[:V7_FACT_FROM]->(m)""", keep=keep, rest=rest) + s.run("UNWIND $rest AS rid MATCH (dupe:V7Fact {id:rid}) DETACH DELETE dupe", rest=rest) + + # ---- 3. canonical predicates ---- + preds = [r["p"] for r in s.run("MATCH (f:V7Fact {user_id:$u}) WHERE f.predicate IS NOT NULL " + "RETURN DISTINCT f.predicate AS p", u=U)] + mapping = {p: canon_pred(p) for p in preds if canon_pred(p) != p} + groups = defaultdict(list) + for p in preds: + groups[canon_pred(p)].append(p) + merged = sum(len(v) - 1 for v in groups.values() if len(v) > 1) + print(f"3. PREDICATE {len(preds)} distinct -> {len(groups)} canonical ({merged} variants merged)") + for k, v in sorted(groups.items(), key=lambda x: -len(x[1]))[:5]: + if len(v) > 1: + print(f" '{k}' <- {v[:4]}") + if APPLY and mapping: + for old, new in mapping.items(): + s.run("MATCH (f:V7Fact {user_id:$u}) WHERE f.predicate=$old SET f.predicate=$new", + u=U, old=old, new=new) + + # ---- 4. prune dead-weight anchors ---- + dead = s.run("""MATCH (a:V7Anchor {user_id:$u}) + WHERE NOT (a)<-[:V7_FACT_SUBJECT|V7_FACT_OBJECT]-(:V7Fact) + WITH a, size([(a)-[:V7_APPEARS_IN|V7_DESCRIBED_BY]->(:V7MemoryRef)|1]) AS deg + WHERE deg <= 1 RETURN count(a) AS c""", u=U).single()["c"] + print(f"4. PRUNE dead-weight anchors (no fact, <=1 memory): {dead}") + if APPLY and dead: + s.run("""MATCH (a:V7Anchor {user_id:$u}) + WHERE NOT (a)<-[:V7_FACT_SUBJECT|V7_FACT_OBJECT]-(:V7Fact) + WITH a, size([(a)-[:V7_APPEARS_IN|V7_DESCRIBED_BY]->(:V7MemoryRef)|1]) AS deg + WHERE deg <= 1 DETACH DELETE a""", u=U) + + if APPLY: + print("\n=== AFTER ===") + stats(s, "after") + else: + print("\n(dry run — rerun with --apply to write)") +d.close() diff --git a/archive/evals/run_v4_sample0.sh b/archive/evals/run_v4_sample0.sh new file mode 100755 index 000000000..1581449eb --- /dev/null +++ b/archive/evals/run_v4_sample0.sh @@ -0,0 +1,81 @@ +#!/bin/bash +# Run v4 graph-memory eval on LoCoMo sample 0 (conv-26) end-to-end. +# +# Prereqs: +# 1. `.env` has OPENAI_API_KEY and MIRIX_ENABLE_GRAPH_MEMORY=true +# 2. Infra is up: docker compose --profile graph up -d +# 3. Server is up: http://localhost:8531/health returns 200 +# +# Outputs: +# evals/results/locomo/v4_/conv-26.json — per-question records +# evals/results/locomo/v4_/metrics.json — LLM-judge accuracy +# +# Final stdout prints overall accuracy + per-category breakdown. + +set -euo pipefail +cd "$(dirname "$0")/.." + +# ---- pre-flight ------------------------------------------------------------ +if ! lsof -ti:8531 >/dev/null 2>&1; then + echo "ERROR: nothing listening on :8531" >&2 + echo " start the stack first: docker compose --profile graph up -d" >&2 + exit 1 +fi + +python - <<'PY' || { echo "ERROR: server /health not 200" >&2; exit 1; } +import sys, urllib.request +try: + r = urllib.request.urlopen("http://localhost:8531/health", timeout=5) + sys.exit(0 if r.status == 200 else 2) +except Exception as e: + print(f" health probe: {e}", file=sys.stderr) + sys.exit(3) +PY + +if [ ! -f locomo10.json ]; then + echo "ERROR: locomo10.json not in repo root" >&2 + echo " download from the LoCoMo benchmark and place at $(pwd)/locomo10.json" >&2 + exit 1 +fi + +# ---- run ------------------------------------------------------------------- +cd evals +TS=$(date +%Y%m%d_%H%M%S) +OUT="v4_${TS}" + +echo "[1/2] running main_eval.py → results/locomo/${OUT}/" +python main_eval.py \ + --data ../locomo10.json \ + --limit 1 \ + --run-llm \ + --mirix_config_path ./configs/0201c.yaml \ + --output_path "$OUT" + +echo +echo "[2/2] running organize_results.py (LLM judge)" +python organize_results.py "$OUT" + +# ---- summary --------------------------------------------------------------- +echo +echo "========================================================" +echo " Summary" +echo "========================================================" +python - "$OUT" <<'PY' +import json, sys +from pathlib import Path + +out_dir = Path("results/locomo") / sys.argv[1] +m = json.loads((out_dir / "metrics.json").read_text()) +mt = m["metrics"] +cn = {1: "single_hop", 2: "temporal", 3: "multi_hop", 4: "open_domain"} + +print(f" Output: {out_dir}") +print(f" Acc: {mt['accuracy']:.4f} ({mt['total_correct']}/{mt['total_judged']})") +print(f" Avg latency: {mt['average_answer_latency_seconds']:.2f}s") +print(f" Total LLM calls: {mt['total_answer_requests']}") +print() +print(" Per category:") +for cid, d in sorted(m["accuracy_by_category"].items(), key=lambda kv: int(kv[0])): + name = cn.get(int(cid), str(cid)) + print(f" {name:15s} n={d['total_judged']:3d} acc={d['accuracy']:.4f}") +PY diff --git a/archive/evals/visualize_v6_graph.py b/archive/evals/visualize_v6_graph.py new file mode 100644 index 000000000..126ffc62a --- /dev/null +++ b/archive/evals/visualize_v6_graph.py @@ -0,0 +1,348 @@ +#!/usr/bin/env python3 +"""Generate Graphviz visualizations for the v6 Neo4j graph. + +The output is intentionally sampled. Rendering all nodes in the LongMem-S +graph is unreadable, so this script emits: + +- an overview with the highest-degree entities and a few memory refs each +- topic-focused subgraphs with more memory refs per seed entity +""" + +from __future__ import annotations + +import argparse +import html +import os +import subprocess +import textwrap +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable + +from neo4j import GraphDatabase + + +ENTITY_COLORS = { + "Person": ("#d8e9ff", "#3a6ea5"), + "Location": ("#dff4df", "#4a8f4a"), + "Organization": ("#ffe7bf", "#bf7a12"), + "Concept": ("#eee4ff", "#7c5bc7"), + "Content": ("#ffdcdc", "#bf4f4f"), + "Event": ("#e4f0ff", "#4b6fb5"), + "Method": ("#e0f7f4", "#368a7f"), + "Other": ("#eeeeee", "#777777"), +} + + +@dataclass +class Entity: + id: str + name: str + entity_type: str + degree: int + + +@dataclass +class MemoryRef: + id: str + memory_type: str + summary: str + title: str | None + occurred_at: str | None + + +@dataclass +class Edge: + entity_id: str + memory_id: str + rel_type: str + + +def dot_escape(value: object) -> str: + text = "" if value is None else str(value) + return text.replace("\\", "\\\\").replace('"', '\\"') + + +def dot_id(prefix: str, value: str) -> str: + clean = "".join(ch if ch.isalnum() else "_" for ch in value) + return f"{prefix}_{clean}" + + +def wrap(value: str, width: int = 34, max_chars: int = 150) -> str: + text = " ".join((value or "").split()) + if len(text) > max_chars: + text = text[: max_chars - 1].rstrip() + "..." + return "\\n".join(textwrap.wrap(text, width=width)) or "(empty)" + + +def graph_driver(): + uri = os.environ.get("MIRIX_NEO4J_URI", "bolt://localhost:7687") + user = os.environ.get("MIRIX_NEO4J_USER", "neo4j") + password = os.environ.get("MIRIX_NEO4J_PASSWORD", "mirix_neo4j_dev") + return GraphDatabase.driver(uri, auth=(user, password)) + + +def fetch_top_entities(driver, user_id: str, limit: int) -> list[Entity]: + query = """ + MATCH (e:V6Entity {user_id: $user_id})-[r]-(m:V6MemoryRef) + WITH e, count(r) AS degree + RETURN e.id AS id, e.name AS name, e.entity_type AS entity_type, degree + ORDER BY degree DESC, name ASC + LIMIT $limit + """ + with driver.session(database=os.environ.get("MIRIX_NEO4J_DATABASE", "neo4j")) as session: + return [ + Entity( + id=rec["id"], + name=rec["name"] or "", + entity_type=rec["entity_type"] or "Other", + degree=int(rec["degree"] or 0), + ) + for rec in session.run(query, user_id=user_id, limit=limit) + ] + + +def fetch_topic_entities(driver, user_id: str, terms: list[str], limit: int) -> list[Entity]: + query = """ + MATCH (e:V6Entity {user_id: $user_id})-[r]-(m:V6MemoryRef) + WITH e, count(r) AS degree + WHERE any(term IN $terms WHERE toLower(e.name) CONTAINS term) + RETURN e.id AS id, e.name AS name, e.entity_type AS entity_type, degree + ORDER BY degree DESC, name ASC + LIMIT $limit + """ + with driver.session(database=os.environ.get("MIRIX_NEO4J_DATABASE", "neo4j")) as session: + return [ + Entity( + id=rec["id"], + name=rec["name"] or "", + entity_type=rec["entity_type"] or "Other", + degree=int(rec["degree"] or 0), + ) + for rec in session.run(query, user_id=user_id, terms=[t.lower() for t in terms], limit=limit) + ] + + +def fetch_edges_for_entities( + driver, entity_ids: list[str], refs_per_entity: int +) -> tuple[dict[str, MemoryRef], list[Edge]]: + if not entity_ids: + return {}, [] + query = """ + UNWIND $entity_ids AS entity_id + MATCH (e:V6Entity {id: entity_id})-[r]-(m:V6MemoryRef) + WITH e, r, m + ORDER BY e.id, type(r), coalesce(m.occurred_at, m.semantic_created_at, m.created_at) DESC + WITH e, collect({ + rel_type: type(r), + memory_id: m.id, + memory_type: coalesce(m.memory_type, CASE WHEN m:V6EpisodeRef THEN 'episodic' ELSE 'semantic' END), + summary: coalesce(m.summary, ''), + title: coalesce(m.title, ''), + occurred_at: coalesce(toString(m.occurred_at), toString(m.semantic_created_at), toString(m.created_at)) + })[..$refs_per_entity] AS refs + RETURN e.id AS entity_id, refs + """ + memories: dict[str, MemoryRef] = {} + edges: list[Edge] = [] + with driver.session(database=os.environ.get("MIRIX_NEO4J_DATABASE", "neo4j")) as session: + for rec in session.run(query, entity_ids=entity_ids, refs_per_entity=refs_per_entity): + entity_id = rec["entity_id"] + for ref in rec["refs"]: + memory_id = ref["memory_id"] + memories[memory_id] = MemoryRef( + id=memory_id, + memory_type=ref["memory_type"] or "", + summary=ref["summary"] or "", + title=ref["title"] or None, + occurred_at=ref["occurred_at"] or None, + ) + edges.append(Edge(entity_id=entity_id, memory_id=memory_id, rel_type=ref["rel_type"])) + return memories, edges + + +def make_dot( + *, + title: str, + entities: list[Entity], + memories: dict[str, MemoryRef], + edges: list[Edge], +) -> str: + lines = [ + "digraph G {", + ' graph [rankdir=LR, bgcolor="white", splines=true, overlap=false, nodesep=0.35, ranksep=1.0];', + ' node [fontname="Helvetica", fontsize=10, style="filled,rounded", penwidth=1.2];', + ' edge [fontname="Helvetica", fontsize=8, color="#9aa4b2", arrowsize=0.55];', + f' label="{dot_escape(title)}";', + ' labelloc="t";', + ' fontsize=20;', + "", + " subgraph cluster_entities {", + ' label="V6Entity";', + ' color="#d8dee9";', + ' style="rounded";', + ] + + for entity in entities: + fill, border = ENTITY_COLORS.get(entity.entity_type, ENTITY_COLORS["Other"]) + label = f"{wrap(entity.name, 24, 80)}\\n({entity.entity_type}, d={entity.degree})" + lines.append( + f' {dot_id("e", entity.id)} [shape=ellipse, fillcolor="{fill}", color="{border}", label="{dot_escape(label)}"];' + ) + + lines += [ + " }", + "", + " subgraph cluster_memories {", + ' label="V6MemoryRef -> PG flat memory";', + ' color="#d8dee9";', + ' style="rounded";', + ] + + for memory in memories.values(): + if memory.memory_type == "episodic": + fill, border, shape = "#fff2bf", "#b38a00", "box" + prefix = "E" + else: + fill, border, shape = "#dff7ff", "#2f7f9f", "component" + prefix = "S" + stamp = f"{memory.occurred_at[:10]}\\n" if memory.occurred_at else "" + summary = memory.title or memory.summary or memory.id + label = f"{prefix}: {stamp}{wrap(summary, 34, 135)}" + lines.append( + f' {dot_id("m", memory.id)} [shape={shape}, fillcolor="{fill}", color="{border}", label="{dot_escape(label)}"];' + ) + + lines += [" }", ""] + + for edge in edges: + color = "#5f8dd3" if edge.rel_type == "APPEARS_IN" else "#9b6bd3" + lines.append( + f' {dot_id("e", edge.entity_id)} -> {dot_id("m", edge.memory_id)} ' + f'[label="{dot_escape(edge.rel_type)}", color="{color}"];' + ) + + lines.append("}") + return "\n".join(lines) + + +def render_dot(dot: str, out_dot: Path, out_svg: Path) -> None: + out_dot.write_text(dot, encoding="utf-8") + subprocess.run(["dot", "-Tsvg", str(out_dot), "-o", str(out_svg)], check=True) + subprocess.run(["dot", "-Tpng", str(out_dot), "-o", str(out_svg.with_suffix(".png"))], check=True) + + +def write_index(out_dir: Path, figures: list[tuple[str, str, int, int]]) -> None: + sections = [] + for title, filename, node_n, edge_n in figures: + sections.append( + f""" +
+

{html.escape(title)}

+

{node_n} nodes, {edge_n} sampled edges

+ +
+ """ + ) + page = f""" + + + + v6 LongMem-S Graph Visualizations + + + +

v6 LongMem-S Graph Visualizations

+

+ Sampled from user_id=longmem_s_0. The full graph has + 7,281 nodes and 14,125 edges; these views show readable slices. + Entity nodes link to V6MemoryRef nodes, which point back to PostgreSQL + episodic and semantic flat memory. +

+ {''.join(sections)} + + +""" + (out_dir / "index.html").write_text(page, encoding="utf-8") + + +def build_view( + *, + driver, + user_id: str, + out_dir: Path, + slug: str, + title: str, + entities: list[Entity], + refs_per_entity: int, +) -> tuple[str, str, int, int]: + memories, edges = fetch_edges_for_entities(driver, [e.id for e in entities], refs_per_entity) + dot = make_dot(title=title, entities=entities, memories=memories, edges=edges) + dot_path = out_dir / f"{slug}.dot" + svg_path = out_dir / f"{slug}.svg" + render_dot(dot, dot_path, svg_path) + return title, svg_path.name, len(entities) + len(memories), len(edges) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--user-id", default="longmem_s_0") + parser.add_argument( + "--out-dir", + type=Path, + default=Path("docs/graph_memory_v6/visualizations"), + ) + args = parser.parse_args() + + args.out_dir.mkdir(parents=True, exist_ok=True) + figures: list[tuple[str, str, int, int]] = [] + + topics = [ + ("aquarium", "Aquarium / Fish Detail", ["aquarium", "fish", "betta", "gourami", "tetra", "pleco"], 14, 8), + ("career", "Career / Campaign Work Detail", ["campaign", "work hours", "resume", "linkedin", "marketing"], 14, 8), + ("fitness", "Fitness / Health Devices Detail", ["fitness", "bodypump", "zumba", "yoga", "fitbit", "health"], 14, 8), + ("travel", "Travel / Places Detail", ["vatican", "rome", "speyer", "moncayo", "hawaii", "yosemite"], 14, 8), + ] + + with graph_driver() as driver: + overview_entities = fetch_top_entities(driver, args.user_id, 28) + figures.append( + build_view( + driver=driver, + user_id=args.user_id, + out_dir=args.out_dir, + slug="overview_top_entities", + title="Overview: Top-Degree Entities", + entities=overview_entities, + refs_per_entity=4, + ) + ) + + for slug, title, terms, entity_limit, refs_per_entity in topics: + entities = fetch_topic_entities(driver, args.user_id, terms, entity_limit) + figures.append( + build_view( + driver=driver, + user_id=args.user_id, + out_dir=args.out_dir, + slug=f"topic_{slug}", + title=title, + entities=entities, + refs_per_entity=refs_per_entity, + ) + ) + + write_index(args.out_dir, figures) + print(f"Wrote {len(figures)} graph views to {args.out_dir}") + for title, filename, node_n, edge_n in figures: + print(f"- {filename}: {title} ({node_n} nodes, {edge_n} edges)") + + +if __name__ == "__main__": + main() diff --git a/archive/legacy_graph/_graph_retriever_base.py b/archive/legacy_graph/_graph_retriever_base.py new file mode 100644 index 000000000..2b1086bf2 --- /dev/null +++ b/archive/legacy_graph/_graph_retriever_base.py @@ -0,0 +1,369 @@ +""" +Shared retriever base for v4 graph readers. + +EpisodicRetriever and SemanticRetriever are structurally identical — they +walk one graph (entities → relations → items), dedup, and score. The only +differences are which labels/types they MATCH, which vector indexes they +query, and what extra item-item edges they expand (NEXT for episodes, +CONCEPT_RELATES for concepts). This base factors out the shared algorithm +and lets subclasses fill in label/type strings. +""" + +from __future__ import annotations + +import asyncio +import math +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any, Optional + +import tiktoken + +from mirix.log import get_logger + +logger = get_logger(__name__) + + +# Token budgets and ranking constants. Tuned for chat-memory scale. +DEFAULT_TOP_K = 30 +DEFAULT_CHUNK_TOP_K = 10 +RECENCY_HALF_LIFE_DAYS = 30.0 +COSINE_WEIGHT = 0.7 +RECENCY_WEIGHT = 0.3 + + +_tokenizer = None + + +def count_tokens(text: str) -> int: + global _tokenizer + if _tokenizer is None: + _tokenizer = tiktoken.get_encoding("cl100k_base") + if not text: + return 0 + return len(_tokenizer.encode(text)) + + +def recency_decay(ts: Optional[Any]) -> float: + """Exponential decay over days; missing ts → 0.5.""" + if ts is None: + return 0.5 + try: + if isinstance(ts, str): + ts = datetime.fromisoformat(ts.replace("Z", "+00:00")) + if ts.tzinfo is None: + ts = ts.replace(tzinfo=timezone.utc) + age_days = (datetime.now(timezone.utc) - ts).total_seconds() / 86400.0 + if age_days < 0: + return 1.0 + return math.exp(-0.693 * age_days / RECENCY_HALF_LIFE_DAYS) + except Exception: + return 0.5 + + +def final_score(cosine: float, ts: Optional[Any]) -> float: + return COSINE_WEIGHT * float(cosine or 0.0) + RECENCY_WEIGHT * recency_decay(ts) + + +def fmt_date(ts: Optional[Any]) -> str: + if ts is None: + return "unknown" + try: + if isinstance(ts, str): + ts = datetime.fromisoformat(ts.replace("Z", "+00:00")) + return ts.strftime("%d %B %Y") + except Exception: + return str(ts) + + +# ────────────────────────────────────────────────────────────── dataclasses + +@dataclass +class EntityHit: + id: str + name: str + entity_type: str + description: str + rank: int + cosine: float + updated_at: Optional[Any] + score: float = 0.0 + + +@dataclass +class RelationHit: + id: str + src_name: str + tgt_name: str + keywords: str + description: str + weight: float + cosine: float + valid_at: Optional[Any] + score: float = 0.0 + + +@dataclass +class ItemHit: + """Episode or Concept — same shape so format/budget code is shared.""" + id: str + label: str # 'Episode' or 'Concept' + summary: str + detail: str # episode.details or concept.summary (fallback) + timestamp: Optional[Any] # occurred_at for Episode, created_at for Concept + cosine: float + score: float = 0.0 + source: str = "mentions" # 'mentions', 'one_hop', etc — for debugging + + +@dataclass +class GraphSearchResult: + entities: list[EntityHit] = field(default_factory=list) + relations: list[RelationHit] = field(default_factory=list) + items: list[ItemHit] = field(default_factory=list) + + +# ────────────────────────────────────────── round-robin merge helpers + +def round_robin_merge_entities( + locals_: list[EntityHit], globals_: list[EntityHit] +) -> list[EntityHit]: + merged: list[EntityHit] = [] + seen: set[str] = set() + n = max(len(locals_), len(globals_)) + for i in range(n): + for source in (locals_, globals_): + if i < len(source): + hit = source[i] + key = (hit.name or "").lower() or hit.id + if key in seen: + continue + seen.add(key) + merged.append(hit) + return merged + + +def round_robin_merge_relations( + locals_: list[RelationHit], globals_: list[RelationHit] +) -> list[RelationHit]: + merged: list[RelationHit] = [] + seen: set[tuple[str, str]] = set() + n = max(len(locals_), len(globals_)) + for i in range(n): + for source in (locals_, globals_): + if i < len(source): + hit = source[i] + key = tuple(sorted([(hit.src_name or "").lower(), (hit.tgt_name or "").lower()])) + if key in seen: + continue + seen.add(key) + merged.append(hit) + return merged + + +# ────────────────────────────────────────── token budget application + +def apply_budget_to_search( + search: GraphSearchResult, + *, + max_entity_tokens: int, + max_relation_tokens: int, + max_item_tokens: int, +) -> GraphSearchResult: + def _trim(items: list, render, budget: int) -> list: + kept = [] + used = 0 + for it in items: + t = count_tokens(render(it)) + if used + t > budget: + break + kept.append(it) + used += t + return kept + + kept_e = _trim( + search.entities, + lambda e: f"- {e.name} ({e.entity_type}, rank={e.rank}): {e.description}", + max_entity_tokens, + ) + kept_r = _trim( + search.relations, + lambda r: f"- {r.src_name} <-> {r.tgt_name} [{r.keywords}]: {r.description}", + max_relation_tokens, + ) + kept_i = _trim( + search.items, + lambda it: f"- [{fmt_date(it.timestamp)}] {it.summary} {it.detail[:200]}", + max_item_tokens, + ) + return GraphSearchResult(entities=kept_e, relations=kept_r, items=kept_i) + + +# ────────────────────────────────────────── retriever base + +class GraphRetrieverBase: + """Subclasses set these as class attributes.""" + + # Labels + ENTITY_LABEL: str = "" # 'EpisodicEntity' or 'SemanticEntity' + ITEM_LABEL: str = "" # 'Episode' or 'Concept' + REL_TYPE: str = "" # 'EP_RELATES' or 'SEM_RELATES' + + # Vector index names + ENTITY_VECTOR_INDEX: str = "" # 'ep_entity_name_emb' or 'sem_entity_name_emb' + REL_VECTOR_INDEX: str = "" # 'ep_rel_kw_emb' or 'sem_rel_kw_emb' + + # Title used in markdown output + SECTION_TITLE: str = "" # 'Episodic' or 'Semantic' + + # ──────────────────────────────────────────────────────── low-level path + + def _ll_cypher(self) -> str: + # Vector search on entity names → seed entities. For each seed, pull + # one-hop neighbor entities via REL_TYPE (no expired_at on semantic + # rels — but the predicate is harmless: missing property tests as + # NULL which evaluates the WHERE filter as "IS NULL = true"). + return f""" + CALL db.index.vector.queryNodes('{self.ENTITY_VECTOR_INDEX}', $top_k, $emb) + YIELD node AS e, score AS sim + WHERE e.user_id = $user_id + WITH e, sim + ORDER BY sim DESC LIMIT $top_k + WITH collect({{e: e, sim: sim}}) AS seeds + UNWIND seeds AS s + WITH s.e AS seed, s.sim AS sim + OPTIONAL MATCH (seed)-[r:{self.REL_TYPE}]-(other:{self.ENTITY_LABEL} {{user_id: $user_id}}) + WHERE coalesce(r.expired_at, datetime('9999-01-01')) > datetime() + RETURN + seed.id AS sid, seed.name AS sname, seed.entity_type AS stype, + seed.description AS sdesc, coalesce(seed.rank, 0) AS srank, + seed.updated_at AS supdated, sim AS sim, + r.id AS rid, r.description AS rdesc, r.keywords AS rkw, + coalesce(r.weight, 0.5) AS rweight, r.valid_at AS rvalid, + other.name AS oname + """ + + def _hl_cypher(self) -> str: + # Vector search on relation keywords → seed relations. Pull both + # endpoint entities. We don't have to filter by endpoint label since + # REL_TYPE alone identifies the graph (EP_RELATES vs SEM_RELATES). + return f""" + CALL db.index.vector.queryRelationships('{self.REL_VECTOR_INDEX}', $top_k, $emb) + YIELD relationship AS r, score AS sim + WHERE coalesce(r.expired_at, datetime('9999-01-01')) > datetime() + MATCH (a:{self.ENTITY_LABEL})-[r]-(b:{self.ENTITY_LABEL}) + WHERE a.user_id = $user_id AND b.user_id = $user_id + WITH r, sim, a, b + ORDER BY sim DESC LIMIT $top_k + RETURN + r.id AS rid, r.description AS rdesc, r.keywords AS rkw, + coalesce(r.weight, 0.5) AS rweight, r.valid_at AS rvalid, sim AS sim, + a.id AS aid, a.name AS aname, a.entity_type AS atype, + a.description AS adesc, coalesce(a.rank, 0) AS arank, a.updated_at AS aupdated, + b.id AS bid, b.name AS bname, b.entity_type AS btype, + b.description AS bdesc, coalesce(b.rank, 0) AS brank, b.updated_at AS bupdated + """ + + # ─────────────────────────────────────── high-level orchestration + + async def search( + self, + *, + driver, + user_id: str, + ll_embedding: Optional[list[float]], + hl_embedding: Optional[list[float]], + top_k: int = DEFAULT_TOP_K, + ) -> tuple[list[EntityHit], list[RelationHit]]: + """Run ll + hl in parallel where embeddings are available.""" + from mirix.settings import settings + + tasks: dict[str, asyncio.Task] = {} + if ll_embedding is not None: + tasks["ll"] = asyncio.create_task( + self._run_ll(driver, user_id, ll_embedding, top_k, settings.neo4j_database) + ) + if hl_embedding is not None: + tasks["hl"] = asyncio.create_task( + self._run_hl(driver, user_id, hl_embedding, top_k, settings.neo4j_database) + ) + if not tasks: + return [], [] + results = await asyncio.gather(*tasks.values(), return_exceptions=True) + local_e, local_r, global_e, global_r = [], [], [], [] + for purpose, res in zip(tasks.keys(), results): + if isinstance(res, Exception): + logger.warning("%s retrieval branch %s failed: %s", self.SECTION_TITLE, purpose, res) + continue + if purpose == "ll": + local_e, local_r = res + elif purpose == "hl": + global_e, global_r = res + + final_e = round_robin_merge_entities(local_e, global_e) + final_r = round_robin_merge_relations(local_r, global_r) + for e in final_e: + e.score = final_score(e.cosine, e.updated_at) + for r in final_r: + r.score = final_score(r.cosine, r.valid_at) + final_e.sort(key=lambda x: x.score, reverse=True) + final_r.sort(key=lambda x: x.score, reverse=True) + return final_e, final_r + + async def _run_ll(self, driver, user_id, emb, top_k, database): + entities: dict[str, EntityHit] = {} + relations: dict[str, RelationHit] = {} + async with driver.session(database=database) as session: + result = await session.run(self._ll_cypher(), user_id=user_id, emb=emb, top_k=top_k) + async for rec in result: + sid = rec["sid"] + if sid and sid not in entities: + entities[sid] = EntityHit( + id=sid, name=rec["sname"], + entity_type=rec["stype"] or "Other", + description=rec["sdesc"] or "", + rank=int(rec["srank"] or 0), + cosine=float(rec["sim"] or 0.0), + updated_at=rec["supdated"], + ) + rid = rec["rid"] + if rid and rid not in relations: + relations[rid] = RelationHit( + id=rid, + src_name=rec["sname"], tgt_name=rec["oname"] or "", + keywords=rec["rkw"] or "", description=rec["rdesc"] or "", + weight=float(rec["rweight"] or 0.5), + cosine=float(rec["sim"] or 0.0), + valid_at=rec["rvalid"], + ) + return list(entities.values()), list(relations.values()) + + async def _run_hl(self, driver, user_id, emb, top_k, database): + entities: dict[str, EntityHit] = {} + relations: dict[str, RelationHit] = {} + async with driver.session(database=database) as session: + result = await session.run(self._hl_cypher(), user_id=user_id, emb=emb, top_k=top_k) + async for rec in result: + rid = rec["rid"] + if rid and rid not in relations: + relations[rid] = RelationHit( + id=rid, + src_name=rec["aname"] or "", tgt_name=rec["bname"] or "", + keywords=rec["rkw"] or "", description=rec["rdesc"] or "", + weight=float(rec["rweight"] or 0.5), + cosine=float(rec["sim"] or 0.0), + valid_at=rec["rvalid"], + ) + for prefix in ("a", "b"): + eid = rec[f"{prefix}id"] + if not eid or eid in entities: + continue + entities[eid] = EntityHit( + id=eid, name=rec[f"{prefix}name"], + entity_type=rec[f"{prefix}type"] or "Other", + description=rec[f"{prefix}desc"] or "", + rank=int(rec[f"{prefix}rank"] or 0), + cosine=float(rec["sim"] or 0.0), + updated_at=rec[f"{prefix}updated"], + ) + return list(entities.values()), list(relations.values()) diff --git a/archive/legacy_graph/episodic_graph_manager.py b/archive/legacy_graph/episodic_graph_manager.py new file mode 100644 index 000000000..967a0b68d --- /dev/null +++ b/archive/legacy_graph/episodic_graph_manager.py @@ -0,0 +1,500 @@ +""" +Episodic graph manager (v4) — writes G_episodic in Neo4j. + +Hooked from EpisodicMemoryManager.insert_event after the PG row has been +committed. Failures are non-fatal: PG remains the source of truth. + +Graph elements written here: + (:Episode {id, user_id, organization_id, summary, occurred_at}) + (:EpisodicEntity {id, user_id, organization_id, name, name_lower, + entity_type, description, rank, name_embedding, + created_at, updated_at}) + (:Episode)-[:NEXT]->(:Episode) + (:Episode)-[:MENTIONS {role}]->(:EpisodicEntity) + (:EpisodicEntity)-[:EP_RELATES {id, keywords, description, weight, + source_episode_ids, valid_at, invalid_at, + expired_at, keywords_embedding}] + ->(:EpisodicEntity) + +CAUSED_BY edges are reserved for a future optional LLM step (P2 leaves them +unused — write path stays at ~1 LLM call/insert). +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, Optional + +from mirix.log import get_logger +from mirix.schemas.agent import AgentState +from mirix.services._graph_common import ( + embed_batch, + gen_id, + iso, + llm_model_from_agent, + normalize_name, +) +from mirix.services.lightrag_extractor import ( + ExtractedEntity, + ExtractedRelation, + extract_entities_and_relations, +) +from mirix.services.lightrag_merger import merge_descriptions +from mirix.settings import settings + +logger = get_logger(__name__) + + +class EpisodicGraphManager: + """Stateless coordinator. Construct one per call.""" + + async def process_episode( + self, + *, + episode_id: str, + summary: str, + details: str, + occurred_at: datetime, + agent_state: AgentState, + organization_id: str, + user_id: str, + ) -> dict[str, Any]: + """Run the full episodic write path. Never raises.""" + if not settings.enable_graph_memory: + return {"skipped": "disabled"} + + from mirix.database.neo4j_client import get_neo4j_driver + + driver = get_neo4j_driver() + if driver is None: + return {"skipped": "no_driver"} + + text = (summary or "") + ("\n" + details if details else "") + + # W2: extract first so we know what to embed/upsert + extraction = await extract_entities_and_relations( + text=text, llm_model=llm_model_from_agent(agent_state) + ) + + # W1: always create the Episode node, even when extraction is empty + await self._upsert_episode( + driver, + episode_id=episode_id, + summary=summary, + occurred_at=occurred_at, + user_id=user_id, + organization_id=organization_id, + ) + + # W6: connect Episode to previous Episode by occurred_at (auto NEXT) + await self._link_next(driver, user_id=user_id, episode_id=episode_id, occurred_at=occurred_at) + + if not extraction.entities and not extraction.relations: + return {"entities": 0, "relations": 0} + + # W3: upsert EpisodicEntity nodes + merged_entities = await self._upsert_entities( + driver, + entities=extraction.entities, + episode_id=episode_id, + agent_state=agent_state, + user_id=user_id, + organization_id=organization_id, + ) + + # W4: upsert EP_RELATES edges + merged_relations = await self._upsert_relations( + driver, + relations=extraction.relations, + episode_id=episode_id, + occurred_at=occurred_at, + agent_state=agent_state, + user_id=user_id, + llm_model=llm_model_from_agent(agent_state), + ) + + # W7: refresh rank (degree) + touched_names = sorted({e.name for e in extraction.entities}) + await self._refresh_ranks(driver, names=touched_names, user_id=user_id) + + return { + "entities": len(extraction.entities), + "relations": len(extraction.relations), + "merged_entities": merged_entities, + "merged_relations": merged_relations, + } + + # --------------------------------------------------------- W1: Episode + + async def _upsert_episode( + self, + driver, + *, + episode_id: str, + summary: str, + occurred_at: datetime, + user_id: str, + organization_id: str, + ) -> None: + async with driver.session(database=settings.neo4j_database) as session: + await session.run( + """ + MERGE (e:Episode {id: $id}) + ON CREATE SET e.user_id = $user_id, + e.organization_id = $org_id, + e.summary = $summary, + e.occurred_at = $occurred_at + ON MATCH SET e.summary = $summary, + e.occurred_at = $occurred_at + """, + id=episode_id, + user_id=user_id, + org_id=organization_id, + summary=summary or "", + occurred_at=iso(occurred_at), + ) + + # ------------------------------------------------------- W6: NEXT edges + + async def _link_next( + self, driver, *, user_id: str, episode_id: str, occurred_at: datetime + ) -> None: + """ + Connect the new episode to the most recent prior episode (same user) + with a :NEXT edge. Idempotent: if a NEXT edge from the same prior + episode already exists, MERGE keeps it. + """ + async with driver.session(database=settings.neo4j_database) as session: + await session.run( + """ + MATCH (current:Episode {id: $id}) + OPTIONAL MATCH (prev:Episode {user_id: $user_id}) + WHERE prev.id <> $id AND prev.occurred_at < $occurred_at + WITH current, prev + ORDER BY prev.occurred_at DESC + LIMIT 1 + FOREACH (_ IN CASE WHEN prev IS NULL THEN [] ELSE [1] END | + MERGE (prev)-[:NEXT]->(current) + ) + """, + id=episode_id, + user_id=user_id, + occurred_at=iso(occurred_at), + ) + + # -------------------------------------------------------- W3: Entities + + async def _upsert_entities( + self, + driver, + *, + entities: list[ExtractedEntity], + episode_id: str, + agent_state: AgentState, + user_id: str, + organization_id: str, + ) -> int: + if not entities: + return 0 + + # Fetch existing entities by (user_id, name_lower) in one round-trip + name_lowers = [normalize_name(e.name) for e in entities] + existing = await self._fetch_existing_entities(driver, user_id, name_lowers) + + # Embed names for entities not yet in the graph + new_entities = [e for e in entities if normalize_name(e.name) not in existing] + new_embeddings = await embed_batch([e.name for e in new_entities], agent_state) + new_emb_map: dict[str, Optional[list[float]]] = { + normalize_name(e.name): emb for e, emb in zip(new_entities, new_embeddings) + } + + now = iso(datetime.now(timezone.utc)) + merged_count = 0 + llm_model = llm_model_from_agent(agent_state) + + new_rows: list[dict[str, Any]] = [] + for e in new_entities: + nl = normalize_name(e.name) + new_rows.append({ + "id": gen_id("epent"), + "name": e.name, + "name_lower": nl, + "entity_type": e.entity_type, + "description": e.description, + "name_embedding": new_emb_map.get(nl), + "user_id": user_id, + "organization_id": organization_id, + "created_at": now, + "updated_at": now, + }) + + # Update path: merge descriptions for entities that already exist + update_rows: list[dict[str, Any]] = [] + for e in entities: + nl = normalize_name(e.name) + existing_row = existing.get(nl) + if existing_row is None: + continue + old_desc = existing_row.get("description") or "" + new_desc = e.description or "" + if not new_desc.strip() or new_desc.strip() == old_desc.strip(): + continue + merged, llm_used = await merge_descriptions( + description_type="episodic entity", + name=existing_row["name"], + descriptions=[old_desc, new_desc] if old_desc else [new_desc], + llm_model=llm_model, + ) + if llm_used: + merged_count += 1 + update_rows.append({"id": existing_row["id"], "description": merged, "updated_at": now}) + + async with driver.session(database=settings.neo4j_database) as session: + if new_rows: + await session.run( + """ + UNWIND $rows AS row + CREATE (e:EpisodicEntity { + id: row.id, + name: row.name, + name_lower: row.name_lower, + entity_type: row.entity_type, + description: row.description, + rank: 0, + user_id: row.user_id, + organization_id: row.organization_id, + created_at: row.created_at, + updated_at: row.updated_at + }) + WITH e, row + CALL { + WITH e, row + WITH e, row WHERE row.name_embedding IS NOT NULL + CALL db.create.setNodeVectorProperty(e, 'name_embedding', row.name_embedding) + RETURN count(*) AS _ + } + RETURN count(e) AS created + """, + rows=new_rows, + ) + if update_rows: + await session.run( + """ + UNWIND $rows AS row + MATCH (e:EpisodicEntity {id: row.id}) + SET e.description = row.description, e.updated_at = row.updated_at + """, + rows=update_rows, + ) + + # MENTIONS edges from Episode → EpisodicEntity (covers both new + existing) + mention_rows = [ + {"episode_id": episode_id, "name_lower": normalize_name(e.name), "user_id": user_id} + for e in entities + ] + await session.run( + """ + UNWIND $rows AS row + MATCH (ep:Episode {id: row.episode_id}) + MATCH (e:EpisodicEntity {user_id: row.user_id, name_lower: row.name_lower}) + MERGE (ep)-[m:MENTIONS]->(e) + ON CREATE SET m.role = 'MENTIONED' + """, + rows=mention_rows, + ) + + return merged_count + + async def _fetch_existing_entities( + self, driver, user_id: str, name_lowers: list[str] + ) -> dict[str, dict[str, Any]]: + if not name_lowers: + return {} + async with driver.session(database=settings.neo4j_database) as session: + result = await session.run( + """ + UNWIND $names AS nl + MATCH (e:EpisodicEntity {user_id: $user_id, name_lower: nl}) + RETURN e.id AS id, e.name AS name, e.name_lower AS name_lower, + e.description AS description, e.entity_type AS entity_type + """, + names=name_lowers, + user_id=user_id, + ) + out: dict[str, dict[str, Any]] = {} + async for rec in result: + out[rec["name_lower"]] = dict(rec) + return out + + # -------------------------------------------------------- W4: EP_RELATES + + async def _upsert_relations( + self, + driver, + *, + relations: list[ExtractedRelation], + episode_id: str, + occurred_at: datetime, + agent_state: AgentState, + user_id: str, + llm_model: str, + ) -> int: + if not relations: + return 0 + + kw_embeddings = await embed_batch( + [r.keywords or r.description for r in relations], agent_state + ) + + pairs = [(normalize_name(r.src), normalize_name(r.tgt)) for r in relations] + existing_edges = await self._fetch_existing_edges(driver, user_id, pairs) + + now = iso(datetime.now(timezone.utc)) + valid_at = iso(occurred_at) + merged_count = 0 + + new_rows: list[dict[str, Any]] = [] + update_rows: list[dict[str, Any]] = [] + + for r, kw_emb in zip(relations, kw_embeddings): + a = normalize_name(r.src) + b = normalize_name(r.tgt) + key = tuple(sorted([a, b])) + existing = existing_edges.get(key) + if existing is None: + new_rows.append({ + "id": gen_id("eprel"), + "src_lower": a, + "tgt_lower": b, + "user_id": user_id, + "keywords": r.keywords, + "description": r.description, + "weight": float(r.weight), + "valid_at": valid_at, + "created_at": now, + "source_episode_ids": [episode_id], + "keywords_embedding": kw_emb, + }) + continue + + # Merge description, average weight, accumulate source_episode_ids + old_desc = existing.get("description") or "" + new_desc = r.description or "" + if old_desc.strip() and new_desc.strip() and old_desc.strip() != new_desc.strip(): + merged_desc, llm_used = await merge_descriptions( + description_type="episodic relation", + name=f"{r.src} <-> {r.tgt}", + descriptions=[old_desc, new_desc], + llm_model=llm_model, + ) + if llm_used: + merged_count += 1 + else: + merged_desc = new_desc or old_desc + + old_weight = float(existing.get("weight") or 0.5) + new_weight = (old_weight + float(r.weight)) / 2.0 + old_sources: list[str] = list(existing.get("source_episode_ids") or []) + if episode_id not in old_sources: + old_sources.append(episode_id) + update_rows.append({ + "id": existing["id"], + "description": merged_desc, + "weight": new_weight, + "source_episode_ids": old_sources, + "updated_at": now, + }) + + async with driver.session(database=settings.neo4j_database) as session: + if new_rows: + await session.run( + """ + UNWIND $rows AS row + MATCH (a:EpisodicEntity {user_id: row.user_id, name_lower: row.src_lower}) + MATCH (b:EpisodicEntity {user_id: row.user_id, name_lower: row.tgt_lower}) + CREATE (a)-[r:EP_RELATES { + id: row.id, + keywords: row.keywords, + description: row.description, + weight: row.weight, + valid_at: row.valid_at, + created_at: row.created_at, + source_episode_ids: row.source_episode_ids + }]->(b) + WITH r, row + CALL { + WITH r, row + WITH r, row WHERE row.keywords_embedding IS NOT NULL + CALL db.create.setRelationshipVectorProperty(r, 'keywords_embedding', row.keywords_embedding) + RETURN count(*) AS _ + } + RETURN count(r) AS created + """, + rows=new_rows, + ) + if update_rows: + await session.run( + """ + UNWIND $rows AS row + MATCH ()-[r:EP_RELATES {id: row.id}]->() + SET r.description = row.description, + r.weight = row.weight, + r.source_episode_ids = row.source_episode_ids, + r.updated_at = row.updated_at + """, + rows=update_rows, + ) + + return merged_count + + async def _fetch_existing_edges( + self, driver, user_id: str, pairs: list[tuple[str, str]] + ) -> dict[tuple[str, str], dict[str, Any]]: + if not pairs: + return {} + rows = [{"a": a, "b": b} for a, b in pairs] + async with driver.session(database=settings.neo4j_database) as session: + result = await session.run( + """ + UNWIND $rows AS row + MATCH (x:EpisodicEntity {user_id: $user_id, name_lower: row.a}) + MATCH (y:EpisodicEntity {user_id: $user_id, name_lower: row.b}) + MATCH (x)-[r:EP_RELATES]-(y) + WHERE r.expired_at IS NULL + RETURN row.a AS a, row.b AS b, + r.id AS id, r.description AS description, + r.weight AS weight, r.source_episode_ids AS source_episode_ids + """, + rows=rows, + user_id=user_id, + ) + out: dict[tuple[str, str], dict[str, Any]] = {} + async for rec in result: + key = tuple(sorted([rec["a"], rec["b"]])) + out.setdefault(key, { + "id": rec["id"], + "description": rec["description"], + "weight": rec["weight"], + "source_episode_ids": rec["source_episode_ids"], + }) + return out + + # -------------------------------------------------------- W7: ranks + + async def _refresh_ranks(self, driver, *, names: list[str], user_id: str) -> None: + if not names: + return + name_lowers = [normalize_name(n) for n in names] + async with driver.session(database=settings.neo4j_database) as session: + await session.run( + """ + UNWIND $names AS nl + MATCH (e:EpisodicEntity {user_id: $user_id, name_lower: nl}) + OPTIONAL MATCH (e)-[r:EP_RELATES]-() + WHERE r.expired_at IS NULL + WITH e, count(r) AS deg + SET e.rank = deg + """, + names=name_lowers, + user_id=user_id, + ) diff --git a/archive/legacy_graph/episodic_graph_retriever.py b/archive/legacy_graph/episodic_graph_retriever.py new file mode 100644 index 000000000..e257112c4 --- /dev/null +++ b/archive/legacy_graph/episodic_graph_retriever.py @@ -0,0 +1,174 @@ +""" +Episodic graph retriever (v4) — reads G_episodic in Neo4j. + +Pipeline: + 1. ll embedding → ep_entity_name_emb vector → seed EpisodicEntities + 1-hop EP_RELATES + 2. hl embedding → ep_rel_kw_emb vector → seed EP_RELATES + both endpoints + 3. Round-robin merge entities, round-robin merge relations + 4. MENTIONS reverse: entities → Episodes that mention them + 5. NEXT one-hop expansion: each Episode → ±1 temporal neighbors + 6. Score + dedup items + 7. PG fetch full episode details (summary + details + occurred_at) +""" + +from __future__ import annotations + +from typing import Optional + +from mirix.log import get_logger +from mirix.services._graph_retriever_base import ( + DEFAULT_TOP_K, + GraphRetrieverBase, + GraphSearchResult, + ItemHit, + final_score, +) + +logger = get_logger(__name__) + + +class EpisodicRetriever(GraphRetrieverBase): + ENTITY_LABEL = "EpisodicEntity" + ITEM_LABEL = "Episode" + REL_TYPE = "EP_RELATES" + ENTITY_VECTOR_INDEX = "ep_entity_name_emb" + REL_VECTOR_INDEX = "ep_rel_kw_emb" + SECTION_TITLE = "Episodic" + + async def retrieve( + self, + *, + driver, + user_id: str, + ll_embedding: Optional[list[float]], + hl_embedding: Optional[list[float]], + top_k: int = DEFAULT_TOP_K, + item_top_k: int = 15, + ) -> GraphSearchResult: + """Full episodic pipeline: search → MENTIONS reverse → NEXT one-hop → PG fetch.""" + entities, relations = await self.search( + driver=driver, + user_id=user_id, + ll_embedding=ll_embedding, + hl_embedding=hl_embedding, + top_k=top_k, + ) + + # Reverse MENTIONS: get Episodes that mention the surviving entities + entity_ids = [e.id for e in entities] + episodes_via_mentions = await self._fetch_episodes_via_mentions( + driver, user_id=user_id, entity_ids=entity_ids, limit=item_top_k * 2, + ) + + # NEXT one-hop expansion + episode_ids = [it.id for it in episodes_via_mentions] + episodes_via_one_hop = await self._fetch_episodes_one_hop( + driver, user_id=user_id, episode_ids=episode_ids, limit=item_top_k, + ) + + # Merge + dedup + seen_ids: set[str] = set() + merged_items: list[ItemHit] = [] + for it in episodes_via_mentions + episodes_via_one_hop: + if it.id in seen_ids: + continue + seen_ids.add(it.id) + merged_items.append(it) + + # Score & sort by recency-aware score + for it in merged_items: + it.score = final_score(it.cosine, it.timestamp) + merged_items.sort(key=lambda x: x.score, reverse=True) + merged_items = merged_items[:item_top_k] + + # PG fetch full details for the kept items (summary already in graph; + # PG has details). Best-effort — degrade gracefully if PG miss. + await self._enrich_with_pg(merged_items, user_id=user_id) + + return GraphSearchResult(entities=entities, relations=relations, items=merged_items) + + async def _fetch_episodes_via_mentions( + self, driver, *, user_id: str, entity_ids: list[str], limit: int + ) -> list[ItemHit]: + if not entity_ids: + return [] + from mirix.settings import settings + + async with driver.session(database=settings.neo4j_database) as session: + result = await session.run( + """ + UNWIND $eids AS eid + MATCH (e:EpisodicEntity {id: eid})<-[:MENTIONS]-(ep:Episode {user_id: $user_id}) + WITH DISTINCT ep + ORDER BY ep.occurred_at DESC + LIMIT $limit + RETURN ep.id AS id, ep.summary AS summary, ep.occurred_at AS occurred_at + """, + eids=entity_ids, user_id=user_id, limit=limit, + ) + return [ + ItemHit( + id=rec["id"], label="Episode", + summary=rec["summary"] or "", detail="", + timestamp=rec["occurred_at"], cosine=0.5, source="mentions", + ) + async for rec in result + ] + + async def _fetch_episodes_one_hop( + self, driver, *, user_id: str, episode_ids: list[str], limit: int + ) -> list[ItemHit]: + """For each Episode, fetch its NEXT predecessor/successor (±1 hop).""" + if not episode_ids: + return [] + from mirix.settings import settings + + async with driver.session(database=settings.neo4j_database) as session: + result = await session.run( + """ + UNWIND $eids AS eid + MATCH (ep:Episode {id: eid}) + OPTIONAL MATCH (ep)-[:NEXT]->(next:Episode {user_id: $user_id}) + OPTIONAL MATCH (prev:Episode {user_id: $user_id})-[:NEXT]->(ep) + WITH collect(DISTINCT next) + collect(DISTINCT prev) AS neighbors + UNWIND neighbors AS n + WITH n WHERE n IS NOT NULL + RETURN DISTINCT n.id AS id, n.summary AS summary, n.occurred_at AS occurred_at + LIMIT $limit + """, + eids=episode_ids, user_id=user_id, limit=limit, + ) + return [ + ItemHit( + id=rec["id"], label="Episode", + summary=rec["summary"] or "", detail="", + timestamp=rec["occurred_at"], cosine=0.3, source="one_hop", + ) + async for rec in result + ] + + async def _enrich_with_pg(self, items: list[ItemHit], *, user_id: str) -> None: + """Pull full episodic_memory.details for kept items. Graceful on miss.""" + if not items: + return + from sqlalchemy import text as sa_text + from mirix.server.server import db_context + + ids = [it.id for it in items] + try: + async with db_context() as session: + result = await session.execute( + sa_text( + "SELECT id, details FROM episodic_memory " + "WHERE user_id = :u AND id = ANY(:ids)" + ), + {"u": user_id, "ids": ids}, + ) + detail_map = {row[0]: (row[1] or "") for row in result.fetchall()} + except Exception as e: + logger.debug("PG enrich for episodic failed: %s", e) + return + + for it in items: + if it.id in detail_map: + it.detail = detail_map[it.id] diff --git a/archive/legacy_graph/gliner_extractor.py b/archive/legacy_graph/gliner_extractor.py new file mode 100644 index 000000000..8b0b40b35 --- /dev/null +++ b/archive/legacy_graph/gliner_extractor.py @@ -0,0 +1,99 @@ +"""v7.4 GLiNER entity extraction — replaces the per-memory LightRAG LLM call. + +Why: LightRAG extraction was profiled at ~20.8 s/memory (~97% of graph-build +time) and v7 only ever consumes an entity's name + type (relations/descriptions +are discarded). GLiNER is a single local encoder forward pass (~0.3 s, no API, +no GPU required) that emits exactly (span, type) — so it attacks the dominant +cost (gap G6) with no behavioural change to the downstream anchor pipeline, which +already accepts a list of ``ExtractedEntity`` via ``process_memory(entities=...)``. + +It also closes part of gap G4: dialogue-role tokens ("user", "assistant") are +what LightRAG turns into the degree-42 / degree-24 noise mega-hubs. GLiNER tags +them as ``person`` too, so we drop them here — they never become anchors. + +Deterministic (no sampling), so unlike the LightRAG baseline a v7.4 build is +reproducible run-to-run. +""" +from __future__ import annotations + +import asyncio +import os +from functools import lru_cache +from typing import List + +from mirix.log import get_logger +from mirix.services.lightrag_extractor import ExtractedEntity + +logger = get_logger(__name__) + +GLINER_MODEL = os.environ.get("MIRIX_GLINER_MODEL", "urchade/gliner_medium-v2.1") +GLINER_THRESHOLD = float(os.environ.get("MIRIX_GLINER_THRESHOLD", "0.5")) + +# GLiNER labels -> the entity_type vocabulary that _specificity_score scores. +# person/location/organization/event are the high-value types (+4); product/ +# creative-work map to object/content (+3); concept/date are kept as-is. +_LABELS = ["person", "organization", "location", "event", "product", + "creative work", "concept", "date"] +_LABEL_TO_TYPE = { + "person": "person", "organization": "organization", "location": "location", + "event": "event", "product": "object", "creative work": "content", + "concept": "concept", "date": "date", +} +# Dialogue roles / bare pronouns GLiNER tags as person but which carry no entity +# identity — these are exactly the v7 noise hubs. Never anchor them. +_NOISE = { + "user", "assistant", "you", "i", "me", "we", "us", "they", "them", + "he", "she", "it", "my", "your", "our", "their", + # Plurals slipped this filter (a "Users" anchor reached degree 1016), and this + # set never had the singular article forms triple_extractor always had. + "users", "assistants", "the users", "the assistants", + "the user", "the assistant", +} + + +@lru_cache(maxsize=1) +def _model(): + """Load once per process (~19 s first call; cached thereafter).""" + from gliner import GLiNER + + logger.info("loading GLiNER model %s", GLINER_MODEL) + return GLiNER.from_pretrained(GLINER_MODEL) + + +def _extract_sync(text: str, threshold: float, max_chars: int) -> List[ExtractedEntity]: + model = _model() + spans = model.predict_entities(text[:max_chars], _LABELS, threshold=threshold) + out: List[ExtractedEntity] = [] + seen = set() + for sp in spans: + name = (sp.get("text") or "").strip() + nl = name.lower() + if not name or len(nl) < 2 or nl in _NOISE: + continue + key = (nl, sp.get("label")) + if key in seen: + continue + seen.add(key) + out.append(ExtractedEntity( + name=name, + entity_type=_LABEL_TO_TYPE.get(sp.get("label", ""), "other"), + description="", + )) + return out + + +async def extract_entities_gliner( + text: str, + *, + threshold: float = GLINER_THRESHOLD, + max_chars: int = 12000, +) -> List[ExtractedEntity]: + """Single-pass local NER. Returns deduped ``ExtractedEntity`` (name + type; + description empty). Runs the sync forward pass off the event loop.""" + if not text or not text.strip(): + return [] + try: + return await asyncio.to_thread(_extract_sync, text, threshold, max_chars) + except Exception as e: # noqa: BLE001 + logger.warning("GLiNER extraction failed: %s", e) + return [] diff --git a/archive/legacy_graph/graph_memory_manager_v6.py b/archive/legacy_graph/graph_memory_manager_v6.py new file mode 100644 index 000000000..c2f4c55d3 --- /dev/null +++ b/archive/legacy_graph/graph_memory_manager_v6.py @@ -0,0 +1,292 @@ +""" +v6 graph manager — lean entity index. + +Design contrast with v5: +- v5 builds two full LightRAG graphs (G_episodic + G_semantic), each with + Episode/Concept item nodes, EpisodicEntity/SemanticEntity entity nodes, + weighted EP_RELATES/SEM_RELATES edges, plus CONCEPT_RELATES edges built + via an LLM judgement pass. Retrieval walks the graph with dual-level + (entity name + relation keyword) vector search and 1-hop expansion. +- v6 throws all that away. It builds a single :V6Entity label with one + vector index on the entity name. Each V6Entity carries back-references + (episodic_ids / semantic_ids) pointing at the PG rows that mention it. + A single edge type :V6_COOCCUR captures co-occurrence within the same + source chunk, with a count property — no description, no weight, no + embedding. Retrieval is purely: + query embed → vector search on name → 1-hop co-occur expand → PG fetch + +Write cost per insert: 1 LLM call (LightRAG entity extraction, reused +from v5) + N embedding calls (one per new entity name) + a few Cypher +upserts. Roughly half the LLM/Neo4j work of v5 because we skip relation +extraction's prompting overhead, skip merge_descriptions, and skip +CONCEPT_RELATES LLM judgement. + +Both EpisodicMemoryManager.insert_event and SemanticMemoryManager.insert_item +hook into the same process_chunk() entry point with different source_kind. +The V6Entity rows are shared across the two sources — "Alice" mentioned in +both an episode and a semantic item collapses into one node with both +episodic_ids and semantic_ids populated. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, Literal, Optional + +from mirix.log import get_logger +from mirix.schemas.agent import AgentState +from mirix.services._graph_common import ( + embed_batch, + gen_id, + iso, + llm_model_from_agent, + normalize_name, +) +from mirix.services.lightrag_extractor import extract_entities_and_relations +from mirix.settings import settings + +logger = get_logger(__name__) + + +SourceKind = Literal["episodic", "semantic"] + + +class V6GraphManager: + """Stateless. Construct one per call.""" + + async def process_chunk( + self, + *, + source_kind: SourceKind, + source_id: str, + text: str, + agent_state: AgentState, + organization_id: str, + user_id: str, + ) -> dict[str, Any]: + """Extract entities, upsert :V6Entity + back-refs, write co-occur edges. + + Never raises. Returns a small stats dict for logging. + """ + if not settings.enable_graph_memory or settings.graph_version != "v6": + return {"skipped": "disabled"} + + from mirix.database.neo4j_client import get_neo4j_driver + + driver = get_neo4j_driver() + if driver is None: + return {"skipped": "no_driver"} + + if not text or not text.strip(): + return {"entities": 0} + + # Reuse v5's LightRAG extractor. We only need entity names; the + # relations payload is discarded (co-occurrence edges below are + # derived from entity pairs in the same chunk, not from LightRAG's + # relation tuples — they're noisier and add LLM cost we don't need). + extraction = await extract_entities_and_relations( + text=text, llm_model=llm_model_from_agent(agent_state) + ) + entity_names_raw = [e.name for e in extraction.entities if e.name and e.name.strip()] + # dedup within a single chunk on normalized name + seen: set[str] = set() + entity_names: list[str] = [] + entity_types: dict[str, str] = {} + for e in extraction.entities: + nl = normalize_name(e.name) + if not nl or nl in seen: + continue + seen.add(nl) + entity_names.append(e.name) + entity_types[nl] = e.entity_type or "Other" + + if not entity_names: + return {"entities": 0} + + merged_count = await self._upsert_entities_with_backref( + driver, + names=entity_names, + entity_types=entity_types, + source_kind=source_kind, + source_id=source_id, + agent_state=agent_state, + user_id=user_id, + organization_id=organization_id, + ) + + # Co-occurrence edges: all entity pairs in this chunk. + # Skip when only one entity (no pair). + if len(entity_names) >= 2: + await self._upsert_cooccur_edges( + driver, + names=entity_names, + user_id=user_id, + ) + + return { + "entities": len(entity_names), + "merged": merged_count, + "pairs": len(entity_names) * (len(entity_names) - 1) // 2, + } + + async def _upsert_entities_with_backref( + self, + driver, + *, + names: list[str], + entity_types: dict[str, str], + source_kind: SourceKind, + source_id: str, + agent_state: AgentState, + user_id: str, + organization_id: str, + ) -> int: + """Upsert V6Entity nodes; append source_id to the right back-ref list. + + Returns the number of rows that were merged (already existed). + """ + name_lowers = [normalize_name(n) for n in names] + + # One round-trip to see which (user_id, name_lower) already exist + existing = await self._fetch_existing(driver, user_id, name_lowers) + + new_names = [n for n in names if normalize_name(n) not in existing] + new_embeddings = await embed_batch(new_names, agent_state) if new_names else [] + new_emb_map: dict[str, Optional[list[float]]] = { + normalize_name(n): emb for n, emb in zip(new_names, new_embeddings) + } + + now = iso(datetime.now(timezone.utc)) + backref_field = "episodic_ids" if source_kind == "episodic" else "semantic_ids" + + new_rows: list[dict[str, Any]] = [] + for n in new_names: + nl = normalize_name(n) + new_rows.append({ + "id": gen_id("v6ent"), + "name": n, + "name_lower": nl, + "entity_type": entity_types.get(nl, "Other"), + "name_embedding": new_emb_map.get(nl), + "user_id": user_id, + "organization_id": organization_id, + "created_at": now, + "updated_at": now, + # initial back-ref list contains just this source_id + "episodic_ids": [source_id] if source_kind == "episodic" else [], + "semantic_ids": [source_id] if source_kind == "semantic" else [], + }) + + # For existing nodes, append the source_id (idempotent via list dedup) + update_rows = [ + { + "name_lower": nl, + "source_id": source_id, + "updated_at": now, + } + for nl in name_lowers + if nl in existing + ] + + async with driver.session(database=settings.neo4j_database) as session: + if new_rows: + await session.run( + """ + UNWIND $rows AS row + CREATE (e:V6Entity { + id: row.id, + name: row.name, + name_lower: row.name_lower, + entity_type: row.entity_type, + user_id: row.user_id, + organization_id: row.organization_id, + episodic_ids: row.episodic_ids, + semantic_ids: row.semantic_ids, + mention_count: 1, + created_at: row.created_at, + updated_at: row.updated_at + }) + WITH e, row + CALL { + WITH e, row + WITH e, row WHERE row.name_embedding IS NOT NULL + CALL db.create.setNodeVectorProperty(e, 'name_embedding', row.name_embedding) + RETURN count(*) AS _ + } + RETURN count(e) AS created + """, + rows=new_rows, + ) + if update_rows: + # Append source_id to the matching back-ref list, dedup via APOC-free + # Cypher list comprehension. mention_count tracks total appearances. + await session.run( + f""" + UNWIND $rows AS row + MATCH (e:V6Entity {{user_id: $user_id, name_lower: row.name_lower}}) + SET e.{backref_field} = CASE + WHEN row.source_id IN coalesce(e.{backref_field}, []) + THEN e.{backref_field} + ELSE coalesce(e.{backref_field}, []) + row.source_id + END, + e.mention_count = coalesce(e.mention_count, 0) + 1, + e.updated_at = row.updated_at + """, + rows=update_rows, user_id=user_id, + ) + + return len(update_rows) + + async def _fetch_existing( + self, driver, user_id: str, name_lowers: list[str] + ) -> dict[str, dict[str, Any]]: + if not name_lowers: + return {} + async with driver.session(database=settings.neo4j_database) as session: + result = await session.run( + """ + UNWIND $names AS nl + MATCH (e:V6Entity {user_id: $user_id, name_lower: nl}) + RETURN e.id AS id, e.name AS name, e.name_lower AS name_lower + """, + names=name_lowers, user_id=user_id, + ) + out: dict[str, dict[str, Any]] = {} + async for rec in result: + out[rec["name_lower"]] = dict(rec) + return out + + async def _upsert_cooccur_edges( + self, + driver, + *, + names: list[str], + user_id: str, + ) -> None: + """For each unordered pair, MERGE a V6_COOCCUR edge and bump count. + + Edges are undirected by convention but Neo4j requires direction — + we always store with src.name_lower < tgt.name_lower so traversal + from either side hits the same edge instance. + """ + nls = sorted({normalize_name(n) for n in names if normalize_name(n)}) + if len(nls) < 2: + return + + pairs: list[dict[str, str]] = [] + for i in range(len(nls)): + for j in range(i + 1, len(nls)): + pairs.append({"src": nls[i], "tgt": nls[j]}) + + async with driver.session(database=settings.neo4j_database) as session: + await session.run( + """ + UNWIND $pairs AS p + MATCH (a:V6Entity {user_id: $user_id, name_lower: p.src}) + MATCH (b:V6Entity {user_id: $user_id, name_lower: p.tgt}) + MERGE (a)-[e:V6_COOCCUR]->(b) + ON CREATE SET e.count = 1 + ON MATCH SET e.count = e.count + 1 + """, + pairs=pairs, user_id=user_id, + ) diff --git a/archive/legacy_graph/graph_retriever_v6.py b/archive/legacy_graph/graph_retriever_v6.py new file mode 100644 index 000000000..5cca83d56 --- /dev/null +++ b/archive/legacy_graph/graph_retriever_v6.py @@ -0,0 +1,350 @@ +""" +v6 graph retriever — lean entity-index path. + +Pipeline (no dual-level, no relation vector search, no item nodes): + 1. embed query → vector search on V6Entity.name_embedding → top-K entities + 2. 1-hop expand via V6_COOCCUR (top-N neighbors per seed, ordered by edge count) + 3. union back-refs across all (seed ∪ neighbor) entities → episodic_ids, semantic_ids + 4. PG fetch full rows from episodic_memory and semantic_memory + 5. format markdown context + +Returns "" on disabled / no driver / no hits so the dispatcher can degrade. + +Why this shape: +- The whole point of v6 is "graph as inverted index". We never traverse + weighted edges, never compute community, never read description fields off + the graph — those live in PG. Neo4j only owns the entity → memory_id map. + Current graphs may store that map either as V6Entity property arrays or as + V6MemoryRef nodes reached by APPEARS_IN / DESCRIBED_BY edges. +- 1-hop expansion uses raw edge count as a proxy for recall. Hot entities + (mention_count >> 1) would otherwise dominate; the per-seed N cap keeps + the expansion proportional. +""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass, field +from typing import Optional + +from mirix.log import get_logger +from mirix.schemas.agent import AgentState +from mirix.services._graph_common import embed_batch +from mirix.settings import settings + +logger = get_logger(__name__) + + +# Per-seed neighbor cap during 1-hop expansion. With top_k=15 seeds and +# neighbor_top_n=5 we look at ≤90 entity ids before dedup; in practice the +# back-ref union typically resolves to 30-60 unique memory rows. +DEFAULT_NEIGHBOR_TOP_N = 5 + +# Hard cap on PG rows fetched per memory type. Prevents pathological queries +# from blowing the LLM context if a hot entity is mentioned in 200+ memories. +DEFAULT_MAX_ITEMS_PER_KIND = 40 + + +@dataclass +class V6EntityHit: + id: str + name: str + entity_type: str + cosine: float + source: str # "seed" or "neighbor" + + +@dataclass +class V6MemoryRow: + id: str + kind: str # "episodic" or "semantic" + summary: str + details: str + timestamp: Optional[str] = None # occurred_at for episodic, created_at for semantic + extra: dict = field(default_factory=dict) + + +class V6Retriever: + """Stateless. Construct one per request.""" + + async def retrieve( + self, + *, + query: str, + user_id: str, + agent_state: AgentState, + top_k: int = 15, + neighbor_top_n: int = DEFAULT_NEIGHBOR_TOP_N, + max_items_per_kind: int = DEFAULT_MAX_ITEMS_PER_KIND, + ) -> str: + """Run the full v6 retrieval. Returns formatted markdown context.""" + if not settings.enable_graph_memory or settings.graph_version != "v6": + return "" + + from mirix.database.neo4j_client import get_neo4j_driver + + driver = get_neo4j_driver() + if driver is None: + return "" + + # ─── Step 1: embed query ───────────────────────────────────────── + if not query or not query.strip(): + return "" + embs = await embed_batch([query], agent_state) + q_emb = embs[0] if embs else None + if q_emb is None: + logger.info("v6 retrieve: query embedding failed → empty context") + return "" + + # ─── Step 2: vector search for top-K seed entities ─────────────── + seeds = await self._search_entities(driver, user_id, q_emb, top_k) + if not seeds: + return "" + + # ─── Step 3: 1-hop expand via V6_COOCCUR ───────────────────────── + seed_ids = [s.id for s in seeds] + neighbors = await self._expand_neighbors( + driver, user_id=user_id, seed_ids=seed_ids, + per_seed_n=neighbor_top_n, + ) + # Combine seeds + neighbors, dedup by id, keep best cosine per id + by_id: dict[str, V6EntityHit] = {} + for hit in seeds + neighbors: + existing = by_id.get(hit.id) + if existing is None or hit.cosine > existing.cosine: + by_id[hit.id] = hit + all_hits = list(by_id.values()) + + # ─── Step 4: union back-refs across all entities ───────────────── + episodic_ids, semantic_ids = await self._collect_backrefs( + driver, user_id=user_id, entity_ids=[h.id for h in all_hits], + ) + if not episodic_ids and not semantic_ids: + return self._format_context(all_hits, [], []) + + # ─── Step 5: PG fetch in parallel ──────────────────────────────── + ep_task = asyncio.create_task( + self._fetch_episodic(user_id, episodic_ids[:max_items_per_kind]) + ) + sem_task = asyncio.create_task( + self._fetch_semantic(user_id, semantic_ids[:max_items_per_kind]) + ) + ep_rows, sem_rows = await asyncio.gather(ep_task, sem_task, return_exceptions=True) + if isinstance(ep_rows, Exception): + logger.warning("v6 episodic PG fetch failed: %s", ep_rows) + ep_rows = [] + if isinstance(sem_rows, Exception): + logger.warning("v6 semantic PG fetch failed: %s", sem_rows) + sem_rows = [] + + ctx = self._format_context(all_hits, ep_rows, sem_rows) + logger.info( + "v6 retrieve: %d seeds, %d neighbors, %d ep, %d sem → %d chars", + len(seeds), len(neighbors), len(ep_rows), len(sem_rows), len(ctx), + ) + return ctx + + # ─────────────────────────────────────────────── Neo4j: vector search + + async def _search_entities( + self, driver, user_id: str, emb: list[float], top_k: int, + ) -> list[V6EntityHit]: + cypher = """ + CALL db.index.vector.queryNodes('v6_entity_name_emb', $top_k, $emb) + YIELD node AS e, score AS sim + WHERE e.user_id = $user_id + RETURN e.id AS id, e.name AS name, e.entity_type AS entity_type, sim AS sim + ORDER BY sim DESC + """ + async with driver.session(database=settings.neo4j_database) as session: + result = await session.run(cypher, top_k=top_k, emb=emb, user_id=user_id) + return [ + V6EntityHit( + id=rec["id"], name=rec["name"] or "", + entity_type=rec["entity_type"] or "Other", + cosine=float(rec["sim"] or 0.0), source="seed", + ) + async for rec in result + ] + + # ─────────────────────────────────────────────── Neo4j: 1-hop expand + + async def _expand_neighbors( + self, driver, *, user_id: str, seed_ids: list[str], per_seed_n: int, + ) -> list[V6EntityHit]: + """For each seed, pull its top-N most-frequent co-occurring neighbors.""" + if not seed_ids: + return [] + cypher = """ + UNWIND $seed_ids AS sid + MATCH (seed:V6Entity {id: sid}) + MATCH (seed)-[r:V6_COOCCUR]-(nbr:V6Entity {user_id: $user_id}) + WHERE nbr.id <> sid + WITH sid, nbr, r.count AS w + ORDER BY w DESC + WITH sid, collect({nbr: nbr, w: w})[..$per_seed_n] AS top_nbrs + UNWIND top_nbrs AS row + WITH row.nbr AS nbr, row.w AS w + RETURN DISTINCT nbr.id AS id, nbr.name AS name, + nbr.entity_type AS entity_type, w AS edge_count + """ + async with driver.session(database=settings.neo4j_database) as session: + result = await session.run( + cypher, seed_ids=seed_ids, user_id=user_id, per_seed_n=per_seed_n, + ) + # Neighbors don't have a real cosine; use a small synthetic score so + # they rank below seeds in dedup. + return [ + V6EntityHit( + id=rec["id"], name=rec["name"] or "", + entity_type=rec["entity_type"] or "Other", + cosine=0.3, source="neighbor", + ) + async for rec in result + ] + + # ─────────────────────────────────────────────── Neo4j: union back-refs + + async def _collect_backrefs( + self, driver, *, user_id: str, entity_ids: list[str], + ) -> tuple[list[str], list[str]]: + if not entity_ids: + return [], [] + cypher = """ + UNWIND $eids AS eid + MATCH (e:V6Entity {id: eid, user_id: $user_id}) + OPTIONAL MATCH (e)-[:APPEARS_IN]-(ep_ref:V6EpisodeRef) + OPTIONAL MATCH (e)-[:DESCRIBED_BY]-(sem_ref:V6ConceptRef) + RETURN + coalesce(e.episodic_ids, []) AS eps, + coalesce(e.semantic_ids, []) AS sems, + collect(DISTINCT ep_ref.id) AS ep_refs, + collect(DISTINCT sem_ref.id) AS sem_refs + """ + ep_set: set[str] = set() + sem_set: set[str] = set() + + def _strip_kind(raw: object, kind: str) -> Optional[str]: + if raw is None: + return None + value = str(raw) + prefix = f"{kind}:" + return value[len(prefix):] if value.startswith(prefix) else value + + async with driver.session(database=settings.neo4j_database) as session: + result = await session.run(cypher, eids=entity_ids, user_id=user_id) + async for rec in result: + for raw in rec["eps"] or []: + mid = _strip_kind(raw, "episodic") + if mid: + ep_set.add(mid) + for raw in rec["ep_refs"] or []: + mid = _strip_kind(raw, "episodic") + if mid: + ep_set.add(mid) + for raw in rec["sems"] or []: + mid = _strip_kind(raw, "semantic") + if mid: + sem_set.add(mid) + for raw in rec["sem_refs"] or []: + mid = _strip_kind(raw, "semantic") + if mid: + sem_set.add(mid) + return sorted(ep_set), sorted(sem_set) + + # ─────────────────────────────────────────────── PG fetch + + async def _fetch_episodic(self, user_id: str, ids: list[str]) -> list[V6MemoryRow]: + if not ids: + return [] + from sqlalchemy import text as sa_text + from mirix.server.server import db_context + + async with db_context() as session: + result = await session.execute( + sa_text( + "SELECT id, summary, details, occurred_at " + "FROM episodic_memory " + "WHERE user_id = :u AND id = ANY(:ids) " + "ORDER BY occurred_at DESC NULLS LAST" + ), + {"u": user_id, "ids": ids}, + ) + return [ + V6MemoryRow( + id=row[0], kind="episodic", + summary=row[1] or "", details=row[2] or "", + timestamp=row[3].isoformat() if row[3] is not None else None, + ) + for row in result.fetchall() + ] + + async def _fetch_semantic(self, user_id: str, ids: list[str]) -> list[V6MemoryRow]: + if not ids: + return [] + from sqlalchemy import text as sa_text + from mirix.server.server import db_context + + async with db_context() as session: + result = await session.execute( + sa_text( + "SELECT id, name, summary, details, source, created_at " + "FROM semantic_memory " + "WHERE user_id = :u AND id = ANY(:ids) " + "ORDER BY created_at DESC NULLS LAST" + ), + {"u": user_id, "ids": ids}, + ) + return [ + V6MemoryRow( + id=row[0], kind="semantic", + summary=row[2] or "", details=row[3] or "", + timestamp=row[5].isoformat() if row[5] is not None else None, + extra={"name": row[1] or "", "source": row[4] or ""}, + ) + for row in result.fetchall() + ] + + # ─────────────────────────────────────────────── format + + def _format_context( + self, entities: list[V6EntityHit], + ep_rows: list[V6MemoryRow], sem_rows: list[V6MemoryRow], + ) -> str: + if not (entities or ep_rows or sem_rows): + return "" + lines: list[str] = ["## Memory Index (v6)"] + if entities: + seed_names = [f"{e.name} ({e.entity_type})" for e in entities if e.source == "seed"] + nbr_names = [f"{e.name}" for e in entities if e.source == "neighbor"] + if seed_names: + lines.append(f"**Matched entities:** {', '.join(seed_names[:15])}") + if nbr_names: + lines.append(f"**Related entities:** {', '.join(nbr_names[:15])}") + + if ep_rows: + lines.append("\n### Episodic memories") + for r in ep_rows: + ts = self._fmt_ts(r.timestamp) + head = f"- [{ts}] {r.summary}" if ts else f"- {r.summary}" + lines.append(head.rstrip()) + if r.details and r.details != r.summary: + lines.append(f" {r.details[:400]}") + + if sem_rows: + lines.append("\n### Semantic memories") + for r in sem_rows: + name = r.extra.get("name", "") + head = f"- {name}: {r.summary}" if name else f"- {r.summary}" + lines.append(head) + if r.details and r.details != r.summary: + lines.append(f" {r.details[:400]}") + + return "\n".join(lines) + + @staticmethod + def _fmt_ts(ts: Optional[str]) -> str: + if not ts: + return "" + # already ISO; trim to YYYY-MM-DD for readability + return ts[:10] diff --git a/archive/legacy_graph/lightrag_keyword_extractor.py b/archive/legacy_graph/lightrag_keyword_extractor.py new file mode 100644 index 000000000..ef476e30f --- /dev/null +++ b/archive/legacy_graph/lightrag_keyword_extractor.py @@ -0,0 +1,158 @@ +""" +LightRAG-style query keyword extractor (high-level / low-level split). + +One LLM call per unique query, cached in Redis (or skipped if Redis is not +available — the system still works, just pays the extraction cost each time). +""" + +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass +from typing import Optional + +from mirix.log import get_logger +from mirix.prompts.lightrag_prompts import render_keywords_extraction_prompt +from mirix.services.lightrag_extractor import call_openai_chat + +logger = get_logger(__name__) + + +# Match LightRAG defaults (24h is long enough for typical chat sessions). +KEYWORD_CACHE_TTL_SECONDS = 24 * 3600 + + +@dataclass +class Keywords: + high_level: list[str] + low_level: list[str] + + +def _cache_key(user_id: str, query: str, language: str) -> str: + h = hashlib.sha1(f"{language}|{query}".encode("utf-8")).hexdigest()[:24] + return f"mirix:lightrag:kw:{user_id}:{h}" + + +def _parse_json_loose(raw: str) -> Optional[dict]: + """Try strict JSON first; if that fails, strip code fences and retry.""" + if not raw: + return None + try: + return json.loads(raw) + except json.JSONDecodeError: + pass + # Strip ``` fences + if "```" in raw: + try: + body = raw.split("```json")[-1] if "```json" in raw else raw.split("```")[1] + body = body.split("```")[0] + return json.loads(body.strip()) + except (json.JSONDecodeError, IndexError): + pass + return None + + +async def _cache_get(key: str) -> Optional[Keywords]: + try: + from mirix.database.cache_provider import get_cache_provider + + provider = get_cache_provider() + if provider is None: + return None + data = await provider.get_json(key) + if not data: + return None + return Keywords( + high_level=list(data.get("high_level", []) or []), + low_level=list(data.get("low_level", []) or []), + ) + except Exception as e: + logger.debug("Keyword cache get failed: %s", e) + return None + + +async def _cache_set(key: str, kw: Keywords) -> None: + try: + from mirix.database.cache_provider import get_cache_provider + + provider = get_cache_provider() + if provider is None: + return + await provider.set_json( + key, + {"high_level": kw.high_level, "low_level": kw.low_level}, + ttl=KEYWORD_CACHE_TTL_SECONDS, + ) + except Exception as e: + logger.debug("Keyword cache set failed: %s", e) + + +def _fallback_keywords(query: str) -> Keywords: + """When the LLM returns nothing useful, treat the query itself as ll keyword. + + Mirrors LightRAG operate.py:get_keywords_from_query short-query fallback. + """ + q = (query or "").strip() + if not q: + return Keywords(high_level=[], low_level=[]) + if len(q) < 50: + return Keywords(high_level=[], low_level=[q]) + # Long but empty parse: keep first few content words as best-effort. + words = [w for w in q.split() if len(w) > 3][:6] + return Keywords(high_level=[], low_level=words or [q[:80]]) + + +async def extract_keywords( + query: str, + *, + user_id: str, + llm_model: str = "gpt-4.1-mini", + language: str = "English", + use_cache: bool = True, +) -> Keywords: + """ + Return (high_level, low_level) keyword lists for ``query``. + + On any failure or empty model output, falls back to using the query itself + as a single low-level keyword (short queries) or splitting into content + words (long queries). Never raises. + """ + if not query or not query.strip(): + return Keywords(high_level=[], low_level=[]) + + cache_key = _cache_key(user_id, query, language) + if use_cache: + cached = await _cache_get(cache_key) + if cached is not None: + return cached + + prompt = render_keywords_extraction_prompt(query=query, language=language) + + try: + raw = await call_openai_chat( + system_prompt="You are a precise keyword extractor. Output JSON only.", + user_prompt=prompt, + model=llm_model, + temperature=0.0, + max_tokens=400, + ) + except Exception as e: + logger.warning("Keyword extraction LLM call failed: %s", e) + return _fallback_keywords(query) + + parsed = _parse_json_loose(raw) + if not parsed: + logger.warning("Keyword extraction returned unparsable output: %s", (raw or "")[:120]) + return _fallback_keywords(query) + + kw = Keywords( + high_level=[k.strip() for k in parsed.get("high_level_keywords", []) if k and k.strip()], + low_level=[k.strip() for k in parsed.get("low_level_keywords", []) if k and k.strip()], + ) + if not kw.high_level and not kw.low_level: + kw = _fallback_keywords(query) + + if use_cache: + await _cache_set(cache_key, kw) + return kw diff --git a/archive/legacy_graph/lightrag_merger.py b/archive/legacy_graph/lightrag_merger.py new file mode 100644 index 000000000..c2ce177fd --- /dev/null +++ b/archive/legacy_graph/lightrag_merger.py @@ -0,0 +1,173 @@ +""" +Description merging for entities and relations (W3/W4 helper). + +Adapted from LightRAG operate.py:_handle_entity_relation_summary. The strategy: + +1. If the descriptions, joined, fit within ``summary_context_size`` tokens AND + there are fewer than ``force_llm_summary_on_merge`` of them → just join with + a separator. No LLM call. +2. If the joined text fits within ``summary_max_tokens`` → ask the LLM for a + single summary. 1 LLM call. +3. Otherwise → split into chunks, summarize each, recurse on the summaries. + +Token counts are estimated with tiktoken (cl100k_base) for cheap accuracy. +""" + +from __future__ import annotations + +from typing import Optional + +import tiktoken + +from mirix.log import get_logger +from mirix.prompts.lightrag_prompts import render_summarize_descriptions_prompt +from mirix.services.lightrag_extractor import call_openai_chat + +logger = get_logger(__name__) + + +# Defaults align with LightRAG's recommended values. Tuned smaller to keep +# write-path cost low (MIRIX writes much more often than LightRAG ingests docs). +DEFAULT_SUMMARY_CONTEXT_SIZE = 1000 # tokens — when joined desc still fits, no summary +DEFAULT_SUMMARY_MAX_TOKENS = 500 # tokens — target output length +DEFAULT_FORCE_LLM_MERGE_AT = 6 # description count threshold +DEFAULT_SEPARATOR = " | " + +_tokenizer = None + + +def _get_tokenizer(): + global _tokenizer + if _tokenizer is None: + _tokenizer = tiktoken.get_encoding("cl100k_base") + return _tokenizer + + +def _count_tokens(text: str) -> int: + return len(_get_tokenizer().encode(text)) + + +async def merge_descriptions( + description_type: str, + name: str, + descriptions: list[str], + *, + llm_model: str = "gpt-4.1-mini", + summary_context_size: int = DEFAULT_SUMMARY_CONTEXT_SIZE, + summary_max_tokens: int = DEFAULT_SUMMARY_MAX_TOKENS, + force_llm_merge_at: int = DEFAULT_FORCE_LLM_MERGE_AT, + separator: str = DEFAULT_SEPARATOR, + max_recursion: int = 4, +) -> tuple[str, bool]: + """ + Merge a list of descriptions for a single entity or relation. + + Returns ``(merged_text, llm_used)``. ``llm_used`` lets the caller decide + whether to bump cache invalidation timestamps. + """ + descs = [d.strip() for d in descriptions if d and d.strip()] + if not descs: + return "", False + if len(descs) == 1: + return descs[0], False + + # Phase 1: cheap path — no LLM if small enough and few enough. + joined = separator.join(descs) + total_tokens = _count_tokens(joined) + if total_tokens <= summary_context_size and len(descs) < force_llm_merge_at: + return joined, False + + # Phase 2: single LLM summary if it all fits as a prompt. + if total_tokens <= summary_max_tokens * 4: # rough budget for prompt+output + summary = await _summarize_via_llm( + description_type=description_type, + name=name, + descriptions=descs, + llm_model=llm_model, + summary_max_tokens=summary_max_tokens, + ) + return summary or joined[: summary_max_tokens * 4], True + + # Phase 3: map-reduce. Chunk descs into groups whose joined size fits, then + # summarize each chunk, then recurse on the chunk summaries. + if max_recursion <= 0: + # Hard stop: just truncate the joined text. Avoids unbounded recursion + # on pathological input. + return joined[: summary_max_tokens * 4], False + + chunks: list[list[str]] = [] + current: list[str] = [] + current_tokens = 0 + for d in descs: + d_tokens = _count_tokens(d) + if current and current_tokens + d_tokens > summary_context_size: + chunks.append(current) + current, current_tokens = [d], d_tokens + else: + current.append(d) + current_tokens += d_tokens + if current: + chunks.append(current) + + chunk_summaries: list[str] = [] + llm_used = False + for ch in chunks: + if len(ch) == 1: + chunk_summaries.append(ch[0]) + continue + s = await _summarize_via_llm( + description_type=description_type, + name=name, + descriptions=ch, + llm_model=llm_model, + summary_max_tokens=summary_max_tokens, + ) + if s: + chunk_summaries.append(s) + llm_used = True + else: + # Fallback: keep raw join of this chunk + chunk_summaries.append(separator.join(ch)) + + # Recurse on the chunk summaries (now fewer items, each smaller). + final, recurse_used = await merge_descriptions( + description_type=description_type, + name=name, + descriptions=chunk_summaries, + llm_model=llm_model, + summary_context_size=summary_context_size, + summary_max_tokens=summary_max_tokens, + force_llm_merge_at=force_llm_merge_at, + separator=separator, + max_recursion=max_recursion - 1, + ) + return final, llm_used or recurse_used + + +async def _summarize_via_llm( + description_type: str, + name: str, + descriptions: list[str], + llm_model: str, + summary_max_tokens: int, +) -> Optional[str]: + """One LLM call to merge ``descriptions`` into a single paragraph.""" + prompt = render_summarize_descriptions_prompt( + description_type=description_type, + description_name=name, + description_list=descriptions, + summary_length=summary_max_tokens, + ) + try: + # Use a tiny system prompt; the user prompt carries the full template. + return ( + await call_openai_chat( + system_prompt="You are a precise summarizer.", + user_prompt=prompt, + model=llm_model, + max_tokens=summary_max_tokens + 200, + ) + ).strip() + except Exception as e: + logger.warning("Description merge LLM call failed for %s '%s': %s", description_type, name, e) + return None diff --git a/archive/legacy_graph/proposition_extractor.py b/archive/legacy_graph/proposition_extractor.py new file mode 100644 index 000000000..7edb7aaca --- /dev/null +++ b/archive/legacy_graph/proposition_extractor.py @@ -0,0 +1,152 @@ +"""v7.3 atomic-proposition extraction. + +Replaces multi-round LightRAG "gleaning" summaries with a SINGLE structured LLM +call that emits every atomic, self-contained fact. Motivation: gleaning-style +entity extraction captures salient entities but drops "cold" peripheral facts +(a county's population, a film's executive producer, a person's death year), +which are exactly the second-hop bridging facts multi-hop QA needs. Empirically +(gpt-4.1-mini): single-call proposition extraction captured cold facts LightRAG +dropped, at ~3-8x lower latency (one call vs N gleaning rounds). + +Each returned proposition is stored as a fine-grained semantic memory; the +existing V7 graph build (anchor -> DESCRIBED_BY -> ConceptRef) indexes it +unchanged, so a proposition needs no new node type. + +The same call also emits each proposition's entities. V7's graph build consumes +only `name` + `entity_type` from an extraction (relations and descriptions are +discarded), so handing those two fields to `V7GraphManager.process_memory( +entities=...)` removes the per-proposition LightRAG call entirely: one LLM call +per chunk instead of one proposition call plus one LightRAG call per proposition. +""" +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from typing import List + +from mirix.log import get_logger + +logger = get_logger(__name__) + +# Must stay aligned with V7GraphManager._specificity_score, which scores +# person/location/organization/event highest, then content/object, then +# concept/method. An unrecognised type scores as "other", and a single-word +# "other" anchor is dropped outright -- so prompt drift here silently shrinks +# the graph rather than failing loudly. +ENTITY_TYPES = ( + "person", + "location", + "organization", + "event", + "content", + "object", + "concept", + "method", +) + +PROPOSITION_PROMPT = """You extract memory facts from a conversation or document chunk. +Output EVERY atomic fact as a self-contained proposition: +- ONE fact per proposition; each must be understandable ALONE (resolve pronouns to full + names; carry the subject and any date into every proposition). +- Include ALL numbers, dates, names, amounts, roles, populations, and preferences VERBATIM — + these peripheral details are the point, do not summarize them away. +- For dialogue, capture what the user did / said / prefers, with the date if present. + +For each proposition also list its entities. An entity is a specific, named thing the +proposition is about — a person, place, organization, event, work, or object. Do NOT list +generic words ("meeting", "food"), pronouns, or the proposition's verb. +Each entity needs a "type" from EXACTLY this list: +person, location, organization, event, content, object, concept, method + +Return JSON: +{"propositions": [{"text": "...", "entities": [{"name": "...", "type": "person"}]}]}""" + + +@dataclass +class PropositionEntity: + name: str + type: str + + +@dataclass +class Proposition: + text: str + entities: List[PropositionEntity] = field(default_factory=list) + + +def _coerce(data: dict) -> List[Proposition]: + """Parse the model's JSON into Propositions, tolerating a bare-string + proposition (no entities) so a partial response still yields memories.""" + out: List[Proposition] = [] + for raw in data.get("propositions", []) or []: + if isinstance(raw, str): + text, ents = raw.strip(), [] + elif isinstance(raw, dict): + text = str(raw.get("text") or "").strip() + ents = raw.get("entities") or [] + else: + continue + if not text: + continue + + entities: List[PropositionEntity] = [] + for e in ents: + if isinstance(e, str): + name, etype = e.strip(), "other" + elif isinstance(e, dict): + name = str(e.get("name") or "").strip() + etype = str(e.get("type") or "other").strip().lower() + else: + continue + if not name: + continue + if etype not in ENTITY_TYPES: + logger.debug("proposition entity type %r not in vocabulary", etype) + etype = "other" + entities.append(PropositionEntity(name=name, type=etype)) + out.append(Proposition(text=text, entities=entities)) + return out + + +async def extract_propositions_with_entities( + text: str, + *, + api_key: str, + llm_model: str = "gpt-4.1-mini", + endpoint: str = "https://api.openai.com/v1", + max_chars: int = 16000, +) -> List[Proposition]: + """Single-call extraction of atomic propositions AND their typed entities. + Returns [] on failure — the caller decides how to handle.""" + from openai import AsyncOpenAI + + client = AsyncOpenAI(api_key=api_key, base_url=endpoint) + try: + resp = await client.chat.completions.create( + model=llm_model, + temperature=0, + response_format={"type": "json_object"}, + messages=[ + {"role": "system", "content": PROPOSITION_PROMPT}, + {"role": "user", "content": text[:max_chars]}, + ], + ) + return _coerce(json.loads(resp.choices[0].message.content)) + except Exception as e: # noqa: BLE001 + logger.warning("proposition extraction failed: %s", e) + return [] + + +async def extract_propositions( + text: str, + *, + api_key: str, + llm_model: str = "gpt-4.1-mini", + endpoint: str = "https://api.openai.com/v1", + max_chars: int = 16000, +) -> List[str]: + """Proposition text only, for callers that build the graph separately.""" + props = await extract_propositions_with_entities( + text, api_key=api_key, llm_model=llm_model, endpoint=endpoint, max_chars=max_chars + ) + return [p.text for p in props] diff --git a/archive/legacy_graph/semantic_graph_manager.py b/archive/legacy_graph/semantic_graph_manager.py new file mode 100644 index 000000000..540772174 --- /dev/null +++ b/archive/legacy_graph/semantic_graph_manager.py @@ -0,0 +1,642 @@ +""" +Semantic graph manager (v4) — writes G_semantic in Neo4j. + +Hooked from SemanticMemoryManager.insert_semantic_item after the PG row has +been committed. Failures are non-fatal. + +Graph elements written here: + (:Concept {id, user_id, organization_id, name, summary, created_at}) + (:SemanticEntity {id, user_id, organization_id, name, name_lower, + entity_type, description, rank, name_embedding, + created_at, updated_at}) + (:Concept)-[:CONCEPT_RELATES {keywords, description, weight, + keywords_embedding}]->(:Concept) + (:Concept)-[:MENTIONS]->(:SemanticEntity) + (:SemanticEntity)-[:SEM_RELATES {id, keywords, description, weight, + source_concept_ids, keywords_embedding}] + ->(:SemanticEntity) + +Concept-Concept edges are LLM-judged: when a new Concept is inserted, the +top-K most similar existing Concepts (by name embedding) are candidates; +one LLM call decides which actually have a meaningful relationship. + +Cost per insert: ~1 LLM call (entity extraction) + ~0.3-1 LLM call (description +merging + concept relation judgement). Heavier than episodic by design — the +semantic graph is small and dense, so investing in good edges pays off. +""" + +from __future__ import annotations + +import json +from datetime import datetime, timezone +from typing import Any, Optional + +from mirix.log import get_logger +from mirix.schemas.agent import AgentState +from mirix.services._graph_common import ( + embed_batch, + gen_id, + iso, + llm_model_from_agent, + normalize_name, +) +from mirix.services.lightrag_extractor import ( + ExtractedEntity, + ExtractedRelation, + call_openai_chat, + extract_entities_and_relations, +) +from mirix.services.lightrag_merger import merge_descriptions +from mirix.settings import settings + +logger = get_logger(__name__) + + +# Concept-concept relation candidate pool size. Top-K nearest concepts (by +# name embedding) get sent to the LLM for relation judgement in one batch. +DEFAULT_CONCEPT_REL_TOP_K = 5 + +# Concept-concept relation judgement prompt. Asks the LLM to return JSON for +# which candidates actually relate to the new concept and how. +_CONCEPT_REL_PROMPT = """You are a knowledge graph editor. A new concept has been added to the user's semantic memory. Decide which of the candidate concepts have a meaningful relationship with the new one. + +New concept: + name: {new_name} + summary: {new_summary} + +Candidate concepts (existing in the graph): +{candidates_block} + +For each candidate that genuinely relates to the new concept (e.g. IS_A, PART_OF, RELATES_TO, CONTRADICTS, ENABLES, CAUSED_BY), output one JSON object per line. Skip candidates that are unrelated or duplicates. Output strict JSON, one object per line, no markdown fences. If nothing relates, output nothing. + +Each object must have: + "candidate_name": str // exact name from the list above + "keywords": str // short phrase summarizing the relation type (e.g. "subclass", "part of", "contradicts") + "description": str // one sentence explaining the relationship + "weight": float // 0.0-1.0 strength +""" + + +class SemanticGraphManager: + """Stateless coordinator. Construct one per call.""" + + async def process_concept( + self, + *, + concept_id: str, + name: str, + summary: str, + details: str, + agent_state: AgentState, + organization_id: str, + user_id: str, + ) -> dict[str, Any]: + """Run the full semantic write path. Never raises.""" + if not settings.enable_graph_memory: + return {"skipped": "disabled"} + + from mirix.database.neo4j_client import get_neo4j_driver + + driver = get_neo4j_driver() + if driver is None: + return {"skipped": "no_driver"} + + text = f"{name}: {summary}\n{details or ''}" + llm_model = llm_model_from_agent(agent_state) + + # W2: extract entities + entity-entity relations from the concept text + extraction = await extract_entities_and_relations(text=text, llm_model=llm_model) + + # W1: always create the Concept node + concept_name_emb = (await embed_batch([name], agent_state))[0] + await self._upsert_concept( + driver, + concept_id=concept_id, + name=name, + summary=summary, + user_id=user_id, + organization_id=organization_id, + name_embedding=concept_name_emb, + ) + + # W6: concept-concept relation discovery + concept_rels_added = await self._discover_concept_relations( + driver, + new_concept_id=concept_id, + new_concept_name=name, + new_concept_summary=summary, + new_concept_name_emb=concept_name_emb, + user_id=user_id, + agent_state=agent_state, + llm_model=llm_model, + ) + + if not extraction.entities and not extraction.relations: + return {"entities": 0, "relations": 0, "concept_rels": concept_rels_added} + + # W3: upsert SemanticEntity nodes + merged_entities = await self._upsert_entities( + driver, + entities=extraction.entities, + concept_id=concept_id, + agent_state=agent_state, + user_id=user_id, + organization_id=organization_id, + ) + + # W4: upsert SEM_RELATES edges + merged_relations = await self._upsert_relations( + driver, + relations=extraction.relations, + concept_id=concept_id, + agent_state=agent_state, + user_id=user_id, + llm_model=llm_model, + ) + + # W7: refresh rank + await self._refresh_ranks( + driver, + names=sorted({e.name for e in extraction.entities}), + user_id=user_id, + ) + + return { + "entities": len(extraction.entities), + "relations": len(extraction.relations), + "concept_rels": concept_rels_added, + "merged_entities": merged_entities, + "merged_relations": merged_relations, + } + + # --------------------------------------------------------- W1: Concept + + async def _upsert_concept( + self, + driver, + *, + concept_id: str, + name: str, + summary: str, + user_id: str, + organization_id: str, + name_embedding: Optional[list[float]], + ) -> None: + now = iso(datetime.now(timezone.utc)) + async with driver.session(database=settings.neo4j_database) as session: + await session.run( + """ + MERGE (c:Concept {id: $id}) + ON CREATE SET c.user_id = $user_id, + c.organization_id = $org_id, + c.name = $name, + c.summary = $summary, + c.created_at = $now + ON MATCH SET c.name = $name, c.summary = $summary + """, + id=concept_id, + user_id=user_id, + org_id=organization_id, + name=name, + summary=summary or "", + now=now, + ) + if name_embedding: + # Attach name embedding to the concept itself so we can find + # similar concepts via vector search later. + await session.run( + """ + MATCH (c:Concept {id: $id}) + CALL db.create.setNodeVectorProperty(c, 'name_embedding', $emb) + RETURN count(*) AS _ + """, + id=concept_id, + emb=name_embedding, + ) + + # ----------------------------------------- W6: concept-concept relations + + async def _discover_concept_relations( + self, + driver, + *, + new_concept_id: str, + new_concept_name: str, + new_concept_summary: str, + new_concept_name_emb: Optional[list[float]], + user_id: str, + agent_state: AgentState, + llm_model: str, + top_k: int = DEFAULT_CONCEPT_REL_TOP_K, + ) -> int: + """Find candidate concepts by name vector, ask LLM which actually relate.""" + if new_concept_name_emb is None: + return 0 + + # Find top-K similar concepts via raw cypher (Concept nodes don't yet + # have a dedicated vector index by design — we use cosine over the + # property we set above; small graphs make this affordable). For + # larger deployments switch to a dedicated index on Concept. + async with driver.session(database=settings.neo4j_database) as session: + result = await session.run( + """ + MATCH (c:Concept {user_id: $user_id}) + WHERE c.id <> $id AND c.name_embedding IS NOT NULL + WITH c, vector.similarity.cosine(c.name_embedding, $emb) AS sim + ORDER BY sim DESC LIMIT $k + RETURN c.id AS id, c.name AS name, c.summary AS summary, sim + """, + user_id=user_id, + id=new_concept_id, + emb=new_concept_name_emb, + k=top_k, + ) + candidates = [dict(rec) async for rec in result] + + if not candidates: + return 0 + + candidates_block = "\n".join( + f" - {c['name']}: {(c.get('summary') or '')[:200]}" for c in candidates + ) + prompt = _CONCEPT_REL_PROMPT.format( + new_name=new_concept_name, + new_summary=(new_concept_summary or "")[:300], + candidates_block=candidates_block, + ) + + try: + raw = await call_openai_chat( + system_prompt="You are a precise knowledge graph editor. Output JSON only.", + user_prompt=prompt, + model=llm_model, + temperature=0.0, + max_tokens=600, + ) + except Exception as e: + logger.warning("Concept relation LLM call failed: %s", e) + return 0 + + # Parse line-delimited JSON, tolerant to LLM noise + relations: list[dict[str, Any]] = [] + for line in (raw or "").splitlines(): + line = line.strip().lstrip("-").strip() + if not line or not line.startswith("{"): + continue + try: + obj = json.loads(line) + except json.JSONDecodeError: + continue + cand_name = (obj.get("candidate_name") or "").strip() + if not cand_name: + continue + # Find candidate id by name (case-insensitive) + cand = next((c for c in candidates if c["name"].lower() == cand_name.lower()), None) + if cand is None: + continue + relations.append({ + "src_id": new_concept_id, + "tgt_id": cand["id"], + "keywords": (obj.get("keywords") or "")[:120], + "description": (obj.get("description") or "")[:500], + "weight": float(obj.get("weight") or 0.5), + }) + + if not relations: + return 0 + + # Embed keywords for the new edges + kw_embs = await embed_batch([r["keywords"] or r["description"] for r in relations], agent_state) + for r, emb in zip(relations, kw_embs): + r["keywords_embedding"] = emb + + async with driver.session(database=settings.neo4j_database) as session: + await session.run( + """ + UNWIND $rows AS row + MATCH (a:Concept {id: row.src_id}) + MATCH (b:Concept {id: row.tgt_id}) + MERGE (a)-[r:CONCEPT_RELATES]->(b) + ON CREATE SET r.keywords = row.keywords, + r.description = row.description, + r.weight = row.weight + ON MATCH SET r.keywords = row.keywords, + r.description = row.description, + r.weight = (coalesce(r.weight, 0.5) + row.weight) / 2.0 + WITH r, row + CALL { + WITH r, row + WITH r, row WHERE row.keywords_embedding IS NOT NULL + CALL db.create.setRelationshipVectorProperty(r, 'keywords_embedding', row.keywords_embedding) + RETURN count(*) AS _ + } + RETURN count(r) AS created + """, + rows=relations, + ) + + return len(relations) + + # --------------------------------------------------- W3: SemanticEntity + + async def _upsert_entities( + self, + driver, + *, + entities: list[ExtractedEntity], + concept_id: str, + agent_state: AgentState, + user_id: str, + organization_id: str, + ) -> int: + if not entities: + return 0 + + name_lowers = [normalize_name(e.name) for e in entities] + existing = await self._fetch_existing_entities(driver, user_id, name_lowers) + + new_entities = [e for e in entities if normalize_name(e.name) not in existing] + new_embeddings = await embed_batch([e.name for e in new_entities], agent_state) + new_emb_map: dict[str, Optional[list[float]]] = { + normalize_name(e.name): emb for e, emb in zip(new_entities, new_embeddings) + } + + now = iso(datetime.now(timezone.utc)) + merged_count = 0 + llm_model = llm_model_from_agent(agent_state) + + new_rows: list[dict[str, Any]] = [] + for e in new_entities: + nl = normalize_name(e.name) + new_rows.append({ + "id": gen_id("sement"), + "name": e.name, + "name_lower": nl, + "entity_type": e.entity_type, + "description": e.description, + "name_embedding": new_emb_map.get(nl), + "user_id": user_id, + "organization_id": organization_id, + "created_at": now, + "updated_at": now, + }) + + update_rows: list[dict[str, Any]] = [] + for e in entities: + nl = normalize_name(e.name) + existing_row = existing.get(nl) + if existing_row is None: + continue + old_desc = existing_row.get("description") or "" + new_desc = e.description or "" + if not new_desc.strip() or new_desc.strip() == old_desc.strip(): + continue + merged, llm_used = await merge_descriptions( + description_type="semantic entity", + name=existing_row["name"], + descriptions=[old_desc, new_desc] if old_desc else [new_desc], + llm_model=llm_model, + ) + if llm_used: + merged_count += 1 + update_rows.append({"id": existing_row["id"], "description": merged, "updated_at": now}) + + async with driver.session(database=settings.neo4j_database) as session: + if new_rows: + await session.run( + """ + UNWIND $rows AS row + CREATE (e:SemanticEntity { + id: row.id, + name: row.name, + name_lower: row.name_lower, + entity_type: row.entity_type, + description: row.description, + rank: 0, + user_id: row.user_id, + organization_id: row.organization_id, + created_at: row.created_at, + updated_at: row.updated_at + }) + WITH e, row + CALL { + WITH e, row + WITH e, row WHERE row.name_embedding IS NOT NULL + CALL db.create.setNodeVectorProperty(e, 'name_embedding', row.name_embedding) + RETURN count(*) AS _ + } + RETURN count(e) AS created + """, + rows=new_rows, + ) + if update_rows: + await session.run( + """ + UNWIND $rows AS row + MATCH (e:SemanticEntity {id: row.id}) + SET e.description = row.description, e.updated_at = row.updated_at + """, + rows=update_rows, + ) + + # MENTIONS edges from Concept → SemanticEntity + mention_rows = [ + {"concept_id": concept_id, "name_lower": normalize_name(e.name), "user_id": user_id} + for e in entities + ] + await session.run( + """ + UNWIND $rows AS row + MATCH (c:Concept {id: row.concept_id}) + MATCH (e:SemanticEntity {user_id: row.user_id, name_lower: row.name_lower}) + MERGE (c)-[m:MENTIONS]->(e) + """, + rows=mention_rows, + ) + + return merged_count + + async def _fetch_existing_entities( + self, driver, user_id: str, name_lowers: list[str] + ) -> dict[str, dict[str, Any]]: + if not name_lowers: + return {} + async with driver.session(database=settings.neo4j_database) as session: + result = await session.run( + """ + UNWIND $names AS nl + MATCH (e:SemanticEntity {user_id: $user_id, name_lower: nl}) + RETURN e.id AS id, e.name AS name, e.name_lower AS name_lower, + e.description AS description, e.entity_type AS entity_type + """, + names=name_lowers, + user_id=user_id, + ) + out: dict[str, dict[str, Any]] = {} + async for rec in result: + out[rec["name_lower"]] = dict(rec) + return out + + # -------------------------------------------------------- W4: SEM_RELATES + + async def _upsert_relations( + self, + driver, + *, + relations: list[ExtractedRelation], + concept_id: str, + agent_state: AgentState, + user_id: str, + llm_model: str, + ) -> int: + if not relations: + return 0 + + kw_embeddings = await embed_batch( + [r.keywords or r.description for r in relations], agent_state + ) + pairs = [(normalize_name(r.src), normalize_name(r.tgt)) for r in relations] + existing_edges = await self._fetch_existing_edges(driver, user_id, pairs) + + now = iso(datetime.now(timezone.utc)) + merged_count = 0 + + new_rows: list[dict[str, Any]] = [] + update_rows: list[dict[str, Any]] = [] + + for r, kw_emb in zip(relations, kw_embeddings): + a, b = normalize_name(r.src), normalize_name(r.tgt) + key = tuple(sorted([a, b])) + existing = existing_edges.get(key) + if existing is None: + new_rows.append({ + "id": gen_id("semrel"), + "src_lower": a, + "tgt_lower": b, + "user_id": user_id, + "keywords": r.keywords, + "description": r.description, + "weight": float(r.weight), + "created_at": now, + "source_concept_ids": [concept_id], + "keywords_embedding": kw_emb, + }) + continue + + old_desc = existing.get("description") or "" + new_desc = r.description or "" + if old_desc.strip() and new_desc.strip() and old_desc.strip() != new_desc.strip(): + merged_desc, llm_used = await merge_descriptions( + description_type="semantic relation", + name=f"{r.src} <-> {r.tgt}", + descriptions=[old_desc, new_desc], + llm_model=llm_model, + ) + if llm_used: + merged_count += 1 + else: + merged_desc = new_desc or old_desc + + old_weight = float(existing.get("weight") or 0.5) + new_weight = (old_weight + float(r.weight)) / 2.0 + old_sources: list[str] = list(existing.get("source_concept_ids") or []) + if concept_id not in old_sources: + old_sources.append(concept_id) + update_rows.append({ + "id": existing["id"], + "description": merged_desc, + "weight": new_weight, + "source_concept_ids": old_sources, + "updated_at": now, + }) + + async with driver.session(database=settings.neo4j_database) as session: + if new_rows: + await session.run( + """ + UNWIND $rows AS row + MATCH (a:SemanticEntity {user_id: row.user_id, name_lower: row.src_lower}) + MATCH (b:SemanticEntity {user_id: row.user_id, name_lower: row.tgt_lower}) + CREATE (a)-[r:SEM_RELATES { + id: row.id, + keywords: row.keywords, + description: row.description, + weight: row.weight, + created_at: row.created_at, + source_concept_ids: row.source_concept_ids + }]->(b) + WITH r, row + CALL { + WITH r, row + WITH r, row WHERE row.keywords_embedding IS NOT NULL + CALL db.create.setRelationshipVectorProperty(r, 'keywords_embedding', row.keywords_embedding) + RETURN count(*) AS _ + } + RETURN count(r) AS created + """, + rows=new_rows, + ) + if update_rows: + await session.run( + """ + UNWIND $rows AS row + MATCH ()-[r:SEM_RELATES {id: row.id}]->() + SET r.description = row.description, + r.weight = row.weight, + r.source_concept_ids = row.source_concept_ids, + r.updated_at = row.updated_at + """, + rows=update_rows, + ) + + return merged_count + + async def _fetch_existing_edges( + self, driver, user_id: str, pairs: list[tuple[str, str]] + ) -> dict[tuple[str, str], dict[str, Any]]: + if not pairs: + return {} + rows = [{"a": a, "b": b} for a, b in pairs] + async with driver.session(database=settings.neo4j_database) as session: + result = await session.run( + """ + UNWIND $rows AS row + MATCH (x:SemanticEntity {user_id: $user_id, name_lower: row.a}) + MATCH (y:SemanticEntity {user_id: $user_id, name_lower: row.b}) + MATCH (x)-[r:SEM_RELATES]-(y) + RETURN row.a AS a, row.b AS b, + r.id AS id, r.description AS description, + r.weight AS weight, r.source_concept_ids AS source_concept_ids + """, + rows=rows, + user_id=user_id, + ) + out: dict[tuple[str, str], dict[str, Any]] = {} + async for rec in result: + key = tuple(sorted([rec["a"], rec["b"]])) + out.setdefault(key, { + "id": rec["id"], + "description": rec["description"], + "weight": rec["weight"], + "source_concept_ids": rec["source_concept_ids"], + }) + return out + + # -------------------------------------------------------- W7: ranks + + async def _refresh_ranks(self, driver, *, names: list[str], user_id: str) -> None: + if not names: + return + name_lowers = [normalize_name(n) for n in names] + async with driver.session(database=settings.neo4j_database) as session: + await session.run( + """ + UNWIND $names AS nl + MATCH (e:SemanticEntity {user_id: $user_id, name_lower: nl}) + OPTIONAL MATCH (e)-[r:SEM_RELATES]-() + WITH e, count(r) AS deg + SET e.rank = deg + """, + names=name_lowers, + user_id=user_id, + ) diff --git a/archive/legacy_graph/semantic_graph_retriever.py b/archive/legacy_graph/semantic_graph_retriever.py new file mode 100644 index 000000000..669aa80ac --- /dev/null +++ b/archive/legacy_graph/semantic_graph_retriever.py @@ -0,0 +1,171 @@ +""" +Semantic graph retriever (v4) — reads G_semantic in Neo4j. + +Pipeline: + 1. ll embedding → sem_entity_name_emb vector → seed SemanticEntities + 1-hop SEM_RELATES + 2. hl embedding → sem_rel_kw_emb vector → seed SEM_RELATES + endpoints + 3. Round-robin merge + 4. MENTIONS reverse: entities → Concepts that mention them + 5. CONCEPT_RELATES one-hop: each Concept → adjacent Concepts + 6. Score + dedup + 7. PG fetch full concept details + +Unlike episodic, there is no timestamp ordering — concepts are ordered by +cosine score (recency_decay defaults to 0.5 when timestamp is missing). +""" + +from __future__ import annotations + +from typing import Optional + +from mirix.log import get_logger +from mirix.services._graph_retriever_base import ( + DEFAULT_TOP_K, + GraphRetrieverBase, + GraphSearchResult, + ItemHit, + final_score, +) + +logger = get_logger(__name__) + + +class SemanticRetriever(GraphRetrieverBase): + ENTITY_LABEL = "SemanticEntity" + ITEM_LABEL = "Concept" + REL_TYPE = "SEM_RELATES" + ENTITY_VECTOR_INDEX = "sem_entity_name_emb" + REL_VECTOR_INDEX = "sem_rel_kw_emb" + SECTION_TITLE = "Semantic" + + async def retrieve( + self, + *, + driver, + user_id: str, + ll_embedding: Optional[list[float]], + hl_embedding: Optional[list[float]], + top_k: int = DEFAULT_TOP_K, + item_top_k: int = 15, + ) -> GraphSearchResult: + entities, relations = await self.search( + driver=driver, + user_id=user_id, + ll_embedding=ll_embedding, + hl_embedding=hl_embedding, + top_k=top_k, + ) + + entity_ids = [e.id for e in entities] + concepts_via_mentions = await self._fetch_concepts_via_mentions( + driver, user_id=user_id, entity_ids=entity_ids, limit=item_top_k * 2, + ) + + concept_ids = [it.id for it in concepts_via_mentions] + concepts_via_one_hop = await self._fetch_concepts_one_hop( + driver, user_id=user_id, concept_ids=concept_ids, limit=item_top_k, + ) + + seen: set[str] = set() + merged: list[ItemHit] = [] + for it in concepts_via_mentions + concepts_via_one_hop: + if it.id in seen: + continue + seen.add(it.id) + merged.append(it) + + for it in merged: + it.score = final_score(it.cosine, it.timestamp) + merged.sort(key=lambda x: x.score, reverse=True) + merged = merged[:item_top_k] + + await self._enrich_with_pg(merged, user_id=user_id) + + return GraphSearchResult(entities=entities, relations=relations, items=merged) + + async def _fetch_concepts_via_mentions( + self, driver, *, user_id: str, entity_ids: list[str], limit: int + ) -> list[ItemHit]: + if not entity_ids: + return [] + from mirix.settings import settings + + async with driver.session(database=settings.neo4j_database) as session: + result = await session.run( + """ + UNWIND $eids AS eid + MATCH (e:SemanticEntity {id: eid})<-[:MENTIONS]-(c:Concept {user_id: $user_id}) + WITH DISTINCT c + ORDER BY c.created_at DESC + LIMIT $limit + RETURN c.id AS id, c.name AS name, c.summary AS summary, c.created_at AS created_at + """, + eids=entity_ids, user_id=user_id, limit=limit, + ) + return [ + ItemHit( + id=rec["id"], label="Concept", + summary=rec["name"] or "", # concept "summary" line uses name + detail=rec["summary"] or "", # detail line uses summary + timestamp=rec["created_at"], cosine=0.5, source="mentions", + ) + async for rec in result + ] + + async def _fetch_concepts_one_hop( + self, driver, *, user_id: str, concept_ids: list[str], limit: int + ) -> list[ItemHit]: + if not concept_ids: + return [] + from mirix.settings import settings + + async with driver.session(database=settings.neo4j_database) as session: + result = await session.run( + """ + UNWIND $cids AS cid + MATCH (c:Concept {id: cid}) + OPTIONAL MATCH (c)-[:CONCEPT_RELATES]-(n:Concept {user_id: $user_id}) + WITH n WHERE n IS NOT NULL + RETURN DISTINCT n.id AS id, n.name AS name, n.summary AS summary, n.created_at AS created_at + LIMIT $limit + """, + cids=concept_ids, user_id=user_id, limit=limit, + ) + return [ + ItemHit( + id=rec["id"], label="Concept", + summary=rec["name"] or "", + detail=rec["summary"] or "", + timestamp=rec["created_at"], cosine=0.3, source="one_hop", + ) + async for rec in result + ] + + async def _enrich_with_pg(self, items: list[ItemHit], *, user_id: str) -> None: + """Pull full semantic_memory.details. Best-effort; graph summary covers basics.""" + if not items: + return + from sqlalchemy import text as sa_text + from mirix.server.server import db_context + + ids = [it.id for it in items] + try: + async with db_context() as session: + result = await session.execute( + sa_text( + "SELECT id, details FROM semantic_memory " + "WHERE user_id = :u AND id = ANY(:ids)" + ), + {"u": user_id, "ids": ids}, + ) + detail_map = {row[0]: (row[1] or "") for row in result.fetchall()} + except Exception as e: + logger.debug("PG enrich for semantic failed: %s", e) + return + + for it in items: + if it.id in detail_map: + # If PG details is more informative than graph summary, use it + pg_detail = detail_map[it.id] + if pg_detail and pg_detail != it.detail: + it.detail = pg_detail diff --git a/docker-compose.yml b/docker-compose.yml index 2a87d1232..44667d0d5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -71,6 +71,40 @@ services: retries: 5 start_period: 5s + # ========================================================================== + # Neo4j (graph memory backend, only used when MIRIX_ENABLE_GRAPH_MEMORY=true) + # ========================================================================== + neo4j: + image: neo4j:5.20-community + container_name: mirix_neo4j + restart: unless-stopped + # Only starts when explicitly requested via: + # docker compose --profile graph up -d + # Without the profile, `docker compose up` skips this service entirely, + # so users who don't enable graph memory pay zero overhead. + profiles: ["graph"] + networks: + default: + aliases: + - mirix-neo4j + ports: + - "7474:7474" + - "7687:7687" + environment: + - NEO4J_AUTH=${MIRIX_NEO4J_USER:-neo4j}/${MIRIX_NEO4J_PASSWORD:-mirix_neo4j_dev} + - NEO4J_PLUGINS=["apoc"] + - NEO4J_dbms_memory_heap_max__size=2G + - NEO4J_dbms_memory_pagecache_size=1G + volumes: + - ./.persist/neo4j-data:/data + - ./.persist/neo4j-logs:/logs + healthcheck: + test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:7474 || exit 1"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + # ========================================================================== # Mirix API Backend # ========================================================================== @@ -99,6 +133,13 @@ services: condition: service_healthy redis: condition: service_healthy + # neo4j is profile-gated ("graph"). required: false means mirix_api + # starts even when the neo4j service isn't included in the compose run. + # When graph memory is enabled, bring it up with: + # docker compose --profile graph up -d + neo4j: + condition: service_healthy + required: false networks: default: aliases: @@ -131,6 +172,15 @@ services: - MIRIX_REDIS_ENABLED=true - MIRIX_REDIS_HOST=redis - MIRIX_REDIS_PORT=6379 + + # ======================================================================= + # Neo4j Configuration (graph memory) + # ======================================================================= + # Used when MIRIX_ENABLE_GRAPH_MEMORY=true. + - MIRIX_ENABLE_GRAPH_MEMORY=${MIRIX_ENABLE_GRAPH_MEMORY:-false} + - MIRIX_NEO4J_URI=bolt://neo4j:7687 + - MIRIX_NEO4J_USER=${MIRIX_NEO4J_USER:-neo4j} + - MIRIX_NEO4J_PASSWORD=${MIRIX_NEO4J_PASSWORD:-mirix_neo4j_dev} # - MIRIX_REDIS_PASSWORD= # Set if Redis requires auth # - MIRIX_REDIS_DB=0 # Redis database number # Alternative: Full Redis URI (overrides individual settings) diff --git a/docs/graph_memory_v2.md b/docs/graph_memory_v2.md new file mode 100644 index 000000000..1906a75fe --- /dev/null +++ b/docs/graph_memory_v2.md @@ -0,0 +1,261 @@ +# MIRIX v2: Graph Memory Layer + +**Author**: JasonH +**Date**: 2026-04-03 +**Status**: Implemented & Evaluated + +--- + +## Overview + +Graph Memory adds a temporal knowledge graph on top of MIRIX's existing flat episodic and semantic memory. When enabled, it extracts entities and relations from conversations and stores them as a graph (nodes + edges) in PostgreSQL, enabling multi-hop traversal and temporal reasoning during retrieval. + +**Key result**: +3.05% LLM Judge accuracy on LoCoMo benchmark (1540 questions, 10 conversations) with +26% time overhead. + +--- + +## Architecture + +``` +Conversation Input + │ + ▼ + ┌─────────────────────────┐ + │ Meta Memory Manager │ (unchanged) + │ → Episodic Agent │ + │ → Semantic Agent │ + │ → Core/Procedural/etc │ + └─────┬──────────┬─────────┘ + │ │ + ▼ ▼ + ┌──────────┐ ┌──────────┐ + │ Flat │ │ Graph │ ← NEW (toggle: MIRIX_ENABLE_GRAPH_MEMORY) + │ Memory │ │ Memory │ + │ (v1) │ │ (v2) │ + └──────────┘ └──────────┘ + │ │ + ▼ ▼ + PostgreSQL + pgvector +``` + +When `MIRIX_ENABLE_GRAPH_MEMORY=true`: +- **Write path**: After each episodic/semantic memory insert, additionally extract entities + relations into the graph +- **Read path**: During retrieval, supplement flat memory results with graph-traversed facts + episodes + +When disabled (default): zero changes to behavior, zero overhead. + +--- + +## Database Schema + +4 new tables (auto-created by SQLAlchemy): + +| Table | Purpose | Key Fields | +|-------|---------|------------| +| `entity_nodes` | Named entities (people, places, concepts) | name, entity_type, embedding, summary | +| `entity_edges` | Semantic facts between entities (bi-temporal) | src_id, dst_id, rel_type, fact_text, valid_at, invalid_at, expired_at | +| `episode_nodes` | Timestamped events | summary, details, event_time, embedding | +| `involves_edges` | Cross-links episode ↔ entity | episode_id, entity_id, role | + +--- + +## Write Path (W1–W5) + +Triggered at the end of `insert_event()` and `insert_semantic_item()`. + +| Step | What | LLM Calls | +|------|------|-----------| +| W1 | Create episode_node (summary + embedding) | 0 | +| W2 | Extract entities + relations from text (structured JSON output) | **1** | +| W3 | Entity dedup (case-insensitive name match by user_id) | 0 | +| W4 | Edge insert + conflict detection (expire old edge if same src+rel_type) | 0 | +| W5 | Create involves_edges (episode ↔ entity links) | 0 | + +**Total: 1 LLM call per memory insert** (vs. Graphiti's 6–10). + +### W2 Extraction Prompt + +Single structured-output call extracts: +```json +{ + "entities": [{"name": "Caroline", "type": "PERSON"}], + "relations": [{"src": "Caroline", "rel_type": "WORKS_AT", "dst": "Meta", "fact_text": "...", "valid_at": "..."}], + "episode_entities": ["Caroline", "Meta"] +} +``` + +### W4 Conflict Detection + +If a new edge has the same `(src_id, rel_type)` as an existing active edge with different `fact_text`: +- Old edge gets `expired_at = now()` +- New edge is inserted +- History is preserved (not deleted) + +--- + +## Read Path (R1–R4) + +Triggered in `retrieve_memories_by_keywords()` in rest_api.py. + +### R1: Seed Discovery (union of 4 signals) + +| Signal | What | Purpose | +|--------|------|---------| +| R1a | BM25 keyword search on entity names (top 3 query words) | Name matching | +| R1b | Embedding similarity search on entity_nodes | Semantic matching | +| R1c | Most recent 5 entities | Recency coverage | +| R1d | BM25 search on entity_edges.fact_text | Find edges directly (critical for temporal) | + +All results are unioned — no mutual exclusion. + +### R2: 2-Hop Graph Expansion + +``` +Seed entities → Hop 1 neighbors (via entity_edges) → All edges + episodes touching expanded set +``` + +SQL uses explicit hop tracking for scoring. + +### R3: Scoring & Pruning + +```python +score = 0.5 * cosine_sim(query_emb, candidate_emb) # semantic relevance + + 0.3 * exp(-0.693 * age_days / 30) # recency (valid_at, 30-day half-life) + + 0.2 * (1 / (1 + hop_distance)) # proximity (0=seed, 1=neighbor) +``` + +Top 15 edges + top 5 episodes selected. + +### R4: Context Formatting + +``` +## Relevant Facts (from knowledge graph) +- Caroline attended an LGBTQ support group (on/since 07 May 2023) +- Melanie painted a lake sunrise (on/since 15 March 2022) + +## Recent Related Events (from knowledge graph) +- [08 May 2023] Caroline shared her experience at an LGBTQ support group... +``` + +Full dates (`%d %B %Y`) for temporal reasoning. + +--- + +## Code Changes + +### Modified Files (6 files, +59 lines, 0 deletions) + +| File | Change | +|------|--------| +| `mirix/settings.py` | Add `enable_graph_memory: bool = Field(False, env="MIRIX_ENABLE_GRAPH_MEMORY")` | +| `mirix/orm/__init__.py` | Register 4 new ORM models | +| `mirix/server/server.py` | Import + init `GraphMemoryManager` | +| `mirix/server/rest_api.py` | Add graph retrieval in `retrieve_memories_by_keywords()` | +| `mirix/services/episodic_memory_manager.py` | Add graph write hook in `insert_event()` | +| `mirix/services/semantic_memory_manager.py` | Add graph write hook in `insert_semantic_item()` | + +### New Files (2 files) + +| File | Purpose | +|------|---------| +| `mirix/orm/graph_memory.py` | ORM models: EntityNode, EntityEdge, EpisodeNode, InvolvesEdge | +| `mirix/services/graph_memory_manager.py` | Write path (W1–W5) + Read path (R1–R4) | + +### Design Principles + +- All graph code is behind `if settings.enable_graph_memory` — default off +- Graph hooks are `try/except` non-fatal — graph failure doesn't affect v1 memory +- No original MIRIX logic is altered — only additions at the end of existing functions +- No new external dependencies — uses existing PostgreSQL (pgvector) + OpenAI API + +--- + +## Evaluation: LoCoMo Benchmark + +### Setup + +- **Dataset**: LoCoMo 10 conversations, 1540 questions (excl. adversarial) +- **Model**: gpt-4.1-mini (memory extraction + QA + judge) +- **Embedding**: text-embedding-3-small (1536 dim) +- **Protocol**: Per the original MIRIX eval — each conversation gets fresh DB, all sessions ingested, then all questions answered +- **Judge**: Binary CORRECT/WRONG (original MIRIX eval metric) + +### Results + +| Metric | No Graph | With Graph | Delta | +|--------|---------|------------|-------| +| **LLM Judge** | 0.5429 | **0.5734** | **+0.0305 (+5.6%)** | +| Token F1 | 0.3255 | 0.3285 | +0.0030 | +| BLEU-1 | 0.2698 | 0.2730 | +0.0031 | + +### Per Category (LLM Judge) + +| Category | n | No Graph | Graph | Delta | +|----------|---|---------|-------|-------| +| **Open Domain** | 96 | 0.3646 | **0.4271** | **+0.0625** | +| **Single Hop** | 282 | 0.5461 | **0.5957** | **+0.0496** | +| **Temporal** | 841 | 0.5541 | **0.5874** | **+0.0333** | +| Multi-Hop | 321 | 0.5639 | 0.5607 | -0.0031 | + +### Timing + +| | No Graph | With Graph | Overhead | +|--|---------|------------|----------| +| Wall time | 2.7 hours | 3.4 hours | +42 min (+26%) | + +### Analysis + +1. **Open Domain (+6.25%)**: Largest gain — graph entity/relation context enriches open-ended answers +2. **Single Hop (+4.96%)**: Direct fact retrieval from entity_edges +3. **Temporal (+3.33%)**: R1d edge BM25 search + full-date formatting helps time reasoning +4. **Multi-Hop (-0.31%)**: Essentially flat — confirmed not a regression (was -9.6% on single sample due to noise) +5. **Cost**: +1 LLM call per memory insert, ~26% more time overall + +--- + +## Usage + +### Enable Graph Memory + +```bash +# Server-side toggle +MIRIX_ENABLE_GRAPH_MEMORY=true python scripts/start_server.py --port 8531 + +# Or in .env +MIRIX_ENABLE_GRAPH_MEMORY=true +``` + +### Run Evaluation + +```bash +# Without graph (baseline) +python tests/run_locomo_all.py + +# With graph +python tests/run_locomo_all.py --graph + +# Single sample +python tests/test_locomo_quick.py --sample 0 + +# Both modes + comparison +bash public_evaluations/run.sh +``` + +### Check Graph Data + +```sql +SELECT 'entity_nodes' as tbl, count(*) FROM entity_nodes +UNION ALL SELECT 'entity_edges', count(*) FROM entity_edges +UNION ALL SELECT 'episode_nodes', count(*) FROM episode_nodes +UNION ALL SELECT 'involves_edges', count(*) FROM involves_edges; +``` + +--- + +## Future Work + +1. **Multi-hop scoring**: Use Personalized PageRank instead of simple 2-hop BFS for better multi-hop retrieval +2. **Entity summary updates**: LLM call to update entity summaries on dedup merge (currently only creates) +3. **Conflict detection with LLM**: Add LLM confirmation for ambiguous edge conflicts (currently auto-expires) +4. **Embedding-based edge search in R1**: Add `embedding <=> query` search on entity_edges alongside BM25 +5. **Adaptive top_k**: Dynamically adjust number of graph facts based on query complexity diff --git a/docs/graph_memory_v4/README.md b/docs/graph_memory_v4/README.md new file mode 100644 index 000000000..814fcb83a --- /dev/null +++ b/docs/graph_memory_v4/README.md @@ -0,0 +1,132 @@ +# v4 Graph Memory Patch + +LightRAG-style dual-graph memory for MIRIX. Adds two independent Neo4j graphs +(G_episodic for events, G_semantic for concepts), each with LightRAG dual-level +retrieval (entity name + relation keyword vectors), and dispatches retrieval +to both graphs in parallel. + +## Visualizations (LoCoMo conv-26, Caroline & Melanie) + +Whole-graph overviews, top-N entities + items, force-directed layout: + +| Episodic graph | Semantic graph | +|---|---| +| ![](kg_overview_episodic.png) | ![](kg_overview_semantic.png) | + +Same seed entity ("Painting" / "Camping" / identity-related) in both graphs, +showing how each layer organizes the topic differently: + +- `kg_subgraph_identity.png` — Transgender Journey (episodic) vs Self-Acceptance (semantic) +- `kg_subgraph_family_camping.png` — Family Camping Trip vs Camping concept +- `kg_subgraph_art_creativity.png` — Painting episode-side vs Painting concept-side + +## Source + +See [v4_graph_memory.md](v4_graph_memory.md) for per-file source and diffs +(new files in full, modifications as unified diffs). + +## What changes + +### New files (14) + +**Neo4j infrastructure** +- `mirix/database/neo4j_client.py` — async driver + schema bootstrap (6 constraints, 5 vector indexes) +- `mirix/database/startup_migrations.py` — PG migration framework (drops v2 graph tables) +- `mirix/database/token_tracker.py` — server-side token usage counter + +**LightRAG building blocks (shared)** +- `mirix/prompts/lightrag_prompts.py` — entity extraction + keyword extraction + summarize prompts +- `mirix/services/lightrag_extractor.py` — LLM-driven entity/relation extraction with delimiter parsing +- `mirix/services/lightrag_keyword_extractor.py` — query keyword extraction (ll/hl), Redis-cached +- `mirix/services/lightrag_merger.py` — map-reduce description merge + +**Two-graph write managers** +- `mirix/services/_graph_common.py` — shared helpers (gen_id, normalize_name, embed_batch) +- `mirix/services/episodic_graph_manager.py` — writes G_episodic (`:Episode`, `:EpisodicEntity`, `[:NEXT]`, `[:EP_RELATES]`, `[:MENTIONS]`) +- `mirix/services/semantic_graph_manager.py` — writes G_semantic (`:Concept`, `:SemanticEntity`, `[:CONCEPT_RELATES]`, `[:SEM_RELATES]`, `[:MENTIONS]`) + +**Two-graph retrievers** +- `mirix/services/_graph_retriever_base.py` — shared base with ll/hl Cypher + round-robin merge + budget +- `mirix/services/episodic_graph_retriever.py` — searches G_episodic + MENTIONS reverse + NEXT one-hop +- `mirix/services/semantic_graph_retriever.py` — searches G_semantic + MENTIONS reverse + CONCEPT_RELATES one-hop +- `mirix/services/graph_retriever_dispatcher.py` — keyword extract → embed batch → dispatch both retrievers in parallel → combined markdown + +### Modified (12) + +- `docker-compose.yml` — adds `neo4j:5.20-community` service + `MIRIX_NEO4J_*` env wiring +- `mirix/settings.py` — adds `neo4j_uri`, `neo4j_user`, `neo4j_password`, `neo4j_database`, `neo4j_vector_dim` +- `mirix/server/server.py` — calls `run_startup_migrations` before `Base.metadata.create_all` +- `mirix/server/rest_api.py` — Neo4j init in lifespan, `/debug/token_stats` endpoints, dispatcher hook in `retrieve_memories_by_keywords` +- `mirix/services/episodic_memory_manager.py` — sync hook after PG insert calls `EpisodicGraphManager.process_episode` +- `mirix/services/semantic_memory_manager.py` — sync hook after PG insert calls `SemanticGraphManager.process_concept` +- `mirix/llm_api/openai.py` — records token usage to tracker after each chat completion +- `mirix/orm/__init__.py` — drops v2 ORM imports +- `requirements.txt` — `neo4j>=5.20.0,<6.0.0` +- `evals/main_eval.py` — token tracker reset/snapshot per sample; saves `token_stats` to per-sample JSON +- `evals/mirix_memory_system.py` — client timeout 60s → 600s (v4 ingest is slow) +- `evals/task_agent.py` — same timeout bump + +### Deleted (2) + +- `mirix/orm/graph_memory.py` — v2 single-graph ORM (`EntityNode`, `EntityEdge`, etc) +- `mirix/services/graph_memory_manager.py` — v2 manager + +## Configuration + +When you want to use graph memory: + +```bash +# .env +MIRIX_ENABLE_GRAPH_MEMORY=true +MIRIX_NEO4J_URI=bolt://neo4j:7687 +MIRIX_NEO4J_USER=neo4j +MIRIX_NEO4J_PASSWORD=mirix_neo4j_dev +``` + +Then start the stack with the `graph` profile so Neo4j comes up: + +```bash +docker compose --profile graph up -d +``` + +## Zero-overhead default + +The flag is `false` by default. With graph memory off, every patch addition +is a no-op: + +- The hooks in `episodic_memory_manager.insert_event` and + `semantic_memory_manager.insert_semantic_item` are guarded by + `if settings.enable_graph_memory:`. +- `init_neo4j_client()` returns `None` immediately, never opens a connection. +- The `GraphRetrieverDispatcher` short-circuits to an empty string at the + same flag check, so retrieval payloads are unchanged. +- The Neo4j compose service is profile-gated (`profiles: ["graph"]`) and + `mirix_api`'s dependency on it is `required: false`, so plain + `docker compose up` skips Neo4j entirely. +- The token tracker (`mirix/database/token_tracker.py`) is disabled by + default; `record()` is a no-op until `enable()` is called (the eval + harness flips it via `POST /debug/token_stats/reset`). + +The only unconditional additions are: +- `neo4j>=5.20.0` in `requirements.txt` (~600KB pip install) +- ~10 lines of guarded code in pre-existing files + +If you don't enable graph memory, behavior is identical to upstream. + +## Tested with + +- `gpt-4.1-mini` (extraction + answer) +- `text-embedding-3-small` (entity / relation keyword embeddings) +- Neo4j 5.20-community (vector indexes require ≥5.13) +- LoCoMo conv-26: 154 QA, ~30 min ingest + retrieve + judge + +## Known limitations + +1. **TokCost(Build) instrumentation** covers OpenAI chat completions only; + embeddings + Anthropic/Gemini paths are not yet wrapped. +2. **No cross-graph entity linking** — "Caroline" in G_episodic and G_semantic + are distinct nodes by design; cross-graph reasoning happens at the LLM layer + when both KG sections appear in the prompt. +3. **Concept→Concept LLM judgement** adds ~1 LLM call per semantic insert. + Disable by patching `SemanticGraphManager._discover_concept_relations` to + return 0 unconditionally if cost is a concern. diff --git a/docs/graph_memory_v4/kg_overview_episodic.png b/docs/graph_memory_v4/kg_overview_episodic.png new file mode 100644 index 000000000..3784e9e76 Binary files /dev/null and b/docs/graph_memory_v4/kg_overview_episodic.png differ diff --git a/docs/graph_memory_v4/kg_overview_semantic.png b/docs/graph_memory_v4/kg_overview_semantic.png new file mode 100644 index 000000000..d15754eb9 Binary files /dev/null and b/docs/graph_memory_v4/kg_overview_semantic.png differ diff --git a/docs/graph_memory_v4/kg_subgraph_art_creativity.png b/docs/graph_memory_v4/kg_subgraph_art_creativity.png new file mode 100644 index 000000000..490c61c8d Binary files /dev/null and b/docs/graph_memory_v4/kg_subgraph_art_creativity.png differ diff --git a/docs/graph_memory_v4/kg_subgraph_family_camping.png b/docs/graph_memory_v4/kg_subgraph_family_camping.png new file mode 100644 index 000000000..82747e413 Binary files /dev/null and b/docs/graph_memory_v4/kg_subgraph_family_camping.png differ diff --git a/docs/graph_memory_v4/kg_subgraph_identity.png b/docs/graph_memory_v4/kg_subgraph_identity.png new file mode 100644 index 000000000..b2aac582d Binary files /dev/null and b/docs/graph_memory_v4/kg_subgraph_identity.png differ diff --git a/docs/graph_memory_v4/v4_graph_memory.md b/docs/graph_memory_v4/v4_graph_memory.md new file mode 100644 index 000000000..0cbd5c485 --- /dev/null +++ b/docs/graph_memory_v4/v4_graph_memory.md @@ -0,0 +1,4518 @@ +# v4 Graph Memory — Code Changes + +Per-file changelog of the dual-graph LightRAG patch. Companion to [README.md](README.md) (overall design) and [v4_graph_memory.patch](v4_graph_memory.patch) (machine-applicable form). + +**Conventions** +- New files: full source in a ```python``` fence +- Modified files: minimal hunk in a ```diff``` fence +- Deleted files: brief note (full deletion captured in the .patch) + +## Table of contents + +**New files (14)** +- [mirix/database/neo4j_client.py](#mirixdatabaseneo4jclientpy) +- [mirix/database/startup_migrations.py](#mirixdatabasestartupmigrationspy) +- [mirix/database/token_tracker.py](#mirixdatabasetokentrackerpy) +- [mirix/prompts/lightrag_prompts.py](#mirixpromptslightragpromptspy) +- [mirix/services/_graph_common.py](#mirixservicesgraphcommonpy) +- [mirix/services/_graph_retriever_base.py](#mirixservicesgraphretrieverbasepy) +- [mirix/services/episodic_graph_manager.py](#mirixservicesepisodicgraphmanagerpy) +- [mirix/services/episodic_graph_retriever.py](#mirixservicesepisodicgraphretrieverpy) +- [mirix/services/graph_retriever_dispatcher.py](#mirixservicesgraphretrieverdispatcherpy) +- [mirix/services/lightrag_extractor.py](#mirixserviceslightragextractorpy) +- [mirix/services/lightrag_keyword_extractor.py](#mirixserviceslightragkeywordextractorpy) +- [mirix/services/lightrag_merger.py](#mirixserviceslightragmergerpy) +- [mirix/services/semantic_graph_manager.py](#mirixservicessemanticgraphmanagerpy) +- [mirix/services/semantic_graph_retriever.py](#mirixservicessemanticgraphretrieverpy) + +**Modified files (12)** +- [docker-compose.yml](#docker-composeyml) +- [evals/main_eval.py](#evalsmainevalpy) +- [evals/mirix_memory_system.py](#evalsmirixmemorysystempy) +- [evals/task_agent.py](#evalstaskagentpy) +- [mirix/llm_api/openai.py](#mirixllmapiopenaipy) +- [mirix/server/rest_api.py](#mirixserverrestapipy) +- [mirix/server/server.py](#mirixserverserverpy) +- [mirix/services/episodic_memory_manager.py](#mirixservicesepisodicmemorymanagerpy) +- [mirix/services/semantic_memory_manager.py](#mirixservicessemanticmemorymanagerpy) +- [mirix/orm/__init__.py](#mirixorminitpy) +- [mirix/settings.py](#mirixsettingspy) +- [requirements.txt](#requirementstxt) + +**Deleted files (2)** +- `mirix/orm/graph_memory.py` +- `mirix/services/graph_memory_manager.py` + +--- + +## New files + +### `mirix/database/neo4j_client.py` + +_Async Neo4j driver + schema bootstrap (6 constraints, 5 vector indexes)_ + +```python +""" +Neo4j async client for MIRIX graph memory (v4: two independent graphs). + +Used only when ``settings.enable_graph_memory`` is True. Provides: +- Singleton AsyncDriver wrapping the bolt connection +- Schema bootstrap (constraints + vector indexes for both graphs) +- Health check + +Two independent graphs, dispatched by which MIRIX memory layer wrote the data: + +**G_episodic** (written by EpisodicMemoryManager.insert_event): +- (:Episode {id, user_id, organization_id, summary, occurred_at}) +- (:EpisodicEntity {id, user_id, organization_id, name, name_lower, + entity_type, description, rank, name_embedding, + created_at, updated_at}) +- (:Episode)-[:NEXT]->(:Episode) # auto, per user, by occurred_at +- (:Episode)-[:CAUSED_BY]->(:Episode) # optional, LLM-judged +- (:Episode)-[:MENTIONS {role}]->(:EpisodicEntity) +- (:EpisodicEntity)-[:EP_RELATES {id, keywords, description, weight, + source_episode_ids, valid_at, invalid_at, + expired_at, keywords_embedding}] + ->(:EpisodicEntity) + +**G_semantic** (written by SemanticMemoryManager.insert_semantic_item): +- (:Concept {id, user_id, organization_id, name, summary, created_at}) +- (:SemanticEntity {id, user_id, organization_id, name, name_lower, + entity_type, description, rank, name_embedding, + created_at, updated_at}) +- (:Concept)-[:CONCEPT_RELATES {keywords, description, weight}]->(:Concept) +- (:Concept)-[:MENTIONS]->(:SemanticEntity) +- (:SemanticEntity)-[:SEM_RELATES {id, keywords, description, weight, + source_concept_ids, keywords_embedding}] + ->(:SemanticEntity) + +Two independent graphs with disjoint labels AND disjoint edge types — vector +indexes can be queried per-graph without endpoint-label post-filtering. +EpisodicEntity and SemanticEntity are independent even when they share a name +("Apple" as a fruit Caroline ate vs "Apple" the company concept are distinct). +""" + +from typing import Optional + +from neo4j import AsyncDriver, AsyncGraphDatabase + +from mirix.log import get_logger +from mirix.settings import settings + +logger = get_logger(__name__) + +_neo4j_driver: Optional[AsyncDriver] = None + + +# Constraint + non-vector index DDL. Idempotent. +_SCHEMA_STATEMENTS = [ + # G_episodic constraints + "CREATE CONSTRAINT episode_id_unique IF NOT EXISTS " + "FOR (e:Episode) REQUIRE e.id IS UNIQUE", + "CREATE CONSTRAINT episodic_entity_id_unique IF NOT EXISTS " + "FOR (e:EpisodicEntity) REQUIRE e.id IS UNIQUE", + "CREATE CONSTRAINT episodic_entity_user_name_unique IF NOT EXISTS " + "FOR (e:EpisodicEntity) REQUIRE (e.user_id, e.name_lower) IS UNIQUE", + "CREATE INDEX episode_user_time IF NOT EXISTS " + "FOR (e:Episode) ON (e.user_id, e.occurred_at)", + + # G_semantic constraints + "CREATE CONSTRAINT concept_id_unique IF NOT EXISTS " + "FOR (c:Concept) REQUIRE c.id IS UNIQUE", + "CREATE CONSTRAINT semantic_entity_id_unique IF NOT EXISTS " + "FOR (e:SemanticEntity) REQUIRE e.id IS UNIQUE", + "CREATE CONSTRAINT semantic_entity_user_name_unique IF NOT EXISTS " + "FOR (e:SemanticEntity) REQUIRE (e.user_id, e.name_lower) IS UNIQUE", + "CREATE INDEX concept_user_created IF NOT EXISTS " + "FOR (c:Concept) ON (c.user_id, c.created_at)", +] + + +# Migration: drop v3 schema if it exists (old single-graph design used +# :Entity, :Event, and shared :RELATES). Also drops any old v4-pre-confirmation +# shared :RELATES vector index. Safe to run on a fresh DB — all OPTIONAL. +_V3_CLEANUP_STATEMENTS = [ + "MATCH (n:Entity) DETACH DELETE n", + "MATCH (n:Event) DETACH DELETE n", + "DROP CONSTRAINT entity_id_unique IF EXISTS", + "DROP CONSTRAINT event_id_unique IF EXISTS", + "DROP CONSTRAINT entity_user_name_unique IF EXISTS", + "DROP INDEX event_user_time IF EXISTS", + "DROP INDEX rel_expired IF EXISTS", + "DROP INDEX entity_name_emb IF EXISTS", + # Old v4-draft used a shared :RELATES vector index — drop in favor of + # ep_rel_kw_emb / sem_rel_kw_emb / concept_rel_kw_emb. + "DROP INDEX rel_kw_emb IF EXISTS", +] + + +def _vector_index_statement(name: str, label_or_rel: str, prop: str, dim: int, is_rel: bool) -> str: + """Build a CREATE VECTOR INDEX statement for nodes or relationships.""" + target = f"()-[r:{label_or_rel}]-()" if is_rel else f"(n:{label_or_rel})" + var = "r" if is_rel else "n" + return ( + f"CREATE VECTOR INDEX {name} IF NOT EXISTS " + f"FOR {target} ON {var}.{prop} " + f"OPTIONS {{indexConfig: {{" + f"`vector.dimensions`: {dim}, " + f"`vector.similarity_function`: 'cosine'" + f"}}}}" + ) + + +# Vector indexes — one per (graph, target) pair. Disjoint relationship types +# (:EP_RELATES vs :SEM_RELATES) let us keep separate vector indexes so +# queryRelationships returns episodic-only or semantic-only hits without any +# post-filtering by endpoint label. Concept-Concept edges also get their own +# vector index in case we want to do hl-style retrieval on concept relations +# in the future (P3 may or may not use it). +def _vector_indexes(dim: int) -> list[str]: + return [ + # G_episodic — entity nodes + entity-entity relations + _vector_index_statement("ep_entity_name_emb", "EpisodicEntity", "name_embedding", dim, is_rel=False), + _vector_index_statement("ep_rel_kw_emb", "EP_RELATES", "keywords_embedding", dim, is_rel=True), + + # G_semantic — entity nodes + entity-entity relations + concept-concept relations + _vector_index_statement("sem_entity_name_emb", "SemanticEntity", "name_embedding", dim, is_rel=False), + _vector_index_statement("sem_rel_kw_emb", "SEM_RELATES", "keywords_embedding", dim, is_rel=True), + _vector_index_statement("concept_rel_kw_emb", "CONCEPT_RELATES", "keywords_embedding", dim, is_rel=True), + ] + + +async def init_neo4j_client() -> Optional[AsyncDriver]: + """Initialize the Neo4j async driver and bootstrap v4 schema. + + Returns the driver, or ``None`` if graph memory is disabled or connection + fails. Failures are logged but do not raise — the rest of MIRIX must + continue working without graph memory. + """ + global _neo4j_driver + if not settings.enable_graph_memory: + logger.debug("Graph memory disabled; skipping Neo4j init") + return None + + if _neo4j_driver is not None: + return _neo4j_driver + + try: + driver = AsyncGraphDatabase.driver( + settings.neo4j_uri, + auth=(settings.neo4j_user, settings.neo4j_password), + ) + await driver.verify_connectivity() + logger.info("Neo4j async driver connected at %s", settings.neo4j_uri) + + await _bootstrap_schema(driver, settings.neo4j_vector_dim, settings.neo4j_database) + + _neo4j_driver = driver + return driver + except Exception as e: + logger.error("Neo4j init failed: %s — graph memory will be unavailable", e) + _neo4j_driver = None + return None + + +async def _bootstrap_schema(driver: AsyncDriver, vector_dim: int, database: str) -> None: + """Run DDL in order: v3 cleanup → v4 constraints/indexes → vector indexes.""" + async with driver.session(database=database) as session: + # Step 1: clean up v3 schema (no-op on fresh DB) + for stmt in _V3_CLEANUP_STATEMENTS: + try: + await session.run(stmt) + except Exception as e: + logger.debug("v3 cleanup stmt skipped (%s): %s", stmt[:50], e) + + # Step 2: v4 constraints + plain indexes + for stmt in _SCHEMA_STATEMENTS: + try: + await session.run(stmt) + except Exception as e: + logger.warning("v4 schema stmt failed (%s): %s", stmt[:60], e) + + # Step 3: vector indexes + for stmt in _vector_indexes(vector_dim): + try: + await session.run(stmt) + except Exception as e: + logger.warning("vector index stmt failed (%s): %s", stmt[:60], e) + + logger.info("Neo4j v4 schema bootstrap complete (vector_dim=%d)", vector_dim) + + +def get_neo4j_driver() -> Optional[AsyncDriver]: + """Get the global Neo4j driver. Returns None if not initialized.""" + return _neo4j_driver + + +async def close_neo4j_driver() -> None: + """Close the global driver. Safe to call when not initialized.""" + global _neo4j_driver + if _neo4j_driver is not None: + try: + await _neo4j_driver.close() + except Exception as e: + logger.warning("Error closing Neo4j driver: %s", e) + _neo4j_driver = None + + +async def neo4j_healthcheck() -> bool: + """Return True iff a trivial Cypher round-trip succeeds.""" + driver = _neo4j_driver + if driver is None: + return False + try: + async with driver.session(database=settings.neo4j_database) as session: + result = await session.run("RETURN 1 AS ok") + record = await result.single() + return record is not None and record["ok"] == 1 + except Exception as e: + logger.warning("Neo4j healthcheck failed: %s", e) + return False +``` + +### `mirix/database/startup_migrations.py` + +_Idempotent PG migration framework run before create_all (drops v2 graph tables)_ + +```python +""" +Lightweight startup migrations. + +MIRIX has no Alembic — schema is created via ``Base.metadata.create_all`` in +``ensure_tables_created``. This module runs idempotent DROP/ALTER statements +that need to happen *before* ``create_all`` so the new ORM state takes hold. + +Add a migration by appending to ``MIGRATIONS`` below. Each migration is a +``(name, sql_statements)`` tuple. Each statement runs in its own transaction; +failures are logged but do not raise (so a partial drop on dev DBs doesn't +brick startup). +""" + +from typing import List, Tuple + +from sqlalchemy.ext.asyncio import AsyncEngine + +from mirix.log import get_logger + +logger = get_logger(__name__) + + +# (migration_name, [statements]) +MIGRATIONS: List[Tuple[str, List[str]]] = [ + ( + # v2 graph memory tables — replaced by Neo4j-backed implementation + # (see lightrag_graph_manager). Drop in dependency order: edges that + # FK into entity_nodes / episode_nodes must go first. + "drop_v2_graph_memory_tables", + [ + "DROP TABLE IF EXISTS involves_edges CASCADE", + "DROP TABLE IF EXISTS entity_edges CASCADE", + "DROP TABLE IF EXISTS episode_nodes CASCADE", + "DROP TABLE IF EXISTS entity_nodes CASCADE", + ], + ), +] + + +async def run_startup_migrations(engine: AsyncEngine) -> None: + """Run all pending migrations against ``engine``. Safe to call repeatedly.""" + for name, statements in MIGRATIONS: + logger.info("Startup migration: %s", name) + for stmt in statements: + try: + async with engine.begin() as conn: + from sqlalchemy import text as sa_text + await conn.execute(sa_text(stmt)) + except Exception as e: + # Most likely on fresh DBs: table never existed. That's fine. + logger.debug("Migration stmt skipped (%s): %s", stmt, e) +``` + +### `mirix/database/token_tracker.py` + +_Opt-in process-global LLM token usage counter (default off)_ + +```python +""" +Global token-usage tracker for instrumenting MIRIX's LLM calls. + +Designed so call-sites can blindly call ``record(...)`` and external code (evals, +benchmarks) decides what counts as "build" vs "query" via context-managed phases. + +Why a tracker module instead of LangFuse: + - LangFuse is heavy (network round-trips, project setup, env vars). + - For evals we just want a per-run integer total. A process-global dict + that records by ``(phase, user_id)`` is enough. + +Usage in eval: + + from mirix.database.token_tracker import set_phase, snapshot, reset + + reset() # at process start, optional + with set_phase("build"): + await client.add(...) # all server LLM calls recorded under "build" + with set_phase("query"): + await task_agent.answer(...) + stats = snapshot() # {(phase, user_id): {prompt, completion, total, calls}} + +Usage in call-sites (one-liner): + + from mirix.database.token_tracker import record + record(prompt_tokens=..., completion_tokens=...) + +Thread-safe via a single lock; concurrency-safe via contextvars for ``_phase_var``. +""" + +from __future__ import annotations + +import contextlib +import threading +from collections import defaultdict +from contextvars import ContextVar +from typing import Optional + +# Process-wide enable flag. Default OFF so the tracker is a true no-op for +# anyone not running an eval. Flip via enable()/disable() — typically called +# from an eval harness (see evals/main_eval.py) or from the +# /debug/token_stats/* REST endpoints. +_enabled: bool = False + +# Current logical phase, propagated through asyncio tasks via contextvar. +# When tracker is enabled and no explicit phase is set, falls back to "server". +# Evals call set_phase("build") or set_phase("query") to bucket more finely. +_phase_var: ContextVar[Optional[str]] = ContextVar("mirix_token_phase", default=None) + +# Stable buckets keyed by (phase, user_id). user_id is optional — calls from +# server endpoints that don't know the user just bucket as user_id="*". +_lock = threading.Lock() +_stats: dict[tuple[str, str], dict[str, int]] = defaultdict( + lambda: {"prompt": 0, "completion": 0, "total": 0, "calls": 0} +) + + +def enable() -> None: + """Turn the tracker on. ``record()`` becomes a real write after this.""" + global _enabled + _enabled = True + + +def disable() -> None: + """Turn the tracker off. ``record()`` becomes a no-op.""" + global _enabled + _enabled = False + + +def is_enabled() -> bool: + return _enabled + + +def record( + prompt_tokens: int = 0, + completion_tokens: int = 0, + total_tokens: Optional[int] = None, + user_id: str = "*", +) -> None: + """Add one OpenAI/Anthropic ``usage`` payload to the active phase bucket. + + No-op unless ``enable()`` has been called. Phase defaults to "server" + when enabled but no ``set_phase`` context is active. + Robust to ``None`` / negative inputs. + """ + if not _enabled: + return + phase = _phase_var.get() or "server" + p = max(int(prompt_tokens or 0), 0) + c = max(int(completion_tokens or 0), 0) + t = int(total_tokens) if total_tokens is not None else p + c + with _lock: + bucket = _stats[(phase, user_id)] + bucket["prompt"] += p + bucket["completion"] += c + bucket["total"] += t + bucket["calls"] += 1 + + +@contextlib.contextmanager +def set_phase(phase: str): + """Context manager that sets ``_phase_var`` for the duration of the block. + + Nested calls are supported — inner phase wins, restored on exit. Cross-task + propagation works because ``_phase_var`` is a contextvar (each asyncio Task + inherits the calling task's context). + """ + token = _phase_var.set(phase) + try: + yield + finally: + _phase_var.reset(token) + + +def snapshot() -> dict[str, dict[str, int]]: + """Return a copy of current stats keyed by ``"phase|user_id"`` strings.""" + with _lock: + return {f"{phase}|{uid}": dict(v) for (phase, uid), v in _stats.items()} + + +def reset() -> None: + """Wipe all buckets. Use at the start of a fresh eval run.""" + with _lock: + _stats.clear() +``` + +### `mirix/prompts/lightrag_prompts.py` + +_LightRAG entity-extraction + keyword-extraction + summarize prompts_ + +```python +""" +LightRAG prompt templates, adapted for MIRIX graph memory. + +Source: https://github.com/HKUDS/LightRAG (MIT License) — see prompt.py. +The structure (delimiters, system/user split, examples format) is preserved +verbatim so output parsers can be shared. Entity types are tuned for +MIRIX's conversational corpus (added "Date", "Quantity"; dropped +"NaturalObject" / "Artifact" which rarely appear in chat). +""" + +# Delimiters used inside extracted tuples. Must match the parser in +# lightrag_extractor._parse_extraction_output. +TUPLE_DELIMITER = "<|#|>" +COMPLETION_DELIMITER = "<|COMPLETE|>" + +# Default entity types — tuned for personal-assistant conversation memory. +DEFAULT_ENTITY_TYPES = [ + "Person", + "Organization", + "Location", + "Event", + "Concept", + "Method", + "Content", + "Date", + "Quantity", + "Other", +] + + +ENTITY_EXTRACTION_SYSTEM_PROMPT = """---Role--- +You are a Knowledge Graph Specialist responsible for extracting entities and relationships from the input text. + +---Instructions--- +1. **Entity Extraction & Output:** + * **Identification:** Identify clearly defined and meaningful entities in the input text. + * **Entity Details:** For each identified entity, extract the following information: + * `entity_name`: The name of the entity. If the entity name is case-insensitive, capitalize the first letter of each significant word (title case). Ensure **consistent naming** across the entire extraction process. + * `entity_type`: Categorize the entity using one of the following types: `{entity_types}`. If none of the provided entity types apply, do not add new entity type and classify it as `Other`. + * `entity_description`: Provide a concise yet comprehensive description of the entity's attributes and activities, based *solely* on the information present in the input text. + * **Output Format - Entities:** Output a total of 4 fields for each entity, delimited by `{tuple_delimiter}`, on a single line. The first field *must* be the literal string `entity`. + * Format: `entity{tuple_delimiter}entity_name{tuple_delimiter}entity_type{tuple_delimiter}entity_description` + +2. **Relationship Extraction & Output:** + * **Identification:** Identify direct, clearly stated, and meaningful relationships between previously extracted entities. + * **N-ary Relationship Decomposition:** If a single statement describes a relationship involving more than two entities (an N-ary relationship), decompose it into multiple binary (two-entity) relationship pairs for separate description. + * **Example:** For "Alice, Bob, and Carol collaborated on Project X," extract binary relationships such as "Alice collaborated with Project X," "Bob collaborated with Project X," and "Carol collaborated with Project X," or "Alice collaborated with Bob," based on the most reasonable binary interpretations. + * **Relationship Details:** For each binary relationship, extract the following fields: + * `source_entity`: The name of the source entity. Ensure **consistent naming** with entity extraction. Capitalize the first letter of each significant word (title case) if the name is case-insensitive. + * `target_entity`: The name of the target entity. Ensure **consistent naming** with entity extraction. Capitalize the first letter of each significant word (title case) if the name is case-insensitive. + * `relationship_keywords`: One or more high-level keywords summarizing the overarching nature, concepts, or themes of the relationship. Multiple keywords within this field must be separated by a comma `,`. **DO NOT use `{tuple_delimiter}` for separating multiple keywords within this field.** + * `relationship_description`: A concise explanation of the nature of the relationship between the source and target entities, providing a clear rationale for their connection. + * `relationship_strength`: A floating point value between 0.0 and 1.0 estimating how strong/important this relationship is. + * **Output Format - Relationships:** Output a total of 6 fields for each relationship, delimited by `{tuple_delimiter}`, on a single line. The first field *must* be the literal string `relation`. + * Format: `relation{tuple_delimiter}source_entity{tuple_delimiter}target_entity{tuple_delimiter}relationship_keywords{tuple_delimiter}relationship_description{tuple_delimiter}relationship_strength` + +3. **Delimiter Usage Protocol:** + * The `{tuple_delimiter}` is a complete, atomic marker and **must not be filled with content**. It serves strictly as a field separator. + * **Incorrect Example:** `entity{tuple_delimiter}Tokyo<|location|>Tokyo is the capital of Japan.` + * **Correct Example:** `entity{tuple_delimiter}Tokyo{tuple_delimiter}Location{tuple_delimiter}Tokyo is the capital of Japan.` + +4. **Relationship Direction & Duplication:** + * Treat all relationships as **undirected** unless explicitly stated otherwise. Swapping the source and target entities for an undirected relationship does not constitute a new relationship. + * Avoid outputting duplicate relationships. + +5. **Output Order & Prioritization:** + * Output all extracted entities first, followed by all extracted relationships. + * Within the list of relationships, prioritize and output those relationships that are **most significant** to the core meaning of the input text first. + +6. **Context & Objectivity:** + * Ensure all entity names and descriptions are written in the **third person**. + * Explicitly name the subject or object; **avoid using pronouns** such as `this article`, `this paper`, `our company`, `I`, `you`, and `he/she`. + +7. **Language & Proper Nouns:** + * The entire output (entity names, keywords, and descriptions) must be written in `{language}`. + * Proper nouns (e.g., personal names, place names, organization names) should be retained in their original language if a proper, widely accepted translation is not available or would cause ambiguity. + +8. **Completion Signal:** Output the literal string `{completion_delimiter}` only after all entities and relationships, following all criteria, have been completely extracted and outputted. + +---Examples--- +{examples} +""" + + +ENTITY_EXTRACTION_USER_PROMPT = """---Task--- +Extract entities and relationships from the input text in Data to be Processed below. + +---Instructions--- +1. **Strict Adherence to Format:** Strictly adhere to all format requirements for entity and relationship lists, including output order, field delimiters, and proper noun handling, as specified in the system prompt. +2. **Output Content Only:** Output *only* the extracted list of entities and relationships. Do not include any introductory or concluding remarks, explanations, or additional text before or after the list. +3. **Completion Signal:** Output `{completion_delimiter}` as the final line after all relevant entities and relationships have been extracted and presented. +4. **Output Language:** Ensure the output language is {language}. Proper nouns (e.g., personal names, place names, organization names) must be kept in their original language and not translated. + +---Data to be Processed--- + +[{entity_types}] + + +``` +{input_text} +``` + + +""" + + +# A single conversational example to keep the system prompt small. Adding more +# examples helps consistency but inflates the prompt cost on every chunk. +ENTITY_EXTRACTION_EXAMPLES = [ + """ +["Person","Organization","Location","Event","Concept","Method","Content","Date","Quantity","Other"] + + +``` +Caroline mentioned that her cousin Melanie just moved to Berlin to start a job at SAP last month. They used to live together in Munich while Caroline was finishing her PhD on quantum optics. +``` + + +entity{tuple_delimiter}Caroline{tuple_delimiter}Person{tuple_delimiter}Caroline is the speaker; she previously lived in Munich while pursuing a PhD on quantum optics. +entity{tuple_delimiter}Melanie{tuple_delimiter}Person{tuple_delimiter}Melanie is Caroline's cousin who recently moved to Berlin to start a job at SAP. +entity{tuple_delimiter}Berlin{tuple_delimiter}Location{tuple_delimiter}Berlin is the city Melanie moved to for her new job at SAP. +entity{tuple_delimiter}Munich{tuple_delimiter}Location{tuple_delimiter}Munich is the city where Caroline and Melanie used to live together while Caroline was a PhD student. +entity{tuple_delimiter}SAP{tuple_delimiter}Organization{tuple_delimiter}SAP is the organization where Melanie recently started working. +entity{tuple_delimiter}Quantum Optics{tuple_delimiter}Concept{tuple_delimiter}Quantum optics is the subject of Caroline's PhD research. +relation{tuple_delimiter}Caroline{tuple_delimiter}Melanie{tuple_delimiter}family relation, cohabitation{tuple_delimiter}Caroline and Melanie are cousins who previously lived together in Munich.{tuple_delimiter}0.9 +relation{tuple_delimiter}Melanie{tuple_delimiter}Berlin{tuple_delimiter}relocation, residence{tuple_delimiter}Melanie recently moved to Berlin.{tuple_delimiter}0.8 +relation{tuple_delimiter}Melanie{tuple_delimiter}SAP{tuple_delimiter}employment, new job{tuple_delimiter}Melanie started a job at SAP.{tuple_delimiter}0.85 +relation{tuple_delimiter}Caroline{tuple_delimiter}Munich{tuple_delimiter}past residence, education{tuple_delimiter}Caroline lived in Munich while completing her PhD.{tuple_delimiter}0.7 +relation{tuple_delimiter}Caroline{tuple_delimiter}Quantum Optics{tuple_delimiter}academic research, PhD topic{tuple_delimiter}Caroline pursued a PhD on quantum optics.{tuple_delimiter}0.8 +{completion_delimiter} +""", +] + + +# Used by the description merge step in lightrag_merger when an entity or +# relation accumulates more than FORCE_LLM_SUMMARY_ON_MERGE descriptions. +SUMMARIZE_DESCRIPTIONS_PROMPT = """---Role--- +You are a Knowledge Graph Specialist, proficient in data curation and synthesis. + +---Task--- +Your task is to synthesize a list of descriptions of a given {description_type} into a single, comprehensive, and cohesive summary. + +---Instructions--- +1. Comprehensiveness: The summary must integrate all key information from *every* provided description. Do not omit any important facts or details. +2. Context & Objectivity: + - Write the summary from an objective, third-person perspective. + - Explicitly mention the full name of the {description_type} at the beginning of the summary to ensure immediate clarity and context. +3. Conflict Handling: + - In cases of conflicting or inconsistent descriptions, attempt to reconcile them or present both viewpoints with noted uncertainty. +4. Length Constraint: The summary's total length must not exceed {summary_length} tokens, while still maintaining depth and completeness. +5. Output: Plain text, no markdown fences, no preamble. + +---Input--- +{description_type} Name: {description_name} + +Description List: +``` +{description_list} +``` + +---Output--- +""" + + +KEYWORDS_EXTRACTION_PROMPT = """---Role--- +You are an expert keyword extractor, specializing in analyzing user queries for a Retrieval-Augmented Generation (RAG) system. Your purpose is to identify both high-level and low-level keywords in the user's query that will be used for effective document retrieval. + +---Goal--- +Given a user query, your task is to extract two distinct types of keywords: +1. **high_level_keywords**: for overarching concepts or themes, capturing user's core intent, the subject area, or the type of question being asked. +2. **low_level_keywords**: for specific entities or details, identifying the specific entities, proper nouns, technical jargon, product names, or concrete items. + +---Instructions & Constraints--- +1. **Output Format**: Your output MUST be a valid JSON object and nothing else. Do not include any explanatory text, markdown code fences (like ```json), or any other text before or after the JSON. It will be parsed directly by a JSON parser. +2. **Source of Truth**: All keywords must be explicitly derived from the user query, with both high-level and low-level keyword categories are required to contain content. +3. **Concise & Meaningful**: Keywords should be concise words or meaningful phrases. Prioritize multi-word phrases when they represent a single concept. For example, from "latest financial report of Apple Inc.", you should extract "latest financial report" and "Apple Inc." rather than "latest", "financial", "report", and "Apple". +4. **Handle Edge Cases**: For queries that are too simple, vague, or nonsensical (e.g., "hello", "ok", "asdfghjkl"), you must return a JSON object with empty lists for both keyword types. +5. **Language**: All extracted keywords MUST be in {language}. Proper nouns (e.g., personal names, place names, organization names) should be kept in their original language. + +---Examples--- +Example 1: +Query: "How does international trade influence global economic stability?" +Output: +{{"high_level_keywords": ["International trade", "Global economic stability", "Economic impact"], "low_level_keywords": ["Trade agreements", "Tariffs", "Currency exchange", "Imports", "Exports"]}} + +Example 2: +Query: "Where did Caroline live during her PhD?" +Output: +{{"high_level_keywords": ["Past residence", "Academic life"], "low_level_keywords": ["Caroline", "PhD", "Munich"]}} + +Example 3: +Query: "What is the role of education in reducing poverty?" +Output: +{{"high_level_keywords": ["Education", "Poverty reduction", "Socioeconomic development"], "low_level_keywords": ["School access", "Literacy rates", "Job training", "Income inequality"]}} + +---Real Data--- +User Query: {query} + +---Output--- +""" + + +def render_keywords_extraction_prompt(query: str, language: str = "English") -> str: + return KEYWORDS_EXTRACTION_PROMPT.format(query=query, language=language) + + +def render_extraction_system_prompt( + entity_types: list[str] | None = None, + language: str = "English", +) -> str: + """Render the system prompt with entity types and example bodies inlined.""" + types = entity_types or DEFAULT_ENTITY_TYPES + types_str = ", ".join(types) + example_ctx = { + "tuple_delimiter": TUPLE_DELIMITER, + "completion_delimiter": COMPLETION_DELIMITER, + } + examples = "\n".join(ex.format(**example_ctx) for ex in ENTITY_EXTRACTION_EXAMPLES) + return ENTITY_EXTRACTION_SYSTEM_PROMPT.format( + entity_types=types_str, + tuple_delimiter=TUPLE_DELIMITER, + completion_delimiter=COMPLETION_DELIMITER, + language=language, + examples=examples, + ) + + +def render_extraction_user_prompt( + input_text: str, + entity_types: list[str] | None = None, + language: str = "English", +) -> str: + types = entity_types or DEFAULT_ENTITY_TYPES + return ENTITY_EXTRACTION_USER_PROMPT.format( + entity_types=", ".join(types), + completion_delimiter=COMPLETION_DELIMITER, + language=language, + input_text=input_text, + ) + + +def render_summarize_descriptions_prompt( + description_type: str, + description_name: str, + description_list: list[str], + summary_length: int = 500, +) -> str: + return SUMMARIZE_DESCRIPTIONS_PROMPT.format( + description_type=description_type, + description_name=description_name, + description_list="\n".join(f"- {d}" for d in description_list), + summary_length=summary_length, + ) +``` + +### `mirix/services/_graph_common.py` + +_Shared helpers: gen_id, normalize_name, iso, embed_batch_ + +```python +""" +Shared helpers for v4 graph managers (episodic + semantic). + +Both managers do roughly the same things but write into disjoint Neo4j labels: + - episodic: (:Episode), (:EpisodicEntity), [:EP_RELATES], [:MENTIONS], [:NEXT] + - semantic: (:Concept), (:SemanticEntity), [:SEM_RELATES], [:MENTIONS], + [:CONCEPT_RELATES] + +This module hosts the parts that don't care which label set is in play: +helpers for id generation, name normalization, embedding batching, and the +LLM model resolution from an AgentState. +""" + +from __future__ import annotations + +import asyncio +import uuid +from datetime import datetime, timezone +from typing import Optional + +from mirix.embeddings import embedding_model +from mirix.log import get_logger +from mirix.schemas.agent import AgentState + +logger = get_logger(__name__) + + +def gen_id(prefix: str) -> str: + return f"{prefix}-{uuid.uuid4().hex[:24]}" + + +def normalize_name(name: str) -> str: + return (name or "").strip().lower() + + +def iso(ts: datetime) -> str: + """Neo4j datetime properties want ISO-8601 strings (with tz).""" + if ts.tzinfo is None: + ts = ts.replace(tzinfo=timezone.utc) + return ts.isoformat() + + +def llm_model_from_agent(agent_state: AgentState, default: str = "gpt-4.1-mini") -> str: + """Pull LLM model name from agent_state, falling back to default.""" + try: + cfg = getattr(agent_state, "llm_config", None) + if cfg is not None and getattr(cfg, "model", None): + return cfg.model + except Exception: + pass + return default + + +async def embed_batch( + texts: list[str], agent_state: AgentState, *, max_concurrency: int = 8 +) -> list[Optional[list[float]]]: + """ + Compute embeddings for many short strings via the agent's configured model. + + MIRIX's embedding adapter is single-text only, so we fan out with bounded + concurrency. Returns ``None`` for failed entries so callers can decide + whether to drop the row or store without a vector. + """ + if not texts: + return [] + try: + model = await embedding_model(agent_state.embedding_config) + except Exception as e: + logger.warning("Embedding model init failed: %s", e) + return [None] * len(texts) + + sem = asyncio.Semaphore(max_concurrency) + + async def one(t: str) -> Optional[list[float]]: + async with sem: + try: + return await model.get_text_embedding(t) + except Exception as e: + logger.debug("Embed failed for '%s...': %s", (t or "")[:40], e) + return None + + return await asyncio.gather(*(one(t) for t in texts)) +``` + +### `mirix/services/_graph_retriever_base.py` + +_Symmetric base for episodic+semantic retrievers (ll/hl Cypher + budget)_ + +```python +""" +Shared retriever base for v4 graph readers. + +EpisodicRetriever and SemanticRetriever are structurally identical — they +walk one graph (entities → relations → items), dedup, and score. The only +differences are which labels/types they MATCH, which vector indexes they +query, and what extra item-item edges they expand (NEXT for episodes, +CONCEPT_RELATES for concepts). This base factors out the shared algorithm +and lets subclasses fill in label/type strings. +""" + +from __future__ import annotations + +import asyncio +import math +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any, Optional + +import tiktoken + +from mirix.log import get_logger + +logger = get_logger(__name__) + + +# Token budgets and ranking constants. Tuned for chat-memory scale. +DEFAULT_TOP_K = 30 +DEFAULT_CHUNK_TOP_K = 10 +RECENCY_HALF_LIFE_DAYS = 30.0 +COSINE_WEIGHT = 0.7 +RECENCY_WEIGHT = 0.3 + + +_tokenizer = None + + +def count_tokens(text: str) -> int: + global _tokenizer + if _tokenizer is None: + _tokenizer = tiktoken.get_encoding("cl100k_base") + if not text: + return 0 + return len(_tokenizer.encode(text)) + + +def recency_decay(ts: Optional[Any]) -> float: + """Exponential decay over days; missing ts → 0.5.""" + if ts is None: + return 0.5 + try: + if isinstance(ts, str): + ts = datetime.fromisoformat(ts.replace("Z", "+00:00")) + if ts.tzinfo is None: + ts = ts.replace(tzinfo=timezone.utc) + age_days = (datetime.now(timezone.utc) - ts).total_seconds() / 86400.0 + if age_days < 0: + return 1.0 + return math.exp(-0.693 * age_days / RECENCY_HALF_LIFE_DAYS) + except Exception: + return 0.5 + + +def final_score(cosine: float, ts: Optional[Any]) -> float: + return COSINE_WEIGHT * float(cosine or 0.0) + RECENCY_WEIGHT * recency_decay(ts) + + +def fmt_date(ts: Optional[Any]) -> str: + if ts is None: + return "unknown" + try: + if isinstance(ts, str): + ts = datetime.fromisoformat(ts.replace("Z", "+00:00")) + return ts.strftime("%d %B %Y") + except Exception: + return str(ts) + + +# ────────────────────────────────────────────────────────────── dataclasses + +@dataclass +class EntityHit: + id: str + name: str + entity_type: str + description: str + rank: int + cosine: float + updated_at: Optional[Any] + score: float = 0.0 + + +@dataclass +class RelationHit: + id: str + src_name: str + tgt_name: str + keywords: str + description: str + weight: float + cosine: float + valid_at: Optional[Any] + score: float = 0.0 + + +@dataclass +class ItemHit: + """Episode or Concept — same shape so format/budget code is shared.""" + id: str + label: str # 'Episode' or 'Concept' + summary: str + detail: str # episode.details or concept.summary (fallback) + timestamp: Optional[Any] # occurred_at for Episode, created_at for Concept + cosine: float + score: float = 0.0 + source: str = "mentions" # 'mentions', 'one_hop', etc — for debugging + + +@dataclass +class GraphSearchResult: + entities: list[EntityHit] = field(default_factory=list) + relations: list[RelationHit] = field(default_factory=list) + items: list[ItemHit] = field(default_factory=list) + + +# ────────────────────────────────────────── round-robin merge helpers + +def round_robin_merge_entities( + locals_: list[EntityHit], globals_: list[EntityHit] +) -> list[EntityHit]: + merged: list[EntityHit] = [] + seen: set[str] = set() + n = max(len(locals_), len(globals_)) + for i in range(n): + for source in (locals_, globals_): + if i < len(source): + hit = source[i] + key = (hit.name or "").lower() or hit.id + if key in seen: + continue + seen.add(key) + merged.append(hit) + return merged + + +def round_robin_merge_relations( + locals_: list[RelationHit], globals_: list[RelationHit] +) -> list[RelationHit]: + merged: list[RelationHit] = [] + seen: set[tuple[str, str]] = set() + n = max(len(locals_), len(globals_)) + for i in range(n): + for source in (locals_, globals_): + if i < len(source): + hit = source[i] + key = tuple(sorted([(hit.src_name or "").lower(), (hit.tgt_name or "").lower()])) + if key in seen: + continue + seen.add(key) + merged.append(hit) + return merged + + +# ────────────────────────────────────────── token budget application + +def apply_budget_to_search( + search: GraphSearchResult, + *, + max_entity_tokens: int, + max_relation_tokens: int, + max_item_tokens: int, +) -> GraphSearchResult: + def _trim(items: list, render, budget: int) -> list: + kept = [] + used = 0 + for it in items: + t = count_tokens(render(it)) + if used + t > budget: + break + kept.append(it) + used += t + return kept + + kept_e = _trim( + search.entities, + lambda e: f"- {e.name} ({e.entity_type}, rank={e.rank}): {e.description}", + max_entity_tokens, + ) + kept_r = _trim( + search.relations, + lambda r: f"- {r.src_name} <-> {r.tgt_name} [{r.keywords}]: {r.description}", + max_relation_tokens, + ) + kept_i = _trim( + search.items, + lambda it: f"- [{fmt_date(it.timestamp)}] {it.summary} {it.detail[:200]}", + max_item_tokens, + ) + return GraphSearchResult(entities=kept_e, relations=kept_r, items=kept_i) + + +# ────────────────────────────────────────── retriever base + +class GraphRetrieverBase: + """Subclasses set these as class attributes.""" + + # Labels + ENTITY_LABEL: str = "" # 'EpisodicEntity' or 'SemanticEntity' + ITEM_LABEL: str = "" # 'Episode' or 'Concept' + REL_TYPE: str = "" # 'EP_RELATES' or 'SEM_RELATES' + + # Vector index names + ENTITY_VECTOR_INDEX: str = "" # 'ep_entity_name_emb' or 'sem_entity_name_emb' + REL_VECTOR_INDEX: str = "" # 'ep_rel_kw_emb' or 'sem_rel_kw_emb' + + # Title used in markdown output + SECTION_TITLE: str = "" # 'Episodic' or 'Semantic' + + # ──────────────────────────────────────────────────────── low-level path + + def _ll_cypher(self) -> str: + # Vector search on entity names → seed entities. For each seed, pull + # one-hop neighbor entities via REL_TYPE (no expired_at on semantic + # rels — but the predicate is harmless: missing property tests as + # NULL which evaluates the WHERE filter as "IS NULL = true"). + return f""" + CALL db.index.vector.queryNodes('{self.ENTITY_VECTOR_INDEX}', $top_k, $emb) + YIELD node AS e, score AS sim + WHERE e.user_id = $user_id + WITH e, sim + ORDER BY sim DESC LIMIT $top_k + WITH collect({{e: e, sim: sim}}) AS seeds + UNWIND seeds AS s + WITH s.e AS seed, s.sim AS sim + OPTIONAL MATCH (seed)-[r:{self.REL_TYPE}]-(other:{self.ENTITY_LABEL} {{user_id: $user_id}}) + WHERE coalesce(r.expired_at, datetime('9999-01-01')) > datetime() + RETURN + seed.id AS sid, seed.name AS sname, seed.entity_type AS stype, + seed.description AS sdesc, coalesce(seed.rank, 0) AS srank, + seed.updated_at AS supdated, sim AS sim, + r.id AS rid, r.description AS rdesc, r.keywords AS rkw, + coalesce(r.weight, 0.5) AS rweight, r.valid_at AS rvalid, + other.name AS oname + """ + + def _hl_cypher(self) -> str: + # Vector search on relation keywords → seed relations. Pull both + # endpoint entities. We don't have to filter by endpoint label since + # REL_TYPE alone identifies the graph (EP_RELATES vs SEM_RELATES). + return f""" + CALL db.index.vector.queryRelationships('{self.REL_VECTOR_INDEX}', $top_k, $emb) + YIELD relationship AS r, score AS sim + WHERE coalesce(r.expired_at, datetime('9999-01-01')) > datetime() + MATCH (a:{self.ENTITY_LABEL})-[r]-(b:{self.ENTITY_LABEL}) + WHERE a.user_id = $user_id AND b.user_id = $user_id + WITH r, sim, a, b + ORDER BY sim DESC LIMIT $top_k + RETURN + r.id AS rid, r.description AS rdesc, r.keywords AS rkw, + coalesce(r.weight, 0.5) AS rweight, r.valid_at AS rvalid, sim AS sim, + a.id AS aid, a.name AS aname, a.entity_type AS atype, + a.description AS adesc, coalesce(a.rank, 0) AS arank, a.updated_at AS aupdated, + b.id AS bid, b.name AS bname, b.entity_type AS btype, + b.description AS bdesc, coalesce(b.rank, 0) AS brank, b.updated_at AS bupdated + """ + + # ─────────────────────────────────────── high-level orchestration + + async def search( + self, + *, + driver, + user_id: str, + ll_embedding: Optional[list[float]], + hl_embedding: Optional[list[float]], + top_k: int = DEFAULT_TOP_K, + ) -> tuple[list[EntityHit], list[RelationHit]]: + """Run ll + hl in parallel where embeddings are available.""" + from mirix.settings import settings + + tasks: dict[str, asyncio.Task] = {} + if ll_embedding is not None: + tasks["ll"] = asyncio.create_task( + self._run_ll(driver, user_id, ll_embedding, top_k, settings.neo4j_database) + ) + if hl_embedding is not None: + tasks["hl"] = asyncio.create_task( + self._run_hl(driver, user_id, hl_embedding, top_k, settings.neo4j_database) + ) + if not tasks: + return [], [] + results = await asyncio.gather(*tasks.values(), return_exceptions=True) + local_e, local_r, global_e, global_r = [], [], [], [] + for purpose, res in zip(tasks.keys(), results): + if isinstance(res, Exception): + logger.warning("%s retrieval branch %s failed: %s", self.SECTION_TITLE, purpose, res) + continue + if purpose == "ll": + local_e, local_r = res + elif purpose == "hl": + global_e, global_r = res + + final_e = round_robin_merge_entities(local_e, global_e) + final_r = round_robin_merge_relations(local_r, global_r) + for e in final_e: + e.score = final_score(e.cosine, e.updated_at) + for r in final_r: + r.score = final_score(r.cosine, r.valid_at) + final_e.sort(key=lambda x: x.score, reverse=True) + final_r.sort(key=lambda x: x.score, reverse=True) + return final_e, final_r + + async def _run_ll(self, driver, user_id, emb, top_k, database): + entities: dict[str, EntityHit] = {} + relations: dict[str, RelationHit] = {} + async with driver.session(database=database) as session: + result = await session.run(self._ll_cypher(), user_id=user_id, emb=emb, top_k=top_k) + async for rec in result: + sid = rec["sid"] + if sid and sid not in entities: + entities[sid] = EntityHit( + id=sid, name=rec["sname"], + entity_type=rec["stype"] or "Other", + description=rec["sdesc"] or "", + rank=int(rec["srank"] or 0), + cosine=float(rec["sim"] or 0.0), + updated_at=rec["supdated"], + ) + rid = rec["rid"] + if rid and rid not in relations: + relations[rid] = RelationHit( + id=rid, + src_name=rec["sname"], tgt_name=rec["oname"] or "", + keywords=rec["rkw"] or "", description=rec["rdesc"] or "", + weight=float(rec["rweight"] or 0.5), + cosine=float(rec["sim"] or 0.0), + valid_at=rec["rvalid"], + ) + return list(entities.values()), list(relations.values()) + + async def _run_hl(self, driver, user_id, emb, top_k, database): + entities: dict[str, EntityHit] = {} + relations: dict[str, RelationHit] = {} + async with driver.session(database=database) as session: + result = await session.run(self._hl_cypher(), user_id=user_id, emb=emb, top_k=top_k) + async for rec in result: + rid = rec["rid"] + if rid and rid not in relations: + relations[rid] = RelationHit( + id=rid, + src_name=rec["aname"] or "", tgt_name=rec["bname"] or "", + keywords=rec["rkw"] or "", description=rec["rdesc"] or "", + weight=float(rec["rweight"] or 0.5), + cosine=float(rec["sim"] or 0.0), + valid_at=rec["rvalid"], + ) + for prefix in ("a", "b"): + eid = rec[f"{prefix}id"] + if not eid or eid in entities: + continue + entities[eid] = EntityHit( + id=eid, name=rec[f"{prefix}name"], + entity_type=rec[f"{prefix}type"] or "Other", + description=rec[f"{prefix}desc"] or "", + rank=int(rec[f"{prefix}rank"] or 0), + cosine=float(rec["sim"] or 0.0), + updated_at=rec[f"{prefix}updated"], + ) + return list(entities.values()), list(relations.values()) +``` + +### `mirix/services/episodic_graph_manager.py` + +_Writes G_episodic: Episode, EpisodicEntity, NEXT, EP_RELATES, MENTIONS_ + +```python +""" +Episodic graph manager (v4) — writes G_episodic in Neo4j. + +Hooked from EpisodicMemoryManager.insert_event after the PG row has been +committed. Failures are non-fatal: PG remains the source of truth. + +Graph elements written here: + (:Episode {id, user_id, organization_id, summary, occurred_at}) + (:EpisodicEntity {id, user_id, organization_id, name, name_lower, + entity_type, description, rank, name_embedding, + created_at, updated_at}) + (:Episode)-[:NEXT]->(:Episode) + (:Episode)-[:MENTIONS {role}]->(:EpisodicEntity) + (:EpisodicEntity)-[:EP_RELATES {id, keywords, description, weight, + source_episode_ids, valid_at, invalid_at, + expired_at, keywords_embedding}] + ->(:EpisodicEntity) + +CAUSED_BY edges are reserved for a future optional LLM step (P2 leaves them +unused — write path stays at ~1 LLM call/insert). +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, Optional + +from mirix.log import get_logger +from mirix.schemas.agent import AgentState +from mirix.services._graph_common import ( + embed_batch, + gen_id, + iso, + llm_model_from_agent, + normalize_name, +) +from mirix.services.lightrag_extractor import ( + ExtractedEntity, + ExtractedRelation, + extract_entities_and_relations, +) +from mirix.services.lightrag_merger import merge_descriptions +from mirix.settings import settings + +logger = get_logger(__name__) + + +class EpisodicGraphManager: + """Stateless coordinator. Construct one per call.""" + + async def process_episode( + self, + *, + episode_id: str, + summary: str, + details: str, + occurred_at: datetime, + agent_state: AgentState, + organization_id: str, + user_id: str, + ) -> dict[str, Any]: + """Run the full episodic write path. Never raises.""" + if not settings.enable_graph_memory: + return {"skipped": "disabled"} + + from mirix.database.neo4j_client import get_neo4j_driver + + driver = get_neo4j_driver() + if driver is None: + return {"skipped": "no_driver"} + + text = (summary or "") + ("\n" + details if details else "") + + # W2: extract first so we know what to embed/upsert + extraction = await extract_entities_and_relations( + text=text, llm_model=llm_model_from_agent(agent_state) + ) + + # W1: always create the Episode node, even when extraction is empty + await self._upsert_episode( + driver, + episode_id=episode_id, + summary=summary, + occurred_at=occurred_at, + user_id=user_id, + organization_id=organization_id, + ) + + # W6: connect Episode to previous Episode by occurred_at (auto NEXT) + await self._link_next(driver, user_id=user_id, episode_id=episode_id, occurred_at=occurred_at) + + if not extraction.entities and not extraction.relations: + return {"entities": 0, "relations": 0} + + # W3: upsert EpisodicEntity nodes + merged_entities = await self._upsert_entities( + driver, + entities=extraction.entities, + episode_id=episode_id, + agent_state=agent_state, + user_id=user_id, + organization_id=organization_id, + ) + + # W4: upsert EP_RELATES edges + merged_relations = await self._upsert_relations( + driver, + relations=extraction.relations, + episode_id=episode_id, + occurred_at=occurred_at, + agent_state=agent_state, + user_id=user_id, + llm_model=llm_model_from_agent(agent_state), + ) + + # W7: refresh rank (degree) + touched_names = sorted({e.name for e in extraction.entities}) + await self._refresh_ranks(driver, names=touched_names, user_id=user_id) + + return { + "entities": len(extraction.entities), + "relations": len(extraction.relations), + "merged_entities": merged_entities, + "merged_relations": merged_relations, + } + + # --------------------------------------------------------- W1: Episode + + async def _upsert_episode( + self, + driver, + *, + episode_id: str, + summary: str, + occurred_at: datetime, + user_id: str, + organization_id: str, + ) -> None: + async with driver.session(database=settings.neo4j_database) as session: + await session.run( + """ + MERGE (e:Episode {id: $id}) + ON CREATE SET e.user_id = $user_id, + e.organization_id = $org_id, + e.summary = $summary, + e.occurred_at = $occurred_at + ON MATCH SET e.summary = $summary, + e.occurred_at = $occurred_at + """, + id=episode_id, + user_id=user_id, + org_id=organization_id, + summary=summary or "", + occurred_at=iso(occurred_at), + ) + + # ------------------------------------------------------- W6: NEXT edges + + async def _link_next( + self, driver, *, user_id: str, episode_id: str, occurred_at: datetime + ) -> None: + """ + Connect the new episode to the most recent prior episode (same user) + with a :NEXT edge. Idempotent: if a NEXT edge from the same prior + episode already exists, MERGE keeps it. + """ + async with driver.session(database=settings.neo4j_database) as session: + await session.run( + """ + MATCH (current:Episode {id: $id}) + OPTIONAL MATCH (prev:Episode {user_id: $user_id}) + WHERE prev.id <> $id AND prev.occurred_at < $occurred_at + WITH current, prev + ORDER BY prev.occurred_at DESC + LIMIT 1 + FOREACH (_ IN CASE WHEN prev IS NULL THEN [] ELSE [1] END | + MERGE (prev)-[:NEXT]->(current) + ) + """, + id=episode_id, + user_id=user_id, + occurred_at=iso(occurred_at), + ) + + # -------------------------------------------------------- W3: Entities + + async def _upsert_entities( + self, + driver, + *, + entities: list[ExtractedEntity], + episode_id: str, + agent_state: AgentState, + user_id: str, + organization_id: str, + ) -> int: + if not entities: + return 0 + + # Fetch existing entities by (user_id, name_lower) in one round-trip + name_lowers = [normalize_name(e.name) for e in entities] + existing = await self._fetch_existing_entities(driver, user_id, name_lowers) + + # Embed names for entities not yet in the graph + new_entities = [e for e in entities if normalize_name(e.name) not in existing] + new_embeddings = await embed_batch([e.name for e in new_entities], agent_state) + new_emb_map: dict[str, Optional[list[float]]] = { + normalize_name(e.name): emb for e, emb in zip(new_entities, new_embeddings) + } + + now = iso(datetime.now(timezone.utc)) + merged_count = 0 + llm_model = llm_model_from_agent(agent_state) + + new_rows: list[dict[str, Any]] = [] + for e in new_entities: + nl = normalize_name(e.name) + new_rows.append({ + "id": gen_id("epent"), + "name": e.name, + "name_lower": nl, + "entity_type": e.entity_type, + "description": e.description, + "name_embedding": new_emb_map.get(nl), + "user_id": user_id, + "organization_id": organization_id, + "created_at": now, + "updated_at": now, + }) + + # Update path: merge descriptions for entities that already exist + update_rows: list[dict[str, Any]] = [] + for e in entities: + nl = normalize_name(e.name) + existing_row = existing.get(nl) + if existing_row is None: + continue + old_desc = existing_row.get("description") or "" + new_desc = e.description or "" + if not new_desc.strip() or new_desc.strip() == old_desc.strip(): + continue + merged, llm_used = await merge_descriptions( + description_type="episodic entity", + name=existing_row["name"], + descriptions=[old_desc, new_desc] if old_desc else [new_desc], + llm_model=llm_model, + ) + if llm_used: + merged_count += 1 + update_rows.append({"id": existing_row["id"], "description": merged, "updated_at": now}) + + async with driver.session(database=settings.neo4j_database) as session: + if new_rows: + await session.run( + """ + UNWIND $rows AS row + CREATE (e:EpisodicEntity { + id: row.id, + name: row.name, + name_lower: row.name_lower, + entity_type: row.entity_type, + description: row.description, + rank: 0, + user_id: row.user_id, + organization_id: row.organization_id, + created_at: row.created_at, + updated_at: row.updated_at + }) + WITH e, row + CALL { + WITH e, row + WITH e, row WHERE row.name_embedding IS NOT NULL + CALL db.create.setNodeVectorProperty(e, 'name_embedding', row.name_embedding) + RETURN count(*) AS _ + } + RETURN count(e) AS created + """, + rows=new_rows, + ) + if update_rows: + await session.run( + """ + UNWIND $rows AS row + MATCH (e:EpisodicEntity {id: row.id}) + SET e.description = row.description, e.updated_at = row.updated_at + """, + rows=update_rows, + ) + + # MENTIONS edges from Episode → EpisodicEntity (covers both new + existing) + mention_rows = [ + {"episode_id": episode_id, "name_lower": normalize_name(e.name), "user_id": user_id} + for e in entities + ] + await session.run( + """ + UNWIND $rows AS row + MATCH (ep:Episode {id: row.episode_id}) + MATCH (e:EpisodicEntity {user_id: row.user_id, name_lower: row.name_lower}) + MERGE (ep)-[m:MENTIONS]->(e) + ON CREATE SET m.role = 'MENTIONED' + """, + rows=mention_rows, + ) + + return merged_count + + async def _fetch_existing_entities( + self, driver, user_id: str, name_lowers: list[str] + ) -> dict[str, dict[str, Any]]: + if not name_lowers: + return {} + async with driver.session(database=settings.neo4j_database) as session: + result = await session.run( + """ + UNWIND $names AS nl + MATCH (e:EpisodicEntity {user_id: $user_id, name_lower: nl}) + RETURN e.id AS id, e.name AS name, e.name_lower AS name_lower, + e.description AS description, e.entity_type AS entity_type + """, + names=name_lowers, + user_id=user_id, + ) + out: dict[str, dict[str, Any]] = {} + async for rec in result: + out[rec["name_lower"]] = dict(rec) + return out + + # -------------------------------------------------------- W4: EP_RELATES + + async def _upsert_relations( + self, + driver, + *, + relations: list[ExtractedRelation], + episode_id: str, + occurred_at: datetime, + agent_state: AgentState, + user_id: str, + llm_model: str, + ) -> int: + if not relations: + return 0 + + kw_embeddings = await embed_batch( + [r.keywords or r.description for r in relations], agent_state + ) + + pairs = [(normalize_name(r.src), normalize_name(r.tgt)) for r in relations] + existing_edges = await self._fetch_existing_edges(driver, user_id, pairs) + + now = iso(datetime.now(timezone.utc)) + valid_at = iso(occurred_at) + merged_count = 0 + + new_rows: list[dict[str, Any]] = [] + update_rows: list[dict[str, Any]] = [] + + for r, kw_emb in zip(relations, kw_embeddings): + a = normalize_name(r.src) + b = normalize_name(r.tgt) + key = tuple(sorted([a, b])) + existing = existing_edges.get(key) + if existing is None: + new_rows.append({ + "id": gen_id("eprel"), + "src_lower": a, + "tgt_lower": b, + "user_id": user_id, + "keywords": r.keywords, + "description": r.description, + "weight": float(r.weight), + "valid_at": valid_at, + "created_at": now, + "source_episode_ids": [episode_id], + "keywords_embedding": kw_emb, + }) + continue + + # Merge description, average weight, accumulate source_episode_ids + old_desc = existing.get("description") or "" + new_desc = r.description or "" + if old_desc.strip() and new_desc.strip() and old_desc.strip() != new_desc.strip(): + merged_desc, llm_used = await merge_descriptions( + description_type="episodic relation", + name=f"{r.src} <-> {r.tgt}", + descriptions=[old_desc, new_desc], + llm_model=llm_model, + ) + if llm_used: + merged_count += 1 + else: + merged_desc = new_desc or old_desc + + old_weight = float(existing.get("weight") or 0.5) + new_weight = (old_weight + float(r.weight)) / 2.0 + old_sources: list[str] = list(existing.get("source_episode_ids") or []) + if episode_id not in old_sources: + old_sources.append(episode_id) + update_rows.append({ + "id": existing["id"], + "description": merged_desc, + "weight": new_weight, + "source_episode_ids": old_sources, + "updated_at": now, + }) + + async with driver.session(database=settings.neo4j_database) as session: + if new_rows: + await session.run( + """ + UNWIND $rows AS row + MATCH (a:EpisodicEntity {user_id: row.user_id, name_lower: row.src_lower}) + MATCH (b:EpisodicEntity {user_id: row.user_id, name_lower: row.tgt_lower}) + CREATE (a)-[r:EP_RELATES { + id: row.id, + keywords: row.keywords, + description: row.description, + weight: row.weight, + valid_at: row.valid_at, + created_at: row.created_at, + source_episode_ids: row.source_episode_ids + }]->(b) + WITH r, row + CALL { + WITH r, row + WITH r, row WHERE row.keywords_embedding IS NOT NULL + CALL db.create.setRelationshipVectorProperty(r, 'keywords_embedding', row.keywords_embedding) + RETURN count(*) AS _ + } + RETURN count(r) AS created + """, + rows=new_rows, + ) + if update_rows: + await session.run( + """ + UNWIND $rows AS row + MATCH ()-[r:EP_RELATES {id: row.id}]->() + SET r.description = row.description, + r.weight = row.weight, + r.source_episode_ids = row.source_episode_ids, + r.updated_at = row.updated_at + """, + rows=update_rows, + ) + + return merged_count + + async def _fetch_existing_edges( + self, driver, user_id: str, pairs: list[tuple[str, str]] + ) -> dict[tuple[str, str], dict[str, Any]]: + if not pairs: + return {} + rows = [{"a": a, "b": b} for a, b in pairs] + async with driver.session(database=settings.neo4j_database) as session: + result = await session.run( + """ + UNWIND $rows AS row + MATCH (x:EpisodicEntity {user_id: $user_id, name_lower: row.a}) + MATCH (y:EpisodicEntity {user_id: $user_id, name_lower: row.b}) + MATCH (x)-[r:EP_RELATES]-(y) + WHERE r.expired_at IS NULL + RETURN row.a AS a, row.b AS b, + r.id AS id, r.description AS description, + r.weight AS weight, r.source_episode_ids AS source_episode_ids + """, + rows=rows, + user_id=user_id, + ) + out: dict[tuple[str, str], dict[str, Any]] = {} + async for rec in result: + key = tuple(sorted([rec["a"], rec["b"]])) + out.setdefault(key, { + "id": rec["id"], + "description": rec["description"], + "weight": rec["weight"], + "source_episode_ids": rec["source_episode_ids"], + }) + return out + + # -------------------------------------------------------- W7: ranks + + async def _refresh_ranks(self, driver, *, names: list[str], user_id: str) -> None: + if not names: + return + name_lowers = [normalize_name(n) for n in names] + async with driver.session(database=settings.neo4j_database) as session: + await session.run( + """ + UNWIND $names AS nl + MATCH (e:EpisodicEntity {user_id: $user_id, name_lower: nl}) + OPTIONAL MATCH (e)-[r:EP_RELATES]-() + WHERE r.expired_at IS NULL + WITH e, count(r) AS deg + SET e.rank = deg + """, + names=name_lowers, + user_id=user_id, + ) +``` + +### `mirix/services/episodic_graph_retriever.py` + +_Reads G_episodic: dual-level + MENTIONS reverse + NEXT one-hop_ + +```python +""" +Episodic graph retriever (v4) — reads G_episodic in Neo4j. + +Pipeline: + 1. ll embedding → ep_entity_name_emb vector → seed EpisodicEntities + 1-hop EP_RELATES + 2. hl embedding → ep_rel_kw_emb vector → seed EP_RELATES + both endpoints + 3. Round-robin merge entities, round-robin merge relations + 4. MENTIONS reverse: entities → Episodes that mention them + 5. NEXT one-hop expansion: each Episode → ±1 temporal neighbors + 6. Score + dedup items + 7. PG fetch full episode details (summary + details + occurred_at) +""" + +from __future__ import annotations + +from typing import Optional + +from mirix.log import get_logger +from mirix.services._graph_retriever_base import ( + DEFAULT_TOP_K, + GraphRetrieverBase, + GraphSearchResult, + ItemHit, + final_score, +) + +logger = get_logger(__name__) + + +class EpisodicRetriever(GraphRetrieverBase): + ENTITY_LABEL = "EpisodicEntity" + ITEM_LABEL = "Episode" + REL_TYPE = "EP_RELATES" + ENTITY_VECTOR_INDEX = "ep_entity_name_emb" + REL_VECTOR_INDEX = "ep_rel_kw_emb" + SECTION_TITLE = "Episodic" + + async def retrieve( + self, + *, + driver, + user_id: str, + ll_embedding: Optional[list[float]], + hl_embedding: Optional[list[float]], + top_k: int = DEFAULT_TOP_K, + item_top_k: int = 15, + ) -> GraphSearchResult: + """Full episodic pipeline: search → MENTIONS reverse → NEXT one-hop → PG fetch.""" + entities, relations = await self.search( + driver=driver, + user_id=user_id, + ll_embedding=ll_embedding, + hl_embedding=hl_embedding, + top_k=top_k, + ) + + # Reverse MENTIONS: get Episodes that mention the surviving entities + entity_ids = [e.id for e in entities] + episodes_via_mentions = await self._fetch_episodes_via_mentions( + driver, user_id=user_id, entity_ids=entity_ids, limit=item_top_k * 2, + ) + + # NEXT one-hop expansion + episode_ids = [it.id for it in episodes_via_mentions] + episodes_via_one_hop = await self._fetch_episodes_one_hop( + driver, user_id=user_id, episode_ids=episode_ids, limit=item_top_k, + ) + + # Merge + dedup + seen_ids: set[str] = set() + merged_items: list[ItemHit] = [] + for it in episodes_via_mentions + episodes_via_one_hop: + if it.id in seen_ids: + continue + seen_ids.add(it.id) + merged_items.append(it) + + # Score & sort by recency-aware score + for it in merged_items: + it.score = final_score(it.cosine, it.timestamp) + merged_items.sort(key=lambda x: x.score, reverse=True) + merged_items = merged_items[:item_top_k] + + # PG fetch full details for the kept items (summary already in graph; + # PG has details). Best-effort — degrade gracefully if PG miss. + await self._enrich_with_pg(merged_items, user_id=user_id) + + return GraphSearchResult(entities=entities, relations=relations, items=merged_items) + + async def _fetch_episodes_via_mentions( + self, driver, *, user_id: str, entity_ids: list[str], limit: int + ) -> list[ItemHit]: + if not entity_ids: + return [] + from mirix.settings import settings + + async with driver.session(database=settings.neo4j_database) as session: + result = await session.run( + """ + UNWIND $eids AS eid + MATCH (e:EpisodicEntity {id: eid})<-[:MENTIONS]-(ep:Episode {user_id: $user_id}) + WITH DISTINCT ep + ORDER BY ep.occurred_at DESC + LIMIT $limit + RETURN ep.id AS id, ep.summary AS summary, ep.occurred_at AS occurred_at + """, + eids=entity_ids, user_id=user_id, limit=limit, + ) + return [ + ItemHit( + id=rec["id"], label="Episode", + summary=rec["summary"] or "", detail="", + timestamp=rec["occurred_at"], cosine=0.5, source="mentions", + ) + async for rec in result + ] + + async def _fetch_episodes_one_hop( + self, driver, *, user_id: str, episode_ids: list[str], limit: int + ) -> list[ItemHit]: + """For each Episode, fetch its NEXT predecessor/successor (±1 hop).""" + if not episode_ids: + return [] + from mirix.settings import settings + + async with driver.session(database=settings.neo4j_database) as session: + result = await session.run( + """ + UNWIND $eids AS eid + MATCH (ep:Episode {id: eid}) + OPTIONAL MATCH (ep)-[:NEXT]->(next:Episode {user_id: $user_id}) + OPTIONAL MATCH (prev:Episode {user_id: $user_id})-[:NEXT]->(ep) + WITH collect(DISTINCT next) + collect(DISTINCT prev) AS neighbors + UNWIND neighbors AS n + WITH n WHERE n IS NOT NULL + RETURN DISTINCT n.id AS id, n.summary AS summary, n.occurred_at AS occurred_at + LIMIT $limit + """, + eids=episode_ids, user_id=user_id, limit=limit, + ) + return [ + ItemHit( + id=rec["id"], label="Episode", + summary=rec["summary"] or "", detail="", + timestamp=rec["occurred_at"], cosine=0.3, source="one_hop", + ) + async for rec in result + ] + + async def _enrich_with_pg(self, items: list[ItemHit], *, user_id: str) -> None: + """Pull full episodic_memory.details for kept items. Graceful on miss.""" + if not items: + return + from sqlalchemy import text as sa_text + from mirix.server.server import db_context + + ids = [it.id for it in items] + try: + async with db_context() as session: + result = await session.execute( + sa_text( + "SELECT id, details FROM episodic_memory " + "WHERE user_id = :u AND id = ANY(:ids)" + ), + {"u": user_id, "ids": ids}, + ) + detail_map = {row[0]: (row[1] or "") for row in result.fetchall()} + except Exception as e: + logger.debug("PG enrich for episodic failed: %s", e) + return + + for it in items: + if it.id in detail_map: + it.detail = detail_map[it.id] +``` + +### `mirix/services/graph_retriever_dispatcher.py` + +_Parallel dispatch to both retrievers + 50/50 budget split + combined markdown_ + +```python +""" +Top-level dispatcher that runs both graph retrievers in parallel. + +Entry point from rest_api.retrieve_memories_by_keywords. Owns: +- keyword extraction (1 LLM call, cached, shared between graphs) +- batch embed [ll_kw, hl_kw] (1 API call) +- parallel dispatch to EpisodicRetriever + SemanticRetriever +- token-budget split (50/50 between graphs) +- combined markdown formatting + +Returns an empty string when graph memory is disabled, when Neo4j is down, +or when no hits across either graph. Callers treat empty as "no graph context". +""" + +from __future__ import annotations + +import asyncio +from typing import Optional + +from mirix.log import get_logger +from mirix.schemas.agent import AgentState +from mirix.services._graph_common import embed_batch, llm_model_from_agent +from mirix.services._graph_retriever_base import ( + GraphSearchResult, + apply_budget_to_search, + fmt_date, +) +from mirix.services.episodic_graph_retriever import EpisodicRetriever +from mirix.services.lightrag_keyword_extractor import extract_keywords +from mirix.services.semantic_graph_retriever import SemanticRetriever +from mirix.settings import settings + +logger = get_logger(__name__) + + +# Total token budget across both graphs (split 50/50 per Q2 decision). +DEFAULT_MAX_TOTAL_TOKENS = 12000 + + +class GraphRetrieverDispatcher: + """Stateless. Create one per request.""" + + async def retrieve( + self, + *, + query: str, + user_id: str, + agent_state: AgentState, + max_total_tokens: int = DEFAULT_MAX_TOTAL_TOKENS, + top_k: int = 30, + item_top_k: int = 15, + ) -> str: + """Full v4 retrieval. Returns markdown context string.""" + if not settings.enable_graph_memory: + return "" + + from mirix.database.neo4j_client import get_neo4j_driver + + driver = get_neo4j_driver() + if driver is None: + return "" + + # ─── Step 1: keyword extraction (1 LLM call, cached) ─────────────── + llm_model = llm_model_from_agent(agent_state) + kw = await extract_keywords(query or "", user_id=user_id, llm_model=llm_model) + + # ─── Step 2: batch embed [ll, hl] ────────────────────────────────── + ll_str = ", ".join(kw.low_level) if kw.low_level else "" + hl_str = ", ".join(kw.high_level) if kw.high_level else "" + texts: list[str] = [] + purposes: list[str] = [] + if ll_str: + texts.append(ll_str); purposes.append("ll") + if hl_str: + texts.append(hl_str); purposes.append("hl") + + emb_by_purpose: dict[str, Optional[list[float]]] = {"ll": None, "hl": None} + if texts: + embeddings = await embed_batch(texts, agent_state) + for p, e in zip(purposes, embeddings): + emb_by_purpose[p] = e + + ll_emb = emb_by_purpose["ll"] + hl_emb = emb_by_purpose["hl"] + + if ll_emb is None and hl_emb is None: + logger.info("Graph retrieve: no embeddings → empty context") + return "" + + # ─── Step 3: dispatch both retrievers in parallel ────────────────── + ep_task = asyncio.create_task( + EpisodicRetriever().retrieve( + driver=driver, user_id=user_id, + ll_embedding=ll_emb, hl_embedding=hl_emb, + top_k=top_k, item_top_k=item_top_k, + ) + ) + sem_task = asyncio.create_task( + SemanticRetriever().retrieve( + driver=driver, user_id=user_id, + ll_embedding=ll_emb, hl_embedding=hl_emb, + top_k=top_k, item_top_k=item_top_k, + ) + ) + ep_result, sem_result = await asyncio.gather(ep_task, sem_task, return_exceptions=True) + + if isinstance(ep_result, Exception): + logger.warning("Episodic retrieve failed: %s", ep_result) + ep_result = GraphSearchResult() + if isinstance(sem_result, Exception): + logger.warning("Semantic retrieve failed: %s", sem_result) + sem_result = GraphSearchResult() + + # ─── Step 4: token budget split 50/50, then format ───────────────── + per_graph_budget = max_total_tokens // 2 + # Within each graph, split: 30% entity, 35% relations, 35% items + e_budget = int(per_graph_budget * 0.30) + r_budget = int(per_graph_budget * 0.35) + i_budget = per_graph_budget - e_budget - r_budget + + ep_trim = apply_budget_to_search( + ep_result, max_entity_tokens=e_budget, + max_relation_tokens=r_budget, max_item_tokens=i_budget, + ) + sem_trim = apply_budget_to_search( + sem_result, max_entity_tokens=e_budget, + max_relation_tokens=r_budget, max_item_tokens=i_budget, + ) + + ep_md = _format_section(ep_trim, "Episodic") + sem_md = _format_section(sem_trim, "Semantic") + + parts = [] + if ep_md: + parts.append(ep_md) + if sem_md: + parts.append(sem_md) + ctx = "\n\n".join(parts) + logger.info( + "Graph retrieve: ep[%dE/%dR/%dI] sem[%dE/%dR/%dI] total %d chars", + len(ep_trim.entities), len(ep_trim.relations), len(ep_trim.items), + len(sem_trim.entities), len(sem_trim.relations), len(sem_trim.items), + len(ctx), + ) + return ctx + + +def _format_section(s: GraphSearchResult, title: str) -> str: + if not (s.entities or s.relations or s.items): + return "" + lines = [f"## {title} Knowledge Graph"] + if s.entities: + lines.append("### Entities") + for e in s.entities: + lines.append(f"- {e.name} ({e.entity_type}, rank={e.rank}): {e.description}") + if s.relations: + lines.append("\n### Relationships") + for r in s.relations: + validity = f" (on/since {fmt_date(r.valid_at)})" if r.valid_at else "" + lines.append( + f"- {r.src_name} <-> {r.tgt_name} [{r.keywords}]: {r.description}{validity}" + ) + if s.items: + item_label = "Episodes" if title == "Episodic" else "Concepts" + lines.append(f"\n### Related {item_label}") + for it in s.items: + ts = fmt_date(it.timestamp) if it.timestamp else "" + ts_part = f"[{ts}] " if ts else "" + head = f"- {ts_part}{it.summary}".rstrip() + lines.append(head) + if it.detail and it.detail != it.summary: + lines.append(f" {it.detail[:400]}") + return "\n".join(lines) +``` + +### `mirix/services/lightrag_extractor.py` + +_LLM-driven entity/relation extraction with delimiter parsing_ + +```python +""" +LightRAG-style entity & relation extractor (W2 of the write path). + +Adapted from LightRAG operate.py:extract_entities. One LLM call per event; +output is delimiter-separated tuples that are parsed into structured dicts. +Optional gleaning pass (default off) re-prompts the LLM to catch misses. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from typing import Any, Optional + +import httpx + +from mirix.log import get_logger +from mirix.prompts.lightrag_prompts import ( + COMPLETION_DELIMITER, + DEFAULT_ENTITY_TYPES, + TUPLE_DELIMITER, + render_extraction_system_prompt, + render_extraction_user_prompt, +) + +logger = get_logger(__name__) + + +@dataclass +class ExtractedEntity: + name: str + entity_type: str + description: str + + +@dataclass +class ExtractedRelation: + src: str + tgt: str + keywords: str + description: str + weight: float + + +@dataclass +class ExtractionResult: + entities: list[ExtractedEntity] = field(default_factory=list) + relations: list[ExtractedRelation] = field(default_factory=list) + + +def _strip_quotes(s: str) -> str: + s = s.strip() + if len(s) >= 2 and s[0] in {'"', "'"} and s[-1] == s[0]: + return s[1:-1].strip() + return s + + +def _coerce_weight(raw: str) -> float: + """Parse the trailing relationship_strength field. Defaults to 0.5 on bad input.""" + try: + v = float(_strip_quotes(raw)) + if 0.0 <= v <= 1.0: + return v + # Some models emit 0..10 or 0..100. Normalize. + if 1.0 < v <= 10.0: + return v / 10.0 + if 10.0 < v <= 100.0: + return v / 100.0 + except (ValueError, TypeError): + pass + return 0.5 + + +def parse_extraction_output(raw: str) -> ExtractionResult: + """ + Parse LightRAG-style delimiter output into structured entities & relations. + + Each line should look like: + entity<|#|>NAME<|#|>TYPE<|#|>DESCRIPTION + relation<|#|>SRC<|#|>TGT<|#|>KEYWORDS<|#|>DESCRIPTION<|#|>STRENGTH + Lines that do not parse cleanly are logged and skipped. + """ + result = ExtractionResult() + if not raw: + return result + + # Stop at the completion delimiter if the model emitted it + cut = raw.find(COMPLETION_DELIMITER) + if cut >= 0: + raw = raw[:cut] + + seen_entity_names: set[str] = set() + seen_relation_keys: set[tuple[str, str]] = set() + + for raw_line in raw.splitlines(): + line = raw_line.strip() + if not line or TUPLE_DELIMITER not in line: + continue + parts = [p.strip() for p in line.split(TUPLE_DELIMITER)] + kind = parts[0].lower().strip("()`* ") + if kind == "entity" and len(parts) >= 4: + name = _strip_quotes(parts[1]) + entity_type = _strip_quotes(parts[2]) or "Other" + description = _strip_quotes(parts[3]) + if not name or name in seen_entity_names: + continue + seen_entity_names.add(name) + result.entities.append( + ExtractedEntity(name=name, entity_type=entity_type, description=description) + ) + elif kind == "relation" and len(parts) >= 5: + src = _strip_quotes(parts[1]) + tgt = _strip_quotes(parts[2]) + keywords = _strip_quotes(parts[3]) + description = _strip_quotes(parts[4]) + weight = _coerce_weight(parts[5]) if len(parts) >= 6 else 0.5 + if not src or not tgt or src == tgt: + continue + # Treat undirected; dedup on sorted endpoints + key = tuple(sorted([src.lower(), tgt.lower()])) + if key in seen_relation_keys: + continue + seen_relation_keys.add(key) + result.relations.append( + ExtractedRelation( + src=src, + tgt=tgt, + keywords=keywords, + description=description, + weight=weight, + ) + ) + else: + # Unknown leading token — skip silently to avoid log spam on + # benign formatting variations. + continue + + return result + + +async def call_openai_chat( + system_prompt: str, + user_prompt: str, + model: str, + *, + temperature: float = 0.0, + max_tokens: int = 4000, + timeout: float = 60.0, + api_key: Optional[str] = None, + api_base: Optional[str] = None, +) -> str: + """Bare-metal OpenAI chat completion. Mirrors v2 graph_memory_manager.""" + api_key = api_key or os.environ.get("OPENAI_API_KEY", "") + api_base = api_base or os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1") + endpoint = f"{api_base.rstrip('/')}/chat/completions" + + headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} + payload: dict[str, Any] = { + "model": model, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + "temperature": temperature, + "max_tokens": max_tokens, + } + async with httpx.AsyncClient(timeout=timeout) as client: + resp = await client.post(endpoint, headers=headers, json=payload) + resp.raise_for_status() + data = resp.json() + + # Record token usage if a phase is active (no-op outside instrumented evals) + try: + from mirix.database.token_tracker import record as _record_tokens + usage = (data.get("usage") or {}) + _record_tokens( + prompt_tokens=usage.get("prompt_tokens", 0), + completion_tokens=usage.get("completion_tokens", 0), + total_tokens=usage.get("total_tokens"), + ) + except Exception: + pass + + return data["choices"][0]["message"]["content"] + + +async def extract_entities_and_relations( + text: str, + *, + llm_model: str = "gpt-4.1-mini", + entity_types: Optional[list[str]] = None, + language: str = "English", + max_input_chars: int = 12000, +) -> ExtractionResult: + """ + Run a single LLM extraction pass over ``text`` and parse the result. + + Returns an empty ``ExtractionResult`` on error so the caller can carry on. + """ + if not text or not text.strip(): + return ExtractionResult() + + types = entity_types or DEFAULT_ENTITY_TYPES + system_prompt = render_extraction_system_prompt(entity_types=types, language=language) + user_prompt = render_extraction_user_prompt( + input_text=text[:max_input_chars], + entity_types=types, + language=language, + ) + + try: + raw = await call_openai_chat(system_prompt, user_prompt, model=llm_model) + except Exception as e: + logger.warning("LightRAG extraction LLM call failed: %s", e) + return ExtractionResult() + + parsed = parse_extraction_output(raw) + logger.info( + "LightRAG extraction: %d entities, %d relations from %d chars", + len(parsed.entities), + len(parsed.relations), + len(text), + ) + return parsed +``` + +### `mirix/services/lightrag_keyword_extractor.py` + +_Query keyword extraction (ll/hl), Redis-cached_ + +```python +""" +LightRAG-style query keyword extractor (high-level / low-level split). + +One LLM call per unique query, cached in Redis (or skipped if Redis is not +available — the system still works, just pays the extraction cost each time). +""" + +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass +from typing import Optional + +from mirix.log import get_logger +from mirix.prompts.lightrag_prompts import render_keywords_extraction_prompt +from mirix.services.lightrag_extractor import call_openai_chat + +logger = get_logger(__name__) + + +# Match LightRAG defaults (24h is long enough for typical chat sessions). +KEYWORD_CACHE_TTL_SECONDS = 24 * 3600 + + +@dataclass +class Keywords: + high_level: list[str] + low_level: list[str] + + +def _cache_key(user_id: str, query: str, language: str) -> str: + h = hashlib.sha1(f"{language}|{query}".encode("utf-8")).hexdigest()[:24] + return f"mirix:lightrag:kw:{user_id}:{h}" + + +def _parse_json_loose(raw: str) -> Optional[dict]: + """Try strict JSON first; if that fails, strip code fences and retry.""" + if not raw: + return None + try: + return json.loads(raw) + except json.JSONDecodeError: + pass + # Strip ``` fences + if "```" in raw: + try: + body = raw.split("```json")[-1] if "```json" in raw else raw.split("```")[1] + body = body.split("```")[0] + return json.loads(body.strip()) + except (json.JSONDecodeError, IndexError): + pass + return None + + +async def _cache_get(key: str) -> Optional[Keywords]: + try: + from mirix.database.cache_provider import get_cache_provider + + provider = get_cache_provider() + if provider is None: + return None + data = await provider.get_json(key) + if not data: + return None + return Keywords( + high_level=list(data.get("high_level", []) or []), + low_level=list(data.get("low_level", []) or []), + ) + except Exception as e: + logger.debug("Keyword cache get failed: %s", e) + return None + + +async def _cache_set(key: str, kw: Keywords) -> None: + try: + from mirix.database.cache_provider import get_cache_provider + + provider = get_cache_provider() + if provider is None: + return + await provider.set_json( + key, + {"high_level": kw.high_level, "low_level": kw.low_level}, + ttl=KEYWORD_CACHE_TTL_SECONDS, + ) + except Exception as e: + logger.debug("Keyword cache set failed: %s", e) + + +def _fallback_keywords(query: str) -> Keywords: + """When the LLM returns nothing useful, treat the query itself as ll keyword. + + Mirrors LightRAG operate.py:get_keywords_from_query short-query fallback. + """ + q = (query or "").strip() + if not q: + return Keywords(high_level=[], low_level=[]) + if len(q) < 50: + return Keywords(high_level=[], low_level=[q]) + # Long but empty parse: keep first few content words as best-effort. + words = [w for w in q.split() if len(w) > 3][:6] + return Keywords(high_level=[], low_level=words or [q[:80]]) + + +async def extract_keywords( + query: str, + *, + user_id: str, + llm_model: str = "gpt-4.1-mini", + language: str = "English", + use_cache: bool = True, +) -> Keywords: + """ + Return (high_level, low_level) keyword lists for ``query``. + + On any failure or empty model output, falls back to using the query itself + as a single low-level keyword (short queries) or splitting into content + words (long queries). Never raises. + """ + if not query or not query.strip(): + return Keywords(high_level=[], low_level=[]) + + cache_key = _cache_key(user_id, query, language) + if use_cache: + cached = await _cache_get(cache_key) + if cached is not None: + return cached + + prompt = render_keywords_extraction_prompt(query=query, language=language) + + try: + raw = await call_openai_chat( + system_prompt="You are a precise keyword extractor. Output JSON only.", + user_prompt=prompt, + model=llm_model, + temperature=0.0, + max_tokens=400, + ) + except Exception as e: + logger.warning("Keyword extraction LLM call failed: %s", e) + return _fallback_keywords(query) + + parsed = _parse_json_loose(raw) + if not parsed: + logger.warning("Keyword extraction returned unparsable output: %s", (raw or "")[:120]) + return _fallback_keywords(query) + + kw = Keywords( + high_level=[k.strip() for k in parsed.get("high_level_keywords", []) if k and k.strip()], + low_level=[k.strip() for k in parsed.get("low_level_keywords", []) if k and k.strip()], + ) + if not kw.high_level and not kw.low_level: + kw = _fallback_keywords(query) + + if use_cache: + await _cache_set(cache_key, kw) + return kw +``` + +### `mirix/services/lightrag_merger.py` + +_Map-reduce description merge (cheap path → LLM summary → recursive)_ + +```python +""" +Description merging for entities and relations (W3/W4 helper). + +Adapted from LightRAG operate.py:_handle_entity_relation_summary. The strategy: + +1. If the descriptions, joined, fit within ``summary_context_size`` tokens AND + there are fewer than ``force_llm_summary_on_merge`` of them → just join with + a separator. No LLM call. +2. If the joined text fits within ``summary_max_tokens`` → ask the LLM for a + single summary. 1 LLM call. +3. Otherwise → split into chunks, summarize each, recurse on the summaries. + +Token counts are estimated with tiktoken (cl100k_base) for cheap accuracy. +""" + +from __future__ import annotations + +from typing import Optional + +import tiktoken + +from mirix.log import get_logger +from mirix.prompts.lightrag_prompts import render_summarize_descriptions_prompt +from mirix.services.lightrag_extractor import call_openai_chat + +logger = get_logger(__name__) + + +# Defaults align with LightRAG's recommended values. Tuned smaller to keep +# write-path cost low (MIRIX writes much more often than LightRAG ingests docs). +DEFAULT_SUMMARY_CONTEXT_SIZE = 1000 # tokens — when joined desc still fits, no summary +DEFAULT_SUMMARY_MAX_TOKENS = 500 # tokens — target output length +DEFAULT_FORCE_LLM_MERGE_AT = 6 # description count threshold +DEFAULT_SEPARATOR = " | " + +_tokenizer = None + + +def _get_tokenizer(): + global _tokenizer + if _tokenizer is None: + _tokenizer = tiktoken.get_encoding("cl100k_base") + return _tokenizer + + +def _count_tokens(text: str) -> int: + return len(_get_tokenizer().encode(text)) + + +async def merge_descriptions( + description_type: str, + name: str, + descriptions: list[str], + *, + llm_model: str = "gpt-4.1-mini", + summary_context_size: int = DEFAULT_SUMMARY_CONTEXT_SIZE, + summary_max_tokens: int = DEFAULT_SUMMARY_MAX_TOKENS, + force_llm_merge_at: int = DEFAULT_FORCE_LLM_MERGE_AT, + separator: str = DEFAULT_SEPARATOR, + max_recursion: int = 4, +) -> tuple[str, bool]: + """ + Merge a list of descriptions for a single entity or relation. + + Returns ``(merged_text, llm_used)``. ``llm_used`` lets the caller decide + whether to bump cache invalidation timestamps. + """ + descs = [d.strip() for d in descriptions if d and d.strip()] + if not descs: + return "", False + if len(descs) == 1: + return descs[0], False + + # Phase 1: cheap path — no LLM if small enough and few enough. + joined = separator.join(descs) + total_tokens = _count_tokens(joined) + if total_tokens <= summary_context_size and len(descs) < force_llm_merge_at: + return joined, False + + # Phase 2: single LLM summary if it all fits as a prompt. + if total_tokens <= summary_max_tokens * 4: # rough budget for prompt+output + summary = await _summarize_via_llm( + description_type=description_type, + name=name, + descriptions=descs, + llm_model=llm_model, + summary_max_tokens=summary_max_tokens, + ) + return summary or joined[: summary_max_tokens * 4], True + + # Phase 3: map-reduce. Chunk descs into groups whose joined size fits, then + # summarize each chunk, then recurse on the chunk summaries. + if max_recursion <= 0: + # Hard stop: just truncate the joined text. Avoids unbounded recursion + # on pathological input. + return joined[: summary_max_tokens * 4], False + + chunks: list[list[str]] = [] + current: list[str] = [] + current_tokens = 0 + for d in descs: + d_tokens = _count_tokens(d) + if current and current_tokens + d_tokens > summary_context_size: + chunks.append(current) + current, current_tokens = [d], d_tokens + else: + current.append(d) + current_tokens += d_tokens + if current: + chunks.append(current) + + chunk_summaries: list[str] = [] + llm_used = False + for ch in chunks: + if len(ch) == 1: + chunk_summaries.append(ch[0]) + continue + s = await _summarize_via_llm( + description_type=description_type, + name=name, + descriptions=ch, + llm_model=llm_model, + summary_max_tokens=summary_max_tokens, + ) + if s: + chunk_summaries.append(s) + llm_used = True + else: + # Fallback: keep raw join of this chunk + chunk_summaries.append(separator.join(ch)) + + # Recurse on the chunk summaries (now fewer items, each smaller). + final, recurse_used = await merge_descriptions( + description_type=description_type, + name=name, + descriptions=chunk_summaries, + llm_model=llm_model, + summary_context_size=summary_context_size, + summary_max_tokens=summary_max_tokens, + force_llm_merge_at=force_llm_merge_at, + separator=separator, + max_recursion=max_recursion - 1, + ) + return final, llm_used or recurse_used + + +async def _summarize_via_llm( + description_type: str, + name: str, + descriptions: list[str], + llm_model: str, + summary_max_tokens: int, +) -> Optional[str]: + """One LLM call to merge ``descriptions`` into a single paragraph.""" + prompt = render_summarize_descriptions_prompt( + description_type=description_type, + description_name=name, + description_list=descriptions, + summary_length=summary_max_tokens, + ) + try: + # Use a tiny system prompt; the user prompt carries the full template. + return ( + await call_openai_chat( + system_prompt="You are a precise summarizer.", + user_prompt=prompt, + model=llm_model, + max_tokens=summary_max_tokens + 200, + ) + ).strip() + except Exception as e: + logger.warning("Description merge LLM call failed for %s '%s': %s", description_type, name, e) + return None +``` + +### `mirix/services/semantic_graph_manager.py` + +_Writes G_semantic: Concept, SemanticEntity, CONCEPT_RELATES, SEM_RELATES_ + +```python +""" +Semantic graph manager (v4) — writes G_semantic in Neo4j. + +Hooked from SemanticMemoryManager.insert_semantic_item after the PG row has +been committed. Failures are non-fatal. + +Graph elements written here: + (:Concept {id, user_id, organization_id, name, summary, created_at}) + (:SemanticEntity {id, user_id, organization_id, name, name_lower, + entity_type, description, rank, name_embedding, + created_at, updated_at}) + (:Concept)-[:CONCEPT_RELATES {keywords, description, weight, + keywords_embedding}]->(:Concept) + (:Concept)-[:MENTIONS]->(:SemanticEntity) + (:SemanticEntity)-[:SEM_RELATES {id, keywords, description, weight, + source_concept_ids, keywords_embedding}] + ->(:SemanticEntity) + +Concept-Concept edges are LLM-judged: when a new Concept is inserted, the +top-K most similar existing Concepts (by name embedding) are candidates; +one LLM call decides which actually have a meaningful relationship. + +Cost per insert: ~1 LLM call (entity extraction) + ~0.3-1 LLM call (description +merging + concept relation judgement). Heavier than episodic by design — the +semantic graph is small and dense, so investing in good edges pays off. +""" + +from __future__ import annotations + +import json +from datetime import datetime, timezone +from typing import Any, Optional + +from mirix.log import get_logger +from mirix.schemas.agent import AgentState +from mirix.services._graph_common import ( + embed_batch, + gen_id, + iso, + llm_model_from_agent, + normalize_name, +) +from mirix.services.lightrag_extractor import ( + ExtractedEntity, + ExtractedRelation, + call_openai_chat, + extract_entities_and_relations, +) +from mirix.services.lightrag_merger import merge_descriptions +from mirix.settings import settings + +logger = get_logger(__name__) + + +# Concept-concept relation candidate pool size. Top-K nearest concepts (by +# name embedding) get sent to the LLM for relation judgement in one batch. +DEFAULT_CONCEPT_REL_TOP_K = 5 + +# Concept-concept relation judgement prompt. Asks the LLM to return JSON for +# which candidates actually relate to the new concept and how. +_CONCEPT_REL_PROMPT = """You are a knowledge graph editor. A new concept has been added to the user's semantic memory. Decide which of the candidate concepts have a meaningful relationship with the new one. + +New concept: + name: {new_name} + summary: {new_summary} + +Candidate concepts (existing in the graph): +{candidates_block} + +For each candidate that genuinely relates to the new concept (e.g. IS_A, PART_OF, RELATES_TO, CONTRADICTS, ENABLES, CAUSED_BY), output one JSON object per line. Skip candidates that are unrelated or duplicates. Output strict JSON, one object per line, no markdown fences. If nothing relates, output nothing. + +Each object must have: + "candidate_name": str // exact name from the list above + "keywords": str // short phrase summarizing the relation type (e.g. "subclass", "part of", "contradicts") + "description": str // one sentence explaining the relationship + "weight": float // 0.0-1.0 strength +""" + + +class SemanticGraphManager: + """Stateless coordinator. Construct one per call.""" + + async def process_concept( + self, + *, + concept_id: str, + name: str, + summary: str, + details: str, + agent_state: AgentState, + organization_id: str, + user_id: str, + ) -> dict[str, Any]: + """Run the full semantic write path. Never raises.""" + if not settings.enable_graph_memory: + return {"skipped": "disabled"} + + from mirix.database.neo4j_client import get_neo4j_driver + + driver = get_neo4j_driver() + if driver is None: + return {"skipped": "no_driver"} + + text = f"{name}: {summary}\n{details or ''}" + llm_model = llm_model_from_agent(agent_state) + + # W2: extract entities + entity-entity relations from the concept text + extraction = await extract_entities_and_relations(text=text, llm_model=llm_model) + + # W1: always create the Concept node + concept_name_emb = (await embed_batch([name], agent_state))[0] + await self._upsert_concept( + driver, + concept_id=concept_id, + name=name, + summary=summary, + user_id=user_id, + organization_id=organization_id, + name_embedding=concept_name_emb, + ) + + # W6: concept-concept relation discovery + concept_rels_added = await self._discover_concept_relations( + driver, + new_concept_id=concept_id, + new_concept_name=name, + new_concept_summary=summary, + new_concept_name_emb=concept_name_emb, + user_id=user_id, + agent_state=agent_state, + llm_model=llm_model, + ) + + if not extraction.entities and not extraction.relations: + return {"entities": 0, "relations": 0, "concept_rels": concept_rels_added} + + # W3: upsert SemanticEntity nodes + merged_entities = await self._upsert_entities( + driver, + entities=extraction.entities, + concept_id=concept_id, + agent_state=agent_state, + user_id=user_id, + organization_id=organization_id, + ) + + # W4: upsert SEM_RELATES edges + merged_relations = await self._upsert_relations( + driver, + relations=extraction.relations, + concept_id=concept_id, + agent_state=agent_state, + user_id=user_id, + llm_model=llm_model, + ) + + # W7: refresh rank + await self._refresh_ranks( + driver, + names=sorted({e.name for e in extraction.entities}), + user_id=user_id, + ) + + return { + "entities": len(extraction.entities), + "relations": len(extraction.relations), + "concept_rels": concept_rels_added, + "merged_entities": merged_entities, + "merged_relations": merged_relations, + } + + # --------------------------------------------------------- W1: Concept + + async def _upsert_concept( + self, + driver, + *, + concept_id: str, + name: str, + summary: str, + user_id: str, + organization_id: str, + name_embedding: Optional[list[float]], + ) -> None: + now = iso(datetime.now(timezone.utc)) + async with driver.session(database=settings.neo4j_database) as session: + await session.run( + """ + MERGE (c:Concept {id: $id}) + ON CREATE SET c.user_id = $user_id, + c.organization_id = $org_id, + c.name = $name, + c.summary = $summary, + c.created_at = $now + ON MATCH SET c.name = $name, c.summary = $summary + """, + id=concept_id, + user_id=user_id, + org_id=organization_id, + name=name, + summary=summary or "", + now=now, + ) + if name_embedding: + # Attach name embedding to the concept itself so we can find + # similar concepts via vector search later. + await session.run( + """ + MATCH (c:Concept {id: $id}) + CALL db.create.setNodeVectorProperty(c, 'name_embedding', $emb) + RETURN count(*) AS _ + """, + id=concept_id, + emb=name_embedding, + ) + + # ----------------------------------------- W6: concept-concept relations + + async def _discover_concept_relations( + self, + driver, + *, + new_concept_id: str, + new_concept_name: str, + new_concept_summary: str, + new_concept_name_emb: Optional[list[float]], + user_id: str, + agent_state: AgentState, + llm_model: str, + top_k: int = DEFAULT_CONCEPT_REL_TOP_K, + ) -> int: + """Find candidate concepts by name vector, ask LLM which actually relate.""" + if new_concept_name_emb is None: + return 0 + + # Find top-K similar concepts via raw cypher (Concept nodes don't yet + # have a dedicated vector index by design — we use cosine over the + # property we set above; small graphs make this affordable). For + # larger deployments switch to a dedicated index on Concept. + async with driver.session(database=settings.neo4j_database) as session: + result = await session.run( + """ + MATCH (c:Concept {user_id: $user_id}) + WHERE c.id <> $id AND c.name_embedding IS NOT NULL + WITH c, vector.similarity.cosine(c.name_embedding, $emb) AS sim + ORDER BY sim DESC LIMIT $k + RETURN c.id AS id, c.name AS name, c.summary AS summary, sim + """, + user_id=user_id, + id=new_concept_id, + emb=new_concept_name_emb, + k=top_k, + ) + candidates = [dict(rec) async for rec in result] + + if not candidates: + return 0 + + candidates_block = "\n".join( + f" - {c['name']}: {(c.get('summary') or '')[:200]}" for c in candidates + ) + prompt = _CONCEPT_REL_PROMPT.format( + new_name=new_concept_name, + new_summary=(new_concept_summary or "")[:300], + candidates_block=candidates_block, + ) + + try: + raw = await call_openai_chat( + system_prompt="You are a precise knowledge graph editor. Output JSON only.", + user_prompt=prompt, + model=llm_model, + temperature=0.0, + max_tokens=600, + ) + except Exception as e: + logger.warning("Concept relation LLM call failed: %s", e) + return 0 + + # Parse line-delimited JSON, tolerant to LLM noise + relations: list[dict[str, Any]] = [] + for line in (raw or "").splitlines(): + line = line.strip().lstrip("-").strip() + if not line or not line.startswith("{"): + continue + try: + obj = json.loads(line) + except json.JSONDecodeError: + continue + cand_name = (obj.get("candidate_name") or "").strip() + if not cand_name: + continue + # Find candidate id by name (case-insensitive) + cand = next((c for c in candidates if c["name"].lower() == cand_name.lower()), None) + if cand is None: + continue + relations.append({ + "src_id": new_concept_id, + "tgt_id": cand["id"], + "keywords": (obj.get("keywords") or "")[:120], + "description": (obj.get("description") or "")[:500], + "weight": float(obj.get("weight") or 0.5), + }) + + if not relations: + return 0 + + # Embed keywords for the new edges + kw_embs = await embed_batch([r["keywords"] or r["description"] for r in relations], agent_state) + for r, emb in zip(relations, kw_embs): + r["keywords_embedding"] = emb + + async with driver.session(database=settings.neo4j_database) as session: + await session.run( + """ + UNWIND $rows AS row + MATCH (a:Concept {id: row.src_id}) + MATCH (b:Concept {id: row.tgt_id}) + MERGE (a)-[r:CONCEPT_RELATES]->(b) + ON CREATE SET r.keywords = row.keywords, + r.description = row.description, + r.weight = row.weight + ON MATCH SET r.keywords = row.keywords, + r.description = row.description, + r.weight = (coalesce(r.weight, 0.5) + row.weight) / 2.0 + WITH r, row + CALL { + WITH r, row + WITH r, row WHERE row.keywords_embedding IS NOT NULL + CALL db.create.setRelationshipVectorProperty(r, 'keywords_embedding', row.keywords_embedding) + RETURN count(*) AS _ + } + RETURN count(r) AS created + """, + rows=relations, + ) + + return len(relations) + + # --------------------------------------------------- W3: SemanticEntity + + async def _upsert_entities( + self, + driver, + *, + entities: list[ExtractedEntity], + concept_id: str, + agent_state: AgentState, + user_id: str, + organization_id: str, + ) -> int: + if not entities: + return 0 + + name_lowers = [normalize_name(e.name) for e in entities] + existing = await self._fetch_existing_entities(driver, user_id, name_lowers) + + new_entities = [e for e in entities if normalize_name(e.name) not in existing] + new_embeddings = await embed_batch([e.name for e in new_entities], agent_state) + new_emb_map: dict[str, Optional[list[float]]] = { + normalize_name(e.name): emb for e, emb in zip(new_entities, new_embeddings) + } + + now = iso(datetime.now(timezone.utc)) + merged_count = 0 + llm_model = llm_model_from_agent(agent_state) + + new_rows: list[dict[str, Any]] = [] + for e in new_entities: + nl = normalize_name(e.name) + new_rows.append({ + "id": gen_id("sement"), + "name": e.name, + "name_lower": nl, + "entity_type": e.entity_type, + "description": e.description, + "name_embedding": new_emb_map.get(nl), + "user_id": user_id, + "organization_id": organization_id, + "created_at": now, + "updated_at": now, + }) + + update_rows: list[dict[str, Any]] = [] + for e in entities: + nl = normalize_name(e.name) + existing_row = existing.get(nl) + if existing_row is None: + continue + old_desc = existing_row.get("description") or "" + new_desc = e.description or "" + if not new_desc.strip() or new_desc.strip() == old_desc.strip(): + continue + merged, llm_used = await merge_descriptions( + description_type="semantic entity", + name=existing_row["name"], + descriptions=[old_desc, new_desc] if old_desc else [new_desc], + llm_model=llm_model, + ) + if llm_used: + merged_count += 1 + update_rows.append({"id": existing_row["id"], "description": merged, "updated_at": now}) + + async with driver.session(database=settings.neo4j_database) as session: + if new_rows: + await session.run( + """ + UNWIND $rows AS row + CREATE (e:SemanticEntity { + id: row.id, + name: row.name, + name_lower: row.name_lower, + entity_type: row.entity_type, + description: row.description, + rank: 0, + user_id: row.user_id, + organization_id: row.organization_id, + created_at: row.created_at, + updated_at: row.updated_at + }) + WITH e, row + CALL { + WITH e, row + WITH e, row WHERE row.name_embedding IS NOT NULL + CALL db.create.setNodeVectorProperty(e, 'name_embedding', row.name_embedding) + RETURN count(*) AS _ + } + RETURN count(e) AS created + """, + rows=new_rows, + ) + if update_rows: + await session.run( + """ + UNWIND $rows AS row + MATCH (e:SemanticEntity {id: row.id}) + SET e.description = row.description, e.updated_at = row.updated_at + """, + rows=update_rows, + ) + + # MENTIONS edges from Concept → SemanticEntity + mention_rows = [ + {"concept_id": concept_id, "name_lower": normalize_name(e.name), "user_id": user_id} + for e in entities + ] + await session.run( + """ + UNWIND $rows AS row + MATCH (c:Concept {id: row.concept_id}) + MATCH (e:SemanticEntity {user_id: row.user_id, name_lower: row.name_lower}) + MERGE (c)-[m:MENTIONS]->(e) + """, + rows=mention_rows, + ) + + return merged_count + + async def _fetch_existing_entities( + self, driver, user_id: str, name_lowers: list[str] + ) -> dict[str, dict[str, Any]]: + if not name_lowers: + return {} + async with driver.session(database=settings.neo4j_database) as session: + result = await session.run( + """ + UNWIND $names AS nl + MATCH (e:SemanticEntity {user_id: $user_id, name_lower: nl}) + RETURN e.id AS id, e.name AS name, e.name_lower AS name_lower, + e.description AS description, e.entity_type AS entity_type + """, + names=name_lowers, + user_id=user_id, + ) + out: dict[str, dict[str, Any]] = {} + async for rec in result: + out[rec["name_lower"]] = dict(rec) + return out + + # -------------------------------------------------------- W4: SEM_RELATES + + async def _upsert_relations( + self, + driver, + *, + relations: list[ExtractedRelation], + concept_id: str, + agent_state: AgentState, + user_id: str, + llm_model: str, + ) -> int: + if not relations: + return 0 + + kw_embeddings = await embed_batch( + [r.keywords or r.description for r in relations], agent_state + ) + pairs = [(normalize_name(r.src), normalize_name(r.tgt)) for r in relations] + existing_edges = await self._fetch_existing_edges(driver, user_id, pairs) + + now = iso(datetime.now(timezone.utc)) + merged_count = 0 + + new_rows: list[dict[str, Any]] = [] + update_rows: list[dict[str, Any]] = [] + + for r, kw_emb in zip(relations, kw_embeddings): + a, b = normalize_name(r.src), normalize_name(r.tgt) + key = tuple(sorted([a, b])) + existing = existing_edges.get(key) + if existing is None: + new_rows.append({ + "id": gen_id("semrel"), + "src_lower": a, + "tgt_lower": b, + "user_id": user_id, + "keywords": r.keywords, + "description": r.description, + "weight": float(r.weight), + "created_at": now, + "source_concept_ids": [concept_id], + "keywords_embedding": kw_emb, + }) + continue + + old_desc = existing.get("description") or "" + new_desc = r.description or "" + if old_desc.strip() and new_desc.strip() and old_desc.strip() != new_desc.strip(): + merged_desc, llm_used = await merge_descriptions( + description_type="semantic relation", + name=f"{r.src} <-> {r.tgt}", + descriptions=[old_desc, new_desc], + llm_model=llm_model, + ) + if llm_used: + merged_count += 1 + else: + merged_desc = new_desc or old_desc + + old_weight = float(existing.get("weight") or 0.5) + new_weight = (old_weight + float(r.weight)) / 2.0 + old_sources: list[str] = list(existing.get("source_concept_ids") or []) + if concept_id not in old_sources: + old_sources.append(concept_id) + update_rows.append({ + "id": existing["id"], + "description": merged_desc, + "weight": new_weight, + "source_concept_ids": old_sources, + "updated_at": now, + }) + + async with driver.session(database=settings.neo4j_database) as session: + if new_rows: + await session.run( + """ + UNWIND $rows AS row + MATCH (a:SemanticEntity {user_id: row.user_id, name_lower: row.src_lower}) + MATCH (b:SemanticEntity {user_id: row.user_id, name_lower: row.tgt_lower}) + CREATE (a)-[r:SEM_RELATES { + id: row.id, + keywords: row.keywords, + description: row.description, + weight: row.weight, + created_at: row.created_at, + source_concept_ids: row.source_concept_ids + }]->(b) + WITH r, row + CALL { + WITH r, row + WITH r, row WHERE row.keywords_embedding IS NOT NULL + CALL db.create.setRelationshipVectorProperty(r, 'keywords_embedding', row.keywords_embedding) + RETURN count(*) AS _ + } + RETURN count(r) AS created + """, + rows=new_rows, + ) + if update_rows: + await session.run( + """ + UNWIND $rows AS row + MATCH ()-[r:SEM_RELATES {id: row.id}]->() + SET r.description = row.description, + r.weight = row.weight, + r.source_concept_ids = row.source_concept_ids, + r.updated_at = row.updated_at + """, + rows=update_rows, + ) + + return merged_count + + async def _fetch_existing_edges( + self, driver, user_id: str, pairs: list[tuple[str, str]] + ) -> dict[tuple[str, str], dict[str, Any]]: + if not pairs: + return {} + rows = [{"a": a, "b": b} for a, b in pairs] + async with driver.session(database=settings.neo4j_database) as session: + result = await session.run( + """ + UNWIND $rows AS row + MATCH (x:SemanticEntity {user_id: $user_id, name_lower: row.a}) + MATCH (y:SemanticEntity {user_id: $user_id, name_lower: row.b}) + MATCH (x)-[r:SEM_RELATES]-(y) + RETURN row.a AS a, row.b AS b, + r.id AS id, r.description AS description, + r.weight AS weight, r.source_concept_ids AS source_concept_ids + """, + rows=rows, + user_id=user_id, + ) + out: dict[tuple[str, str], dict[str, Any]] = {} + async for rec in result: + key = tuple(sorted([rec["a"], rec["b"]])) + out.setdefault(key, { + "id": rec["id"], + "description": rec["description"], + "weight": rec["weight"], + "source_concept_ids": rec["source_concept_ids"], + }) + return out + + # -------------------------------------------------------- W7: ranks + + async def _refresh_ranks(self, driver, *, names: list[str], user_id: str) -> None: + if not names: + return + name_lowers = [normalize_name(n) for n in names] + async with driver.session(database=settings.neo4j_database) as session: + await session.run( + """ + UNWIND $names AS nl + MATCH (e:SemanticEntity {user_id: $user_id, name_lower: nl}) + OPTIONAL MATCH (e)-[r:SEM_RELATES]-() + WITH e, count(r) AS deg + SET e.rank = deg + """, + names=name_lowers, + user_id=user_id, + ) +``` + +### `mirix/services/semantic_graph_retriever.py` + +_Reads G_semantic: dual-level + MENTIONS reverse + CONCEPT_RELATES one-hop_ + +```python +""" +Semantic graph retriever (v4) — reads G_semantic in Neo4j. + +Pipeline: + 1. ll embedding → sem_entity_name_emb vector → seed SemanticEntities + 1-hop SEM_RELATES + 2. hl embedding → sem_rel_kw_emb vector → seed SEM_RELATES + endpoints + 3. Round-robin merge + 4. MENTIONS reverse: entities → Concepts that mention them + 5. CONCEPT_RELATES one-hop: each Concept → adjacent Concepts + 6. Score + dedup + 7. PG fetch full concept details + +Unlike episodic, there is no timestamp ordering — concepts are ordered by +cosine score (recency_decay defaults to 0.5 when timestamp is missing). +""" + +from __future__ import annotations + +from typing import Optional + +from mirix.log import get_logger +from mirix.services._graph_retriever_base import ( + DEFAULT_TOP_K, + GraphRetrieverBase, + GraphSearchResult, + ItemHit, + final_score, +) + +logger = get_logger(__name__) + + +class SemanticRetriever(GraphRetrieverBase): + ENTITY_LABEL = "SemanticEntity" + ITEM_LABEL = "Concept" + REL_TYPE = "SEM_RELATES" + ENTITY_VECTOR_INDEX = "sem_entity_name_emb" + REL_VECTOR_INDEX = "sem_rel_kw_emb" + SECTION_TITLE = "Semantic" + + async def retrieve( + self, + *, + driver, + user_id: str, + ll_embedding: Optional[list[float]], + hl_embedding: Optional[list[float]], + top_k: int = DEFAULT_TOP_K, + item_top_k: int = 15, + ) -> GraphSearchResult: + entities, relations = await self.search( + driver=driver, + user_id=user_id, + ll_embedding=ll_embedding, + hl_embedding=hl_embedding, + top_k=top_k, + ) + + entity_ids = [e.id for e in entities] + concepts_via_mentions = await self._fetch_concepts_via_mentions( + driver, user_id=user_id, entity_ids=entity_ids, limit=item_top_k * 2, + ) + + concept_ids = [it.id for it in concepts_via_mentions] + concepts_via_one_hop = await self._fetch_concepts_one_hop( + driver, user_id=user_id, concept_ids=concept_ids, limit=item_top_k, + ) + + seen: set[str] = set() + merged: list[ItemHit] = [] + for it in concepts_via_mentions + concepts_via_one_hop: + if it.id in seen: + continue + seen.add(it.id) + merged.append(it) + + for it in merged: + it.score = final_score(it.cosine, it.timestamp) + merged.sort(key=lambda x: x.score, reverse=True) + merged = merged[:item_top_k] + + await self._enrich_with_pg(merged, user_id=user_id) + + return GraphSearchResult(entities=entities, relations=relations, items=merged) + + async def _fetch_concepts_via_mentions( + self, driver, *, user_id: str, entity_ids: list[str], limit: int + ) -> list[ItemHit]: + if not entity_ids: + return [] + from mirix.settings import settings + + async with driver.session(database=settings.neo4j_database) as session: + result = await session.run( + """ + UNWIND $eids AS eid + MATCH (e:SemanticEntity {id: eid})<-[:MENTIONS]-(c:Concept {user_id: $user_id}) + WITH DISTINCT c + ORDER BY c.created_at DESC + LIMIT $limit + RETURN c.id AS id, c.name AS name, c.summary AS summary, c.created_at AS created_at + """, + eids=entity_ids, user_id=user_id, limit=limit, + ) + return [ + ItemHit( + id=rec["id"], label="Concept", + summary=rec["name"] or "", # concept "summary" line uses name + detail=rec["summary"] or "", # detail line uses summary + timestamp=rec["created_at"], cosine=0.5, source="mentions", + ) + async for rec in result + ] + + async def _fetch_concepts_one_hop( + self, driver, *, user_id: str, concept_ids: list[str], limit: int + ) -> list[ItemHit]: + if not concept_ids: + return [] + from mirix.settings import settings + + async with driver.session(database=settings.neo4j_database) as session: + result = await session.run( + """ + UNWIND $cids AS cid + MATCH (c:Concept {id: cid}) + OPTIONAL MATCH (c)-[:CONCEPT_RELATES]-(n:Concept {user_id: $user_id}) + WITH n WHERE n IS NOT NULL + RETURN DISTINCT n.id AS id, n.name AS name, n.summary AS summary, n.created_at AS created_at + LIMIT $limit + """, + cids=concept_ids, user_id=user_id, limit=limit, + ) + return [ + ItemHit( + id=rec["id"], label="Concept", + summary=rec["name"] or "", + detail=rec["summary"] or "", + timestamp=rec["created_at"], cosine=0.3, source="one_hop", + ) + async for rec in result + ] + + async def _enrich_with_pg(self, items: list[ItemHit], *, user_id: str) -> None: + """Pull full semantic_memory.details. Best-effort; graph summary covers basics.""" + if not items: + return + from sqlalchemy import text as sa_text + from mirix.server.server import db_context + + ids = [it.id for it in items] + try: + async with db_context() as session: + result = await session.execute( + sa_text( + "SELECT id, details FROM semantic_memory " + "WHERE user_id = :u AND id = ANY(:ids)" + ), + {"u": user_id, "ids": ids}, + ) + detail_map = {row[0]: (row[1] or "") for row in result.fetchall()} + except Exception as e: + logger.debug("PG enrich for semantic failed: %s", e) + return + + for it in items: + if it.id in detail_map: + # If PG details is more informative than graph summary, use it + pg_detail = detail_map[it.id] + if pg_detail and pg_detail != it.detail: + it.detail = pg_detail +``` + +--- + +## Modified files + +### `docker-compose.yml` + +_Adds neo4j:5.20-community (profile-gated: graph) + MIRIX_NEO4J_* env wiring_ + +```diff +diff --git a/docker-compose.yml b/docker-compose.yml +index 2a87d123..44667d0d 100644 +--- a/docker-compose.yml ++++ b/docker-compose.yml +@@ -71,6 +71,40 @@ services: + retries: 5 + start_period: 5s + ++ # ========================================================================== ++ # Neo4j (graph memory backend, only used when MIRIX_ENABLE_GRAPH_MEMORY=true) ++ # ========================================================================== ++ neo4j: ++ image: neo4j:5.20-community ++ container_name: mirix_neo4j ++ restart: unless-stopped ++ # Only starts when explicitly requested via: ++ # docker compose --profile graph up -d ++ # Without the profile, `docker compose up` skips this service entirely, ++ # so users who don't enable graph memory pay zero overhead. ++ profiles: ["graph"] ++ networks: ++ default: ++ aliases: ++ - mirix-neo4j ++ ports: ++ - "7474:7474" ++ - "7687:7687" ++ environment: ++ - NEO4J_AUTH=${MIRIX_NEO4J_USER:-neo4j}/${MIRIX_NEO4J_PASSWORD:-mirix_neo4j_dev} ++ - NEO4J_PLUGINS=["apoc"] ++ - NEO4J_dbms_memory_heap_max__size=2G ++ - NEO4J_dbms_memory_pagecache_size=1G ++ volumes: ++ - ./.persist/neo4j-data:/data ++ - ./.persist/neo4j-logs:/logs ++ healthcheck: ++ test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:7474 || exit 1"] ++ interval: 10s ++ timeout: 5s ++ retries: 10 ++ start_period: 30s ++ + # ========================================================================== + # Mirix API Backend + # ========================================================================== +@@ -99,6 +133,13 @@ services: + condition: service_healthy + redis: + condition: service_healthy ++ # neo4j is profile-gated ("graph"). required: false means mirix_api ++ # starts even when the neo4j service isn't included in the compose run. ++ # When graph memory is enabled, bring it up with: ++ # docker compose --profile graph up -d ++ neo4j: ++ condition: service_healthy ++ required: false + networks: + default: + aliases: +@@ -131,6 +172,15 @@ services: + - MIRIX_REDIS_ENABLED=true + - MIRIX_REDIS_HOST=redis + - MIRIX_REDIS_PORT=6379 ++ ++ # ======================================================================= ++ # Neo4j Configuration (graph memory) ++ # ======================================================================= ++ # Used when MIRIX_ENABLE_GRAPH_MEMORY=true. ++ - MIRIX_ENABLE_GRAPH_MEMORY=${MIRIX_ENABLE_GRAPH_MEMORY:-false} ++ - MIRIX_NEO4J_URI=bolt://neo4j:7687 ++ - MIRIX_NEO4J_USER=${MIRIX_NEO4J_USER:-neo4j} ++ - MIRIX_NEO4J_PASSWORD=${MIRIX_NEO4J_PASSWORD:-mirix_neo4j_dev} + # - MIRIX_REDIS_PASSWORD= # Set if Redis requires auth + # - MIRIX_REDIS_DB=0 # Redis database number + # Alternative: Full Redis URI (overrides individual settings) +``` + +### `evals/main_eval.py` + +_Per-sample token tracker reset/snapshot, writes token_stats into result JSON_ + +```diff +diff --git a/evals/main_eval.py b/evals/main_eval.py +index 44229f88..9afab12e 100644 +--- a/evals/main_eval.py ++++ b/evals/main_eval.py +@@ -141,8 +141,13 @@ def main() -> None: + parser.add_argument( + "--output_path", + type=Path, +- default=Path("results"), +- help="Output folder for per-sample JSON results.", ++ default=Path("locomo_run"), ++ help=( ++ "Output sub-folder name. The path is resolved relative to " ++ "/evals/results/locomo/, so passing 'foo' writes to " ++ "evals/results/locomo/foo. Absolute paths are still honored " ++ "but warned about, since they bypass the locomo namespace." ++ ), + ) + parser.add_argument( + "--mirix_config_path", +@@ -159,8 +164,43 @@ def main() -> None: + mirix_client_id = os.environ.get("MIRIX_CLIENT_ID", "mirix-eval-client") + mirix_org_id = os.environ.get("MIRIX_ORG_ID", "mirix-eval-org") + +- output_path = args.output_path ++ # Force every main_eval run into the LoCoMo namespace so MAB and LoCoMo ++ # outputs cannot bleed into each other. The user can still pass an ++ # absolute path to break out (e.g. for one-off experiments), but a warning ++ # makes the divergence explicit. ++ locomo_root = Path(__file__).resolve().parent / "results" / "locomo" ++ if args.output_path.is_absolute(): ++ print( ++ f"[main_eval] WARNING: --output_path is absolute ({args.output_path}); " ++ f"writing outside evals/results/locomo/ namespace.", ++ ) ++ output_path = args.output_path ++ else: ++ output_path = locomo_root / args.output_path + output_path.mkdir(parents=True, exist_ok=True) ++ print(f"[main_eval] writing per-sample results to {output_path}") ++ ++ # Server-side token tracker is always-on (see mirix/database/token_tracker.py). ++ # We just need to (a) reset before each sample's ingest, (b) snapshot after ++ # ingest to get "build" tokens, (c) snapshot after QA to get "query" tokens. ++ import httpx ++ server_base = "http://127.0.0.1:8531" ++ def _reset_tokens(): ++ try: ++ httpx.post(f"{server_base}/debug/token_stats/reset", timeout=10) ++ except Exception: ++ pass ++ def _snapshot_tokens(): ++ try: ++ r = httpx.get(f"{server_base}/debug/token_stats", timeout=10) ++ return r.json().get("stats", {}) ++ except Exception: ++ return {} ++ def _sum_tokens(stats): ++ s = {"prompt": 0, "completion": 0, "total": 0, "calls": 0} ++ for v in stats.values(): ++ for k in s: s[k] += v.get(k, 0) ++ return s + + for item in items: + sample_id = item.get("sample_id") +@@ -186,6 +226,9 @@ def main() -> None: + mirix_config_path=str(args.mirix_config_path), + client=task_agent.mirix_client) + ++ # Reset server-side token counter so build_tokens reflects only this sample's ingest ++ _reset_tokens() ++ + conversation = item.get("conversation", {}) + for idx, session in enumerate(iter_sessions(conversation), start=1): + idx_key = str(idx) +@@ -215,6 +258,11 @@ def main() -> None: + sample_result["timings"]["add_chunk"][idx_key] = elapsed + save_sample_result(sample_path, sample_result) + ++ # Snapshot build tokens (everything since reset, before any QA runs) ++ build_stats = _snapshot_tokens() ++ sample_result["token_stats"] = {"build_raw": build_stats, "build_sum": _sum_tokens(build_stats)} ++ save_sample_result(sample_path, sample_result) ++ + qa_list = item.get("qa", []) + if args.max_questions is not None: + qa_list = qa_list[: args.max_questions] +@@ -300,6 +348,22 @@ def main() -> None: + with memories_path.open("w", encoding="utf-8") as handle: + json.dump(all_memories, handle, ensure_ascii=False, indent=2) + ++ # Snapshot post-QA total tokens. "query_tokens" is server-side retrieval ++ # cost only (keyword extraction + LightRAG sub-calls). The actual QA ++ # answer LLM call goes through task_agent (client-side OpenAI), tracked ++ # separately in records[*].usage_total. ++ post_qa_stats = _snapshot_tokens() ++ post_qa_sum = _sum_tokens(post_qa_stats) ++ build_sum = sample_result.get("token_stats", {}).get("build_sum", {}) ++ query_sum = { ++ k: max(post_qa_sum.get(k, 0) - build_sum.get(k, 0), 0) ++ for k in ("prompt", "completion", "total", "calls") ++ } ++ sample_result.setdefault("token_stats", {}) ++ sample_result["token_stats"]["query_raw"] = post_qa_stats ++ sample_result["token_stats"]["query_sum"] = query_sum ++ save_sample_result(sample_path, sample_result) ++ + + if __name__ == "__main__": + main() +``` + +### `evals/mirix_memory_system.py` + +_Client timeout 60s → 600s (v4 ingest is slow). Fixes content list shape for retrieve_ + +```diff +diff --git a/evals/mirix_memory_system.py b/evals/mirix_memory_system.py +index 4de1ebe9..cffdf906 100644 +--- a/evals/mirix_memory_system.py ++++ b/evals/mirix_memory_system.py +@@ -48,7 +48,10 @@ class MirixMemorySystem: + + def __init__(self, user_id: Optional[str] = None, mirix_config_path: Optional[str] = None, client_id: Optional[str] = None, org_id: Optional[str] = None, client: Optional[MirixClient] = None): + if client is None: +- self.client = MirixClient(client_id=client_id, org_id=org_id, base_url="http://127.0.0.1:8531", write_scope="read_write") ++ # Long timeout: v4 graph hooks add per-chunk LLM extraction + Neo4j writes ++ # for both episodic and semantic graphs, easily pushing single-chunk processing ++ # past the 60s default. 600s gives headroom; LightRAG retrievals are still fast. ++ self.client = MirixClient(client_id=client_id, org_id=org_id, base_url="http://127.0.0.1:8531", write_scope="read_write", timeout=600) + config_path = Path(mirix_config_path) if mirix_config_path else Path(__file__).with_name("mirix_openai.yaml") + with config_path.open("r", encoding="utf-8") as handle: + config = yaml.safe_load(handle) or {} +@@ -75,10 +78,14 @@ class MirixMemorySystem: + return response + + def wrap_user_prompt(self, prompt: str): ++ # The retrieve endpoint's topic-extraction step iterates msg["content"] ++ # expecting a list of {type, text} dicts (multimodal format). Passing a ++ # bare string here silently degrades to topics="" → LightRAG retrieve ++ # gets an empty query → empty graph context. + memories = asyncio.run(self.client.retrieve_with_conversation( + user_id=self.user_id, + messages=[ +- {'role': 'user', 'content': prompt} ++ {'role': 'user', 'content': [{'type': 'text', 'text': prompt}]} + ] + )) +``` + +### `evals/task_agent.py` + +_Same client timeout bump_ + +```diff +diff --git a/evals/task_agent.py b/evals/task_agent.py +index be34a1bd..4f38a677 100644 +--- a/evals/task_agent.py ++++ b/evals/task_agent.py +@@ -32,7 +32,7 @@ class TaskAgent: + self.model = model + self.user_id = user_id + self.max_tool_rounds = max_tool_rounds +- self.mirix_client = MirixClient(client_id=client_id, org_id=org_id, base_url="http://127.0.0.1:8531", write_scope="read_write") ++ self.mirix_client = MirixClient(client_id=client_id, org_id=org_id, base_url="http://127.0.0.1:8531", write_scope="read_write", timeout=600) + self.user_id = user_id if user_id is not None else str(uuid.uuid4()) + config_path = Path(mirix_config_path) + with config_path.open("r", encoding="utf-8") as handle: +``` + +### `mirix/llm_api/openai.py` + +_Records token usage to tracker after each chat completion (no-op if tracker disabled)_ + +```diff +diff --git a/mirix/llm_api/openai.py b/mirix/llm_api/openai.py +index 30c148fc..7fffe64c 100755 +--- a/mirix/llm_api/openai.py ++++ b/mirix/llm_api/openai.py +@@ -536,6 +536,18 @@ async def openai_chat_completions_request( + + response_json = await make_post_request(url, headers, data) + ++ # Record token usage for instrumented eval runs (no-op outside) ++ try: ++ from mirix.database.token_tracker import record as _record_tokens ++ usage = (response_json.get("usage") or {}) ++ _record_tokens( ++ prompt_tokens=usage.get("prompt_tokens", 0), ++ completion_tokens=usage.get("completion_tokens", 0), ++ total_tokens=usage.get("total_tokens"), ++ ) ++ except Exception: ++ pass ++ + return ChatCompletionResponse(**response_json) +``` + +### `mirix/server/rest_api.py` + +_Neo4j init in lifespan, /debug/token_stats endpoints, dispatcher hook_ + +```diff +diff --git a/mirix/server/rest_api.py b/mirix/server/rest_api.py +index b592a6b3..e0ca3d58 100644 +--- a/mirix/server/rest_api.py ++++ b/mirix/server/rest_api.py +@@ -108,6 +108,14 @@ async def initialize(): + except Exception as e: + logger.warning("Redis async init failed: %s", e) + ++ # Initialize Neo4j driver if graph memory is enabled. No-op otherwise. ++ try: ++ from mirix.database.neo4j_client import init_neo4j_client ++ ++ await init_neo4j_client() ++ except Exception as e: ++ logger.warning("Neo4j init failed: %s — graph memory will be unavailable", e) ++ + # Initialize AsyncServer (singleton) and create default org/user/client + server = get_server() + await server.ensure_defaults() +@@ -154,6 +162,14 @@ async def cleanup(): + await queue_manager.cleanup() + logger.info("Queue service stopped") + ++ # Close Neo4j driver if initialized ++ try: ++ from mirix.database.neo4j_client import close_neo4j_driver ++ ++ await close_neo4j_driver() ++ except Exception as e: ++ logger.warning("Error closing Neo4j driver: %s", e) ++ + + @asynccontextmanager + async def lifespan(app: FastAPI): +@@ -681,6 +697,34 @@ async def health_check(): + return {"status": "healthy", "service": "mirix-api"} + + ++@router.get("/debug/token_stats") ++async def debug_token_stats(): ++ """Return cumulative LLM token usage recorded server-side since last reset. ++ ++ Tracker is off by default; only counts data after a POST to ++ /debug/token_stats/reset (which enables it). ++ """ ++ from mirix.database.token_tracker import is_enabled, snapshot ++ return {"enabled": is_enabled(), "stats": snapshot()} ++ ++ ++@router.post("/debug/token_stats/reset") ++async def debug_token_stats_reset(): ++ """Wipe counters and enable the tracker. Idempotent.""" ++ from mirix.database.token_tracker import enable, reset ++ reset() ++ enable() ++ return {"status": "reset", "enabled": True} ++ ++ ++@router.post("/debug/token_stats/disable") ++async def debug_token_stats_disable(): ++ """Turn the tracker off (recording becomes a no-op again).""" ++ from mirix.database.token_tracker import disable ++ disable() ++ return {"status": "disabled"} ++ ++ + # ============================================================================ + # Agent Endpoints + # ============================================================================ +@@ -1944,6 +1988,10 @@ async def initialize_meta_agent( + create_params["agents"] = meta_config["agents"] + if "system_prompts" in meta_config: + create_params["system_prompts"] = meta_config["system_prompts"] ++ if "enable_conflict_resolution" in meta_config: ++ create_params["enable_conflict_resolution"] = bool( ++ meta_config["enable_conflict_resolution"] ++ ) + + # Check if meta agent already exists for this client + # list_agents now automatically filters by client (organization_id + _created_by_id) +@@ -1985,6 +2033,68 @@ async def initialize_meta_agent( + return meta_agent + + ++async def _augment_source_meta_with_server_fallbacks( ++ filter_tags: dict, ++ user_id: str, ++ n_turns: int, ++ request_occurred_at: Optional[str], ++ server: AsyncServer, ++) -> None: ++ """Mutate ``filter_tags`` in place so it carries a ``source_meta`` dict ++ with at least ``turn_id``, ``chunk_id``, and ``occurred_at`` set. ++ ++ Policy: ++ ++ 1. Anything the client already put in ``filter_tags["source_meta"]`` ++ wins. This lets callers with domain knowledge (e.g. the MAB ++ adapter which knows the serial range of a chunk) carry their ++ fields through unchanged. ++ 2. Fields the client did NOT set get filled from the server: ++ - ``turn_id`` : next per-user counter (one per input message) ++ - ``chunk_id`` : next per-user counter (one per /memory/add call) ++ - ``occurred_at`` : the request's ``occurred_at`` if provided, ++ else server wall-clock ISO 8601. ++ 3. ``serial`` is never auto-filled. It is a domain-specific signal ++ (e.g. FactConsolidation's numbered fact list) and only present ++ when the caller explicitly set it. ++ ++ This is the single point that makes conflict resolution + source ++ provenance general: every ``/memory/add`` (sync or async) ends up ++ with the same ``source_meta`` contract, regardless of which client ++ sent it. ++ """ ++ from datetime import timezone as _dt_tz ++ ++ existing = filter_tags.get("source_meta") ++ if not isinstance(existing, dict): ++ existing = {} ++ else: ++ existing = dict(existing) # don't mutate the caller's dict ++ ++ needs_turn = "turn_id" not in existing ++ needs_chunk = "chunk_id" not in existing ++ if needs_turn or needs_chunk: ++ reserved = await server.user_manager.reserve_source_ids( ++ user_id=user_id, n_turns=max(n_turns, 1) ++ ) ++ if needs_turn: ++ # For a multi-message batch we record the *first* turn_id of ++ # the batch; the agent is free to walk the message list if it ++ # needs per-message granularity. Single-message ingests are ++ # the common case and this is exact. ++ existing["turn_id"] = reserved["turn_id_start"] ++ if needs_chunk: ++ existing["chunk_id"] = reserved["chunk_id"] ++ ++ if "occurred_at" not in existing: ++ if request_occurred_at: ++ existing["occurred_at"] = request_occurred_at ++ else: ++ existing["occurred_at"] = datetime.now(_dt_tz.utc).isoformat() ++ ++ filter_tags["source_meta"] = existing ++ ++ + class AddMemoryRequest(BaseModel): + """Request model for adding memory.""" + +@@ -2100,6 +2210,20 @@ async def add_memory( + raise HTTPException(status_code=403, detail="Client has no write_scope - cannot create memories") + filter_tags["scope"] = client.write_scope + ++ # Merge client-provided source_meta with server-side fallbacks (turn_id, ++ # chunk_id, occurred_at). This is what makes conflict resolution + ++ # source provenance general: clients with their own source knowledge ++ # (e.g. the MAB adapter knows the chunk's serial range) keep what they ++ # passed; clients that pass nothing still get turn_id / chunk_id / ++ # occurred_at auto-filled from the server. ++ await _augment_source_meta_with_server_fallbacks( ++ filter_tags=filter_tags, ++ user_id=user_id, ++ n_turns=len(input_messages), ++ request_occurred_at=request.occurred_at, ++ server=server, ++ ) ++ + # Queue for async processing instead of synchronous execution + # Note: actor is Client for org-level access control + # user_id represents the actual end-user (or admin user if not provided) +@@ -2193,6 +2317,16 @@ async def add_memory_sync( + raise HTTPException(status_code=403, detail="Client has no write_scope - cannot create memories") + filter_tags["scope"] = client.write_scope + ++ # Same server-side source_meta fallback as the async path; see helper ++ # docstring for details. ++ await _augment_source_meta_with_server_fallbacks( ++ filter_tags=filter_tags, ++ user_id=user_id, ++ n_turns=len(input_messages), ++ request_occurred_at=request.occurred_at, ++ server=server, ++ ) ++ + from mirix.services.user_manager import UserManager + + user_manager = UserManager() +@@ -2301,19 +2435,27 @@ async def retrieve_memories_by_keywords( + timezone_str = "UTC" + memories = {} + +- # Graph memory retrieval (supplements flat retrieval when enabled) ++ # LightRAG-style dual-level graph retrieval (P3). Supplements flat memory ++ # retrieval with KG entities/relations + episodic chunks. Returns an empty ++ # context string when no hits — caller is robust to that. + if settings.enable_graph_memory: + try: +- graph_context = await server.graph_memory_manager.retrieve_graph_context( ++ from mirix.services.graph_retriever_dispatcher import GraphRetrieverDispatcher ++ ++ logger.info( ++ "Graph retrieve: user_id=%s, key_words=%r (len=%d)", ++ user_id, (key_words or "")[:120], len(key_words or ""), ++ ) ++ graph_context = await GraphRetrieverDispatcher().retrieve( + query=key_words, +- agent_state=agent_state, +- organization_id=client.organization_id, + user_id=user_id, ++ agent_state=agent_state, + ) ++ logger.info("Graph retrieve result: ctx_len=%d", len(graph_context or "")) + if graph_context: + memories["graph"] = {"context": graph_context} + except Exception as e: +- logger.error("Graph memory retrieval failed: %s", e) ++ logger.error("Graph retrieval failed: %s", e, exc_info=True) + + # Get episodic memories (recent + relevant) with optional temporal filtering + try: +``` + +### `mirix/server/server.py` + +_Calls run_startup_migrations before Base.metadata.create_all_ + +```diff +diff --git a/mirix/server/server.py b/mirix/server/server.py +index 91ddc920..ce13e101 100644 +--- a/mirix/server/server.py ++++ b/mirix/server/server.py +@@ -85,7 +85,6 @@ from mirix.services.organization_manager import OrganizationManager + from mirix.services.per_agent_lock_manager import PerAgentLockManager + from mirix.services.procedural_memory_manager import ProceduralMemoryManager + from mirix.services.provider_manager import ProviderManager +-from mirix.services.graph_memory_manager import GraphMemoryManager + from mirix.services.raw_memory_manager import RawMemoryManager + from mirix.services.resource_memory_manager import ResourceMemoryManager + from mirix.services.semantic_memory_manager import SemanticMemoryManager +@@ -454,9 +453,15 @@ else: + + + async def ensure_tables_created(): +- """Create all tables on the async engine. Call from FastAPI lifespan startup.""" ++ """Create all tables on the async engine. Call from FastAPI lifespan startup. ++ ++ Order matters: startup migrations (e.g. dropping retired tables) must run ++ *before* ``create_all`` so the new ORM state is what gets materialized. ++ """ + if USE_PGLITE: + return ++ from mirix.database.startup_migrations import run_startup_migrations ++ await run_startup_migrations(engine) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + +@@ -521,7 +526,6 @@ class AsyncServer(Server): + self.raw_memory_manager = RawMemoryManager() + self.resource_memory_manager = ResourceMemoryManager() + self.semantic_memory_manager = SemanticMemoryManager() +- self.graph_memory_manager = GraphMemoryManager() + + # Provider Manager + self.provider_manager = ProviderManager() +``` + +### `mirix/services/episodic_memory_manager.py` + +_Sync hook after PG insert → EpisodicGraphManager.process_episode_ + +```diff +diff --git a/mirix/services/episodic_memory_manager.py b/mirix/services/episodic_memory_manager.py +index c5c6f7eb..0bee714a 100755 +--- a/mirix/services/episodic_memory_manager.py ++++ b/mirix/services/episodic_memory_manager.py +@@ -565,6 +565,16 @@ class EpisodicMemoryManager: + summary_embedding = None + embedding_config = None + ++ # Source provenance: when the /memory/add caller (or the ++ # server-side fallback in rest_api._augment_source_meta_with_ ++ # server_fallbacks) attaches a ``source_meta`` dict to ++ # filter_tags, copy it onto the episodic event's ++ # ``source_refs``. We do not strip it from filter_tags so that ++ # downstream filtering / debug still sees it. ++ source_refs_for_event: list = [] ++ if filter_tags and isinstance(filter_tags.get("source_meta"), dict): ++ source_refs_for_event = [dict(filter_tags["source_meta"])] ++ + event = await self.create_episodic_memory( + PydanticEpisodicEvent( + occurred_at=timestamp, +@@ -580,6 +590,7 @@ class EpisodicMemoryManager: + details_embedding=details_embedding, + embedding_config=embedding_config, + filter_tags=filter_tags, ++ source_refs=source_refs_for_event, + last_modify={ + "timestamp": datetime.now(dt.timezone.utc).isoformat(), + "operation": "created", +@@ -591,22 +602,24 @@ class EpisodicMemoryManager: + use_cache=use_cache, + ) + +- # Graph memory: create episode node + involves edges (async, non-blocking) ++ # Graph memory write path (v4): writes to G_episodic in Neo4j. ++ # Sync hook — failures logged but do not affect the PG insert that ++ # already completed. + if settings.enable_graph_memory: + try: +- from mirix.services.graph_memory_manager import GraphMemoryManager +- gm = GraphMemoryManager() +- await gm.process_for_graph( +- text=f"{summary}\n{details}", ++ from mirix.services.episodic_graph_manager import EpisodicGraphManager ++ ++ await EpisodicGraphManager().process_episode( ++ episode_id=event.id, + summary=summary, + details=details, +- event_time=timestamp, ++ occurred_at=timestamp, + agent_state=agent_state, + organization_id=organization_id, + user_id=user_id or "unknown", + ) + except Exception as graph_err: +- logger.warning("Graph memory processing failed (non-fatal): %s", graph_err) ++ logger.warning("Episodic graph write failed (non-fatal): %s", graph_err) + + return event + +@@ -1287,6 +1300,7 @@ class EpisodicMemoryManager: + actor: PydanticClient = None, + agent_state: AgentState = None, + update_mode: str = "append", ++ additional_source_ref: Optional[Dict[str, Any]] = None, + ): + """ + Update the selected events +@@ -1299,6 +1313,11 @@ class EpisodicMemoryManager: + agent_state: Agent state containing embedding configuration (needed for embedding regeneration) + update_mode: How to handle new_details - "append" (default) appends to existing, + "replace" overwrites existing details entirely ++ additional_source_ref: Optional source-provenance dict from the ++ current ingest call (turn_id / chunk_id / serial / ++ occurred_at). When supplied, it is appended to the event's ++ ``source_refs`` list so a merged event still carries the ++ trail of every ingest that contributed to it. + """ + + async with self.session_maker() as session: +@@ -1332,6 +1351,15 @@ class EpisodicMemoryManager: + ) + selected_event.embedding_config = agent_state.embedding_config + ++ # Append the current ingest's source_ref to the event's ++ # provenance trail (if provided). Late-arriving ingests that ++ # merge into an existing event keep their pointer in the list ++ # rather than being lost. ++ if additional_source_ref: ++ existing_refs = list(selected_event.source_refs or []) ++ existing_refs.append(dict(additional_source_ref)) ++ selected_event.source_refs = existing_refs ++ + # Update last_modify field with timestamp and operation info + selected_event.last_modify = { + "timestamp": datetime.now(dt.timezone.utc).isoformat(), +``` + +### `mirix/services/semantic_memory_manager.py` + +_Sync hook after PG insert → SemanticGraphManager.process_concept_ + +```diff +diff --git a/mirix/services/semantic_memory_manager.py b/mirix/services/semantic_memory_manager.py +index 89d9bc2a..47edec47 100755 +--- a/mirix/services/semantic_memory_manager.py ++++ b/mirix/services/semantic_memory_manager.py +@@ -959,7 +959,40 @@ class SemanticMemoryManager: + ) -> PydanticSemanticMemoryItem: + """ + Create a new semantic memory entry using provided parameters. ++ ++ Auto-route: when ``filter_tags`` contains a ``source_meta`` dict ++ (chunk_id / serial / occurred_at) AND ``name`` is shaped like ++ ``" / "``, the call is forwarded to ++ ``upsert_with_conflict_resolution`` for deterministic merge with ++ ``prior_values`` history. Otherwise the legacy free-form path is ++ used unchanged. + """ ++ # ---- conflict-resolution auto-route ------------------------------ ++ source_meta = (filter_tags or {}).get("source_meta") ++ if source_meta and isinstance(name, str) and " / " in name: ++ entity, _, relation = name.partition(" / ") ++ entity, relation = entity.strip(), relation.strip() ++ if entity and relation: ++ # The ``source_meta`` dict is the per-ingest payload sent by ++ # the client. Carry every field through as the source_ref ++ # so the ordering tuple (occurred_at > serial > created_at) ++ # in the manager can use whichever fields are present. ++ return await self.upsert_with_conflict_resolution( ++ actor=actor, ++ agent_state=agent_state, ++ agent_id=agent_id, ++ entity=entity, ++ relation=relation, ++ value=summary, ++ source_ref=dict(source_meta), ++ organization_id=organization_id, ++ extra_filter_tags={ ++ k: v for k, v in (filter_tags or {}).items() if k != "source_meta" ++ }, ++ use_cache=use_cache, ++ client_id=client_id, ++ user_id=user_id, ++ ) + try: + # Set defaults for required fields + from mirix.services.user_manager import UserManager +@@ -1008,27 +1041,246 @@ class SemanticMemoryManager: + + # Note: Item is already added to clustering tree in create_item() + +- # Graph memory: extract entities/relations from semantic item (async, non-blocking) ++ # Graph memory write path (v4): writes to G_semantic in Neo4j. ++ # Each semantic item becomes a (:Concept) node; LightRAG-style ++ # extraction adds (:SemanticEntity) + [:SEM_RELATES], and an LLM ++ # judgement step builds (:Concept)-[:CONCEPT_RELATES]->(:Concept) ++ # edges to existing top-K similar concepts. Sync hook — failures ++ # logged but do not affect the PG insert that already completed. + if settings.enable_graph_memory: + try: +- from mirix.services.graph_memory_manager import GraphMemoryManager +- gm = GraphMemoryManager() +- await gm.process_for_graph( +- text=f"{name}: {summary}\n{details or ''}", ++ from mirix.services.semantic_graph_manager import SemanticGraphManager ++ ++ await SemanticGraphManager().process_concept( ++ concept_id=semantic_item.id, ++ name=name, + summary=summary, +- details=details, +- event_time=datetime.now(timezone.utc), ++ details=details or "", + agent_state=agent_state, + organization_id=organization_id, + user_id=user_id or "unknown", + ) + except Exception as graph_err: +- logger.warning("Graph memory processing failed (non-fatal): %s", graph_err) ++ logger.warning("Semantic graph write failed (non-fatal): %s", graph_err) + + return semantic_item + except Exception as e: + raise e + ++ @staticmethod ++ def _build_cr_filter_tags( ++ entity: str, ++ relation: str, ++ existing: Optional[Dict[str, Any]] = None, ++ ) -> Dict[str, Any]: ++ """Merge the conflict-resolution lookup keys into a filter_tags dict. ++ ++ ``cr_entity`` and ``cr_relation`` are the index used by ++ ``upsert_with_conflict_resolution`` to find the canonical item for a ++ given (entity, relation) pair within a user_id. Other tags ++ (scope, project_id, ...) are preserved. ++ """ ++ out: Dict[str, Any] = dict(existing or {}) ++ out["cr_entity"] = entity ++ out["cr_relation"] = relation ++ return out ++ ++ @staticmethod ++ def _source_ref_key(source_ref: Optional[Dict[str, Any]]) -> tuple: ++ """Total ordering for source refs. ++ ++ Priority: occurred_at > serial > created_at (caller fills created_at ++ when nothing else is available). All missing → very small key, so ++ the caller's new ref wins ties via the explicit ``-1`` fallback. ++ """ ++ if not source_ref: ++ return (0, "", -1, "") ++ # occurred_at: ISO 8601 strings compare lexicographically when in UTC. ++ occurred = source_ref.get("occurred_at") or "" ++ serial = source_ref.get("serial") ++ created = source_ref.get("created_at") or "" ++ # Each tier becomes its own sort key; "" sorts before any real value. ++ return ( ++ 1 if occurred else 0, occurred, ++ 1 if serial is not None else 0, serial if serial is not None else -1, ++ 1 if created else 0, created, ++ ) ++ ++ async def _find_by_entity_relation( ++ self, ++ entity: str, ++ relation: str, ++ user_id: str, ++ actor: PydanticClient, ++ ) -> Optional[SemanticMemoryItem]: ++ """Lookup the existing canonical item for (entity, relation) under ++ this user, or None. Uses the ``cr_entity`` / ``cr_relation`` keys ++ the upsert path writes into ``filter_tags``. ++ """ ++ async with self.session_maker() as session: ++ # Postgres: filter_tags is JSONB; use ->> operator. SQLite path ++ # falls back to a Python-side filter for the small subset that ++ # already matches user_id. ++ if settings.mirix_pg_uri_no_default: ++ stmt = ( ++ select(SemanticMemoryItem) ++ .where(SemanticMemoryItem.user_id == user_id) ++ .where(text("(filter_tags->>'cr_entity') = :ent")) ++ .where(text("(filter_tags->>'cr_relation') = :rel")) ++ .params(ent=entity, rel=relation) ++ .limit(1) ++ ) ++ result = await session.execute(stmt) ++ row = result.scalar_one_or_none() ++ return row ++ # SQLite fallback ++ stmt = select(SemanticMemoryItem).where( ++ SemanticMemoryItem.user_id == user_id ++ ) ++ result = await session.execute(stmt) ++ for row in result.scalars().all(): ++ ft = row.filter_tags or {} ++ if ft.get("cr_entity") == entity and ft.get("cr_relation") == relation: ++ return row ++ return None ++ ++ async def upsert_with_conflict_resolution( ++ self, ++ actor: PydanticClient, ++ agent_state: AgentState, ++ agent_id: str, ++ entity: str, ++ relation: str, ++ value: str, ++ source_ref: Dict[str, Any], ++ organization_id: str, ++ status: str = "asserted", ++ extra_filter_tags: Optional[Dict[str, Any]] = None, ++ use_cache: bool = True, ++ client_id: Optional[str] = None, ++ user_id: Optional[str] = None, ++ ) -> PydanticSemanticMemoryItem: ++ """Deterministic upsert of a (entity, relation, value) fact. ++ ++ Lookup the existing canonical item for this (entity, relation) and: ++ ++ - If no existing item, insert a new one with ``name = " / ++ "``, ``summary = value``, ``source_refs = [source_ref]``, ++ and ``filter_tags`` carrying ``cr_entity``/``cr_relation``. ++ - If the new source_ref has a strictly larger sort key than the ++ existing canonical's most recent ref, replace the canonical: ++ old summary/source_refs move into ``prior_values`` with status ++ ``"superseded"``; new value becomes the current ``summary``. ++ - Otherwise append the new ref to ``prior_values`` as a late-arriving ++ older version (so the audit trail is preserved without changing ++ the current canonical). ++ - ``status="corrected"`` forces a replace and marks the displaced ++ version with ``status="corrected"`` regardless of source_ref order. ++ ++ Returns the canonical item after the upsert. ++ ++ No LLM is involved in this method — the merge is deterministic on ++ the contents of ``source_ref``. ++ """ ++ from mirix.services.user_manager import UserManager ++ ++ if client_id is None: ++ client_id = actor.id ++ if user_id is None: ++ user_id = UserManager.ADMIN_USER_ID ++ ++ existing = await self._find_by_entity_relation(entity, relation, user_id, actor) ++ merged_tags = self._build_cr_filter_tags(entity, relation, extra_filter_tags) ++ ++ if existing is None: ++ # Cold path: behave like a regular insert, but seed source_refs ++ # and stash the cr_entity/cr_relation in filter_tags. ++ name = f"{entity} / {relation}" ++ details = f"Current value: {value}" ++ item = await self.insert_semantic_item( ++ actor=actor, ++ agent_state=agent_state, ++ agent_id=agent_id, ++ name=name, ++ summary=value, ++ details=details, ++ source=str(source_ref) if source_ref else "", ++ organization_id=organization_id, ++ filter_tags=merged_tags, ++ use_cache=use_cache, ++ client_id=client_id, ++ user_id=user_id, ++ ) ++ # Patch source_refs onto the row in-place; insert_semantic_item ++ # doesn't take it as a parameter to keep the legacy surface stable. ++ async with self.session_maker() as session: ++ db_row = await SemanticMemoryItem.read( ++ db_session=session, identifier=item.id, actor=actor ++ ) ++ db_row.source_refs = [source_ref] if source_ref else [] ++ await session.commit() ++ await session.refresh(db_row) ++ return db_row.to_pydantic() ++ ++ # Hot path: an existing canonical exists. Compare source_refs and ++ # decide whether the new ref supersedes it. ++ existing_refs: List[Dict[str, Any]] = list(existing.source_refs or []) ++ # The "most recent" existing ref is the max under our ordering. ++ existing_top = max(existing_refs, key=self._source_ref_key) if existing_refs else None ++ new_wins = ( ++ status == "corrected" ++ or existing_top is None ++ or self._source_ref_key(source_ref) > self._source_ref_key(existing_top) ++ ) ++ ++ async with self.session_maker() as session: ++ db_row = await SemanticMemoryItem.read( ++ db_session=session, identifier=existing.id, actor=actor ++ ) ++ now_iso = datetime.now(timezone.utc).isoformat() ++ ++ if new_wins: ++ # Move the current value into prior_values, swap in the new. ++ prior_entry = { ++ "value": db_row.summary, ++ "source_refs": list(db_row.source_refs or []), ++ "status": "corrected" if status == "corrected" else "superseded", ++ "moved_at": now_iso, ++ } ++ db_row.prior_values = list(db_row.prior_values or []) + [prior_entry] ++ db_row.summary = value ++ db_row.details = f"Current value: {value}" ++ db_row.source_refs = [source_ref] if source_ref else [] ++ # Keep the cr_entity/cr_relation tags intact while merging ++ # any extra tags from this update. ++ merged_existing = self._build_cr_filter_tags( ++ entity, relation, {**(db_row.filter_tags or {}), **(extra_filter_tags or {})} ++ ) ++ db_row.filter_tags = merged_existing ++ db_row.last_modify = { ++ "timestamp": now_iso, ++ "operation": "cr_supersede" if status != "corrected" else "cr_correct", ++ } ++ else: ++ # Late-arriving older fact — record in prior_values, don't ++ # touch the canonical. ++ prior_entry = { ++ "value": value, ++ "source_refs": [source_ref] if source_ref else [], ++ "status": "superseded", ++ "moved_at": now_iso, ++ "note": "late-arrived older fact", ++ } ++ db_row.prior_values = list(db_row.prior_values or []) + [prior_entry] ++ db_row.last_modify = { ++ "timestamp": now_iso, ++ "operation": "cr_record_late", ++ } ++ ++ await session.commit() ++ await session.refresh(db_row) ++ return db_row.to_pydantic() ++ + async def delete_semantic_item_by_id(self, semantic_memory_id: str, actor: PydanticClient) -> None: + """Delete a semantic memory item by ID (removes from cache).""" + async with self.session_maker() as session: +``` + +### `mirix/orm/__init__.py` + +_Drops v2 ORM imports (EntityNode, EntityEdge, EpisodeNode, InvolvesEdge)_ + +```diff +diff --git a/mirix/orm/__init__.py b/mirix/orm/__init__.py +index aaa885e8..62007389 100755 +--- a/mirix/orm/__init__.py ++++ b/mirix/orm/__init__.py +@@ -6,7 +6,6 @@ from mirix.orm.client_api_key import ClientApiKey + from mirix.orm.cloud_file_mapping import CloudFileMapping + from mirix.orm.episodic_memory import EpisodicEvent + from mirix.orm.file import FileMetadata +-from mirix.orm.graph_memory import EntityEdge, EntityNode, EpisodeNode, InvolvesEdge + from mirix.orm.knowledge_vault import KnowledgeVaultItem + from mirix.orm.message import Message + from mirix.orm.organization import Organization +@@ -26,12 +25,8 @@ __all__ = [ + "Client", + "ClientApiKey", + "CloudFileMapping", +- "EntityEdge", +- "EntityNode", +- "EpisodeNode", + "EpisodicEvent", + "FileMetadata", +- "InvolvesEdge", + "KnowledgeVaultItem", + "Message", + "Organization", +``` + +### `mirix/settings.py` + +_Adds neo4j_uri, neo4j_user, neo4j_password, neo4j_database, neo4j_vector_dim_ + +```diff +diff --git a/mirix/settings.py b/mirix/settings.py +index 5d513c19..dbd18fc3 100755 +--- a/mirix/settings.py ++++ b/mirix/settings.py +@@ -226,8 +226,18 @@ class Settings(BaseSettings): + llm_retry_backoff_factor: float = Field(0.5, env="MIRIX_LLM_RETRY_BACKOFF_FACTOR") # Exponential backoff multiplier + llm_retry_max_delay: float = Field(10.0, env="MIRIX_LLM_RETRY_MAX_DELAY") # Max delay between retries (seconds) + +- # Graph memory: temporal knowledge graph for episodic + semantic ++ # Graph memory: LightRAG-style dual-level retrieval over Neo4j. ++ # When enabled, episodic event inserts also extract entities/relations into ++ # Neo4j; retrieval supplements flat memory with graph context. + enable_graph_memory: bool = Field(False, env="MIRIX_ENABLE_GRAPH_MEMORY") ++ neo4j_uri: str = Field("bolt://localhost:7687", env="MIRIX_NEO4J_URI") ++ neo4j_user: str = Field("neo4j", env="MIRIX_NEO4J_USER") ++ neo4j_password: str = Field("mirix_neo4j_dev", env="MIRIX_NEO4J_PASSWORD") ++ neo4j_database: str = Field("neo4j", env="MIRIX_NEO4J_DATABASE") ++ # Dimension of embeddings used for entity/relation vector indexes in Neo4j. ++ # Must match the embedding model in use (1536 for text-embedding-3-small, ++ # 3072 for text-embedding-3-large). ++ neo4j_vector_dim: int = Field(1536, env="MIRIX_NEO4J_VECTOR_DIM") + + # cron job parameters + enable_batch_job_polling: bool = False +``` + +### `requirements.txt` + +_Adds neo4j>=5.20.0,<6.0.0_ + +```diff +diff --git a/requirements.txt b/requirements.txt +index 90b1f708..81a875a4 100644 +--- a/requirements.txt ++++ b/requirements.txt +@@ -48,6 +48,7 @@ httpx + ipdb + protobuf>=5.0.0,<6.0.0 + redis[hiredis]>=7.0.1,<8.0.0 ++neo4j>=5.20.0,<6.0.0 + aiokafka>=0.13.0,<0.14.0 + asyncddgs + google-auth +``` + +--- + +## Deleted files + +These v2 single-graph artifacts are removed in favor of the v4 dual-graph design. Full deletion diffs are in `v4_graph_memory.patch`. + +- `mirix/orm/graph_memory.py` +- `mirix/services/graph_memory_manager.py` diff --git a/docs/graph_memory_v6/README.md b/docs/graph_memory_v6/README.md new file mode 100644 index 000000000..e3be8b227 --- /dev/null +++ b/docs/graph_memory_v6/README.md @@ -0,0 +1,257 @@ +# v6 Graph Memory Revision Notes + +Lean entity-index graph memory for MIRIX. v6 keeps Neo4j as a lightweight +connector between extracted entities and PostgreSQL flat memory rows, rather +than storing full memory content or dense relation descriptions in the graph. + +## Related Docs + +- `docs/graph_memory_v2.md` - original PostgreSQL graph-memory layer. +- `docs/graph_memory_v4/README.md` - LightRAG-style dual graph patch summary. +- `docs/graph_memory_v4/v4_graph_memory.md` - full v4 source/diff archive. +- `docs/graph_memory_v7/README.md` - minimal semantic+episodic linkage graph + revision that keeps details in flat memory. + +This file records the v6 revision and the LongMemEval-S smoke run. It is not a +replacement for the v4 docs; it is meant to make v6 comparable against earlier +graph designs. + +## Design + +v6 treats the graph as an inverted index: + +1. Store entity nodes in Neo4j. +2. Link those entities to episodic and semantic flat-memory rows. +3. Retrieve by entity-name vector search plus one-hop co-occurrence expansion. +4. Fetch full episodic/semantic details from PostgreSQL. +5. Format the graph-linked flat details into the QA prompt. + +The important invariant is that Neo4j should improve linking and recall, while +PostgreSQL remains the source of detailed memory content. + +## Current Graph Shape + +The LongMemEval-S v6 graph currently uses this shape: + +```text +(:V6Entity)-[:APPEARS_IN]->(:V6MemoryRef:V6EpisodeRef {id: "episodic:"}) +(:V6Entity)-[:DESCRIBED_BY]->(:V6MemoryRef:V6ConceptRef {id: "semantic:"}) +``` + +`V6_COOCCUR` is part of the lean-index design, but this LongMem-S snapshot has +0 co-occurrence edges. The generated visualizations therefore show the actual +entity-to-memory-ref bipartite graph. + +Older v6 code also expected this property-array shape: + +```text +(:V6Entity {episodic_ids: [], semantic_ids: []}) +``` + +The retriever now accepts both shapes so older runs and current `V6MemoryRef` +runs can be compared without rebuilding the graph. + +## Visualizations + +Generated from the current `longmem_s_0` v6 Neo4j graph: + +- [Interactive HTML index](visualizations/index.html) +- [Overview: top-degree entities](visualizations/overview_top_entities.svg) +- [Aquarium / fish detail](visualizations/topic_aquarium.svg) +- [Career / campaign work detail](visualizations/topic_career.svg) +- [Fitness / health devices detail](visualizations/topic_fitness.svg) +- [Travel / places detail](visualizations/topic_travel.svg) + +The full graph has 7,281 nodes and 14,125 edges, so these are sampled views. +Entity nodes are on the left; `V6MemoryRef` nodes are on the right. Yellow +memory nodes are episodic refs, blue memory nodes are semantic refs. Blue edges +mean `APPEARS_IN`; purple edges mean `DESCRIBED_BY`. + +Regenerate with: + +```bash +set -a; source /Users/weichiehhuang/MIRIX_eval/.env; set +a +/Users/weichiehhuang/MIRIX_eval/.venv/bin/python scripts/visualize_v6_graph.py \ + --user-id longmem_s_0 \ + --out-dir docs/graph_memory_v6/visualizations +``` + +## LongMemEval-S Run + +Run date: 2026-06-18 +Dataset: LongMemEval-S, `longmem_s_0` +Scope: 1 conversation, 534 chunks, 60 QA +Mode: graph enabled, `MIRIX_GRAPH_VERSION=v6` +Result folder: +`evals/results/longmem/longmemS_gmemS_v6_60qa_judge_clean/` + +Stored memory size after ingest: + +| Store | Count | Stored chars | +|---|---:|---:| +| Episodic flat memory | 702 rows | 417,922 | +| Semantic flat memory | 566 rows | 372,347 | +| Neo4j graph | 7,281 nodes / 14,125 edges | 742,271 | + +QA runtime for the 60 questions: + +| Phase | Total sec | Avg sec | +|---|---:|---:| +| Retrieval / prompt wrap | 157.51 | 2.63 | +| Answer generation | 240.79 | 4.01 | +| Total QA wall time | 398.30 | 6.64 | + +## Judge Result + +Judge model: `gpt-4o-mini` via `evals/organize_results.py` / `evals/llm_judge.py`. + +| Category | n | Correct | Accuracy | +|---|---:|---:|---:| +| knowledge-update | 9 | 7 | 77.78% | +| multi-session | 15 | 9 | 60.00% | +| single-session-assistant | 6 | 6 | 100.00% | +| single-session-preference | 6 | 4 | 66.67% | +| single-session-user | 9 | 7 | 77.78% | +| temporal-reasoning | 15 | 10 | 66.67% | +| **Overall** | **60** | **43** | **71.67%** | + +Metrics file: +`evals/results/longmem/longmemS_gmemS_v6_60qa_judge_clean/metrics.json` + +## Cross-Run Comparison + +Timing columns use eval JSON timings: + +- `Ingest min` = `timings.add_chunk` total. +- `QA min` = `timings.wrap_user_prompt + timings.answer`. +- `Retrieve min` = `timings.wrap_user_prompt`. +- `Answer min` = `timings.answer`. + +Node/edge columns come from `memory_stats.graph` when the run recorded it. +Rows with different chunk counts are useful directional references, but the +closest apples-to-apples LongMem comparison is `Small-chunk graph` vs `v6 +gmemS` because both use 534 chunks and the same 60 QA set. + +### LongMemEval-S `longmem_s_0`, 60 QA + +| Setting | Chunks | Correct | Accuracy | Nodes | Edges | Graph chars | Ingest min | QA min | Retrieve min | Answer min | +|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:| +| No graph full | 391 | 39/60 | 65.00% | 0 | 0 | 0 | 135.3 | 4.72 | 1.23 | 3.49 | +| Graph full | 391 | 36/60 | 60.00% | 8,590 | 21,635 | 2,155,921 | 485.3 | 6.95 | 3.19 | 3.76 | +| Graph v2 session chunk | 111 | 38/60 | 63.33% | 11,218 | 28,646 | 2,643,620 | 344.2 | 8.36 | 3.79 | 4.56 | +| Graph v2 recency 0.3 | 111 | 38/60 | 63.33% | 11,218 | 28,646 | 2,643,620 | 344.2 | 9.46 | 4.28 | 5.18 | +| Small-chunk graph | 534 | 44/60 | 73.33% | 9,130 | 23,658 | 2,361,276 | 444.8 | 8.15 | 3.62 | 4.53 | +| v6 gmemS | 534 | 43/60 | 71.67% | 7,281 | 14,125 | 742,271 | resume | 6.64 | 2.63 | 4.01 | + +Against the closest 534-chunk baseline (`Small-chunk graph`), v6 is slightly +lower on accuracy (-1/60, -1.67 pp) but much smaller and faster at QA: + +| Delta: v6 vs small-chunk graph | Value | +|---|---:| +| Accuracy | -1.67 pp | +| Nodes | -20.3% | +| Edges | -40.3% | +| Graph stored chars | -68.6% | +| QA time | -18.5% | +| Retrieval / prompt-wrap time | -27.4% | +| Answer time | -11.4% | + +Size deltas against older graph settings: + +| Delta: v6 vs | Nodes | Edges | Graph chars | +|---|---:|---:|---:| +| Graph full | -15.2% | -34.7% | -65.6% | +| Graph v2 session chunk | -35.1% | -50.7% | -71.9% | +| Small-chunk graph | -20.3% | -40.3% | -68.6% | + +### LoCoMo `conv-26`, Historical Runs + +These results are the available single-conversation LoCoMo metrics from +`evals/results/locomo`. They have accuracy and timing, but those older result +JSON files did not record graph node/edge counts, and the live Neo4j database +has since been reused for other runs. + +| Run | Correct | Accuracy | Nodes | Edges | Ingest min | QA min | Retrieve min | Answer min | Avg answer | +|---|---:|---:|---:|---:|---:|---:|---:|---:|---:| +| 0201c no graph | 133/152 | 87.50% | -- | -- | 9.9 | 7.17 | 0.25 | 6.92 | 2.70s | +| 0201c_graph_v5 | 137/152 | 90.13% | -- | -- | 42.2 | 12.75 | 0.06 | 12.69 | 4.94s | +| 0201c_graph_v6 | 134/152 | 88.16% | -- | -- | 36.2 | 10.55 | 0.10 | 10.44 | 4.07s | +| 0201c_graph_v7_run1 | 112/152 | 73.68% | -- | -- | 16.4 | 11.78 | 3.91 | 7.86 | 3.06s | +| 0201c_v5_promptfix_graph | 127/152 | 83.55% | -- | -- | 32.5 | 17.21 | 7.76 | 9.45 | 3.68s | +| 0201c_v5_triggerfix | 130/152 | 85.53% | -- | -- | 8.7 | 12.69 | 3.34 | 9.35 | 3.64s | +| early graph | 129/152 | 84.87% | -- | -- | 29.5 | 18.83 | 9.72 | 9.11 | 3.55s | +| graph_v5_premerge_backup | 128/152 | 84.21% | -- | -- | 30.3 | 17.80 | 7.59 | 10.20 | 3.98s | +| premerge no graph backup | 122/152 | 80.26% | -- | -- | 8.8 | 16.01 | 3.61 | 12.40 | 4.83s | + +### Published v2 LoCoMo Full-Benchmark Result + +`docs/graph_memory_v2.md` records a broader 10-conversation LoCoMo benchmark +(1540 judged questions). That run is not directly comparable to the single +`conv-26` and LongMem smoke runs above, but it provides useful historical +context: + +| Setting | LLM Judge | Wall time | +|---|---:|---:| +| No graph | 54.29% | 2.7 h | +| v2 graph | 57.34% | 3.4 h | +| Delta | +3.05 pp | +42 min (+26%) | + +## Diagnosis From This Run + +The first QA attempt looked graph-only because the retriever returned only +matched entity names: + +```text +## Memory Index (v6) +Matched entities: ... +``` + +That happened for two separate compatibility reasons: + +1. The existing Neo4j graph stored links through `V6MemoryRef` nodes, while the + checked-out v6 retriever only read `V6Entity.episodic_ids` and + `V6Entity.semantic_ids`. +2. Flat fallback search hit PostgreSQL schema drift: the current ORM selected + `source_refs` / `prior_values`, but the existing `mirix_v6_longmems` + database did not have those columns yet. + +Fixes applied: + +- `mirix/services/graph_retriever_v6.py` now collects backrefs from both + `V6MemoryRef` edges and legacy entity arrays, strips `episodic:` / + `semantic:` prefixes, then fetches the matching PG rows. +- `evals/longmem_eval.py` now measures PostgreSQL flat memory using + `MIRIX_PG_DB`, `MIRIX_PG_HOST`, `MIRIX_PG_PORT`, `MIRIX_PG_USER`, and + `MIRIX_PG_PASSWORD` instead of hard-coding database `mirix`. +- `mirix_v6_longmems` was migrated with empty JSONB defaults for + `episodic_memory.source_refs`, `semantic_memory.source_refs`, and + `semantic_memory.prior_values`. + +After the retriever fix, QA prompts included both entity matches and full +episodic / semantic flat details. + +## Error Pattern + +The remaining 17 judged wrong answers are mostly not linkage failures. They +cluster around: + +- multi-session counting and aggregation, such as fish count, fitness classes, + health devices, jewelry count, and current-role duration; +- temporal comparison or ordering, such as seed-start order, phone case versus + charger, Valentine's Day airline, Yosemite trip length, and January sports + event order; +- single conflicting fact retrieval, such as handbag amount, farmers market + earning, and internet-plan speed. + +This suggests the v6 graph-to-flat path is usable, but the next revision should +focus on ranking enough linked details for aggregation and adding stronger +temporal/negative-evidence handling. + +## Notes + +- The original result folder also contains + `longmem_s_0.graph_only_bad24.json`, a partial 24-question run from before + the retriever compatibility fix. It is intentionally excluded from the clean + judge folder. +- `metrics_contaminated_with_bad24.json` in the resume folder should not be + used for reporting; it included the partial graph-only run. diff --git a/docs/graph_memory_v6/visualizations/index.html b/docs/graph_memory_v6/visualizations/index.html new file mode 100644 index 000000000..486e33794 --- /dev/null +++ b/docs/graph_memory_v6/visualizations/index.html @@ -0,0 +1,54 @@ + + + + + v6 LongMem-S Graph Visualizations + + + +

v6 LongMem-S Graph Visualizations

+

+ Sampled from user_id=longmem_s_0. The full graph has + 7,281 nodes and 14,125 edges; these views show readable slices. + Entity nodes link to V6MemoryRef nodes, which point back to PostgreSQL + episodic and semantic flat memory. +

+ +
+

Overview: Top-Degree Entities

+

130 nodes, 112 sampled edges

+ +
+ +
+

Aquarium / Fish Detail

+

35 nodes, 50 sampled edges

+ +
+ +
+

Career / Campaign Work Detail

+

45 nodes, 50 sampled edges

+ +
+ +
+

Fitness / Health Devices Detail

+

65 nodes, 65 sampled edges

+ +
+ +
+

Travel / Places Detail

+

55 nodes, 56 sampled edges

+ +
+ + + diff --git a/docs/graph_memory_v6/visualizations/overview_top_entities.dot b/docs/graph_memory_v6/visualizations/overview_top_entities.dot new file mode 100644 index 000000000..13df7cbc7 --- /dev/null +++ b/docs/graph_memory_v6/visualizations/overview_top_entities.dot @@ -0,0 +1,263 @@ +digraph G { + graph [rankdir=LR, bgcolor="white", splines=true, overlap=false, nodesep=0.35, ranksep=1.0]; + node [fontname="Helvetica", fontsize=10, style="filled,rounded", penwidth=1.2]; + edge [fontname="Helvetica", fontsize=8, color="#9aa4b2", arrowsize=0.55]; + label="Overview: Top-Degree Entities"; + labelloc="t"; + fontsize=20; + + subgraph cluster_entities { + label="V6Entity"; + color="#d8dee9"; + style="rounded"; + e_v6ent_f83bb95d8b714cd295e32961 [shape=ellipse, fillcolor="#ffe7bf", color="#bf7a12", label="American Airlines\\n(Organization, d=25)"]; + e_v6ent_3ac21b67bc6f4ebeab5cd810 [shape=ellipse, fillcolor="#eee4ff", color="#7c5bc7", label="Sentiment Analysis\\n(Concept, d=24)"]; + e_v6ent_55c79e0244504b8f83c99123 [shape=ellipse, fillcolor="#d8e9ff", color="#3a6ea5", label="Max\\n(Person, d=22)"]; + e_v6ent_702b7dd96e8a4492bf63a345 [shape=ellipse, fillcolor="#eeeeee", color="#777777", label="Social Media\\n(Other, d=22)"]; + e_v6ent_9a8ff8c60d254595b831c76a [shape=ellipse, fillcolor="#eeeeee", color="#777777", label="Luna\\n(Other, d=21)"]; + e_v6ent_c31a6bfa0937438c851644bc [shape=ellipse, fillcolor="#eee4ff", color="#7c5bc7", label="Flexibility\\n(Concept, d=18)"]; + e_v6ent_47972d97c8b14349b997a49f [shape=ellipse, fillcolor="#eee4ff", color="#7c5bc7", label="Hydration\\n(Concept, d=18)"]; + e_v6ent_03abaddd19bc4edfaa88c8fd [shape=ellipse, fillcolor="#dff4df", color="#4a8f4a", label="Los Angeles\\n(Location, d=18)"]; + e_v6ent_ad07057ea8894b489269ea10 [shape=ellipse, fillcolor="#dff4df", color="#4a8f4a", label="Rome\\n(Location, d=16)"]; + e_v6ent_8ea704bb83be494982027727 [shape=ellipse, fillcolor="#ffe7bf", color="#bf7a12", label="Sephora\\n(Organization, d=16)"]; + e_v6ent_3491ea2e65e54d24ba186ea6 [shape=ellipse, fillcolor="#dff4df", color="#4a8f4a", label="Aragón\\n(Location, d=15)"]; + e_v6ent_4337a84045d74e31ada7d53c [shape=ellipse, fillcolor="#ffdcdc", color="#bf4f4f", label="Lemon Lavender Pound\\nCake\\n(Content, d=15)"]; + e_v6ent_935cb6e81424437a82c7329d [shape=ellipse, fillcolor="#eee4ff", color="#7c5bc7", label="Tips\\n(Concept, d=15)"]; + e_v6ent_b1b811d91ca34359b9fff646 [shape=ellipse, fillcolor="#eee4ff", color="#7c5bc7", label="Virtual Coffee Breaks\\n(Concept, d=15)"]; + e_v6ent_bc0caed834694c3083084c2c [shape=ellipse, fillcolor="#ffe7bf", color="#bf7a12", label="spaCy\\n(Organization, d=15)"]; + e_v6ent_b3342708dccf4b69906efa0b [shape=ellipse, fillcolor="#eee4ff", color="#7c5bc7", label="Astrophotography\\n(Concept, d=14)"]; + e_v6ent_9fb40da80dc944a0a499e231 [shape=ellipse, fillcolor="#eee4ff", color="#7c5bc7", label="Fatigue\\n(Concept, d=14)"]; + e_v6ent_b2965f3f5073405d94928874 [shape=ellipse, fillcolor="#ffdcdc", color="#bf4f4f", label="Scythe\\n(Content, d=14)"]; + e_v6ent_865162e16bd6455f9c7f3037 [shape=ellipse, fillcolor="#dff4df", color="#4a8f4a", label="Vatican\\n(Location, d=14)"]; + e_v6ent_eaa3e87ae15f46b5ba01a845 [shape=ellipse, fillcolor="#dff4df", color="#4a8f4a", label="Amsterdam\\n(Location, d=13)"]; + e_v6ent_3b28ae5cd0b34878a885c180 [shape=ellipse, fillcolor="#dff4df", color="#4a8f4a", label="Barcelona\\n(Location, d=13)"]; + e_v6ent_bef3247486b0450f80663818 [shape=ellipse, fillcolor="#ffe7bf", color="#bf7a12", label="Goodreads\\n(Organization, d=13)"]; + e_v6ent_3c13d846a39d4b8d9be4865a [shape=ellipse, fillcolor="#dff4df", color="#4a8f4a", label="Japan\\n(Location, d=13)"]; + e_v6ent_4058cd532f2342e5b8b126ca [shape=ellipse, fillcolor="#dff4df", color="#4a8f4a", label="Natural Park of Moncayo\\nMountain\\n(Location, d=13)"]; + e_v6ent_51f1f33de2d74af78d969a26 [shape=ellipse, fillcolor="#d8e9ff", color="#3a6ea5", label="Parents\\n(Person, d=13)"]; + e_v6ent_b16675464a50408f92503dc2 [shape=ellipse, fillcolor="#eee4ff", color="#7c5bc7", label="BodyPump\\n(Concept, d=12)"]; + e_v6ent_b2297c2449a24ff09839ffb1 [shape=ellipse, fillcolor="#dff4df", color="#4a8f4a", label="Boston\\n(Location, d=12)"]; + e_v6ent_ef707da356434e80a7527335 [shape=ellipse, fillcolor="#eeeeee", color="#777777", label="Brown Sugar\\n(Other, d=12)"]; + } + + subgraph cluster_memories { + label="V6MemoryRef -> PG flat memory"; + color="#d8dee9"; + style="rounded"; + m_episodic_ep_MH3L [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-03-08\\nUser learned about the history and\\nevolution of street art in Los\\nAngeles"]; + m_episodic_ep_OIZ0 [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-03-08\\nUser requested recommendations for\\nLA-based street artists and\\nmuralists"]; + m_episodic_ep_JY48 [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-03-08\\nUser inquired about Shepard Fairey\\nand Retna's exhibitions and murals\\nin Los Angeles"]; + m_episodic_ep_OGUQ [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-03-08\\nUser requested local street art\\nspots and murals in Los Angeles"]; + m_episodic_ep_QXGA [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-25\\nAssistant expressed hope that the\\nuser has a wonderful time at the\\nNatural Park of Moncayo mountain\\nin Aragón."]; + m_episodic_ep_KWWW [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-25\\nUser expressed excitement about\\nplanning a visit to the Natural\\nPark of Moncayo mountain in Aragón\\nand appreciated the\\nrecommendations."]; + m_episodic_ep_UTTI [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-25\\nAssistant recommended travel\\nwebsites for finding rural\\ncottages near the Natural Park of\\nMoncayo mountain in Aragón."]; + m_episodic_ep_A4SQ [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-25\\nUser considered renting a rural\\ncottage near the Natural Park of\\nMoncayo mountain in Aragón and\\nasked for specific\\nrecommendations."]; + m_episodic_ep_HRGA [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-24\\nAssistant congratulates user on\\nPython progress and supports\\nexploring other NLP techniques\\nafter sentiment analysis, with\\ndetailed re..."]; + m_episodic_ep_W5HE [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-24\\nUser reiterates completion of 30\\nvideos of Corey Schafer's Python\\nseries and plans to start\\nsentiment analysis project; asks\\nabout exp..."]; + m_episodic_ep_IPBE [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-24\\nAssistant encourages exploring\\nother NLP techniques like named\\nentity recognition and topic\\nmodeling after sentiment analysis,\\nexplain..."]; + m_episodic_ep_EVJS [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-24\\nUser considers exploring other NLP\\ntechniques like named entity\\nrecognition and topic modeling\\nafter mastering sentiment\\nanalysis."]; + m_episodic_ep_Q2SL [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2024-02-05\\nUser decides to focus on\\nneighborhood exploration and skip\\nday trips on Europe trip"]; + m_episodic_ep_J5OF [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2024-02-05\\nAssistant encourages prioritizing\\nneighborhood exploration in\\nBarcelona and Amsterdam, suggests\\noptional day trips"]; + m_episodic_ep_S6DC [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2024-02-05\\nAssistant recommends itinerary\\nplanning for 4-5 days each in\\nBarcelona and Amsterdam, balancing\\nlandmarks and neighborhood\\nexploration"]; + m_episodic_ep_PPB0 [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2024-02-05\\nAssistant details must-see\\nattractions and neighborhoods in\\nBarcelona and Amsterdam, with\\naccommodation advice"]; + m_episodic_ep_E5XP [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-23\\nUser interested in Japanese\\ncuisine and restaurants to try\\nduring Japan trip."]; + m_episodic_ep_BI7Q [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-23\\nAssistant recommended solo travel\\ndestinations and provided details\\nabout Japan as a solo travel\\ndestination including best seasons\\nan..."]; + m_episodic_ep_I63V [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-23\\nUser interested in solo travel\\ndestinations, mentions fascination\\nwith Japan."]; + m_episodic_ep_SLLG [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-03-03\\nPlanning 7th international trip\\nthis year to Japan in May, booked\\nflights and accommodation"]; + m_episodic_ep_Q4I2 [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-25\\nUser asked for accommodation\\nadvice near the Natural Park of\\nMoncayo mountain in Aragón."]; + m_episodic_ep_SLTB [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-22\\nAssistant encouraged user on cake\\nserving plan and wished a\\nwonderful birthday party."]; + m_episodic_ep_N310 [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-22\\nUser planned to serve lemon\\nlavender pound cake at friend's\\nbirthday party and thanked for\\nstorage tips."]; + m_episodic_ep_U05E [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-22\\nAssistant provided detailed\\nstorage tips and freshness\\nduration for lemon lavender pound\\ncake."]; + m_episodic_ep_K00N [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-22\\nUser asked for storage tips and\\nfreshness duration for lemon\\nlavender pound cake."]; + m_episodic_ep_W2D0 [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2024-02-05\\nUser inquires about promoting\\nhealthy aging and longevity\\ninspired by grandma's energetic\\n75th birthday"]; + m_episodic_ep_Y8BY [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-08-11\\nAssistant provides detailed tips\\non reducing cat Luna's shedding."]; + m_episodic_ep_JXWT [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-25\\nAssistant explained how Laneige\\nTime Freeze Essence works and how\\nto incorporate it into skincare\\nroutine."]; + m_episodic_ep_ZLZ9 [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-23\\nAssistant provided general advice\\non managing fatigue, potential\\ncauses, and chronic sinusitis\\nmanagement tips including nasal\\nsaline..."]; + m_episodic_ep_BUKO [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-10-20\\nUser plans to discuss waiver of\\ninadmissibility process with\\nattorney regarding parents' nine-\\nmonth overstay."]; + m_episodic_ep_S1LA [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-10-20\\nAssistant explained waiver of\\ninadmissibility process,\\neligibility, requirements, and\\napplication in case of parents'\\nnine-month visa..."]; + m_episodic_ep_MFX7 [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-10-20\\nUser inquires about waiver of\\ninadmissibility process and its\\napplication to parents' overstay\\nsituation."]; + m_episodic_ep_L0D1 [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-10-20\\nAssistant provided detailed\\nguidance on points to discuss with\\nattorney about parents' nine-month\\noverstaying visa and green card\\nappl..."]; + m_episodic_ep_CKSD [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-26\\nAssistant provides detailed tips\\nfor introducing the Kong toy to\\nMax effectively and safely."]; + m_episodic_ep_EOXW [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-26\\nUser requests tips on introducing\\nthe Kong toy to Max to ensure he\\nunderstands how to use it."]; + m_episodic_ep_EQU0 [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-26\\nAssistant affirms Kong Classic Dog\\nToy choice and praises new dog bed\\nfor Max."]; + m_episodic_ep_Q37C [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-26\\nUser decides to get the Kong\\nClassic Dog Toy with Holes and\\nmentions Max's new black and brown\\ndog bed from PetSmart."]; + m_episodic_ep_I1GG [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-09-30\\nUser inquired about local\\nbookstore events and book clubs"]; + m_episodic_ep_YR1W [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-09-30\\nUser asked about The Vault thrift\\nstore business hours and events"]; + m_episodic_ep_RSPM [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-09-30\\nUser planned and designed a\\ncustomer loyalty program"]; + m_episodic_ep_E9J3 [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-08-11\\nUser sought reviews for The Vault\\nthrift store and asked for other\\nthrift stores and vintage shops\\nnearby."]; + m_episodic_ep_QKUF [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-21\\nUser asked for general tips for\\nvisiting Rome."]; + m_episodic_ep_BB6N [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-21\\nAssistant explained souvenir shops\\nnear the Vatican and what they\\nsell."]; + m_episodic_ep_THG5 [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-21\\nUser asked about souvenir shops\\naround the Vatican."]; + m_episodic_ep_VT1U [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-21\\nAssistant recommended restaurants\\nnear the Vatican including\\nPizzarium, La Locanda dei\\nGirasoli, Roscioli, and Caffè\\nVaticano."]; + m_episodic_ep_OH6O [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-09-30\\nUser planned mall trip to check\\nsales at H&M and Sephora and shop\\nmore tops"]; + m_episodic_ep_ZO71 [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-25\\nAssistant recommended popular\\nskincare products redeemable with\\nSephora loyalty points, including\\nLaneige Water Bank Moisturizing\\nCrea..."]; + m_episodic_ep_SCO3 [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-25\\nUser earned 200 points in Sephora\\nloyalty program and sought\\nskincare product recommendations\\nto complement eyeshadow purchases."]; + m_episodic_ep_SUVN [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-23\\nAssistant provided current Sephora\\npromotions and discounts relevant\\nto La Roche-Posay moisturizer\\npurchase."]; + m_episodic_ep_MQZN [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-26\\nUser requests tips for proper form\\nand technique for bodyweight\\nsquats and lunges"]; + m_episodic_ep_RK5E [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-25\\nUser requested tips on leading\\nengaging book club discussions."]; + m_episodic_ep_HTLY [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-24\\nAssistant suggests how to\\ncollaboratively introduce virtual\\ncoffee breaks in team meeting"]; + m_episodic_ep_M5SM [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-24\\nUser asks how to bring up virtual\\ncoffee breaks in a team meeting\\nwithout forcing the idea"]; + m_episodic_ep_MBE7 [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-11-30\\nAssistant encourages gradual\\nintroduction and rotation of\\nPetcube Interactive Wall Toy for\\nLuna's enjoyment."]; + m_episodic_ep_O0AF [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-11-30\\nUser excited about getting the\\nPetcube Interactive Wall Toy for\\ncat Luna."]; + m_episodic_ep_PE6N [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-11-30\\nAssistant recommends brands and\\nproducts for interactive climbing\\nwalls and wall-mounted toys for\\ncat Luna."]; + m_episodic_ep_OM4L [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-11-30\\nUser plans to try interactive\\nclimbing walls and wall-mounted\\ntoys for cat Luna's playtime."]; + m_episodic_ep_RPBF [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-25\\nAssistant provided detailed\\nguidance on chronic sinusitis\\nmanagement including nasal sprays,\\nhumidifiers, and addressing\\nfatigue and j..."]; + m_episodic_ep_KZZ9 [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-25\\nUser planned follow-up\\nappointments with Dr. Patel and\\nprimary care physician to discuss\\nsinusitis, UTI, fatigue, and joint\\npain."]; + m_episodic_ep_I8OZ [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-25\\nUser discussed recent UTI and\\nantibiotic treatment and their\\npotential impact on fatigue, joint\\npain, and chronic sinusitis."]; + m_episodic_ep_P5A2 [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-25\\nUser reported fatigue and joint\\npain and inquired about their\\nrelation to chronic sinusitis or\\nother causes."]; + m_episodic_ep_IEUT [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2024-02-05\\nAssistant recommends must-see\\nEuropean destinations and\\nexperiences for travelers in their\\n30s"]; + m_episodic_ep_DSYZ [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-24\\nUser shared solo trip experience\\nto New York City and sought travel\\nbudgeting tips for Europe trip"]; + m_episodic_ep_EOS7 [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-21\\nAssistant recommended popular\\ngelato shops in Rome including\\nGelateria del Teatro, Giolitti,\\nFatamorgana, San Crispino, and La\\nRomana."]; + m_episodic_ep_JN84 [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-21\\nUser asked for gelato shop\\nrecommendations in Rome."]; + m_episodic_ep_DRBH [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-21\\nAssistant provides workout\\nplaylist recommendations, Zumba\\nwarm-up tips, healthy snacks, and\\nmotivation advice"]; + m_episodic_ep_PR3N [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-21\\nUser seeks new workout playlists\\nfor Zumba and BodyPump classes"]; + m_episodic_ep_D4JU [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-20\\nUser requested recommendations for\\nprotein powders suitable for\\nbeginners to support BodyPump\\nweightlifting classes."]; + m_episodic_ep_EKC5 [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-20\\nAssistant provided guidance on\\nincorporating protein shakes into\\nfitness routines, including\\ntiming, protein types, recipes,\\nand BodyP..."]; + m_episodic_ep_U4C2 [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-24\\nAssistant supports setting ground\\nrules for virtual coffee breaks to\\nenhance engagement"]; + m_episodic_ep_M5AJ [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-24\\nUser suggests establishing regular\\nschedule and ground rules for\\nvirtual coffee breaks"]; + m_episodic_ep_AOIY [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-24\\nAssistant outlines potential\\nbenefits and challenges of virtual\\ncoffee breaks"]; + m_episodic_ep_RTWB [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-24\\nAssistant provides tips to\\nfacilitate collaborative\\nconversation and avoid dominating\\ndiscussion"]; + m_episodic_ep_CO6I [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-25\\nUser shares summer concert\\nschedule including Jonas Brothers,\\nThe Lumineers, and recent Imagine\\nDragons concert"]; + m_episodic_ep_OORP [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-25\\nUser shares summer concert\\nschedule including Jonas Brothers,\\nThe Lumineers, and recent Imagine\\nDragons concert"]; + m_episodic_ep_FLJB [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-02-28\\nUser compared American Airlines\\nand Delta in-flight entertainment\\nsystems and shared flight delay\\nexperience with United Airlines"]; + m_episodic_ep_GXTJ [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-02-14\\nUser inquired about travel\\ninsurance options and prices for\\ntrip from Boston to Fort\\nLauderdale"]; + m_episodic_ep_E0BQ [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-30\\nUser asked for snack and drink\\nsuggestions for a Scythe-themed\\ngame night."]; + m_episodic_ep_CUZO [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-30\\nAssistant provided strategies for\\nmaximizing trade opportunities in\\nScythe, emphasizing Nordic\\nKingdom's strengths."]; + m_episodic_ep_P5WH [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-30\\nUser shared their Ticket to Ride\\nhigh score and asked about\\nmaximizing trade opportunities in\\nScythe."]; + m_episodic_ep_JBCM [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-30\\nAssistant explained structure\\nbuilding and trading mechanics in\\nScythe, focusing on the Nordic\\nKingdom."]; + m_episodic_ep_Z4Y0 [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-11-01\\nUser learned about camera sensors\\nand astrophotography camera\\nrecommendations"]; + m_episodic_ep_HSH4 [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-11-01\\nUser attended 3-day photography\\nworkshop and sought explanation on\\ndark frames and bias frames with\\nlens recommendations"]; + m_episodic_ep_XBTD [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-11-01\\nUser learned about image stacking\\nand remote shutter release\\nrecommendations"]; + m_episodic_ep_ONEA [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-11-01\\nUser sought apps, software, and\\ncommunities for astrophotography\\nediting and sharing"]; + m_episodic_ep_TN5Q [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-24\\nAssistant gives detailed step-by-\\nstep instructions for setting up\\nan NLP project environment with\\nPython and common NLP libraries."]; + m_episodic_ep_QQVH [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-24\\nAssistant provides a comprehensive\\nlist of beginner NLP resources\\nincluding courses, tutorials,\\nbooks, and practice projects."]; + m_episodic_ep_H0PP [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-24\\nUser seeks beginner resources and\\ntutorials for NLP, mentions\\nwatching Corey Schafer's Python\\nseries."]; + m_episodic_ep_YRM2 [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-01-15\\nAssistant advised on using\\nGoodreads and physical notebooks\\nfor TBR list tracking and the\\nbenefits of tracking\\nrecommendation sources."]; + m_episodic_ep_LGPJ [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-01-15\\nAssistant suggested methods for\\ntracking book recommendations and\\nTBR lists."]; + m_episodic_ep_KKRR [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-01-15\\nAssistant advised on using both\\nphysical journals and digital\\nplatforms for reading tracking."]; + m_episodic_ep_AZAE [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-01-15\\nAssistant recommended book\\njournals and reading logs options\\nto user."]; + m_episodic_ep_QZ3W [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-25\\nUser requested tips and tools for\\ncreating an effective LinkedIn\\ncontent calendar"]; + m_episodic_ep_VZJS [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-24\\nUser asks about potential benefits\\nand challenges of virtual coffee\\nbreaks"]; + m_episodic_ep_GLP6 [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-23\\nUser seeks help weighing pros and\\ncons of solo travel versus family\\ntravel, inspired by recent Hawaii\\ntrip."]; + m_episodic_ep_IQJW [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-07-01\\nAssistant provided tips for using\\nhickory wood chips effectively for\\nBBQ, including soaking, pairing\\nwith right meats, balancing\\nflavo..."]; + m_episodic_ep_H5OK [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-07-01\\nUser requested and received a\\nKorean-style BBQ marinade recipe\\nand tips"]; + m_episodic_ep_L8EM [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-22\\nAssistant provided updated lemon\\nlavender pound cake recipe with\\nsugar and lemon adjustments for\\ndried lavender buds."]; + m_episodic_ep_ESB9 [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-22\\nUser chose to add dried lavender\\nbuds to the lemon pound cake\\nbatter and asked about sugar and\\nlemon adjustments."]; + m_episodic_ep_U7QR [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-02-20\\nAssistant explained American\\nAirlines baggage insurance\\noptions, including trip insurance\\nand baggage protection policy."]; + m_episodic_ep_O8M1 [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-02-20\\nUser asked about American Airlines\\nbaggage insurance options for\\nchecked bags on a week-long Miami\\ntrip."]; + m_episodic_ep_QKNR [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-02-20\\nAssistant detailed American\\nAirlines baggage delivery\\nguarantee and procedures for\\ndelayed or lost bags."]; + } + + e_v6ent_03abaddd19bc4edfaa88c8fd -> m_episodic_ep_MH3L [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_03abaddd19bc4edfaa88c8fd -> m_episodic_ep_OIZ0 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_03abaddd19bc4edfaa88c8fd -> m_episodic_ep_JY48 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_03abaddd19bc4edfaa88c8fd -> m_episodic_ep_OGUQ [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_3491ea2e65e54d24ba186ea6 -> m_episodic_ep_QXGA [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_3491ea2e65e54d24ba186ea6 -> m_episodic_ep_KWWW [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_3491ea2e65e54d24ba186ea6 -> m_episodic_ep_UTTI [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_3491ea2e65e54d24ba186ea6 -> m_episodic_ep_A4SQ [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_3ac21b67bc6f4ebeab5cd810 -> m_episodic_ep_HRGA [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_3ac21b67bc6f4ebeab5cd810 -> m_episodic_ep_W5HE [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_3ac21b67bc6f4ebeab5cd810 -> m_episodic_ep_IPBE [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_3ac21b67bc6f4ebeab5cd810 -> m_episodic_ep_EVJS [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_3b28ae5cd0b34878a885c180 -> m_episodic_ep_Q2SL [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_3b28ae5cd0b34878a885c180 -> m_episodic_ep_J5OF [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_3b28ae5cd0b34878a885c180 -> m_episodic_ep_S6DC [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_3b28ae5cd0b34878a885c180 -> m_episodic_ep_PPB0 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_3c13d846a39d4b8d9be4865a -> m_episodic_ep_E5XP [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_3c13d846a39d4b8d9be4865a -> m_episodic_ep_BI7Q [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_3c13d846a39d4b8d9be4865a -> m_episodic_ep_I63V [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_3c13d846a39d4b8d9be4865a -> m_episodic_ep_SLLG [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_4058cd532f2342e5b8b126ca -> m_episodic_ep_QXGA [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_4058cd532f2342e5b8b126ca -> m_episodic_ep_KWWW [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_4058cd532f2342e5b8b126ca -> m_episodic_ep_A4SQ [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_4058cd532f2342e5b8b126ca -> m_episodic_ep_Q4I2 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_4337a84045d74e31ada7d53c -> m_episodic_ep_SLTB [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_4337a84045d74e31ada7d53c -> m_episodic_ep_N310 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_4337a84045d74e31ada7d53c -> m_episodic_ep_U05E [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_4337a84045d74e31ada7d53c -> m_episodic_ep_K00N [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_47972d97c8b14349b997a49f -> m_episodic_ep_W2D0 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_47972d97c8b14349b997a49f -> m_episodic_ep_Y8BY [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_47972d97c8b14349b997a49f -> m_episodic_ep_JXWT [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_47972d97c8b14349b997a49f -> m_episodic_ep_ZLZ9 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_51f1f33de2d74af78d969a26 -> m_episodic_ep_BUKO [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_51f1f33de2d74af78d969a26 -> m_episodic_ep_S1LA [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_51f1f33de2d74af78d969a26 -> m_episodic_ep_MFX7 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_51f1f33de2d74af78d969a26 -> m_episodic_ep_L0D1 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_55c79e0244504b8f83c99123 -> m_episodic_ep_CKSD [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_55c79e0244504b8f83c99123 -> m_episodic_ep_EOXW [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_55c79e0244504b8f83c99123 -> m_episodic_ep_EQU0 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_55c79e0244504b8f83c99123 -> m_episodic_ep_Q37C [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_702b7dd96e8a4492bf63a345 -> m_episodic_ep_I1GG [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_702b7dd96e8a4492bf63a345 -> m_episodic_ep_YR1W [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_702b7dd96e8a4492bf63a345 -> m_episodic_ep_RSPM [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_702b7dd96e8a4492bf63a345 -> m_episodic_ep_E9J3 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_865162e16bd6455f9c7f3037 -> m_episodic_ep_QKUF [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_865162e16bd6455f9c7f3037 -> m_episodic_ep_BB6N [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_865162e16bd6455f9c7f3037 -> m_episodic_ep_THG5 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_865162e16bd6455f9c7f3037 -> m_episodic_ep_VT1U [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_8ea704bb83be494982027727 -> m_episodic_ep_OH6O [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_8ea704bb83be494982027727 -> m_episodic_ep_ZO71 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_8ea704bb83be494982027727 -> m_episodic_ep_SCO3 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_8ea704bb83be494982027727 -> m_episodic_ep_SUVN [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_935cb6e81424437a82c7329d -> m_episodic_ep_MQZN [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_935cb6e81424437a82c7329d -> m_episodic_ep_RK5E [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_935cb6e81424437a82c7329d -> m_episodic_ep_HTLY [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_935cb6e81424437a82c7329d -> m_episodic_ep_M5SM [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_9a8ff8c60d254595b831c76a -> m_episodic_ep_MBE7 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_9a8ff8c60d254595b831c76a -> m_episodic_ep_O0AF [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_9a8ff8c60d254595b831c76a -> m_episodic_ep_PE6N [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_9a8ff8c60d254595b831c76a -> m_episodic_ep_OM4L [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_9fb40da80dc944a0a499e231 -> m_episodic_ep_RPBF [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_9fb40da80dc944a0a499e231 -> m_episodic_ep_KZZ9 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_9fb40da80dc944a0a499e231 -> m_episodic_ep_I8OZ [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_9fb40da80dc944a0a499e231 -> m_episodic_ep_P5A2 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_ad07057ea8894b489269ea10 -> m_episodic_ep_IEUT [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_ad07057ea8894b489269ea10 -> m_episodic_ep_DSYZ [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_ad07057ea8894b489269ea10 -> m_episodic_ep_EOS7 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_ad07057ea8894b489269ea10 -> m_episodic_ep_JN84 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_b16675464a50408f92503dc2 -> m_episodic_ep_DRBH [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_b16675464a50408f92503dc2 -> m_episodic_ep_PR3N [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_b16675464a50408f92503dc2 -> m_episodic_ep_D4JU [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_b16675464a50408f92503dc2 -> m_episodic_ep_EKC5 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_b1b811d91ca34359b9fff646 -> m_episodic_ep_U4C2 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_b1b811d91ca34359b9fff646 -> m_episodic_ep_M5AJ [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_b1b811d91ca34359b9fff646 -> m_episodic_ep_AOIY [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_b1b811d91ca34359b9fff646 -> m_episodic_ep_RTWB [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_b2297c2449a24ff09839ffb1 -> m_episodic_ep_CO6I [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_b2297c2449a24ff09839ffb1 -> m_episodic_ep_OORP [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_b2297c2449a24ff09839ffb1 -> m_episodic_ep_FLJB [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_b2297c2449a24ff09839ffb1 -> m_episodic_ep_GXTJ [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_b2965f3f5073405d94928874 -> m_episodic_ep_E0BQ [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_b2965f3f5073405d94928874 -> m_episodic_ep_CUZO [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_b2965f3f5073405d94928874 -> m_episodic_ep_P5WH [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_b2965f3f5073405d94928874 -> m_episodic_ep_JBCM [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_b3342708dccf4b69906efa0b -> m_episodic_ep_Z4Y0 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_b3342708dccf4b69906efa0b -> m_episodic_ep_HSH4 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_b3342708dccf4b69906efa0b -> m_episodic_ep_XBTD [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_b3342708dccf4b69906efa0b -> m_episodic_ep_ONEA [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_bc0caed834694c3083084c2c -> m_episodic_ep_IPBE [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_bc0caed834694c3083084c2c -> m_episodic_ep_TN5Q [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_bc0caed834694c3083084c2c -> m_episodic_ep_QQVH [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_bc0caed834694c3083084c2c -> m_episodic_ep_H0PP [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_bef3247486b0450f80663818 -> m_episodic_ep_YRM2 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_bef3247486b0450f80663818 -> m_episodic_ep_LGPJ [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_bef3247486b0450f80663818 -> m_episodic_ep_KKRR [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_bef3247486b0450f80663818 -> m_episodic_ep_AZAE [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_c31a6bfa0937438c851644bc -> m_episodic_ep_QZ3W [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_c31a6bfa0937438c851644bc -> m_episodic_ep_AOIY [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_c31a6bfa0937438c851644bc -> m_episodic_ep_VZJS [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_c31a6bfa0937438c851644bc -> m_episodic_ep_GLP6 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_eaa3e87ae15f46b5ba01a845 -> m_episodic_ep_Q2SL [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_eaa3e87ae15f46b5ba01a845 -> m_episodic_ep_J5OF [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_eaa3e87ae15f46b5ba01a845 -> m_episodic_ep_S6DC [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_eaa3e87ae15f46b5ba01a845 -> m_episodic_ep_PPB0 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_ef707da356434e80a7527335 -> m_episodic_ep_IQJW [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_ef707da356434e80a7527335 -> m_episodic_ep_H5OK [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_ef707da356434e80a7527335 -> m_episodic_ep_L8EM [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_ef707da356434e80a7527335 -> m_episodic_ep_ESB9 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_f83bb95d8b714cd295e32961 -> m_episodic_ep_FLJB [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_f83bb95d8b714cd295e32961 -> m_episodic_ep_U7QR [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_f83bb95d8b714cd295e32961 -> m_episodic_ep_O8M1 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_f83bb95d8b714cd295e32961 -> m_episodic_ep_QKNR [label="APPEARS_IN", color="#5f8dd3"]; +} \ No newline at end of file diff --git a/docs/graph_memory_v6/visualizations/overview_top_entities.png b/docs/graph_memory_v6/visualizations/overview_top_entities.png new file mode 100644 index 000000000..f9a76b3aa Binary files /dev/null and b/docs/graph_memory_v6/visualizations/overview_top_entities.png differ diff --git a/docs/graph_memory_v6/visualizations/overview_top_entities.svg b/docs/graph_memory_v6/visualizations/overview_top_entities.svg new file mode 100644 index 000000000..a469fe2ac --- /dev/null +++ b/docs/graph_memory_v6/visualizations/overview_top_entities.svg @@ -0,0 +1,1588 @@ + + + + + + +G + +Overview: Top-Degree Entities + +cluster_entities + +V6Entity + + +cluster_memories + +V6MemoryRef -> PG flat memory + + + +e_v6ent_f83bb95d8b714cd295e32961 + +American Airlines\n(Organization, d=25) + + + +m_episodic_ep_FLJB + +E: 2023-02-28\nUser compared American Airlines\nand Delta in-flight entertainment\nsystems and shared flight delay\nexperience with United Airlines + + + +e_v6ent_f83bb95d8b714cd295e32961->m_episodic_ep_FLJB + + +APPEARS_IN + + + +m_episodic_ep_U7QR + +E: 2023-02-20\nAssistant explained American\nAirlines baggage insurance\noptions, including trip insurance\nand baggage protection policy. + + + +e_v6ent_f83bb95d8b714cd295e32961->m_episodic_ep_U7QR + + +APPEARS_IN + + + +m_episodic_ep_O8M1 + +E: 2023-02-20\nUser asked about American Airlines\nbaggage insurance options for\nchecked bags on a week-long Miami\ntrip. + + + +e_v6ent_f83bb95d8b714cd295e32961->m_episodic_ep_O8M1 + + +APPEARS_IN + + + +m_episodic_ep_QKNR + +E: 2023-02-20\nAssistant detailed American\nAirlines baggage delivery\nguarantee and procedures for\ndelayed or lost bags. + + + +e_v6ent_f83bb95d8b714cd295e32961->m_episodic_ep_QKNR + + +APPEARS_IN + + + +e_v6ent_3ac21b67bc6f4ebeab5cd810 + +Sentiment Analysis\n(Concept, d=24) + + + +m_episodic_ep_HRGA + +E: 2023-05-24\nAssistant congratulates user on\nPython progress and supports\nexploring other NLP techniques\nafter sentiment analysis, with\ndetailed re... + + + +e_v6ent_3ac21b67bc6f4ebeab5cd810->m_episodic_ep_HRGA + + +APPEARS_IN + + + +m_episodic_ep_W5HE + +E: 2023-05-24\nUser reiterates completion of 30\nvideos of Corey Schafer's Python\nseries and plans to start\nsentiment analysis project; asks\nabout exp... + + + +e_v6ent_3ac21b67bc6f4ebeab5cd810->m_episodic_ep_W5HE + + +APPEARS_IN + + + +m_episodic_ep_IPBE + +E: 2023-05-24\nAssistant encourages exploring\nother NLP techniques like named\nentity recognition and topic\nmodeling after sentiment analysis,\nexplain... + + + +e_v6ent_3ac21b67bc6f4ebeab5cd810->m_episodic_ep_IPBE + + +APPEARS_IN + + + +m_episodic_ep_EVJS + +E: 2023-05-24\nUser considers exploring other NLP\ntechniques like named entity\nrecognition and topic modeling\nafter mastering sentiment\nanalysis. + + + +e_v6ent_3ac21b67bc6f4ebeab5cd810->m_episodic_ep_EVJS + + +APPEARS_IN + + + +e_v6ent_55c79e0244504b8f83c99123 + +Max\n(Person, d=22) + + + +m_episodic_ep_CKSD + +E: 2023-05-26\nAssistant provides detailed tips\nfor introducing the Kong toy to\nMax effectively and safely. + + + +e_v6ent_55c79e0244504b8f83c99123->m_episodic_ep_CKSD + + +APPEARS_IN + + + +m_episodic_ep_EOXW + +E: 2023-05-26\nUser requests tips on introducing\nthe Kong toy to Max to ensure he\nunderstands how to use it. + + + +e_v6ent_55c79e0244504b8f83c99123->m_episodic_ep_EOXW + + +APPEARS_IN + + + +m_episodic_ep_EQU0 + +E: 2023-05-26\nAssistant affirms Kong Classic Dog\nToy choice and praises new dog bed\nfor Max. + + + +e_v6ent_55c79e0244504b8f83c99123->m_episodic_ep_EQU0 + + +APPEARS_IN + + + +m_episodic_ep_Q37C + +E: 2023-05-26\nUser decides to get the Kong\nClassic Dog Toy with Holes and\nmentions Max's new black and brown\ndog bed from PetSmart. + + + +e_v6ent_55c79e0244504b8f83c99123->m_episodic_ep_Q37C + + +APPEARS_IN + + + +e_v6ent_702b7dd96e8a4492bf63a345 + +Social Media\n(Other, d=22) + + + +m_episodic_ep_I1GG + +E: 2023-09-30\nUser inquired about local\nbookstore events and book clubs + + + +e_v6ent_702b7dd96e8a4492bf63a345->m_episodic_ep_I1GG + + +APPEARS_IN + + + +m_episodic_ep_YR1W + +E: 2023-09-30\nUser asked about The Vault thrift\nstore business hours and events + + + +e_v6ent_702b7dd96e8a4492bf63a345->m_episodic_ep_YR1W + + +APPEARS_IN + + + +m_episodic_ep_RSPM + +E: 2023-09-30\nUser planned and designed a\ncustomer loyalty program + + + +e_v6ent_702b7dd96e8a4492bf63a345->m_episodic_ep_RSPM + + +APPEARS_IN + + + +m_episodic_ep_E9J3 + +E: 2023-08-11\nUser sought reviews for The Vault\nthrift store and asked for other\nthrift stores and vintage shops\nnearby. + + + +e_v6ent_702b7dd96e8a4492bf63a345->m_episodic_ep_E9J3 + + +APPEARS_IN + + + +e_v6ent_9a8ff8c60d254595b831c76a + +Luna\n(Other, d=21) + + + +m_episodic_ep_MBE7 + +E: 2023-11-30\nAssistant encourages gradual\nintroduction and rotation of\nPetcube Interactive Wall Toy for\nLuna's enjoyment. + + + +e_v6ent_9a8ff8c60d254595b831c76a->m_episodic_ep_MBE7 + + +APPEARS_IN + + + +m_episodic_ep_O0AF + +E: 2023-11-30\nUser excited about getting the\nPetcube Interactive Wall Toy for\ncat Luna. + + + +e_v6ent_9a8ff8c60d254595b831c76a->m_episodic_ep_O0AF + + +APPEARS_IN + + + +m_episodic_ep_PE6N + +E: 2023-11-30\nAssistant recommends brands and\nproducts for interactive climbing\nwalls and wall-mounted toys for\ncat Luna. + + + +e_v6ent_9a8ff8c60d254595b831c76a->m_episodic_ep_PE6N + + +APPEARS_IN + + + +m_episodic_ep_OM4L + +E: 2023-11-30\nUser plans to try interactive\nclimbing walls and wall-mounted\ntoys for cat Luna's playtime. + + + +e_v6ent_9a8ff8c60d254595b831c76a->m_episodic_ep_OM4L + + +APPEARS_IN + + + +e_v6ent_c31a6bfa0937438c851644bc + +Flexibility\n(Concept, d=18) + + + +m_episodic_ep_AOIY + +E: 2023-05-24\nAssistant outlines potential\nbenefits and challenges of virtual\ncoffee breaks + + + +e_v6ent_c31a6bfa0937438c851644bc->m_episodic_ep_AOIY + + +APPEARS_IN + + + +m_episodic_ep_QZ3W + +E: 2023-05-25\nUser requested tips and tools for\ncreating an effective LinkedIn\ncontent calendar + + + +e_v6ent_c31a6bfa0937438c851644bc->m_episodic_ep_QZ3W + + +APPEARS_IN + + + +m_episodic_ep_VZJS + +E: 2023-05-24\nUser asks about potential benefits\nand challenges of virtual coffee\nbreaks + + + +e_v6ent_c31a6bfa0937438c851644bc->m_episodic_ep_VZJS + + +APPEARS_IN + + + +m_episodic_ep_GLP6 + +E: 2023-05-23\nUser seeks help weighing pros and\ncons of solo travel versus family\ntravel, inspired by recent Hawaii\ntrip. + + + +e_v6ent_c31a6bfa0937438c851644bc->m_episodic_ep_GLP6 + + +APPEARS_IN + + + +e_v6ent_47972d97c8b14349b997a49f + +Hydration\n(Concept, d=18) + + + +m_episodic_ep_W2D0 + +E: 2024-02-05\nUser inquires about promoting\nhealthy aging and longevity\ninspired by grandma's energetic\n75th birthday + + + +e_v6ent_47972d97c8b14349b997a49f->m_episodic_ep_W2D0 + + +APPEARS_IN + + + +m_episodic_ep_Y8BY + +E: 2023-08-11\nAssistant provides detailed tips\non reducing cat Luna's shedding. + + + +e_v6ent_47972d97c8b14349b997a49f->m_episodic_ep_Y8BY + + +APPEARS_IN + + + +m_episodic_ep_JXWT + +E: 2023-05-25\nAssistant explained how Laneige\nTime Freeze Essence works and how\nto incorporate it into skincare\nroutine. + + + +e_v6ent_47972d97c8b14349b997a49f->m_episodic_ep_JXWT + + +APPEARS_IN + + + +m_episodic_ep_ZLZ9 + +E: 2023-05-23\nAssistant provided general advice\non managing fatigue, potential\ncauses, and chronic sinusitis\nmanagement tips including nasal\nsaline... + + + +e_v6ent_47972d97c8b14349b997a49f->m_episodic_ep_ZLZ9 + + +APPEARS_IN + + + +e_v6ent_03abaddd19bc4edfaa88c8fd + +Los Angeles\n(Location, d=18) + + + +m_episodic_ep_MH3L + +E: 2023-03-08\nUser learned about the history and\nevolution of street art in Los\nAngeles + + + +e_v6ent_03abaddd19bc4edfaa88c8fd->m_episodic_ep_MH3L + + +APPEARS_IN + + + +m_episodic_ep_OIZ0 + +E: 2023-03-08\nUser requested recommendations for\nLA-based street artists and\nmuralists + + + +e_v6ent_03abaddd19bc4edfaa88c8fd->m_episodic_ep_OIZ0 + + +APPEARS_IN + + + +m_episodic_ep_JY48 + +E: 2023-03-08\nUser inquired about Shepard Fairey\nand Retna's exhibitions and murals\nin Los Angeles + + + +e_v6ent_03abaddd19bc4edfaa88c8fd->m_episodic_ep_JY48 + + +APPEARS_IN + + + +m_episodic_ep_OGUQ + +E: 2023-03-08\nUser requested local street art\nspots and murals in Los Angeles + + + +e_v6ent_03abaddd19bc4edfaa88c8fd->m_episodic_ep_OGUQ + + +APPEARS_IN + + + +e_v6ent_ad07057ea8894b489269ea10 + +Rome\n(Location, d=16) + + + +m_episodic_ep_IEUT + +E: 2024-02-05\nAssistant recommends must-see\nEuropean destinations and\nexperiences for travelers in their\n30s + + + +e_v6ent_ad07057ea8894b489269ea10->m_episodic_ep_IEUT + + +APPEARS_IN + + + +m_episodic_ep_DSYZ + +E: 2023-05-24\nUser shared solo trip experience\nto New York City and sought travel\nbudgeting tips for Europe trip + + + +e_v6ent_ad07057ea8894b489269ea10->m_episodic_ep_DSYZ + + +APPEARS_IN + + + +m_episodic_ep_EOS7 + +E: 2023-05-21\nAssistant recommended popular\ngelato shops in Rome including\nGelateria del Teatro, Giolitti,\nFatamorgana, San Crispino, and La\nRomana. + + + +e_v6ent_ad07057ea8894b489269ea10->m_episodic_ep_EOS7 + + +APPEARS_IN + + + +m_episodic_ep_JN84 + +E: 2023-05-21\nUser asked for gelato shop\nrecommendations in Rome. + + + +e_v6ent_ad07057ea8894b489269ea10->m_episodic_ep_JN84 + + +APPEARS_IN + + + +e_v6ent_8ea704bb83be494982027727 + +Sephora\n(Organization, d=16) + + + +m_episodic_ep_OH6O + +E: 2023-09-30\nUser planned mall trip to check\nsales at H&M and Sephora and shop\nmore tops + + + +e_v6ent_8ea704bb83be494982027727->m_episodic_ep_OH6O + + +APPEARS_IN + + + +m_episodic_ep_ZO71 + +E: 2023-05-25\nAssistant recommended popular\nskincare products redeemable with\nSephora loyalty points, including\nLaneige Water Bank Moisturizing\nCrea... + + + +e_v6ent_8ea704bb83be494982027727->m_episodic_ep_ZO71 + + +APPEARS_IN + + + +m_episodic_ep_SCO3 + +E: 2023-05-25\nUser earned 200 points in Sephora\nloyalty program and sought\nskincare product recommendations\nto complement eyeshadow purchases. + + + +e_v6ent_8ea704bb83be494982027727->m_episodic_ep_SCO3 + + +APPEARS_IN + + + +m_episodic_ep_SUVN + +E: 2023-05-23\nAssistant provided current Sephora\npromotions and discounts relevant\nto La Roche-Posay moisturizer\npurchase. + + + +e_v6ent_8ea704bb83be494982027727->m_episodic_ep_SUVN + + +APPEARS_IN + + + +e_v6ent_3491ea2e65e54d24ba186ea6 + +Aragón\n(Location, d=15) + + + +m_episodic_ep_QXGA + +E: 2023-05-25\nAssistant expressed hope that the\nuser has a wonderful time at the\nNatural Park of Moncayo mountain\nin Aragón. + + + +e_v6ent_3491ea2e65e54d24ba186ea6->m_episodic_ep_QXGA + + +APPEARS_IN + + + +m_episodic_ep_KWWW + +E: 2023-05-25\nUser expressed excitement about\nplanning a visit to the Natural\nPark of Moncayo mountain in Aragón\nand appreciated the\nrecommendations. + + + +e_v6ent_3491ea2e65e54d24ba186ea6->m_episodic_ep_KWWW + + +APPEARS_IN + + + +m_episodic_ep_UTTI + +E: 2023-05-25\nAssistant recommended travel\nwebsites for finding rural\ncottages near the Natural Park of\nMoncayo mountain in Aragón. + + + +e_v6ent_3491ea2e65e54d24ba186ea6->m_episodic_ep_UTTI + + +APPEARS_IN + + + +m_episodic_ep_A4SQ + +E: 2023-05-25\nUser considered renting a rural\ncottage near the Natural Park of\nMoncayo mountain in Aragón and\nasked for specific\nrecommendations. + + + +e_v6ent_3491ea2e65e54d24ba186ea6->m_episodic_ep_A4SQ + + +APPEARS_IN + + + +e_v6ent_4337a84045d74e31ada7d53c + +Lemon Lavender Pound\nCake\n(Content, d=15) + + + +m_episodic_ep_SLTB + +E: 2023-05-22\nAssistant encouraged user on cake\nserving plan and wished a\nwonderful birthday party. + + + +e_v6ent_4337a84045d74e31ada7d53c->m_episodic_ep_SLTB + + +APPEARS_IN + + + +m_episodic_ep_N310 + +E: 2023-05-22\nUser planned to serve lemon\nlavender pound cake at friend's\nbirthday party and thanked for\nstorage tips. + + + +e_v6ent_4337a84045d74e31ada7d53c->m_episodic_ep_N310 + + +APPEARS_IN + + + +m_episodic_ep_U05E + +E: 2023-05-22\nAssistant provided detailed\nstorage tips and freshness\nduration for lemon lavender pound\ncake. + + + +e_v6ent_4337a84045d74e31ada7d53c->m_episodic_ep_U05E + + +APPEARS_IN + + + +m_episodic_ep_K00N + +E: 2023-05-22\nUser asked for storage tips and\nfreshness duration for lemon\nlavender pound cake. + + + +e_v6ent_4337a84045d74e31ada7d53c->m_episodic_ep_K00N + + +APPEARS_IN + + + +e_v6ent_935cb6e81424437a82c7329d + +Tips\n(Concept, d=15) + + + +m_episodic_ep_MQZN + +E: 2023-05-26\nUser requests tips for proper form\nand technique for bodyweight\nsquats and lunges + + + +e_v6ent_935cb6e81424437a82c7329d->m_episodic_ep_MQZN + + +APPEARS_IN + + + +m_episodic_ep_RK5E + +E: 2023-05-25\nUser requested tips on leading\nengaging book club discussions. + + + +e_v6ent_935cb6e81424437a82c7329d->m_episodic_ep_RK5E + + +APPEARS_IN + + + +m_episodic_ep_HTLY + +E: 2023-05-24\nAssistant suggests how to\ncollaboratively introduce virtual\ncoffee breaks in team meeting + + + +e_v6ent_935cb6e81424437a82c7329d->m_episodic_ep_HTLY + + +APPEARS_IN + + + +m_episodic_ep_M5SM + +E: 2023-05-24\nUser asks how to bring up virtual\ncoffee breaks in a team meeting\nwithout forcing the idea + + + +e_v6ent_935cb6e81424437a82c7329d->m_episodic_ep_M5SM + + +APPEARS_IN + + + +e_v6ent_b1b811d91ca34359b9fff646 + +Virtual Coffee Breaks\n(Concept, d=15) + + + +m_episodic_ep_U4C2 + +E: 2023-05-24\nAssistant supports setting ground\nrules for virtual coffee breaks to\nenhance engagement + + + +e_v6ent_b1b811d91ca34359b9fff646->m_episodic_ep_U4C2 + + +APPEARS_IN + + + +m_episodic_ep_M5AJ + +E: 2023-05-24\nUser suggests establishing regular\nschedule and ground rules for\nvirtual coffee breaks + + + +e_v6ent_b1b811d91ca34359b9fff646->m_episodic_ep_M5AJ + + +APPEARS_IN + + + +e_v6ent_b1b811d91ca34359b9fff646->m_episodic_ep_AOIY + + +APPEARS_IN + + + +m_episodic_ep_RTWB + +E: 2023-05-24\nAssistant provides tips to\nfacilitate collaborative\nconversation and avoid dominating\ndiscussion + + + +e_v6ent_b1b811d91ca34359b9fff646->m_episodic_ep_RTWB + + +APPEARS_IN + + + +e_v6ent_bc0caed834694c3083084c2c + +spaCy\n(Organization, d=15) + + + +e_v6ent_bc0caed834694c3083084c2c->m_episodic_ep_IPBE + + +APPEARS_IN + + + +m_episodic_ep_TN5Q + +E: 2023-05-24\nAssistant gives detailed step-by-\nstep instructions for setting up\nan NLP project environment with\nPython and common NLP libraries. + + + +e_v6ent_bc0caed834694c3083084c2c->m_episodic_ep_TN5Q + + +APPEARS_IN + + + +m_episodic_ep_QQVH + +E: 2023-05-24\nAssistant provides a comprehensive\nlist of beginner NLP resources\nincluding courses, tutorials,\nbooks, and practice projects. + + + +e_v6ent_bc0caed834694c3083084c2c->m_episodic_ep_QQVH + + +APPEARS_IN + + + +m_episodic_ep_H0PP + +E: 2023-05-24\nUser seeks beginner resources and\ntutorials for NLP, mentions\nwatching Corey Schafer's Python\nseries. + + + +e_v6ent_bc0caed834694c3083084c2c->m_episodic_ep_H0PP + + +APPEARS_IN + + + +e_v6ent_b3342708dccf4b69906efa0b + +Astrophotography\n(Concept, d=14) + + + +m_episodic_ep_Z4Y0 + +E: 2023-11-01\nUser learned about camera sensors\nand astrophotography camera\nrecommendations + + + +e_v6ent_b3342708dccf4b69906efa0b->m_episodic_ep_Z4Y0 + + +APPEARS_IN + + + +m_episodic_ep_HSH4 + +E: 2023-11-01\nUser attended 3-day photography\nworkshop and sought explanation on\ndark frames and bias frames with\nlens recommendations + + + +e_v6ent_b3342708dccf4b69906efa0b->m_episodic_ep_HSH4 + + +APPEARS_IN + + + +m_episodic_ep_XBTD + +E: 2023-11-01\nUser learned about image stacking\nand remote shutter release\nrecommendations + + + +e_v6ent_b3342708dccf4b69906efa0b->m_episodic_ep_XBTD + + +APPEARS_IN + + + +m_episodic_ep_ONEA + +E: 2023-11-01\nUser sought apps, software, and\ncommunities for astrophotography\nediting and sharing + + + +e_v6ent_b3342708dccf4b69906efa0b->m_episodic_ep_ONEA + + +APPEARS_IN + + + +e_v6ent_9fb40da80dc944a0a499e231 + +Fatigue\n(Concept, d=14) + + + +m_episodic_ep_RPBF + +E: 2023-05-25\nAssistant provided detailed\nguidance on chronic sinusitis\nmanagement including nasal sprays,\nhumidifiers, and addressing\nfatigue and j... + + + +e_v6ent_9fb40da80dc944a0a499e231->m_episodic_ep_RPBF + + +APPEARS_IN + + + +m_episodic_ep_KZZ9 + +E: 2023-05-25\nUser planned follow-up\nappointments with Dr. Patel and\nprimary care physician to discuss\nsinusitis, UTI, fatigue, and joint\npain. + + + +e_v6ent_9fb40da80dc944a0a499e231->m_episodic_ep_KZZ9 + + +APPEARS_IN + + + +m_episodic_ep_I8OZ + +E: 2023-05-25\nUser discussed recent UTI and\nantibiotic treatment and their\npotential impact on fatigue, joint\npain, and chronic sinusitis. + + + +e_v6ent_9fb40da80dc944a0a499e231->m_episodic_ep_I8OZ + + +APPEARS_IN + + + +m_episodic_ep_P5A2 + +E: 2023-05-25\nUser reported fatigue and joint\npain and inquired about their\nrelation to chronic sinusitis or\nother causes. + + + +e_v6ent_9fb40da80dc944a0a499e231->m_episodic_ep_P5A2 + + +APPEARS_IN + + + +e_v6ent_b2965f3f5073405d94928874 + +Scythe\n(Content, d=14) + + + +m_episodic_ep_E0BQ + +E: 2023-05-30\nUser asked for snack and drink\nsuggestions for a Scythe-themed\ngame night. + + + +e_v6ent_b2965f3f5073405d94928874->m_episodic_ep_E0BQ + + +APPEARS_IN + + + +m_episodic_ep_CUZO + +E: 2023-05-30\nAssistant provided strategies for\nmaximizing trade opportunities in\nScythe, emphasizing Nordic\nKingdom's strengths. + + + +e_v6ent_b2965f3f5073405d94928874->m_episodic_ep_CUZO + + +APPEARS_IN + + + +m_episodic_ep_P5WH + +E: 2023-05-30\nUser shared their Ticket to Ride\nhigh score and asked about\nmaximizing trade opportunities in\nScythe. + + + +e_v6ent_b2965f3f5073405d94928874->m_episodic_ep_P5WH + + +APPEARS_IN + + + +m_episodic_ep_JBCM + +E: 2023-05-30\nAssistant explained structure\nbuilding and trading mechanics in\nScythe, focusing on the Nordic\nKingdom. + + + +e_v6ent_b2965f3f5073405d94928874->m_episodic_ep_JBCM + + +APPEARS_IN + + + +e_v6ent_865162e16bd6455f9c7f3037 + +Vatican\n(Location, d=14) + + + +m_episodic_ep_QKUF + +E: 2023-05-21\nUser asked for general tips for\nvisiting Rome. + + + +e_v6ent_865162e16bd6455f9c7f3037->m_episodic_ep_QKUF + + +APPEARS_IN + + + +m_episodic_ep_BB6N + +E: 2023-05-21\nAssistant explained souvenir shops\nnear the Vatican and what they\nsell. + + + +e_v6ent_865162e16bd6455f9c7f3037->m_episodic_ep_BB6N + + +APPEARS_IN + + + +m_episodic_ep_THG5 + +E: 2023-05-21\nUser asked about souvenir shops\naround the Vatican. + + + +e_v6ent_865162e16bd6455f9c7f3037->m_episodic_ep_THG5 + + +APPEARS_IN + + + +m_episodic_ep_VT1U + +E: 2023-05-21\nAssistant recommended restaurants\nnear the Vatican including\nPizzarium, La Locanda dei\nGirasoli, Roscioli, and Caffè\nVaticano. + + + +e_v6ent_865162e16bd6455f9c7f3037->m_episodic_ep_VT1U + + +APPEARS_IN + + + +e_v6ent_eaa3e87ae15f46b5ba01a845 + +Amsterdam\n(Location, d=13) + + + +m_episodic_ep_Q2SL + +E: 2024-02-05\nUser decides to focus on\nneighborhood exploration and skip\nday trips on Europe trip + + + +e_v6ent_eaa3e87ae15f46b5ba01a845->m_episodic_ep_Q2SL + + +APPEARS_IN + + + +m_episodic_ep_J5OF + +E: 2024-02-05\nAssistant encourages prioritizing\nneighborhood exploration in\nBarcelona and Amsterdam, suggests\noptional day trips + + + +e_v6ent_eaa3e87ae15f46b5ba01a845->m_episodic_ep_J5OF + + +APPEARS_IN + + + +m_episodic_ep_S6DC + +E: 2024-02-05\nAssistant recommends itinerary\nplanning for 4-5 days each in\nBarcelona and Amsterdam, balancing\nlandmarks and neighborhood\nexploration + + + +e_v6ent_eaa3e87ae15f46b5ba01a845->m_episodic_ep_S6DC + + +APPEARS_IN + + + +m_episodic_ep_PPB0 + +E: 2024-02-05\nAssistant details must-see\nattractions and neighborhoods in\nBarcelona and Amsterdam, with\naccommodation advice + + + +e_v6ent_eaa3e87ae15f46b5ba01a845->m_episodic_ep_PPB0 + + +APPEARS_IN + + + +e_v6ent_3b28ae5cd0b34878a885c180 + +Barcelona\n(Location, d=13) + + + +e_v6ent_3b28ae5cd0b34878a885c180->m_episodic_ep_Q2SL + + +APPEARS_IN + + + +e_v6ent_3b28ae5cd0b34878a885c180->m_episodic_ep_J5OF + + +APPEARS_IN + + + +e_v6ent_3b28ae5cd0b34878a885c180->m_episodic_ep_S6DC + + +APPEARS_IN + + + +e_v6ent_3b28ae5cd0b34878a885c180->m_episodic_ep_PPB0 + + +APPEARS_IN + + + +e_v6ent_bef3247486b0450f80663818 + +Goodreads\n(Organization, d=13) + + + +m_episodic_ep_YRM2 + +E: 2023-01-15\nAssistant advised on using\nGoodreads and physical notebooks\nfor TBR list tracking and the\nbenefits of tracking\nrecommendation sources. + + + +e_v6ent_bef3247486b0450f80663818->m_episodic_ep_YRM2 + + +APPEARS_IN + + + +m_episodic_ep_LGPJ + +E: 2023-01-15\nAssistant suggested methods for\ntracking book recommendations and\nTBR lists. + + + +e_v6ent_bef3247486b0450f80663818->m_episodic_ep_LGPJ + + +APPEARS_IN + + + +m_episodic_ep_KKRR + +E: 2023-01-15\nAssistant advised on using both\nphysical journals and digital\nplatforms for reading tracking. + + + +e_v6ent_bef3247486b0450f80663818->m_episodic_ep_KKRR + + +APPEARS_IN + + + +m_episodic_ep_AZAE + +E: 2023-01-15\nAssistant recommended book\njournals and reading logs options\nto user. + + + +e_v6ent_bef3247486b0450f80663818->m_episodic_ep_AZAE + + +APPEARS_IN + + + +e_v6ent_3c13d846a39d4b8d9be4865a + +Japan\n(Location, d=13) + + + +m_episodic_ep_E5XP + +E: 2023-05-23\nUser interested in Japanese\ncuisine and restaurants to try\nduring Japan trip. + + + +e_v6ent_3c13d846a39d4b8d9be4865a->m_episodic_ep_E5XP + + +APPEARS_IN + + + +m_episodic_ep_BI7Q + +E: 2023-05-23\nAssistant recommended solo travel\ndestinations and provided details\nabout Japan as a solo travel\ndestination including best seasons\nan... + + + +e_v6ent_3c13d846a39d4b8d9be4865a->m_episodic_ep_BI7Q + + +APPEARS_IN + + + +m_episodic_ep_I63V + +E: 2023-05-23\nUser interested in solo travel\ndestinations, mentions fascination\nwith Japan. + + + +e_v6ent_3c13d846a39d4b8d9be4865a->m_episodic_ep_I63V + + +APPEARS_IN + + + +m_episodic_ep_SLLG + +E: 2023-03-03\nPlanning 7th international trip\nthis year to Japan in May, booked\nflights and accommodation + + + +e_v6ent_3c13d846a39d4b8d9be4865a->m_episodic_ep_SLLG + + +APPEARS_IN + + + +e_v6ent_4058cd532f2342e5b8b126ca + +Natural Park of Moncayo\nMountain\n(Location, d=13) + + + +e_v6ent_4058cd532f2342e5b8b126ca->m_episodic_ep_QXGA + + +APPEARS_IN + + + +e_v6ent_4058cd532f2342e5b8b126ca->m_episodic_ep_KWWW + + +APPEARS_IN + + + +e_v6ent_4058cd532f2342e5b8b126ca->m_episodic_ep_A4SQ + + +APPEARS_IN + + + +m_episodic_ep_Q4I2 + +E: 2023-05-25\nUser asked for accommodation\nadvice near the Natural Park of\nMoncayo mountain in Aragón. + + + +e_v6ent_4058cd532f2342e5b8b126ca->m_episodic_ep_Q4I2 + + +APPEARS_IN + + + +e_v6ent_51f1f33de2d74af78d969a26 + +Parents\n(Person, d=13) + + + +m_episodic_ep_BUKO + +E: 2023-10-20\nUser plans to discuss waiver of\ninadmissibility process with\nattorney regarding parents' nine-\nmonth overstay. + + + +e_v6ent_51f1f33de2d74af78d969a26->m_episodic_ep_BUKO + + +APPEARS_IN + + + +m_episodic_ep_S1LA + +E: 2023-10-20\nAssistant explained waiver of\ninadmissibility process,\neligibility, requirements, and\napplication in case of parents'\nnine-month visa... + + + +e_v6ent_51f1f33de2d74af78d969a26->m_episodic_ep_S1LA + + +APPEARS_IN + + + +m_episodic_ep_MFX7 + +E: 2023-10-20\nUser inquires about waiver of\ninadmissibility process and its\napplication to parents' overstay\nsituation. + + + +e_v6ent_51f1f33de2d74af78d969a26->m_episodic_ep_MFX7 + + +APPEARS_IN + + + +m_episodic_ep_L0D1 + +E: 2023-10-20\nAssistant provided detailed\nguidance on points to discuss with\nattorney about parents' nine-month\noverstaying visa and green card\nappl... + + + +e_v6ent_51f1f33de2d74af78d969a26->m_episodic_ep_L0D1 + + +APPEARS_IN + + + +e_v6ent_b16675464a50408f92503dc2 + +BodyPump\n(Concept, d=12) + + + +m_episodic_ep_DRBH + +E: 2023-05-21\nAssistant provides workout\nplaylist recommendations, Zumba\nwarm-up tips, healthy snacks, and\nmotivation advice + + + +e_v6ent_b16675464a50408f92503dc2->m_episodic_ep_DRBH + + +APPEARS_IN + + + +m_episodic_ep_PR3N + +E: 2023-05-21\nUser seeks new workout playlists\nfor Zumba and BodyPump classes + + + +e_v6ent_b16675464a50408f92503dc2->m_episodic_ep_PR3N + + +APPEARS_IN + + + +m_episodic_ep_D4JU + +E: 2023-05-20\nUser requested recommendations for\nprotein powders suitable for\nbeginners to support BodyPump\nweightlifting classes. + + + +e_v6ent_b16675464a50408f92503dc2->m_episodic_ep_D4JU + + +APPEARS_IN + + + +m_episodic_ep_EKC5 + +E: 2023-05-20\nAssistant provided guidance on\nincorporating protein shakes into\nfitness routines, including\ntiming, protein types, recipes,\nand BodyP... + + + +e_v6ent_b16675464a50408f92503dc2->m_episodic_ep_EKC5 + + +APPEARS_IN + + + +e_v6ent_b2297c2449a24ff09839ffb1 + +Boston\n(Location, d=12) + + + +m_episodic_ep_CO6I + +E: 2023-05-25\nUser shares summer concert\nschedule including Jonas Brothers,\nThe Lumineers, and recent Imagine\nDragons concert + + + +e_v6ent_b2297c2449a24ff09839ffb1->m_episodic_ep_CO6I + + +APPEARS_IN + + + +m_episodic_ep_OORP + +E: 2023-05-25\nUser shares summer concert\nschedule including Jonas Brothers,\nThe Lumineers, and recent Imagine\nDragons concert + + + +e_v6ent_b2297c2449a24ff09839ffb1->m_episodic_ep_OORP + + +APPEARS_IN + + + +e_v6ent_b2297c2449a24ff09839ffb1->m_episodic_ep_FLJB + + +APPEARS_IN + + + +m_episodic_ep_GXTJ + +E: 2023-02-14\nUser inquired about travel\ninsurance options and prices for\ntrip from Boston to Fort\nLauderdale + + + +e_v6ent_b2297c2449a24ff09839ffb1->m_episodic_ep_GXTJ + + +APPEARS_IN + + + +e_v6ent_ef707da356434e80a7527335 + +Brown Sugar\n(Other, d=12) + + + +m_episodic_ep_IQJW + +E: 2023-07-01\nAssistant provided tips for using\nhickory wood chips effectively for\nBBQ, including soaking, pairing\nwith right meats, balancing\nflavo... + + + +e_v6ent_ef707da356434e80a7527335->m_episodic_ep_IQJW + + +APPEARS_IN + + + +m_episodic_ep_H5OK + +E: 2023-07-01\nUser requested and received a\nKorean-style BBQ marinade recipe\nand tips + + + +e_v6ent_ef707da356434e80a7527335->m_episodic_ep_H5OK + + +APPEARS_IN + + + +m_episodic_ep_L8EM + +E: 2023-05-22\nAssistant provided updated lemon\nlavender pound cake recipe with\nsugar and lemon adjustments for\ndried lavender buds. + + + +e_v6ent_ef707da356434e80a7527335->m_episodic_ep_L8EM + + +APPEARS_IN + + + +m_episodic_ep_ESB9 + +E: 2023-05-22\nUser chose to add dried lavender\nbuds to the lemon pound cake\nbatter and asked about sugar and\nlemon adjustments. + + + +e_v6ent_ef707da356434e80a7527335->m_episodic_ep_ESB9 + + +APPEARS_IN + + + diff --git a/docs/graph_memory_v6/visualizations/topic_aquarium.dot b/docs/graph_memory_v6/visualizations/topic_aquarium.dot new file mode 100644 index 000000000..498ee9376 --- /dev/null +++ b/docs/graph_memory_v6/visualizations/topic_aquarium.dot @@ -0,0 +1,106 @@ +digraph G { + graph [rankdir=LR, bgcolor="white", splines=true, overlap=false, nodesep=0.35, ranksep=1.0]; + node [fontname="Helvetica", fontsize=10, style="filled,rounded", penwidth=1.2]; + edge [fontname="Helvetica", fontsize=8, color="#9aa4b2", arrowsize=0.55]; + label="Aquarium / Fish Detail"; + labelloc="t"; + fontsize=20; + + subgraph cluster_entities { + label="V6Entity"; + color="#d8dee9"; + style="rounded"; + e_v6ent_66b5ac051f2b4fafb577f7e1 [shape=ellipse, fillcolor="#eeeeee", color="#777777", label="Lemon Tetras\\n(Other, d=9)"]; + e_v6ent_0510651c73fc4dea9c98fac6 [shape=ellipse, fillcolor="#eeeeee", color="#777777", label="Golden Honey Gouramis\\n(Other, d=7)"]; + e_v6ent_4ce56803dadf4815bfc8ccd4 [shape=ellipse, fillcolor="#eeeeee", color="#777777", label="Neon Tetras\\n(Other, d=7)"]; + e_v6ent_38752679e7e7408883a54b1f [shape=ellipse, fillcolor="#eeeeee", color="#777777", label="Pleco Catfish\\n(Other, d=6)"]; + e_v6ent_588c8f1c5fc7442eaa2a846f [shape=ellipse, fillcolor="#eeeeee", color="#777777", label="Betta Fish\\n(Other, d=4)"]; + e_v6ent_76ec0c0b6ea54161a59b2add [shape=ellipse, fillcolor="#eeeeee", color="#777777", label="20-Gallon Community\\nAquarium\\n(Other, d=3)"]; + e_v6ent_33f255ea3b08429a902b2754 [shape=ellipse, fillcolor="#eeeeee", color="#777777", label="Community Aquarium\\n(Other, d=3)"]; + e_v6ent_ffc7a137b3f645598d76d1a2 [shape=ellipse, fillcolor="#eee4ff", color="#7c5bc7", label="Gourami Aggression\\n(Concept, d=3)"]; + e_v6ent_9bd2e0fd51a749e4884bd369 [shape=ellipse, fillcolor="#eee4ff", color="#7c5bc7", label="Schooling Fish\\n(Concept, d=3)"]; + e_v6ent_eec743b377854d25a06e91cb [shape=ellipse, fillcolor="#eeeeee", color="#777777", label="Gouramis\\n(Other, d=2)"]; + e_v6ent_6bab973106704f2cbfc8d13f [shape=ellipse, fillcolor="#eeeeee", color="#777777", label="10-Gallon Betta Tank\\n(Other, d=1)"]; + e_v6ent_b4619a1263b04978b4613072 [shape=ellipse, fillcolor="#eeeeee", color="#777777", label="20-Gallon Aquarium\\n(Other, d=1)"]; + e_v6ent_0695144f338949559a579612 [shape=ellipse, fillcolor="#eeeeee", color="#777777", label="Aggressive Gouramis\\n(Other, d=1)"]; + e_v6ent_e0c87b9a991940a1982a11e1 [shape=ellipse, fillcolor="#eee4ff", color="#7c5bc7", label="Aquarium Decorations\\n(Concept, d=1)"]; + } + + subgraph cluster_memories { + label="V6MemoryRef -> PG flat memory"; + color="#d8dee9"; + style="rounded"; + m_episodic_ep_MEVM [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-22\\nAssistant detailed compatibility\\nand care tips for adding Lemon\\nTetras and Zebra Danios to the\\nexisting 20-gallon community tank."]; + m_episodic_ep_IAXG [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-22\\nUser considers adding schooling\\nfish Lemon Tetras and Zebra Danios\\nto 20-gallon community tank with\\nneon tetras, golden honey\\ngouramis..."]; + m_episodic_ep_DH95 [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-22\\nAssistant provided detailed care\\ninstructions for Java Moss and\\nAnacharis and their interactions\\nwith neon tetras, golden honey\\ngouram..."]; + m_episodic_ep_CNIO [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-22\\nUser plans to add live plants Java\\nMoss and Anacharis to 20-gallon\\ncommunity aquarium with neon\\ntetras, golden honey gouramis, and\\nple..."]; + m_semantic_sem_HMAD [shape=component, fillcolor="#dff7ff", color="#2f7f9f", label="S: 2026-06-12\\nTreasure Chest Decoration in\\n20-Gallon Community Aquarium"]; + m_semantic_sem_MKCK [shape=component, fillcolor="#dff7ff", color="#2f7f9f", label="S: 2026-06-12\\nLemon Tetras and Zebra Danios\\nCompatibility in Community\\nAquarium"]; + m_semantic_sem_GEKU [shape=component, fillcolor="#dff7ff", color="#2f7f9f", label="S: 2026-06-12\\nJava Moss and Anacharis in\\nCommunity Aquarium"]; + m_episodic_ep_U751 [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-27\\nUser reports trying to distract\\naggressive gouramis with extra\\nfood and decorations, considering\\nadding schooling fish, and\\ninquires a..."]; + m_semantic_sem_DFNM [shape=component, fillcolor="#dff7ff", color="#2f7f9f", label="S: 2026-06-12\\nLemon Tetras and Zebra Danios\\nBehavior and Tank Requirements"]; + m_episodic_ep_S4J3 [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-27\\nAssistant confirmed frozen\\nbloodworms are a good treat for\\nbetta fish and gave feeding tips."]; + m_semantic_sem_FZ2Z [shape=component, fillcolor="#dff7ff", color="#2f7f9f", label="S: 2026-06-12\\nBubbles the Betta Fish"]; + m_semantic_sem_I7C6 [shape=component, fillcolor="#dff7ff", color="#2f7f9f", label="S: 2026-06-12\\nFeeding Betta Fish Frozen\\nBloodworms"]; + m_episodic_ep_XLQC [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-27\\nAssistant advised on gourami\\naggression, compatibility with\\nschooling fish, and tank\\nconsiderations in a 20-gallon\\naquarium."]; + m_episodic_ep_YC41 [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-27\\nUser plans to add schooling fish\\nlemon tetras and zebra danios to\\n20-gallon community tank and\\ninquires about their behavior and\\ntank..."]; + m_semantic_sem_XUEA [shape=component, fillcolor="#dff7ff", color="#2f7f9f", label="S: 2026-06-12\\nGourami Aggression and Schooling\\nFish Addition"]; + m_semantic_sem_S83B [shape=component, fillcolor="#dff7ff", color="#2f7f9f", label="S: 2026-06-12\\nLemon Tetras"]; + m_semantic_sem_SBX0 [shape=component, fillcolor="#dff7ff", color="#2f7f9f", label="S: 2026-06-12\\nGourami Aggression and Tank\\nCompatibility"]; + m_episodic_ep_DU2X [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-27\\nUser plans to add live aquatic\\nplants to upgraded freshwater\\ntanks including a 20-gallon\\ncommunity tank and a 10-gallon\\nbetta tank."]; + m_episodic_ep_SLGS [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-22\\nUser plans to add decorations\\nincluding treasure chest to\\n20-gallon community aquarium with\\nartificial coral and rocks for\\nfish hiding..."]; + m_semantic_sem_WIML [shape=component, fillcolor="#dff7ff", color="#2f7f9f", label="S: 2026-06-12\\nZebra Danios"]; + m_semantic_sem_OIEZ [shape=component, fillcolor="#dff7ff", color="#2f7f9f", label="S: 2026-06-12\\nAquarium Decorations for Community\\nTanks"]; + } + + e_v6ent_0510651c73fc4dea9c98fac6 -> m_episodic_ep_MEVM [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_0510651c73fc4dea9c98fac6 -> m_episodic_ep_IAXG [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_0510651c73fc4dea9c98fac6 -> m_episodic_ep_DH95 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_0510651c73fc4dea9c98fac6 -> m_episodic_ep_CNIO [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_0510651c73fc4dea9c98fac6 -> m_semantic_sem_HMAD [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_0510651c73fc4dea9c98fac6 -> m_semantic_sem_MKCK [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_0510651c73fc4dea9c98fac6 -> m_semantic_sem_GEKU [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_0695144f338949559a579612 -> m_episodic_ep_U751 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_33f255ea3b08429a902b2754 -> m_semantic_sem_DFNM [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_33f255ea3b08429a902b2754 -> m_semantic_sem_MKCK [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_33f255ea3b08429a902b2754 -> m_semantic_sem_GEKU [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_38752679e7e7408883a54b1f -> m_episodic_ep_MEVM [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_38752679e7e7408883a54b1f -> m_episodic_ep_IAXG [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_38752679e7e7408883a54b1f -> m_episodic_ep_DH95 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_38752679e7e7408883a54b1f -> m_semantic_sem_HMAD [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_38752679e7e7408883a54b1f -> m_semantic_sem_MKCK [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_38752679e7e7408883a54b1f -> m_semantic_sem_GEKU [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_4ce56803dadf4815bfc8ccd4 -> m_episodic_ep_MEVM [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_4ce56803dadf4815bfc8ccd4 -> m_episodic_ep_IAXG [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_4ce56803dadf4815bfc8ccd4 -> m_episodic_ep_DH95 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_4ce56803dadf4815bfc8ccd4 -> m_episodic_ep_CNIO [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_4ce56803dadf4815bfc8ccd4 -> m_semantic_sem_HMAD [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_4ce56803dadf4815bfc8ccd4 -> m_semantic_sem_MKCK [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_4ce56803dadf4815bfc8ccd4 -> m_semantic_sem_GEKU [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_588c8f1c5fc7442eaa2a846f -> m_episodic_ep_S4J3 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_588c8f1c5fc7442eaa2a846f -> m_episodic_ep_U751 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_588c8f1c5fc7442eaa2a846f -> m_semantic_sem_FZ2Z [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_588c8f1c5fc7442eaa2a846f -> m_semantic_sem_I7C6 [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_66b5ac051f2b4fafb577f7e1 -> m_episodic_ep_XLQC [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_66b5ac051f2b4fafb577f7e1 -> m_episodic_ep_YC41 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_66b5ac051f2b4fafb577f7e1 -> m_episodic_ep_MEVM [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_66b5ac051f2b4fafb577f7e1 -> m_episodic_ep_IAXG [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_66b5ac051f2b4fafb577f7e1 -> m_semantic_sem_XUEA [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_66b5ac051f2b4fafb577f7e1 -> m_semantic_sem_S83B [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_66b5ac051f2b4fafb577f7e1 -> m_semantic_sem_SBX0 [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_66b5ac051f2b4fafb577f7e1 -> m_semantic_sem_DFNM [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_6bab973106704f2cbfc8d13f -> m_episodic_ep_DU2X [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_76ec0c0b6ea54161a59b2add -> m_episodic_ep_SLGS [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_76ec0c0b6ea54161a59b2add -> m_episodic_ep_CNIO [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_76ec0c0b6ea54161a59b2add -> m_semantic_sem_HMAD [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_9bd2e0fd51a749e4884bd369 -> m_episodic_ep_U751 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_9bd2e0fd51a749e4884bd369 -> m_episodic_ep_XLQC [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_9bd2e0fd51a749e4884bd369 -> m_semantic_sem_WIML [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_b4619a1263b04978b4613072 -> m_episodic_ep_XLQC [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_e0c87b9a991940a1982a11e1 -> m_semantic_sem_OIEZ [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_eec743b377854d25a06e91cb -> m_semantic_sem_XUEA [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_eec743b377854d25a06e91cb -> m_semantic_sem_SBX0 [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_ffc7a137b3f645598d76d1a2 -> m_episodic_ep_XLQC [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_ffc7a137b3f645598d76d1a2 -> m_semantic_sem_XUEA [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_ffc7a137b3f645598d76d1a2 -> m_semantic_sem_SBX0 [label="DESCRIBED_BY", color="#9b6bd3"]; +} \ No newline at end of file diff --git a/docs/graph_memory_v6/visualizations/topic_aquarium.png b/docs/graph_memory_v6/visualizations/topic_aquarium.png new file mode 100644 index 000000000..d044d922a Binary files /dev/null and b/docs/graph_memory_v6/visualizations/topic_aquarium.png differ diff --git a/docs/graph_memory_v6/visualizations/topic_aquarium.svg b/docs/graph_memory_v6/visualizations/topic_aquarium.svg new file mode 100644 index 000000000..65690aaa4 --- /dev/null +++ b/docs/graph_memory_v6/visualizations/topic_aquarium.svg @@ -0,0 +1,606 @@ + + + + + + +G + +Aquarium / Fish Detail + +cluster_entities + +V6Entity + + +cluster_memories + +V6MemoryRef -> PG flat memory + + + +e_v6ent_66b5ac051f2b4fafb577f7e1 + +Lemon Tetras\n(Other, d=9) + + + +m_episodic_ep_MEVM + +E: 2023-05-22\nAssistant detailed compatibility\nand care tips for adding Lemon\nTetras and Zebra Danios to the\nexisting 20-gallon community tank. + + + +e_v6ent_66b5ac051f2b4fafb577f7e1->m_episodic_ep_MEVM + + +APPEARS_IN + + + +m_episodic_ep_IAXG + +E: 2023-05-22\nUser considers adding schooling\nfish Lemon Tetras and Zebra Danios\nto 20-gallon community tank with\nneon tetras, golden honey\ngouramis... + + + +e_v6ent_66b5ac051f2b4fafb577f7e1->m_episodic_ep_IAXG + + +APPEARS_IN + + + +m_semantic_sem_DFNM + + + +S: 2026-06-12\nLemon Tetras and Zebra Danios\nBehavior and Tank Requirements + + + +e_v6ent_66b5ac051f2b4fafb577f7e1->m_semantic_sem_DFNM + + +DESCRIBED_BY + + + +m_episodic_ep_XLQC + +E: 2023-05-27\nAssistant advised on gourami\naggression, compatibility with\nschooling fish, and tank\nconsiderations in a 20-gallon\naquarium. + + + +e_v6ent_66b5ac051f2b4fafb577f7e1->m_episodic_ep_XLQC + + +APPEARS_IN + + + +m_episodic_ep_YC41 + +E: 2023-05-27\nUser plans to add schooling fish\nlemon tetras and zebra danios to\n20-gallon community tank and\ninquires about their behavior and\ntank... + + + +e_v6ent_66b5ac051f2b4fafb577f7e1->m_episodic_ep_YC41 + + +APPEARS_IN + + + +m_semantic_sem_XUEA + + + +S: 2026-06-12\nGourami Aggression and Schooling\nFish Addition + + + +e_v6ent_66b5ac051f2b4fafb577f7e1->m_semantic_sem_XUEA + + +DESCRIBED_BY + + + +m_semantic_sem_S83B + + + +S: 2026-06-12\nLemon Tetras + + + +e_v6ent_66b5ac051f2b4fafb577f7e1->m_semantic_sem_S83B + + +DESCRIBED_BY + + + +m_semantic_sem_SBX0 + + + +S: 2026-06-12\nGourami Aggression and Tank\nCompatibility + + + +e_v6ent_66b5ac051f2b4fafb577f7e1->m_semantic_sem_SBX0 + + +DESCRIBED_BY + + + +e_v6ent_0510651c73fc4dea9c98fac6 + +Golden Honey Gouramis\n(Other, d=7) + + + +e_v6ent_0510651c73fc4dea9c98fac6->m_episodic_ep_MEVM + + +APPEARS_IN + + + +e_v6ent_0510651c73fc4dea9c98fac6->m_episodic_ep_IAXG + + +APPEARS_IN + + + +m_episodic_ep_DH95 + +E: 2023-05-22\nAssistant provided detailed care\ninstructions for Java Moss and\nAnacharis and their interactions\nwith neon tetras, golden honey\ngouram... + + + +e_v6ent_0510651c73fc4dea9c98fac6->m_episodic_ep_DH95 + + +APPEARS_IN + + + +m_episodic_ep_CNIO + +E: 2023-05-22\nUser plans to add live plants Java\nMoss and Anacharis to 20-gallon\ncommunity aquarium with neon\ntetras, golden honey gouramis, and\nple... + + + +e_v6ent_0510651c73fc4dea9c98fac6->m_episodic_ep_CNIO + + +APPEARS_IN + + + +m_semantic_sem_HMAD + + + +S: 2026-06-12\nTreasure Chest Decoration in\n20-Gallon Community Aquarium + + + +e_v6ent_0510651c73fc4dea9c98fac6->m_semantic_sem_HMAD + + +DESCRIBED_BY + + + +m_semantic_sem_MKCK + + + +S: 2026-06-12\nLemon Tetras and Zebra Danios\nCompatibility in Community\nAquarium + + + +e_v6ent_0510651c73fc4dea9c98fac6->m_semantic_sem_MKCK + + +DESCRIBED_BY + + + +m_semantic_sem_GEKU + + + +S: 2026-06-12\nJava Moss and Anacharis in\nCommunity Aquarium + + + +e_v6ent_0510651c73fc4dea9c98fac6->m_semantic_sem_GEKU + + +DESCRIBED_BY + + + +e_v6ent_4ce56803dadf4815bfc8ccd4 + +Neon Tetras\n(Other, d=7) + + + +e_v6ent_4ce56803dadf4815bfc8ccd4->m_episodic_ep_MEVM + + +APPEARS_IN + + + +e_v6ent_4ce56803dadf4815bfc8ccd4->m_episodic_ep_IAXG + + +APPEARS_IN + + + +e_v6ent_4ce56803dadf4815bfc8ccd4->m_episodic_ep_DH95 + + +APPEARS_IN + + + +e_v6ent_4ce56803dadf4815bfc8ccd4->m_episodic_ep_CNIO + + +APPEARS_IN + + + +e_v6ent_4ce56803dadf4815bfc8ccd4->m_semantic_sem_HMAD + + +DESCRIBED_BY + + + +e_v6ent_4ce56803dadf4815bfc8ccd4->m_semantic_sem_MKCK + + +DESCRIBED_BY + + + +e_v6ent_4ce56803dadf4815bfc8ccd4->m_semantic_sem_GEKU + + +DESCRIBED_BY + + + +e_v6ent_38752679e7e7408883a54b1f + +Pleco Catfish\n(Other, d=6) + + + +e_v6ent_38752679e7e7408883a54b1f->m_episodic_ep_MEVM + + +APPEARS_IN + + + +e_v6ent_38752679e7e7408883a54b1f->m_episodic_ep_IAXG + + +APPEARS_IN + + + +e_v6ent_38752679e7e7408883a54b1f->m_episodic_ep_DH95 + + +APPEARS_IN + + + +e_v6ent_38752679e7e7408883a54b1f->m_semantic_sem_HMAD + + +DESCRIBED_BY + + + +e_v6ent_38752679e7e7408883a54b1f->m_semantic_sem_MKCK + + +DESCRIBED_BY + + + +e_v6ent_38752679e7e7408883a54b1f->m_semantic_sem_GEKU + + +DESCRIBED_BY + + + +e_v6ent_588c8f1c5fc7442eaa2a846f + +Betta Fish\n(Other, d=4) + + + +m_episodic_ep_U751 + +E: 2023-05-27\nUser reports trying to distract\naggressive gouramis with extra\nfood and decorations, considering\nadding schooling fish, and\ninquires a... + + + +e_v6ent_588c8f1c5fc7442eaa2a846f->m_episodic_ep_U751 + + +APPEARS_IN + + + +m_episodic_ep_S4J3 + +E: 2023-05-27\nAssistant confirmed frozen\nbloodworms are a good treat for\nbetta fish and gave feeding tips. + + + +e_v6ent_588c8f1c5fc7442eaa2a846f->m_episodic_ep_S4J3 + + +APPEARS_IN + + + +m_semantic_sem_FZ2Z + + + +S: 2026-06-12\nBubbles the Betta Fish + + + +e_v6ent_588c8f1c5fc7442eaa2a846f->m_semantic_sem_FZ2Z + + +DESCRIBED_BY + + + +m_semantic_sem_I7C6 + + + +S: 2026-06-12\nFeeding Betta Fish Frozen\nBloodworms + + + +e_v6ent_588c8f1c5fc7442eaa2a846f->m_semantic_sem_I7C6 + + +DESCRIBED_BY + + + +e_v6ent_76ec0c0b6ea54161a59b2add + +20-Gallon Community\nAquarium\n(Other, d=3) + + + +e_v6ent_76ec0c0b6ea54161a59b2add->m_episodic_ep_CNIO + + +APPEARS_IN + + + +e_v6ent_76ec0c0b6ea54161a59b2add->m_semantic_sem_HMAD + + +DESCRIBED_BY + + + +m_episodic_ep_SLGS + +E: 2023-05-22\nUser plans to add decorations\nincluding treasure chest to\n20-gallon community aquarium with\nartificial coral and rocks for\nfish hiding... + + + +e_v6ent_76ec0c0b6ea54161a59b2add->m_episodic_ep_SLGS + + +APPEARS_IN + + + +e_v6ent_33f255ea3b08429a902b2754 + +Community Aquarium\n(Other, d=3) + + + +e_v6ent_33f255ea3b08429a902b2754->m_semantic_sem_MKCK + + +DESCRIBED_BY + + + +e_v6ent_33f255ea3b08429a902b2754->m_semantic_sem_GEKU + + +DESCRIBED_BY + + + +e_v6ent_33f255ea3b08429a902b2754->m_semantic_sem_DFNM + + +DESCRIBED_BY + + + +e_v6ent_ffc7a137b3f645598d76d1a2 + +Gourami Aggression\n(Concept, d=3) + + + +e_v6ent_ffc7a137b3f645598d76d1a2->m_episodic_ep_XLQC + + +APPEARS_IN + + + +e_v6ent_ffc7a137b3f645598d76d1a2->m_semantic_sem_XUEA + + +DESCRIBED_BY + + + +e_v6ent_ffc7a137b3f645598d76d1a2->m_semantic_sem_SBX0 + + +DESCRIBED_BY + + + +e_v6ent_9bd2e0fd51a749e4884bd369 + +Schooling Fish\n(Concept, d=3) + + + +e_v6ent_9bd2e0fd51a749e4884bd369->m_episodic_ep_U751 + + +APPEARS_IN + + + +e_v6ent_9bd2e0fd51a749e4884bd369->m_episodic_ep_XLQC + + +APPEARS_IN + + + +m_semantic_sem_WIML + + + +S: 2026-06-12\nZebra Danios + + + +e_v6ent_9bd2e0fd51a749e4884bd369->m_semantic_sem_WIML + + +DESCRIBED_BY + + + +e_v6ent_eec743b377854d25a06e91cb + +Gouramis\n(Other, d=2) + + + +e_v6ent_eec743b377854d25a06e91cb->m_semantic_sem_XUEA + + +DESCRIBED_BY + + + +e_v6ent_eec743b377854d25a06e91cb->m_semantic_sem_SBX0 + + +DESCRIBED_BY + + + +e_v6ent_6bab973106704f2cbfc8d13f + +10-Gallon Betta Tank\n(Other, d=1) + + + +m_episodic_ep_DU2X + +E: 2023-05-27\nUser plans to add live aquatic\nplants to upgraded freshwater\ntanks including a 20-gallon\ncommunity tank and a 10-gallon\nbetta tank. + + + +e_v6ent_6bab973106704f2cbfc8d13f->m_episodic_ep_DU2X + + +APPEARS_IN + + + +e_v6ent_b4619a1263b04978b4613072 + +20-Gallon Aquarium\n(Other, d=1) + + + +e_v6ent_b4619a1263b04978b4613072->m_episodic_ep_XLQC + + +APPEARS_IN + + + +e_v6ent_0695144f338949559a579612 + +Aggressive Gouramis\n(Other, d=1) + + + +e_v6ent_0695144f338949559a579612->m_episodic_ep_U751 + + +APPEARS_IN + + + +e_v6ent_e0c87b9a991940a1982a11e1 + +Aquarium Decorations\n(Concept, d=1) + + + +m_semantic_sem_OIEZ + + + +S: 2026-06-12\nAquarium Decorations for Community\nTanks + + + +e_v6ent_e0c87b9a991940a1982a11e1->m_semantic_sem_OIEZ + + +DESCRIBED_BY + + + diff --git a/docs/graph_memory_v6/visualizations/topic_career.dot b/docs/graph_memory_v6/visualizations/topic_career.dot new file mode 100644 index 000000000..b6c1b847e --- /dev/null +++ b/docs/graph_memory_v6/visualizations/topic_career.dot @@ -0,0 +1,116 @@ +digraph G { + graph [rankdir=LR, bgcolor="white", splines=true, overlap=false, nodesep=0.35, ranksep=1.0]; + node [fontname="Helvetica", fontsize=10, style="filled,rounded", penwidth=1.2]; + edge [fontname="Helvetica", fontsize=8, color="#9aa4b2", arrowsize=0.55]; + label="Career / Campaign Work Detail"; + labelloc="t"; + fontsize=20; + + subgraph cluster_entities { + label="V6Entity"; + color="#d8dee9"; + style="rounded"; + e_v6ent_68d2be1ac55c4dc8a4518082 [shape=ellipse, fillcolor="#ffdcdc", color="#bf4f4f", label="LinkedIn\\n(Content, d=9)"]; + e_v6ent_b3145cf61ccb43959cc172a3 [shape=ellipse, fillcolor="#eee4ff", color="#7c5bc7", label="Content Marketing\\nStrategist\\n(Concept, d=5)"]; + e_v6ent_d5ab25c292ec46ffa7b49374 [shape=ellipse, fillcolor="#eee4ff", color="#7c5bc7", label="Digital Marketing\\nConsultant\\n(Concept, d=5)"]; + e_v6ent_b457cfd78d5d4c0fa861c36c [shape=ellipse, fillcolor="#d8e9ff", color="#3a6ea5", label="Digital Marketing\\nSpecialist\\n(Person, d=5)"]; + e_v6ent_f189b88db1e34980af44f526 [shape=ellipse, fillcolor="#ffdcdc", color="#bf4f4f", label="Resume\\n(Content, d=4)"]; + e_v6ent_cea74e557ff0489e97cef5cc [shape=ellipse, fillcolor="#eee4ff", color="#7c5bc7", label="Account-Based Marketing\\n(Concept, d=3)"]; + e_v6ent_e67975427d9b4847a27a832e [shape=ellipse, fillcolor="#eee4ff", color="#7c5bc7", label="HubSpot Inbound\\nMarketing\\n(Concept, d=3)"]; + e_v6ent_d0c73799b65c4388ab1abdb3 [shape=ellipse, fillcolor="#ffdcdc", color="#bf4f4f", label="LinkedIn Profile\\n(Content, d=3)"]; + e_v6ent_79fdaa7052be4febb2f5133e [shape=ellipse, fillcolor="#eee4ff", color="#7c5bc7", label="Marketing\\n(Concept, d=3)"]; + e_v6ent_beea932be9e6430da7e9dafc [shape=ellipse, fillcolor="#ffe7bf", color="#bf7a12", label="Speyer Tourismus\\nMarketing GmbH\\n(Organization, d=3)"]; + e_v6ent_5b8b61c6312e4d689dd5d0c7 [shape=ellipse, fillcolor="#ffdcdc", color="#bf4f4f", label="Campaign Content\\n(Content, d=2)"]; + e_v6ent_5975a01a4e214c608b9e3fc8 [shape=ellipse, fillcolor="#ffdcdc", color="#bf4f4f", label="Email Campaigns\\n(Content, d=2)"]; + e_v6ent_b660a5aa2f9c456f9a504555 [shape=ellipse, fillcolor="#eee4ff", color="#7c5bc7", label="Email Marketing\\n(Concept, d=2)"]; + e_v6ent_b8adf74723cc46fa8ee63c75 [shape=ellipse, fillcolor="#e4f0ff", color="#4b6fb5", label="LinkedIn Live Sessions\\n(Event, d=2)"]; + } + + subgraph cluster_memories { + label="V6MemoryRef -> PG flat memory"; + color="#d8dee9"; + style="rounded"; + m_episodic_ep_ALFW [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-26\\nUser describes go-to-market\\nstrategy collaboration with sales\\nteam for new product launch."]; + m_semantic_sem_NKDT [shape=component, fillcolor="#dff7ff", color="#2f7f9f", label="S: 2026-06-12\\nMarketing Campaign Segmentation\\nand Personalization"]; + m_episodic_ep_GE47 [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-23\\nAssistant helped user prioritize\\ntasks and create a detailed work\\nschedule for the week before time\\noff, including suggestions for\\nfle..."]; + m_episodic_ep_ZYDH [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-23\\nAssistant provided a detailed\\nprioritized work schedule for the\\nuser's week before taking time\\noff."]; + m_episodic_ep_Y516 [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-25\\nUser inquired about sharing\\ninsights from LinkedIn group\\nwithout being promotional"]; + m_episodic_ep_D73Z [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-25\\nUser requested advice on creating\\nengaging content on LinkedIn to\\ndrive interactions"]; + m_episodic_ep_DW59 [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-25\\nUser engaged with 'Marketing\\nProfessionals' group on LinkedIn\\nand sought resources on personal\\nbranding"]; + m_episodic_ep_FHXA [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-23\\nAssistant provided tips on finding\\na mentor and making the most of\\nmentorship."]; + m_semantic_sem_K4U6 [shape=component, fillcolor="#dff7ff", color="#2f7f9f", label="S: 2026-06-12\\nCreating Engaging Content on\\nLinkedIn"]; + m_semantic_sem_U5XN [shape=component, fillcolor="#dff7ff", color="#2f7f9f", label="S: 2026-06-12\\nMentorship Guidance for Career\\nGrowth in Digital Marketing"]; + m_semantic_sem_M1Z4 [shape=component, fillcolor="#dff7ff", color="#2f7f9f", label="S: 2026-06-12\\nEmphasizing Time Management and\\nFast-Paced Environment Skills in\\nSenior Marketing Roles"]; + m_semantic_sem_BLFM [shape=component, fillcolor="#dff7ff", color="#2f7f9f", label="S: 2026-06-12\\nHighlighting Certifications in\\nGoogle Analytics and HubSpot\\nInbound Marketing in Job\\nApplications"]; + m_episodic_ep_YS17 [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-26\\nUser shares collaboration with\\nsales team contact Tom and\\ndiscusses sales strategies\\nalignment."]; + m_semantic_sem_P8EX [shape=component, fillcolor="#dff7ff", color="#2f7f9f", label="S: 2026-06-12\\nGo-to-Market Strategy for New\\nProduct Launch"]; + m_semantic_sem_H3OE [shape=component, fillcolor="#dff7ff", color="#2f7f9f", label="S: 2026-06-12\\nInfluencer Partnerships and User-\\nGenerated Content Campaigns in\\nMarketing"]; + m_episodic_ep_IN1W [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-23\\nUser plans to take 3 days off next\\nweek and requests help\\nprioritizing work tasks and\\ncreating a schedule."]; + m_episodic_ep_MQKY [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-23\\nUser expressed interest in\\ntransitioning to specialized\\ndigital marketing roles and\\nrequested career development\\nadvice."]; + m_episodic_ep_Y99G [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-21\\nAssistant provided detailed\\nguidance on updating resume and\\nLinkedIn profile for senior\\nmarketing roles"]; + m_semantic_sem_VMO5 [shape=component, fillcolor="#dff7ff", color="#2f7f9f", label="S: 2026-06-12\\nCareer Development Advice for\\nDigital Marketing Specialist\\nTransitioning to Specialized Roles"]; + m_episodic_ep_YLXE [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-23\\nUser shared detailed schedule and\\ntask prioritization plan for\\nmanaging work before taking 3 days\\noff."]; + m_semantic_sem_VNOL [shape=component, fillcolor="#dff7ff", color="#2f7f9f", label="S: 2026-06-12\\nDigital Marketing Specialist\\nWorkload and Scheduling"]; + m_episodic_ep_I530 [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-22\\nAssistant provides comprehensive\\nmarketing strategy optimization\\nadvice including influencer\\npartnerships, UGC campaigns, email\\nmarket..."]; + m_semantic_sem_QLT2 [shape=component, fillcolor="#dff7ff", color="#2f7f9f", label="S: 2026-06-12\\nEmail Marketing and Account-Based\\nMarketing for Product Launch"]; + m_episodic_ep_BHKF [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-28\\nUser inquired about upcoming\\nsporting events in Speyer and\\nlocal tourism board contact"]; + m_episodic_ep_ZBFP [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-28\\nUser requested contact details of\\nthe tourism board of Speyer"]; + m_semantic_sem_XDIB [shape=component, fillcolor="#dff7ff", color="#2f7f9f", label="S: 2026-06-12\\nTourism Board of Speyer"]; + m_episodic_ep_BYVY [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-22\\nUser seeks advice on optimizing\\nmarketing strategy for new product\\nlaunch with focus on influencer\\npartnerships and UGC campaigns"]; + m_semantic_sem_L6Y2 [shape=component, fillcolor="#dff7ff", color="#2f7f9f", label="S: 2026-06-12\\nMarketing Strategy Optimization\\nfor Product Launch"]; + m_episodic_ep_OZYA [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-25\\nUser requested tips for optimizing\\nLinkedIn profile to showcase\\npersonal brand"]; + m_semantic_sem_ENBE [shape=component, fillcolor="#dff7ff", color="#2f7f9f", label="S: 2026-06-12\\nHybrid Approach to Tailoring\\nResume and LinkedIn Profiles"]; + m_semantic_sem_NXBS [shape=component, fillcolor="#dff7ff", color="#2f7f9f", label="S: 2026-06-12\\nPhrasing Work Hours During Peak\\nCampaign Seasons for Resume and\\nLinkedIn"]; + } + + e_v6ent_5975a01a4e214c608b9e3fc8 -> m_episodic_ep_ALFW [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_5975a01a4e214c608b9e3fc8 -> m_semantic_sem_NKDT [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_5b8b61c6312e4d689dd5d0c7 -> m_episodic_ep_GE47 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_5b8b61c6312e4d689dd5d0c7 -> m_episodic_ep_ZYDH [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_68d2be1ac55c4dc8a4518082 -> m_episodic_ep_Y516 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_68d2be1ac55c4dc8a4518082 -> m_episodic_ep_D73Z [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_68d2be1ac55c4dc8a4518082 -> m_episodic_ep_DW59 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_68d2be1ac55c4dc8a4518082 -> m_episodic_ep_FHXA [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_68d2be1ac55c4dc8a4518082 -> m_semantic_sem_K4U6 [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_68d2be1ac55c4dc8a4518082 -> m_semantic_sem_U5XN [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_68d2be1ac55c4dc8a4518082 -> m_semantic_sem_M1Z4 [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_68d2be1ac55c4dc8a4518082 -> m_semantic_sem_BLFM [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_79fdaa7052be4febb2f5133e -> m_episodic_ep_YS17 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_79fdaa7052be4febb2f5133e -> m_semantic_sem_P8EX [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_79fdaa7052be4febb2f5133e -> m_semantic_sem_H3OE [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_b3145cf61ccb43959cc172a3 -> m_episodic_ep_IN1W [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_b3145cf61ccb43959cc172a3 -> m_episodic_ep_MQKY [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_b3145cf61ccb43959cc172a3 -> m_episodic_ep_Y99G [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_b3145cf61ccb43959cc172a3 -> m_semantic_sem_VMO5 [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_b3145cf61ccb43959cc172a3 -> m_semantic_sem_M1Z4 [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_b457cfd78d5d4c0fa861c36c -> m_episodic_ep_IN1W [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_b457cfd78d5d4c0fa861c36c -> m_episodic_ep_MQKY [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_b457cfd78d5d4c0fa861c36c -> m_episodic_ep_YLXE [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_b457cfd78d5d4c0fa861c36c -> m_semantic_sem_VMO5 [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_b457cfd78d5d4c0fa861c36c -> m_semantic_sem_VNOL [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_b660a5aa2f9c456f9a504555 -> m_episodic_ep_I530 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_b660a5aa2f9c456f9a504555 -> m_semantic_sem_QLT2 [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_b8adf74723cc46fa8ee63c75 -> m_episodic_ep_D73Z [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_b8adf74723cc46fa8ee63c75 -> m_semantic_sem_K4U6 [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_beea932be9e6430da7e9dafc -> m_episodic_ep_BHKF [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_beea932be9e6430da7e9dafc -> m_episodic_ep_ZBFP [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_beea932be9e6430da7e9dafc -> m_semantic_sem_XDIB [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_cea74e557ff0489e97cef5cc -> m_episodic_ep_BYVY [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_cea74e557ff0489e97cef5cc -> m_semantic_sem_QLT2 [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_cea74e557ff0489e97cef5cc -> m_semantic_sem_L6Y2 [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_d0c73799b65c4388ab1abdb3 -> m_episodic_ep_OZYA [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_d0c73799b65c4388ab1abdb3 -> m_episodic_ep_Y99G [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_d0c73799b65c4388ab1abdb3 -> m_semantic_sem_ENBE [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_d5ab25c292ec46ffa7b49374 -> m_episodic_ep_IN1W [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_d5ab25c292ec46ffa7b49374 -> m_episodic_ep_MQKY [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_d5ab25c292ec46ffa7b49374 -> m_episodic_ep_Y99G [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_d5ab25c292ec46ffa7b49374 -> m_semantic_sem_VMO5 [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_d5ab25c292ec46ffa7b49374 -> m_semantic_sem_M1Z4 [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_e67975427d9b4847a27a832e -> m_episodic_ep_Y99G [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_e67975427d9b4847a27a832e -> m_semantic_sem_M1Z4 [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_e67975427d9b4847a27a832e -> m_semantic_sem_BLFM [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_f189b88db1e34980af44f526 -> m_episodic_ep_Y99G [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_f189b88db1e34980af44f526 -> m_semantic_sem_ENBE [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_f189b88db1e34980af44f526 -> m_semantic_sem_BLFM [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_f189b88db1e34980af44f526 -> m_semantic_sem_NXBS [label="DESCRIBED_BY", color="#9b6bd3"]; +} \ No newline at end of file diff --git a/docs/graph_memory_v6/visualizations/topic_career.png b/docs/graph_memory_v6/visualizations/topic_career.png new file mode 100644 index 000000000..2c3f2a66a Binary files /dev/null and b/docs/graph_memory_v6/visualizations/topic_career.png differ diff --git a/docs/graph_memory_v6/visualizations/topic_career.svg b/docs/graph_memory_v6/visualizations/topic_career.svg new file mode 100644 index 000000000..a15ef21e9 --- /dev/null +++ b/docs/graph_memory_v6/visualizations/topic_career.svg @@ -0,0 +1,672 @@ + + + + + + +G + +Career / Campaign Work Detail + +cluster_entities + +V6Entity + + +cluster_memories + +V6MemoryRef -> PG flat memory + + + +e_v6ent_68d2be1ac55c4dc8a4518082 + +LinkedIn\n(Content, d=9) + + + +m_episodic_ep_Y516 + +E: 2023-05-25\nUser inquired about sharing\ninsights from LinkedIn group\nwithout being promotional + + + +e_v6ent_68d2be1ac55c4dc8a4518082->m_episodic_ep_Y516 + + +APPEARS_IN + + + +m_episodic_ep_D73Z + +E: 2023-05-25\nUser requested advice on creating\nengaging content on LinkedIn to\ndrive interactions + + + +e_v6ent_68d2be1ac55c4dc8a4518082->m_episodic_ep_D73Z + + +APPEARS_IN + + + +m_episodic_ep_DW59 + +E: 2023-05-25\nUser engaged with 'Marketing\nProfessionals' group on LinkedIn\nand sought resources on personal\nbranding + + + +e_v6ent_68d2be1ac55c4dc8a4518082->m_episodic_ep_DW59 + + +APPEARS_IN + + + +m_episodic_ep_FHXA + +E: 2023-05-23\nAssistant provided tips on finding\na mentor and making the most of\nmentorship. + + + +e_v6ent_68d2be1ac55c4dc8a4518082->m_episodic_ep_FHXA + + +APPEARS_IN + + + +m_semantic_sem_K4U6 + + + +S: 2026-06-12\nCreating Engaging Content on\nLinkedIn + + + +e_v6ent_68d2be1ac55c4dc8a4518082->m_semantic_sem_K4U6 + + +DESCRIBED_BY + + + +m_semantic_sem_U5XN + + + +S: 2026-06-12\nMentorship Guidance for Career\nGrowth in Digital Marketing + + + +e_v6ent_68d2be1ac55c4dc8a4518082->m_semantic_sem_U5XN + + +DESCRIBED_BY + + + +m_semantic_sem_M1Z4 + + + +S: 2026-06-12\nEmphasizing Time Management and\nFast-Paced Environment Skills in\nSenior Marketing Roles + + + +e_v6ent_68d2be1ac55c4dc8a4518082->m_semantic_sem_M1Z4 + + +DESCRIBED_BY + + + +m_semantic_sem_BLFM + + + +S: 2026-06-12\nHighlighting Certifications in\nGoogle Analytics and HubSpot\nInbound Marketing in Job\nApplications + + + +e_v6ent_68d2be1ac55c4dc8a4518082->m_semantic_sem_BLFM + + +DESCRIBED_BY + + + +e_v6ent_b3145cf61ccb43959cc172a3 + +Content Marketing\nStrategist\n(Concept, d=5) + + + +e_v6ent_b3145cf61ccb43959cc172a3->m_semantic_sem_M1Z4 + + +DESCRIBED_BY + + + +m_episodic_ep_IN1W + +E: 2023-05-23\nUser plans to take 3 days off next\nweek and requests help\nprioritizing work tasks and\ncreating a schedule. + + + +e_v6ent_b3145cf61ccb43959cc172a3->m_episodic_ep_IN1W + + +APPEARS_IN + + + +m_episodic_ep_MQKY + +E: 2023-05-23\nUser expressed interest in\ntransitioning to specialized\ndigital marketing roles and\nrequested career development\nadvice. + + + +e_v6ent_b3145cf61ccb43959cc172a3->m_episodic_ep_MQKY + + +APPEARS_IN + + + +m_episodic_ep_Y99G + +E: 2023-05-21\nAssistant provided detailed\nguidance on updating resume and\nLinkedIn profile for senior\nmarketing roles + + + +e_v6ent_b3145cf61ccb43959cc172a3->m_episodic_ep_Y99G + + +APPEARS_IN + + + +m_semantic_sem_VMO5 + + + +S: 2026-06-12\nCareer Development Advice for\nDigital Marketing Specialist\nTransitioning to Specialized Roles + + + +e_v6ent_b3145cf61ccb43959cc172a3->m_semantic_sem_VMO5 + + +DESCRIBED_BY + + + +e_v6ent_d5ab25c292ec46ffa7b49374 + +Digital Marketing\nConsultant\n(Concept, d=5) + + + +e_v6ent_d5ab25c292ec46ffa7b49374->m_semantic_sem_M1Z4 + + +DESCRIBED_BY + + + +e_v6ent_d5ab25c292ec46ffa7b49374->m_episodic_ep_IN1W + + +APPEARS_IN + + + +e_v6ent_d5ab25c292ec46ffa7b49374->m_episodic_ep_MQKY + + +APPEARS_IN + + + +e_v6ent_d5ab25c292ec46ffa7b49374->m_episodic_ep_Y99G + + +APPEARS_IN + + + +e_v6ent_d5ab25c292ec46ffa7b49374->m_semantic_sem_VMO5 + + +DESCRIBED_BY + + + +e_v6ent_b457cfd78d5d4c0fa861c36c + +Digital Marketing\nSpecialist\n(Person, d=5) + + + +e_v6ent_b457cfd78d5d4c0fa861c36c->m_episodic_ep_IN1W + + +APPEARS_IN + + + +e_v6ent_b457cfd78d5d4c0fa861c36c->m_episodic_ep_MQKY + + +APPEARS_IN + + + +e_v6ent_b457cfd78d5d4c0fa861c36c->m_semantic_sem_VMO5 + + +DESCRIBED_BY + + + +m_episodic_ep_YLXE + +E: 2023-05-23\nUser shared detailed schedule and\ntask prioritization plan for\nmanaging work before taking 3 days\noff. + + + +e_v6ent_b457cfd78d5d4c0fa861c36c->m_episodic_ep_YLXE + + +APPEARS_IN + + + +m_semantic_sem_VNOL + + + +S: 2026-06-12\nDigital Marketing Specialist\nWorkload and Scheduling + + + +e_v6ent_b457cfd78d5d4c0fa861c36c->m_semantic_sem_VNOL + + +DESCRIBED_BY + + + +e_v6ent_f189b88db1e34980af44f526 + +Resume\n(Content, d=4) + + + +e_v6ent_f189b88db1e34980af44f526->m_semantic_sem_BLFM + + +DESCRIBED_BY + + + +e_v6ent_f189b88db1e34980af44f526->m_episodic_ep_Y99G + + +APPEARS_IN + + + +m_semantic_sem_ENBE + + + +S: 2026-06-12\nHybrid Approach to Tailoring\nResume and LinkedIn Profiles + + + +e_v6ent_f189b88db1e34980af44f526->m_semantic_sem_ENBE + + +DESCRIBED_BY + + + +m_semantic_sem_NXBS + + + +S: 2026-06-12\nPhrasing Work Hours During Peak\nCampaign Seasons for Resume and\nLinkedIn + + + +e_v6ent_f189b88db1e34980af44f526->m_semantic_sem_NXBS + + +DESCRIBED_BY + + + +e_v6ent_cea74e557ff0489e97cef5cc + +Account-Based Marketing\n(Concept, d=3) + + + +m_semantic_sem_QLT2 + + + +S: 2026-06-12\nEmail Marketing and Account-Based\nMarketing for Product Launch + + + +e_v6ent_cea74e557ff0489e97cef5cc->m_semantic_sem_QLT2 + + +DESCRIBED_BY + + + +m_episodic_ep_BYVY + +E: 2023-05-22\nUser seeks advice on optimizing\nmarketing strategy for new product\nlaunch with focus on influencer\npartnerships and UGC campaigns + + + +e_v6ent_cea74e557ff0489e97cef5cc->m_episodic_ep_BYVY + + +APPEARS_IN + + + +m_semantic_sem_L6Y2 + + + +S: 2026-06-12\nMarketing Strategy Optimization\nfor Product Launch + + + +e_v6ent_cea74e557ff0489e97cef5cc->m_semantic_sem_L6Y2 + + +DESCRIBED_BY + + + +e_v6ent_e67975427d9b4847a27a832e + +HubSpot Inbound\nMarketing\n(Concept, d=3) + + + +e_v6ent_e67975427d9b4847a27a832e->m_semantic_sem_M1Z4 + + +DESCRIBED_BY + + + +e_v6ent_e67975427d9b4847a27a832e->m_semantic_sem_BLFM + + +DESCRIBED_BY + + + +e_v6ent_e67975427d9b4847a27a832e->m_episodic_ep_Y99G + + +APPEARS_IN + + + +e_v6ent_d0c73799b65c4388ab1abdb3 + +LinkedIn Profile\n(Content, d=3) + + + +e_v6ent_d0c73799b65c4388ab1abdb3->m_episodic_ep_Y99G + + +APPEARS_IN + + + +m_episodic_ep_OZYA + +E: 2023-05-25\nUser requested tips for optimizing\nLinkedIn profile to showcase\npersonal brand + + + +e_v6ent_d0c73799b65c4388ab1abdb3->m_episodic_ep_OZYA + + +APPEARS_IN + + + +e_v6ent_d0c73799b65c4388ab1abdb3->m_semantic_sem_ENBE + + +DESCRIBED_BY + + + +e_v6ent_79fdaa7052be4febb2f5133e + +Marketing\n(Concept, d=3) + + + +m_episodic_ep_YS17 + +E: 2023-05-26\nUser shares collaboration with\nsales team contact Tom and\ndiscusses sales strategies\nalignment. + + + +e_v6ent_79fdaa7052be4febb2f5133e->m_episodic_ep_YS17 + + +APPEARS_IN + + + +m_semantic_sem_P8EX + + + +S: 2026-06-12\nGo-to-Market Strategy for New\nProduct Launch + + + +e_v6ent_79fdaa7052be4febb2f5133e->m_semantic_sem_P8EX + + +DESCRIBED_BY + + + +m_semantic_sem_H3OE + + + +S: 2026-06-12\nInfluencer Partnerships and User-\nGenerated Content Campaigns in\nMarketing + + + +e_v6ent_79fdaa7052be4febb2f5133e->m_semantic_sem_H3OE + + +DESCRIBED_BY + + + +e_v6ent_beea932be9e6430da7e9dafc + +Speyer Tourismus\nMarketing GmbH\n(Organization, d=3) + + + +m_episodic_ep_BHKF + +E: 2023-05-28\nUser inquired about upcoming\nsporting events in Speyer and\nlocal tourism board contact + + + +e_v6ent_beea932be9e6430da7e9dafc->m_episodic_ep_BHKF + + +APPEARS_IN + + + +m_episodic_ep_ZBFP + +E: 2023-05-28\nUser requested contact details of\nthe tourism board of Speyer + + + +e_v6ent_beea932be9e6430da7e9dafc->m_episodic_ep_ZBFP + + +APPEARS_IN + + + +m_semantic_sem_XDIB + + + +S: 2026-06-12\nTourism Board of Speyer + + + +e_v6ent_beea932be9e6430da7e9dafc->m_semantic_sem_XDIB + + +DESCRIBED_BY + + + +e_v6ent_5b8b61c6312e4d689dd5d0c7 + +Campaign Content\n(Content, d=2) + + + +m_episodic_ep_GE47 + +E: 2023-05-23\nAssistant helped user prioritize\ntasks and create a detailed work\nschedule for the week before time\noff, including suggestions for\nfle... + + + +e_v6ent_5b8b61c6312e4d689dd5d0c7->m_episodic_ep_GE47 + + +APPEARS_IN + + + +m_episodic_ep_ZYDH + +E: 2023-05-23\nAssistant provided a detailed\nprioritized work schedule for the\nuser's week before taking time\noff. + + + +e_v6ent_5b8b61c6312e4d689dd5d0c7->m_episodic_ep_ZYDH + + +APPEARS_IN + + + +e_v6ent_5975a01a4e214c608b9e3fc8 + +Email Campaigns\n(Content, d=2) + + + +m_episodic_ep_ALFW + +E: 2023-05-26\nUser describes go-to-market\nstrategy collaboration with sales\nteam for new product launch. + + + +e_v6ent_5975a01a4e214c608b9e3fc8->m_episodic_ep_ALFW + + +APPEARS_IN + + + +m_semantic_sem_NKDT + + + +S: 2026-06-12\nMarketing Campaign Segmentation\nand Personalization + + + +e_v6ent_5975a01a4e214c608b9e3fc8->m_semantic_sem_NKDT + + +DESCRIBED_BY + + + +e_v6ent_b660a5aa2f9c456f9a504555 + +Email Marketing\n(Concept, d=2) + + + +m_episodic_ep_I530 + +E: 2023-05-22\nAssistant provides comprehensive\nmarketing strategy optimization\nadvice including influencer\npartnerships, UGC campaigns, email\nmarket... + + + +e_v6ent_b660a5aa2f9c456f9a504555->m_episodic_ep_I530 + + +APPEARS_IN + + + +e_v6ent_b660a5aa2f9c456f9a504555->m_semantic_sem_QLT2 + + +DESCRIBED_BY + + + +e_v6ent_b8adf74723cc46fa8ee63c75 + +LinkedIn Live Sessions\n(Event, d=2) + + + +e_v6ent_b8adf74723cc46fa8ee63c75->m_episodic_ep_D73Z + + +APPEARS_IN + + + +e_v6ent_b8adf74723cc46fa8ee63c75->m_semantic_sem_K4U6 + + +DESCRIBED_BY + + + diff --git a/docs/graph_memory_v6/visualizations/topic_fitness.dot b/docs/graph_memory_v6/visualizations/topic_fitness.dot new file mode 100644 index 000000000..f0d2e2656 --- /dev/null +++ b/docs/graph_memory_v6/visualizations/topic_fitness.dot @@ -0,0 +1,151 @@ +digraph G { + graph [rankdir=LR, bgcolor="white", splines=true, overlap=false, nodesep=0.35, ranksep=1.0]; + node [fontname="Helvetica", fontsize=10, style="filled,rounded", penwidth=1.2]; + edge [fontname="Helvetica", fontsize=8, color="#9aa4b2", arrowsize=0.55]; + label="Fitness / Health Devices Detail"; + labelloc="t"; + fontsize=20; + + subgraph cluster_entities { + label="V6Entity"; + color="#d8dee9"; + style="rounded"; + e_v6ent_b16675464a50408f92503dc2 [shape=ellipse, fillcolor="#eee4ff", color="#7c5bc7", label="BodyPump\\n(Concept, d=12)"]; + e_v6ent_6337e3ed265b439e8553fb88 [shape=ellipse, fillcolor="#ffe7bf", color="#bf7a12", label="Fitbit\\n(Organization, d=9)"]; + e_v6ent_53b19b7117e548a5bfa8e5a0 [shape=ellipse, fillcolor="#eee4ff", color="#7c5bc7", label="Fitness Goals\\n(Concept, d=7)"]; + e_v6ent_d0377e8c7d354fafab8c4f1d [shape=ellipse, fillcolor="#eee4ff", color="#7c5bc7", label="Healthy Snack Options\\n(Concept, d=7)"]; + e_v6ent_6fcb8431639342aba03d56bf [shape=ellipse, fillcolor="#eeeeee", color="#777777", label="Fitbit Versa 3\\n(Other, d=5)"]; + e_v6ent_49be410775a042f0a8c79805 [shape=ellipse, fillcolor="#ffe7bf", color="#bf7a12", label="Healthcare Providers\\n(Organization, d=5)"]; + e_v6ent_314618512556462889390537 [shape=ellipse, fillcolor="#eee4ff", color="#7c5bc7", label="Zumba\\n(Concept, d=4)"]; + e_v6ent_1fa753bc242447fb88bd514e [shape=ellipse, fillcolor="#eee4ff", color="#7c5bc7", label="Battery Health\\n(Concept, d=3)"]; + e_v6ent_e8db9ce700c24c2aa2dbd06a [shape=ellipse, fillcolor="#eeeeee", color="#777777", label="Fitness Center\\n(Other, d=3)"]; + e_v6ent_081f3858fcf54da18d36cd4c [shape=ellipse, fillcolor="#ffdcdc", color="#bf4f4f", label="Fitness Frenzy\\n(Content, d=3)"]; + e_v6ent_5aa5f1506d4c405da428eb82 [shape=ellipse, fillcolor="#eeeeee", color="#777777", label="Fitness Tracker\\n(Other, d=3)"]; + e_v6ent_f2ab1319a9cf4d3e81b669e9 [shape=ellipse, fillcolor="#d8e9ff", color="#3a6ea5", label="Healthcare Provider\\n(Person, d=3)"]; + e_v6ent_a58d986b006046b8a7f8c3b6 [shape=ellipse, fillcolor="#ffdcdc", color="#bf4f4f", label="Healthy Snack Recipes\\n(Content, d=3)"]; + e_v6ent_a46fca479d174252a3ef40f2 [shape=ellipse, fillcolor="#ffdcdc", color="#bf4f4f", label="Yoga\\n(Content, d=3)"]; + } + + subgraph cluster_memories { + label="V6MemoryRef -> PG flat memory"; + color="#d8dee9"; + style="rounded"; + m_episodic_ep_IUNK [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-20\\nAssistant recommended various\\nworkout playlists tailored for\\nBodyPump weightlifting classes,\\nincluding high-energy and\\nmotivational pl..."]; + m_semantic_sem_DHIA [shape=component, fillcolor="#dff7ff", color="#2f7f9f", label="S: 2026-06-12\\nWorkout Playlists for Zumba\\nClasses"]; + m_semantic_sem_W0XE [shape=component, fillcolor="#dff7ff", color="#2f7f9f", label="S: 2026-06-12\\nWorkout Playlist Recommendations\\nfor BodyPump"]; + m_episodic_ep_YPLY [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-25\\nUser seeks advice on extending\\nlaptop battery life and checking\\nbattery health, discusses proper\\ndisposal and external hard drive\\nuse"]; + m_semantic_sem_Y92F [shape=component, fillcolor="#dff7ff", color="#2f7f9f", label="S: 2026-06-12\\nLaptop RAM Upgrade Impact on\\nBattery Life"]; + m_semantic_sem_OW9V [shape=component, fillcolor="#dff7ff", color="#2f7f9f", label="S: 2026-06-12\\nLaptop Battery Lifespan and\\nReplacement Considerations"]; + m_episodic_ep_DRBH [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-21\\nAssistant provides workout\\nplaylist recommendations, Zumba\\nwarm-up tips, healthy snacks, and\\nmotivation advice"]; + m_episodic_ep_PR3N [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-21\\nUser seeks new workout playlists\\nfor Zumba and BodyPump classes"]; + m_semantic_sem_N1I1 [shape=component, fillcolor="#dff7ff", color="#2f7f9f", label="S: 2026-06-12\\nUser's Workout Routine and\\nMotivation Approach"]; + m_semantic_sem_GRO9 [shape=component, fillcolor="#dff7ff", color="#2f7f9f", label="S: 2026-06-12\\nMotivation and Avoiding Burnout in\\nFitness"]; + m_episodic_ep_RPBF [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-25\\nAssistant provided detailed\\nguidance on chronic sinusitis\\nmanagement including nasal sprays,\\nhumidifiers, and addressing\\nfatigue and j..."]; + m_episodic_ep_RRTX [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-20\\nAssistant encouraged user to\\nprioritize health and seek support\\nfrom healthcare providers,\\nacknowledged benign biopsy\\nresults."]; + m_episodic_ep_W2KV [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-20\\nAssistant provided general\\ninformation about colonoscopy\\nprocedure, purpose, preparation,\\nand recovery."]; + m_episodic_ep_YYY8 [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-20\\nAssistant provided general tips\\nfor effective nasal spray use and\\nacknowledged benign biopsy results\\nfrom dermatologist appointment."]; + m_semantic_sem_J41F [shape=component, fillcolor="#dff7ff", color="#2f7f9f", label="S: 2026-06-12\\nChronic Sinusitis Management"]; + m_episodic_ep_OVBQ [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-27\\nUser sought healthy snack recipes\\nfor fitness support and meal\\nprepping"]; + m_episodic_ep_QGIX [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-23\\nUser plans to focus on setting\\nspecific achievable fitness goals,\\ncreating a routine, and finding\\naccountability to get back on\\ntrack"]; + m_episodic_ep_ZF4Q [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-23\\nUser discusses using Fitbit Versa\\n3 to track activity and sleep and\\nseeks motivation tips for fitness\\ngoals"]; + m_episodic_ep_XX06 [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-20\\nAssistant provided detailed\\nhealthy snack options suitable for\\nwork to support fitness goals,\\nincluding fresh fruits, nuts,\\nprotein-ri..."]; + m_episodic_ep_AO2F [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-20\\nUser asked for healthy snack\\noptions to bring to work to\\nsupport fitness goals."]; + m_semantic_sem_YAME [shape=component, fillcolor="#dff7ff", color="#2f7f9f", label="S: 2026-06-12\\nMotivation and Consistency Tips\\nfor Fitness Goals"]; + m_semantic_sem_U8IC [shape=component, fillcolor="#dff7ff", color="#2f7f9f", label="S: 2026-06-12\\nHealthy Snack Options for Fitness\\nSupport at Work"]; + m_episodic_ep_YCT2 [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-23\\nUser seeks tips to stay active and\\nincrease daily step count to\\nsupport health and blood sugar\\nmanagement"]; + m_semantic_sem_ZN9D [shape=component, fillcolor="#dff7ff", color="#2f7f9f", label="S: 2026-06-12\\nTips to Stay Active and Increase\\nDaily Step Count"]; + m_semantic_sem_ECZ1 [shape=component, fillcolor="#dff7ff", color="#2f7f9f", label="S: 2026-06-12\\nIncreasing Daily Step Count"]; + m_episodic_ep_GFJP [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-22\\nUser seeks help ordering\\nreplacement batteries for Phonak\\nBTE hearing aids."]; + m_episodic_ep_WCP5 [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-21\\nAssistant recommends tips to\\nincrease daily step count and\\nmaintain guided breathing routine"]; + m_episodic_ep_E91V [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-21\\nUser sets a realistic daily step\\ncount goal of 6,000 steps"]; + m_episodic_ep_JNWD [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-21\\nUser plans to track progress and\\nset reminders for stretching\\nexercises"]; + m_episodic_ep_BYQR [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-21\\nUser commits to daily guided\\nbreathing sessions before bed"]; + m_semantic_sem_GT6C [shape=component, fillcolor="#dff7ff", color="#2f7f9f", label="S: 2026-06-12\\nTracking Progress and Setting\\nReminders for Stretching Exercises"]; + m_semantic_sem_S0P9 [shape=component, fillcolor="#dff7ff", color="#2f7f9f", label="S: 2026-06-12\\nConsistency Tips for Guided\\nBreathing Sessions on Fitbit"]; + m_semantic_sem_NYJG [shape=component, fillcolor="#dff7ff", color="#2f7f9f", label="S: 2026-06-12\\nTracking Progress and Setting\\nReminders for Fitness Activities"]; + m_episodic_ep_TP33 [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-22\\nUser commits to tracking sleep\\npatterns and improving sleep\\nhabits with Fitbit Versa 3"]; + m_episodic_ep_B371 [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-22\\nUser plans to increase daily step\\ncount and track sleep using Fitbit\\nVersa 3"]; + m_semantic_sem_KU72 [shape=component, fillcolor="#dff7ff", color="#2f7f9f", label="S: 2026-06-12\\nIncreasing Daily Step Count with\\nFitbit Versa 3"]; + m_semantic_sem_BI8F [shape=component, fillcolor="#dff7ff", color="#2f7f9f", label="S: 2026-06-12\\nChill Hip Hop and R&B Playlists\\nfor Yoga"]; + m_episodic_ep_YJB4 [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-27\\nUser combines Hip Hop Abs class\\nroutine with healthy snack recipes\\nfor fitness"]; + m_semantic_sem_YM87 [shape=component, fillcolor="#dff7ff", color="#2f7f9f", label="S: 2026-06-12\\nHealthy Snack Recipes for Fitness\\nand Meal Prepping"]; + m_episodic_ep_D4JU [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-20\\nUser requested recommendations for\\nprotein powders suitable for\\nbeginners to support BodyPump\\nweightlifting classes."]; + m_episodic_ep_EKC5 [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-20\\nAssistant provided guidance on\\nincorporating protein shakes into\\nfitness routines, including\\ntiming, protein types, recipes,\\nand BodyP..."]; + m_episodic_ep_KC0N [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-20\\nUser shared that they meal prep on\\nSundays and plan to try protein-\\nrich snacks like Greek yogurt and\\nhard-boiled eggs for BodyPump\\nsup..."]; + m_episodic_ep_KNF9 [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-20\\nUser requested workout playlist\\nrecommendations for BodyPump\\nweightlifting classes on Mondays\\nat 6:30 PM."]; + m_episodic_ep_LE5O [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-23\\nUser requests healthy snack\\noptions for blood sugar stability\\nthat are easy to prepare or grab\\non the go"]; + m_semantic_sem_H15A [shape=component, fillcolor="#dff7ff", color="#2f7f9f", label="S: 2026-06-13\\nHealthy Snack Options for Family\\nGatherings"]; + m_semantic_sem_C1P1 [shape=component, fillcolor="#dff7ff", color="#2f7f9f", label="S: 2026-06-12\\nHealthy Snack Options for Fitness\\nSupport"]; + m_episodic_ep_WRPP [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-20\\nUser inquires about Hilton Grand\\nVacations Club on the Las Vegas\\nStrip"]; + m_semantic_sem_KRAQ [shape=component, fillcolor="#dff7ff", color="#2f7f9f", label="S: 2026-06-12\\nComparison of Hilton Paris Eiffel\\nTower, Pullman Paris Tour Eiffel,\\nand Novotel Paris Tour Eiffel\\nHotels"]; + m_semantic_sem_IF8Z [shape=component, fillcolor="#dff7ff", color="#2f7f9f", label="S: 2026-06-12\\nHilton Paris Eiffel Tower Hotel\\nDetails"]; + m_semantic_sem_U1BF [shape=component, fillcolor="#dff7ff", color="#2f7f9f", label="S: 2026-06-12\\nNebulizer Treatment Frequency and\\nDuration"]; + m_semantic_sem_F8VY [shape=component, fillcolor="#dff7ff", color="#2f7f9f", label="S: 2026-06-12\\nSteam Inhalation Method for Sinus\\nRelief"]; + } + + e_v6ent_081f3858fcf54da18d36cd4c -> m_episodic_ep_IUNK [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_081f3858fcf54da18d36cd4c -> m_semantic_sem_DHIA [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_081f3858fcf54da18d36cd4c -> m_semantic_sem_W0XE [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_1fa753bc242447fb88bd514e -> m_episodic_ep_YPLY [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_1fa753bc242447fb88bd514e -> m_semantic_sem_Y92F [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_1fa753bc242447fb88bd514e -> m_semantic_sem_OW9V [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_314618512556462889390537 -> m_episodic_ep_DRBH [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_314618512556462889390537 -> m_episodic_ep_PR3N [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_314618512556462889390537 -> m_semantic_sem_N1I1 [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_314618512556462889390537 -> m_semantic_sem_GRO9 [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_49be410775a042f0a8c79805 -> m_episodic_ep_RPBF [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_49be410775a042f0a8c79805 -> m_episodic_ep_RRTX [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_49be410775a042f0a8c79805 -> m_episodic_ep_W2KV [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_49be410775a042f0a8c79805 -> m_episodic_ep_YYY8 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_49be410775a042f0a8c79805 -> m_semantic_sem_J41F [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_53b19b7117e548a5bfa8e5a0 -> m_episodic_ep_OVBQ [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_53b19b7117e548a5bfa8e5a0 -> m_episodic_ep_QGIX [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_53b19b7117e548a5bfa8e5a0 -> m_episodic_ep_ZF4Q [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_53b19b7117e548a5bfa8e5a0 -> m_episodic_ep_XX06 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_53b19b7117e548a5bfa8e5a0 -> m_episodic_ep_AO2F [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_53b19b7117e548a5bfa8e5a0 -> m_semantic_sem_YAME [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_53b19b7117e548a5bfa8e5a0 -> m_semantic_sem_U8IC [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_5aa5f1506d4c405da428eb82 -> m_episodic_ep_YCT2 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_5aa5f1506d4c405da428eb82 -> m_semantic_sem_ZN9D [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_5aa5f1506d4c405da428eb82 -> m_semantic_sem_ECZ1 [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_6337e3ed265b439e8553fb88 -> m_episodic_ep_GFJP [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_6337e3ed265b439e8553fb88 -> m_episodic_ep_WCP5 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_6337e3ed265b439e8553fb88 -> m_episodic_ep_E91V [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_6337e3ed265b439e8553fb88 -> m_episodic_ep_JNWD [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_6337e3ed265b439e8553fb88 -> m_episodic_ep_BYQR [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_6337e3ed265b439e8553fb88 -> m_semantic_sem_GT6C [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_6337e3ed265b439e8553fb88 -> m_semantic_sem_S0P9 [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_6337e3ed265b439e8553fb88 -> m_semantic_sem_NYJG [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_6fcb8431639342aba03d56bf -> m_episodic_ep_ZF4Q [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_6fcb8431639342aba03d56bf -> m_episodic_ep_TP33 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_6fcb8431639342aba03d56bf -> m_episodic_ep_B371 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_6fcb8431639342aba03d56bf -> m_semantic_sem_YAME [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_6fcb8431639342aba03d56bf -> m_semantic_sem_KU72 [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_a46fca479d174252a3ef40f2 -> m_semantic_sem_BI8F [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_a46fca479d174252a3ef40f2 -> m_semantic_sem_N1I1 [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_a46fca479d174252a3ef40f2 -> m_semantic_sem_GRO9 [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_a58d986b006046b8a7f8c3b6 -> m_episodic_ep_YJB4 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_a58d986b006046b8a7f8c3b6 -> m_episodic_ep_OVBQ [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_a58d986b006046b8a7f8c3b6 -> m_semantic_sem_YM87 [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_b16675464a50408f92503dc2 -> m_episodic_ep_DRBH [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_b16675464a50408f92503dc2 -> m_episodic_ep_PR3N [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_b16675464a50408f92503dc2 -> m_episodic_ep_D4JU [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_b16675464a50408f92503dc2 -> m_episodic_ep_EKC5 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_b16675464a50408f92503dc2 -> m_episodic_ep_KC0N [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_b16675464a50408f92503dc2 -> m_episodic_ep_IUNK [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_b16675464a50408f92503dc2 -> m_episodic_ep_KNF9 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_b16675464a50408f92503dc2 -> m_semantic_sem_N1I1 [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_d0377e8c7d354fafab8c4f1d -> m_episodic_ep_LE5O [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_d0377e8c7d354fafab8c4f1d -> m_episodic_ep_PR3N [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_d0377e8c7d354fafab8c4f1d -> m_episodic_ep_XX06 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_d0377e8c7d354fafab8c4f1d -> m_episodic_ep_AO2F [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_d0377e8c7d354fafab8c4f1d -> m_semantic_sem_H15A [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_d0377e8c7d354fafab8c4f1d -> m_semantic_sem_U8IC [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_d0377e8c7d354fafab8c4f1d -> m_semantic_sem_C1P1 [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_e8db9ce700c24c2aa2dbd06a -> m_episodic_ep_WRPP [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_e8db9ce700c24c2aa2dbd06a -> m_semantic_sem_KRAQ [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_e8db9ce700c24c2aa2dbd06a -> m_semantic_sem_IF8Z [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_f2ab1319a9cf4d3e81b669e9 -> m_semantic_sem_U1BF [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_f2ab1319a9cf4d3e81b669e9 -> m_semantic_sem_F8VY [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_f2ab1319a9cf4d3e81b669e9 -> m_semantic_sem_ZN9D [label="DESCRIBED_BY", color="#9b6bd3"]; +} \ No newline at end of file diff --git a/docs/graph_memory_v6/visualizations/topic_fitness.png b/docs/graph_memory_v6/visualizations/topic_fitness.png new file mode 100644 index 000000000..f8ec4543b Binary files /dev/null and b/docs/graph_memory_v6/visualizations/topic_fitness.png differ diff --git a/docs/graph_memory_v6/visualizations/topic_fitness.svg b/docs/graph_memory_v6/visualizations/topic_fitness.svg new file mode 100644 index 000000000..3a3d6a1a2 --- /dev/null +++ b/docs/graph_memory_v6/visualizations/topic_fitness.svg @@ -0,0 +1,915 @@ + + + + + + +G + +Fitness / Health Devices Detail + +cluster_entities + +V6Entity + + +cluster_memories + +V6MemoryRef -> PG flat memory + + + +e_v6ent_b16675464a50408f92503dc2 + +BodyPump\n(Concept, d=12) + + + +m_episodic_ep_IUNK + +E: 2023-05-20\nAssistant recommended various\nworkout playlists tailored for\nBodyPump weightlifting classes,\nincluding high-energy and\nmotivational pl... + + + +e_v6ent_b16675464a50408f92503dc2->m_episodic_ep_IUNK + + +APPEARS_IN + + + +m_episodic_ep_DRBH + +E: 2023-05-21\nAssistant provides workout\nplaylist recommendations, Zumba\nwarm-up tips, healthy snacks, and\nmotivation advice + + + +e_v6ent_b16675464a50408f92503dc2->m_episodic_ep_DRBH + + +APPEARS_IN + + + +m_episodic_ep_PR3N + +E: 2023-05-21\nUser seeks new workout playlists\nfor Zumba and BodyPump classes + + + +e_v6ent_b16675464a50408f92503dc2->m_episodic_ep_PR3N + + +APPEARS_IN + + + +m_semantic_sem_N1I1 + + + +S: 2026-06-12\nUser's Workout Routine and\nMotivation Approach + + + +e_v6ent_b16675464a50408f92503dc2->m_semantic_sem_N1I1 + + +DESCRIBED_BY + + + +m_episodic_ep_D4JU + +E: 2023-05-20\nUser requested recommendations for\nprotein powders suitable for\nbeginners to support BodyPump\nweightlifting classes. + + + +e_v6ent_b16675464a50408f92503dc2->m_episodic_ep_D4JU + + +APPEARS_IN + + + +m_episodic_ep_EKC5 + +E: 2023-05-20\nAssistant provided guidance on\nincorporating protein shakes into\nfitness routines, including\ntiming, protein types, recipes,\nand BodyP... + + + +e_v6ent_b16675464a50408f92503dc2->m_episodic_ep_EKC5 + + +APPEARS_IN + + + +m_episodic_ep_KC0N + +E: 2023-05-20\nUser shared that they meal prep on\nSundays and plan to try protein-\nrich snacks like Greek yogurt and\nhard-boiled eggs for BodyPump\nsup... + + + +e_v6ent_b16675464a50408f92503dc2->m_episodic_ep_KC0N + + +APPEARS_IN + + + +m_episodic_ep_KNF9 + +E: 2023-05-20\nUser requested workout playlist\nrecommendations for BodyPump\nweightlifting classes on Mondays\nat 6:30 PM. + + + +e_v6ent_b16675464a50408f92503dc2->m_episodic_ep_KNF9 + + +APPEARS_IN + + + +e_v6ent_6337e3ed265b439e8553fb88 + +Fitbit\n(Organization, d=9) + + + +m_episodic_ep_GFJP + +E: 2023-05-22\nUser seeks help ordering\nreplacement batteries for Phonak\nBTE hearing aids. + + + +e_v6ent_6337e3ed265b439e8553fb88->m_episodic_ep_GFJP + + +APPEARS_IN + + + +m_episodic_ep_WCP5 + +E: 2023-05-21\nAssistant recommends tips to\nincrease daily step count and\nmaintain guided breathing routine + + + +e_v6ent_6337e3ed265b439e8553fb88->m_episodic_ep_WCP5 + + +APPEARS_IN + + + +m_episodic_ep_E91V + +E: 2023-05-21\nUser sets a realistic daily step\ncount goal of 6,000 steps + + + +e_v6ent_6337e3ed265b439e8553fb88->m_episodic_ep_E91V + + +APPEARS_IN + + + +m_episodic_ep_JNWD + +E: 2023-05-21\nUser plans to track progress and\nset reminders for stretching\nexercises + + + +e_v6ent_6337e3ed265b439e8553fb88->m_episodic_ep_JNWD + + +APPEARS_IN + + + +m_episodic_ep_BYQR + +E: 2023-05-21\nUser commits to daily guided\nbreathing sessions before bed + + + +e_v6ent_6337e3ed265b439e8553fb88->m_episodic_ep_BYQR + + +APPEARS_IN + + + +m_semantic_sem_GT6C + + + +S: 2026-06-12\nTracking Progress and Setting\nReminders for Stretching Exercises + + + +e_v6ent_6337e3ed265b439e8553fb88->m_semantic_sem_GT6C + + +DESCRIBED_BY + + + +m_semantic_sem_S0P9 + + + +S: 2026-06-12\nConsistency Tips for Guided\nBreathing Sessions on Fitbit + + + +e_v6ent_6337e3ed265b439e8553fb88->m_semantic_sem_S0P9 + + +DESCRIBED_BY + + + +m_semantic_sem_NYJG + + + +S: 2026-06-12\nTracking Progress and Setting\nReminders for Fitness Activities + + + +e_v6ent_6337e3ed265b439e8553fb88->m_semantic_sem_NYJG + + +DESCRIBED_BY + + + +e_v6ent_53b19b7117e548a5bfa8e5a0 + +Fitness Goals\n(Concept, d=7) + + + +m_episodic_ep_OVBQ + +E: 2023-05-27\nUser sought healthy snack recipes\nfor fitness support and meal\nprepping + + + +e_v6ent_53b19b7117e548a5bfa8e5a0->m_episodic_ep_OVBQ + + +APPEARS_IN + + + +m_episodic_ep_QGIX + +E: 2023-05-23\nUser plans to focus on setting\nspecific achievable fitness goals,\ncreating a routine, and finding\naccountability to get back on\ntrack + + + +e_v6ent_53b19b7117e548a5bfa8e5a0->m_episodic_ep_QGIX + + +APPEARS_IN + + + +m_episodic_ep_ZF4Q + +E: 2023-05-23\nUser discusses using Fitbit Versa\n3 to track activity and sleep and\nseeks motivation tips for fitness\ngoals + + + +e_v6ent_53b19b7117e548a5bfa8e5a0->m_episodic_ep_ZF4Q + + +APPEARS_IN + + + +m_episodic_ep_XX06 + +E: 2023-05-20\nAssistant provided detailed\nhealthy snack options suitable for\nwork to support fitness goals,\nincluding fresh fruits, nuts,\nprotein-ri... + + + +e_v6ent_53b19b7117e548a5bfa8e5a0->m_episodic_ep_XX06 + + +APPEARS_IN + + + +m_episodic_ep_AO2F + +E: 2023-05-20\nUser asked for healthy snack\noptions to bring to work to\nsupport fitness goals. + + + +e_v6ent_53b19b7117e548a5bfa8e5a0->m_episodic_ep_AO2F + + +APPEARS_IN + + + +m_semantic_sem_YAME + + + +S: 2026-06-12\nMotivation and Consistency Tips\nfor Fitness Goals + + + +e_v6ent_53b19b7117e548a5bfa8e5a0->m_semantic_sem_YAME + + +DESCRIBED_BY + + + +m_semantic_sem_U8IC + + + +S: 2026-06-12\nHealthy Snack Options for Fitness\nSupport at Work + + + +e_v6ent_53b19b7117e548a5bfa8e5a0->m_semantic_sem_U8IC + + +DESCRIBED_BY + + + +e_v6ent_d0377e8c7d354fafab8c4f1d + +Healthy Snack Options\n(Concept, d=7) + + + +e_v6ent_d0377e8c7d354fafab8c4f1d->m_episodic_ep_PR3N + + +APPEARS_IN + + + +e_v6ent_d0377e8c7d354fafab8c4f1d->m_episodic_ep_XX06 + + +APPEARS_IN + + + +e_v6ent_d0377e8c7d354fafab8c4f1d->m_episodic_ep_AO2F + + +APPEARS_IN + + + +e_v6ent_d0377e8c7d354fafab8c4f1d->m_semantic_sem_U8IC + + +DESCRIBED_BY + + + +m_episodic_ep_LE5O + +E: 2023-05-23\nUser requests healthy snack\noptions for blood sugar stability\nthat are easy to prepare or grab\non the go + + + +e_v6ent_d0377e8c7d354fafab8c4f1d->m_episodic_ep_LE5O + + +APPEARS_IN + + + +m_semantic_sem_H15A + + + +S: 2026-06-13\nHealthy Snack Options for Family\nGatherings + + + +e_v6ent_d0377e8c7d354fafab8c4f1d->m_semantic_sem_H15A + + +DESCRIBED_BY + + + +m_semantic_sem_C1P1 + + + +S: 2026-06-12\nHealthy Snack Options for Fitness\nSupport + + + +e_v6ent_d0377e8c7d354fafab8c4f1d->m_semantic_sem_C1P1 + + +DESCRIBED_BY + + + +e_v6ent_6fcb8431639342aba03d56bf + +Fitbit Versa 3\n(Other, d=5) + + + +e_v6ent_6fcb8431639342aba03d56bf->m_episodic_ep_ZF4Q + + +APPEARS_IN + + + +e_v6ent_6fcb8431639342aba03d56bf->m_semantic_sem_YAME + + +DESCRIBED_BY + + + +m_episodic_ep_TP33 + +E: 2023-05-22\nUser commits to tracking sleep\npatterns and improving sleep\nhabits with Fitbit Versa 3 + + + +e_v6ent_6fcb8431639342aba03d56bf->m_episodic_ep_TP33 + + +APPEARS_IN + + + +m_episodic_ep_B371 + +E: 2023-05-22\nUser plans to increase daily step\ncount and track sleep using Fitbit\nVersa 3 + + + +e_v6ent_6fcb8431639342aba03d56bf->m_episodic_ep_B371 + + +APPEARS_IN + + + +m_semantic_sem_KU72 + + + +S: 2026-06-12\nIncreasing Daily Step Count with\nFitbit Versa 3 + + + +e_v6ent_6fcb8431639342aba03d56bf->m_semantic_sem_KU72 + + +DESCRIBED_BY + + + +e_v6ent_49be410775a042f0a8c79805 + +Healthcare Providers\n(Organization, d=5) + + + +m_episodic_ep_RPBF + +E: 2023-05-25\nAssistant provided detailed\nguidance on chronic sinusitis\nmanagement including nasal sprays,\nhumidifiers, and addressing\nfatigue and j... + + + +e_v6ent_49be410775a042f0a8c79805->m_episodic_ep_RPBF + + +APPEARS_IN + + + +m_episodic_ep_RRTX + +E: 2023-05-20\nAssistant encouraged user to\nprioritize health and seek support\nfrom healthcare providers,\nacknowledged benign biopsy\nresults. + + + +e_v6ent_49be410775a042f0a8c79805->m_episodic_ep_RRTX + + +APPEARS_IN + + + +m_episodic_ep_W2KV + +E: 2023-05-20\nAssistant provided general\ninformation about colonoscopy\nprocedure, purpose, preparation,\nand recovery. + + + +e_v6ent_49be410775a042f0a8c79805->m_episodic_ep_W2KV + + +APPEARS_IN + + + +m_episodic_ep_YYY8 + +E: 2023-05-20\nAssistant provided general tips\nfor effective nasal spray use and\nacknowledged benign biopsy results\nfrom dermatologist appointment. + + + +e_v6ent_49be410775a042f0a8c79805->m_episodic_ep_YYY8 + + +APPEARS_IN + + + +m_semantic_sem_J41F + + + +S: 2026-06-12\nChronic Sinusitis Management + + + +e_v6ent_49be410775a042f0a8c79805->m_semantic_sem_J41F + + +DESCRIBED_BY + + + +e_v6ent_314618512556462889390537 + +Zumba\n(Concept, d=4) + + + +e_v6ent_314618512556462889390537->m_episodic_ep_DRBH + + +APPEARS_IN + + + +e_v6ent_314618512556462889390537->m_episodic_ep_PR3N + + +APPEARS_IN + + + +e_v6ent_314618512556462889390537->m_semantic_sem_N1I1 + + +DESCRIBED_BY + + + +m_semantic_sem_GRO9 + + + +S: 2026-06-12\nMotivation and Avoiding Burnout in\nFitness + + + +e_v6ent_314618512556462889390537->m_semantic_sem_GRO9 + + +DESCRIBED_BY + + + +e_v6ent_1fa753bc242447fb88bd514e + +Battery Health\n(Concept, d=3) + + + +m_episodic_ep_YPLY + +E: 2023-05-25\nUser seeks advice on extending\nlaptop battery life and checking\nbattery health, discusses proper\ndisposal and external hard drive\nuse + + + +e_v6ent_1fa753bc242447fb88bd514e->m_episodic_ep_YPLY + + +APPEARS_IN + + + +m_semantic_sem_Y92F + + + +S: 2026-06-12\nLaptop RAM Upgrade Impact on\nBattery Life + + + +e_v6ent_1fa753bc242447fb88bd514e->m_semantic_sem_Y92F + + +DESCRIBED_BY + + + +m_semantic_sem_OW9V + + + +S: 2026-06-12\nLaptop Battery Lifespan and\nReplacement Considerations + + + +e_v6ent_1fa753bc242447fb88bd514e->m_semantic_sem_OW9V + + +DESCRIBED_BY + + + +e_v6ent_e8db9ce700c24c2aa2dbd06a + +Fitness Center\n(Other, d=3) + + + +m_episodic_ep_WRPP + +E: 2023-05-20\nUser inquires about Hilton Grand\nVacations Club on the Las Vegas\nStrip + + + +e_v6ent_e8db9ce700c24c2aa2dbd06a->m_episodic_ep_WRPP + + +APPEARS_IN + + + +m_semantic_sem_KRAQ + + + +S: 2026-06-12\nComparison of Hilton Paris Eiffel\nTower, Pullman Paris Tour Eiffel,\nand Novotel Paris Tour Eiffel\nHotels + + + +e_v6ent_e8db9ce700c24c2aa2dbd06a->m_semantic_sem_KRAQ + + +DESCRIBED_BY + + + +m_semantic_sem_IF8Z + + + +S: 2026-06-12\nHilton Paris Eiffel Tower Hotel\nDetails + + + +e_v6ent_e8db9ce700c24c2aa2dbd06a->m_semantic_sem_IF8Z + + +DESCRIBED_BY + + + +e_v6ent_081f3858fcf54da18d36cd4c + +Fitness Frenzy\n(Content, d=3) + + + +e_v6ent_081f3858fcf54da18d36cd4c->m_episodic_ep_IUNK + + +APPEARS_IN + + + +m_semantic_sem_DHIA + + + +S: 2026-06-12\nWorkout Playlists for Zumba\nClasses + + + +e_v6ent_081f3858fcf54da18d36cd4c->m_semantic_sem_DHIA + + +DESCRIBED_BY + + + +m_semantic_sem_W0XE + + + +S: 2026-06-12\nWorkout Playlist Recommendations\nfor BodyPump + + + +e_v6ent_081f3858fcf54da18d36cd4c->m_semantic_sem_W0XE + + +DESCRIBED_BY + + + +e_v6ent_5aa5f1506d4c405da428eb82 + +Fitness Tracker\n(Other, d=3) + + + +m_episodic_ep_YCT2 + +E: 2023-05-23\nUser seeks tips to stay active and\nincrease daily step count to\nsupport health and blood sugar\nmanagement + + + +e_v6ent_5aa5f1506d4c405da428eb82->m_episodic_ep_YCT2 + + +APPEARS_IN + + + +m_semantic_sem_ZN9D + + + +S: 2026-06-12\nTips to Stay Active and Increase\nDaily Step Count + + + +e_v6ent_5aa5f1506d4c405da428eb82->m_semantic_sem_ZN9D + + +DESCRIBED_BY + + + +m_semantic_sem_ECZ1 + + + +S: 2026-06-12\nIncreasing Daily Step Count + + + +e_v6ent_5aa5f1506d4c405da428eb82->m_semantic_sem_ECZ1 + + +DESCRIBED_BY + + + +e_v6ent_f2ab1319a9cf4d3e81b669e9 + +Healthcare Provider\n(Person, d=3) + + + +e_v6ent_f2ab1319a9cf4d3e81b669e9->m_semantic_sem_ZN9D + + +DESCRIBED_BY + + + +m_semantic_sem_U1BF + + + +S: 2026-06-12\nNebulizer Treatment Frequency and\nDuration + + + +e_v6ent_f2ab1319a9cf4d3e81b669e9->m_semantic_sem_U1BF + + +DESCRIBED_BY + + + +m_semantic_sem_F8VY + + + +S: 2026-06-12\nSteam Inhalation Method for Sinus\nRelief + + + +e_v6ent_f2ab1319a9cf4d3e81b669e9->m_semantic_sem_F8VY + + +DESCRIBED_BY + + + +e_v6ent_a58d986b006046b8a7f8c3b6 + +Healthy Snack Recipes\n(Content, d=3) + + + +e_v6ent_a58d986b006046b8a7f8c3b6->m_episodic_ep_OVBQ + + +APPEARS_IN + + + +m_episodic_ep_YJB4 + +E: 2023-05-27\nUser combines Hip Hop Abs class\nroutine with healthy snack recipes\nfor fitness + + + +e_v6ent_a58d986b006046b8a7f8c3b6->m_episodic_ep_YJB4 + + +APPEARS_IN + + + +m_semantic_sem_YM87 + + + +S: 2026-06-12\nHealthy Snack Recipes for Fitness\nand Meal Prepping + + + +e_v6ent_a58d986b006046b8a7f8c3b6->m_semantic_sem_YM87 + + +DESCRIBED_BY + + + +e_v6ent_a46fca479d174252a3ef40f2 + +Yoga\n(Content, d=3) + + + +e_v6ent_a46fca479d174252a3ef40f2->m_semantic_sem_N1I1 + + +DESCRIBED_BY + + + +e_v6ent_a46fca479d174252a3ef40f2->m_semantic_sem_GRO9 + + +DESCRIBED_BY + + + +m_semantic_sem_BI8F + + + +S: 2026-06-12\nChill Hip Hop and R&B Playlists\nfor Yoga + + + +e_v6ent_a46fca479d174252a3ef40f2->m_semantic_sem_BI8F + + +DESCRIBED_BY + + + diff --git a/docs/graph_memory_v6/visualizations/topic_travel.dot b/docs/graph_memory_v6/visualizations/topic_travel.dot new file mode 100644 index 000000000..376d9b5be --- /dev/null +++ b/docs/graph_memory_v6/visualizations/topic_travel.dot @@ -0,0 +1,132 @@ +digraph G { + graph [rankdir=LR, bgcolor="white", splines=true, overlap=false, nodesep=0.35, ranksep=1.0]; + node [fontname="Helvetica", fontsize=10, style="filled,rounded", penwidth=1.2]; + edge [fontname="Helvetica", fontsize=8, color="#9aa4b2", arrowsize=0.55]; + label="Travel / Places Detail"; + labelloc="t"; + fontsize=20; + + subgraph cluster_entities { + label="V6Entity"; + color="#d8dee9"; + style="rounded"; + e_v6ent_ad07057ea8894b489269ea10 [shape=ellipse, fillcolor="#dff4df", color="#4a8f4a", label="Rome\\n(Location, d=16)"]; + e_v6ent_865162e16bd6455f9c7f3037 [shape=ellipse, fillcolor="#dff4df", color="#4a8f4a", label="Vatican\\n(Location, d=14)"]; + e_v6ent_4058cd532f2342e5b8b126ca [shape=ellipse, fillcolor="#dff4df", color="#4a8f4a", label="Natural Park of Moncayo\\nMountain\\n(Location, d=13)"]; + e_v6ent_453b59e9cd664cfdb5d12488 [shape=ellipse, fillcolor="#dff4df", color="#4a8f4a", label="Speyer\\n(Location, d=7)"]; + e_v6ent_19d488bf6174416c817f9c1e [shape=ellipse, fillcolor="#dff4df", color="#4a8f4a", label="Yosemite National Park\\n(Location, d=5)"]; + e_v6ent_0ab178b797744b9d8b66a000 [shape=ellipse, fillcolor="#dff4df", color="#4a8f4a", label="Vatican Museums\\n(Location, d=4)"]; + e_v6ent_beea932be9e6430da7e9dafc [shape=ellipse, fillcolor="#ffe7bf", color="#bf7a12", label="Speyer Tourismus\\nMarketing GmbH\\n(Organization, d=3)"]; + e_v6ent_4dfeb7caa0b0468295a1911e [shape=ellipse, fillcolor="#ffe7bf", color="#bf7a12", label="Caffè Vaticano\\n(Organization, d=2)"]; + e_v6ent_a267fc199d5f4bdeb5a5f113 [shape=ellipse, fillcolor="#dff4df", color="#4a8f4a", label="LINQ Promenade\\n(Location, d=2)"]; + e_v6ent_97257e66191c4a79a700a293 [shape=ellipse, fillcolor="#dff4df", color="#4a8f4a", label="Natural Park of Moncayo\\n(Location, d=2)"]; + e_v6ent_8ef4907cb5e94a3aa25a06b6 [shape=ellipse, fillcolor="#ffe7bf", color="#bf7a12", label="Speyer Tourism Board\\n(Organization, d=2)"]; + e_v6ent_528e7bd8404741a4a812ea94 [shape=ellipse, fillcolor="#eeeeee", color="#777777", label="https://www.speyer.de/\\n(Other, d=2)"]; + e_v6ent_136cf70a1a09469bad593070 [shape=ellipse, fillcolor="#eeeeee", color="#777777", label="info@speyer.de\\n(Other, d=2)"]; + e_v6ent_8ff444564a8d4571a29d9180 [shape=ellipse, fillcolor="#eeeeee", color="#777777", label="67346 Speyer\\n(Other, d=1)"]; + } + + subgraph cluster_memories { + label="V6MemoryRef -> PG flat memory"; + color="#d8dee9"; + style="rounded"; + m_episodic_ep_FDEU [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-21\\nAssistant described the\\nsignificance of the Vatican and\\nmain attractions inside."]; + m_episodic_ep_LKIU [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-21\\nUser asked about the significance\\nof the Vatican in Rome and what\\nvisitors can see inside."]; + m_semantic_sem_IL0I [shape=component, fillcolor="#dff7ff", color="#2f7f9f", label="S: 2026-06-12\\nSt. Peter's Basilica"]; + m_semantic_sem_THVY [shape=component, fillcolor="#dff7ff", color="#2f7f9f", label="S: 2026-06-12\\nVatican in Rome"]; + m_episodic_ep_ZBFP [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-28\\nUser requested contact details of\\nthe tourism board of Speyer"]; + m_semantic_sem_XDIB [shape=component, fillcolor="#dff7ff", color="#2f7f9f", label="S: 2026-06-12\\nTourism Board of Speyer"]; + m_episodic_ep_FUH2 [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-17\\nPlanned Eastern Sierra trip with\\ncamping spots and hiking trails\\nrecommendations"]; + m_episodic_ep_ZU0Z [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-17\\nPlanned trip to Eastern Sierra\\nwith camping and hiking in July or\\nAugust"]; + m_episodic_ep_WGS1 [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-15\\nUser planned a trip to the Eastern\\nSierra with focus on Mount Whitney\\nTrail and camping near Whitney\\nPortal Trailhead"]; + m_semantic_sem_TPM4 [shape=component, fillcolor="#dff7ff", color="#2f7f9f", label="S: 2026-06-12\\nDrive from Yosemite to Lone Pine\\nand Scenic Stops"]; + m_semantic_sem_HRQD [shape=component, fillcolor="#dff7ff", color="#2f7f9f", label="S: 2026-06-12\\nAcclimatization for Mount Whitney\\nHike"]; + m_episodic_ep_QXGA [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-25\\nAssistant expressed hope that the\\nuser has a wonderful time at the\\nNatural Park of Moncayo mountain\\nin Aragón."]; + m_episodic_ep_KWWW [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-25\\nUser expressed excitement about\\nplanning a visit to the Natural\\nPark of Moncayo mountain in Aragón\\nand appreciated the\\nrecommendations."]; + m_episodic_ep_A4SQ [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-25\\nUser considered renting a rural\\ncottage near the Natural Park of\\nMoncayo mountain in Aragón and\\nasked for specific\\nrecommendations."]; + m_episodic_ep_Q4I2 [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-25\\nUser asked for accommodation\\nadvice near the Natural Park of\\nMoncayo mountain in Aragón."]; + m_episodic_ep_CV7X [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-25\\nAssistant advised that spring and\\nautumn are the best seasons to\\nvisit the Natural Park of Moncayo\\nmountain for hiking."]; + m_episodic_ep_W4X1 [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-25\\nUser asked for the best time of\\nyear to visit the Natural Park of\\nMoncayo mountain for hiking."]; + m_episodic_ep_FHZJ [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-25\\nAssistant recommended the GR-90\\nhiking trail in the Natural Park\\nof Moncayo mountain for amazing\\nviews."]; + m_episodic_ep_IWI1 [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-25\\nUser requested a recommendation\\nfor the best hiking trail with\\namazing views in the Natural Park\\nof Moncayo mountain."]; + m_episodic_ep_BHKF [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-28\\nUser inquired about upcoming\\nsporting events in Speyer and\\nlocal tourism board contact"]; + m_episodic_ep_HHLY [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-28\\nUser asked about transportation\\noptions from Frankfurt to Speyer"]; + m_episodic_ep_AO8Y [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-28\\nUser inquired about upcoming\\nsporting events in Speyer and how\\nvisitors can attend or participate"]; + m_semantic_sem_KZ93 [shape=component, fillcolor="#dff7ff", color="#2f7f9f", label="S: 2026-06-12\\nUpcoming Sporting Events in Speyer"]; + m_semantic_sem_VJNQ [shape=component, fillcolor="#dff7ff", color="#2f7f9f", label="S: 2026-06-12\\nTransportation Options from\\nFrankfurt to Speyer"]; + m_episodic_ep_VT1U [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-21\\nAssistant recommended restaurants\\nnear the Vatican including\\nPizzarium, La Locanda dei\\nGirasoli, Roscioli, and Caffè\\nVaticano."]; + m_semantic_sem_AFTY [shape=component, fillcolor="#dff7ff", color="#2f7f9f", label="S: 2026-06-12\\nRestaurants near Vatican"]; + m_episodic_ep_QKUF [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-21\\nUser asked for general tips for\\nvisiting Rome."]; + m_episodic_ep_BB6N [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-21\\nAssistant explained souvenir shops\\nnear the Vatican and what they\\nsell."]; + m_episodic_ep_THG5 [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-21\\nUser asked about souvenir shops\\naround the Vatican."]; + m_episodic_ep_KWL3 [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-21\\nUser asked for restaurant\\nrecommendations near the Vatican."]; + m_episodic_ep_CZ8G [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-21\\nAssistant recommended seeing the\\nSistine Chapel first and booking a\\ntour in advance at the Vatican."]; + m_episodic_ep_EU44 [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-21\\nUser asked for recommendations on\\nspecific areas or exhibits to see\\nfirst in the Vatican."]; + m_episodic_ep_UTTI [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-25\\nAssistant recommended travel\\nwebsites for finding rural\\ncottages near the Natural Park of\\nMoncayo mountain in Aragón."]; + m_episodic_ep_Q262 [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-25\\nAssistant provided accommodation\\noptions near the Natural Park of\\nMoncayo mountain in Aragón."]; + m_episodic_ep_WRPP [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-20\\nUser inquires about Hilton Grand\\nVacations Club on the Las Vegas\\nStrip"]; + m_semantic_sem_Z14R [shape=component, fillcolor="#dff7ff", color="#2f7f9f", label="S: 2026-06-12\\nHilton Grand Vacations Club Las\\nVegas Strip"]; + m_episodic_ep_IEUT [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2024-02-05\\nAssistant recommends must-see\\nEuropean destinations and\\nexperiences for travelers in their\\n30s"]; + m_episodic_ep_DSYZ [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-24\\nUser shared solo trip experience\\nto New York City and sought travel\\nbudgeting tips for Europe trip"]; + m_episodic_ep_EOS7 [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-21\\nAssistant recommended popular\\ngelato shops in Rome including\\nGelateria del Teatro, Giolitti,\\nFatamorgana, San Crispino, and La\\nRomana."]; + m_episodic_ep_JN84 [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-21\\nUser asked for gelato shop\\nrecommendations in Rome."]; + m_episodic_ep_LBD1 [shape=box, fillcolor="#fff2bf", color="#b38a00", label="E: 2023-05-21\\nAssistant shared tips for visiting\\nRome including walking shoes,\\ntiming, dress code, safety,\\ntransportation, gelato, and the\\nTrevi Fou..."]; + } + + e_v6ent_0ab178b797744b9d8b66a000 -> m_episodic_ep_FDEU [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_0ab178b797744b9d8b66a000 -> m_episodic_ep_LKIU [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_0ab178b797744b9d8b66a000 -> m_semantic_sem_IL0I [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_0ab178b797744b9d8b66a000 -> m_semantic_sem_THVY [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_136cf70a1a09469bad593070 -> m_episodic_ep_ZBFP [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_136cf70a1a09469bad593070 -> m_semantic_sem_XDIB [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_19d488bf6174416c817f9c1e -> m_episodic_ep_FUH2 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_19d488bf6174416c817f9c1e -> m_episodic_ep_ZU0Z [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_19d488bf6174416c817f9c1e -> m_episodic_ep_WGS1 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_19d488bf6174416c817f9c1e -> m_semantic_sem_TPM4 [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_19d488bf6174416c817f9c1e -> m_semantic_sem_HRQD [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_4058cd532f2342e5b8b126ca -> m_episodic_ep_QXGA [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_4058cd532f2342e5b8b126ca -> m_episodic_ep_KWWW [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_4058cd532f2342e5b8b126ca -> m_episodic_ep_A4SQ [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_4058cd532f2342e5b8b126ca -> m_episodic_ep_Q4I2 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_4058cd532f2342e5b8b126ca -> m_episodic_ep_CV7X [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_4058cd532f2342e5b8b126ca -> m_episodic_ep_W4X1 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_4058cd532f2342e5b8b126ca -> m_episodic_ep_FHZJ [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_4058cd532f2342e5b8b126ca -> m_episodic_ep_IWI1 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_453b59e9cd664cfdb5d12488 -> m_episodic_ep_BHKF [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_453b59e9cd664cfdb5d12488 -> m_episodic_ep_HHLY [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_453b59e9cd664cfdb5d12488 -> m_episodic_ep_ZBFP [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_453b59e9cd664cfdb5d12488 -> m_episodic_ep_AO8Y [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_453b59e9cd664cfdb5d12488 -> m_semantic_sem_KZ93 [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_453b59e9cd664cfdb5d12488 -> m_semantic_sem_VJNQ [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_453b59e9cd664cfdb5d12488 -> m_semantic_sem_XDIB [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_4dfeb7caa0b0468295a1911e -> m_episodic_ep_VT1U [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_4dfeb7caa0b0468295a1911e -> m_semantic_sem_AFTY [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_528e7bd8404741a4a812ea94 -> m_episodic_ep_ZBFP [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_528e7bd8404741a4a812ea94 -> m_semantic_sem_XDIB [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_865162e16bd6455f9c7f3037 -> m_episodic_ep_QKUF [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_865162e16bd6455f9c7f3037 -> m_episodic_ep_BB6N [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_865162e16bd6455f9c7f3037 -> m_episodic_ep_THG5 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_865162e16bd6455f9c7f3037 -> m_episodic_ep_VT1U [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_865162e16bd6455f9c7f3037 -> m_episodic_ep_KWL3 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_865162e16bd6455f9c7f3037 -> m_episodic_ep_CZ8G [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_865162e16bd6455f9c7f3037 -> m_episodic_ep_EU44 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_865162e16bd6455f9c7f3037 -> m_episodic_ep_FDEU [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_8ef4907cb5e94a3aa25a06b6 -> m_episodic_ep_AO8Y [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_8ef4907cb5e94a3aa25a06b6 -> m_semantic_sem_KZ93 [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_8ff444564a8d4571a29d9180 -> m_episodic_ep_ZBFP [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_97257e66191c4a79a700a293 -> m_episodic_ep_UTTI [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_97257e66191c4a79a700a293 -> m_episodic_ep_Q262 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_a267fc199d5f4bdeb5a5f113 -> m_episodic_ep_WRPP [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_a267fc199d5f4bdeb5a5f113 -> m_semantic_sem_Z14R [label="DESCRIBED_BY", color="#9b6bd3"]; + e_v6ent_ad07057ea8894b489269ea10 -> m_episodic_ep_IEUT [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_ad07057ea8894b489269ea10 -> m_episodic_ep_DSYZ [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_ad07057ea8894b489269ea10 -> m_episodic_ep_EOS7 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_ad07057ea8894b489269ea10 -> m_episodic_ep_JN84 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_ad07057ea8894b489269ea10 -> m_episodic_ep_LBD1 [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_ad07057ea8894b489269ea10 -> m_episodic_ep_QKUF [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_ad07057ea8894b489269ea10 -> m_episodic_ep_VT1U [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_ad07057ea8894b489269ea10 -> m_episodic_ep_LKIU [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_beea932be9e6430da7e9dafc -> m_episodic_ep_BHKF [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_beea932be9e6430da7e9dafc -> m_episodic_ep_ZBFP [label="APPEARS_IN", color="#5f8dd3"]; + e_v6ent_beea932be9e6430da7e9dafc -> m_semantic_sem_XDIB [label="DESCRIBED_BY", color="#9b6bd3"]; +} \ No newline at end of file diff --git a/docs/graph_memory_v6/visualizations/topic_travel.png b/docs/graph_memory_v6/visualizations/topic_travel.png new file mode 100644 index 000000000..789b268e5 Binary files /dev/null and b/docs/graph_memory_v6/visualizations/topic_travel.png differ diff --git a/docs/graph_memory_v6/visualizations/topic_travel.svg b/docs/graph_memory_v6/visualizations/topic_travel.svg new file mode 100644 index 000000000..1688905c2 --- /dev/null +++ b/docs/graph_memory_v6/visualizations/topic_travel.svg @@ -0,0 +1,764 @@ + + + + + + +G + +Travel / Places Detail + +cluster_entities + +V6Entity + + +cluster_memories + +V6MemoryRef -> PG flat memory + + + +e_v6ent_ad07057ea8894b489269ea10 + +Rome\n(Location, d=16) + + + +m_episodic_ep_LKIU + +E: 2023-05-21\nUser asked about the significance\nof the Vatican in Rome and what\nvisitors can see inside. + + + +e_v6ent_ad07057ea8894b489269ea10->m_episodic_ep_LKIU + + +APPEARS_IN + + + +m_episodic_ep_VT1U + +E: 2023-05-21\nAssistant recommended restaurants\nnear the Vatican including\nPizzarium, La Locanda dei\nGirasoli, Roscioli, and Caffè\nVaticano. + + + +e_v6ent_ad07057ea8894b489269ea10->m_episodic_ep_VT1U + + +APPEARS_IN + + + +m_episodic_ep_QKUF + +E: 2023-05-21\nUser asked for general tips for\nvisiting Rome. + + + +e_v6ent_ad07057ea8894b489269ea10->m_episodic_ep_QKUF + + +APPEARS_IN + + + +m_episodic_ep_IEUT + +E: 2024-02-05\nAssistant recommends must-see\nEuropean destinations and\nexperiences for travelers in their\n30s + + + +e_v6ent_ad07057ea8894b489269ea10->m_episodic_ep_IEUT + + +APPEARS_IN + + + +m_episodic_ep_DSYZ + +E: 2023-05-24\nUser shared solo trip experience\nto New York City and sought travel\nbudgeting tips for Europe trip + + + +e_v6ent_ad07057ea8894b489269ea10->m_episodic_ep_DSYZ + + +APPEARS_IN + + + +m_episodic_ep_EOS7 + +E: 2023-05-21\nAssistant recommended popular\ngelato shops in Rome including\nGelateria del Teatro, Giolitti,\nFatamorgana, San Crispino, and La\nRomana. + + + +e_v6ent_ad07057ea8894b489269ea10->m_episodic_ep_EOS7 + + +APPEARS_IN + + + +m_episodic_ep_JN84 + +E: 2023-05-21\nUser asked for gelato shop\nrecommendations in Rome. + + + +e_v6ent_ad07057ea8894b489269ea10->m_episodic_ep_JN84 + + +APPEARS_IN + + + +m_episodic_ep_LBD1 + +E: 2023-05-21\nAssistant shared tips for visiting\nRome including walking shoes,\ntiming, dress code, safety,\ntransportation, gelato, and the\nTrevi Fou... + + + +e_v6ent_ad07057ea8894b489269ea10->m_episodic_ep_LBD1 + + +APPEARS_IN + + + +e_v6ent_865162e16bd6455f9c7f3037 + +Vatican\n(Location, d=14) + + + +m_episodic_ep_FDEU + +E: 2023-05-21\nAssistant described the\nsignificance of the Vatican and\nmain attractions inside. + + + +e_v6ent_865162e16bd6455f9c7f3037->m_episodic_ep_FDEU + + +APPEARS_IN + + + +e_v6ent_865162e16bd6455f9c7f3037->m_episodic_ep_VT1U + + +APPEARS_IN + + + +e_v6ent_865162e16bd6455f9c7f3037->m_episodic_ep_QKUF + + +APPEARS_IN + + + +m_episodic_ep_BB6N + +E: 2023-05-21\nAssistant explained souvenir shops\nnear the Vatican and what they\nsell. + + + +e_v6ent_865162e16bd6455f9c7f3037->m_episodic_ep_BB6N + + +APPEARS_IN + + + +m_episodic_ep_THG5 + +E: 2023-05-21\nUser asked about souvenir shops\naround the Vatican. + + + +e_v6ent_865162e16bd6455f9c7f3037->m_episodic_ep_THG5 + + +APPEARS_IN + + + +m_episodic_ep_KWL3 + +E: 2023-05-21\nUser asked for restaurant\nrecommendations near the Vatican. + + + +e_v6ent_865162e16bd6455f9c7f3037->m_episodic_ep_KWL3 + + +APPEARS_IN + + + +m_episodic_ep_CZ8G + +E: 2023-05-21\nAssistant recommended seeing the\nSistine Chapel first and booking a\ntour in advance at the Vatican. + + + +e_v6ent_865162e16bd6455f9c7f3037->m_episodic_ep_CZ8G + + +APPEARS_IN + + + +m_episodic_ep_EU44 + +E: 2023-05-21\nUser asked for recommendations on\nspecific areas or exhibits to see\nfirst in the Vatican. + + + +e_v6ent_865162e16bd6455f9c7f3037->m_episodic_ep_EU44 + + +APPEARS_IN + + + +e_v6ent_4058cd532f2342e5b8b126ca + +Natural Park of Moncayo\nMountain\n(Location, d=13) + + + +m_episodic_ep_QXGA + +E: 2023-05-25\nAssistant expressed hope that the\nuser has a wonderful time at the\nNatural Park of Moncayo mountain\nin Aragón. + + + +e_v6ent_4058cd532f2342e5b8b126ca->m_episodic_ep_QXGA + + +APPEARS_IN + + + +m_episodic_ep_KWWW + +E: 2023-05-25\nUser expressed excitement about\nplanning a visit to the Natural\nPark of Moncayo mountain in Aragón\nand appreciated the\nrecommendations. + + + +e_v6ent_4058cd532f2342e5b8b126ca->m_episodic_ep_KWWW + + +APPEARS_IN + + + +m_episodic_ep_A4SQ + +E: 2023-05-25\nUser considered renting a rural\ncottage near the Natural Park of\nMoncayo mountain in Aragón and\nasked for specific\nrecommendations. + + + +e_v6ent_4058cd532f2342e5b8b126ca->m_episodic_ep_A4SQ + + +APPEARS_IN + + + +m_episodic_ep_Q4I2 + +E: 2023-05-25\nUser asked for accommodation\nadvice near the Natural Park of\nMoncayo mountain in Aragón. + + + +e_v6ent_4058cd532f2342e5b8b126ca->m_episodic_ep_Q4I2 + + +APPEARS_IN + + + +m_episodic_ep_CV7X + +E: 2023-05-25\nAssistant advised that spring and\nautumn are the best seasons to\nvisit the Natural Park of Moncayo\nmountain for hiking. + + + +e_v6ent_4058cd532f2342e5b8b126ca->m_episodic_ep_CV7X + + +APPEARS_IN + + + +m_episodic_ep_W4X1 + +E: 2023-05-25\nUser asked for the best time of\nyear to visit the Natural Park of\nMoncayo mountain for hiking. + + + +e_v6ent_4058cd532f2342e5b8b126ca->m_episodic_ep_W4X1 + + +APPEARS_IN + + + +m_episodic_ep_FHZJ + +E: 2023-05-25\nAssistant recommended the GR-90\nhiking trail in the Natural Park\nof Moncayo mountain for amazing\nviews. + + + +e_v6ent_4058cd532f2342e5b8b126ca->m_episodic_ep_FHZJ + + +APPEARS_IN + + + +m_episodic_ep_IWI1 + +E: 2023-05-25\nUser requested a recommendation\nfor the best hiking trail with\namazing views in the Natural Park\nof Moncayo mountain. + + + +e_v6ent_4058cd532f2342e5b8b126ca->m_episodic_ep_IWI1 + + +APPEARS_IN + + + +e_v6ent_453b59e9cd664cfdb5d12488 + +Speyer\n(Location, d=7) + + + +m_episodic_ep_ZBFP + +E: 2023-05-28\nUser requested contact details of\nthe tourism board of Speyer + + + +e_v6ent_453b59e9cd664cfdb5d12488->m_episodic_ep_ZBFP + + +APPEARS_IN + + + +m_semantic_sem_XDIB + + + +S: 2026-06-12\nTourism Board of Speyer + + + +e_v6ent_453b59e9cd664cfdb5d12488->m_semantic_sem_XDIB + + +DESCRIBED_BY + + + +m_episodic_ep_BHKF + +E: 2023-05-28\nUser inquired about upcoming\nsporting events in Speyer and\nlocal tourism board contact + + + +e_v6ent_453b59e9cd664cfdb5d12488->m_episodic_ep_BHKF + + +APPEARS_IN + + + +m_episodic_ep_HHLY + +E: 2023-05-28\nUser asked about transportation\noptions from Frankfurt to Speyer + + + +e_v6ent_453b59e9cd664cfdb5d12488->m_episodic_ep_HHLY + + +APPEARS_IN + + + +m_episodic_ep_AO8Y + +E: 2023-05-28\nUser inquired about upcoming\nsporting events in Speyer and how\nvisitors can attend or participate + + + +e_v6ent_453b59e9cd664cfdb5d12488->m_episodic_ep_AO8Y + + +APPEARS_IN + + + +m_semantic_sem_KZ93 + + + +S: 2026-06-12\nUpcoming Sporting Events in Speyer + + + +e_v6ent_453b59e9cd664cfdb5d12488->m_semantic_sem_KZ93 + + +DESCRIBED_BY + + + +m_semantic_sem_VJNQ + + + +S: 2026-06-12\nTransportation Options from\nFrankfurt to Speyer + + + +e_v6ent_453b59e9cd664cfdb5d12488->m_semantic_sem_VJNQ + + +DESCRIBED_BY + + + +e_v6ent_19d488bf6174416c817f9c1e + +Yosemite National Park\n(Location, d=5) + + + +m_episodic_ep_FUH2 + +E: 2023-05-17\nPlanned Eastern Sierra trip with\ncamping spots and hiking trails\nrecommendations + + + +e_v6ent_19d488bf6174416c817f9c1e->m_episodic_ep_FUH2 + + +APPEARS_IN + + + +m_episodic_ep_ZU0Z + +E: 2023-05-17\nPlanned trip to Eastern Sierra\nwith camping and hiking in July or\nAugust + + + +e_v6ent_19d488bf6174416c817f9c1e->m_episodic_ep_ZU0Z + + +APPEARS_IN + + + +m_episodic_ep_WGS1 + +E: 2023-05-15\nUser planned a trip to the Eastern\nSierra with focus on Mount Whitney\nTrail and camping near Whitney\nPortal Trailhead + + + +e_v6ent_19d488bf6174416c817f9c1e->m_episodic_ep_WGS1 + + +APPEARS_IN + + + +m_semantic_sem_TPM4 + + + +S: 2026-06-12\nDrive from Yosemite to Lone Pine\nand Scenic Stops + + + +e_v6ent_19d488bf6174416c817f9c1e->m_semantic_sem_TPM4 + + +DESCRIBED_BY + + + +m_semantic_sem_HRQD + + + +S: 2026-06-12\nAcclimatization for Mount Whitney\nHike + + + +e_v6ent_19d488bf6174416c817f9c1e->m_semantic_sem_HRQD + + +DESCRIBED_BY + + + +e_v6ent_0ab178b797744b9d8b66a000 + +Vatican Museums\n(Location, d=4) + + + +e_v6ent_0ab178b797744b9d8b66a000->m_episodic_ep_FDEU + + +APPEARS_IN + + + +e_v6ent_0ab178b797744b9d8b66a000->m_episodic_ep_LKIU + + +APPEARS_IN + + + +m_semantic_sem_IL0I + + + +S: 2026-06-12\nSt. Peter's Basilica + + + +e_v6ent_0ab178b797744b9d8b66a000->m_semantic_sem_IL0I + + +DESCRIBED_BY + + + +m_semantic_sem_THVY + + + +S: 2026-06-12\nVatican in Rome + + + +e_v6ent_0ab178b797744b9d8b66a000->m_semantic_sem_THVY + + +DESCRIBED_BY + + + +e_v6ent_beea932be9e6430da7e9dafc + +Speyer Tourismus\nMarketing GmbH\n(Organization, d=3) + + + +e_v6ent_beea932be9e6430da7e9dafc->m_episodic_ep_ZBFP + + +APPEARS_IN + + + +e_v6ent_beea932be9e6430da7e9dafc->m_semantic_sem_XDIB + + +DESCRIBED_BY + + + +e_v6ent_beea932be9e6430da7e9dafc->m_episodic_ep_BHKF + + +APPEARS_IN + + + +e_v6ent_4dfeb7caa0b0468295a1911e + +Caffè Vaticano\n(Organization, d=2) + + + +e_v6ent_4dfeb7caa0b0468295a1911e->m_episodic_ep_VT1U + + +APPEARS_IN + + + +m_semantic_sem_AFTY + + + +S: 2026-06-12\nRestaurants near Vatican + + + +e_v6ent_4dfeb7caa0b0468295a1911e->m_semantic_sem_AFTY + + +DESCRIBED_BY + + + +e_v6ent_a267fc199d5f4bdeb5a5f113 + +LINQ Promenade\n(Location, d=2) + + + +m_episodic_ep_WRPP + +E: 2023-05-20\nUser inquires about Hilton Grand\nVacations Club on the Las Vegas\nStrip + + + +e_v6ent_a267fc199d5f4bdeb5a5f113->m_episodic_ep_WRPP + + +APPEARS_IN + + + +m_semantic_sem_Z14R + + + +S: 2026-06-12\nHilton Grand Vacations Club Las\nVegas Strip + + + +e_v6ent_a267fc199d5f4bdeb5a5f113->m_semantic_sem_Z14R + + +DESCRIBED_BY + + + +e_v6ent_97257e66191c4a79a700a293 + +Natural Park of Moncayo\n(Location, d=2) + + + +m_episodic_ep_UTTI + +E: 2023-05-25\nAssistant recommended travel\nwebsites for finding rural\ncottages near the Natural Park of\nMoncayo mountain in Aragón. + + + +e_v6ent_97257e66191c4a79a700a293->m_episodic_ep_UTTI + + +APPEARS_IN + + + +m_episodic_ep_Q262 + +E: 2023-05-25\nAssistant provided accommodation\noptions near the Natural Park of\nMoncayo mountain in Aragón. + + + +e_v6ent_97257e66191c4a79a700a293->m_episodic_ep_Q262 + + +APPEARS_IN + + + +e_v6ent_8ef4907cb5e94a3aa25a06b6 + +Speyer Tourism Board\n(Organization, d=2) + + + +e_v6ent_8ef4907cb5e94a3aa25a06b6->m_episodic_ep_AO8Y + + +APPEARS_IN + + + +e_v6ent_8ef4907cb5e94a3aa25a06b6->m_semantic_sem_KZ93 + + +DESCRIBED_BY + + + +e_v6ent_528e7bd8404741a4a812ea94 + +https://www.speyer.de/\n(Other, d=2) + + + +e_v6ent_528e7bd8404741a4a812ea94->m_episodic_ep_ZBFP + + +APPEARS_IN + + + +e_v6ent_528e7bd8404741a4a812ea94->m_semantic_sem_XDIB + + +DESCRIBED_BY + + + +e_v6ent_136cf70a1a09469bad593070 + +info@speyer.de\n(Other, d=2) + + + +e_v6ent_136cf70a1a09469bad593070->m_episodic_ep_ZBFP + + +APPEARS_IN + + + +e_v6ent_136cf70a1a09469bad593070->m_semantic_sem_XDIB + + +DESCRIBED_BY + + + +e_v6ent_8ff444564a8d4571a29d9180 + +67346 Speyer\n(Other, d=1) + + + +e_v6ent_8ff444564a8d4571a29d9180->m_episodic_ep_ZBFP + + +APPEARS_IN + + + diff --git a/docs/graph_memory_v7/README.md b/docs/graph_memory_v7/README.md new file mode 100644 index 000000000..242059b9f --- /dev/null +++ b/docs/graph_memory_v7/README.md @@ -0,0 +1,229 @@ +# v7 Graph Memory: Minimal Semantic+Episodic Linkage + +v7 is the "honmei" graph revision: details stay in flat memory, and graph +nodes/edges must earn their place by creating a useful retrieval or reasoning +path. + +It is intentionally side-by-side with v6. Use `MIRIX_GRAPH_VERSION=v7` to run +it; v5/v6 labels and previous results remain comparable. + +## Related Docs + +- `docs/graph_memory_v6/README.md` - v6 design, LongMem-S run, judge result, + and v6 graph visualization links. +- `docs/graph_memory_v6/visualizations/index.html` - sampled v6 graph views. +- [`development_history.md`](development_history.md) - the research arc from the + v7 baseline to the current system: failed branches, the measurements that + killed them, and the meta-lessons. Start here for "why is it like this". +- [`version_ledger.md`](version_ledger.md) - per-version table with QA numbers. +- [`archive/`](archive/README.md) - superseded design-era documents (10-chunk + study, extractor direction D memo, v7.4-v7.8 summary, hypergraph build log). +- [`locomo_dream_ablation.md`](locomo_dream_ablation.md) - LoCoMo conv-26 + six-arm ablation (graph / reranker / consolidation): why the dream loses + facts (rewrite + hard delete), the union-coverage gate that makes it + lossless at break-even QA, and the graph-as-insurance interaction. + +## Principle + +The graph should not carry the memory content. PostgreSQL flat memory remains +the source of detail: + +- `episodic_memory`: events, timestamps, numbers, concrete evidence. +- `semantic_memory`: stable facts, preferences, identities, durable concepts. +- Neo4j graph: sparse anchors and necessary links between semantic and + episodic memory refs. + +The core rule is: + +> If removing a node or edge does not remove an important retrieval path, it +> probably should not be in the graph. + +## Schema + +```text +(:V7Anchor) + - id + - user_id + - organization_id + - name + - name_lower + - anchor_type + - name_embedding + +(:V7MemoryRef:V7EpisodeRef) + - id = "episodic:" + - memory_id = + - memory_type = "episodic" + - source_key + - timestamp + +(:V7MemoryRef:V7ConceptRef) + - id = "semantic:" + - memory_id = + - memory_type = "semantic" + - source_key + +(:V7Anchor)-[:V7_APPEARS_IN]->(:V7EpisodeRef) +(:V7Anchor)-[:V7_DESCRIBED_BY]->(:V7ConceptRef) +(:V7ConceptRef)-[:V7_SUPPORTED_BY]->(:V7EpisodeRef) +(:V7EpisodeRef)-[:V7_NEXT_MEMORY]->(:V7EpisodeRef) +``` + +`V7MemoryRef` nodes store only ids, timestamps, provenance keys, and short +debug previews. Full summaries/details are fetched from PostgreSQL during +retrieval. + +## What Changed From v6 + +| Area | v6 | v7 | +|---|---|---| +| Entity admission | Keeps most extracted entities | Filters generic noun phrases and keeps specific anchors only | +| Entity/entity edges | Optional co-occurrence | Removed | +| Memory refs | v6 supported both arrays and `V6MemoryRef` compatibility | Explicit `V7MemoryRef` nodes | +| Semantic+episodic bridge | Present in some snapshots as `SUPPORTED_BY` | First-class `V7_SUPPORTED_BY` via shared `source_meta` | +| Details in graph | v6 snapshots duplicated summaries in graph refs | v7 stores only preview/title metadata; details stay in PG | +| Retrieval | Anchor/entity search -> PG fetch | Anchor search -> semantic/episodic refs -> support expansion -> PG fetch | + +## Anchor Admission + +v7 still reuses the existing LightRAG extraction call, but applies a gate before +writing graph nodes. It favors: + +- people, locations, organizations, named events; +- owned or recurring objects; +- named content, products, venues, apps, classes, pets, trips; +- specific concepts with proper names, numbers, or multi-word identity. + +It rejects generic anchors like: + +- `Tips` +- `Advice` +- `Flexibility` +- `Information` +- `Recommendations` +- `Methods` +- `Options` + +This is deliberately conservative. The goal is to reduce graph size and make +each remaining anchor feel necessary. + +## Write Path + +Both episodic and semantic memory managers route into +`V7GraphManager.process_memory()` when: + +```bash +MIRIX_ENABLE_GRAPH_MEMORY=true +MIRIX_GRAPH_VERSION=v7 +``` + +For each PG memory row: + +1. Create/merge one `V7MemoryRef`. +2. Extract candidate entities from the memory text. +3. Filter candidates through the anchor-admission gate. +4. Merge selected `V7Anchor` nodes. +5. Link anchors to the memory ref. +6. If `source_meta` exists, link semantic refs to episodic refs from the same + source chunk with `V7_SUPPORTED_BY`. +7. For episodic refs, add `V7_NEXT_MEMORY` to preserve chronology. + +## Read Path + +`V7Retriever` runs when `MIRIX_GRAPH_VERSION=v7`: + +1. Embed query. +2. Vector search `V7Anchor.name_embedding`. +3. Collect direct episodic refs and semantic refs. +4. Expand semantic refs to supporting episodic refs. +5. Expand episodic refs to nearby temporal refs. +6. Fetch full details from `episodic_memory` and `semantic_memory` in PG. +7. Format a compact context: + +```text +## Memory Linkage Graph (v7) +Matched anchors: ... + +### Semantic memories (PG flat) +... + +### Episodic memories (PG flat evidence) +... +``` + +## Files + +- `mirix/services/graph_memory_manager_v7.py` +- `mirix/services/graph_retriever_v7.py` +- `mirix/database/neo4j_client.py` adds v7 constraints + anchor vector index. +- `mirix/services/episodic_memory_manager.py` routes episodic inserts. +- `mirix/services/semantic_memory_manager.py` routes semantic inserts. +- `mirix/services/graph_retriever_dispatcher.py` routes retrieval. + +## Run Command + +Example server env: + +```bash +export MIRIX_ENABLE_GRAPH_MEMORY=true +export MIRIX_GRAPH_VERSION=v7 +export MIRIX_PG_DB=mirix_v7_longmems +export MIRIX_NEO4J_URI=bolt://localhost:7687 +export MIRIX_NEO4J_USER=neo4j +export MIRIX_NEO4J_PASSWORD=mirix_neo4j_dev +``` + +Use a clean PG DB and clean Neo4j label set when benchmarking so v7 counts are +not mixed with v5/v6 runs. + +## Smoke Validation + +Run date: 2026-06-18 +Scope: local smoke only. This validates v7 wiring, schema, and graph writes; it +is not a LongMem or LoCoMo accuracy run. + +Checks completed: + +- Import and anchor gate: `V7GraphManager` and `V7Retriever` import cleanly. +- Anchor admission kept specific anchors: + `Natural Park of Moncayo Mountain`, `American Airlines`, + `20-Gallon Community Aquarium`, `Luna`. +- Anchor admission rejected generic anchors: + `Tips`, `Flexibility`, `Social Media`. +- Neo4j schema bootstrap connected to `bolt://localhost:7687`. +- v7 indexes observed: + `v7_anchor_name_emb`, `v7_memory_ref_user_source`. +- v7 write path used a mocked extractor and mocked 1536-dim embeddings, then + wrote one episodic memory ref and one semantic memory ref with shared + `source_meta.chunk_id = v7-smoke-chunk-001`. + +Smoke graph counts before cleanup: + +| Item | Count | +|---|---:| +| `V7Anchor` | 3 | +| `V7EpisodeRef` | 1 | +| `V7ConceptRef` | 1 | +| `V7_APPEARS_IN` | 2 | +| `V7_DESCRIBED_BY` | 2 | +| `V7_SUPPORTED_BY` | 1 | + +Smoke cleanup deleted all temporary nodes for user id `__v7_smoke_user__`; +post-cleanup counts were all 0. + +## Expected Evaluation Questions + +v7 should be compared against the closest v6/small-chunk LongMem setup on: + +- accuracy; +- node count; +- edge count; +- graph stored chars; +- ingest time; +- retrieval / prompt-wrap time; +- answer time. + +The target is not necessarily higher raw accuracy on the first run. The target +is a smaller graph whose remaining nodes/edges are easier to justify, while +keeping accuracy close enough that better ranking/support expansion can recover +the last few missed questions. diff --git a/docs/graph_memory_v7/archive/10chunk_ingest_analysis.md b/docs/graph_memory_v7/archive/10chunk_ingest_analysis.md new file mode 100644 index 000000000..3fc3a7110 --- /dev/null +++ b/docs/graph_memory_v7/archive/10chunk_ingest_analysis.md @@ -0,0 +1,136 @@ +# v7 Graph — 10-chunk Ingest Analysis (LongMemEval-S) + +A controlled 10-session-chunk ingest of LongMemEval-S with the standard 6-agent +memory pipeline and `MIRIX_GRAPH_VERSION=v7`. The goal was a fast, valid slice of +the full 114-chunk build to sanity-check that graph size scales as expected — and +it surfaced a harness/ingest pitfall worth documenting. + +- **DB**: `mirix_lm10` (fresh), `user_id = longmem_s_0`, `mirix-eval-org` +- **Graph**: v7, on (`MIRIX_ENABLE_GRAPH_MEMORY=true`) +- **Ingest**: first 10 of 114 session chunks, standard `add_chunk` (meta-agent → + 6 sub-agents), `chaining=False`, `occurred_at` supplied per session +- **Config**: `evals/configs/0201c_v6.yaml` + +## TL;DR + +The first attempt (via `longmem_eval.py --max-chunks 10`) produced only **20 +memories / 62 anchors** — ~8× too few. That run was **broken**: its `add_chunk` +calls returned without actually extracting (the whole ingest+QA finished in ~8 +min, when ingest alone should take ~22 min). A clean re-ingest that lets each +chunk complete produces **116 memories / 524 anchors**, which is exactly +proportional to the full 114-chunk graph. + +## Numbers + +| | Full (114 ch) | **Correct 10 ch** | Broken 10 ch (first try) | +|---|---:|---:|---:| +| episodic + semantic memories | 1219 | **116** (65 ep + 51 sem) | 14 | +| memories per chunk | 10.7 | **11.6** | 1.4 | +| anchors (V7Anchor) | 5588 | **524** | 62 | +| total graph nodes | — | **640** | 76 | +| total graph edges | — | **1324** | 117 | +| anchors per memory | 4.58 | **4.52** | 4.43 | + +The **anchors-per-memory ratio is invariant (~4.5)** across all three runs — the +graph builder is fine. The only thing that changed between the broken and correct +10-chunk runs is the *number of memories extracted*. A correct 10-chunk ingest +lands right on (even slightly above) the full run's per-chunk rate; the first 10 +sessions are marginally richer than the 114-chunk average. + +Graph edge breakdown (correct run): + +| edge | count | meaning | +|---|---:|---| +| `V7_APPEARS_IN` | 473 | anchor → episodic ref | +| `V7_DESCRIBED_BY` | 469 | anchor → semantic (concept) ref | +| `V7_SUPPORTED_BY` | 318 | concept ↔ episodic cross-evidence | +| `V7_NEXT_MEMORY` | 64 | temporal chain between episodic refs | + +## Root cause of the broken run + +`MirixMemorySystem.add_chunk` uses `async_add=False` and is **fully +synchronous**: each call blocks until the chunk is completely extracted and +committed. Measured on the correct run: + +- chunk 1: +141 s, +12 memories +- 10 chunks: ~1328 s total (~2.2 min/chunk) +- After the last `add_chunk`, the count was **immediately stable at 116** — a + 45 s post-ingest watch showed **zero** additional growth. + +That last observation **refutes an earlier "async queue didn't drain" hypothesis**: +there is no async lag to wait out. Extraction happens inline inside `add_chunk`. + +So the broken run — which finished ingest+QA in ~8 min — cannot have run the real +extraction (10 chunks alone need ~22 min). Its `add_chunk` calls returned fast +and near-empty. The broken run happened immediately after killing a prior ingest, +recreating the DB, and restarting the server; the most likely cause is a +**transient: the fresh server/agents were not fully ready for the first ingest +batch**, so per-chunk calls short-circuited. It did not self-heal (the count +stayed pinned at 20), consistent with the work never being enqueued rather than +being dropped from a queue. + +### Practical guard + +To get a valid ingest, confirm the memory count grows at the expected rate +*during* ingest (≈10–12 memories/chunk) rather than trusting that the harness +finished. The helper `~/MIRIX_eval/ingest_drain.py` re-uses the exact `add_chunk` +path, prints a per-chunk count, and then watches until the count is stable — use +it (or the same pattern) when a run completes suspiciously fast. + +### Unrelated reporting bug + +`longmem_eval.py::measure_memory_size` shells out to a hard-coded macOS psql path +(`/usr/local/opt/postgresql@17/bin/psql`) via `PSQL_BIN`, which does not exist on +this Linux host, so it silently reports `flat rows = 0` regardless of the true +memory state. It is cosmetic (QA retrieval goes through the server, not this +function) but is misleading in logs — it reads the `PSQL_BIN` env var, not the +`MIRIX_PG_BIN` we set in `.env`. + +## Graph shape + +![10-chunk v7 graph](lm10_v7_graph.png) + +Left: a real sub-graph seeded on the travel cluster; right: whole-graph stats. +Anchors are never linked directly — two entities connect only *through the +episodic/semantic memory they co-occur in* (`anchor → memory → anchor`), which is +what makes the graph useful for multi-hop retrieval. + +**Degree distribution (anchor → memories):** + +| degree | anchors | share | +|---|---:|---:| +| 1 (singleton) | 336 | 64% | +| 2 | 113 | 22% | +| 3–5 | 63 | 12% | +| 6–10 | 6 | 1% | +| 11+ | 6 | 1% | + +**Top hubs:** User (42), American Airlines (31), Assistant (24), +Fort Lauderdale (12), Boston (12), Buffalo Wild Wings (11), Los Angeles (10), +Miami (9), Goodreads (9), JetBlue/Delta/Road Bike (6). + +Two structural notes that match prior findings: + +1. **64% of anchors are singletons** (connect to exactly one memory). These are + the target of the v8 singleton-prune finalize pass — removing them cost ~0 + retrieval accuracy in A/B tests while cutting anchor count 60–71%. +2. **`User` (42) and `Assistant` (24) are semantics-free mega-hubs** — pure + structural noise from the dialogue roles. Dropping them (the ad-hoc "v8.1" + experiment) was net-negative because `User` doubles as an aggregation point, + so they are kept. + +The 10 ingested sessions are thematically a travel/airline/reading conversation, +so the graph self-organizes into clean clusters: cities & airlines (Boston, +Fort Lauderdale, Miami, Delta, JetBlue, Spirit, Air France), service/review +(Skytrax, TripAdvisor, Consumer Reports), credit-card rewards (Chase Sapphire, +Chase Ultimate Rewards, Citi AAdvantage), and in-flight entertainment. + +## Reproduce + +```bash +# fresh DB + empty graph, server on mirix_lm10 / v7 / graph on +# then, from ~/MIRIX_eval with .env sourced: +MAX_CHUNKS=10 ./.venv/bin/python ingest_drain.py # ingest + drain-watch +# figure: +./.venv/bin/python draw_lm10_graph.py # -> lm10_v7_graph.png +``` diff --git a/docs/graph_memory_v7/archive/README.md b/docs/graph_memory_v7/archive/README.md new file mode 100644 index 000000000..1016680d5 --- /dev/null +++ b/docs/graph_memory_v7/archive/README.md @@ -0,0 +1,22 @@ +# Archive — superseded design-era documents + +Process records from earlier phases of the graph-memory work, preserved verbatim +for the paper trail. The designs they describe have been superseded or absorbed: + +- `10chunk_ingest_analysis.md` — early controlled 10-chunk ingest study (v7 era, + pre-hypergraph). Its broken-ingest pitfall findings were folded into the eval + harness; the graph-shape analysis predates the v7.10 hypergraph. +- `extractor_direction_D.md` — the research memo that selected LLM triple + extraction ("direction D") over GLiNER/LightRAG. Direction D shipped as v7.6 + and remains the current extractor; the memo's speed claims were later + corrected (2.6×, not 34×). +- `v74_v78_summary.md` — consolidated summary of the extractor line v7.4–v7.8 + plus the first QA-failure study. Superseded by the version ledger and the + later per-question forensics. +- `hypergraph_and_consolidate.md` — v7.10 hypergraph build log and the v7.11 + `consolidate` answerer tool (measured negative, rejected; the counting gap it + targeted is now addressed by consolidation-side design instead). + +For the current state of the system, read `../version_ledger.md`, +`../development_history.md`, and the two results records +(`../extraction_recovery.md`, `../locomo_dream_ablation.md`). diff --git a/docs/graph_memory_v7/archive/extractor_direction_D.md b/docs/graph_memory_v7/archive/extractor_direction_D.md new file mode 100644 index 000000000..2d07e0653 --- /dev/null +++ b/docs/graph_memory_v7/archive/extractor_direction_D.md @@ -0,0 +1,109 @@ +# Extractor Direction D — LLM triple extraction (keep relations, per-chunk) + +Design note capturing the validated direction for the graph extractor, to be built +as a version next round. Supersedes the v7.4 GLiNER extractor for the concept gap. + +## Why: the v7.4 GLiNER concept-loss (corrected finding) + +v7.4 (GLiNER) shipped as a ~200× build-time win with the User/Assistant noise hubs +gone, at net-neutral QA (32/60 vs v7 30/60). **But a per-question look revealed a real +regression it hides:** GLiNER, a span tagger, **cannot abstract**. v7's LightRAG (an +LLM) read *"socializing with colleagues while working from home"* and named the concept +**"Networking" / "Team Collaboration" / "Remote Team Connections"**; GLiNER can only tag +spans that literally appear, so those concept anchors vanish (verified: `networking`, +`team collaboration`, `coworkers`, `remote team connections` = 0 in the v7.4 graph; v7 +had them). Type mix confirms it: GLiNER over-produces `object` (907, 37%) and under- +produces `concept` (294, 12%). On concept-heavy queries the v7.4 graph fails to match +and the retriever falls back to off-topic memories (the "staying connected with +colleagues" query retrieved *fitness* memories under v7.4, but the right social/ +networking anchors under v7). The neutral aggregate QA masked this. + +Aggressive GLiNER tuning (threshold 0.3, 14 concept-ish labels) does **not** fix it — +it only catches more literal spans (`social interactions`, `watercooler conversations`), +never the abstraction. This is a hard limit, not a config gap. + +## Why LLM: what recent top-venue graph works actually use + +From the entity-extraction research (see the workflow), **every** recent peer-reviewed +KG/graph-memory method uses **LLM extraction of triples**; GLiNER is the outlier (a +general NER tool, not a KG builder): + +| method | extraction | granularity | output | +|---|---|---|---| +| HippoRAG 2 (ICML'25) | 1 LLM OpenIE call | per passage | (s, p, o) triples | +| AutoSchemaKG (ACL'26) | 3 LLM passes (E-E, E-V, V-V) | per chunk | triples + events | +| KGGen (NeurIPS'25) | 2 LLM calls (entities, then relations) | per chunk | JSON triples + clustering | +| KARMA (NeurIPS'25) | 9 LLM agents, ~20-30 calls | per document | triples + verify | +| EDC (EMNLP'24) | few-shot LLM open IE | per document | [S, R, O] triples | +| GLiNER (NAACL'24) | encoder forward pass | — | spans, **no relations, no abstraction** | + +Takeaways: (1) LLM, because abstraction + relations are the point; (2) they extract +**triples** — the relation (predicate) is kept, whereas v7 **discards** LightRAG's +relations; (3) granularity is **per passage/chunk**, not per memory. + +So MIRIX's real inefficiency was never "using an LLM" — it was running LLM extraction +**twice** (6-agent chunk→memories, then LightRAG memory→anchors) and **throwing the +relations away**. v7.4 GLiNER "fixed" cost by removing the LLM, losing abstraction with +it. Direction D fixes cost the SOTA-aligned way instead. + +## D: the direction + +**One LLM triple-extraction call per chunk; keep the relations; concepts abstracted.** + +| | v7 LightRAG | v7.4 GLiNER | **D** | +|---|---|---|---| +| LLM | ✅ | ❌ | ✅ | +| abstraction | ✅ | ❌ | ✅ | +| relations | extracted, **discarded** | ❌ | ✅ **kept** (anchor→anchor edges) | +| provenance | — | — | ✅ carried in the relation (User seeks…/Assistant suggests…) | +| granularity | per memory (many calls) | — | **per chunk** (few calls) | + +GLiNER is demoted to an optional cheap named-entity supplement. + +### Validated prompt (gpt-4.1-mini, temperature 0, JSON mode) + +``` +Extract knowledge-graph triples from the text: every meaningful (subject, relation, object) fact. +Subjects/objects are ENTITIES: named things (people/places/orgs/products), OR the underlying CONCEPT/THEME. +For concepts, output a CONCISE CANONICAL name (2-4 words, the general theme), NOT a copied phrase: + "misses watercooler chats with colleagues while remote" -> concept "Remote Team Networking" + "socializing with colleagues while working from home" -> concept "Workplace Socializing" +Each entity: name + type from person, organization, location, event, concept, method, content, object, date. +Return JSON: {"triples":[{"s":{"name","type"},"r":"short relation","o":{"name","type"}}]} +``` + +Validated output on the real "colleagues" memory (the query GLiNER failed) — 6 triples, +2.9 s: + +``` +(User) --seeks--> (Workplace Socializing [concept]) ← abstraction GLiNER can't do +(User) --enjoys--> (Remote Work [concept]) +(User) --misses--> (Workplace Socializing [concept]) +(Assistant) --suggests--> (Online Communities [concept]) +(Assistant) --suggests--> (Virtual Coffee Breaks [method]) +(Assistant) --suggests--> (Alumni Networks [organization]) +``` + +Clean canonical concepts (comparable to v7's Networking/Social Connections), relations +kept, and User/Assistant provenance sitting in the relation subject. + +## Build plan (next round) + +1. `mirix/services/triple_extractor.py` — the prompt above → `[Triple(s,r,o)]` with typed + entities. Async, one call per input. +2. `process_memory`: add `relations=` (alongside existing `entities=`). Build + **anchor→anchor `V7_RELATION` edges** (new — v7 has none). Entities still go through + `_select_anchors` and link to memory refs as today. +3. Per-chunk batching driver: group a chunk's memories, one extraction call, map entities + back to the memory refs they appear in. +4. Gate on a new `graph_version` (e.g. `v7.6`); add to both guard tuples. +5. Verify on the same 962 memories: do abstract concept anchors return (networking, team + collaboration…)? relation edges present? per-question fix on the colleagues query? + cost (calls/time) vs v7 LightRAG and v7.4 GLiNER? QA vs both. + +### Roadmap impact + +D becomes the extractor foundation and provides the relation edges that **v7.6 (PPR +retrieval)** needs — so D and "keep relations + PPR" merge into one line. Role/domain +scoping (v7.5) also gets provenance for free from D's relation subjects. v7.4 GLiNER +stays shipped-but-superseded (a cheap named-entity supplement if ever wanted). diff --git a/docs/graph_memory_v7/archive/hypergraph_and_consolidate.md b/docs/graph_memory_v7/archive/hypergraph_and_consolidate.md new file mode 100644 index 000000000..c1d0872f9 --- /dev/null +++ b/docs/graph_memory_v7/archive/hypergraph_and_consolidate.md @@ -0,0 +1,210 @@ +# Hypergraph (V7Fact) + `consolidate` answerer tool — build log & negative result + +Two experiments on the same 962-memory LongMemEval-S store (`mirix_lm114_pm`, +632 episodic + 330 semantic). Both are **kept in the tree but rejected as +defaults**; the reasons are the scientifically useful part. + +> Numbering note: these were built as "v7.10 (hypergraph)" and "v7.11 +> (consolidate)" during the session. That collides with `version_ledger.md`'s +> *roadmap* entries (v7.10 = LLM verify-merge gate, v7.11 = schema induction). +> The roadmap is unchanged; this doc records what was actually built/measured. + +## 1. Hypergraph — reify each triple as a `V7Fact` hyperedge + +`graph_memory_manager_v7._upsert_facts` (fires when `graph_version == "v7.10"`): +each extracted triple becomes a `V7Fact` node carrying `predicate`, `role` +(user/assistant/shared), `timestamp`, linked by `V7_FACT_SUBJECT` / +`V7_FACT_OBJECT` / `V7_FACT_FROM` to the subject anchor, object anchor, and +source memory. Built store: **4785 anchors + 4633 V7Fact** over 962 memories. + +- The structure is sound and is the paper's "facts as first-class, role- and + time-stamped hyperedges" contribution. + +### Refinement pass (`evals/refine_hypergraph.py`) — redundancy removed, free + +The as-built hypergraph carried real redundancy. Four passes, run to convergence +(a second pass finds nothing), **at zero QA cost**: + +| | before | after | | +|---|---:|---:|---| +| V7Fact | 4,633 | **4,306** | −7.1% | +| V7Anchor | 4,785 | **3,665** | −23.4% | +| distinct predicates | 1,320 | **1,221** | −7.5% | +| edges | 26,639 | **24,713** | −7.2% | + +1. **Tautologies (112)** — extraction artifacts where subject == object + (`french -include-> french`). Deleted. +2. **Duplicate triples (215)** — identical (subject, predicate, object) collapsed + to ONE fact node, with each original source re-attached as an extra + `V7_FACT_FROM` edge. **Provenance is preserved, not lost**: 131 facts now cite + >1 memory (max 9), e.g. *"Seven Husbands of Evelyn Hugo -written by-> Taylor + Jenkins Reid"* cited by 4 memories. This is the correct hypergraph semantics — + a fact is one fact, cited N times, not N copies. +3. **Predicate canonicalization (99 variants)** — `includes`(421) folded into + `include`(→823); `is/are located in` → `located in`; `offers` → `offer`. +4. **Dead-weight anchors (1,120)** — no fact touches them *and* they link ≤1 + memory, so they can neither answer nor bridge. Pruned. (Consistent with the + earlier v8 result: −66% anchors at zero accuracy cost.) + +Post-refinement invariants verified: 0 tautologies, 0 duplicate triples, 0 +malformed facts (every fact keeps subject + object + ≥1 source), 0 orphan +anchors. **QA re-checked on the refined graph: 36/60**, inside the cold-fact band +(37/36/37), with all five recovered-fact wins (Q46/Q56/Q57/Q1/Q15) still passing. + +### Prevented at ingest (3 of the 4 no longer need cleanup) + +`_upsert_facts` was *generating* the redundancy: it minted a random `gen_id()` per +extraction, so `MERGE (f:V7Fact {id: ...})` always CREATED — the same triple from +N memories became N nodes. It also stored the raw predicate surface form and had +no tautology guard (while `_link_relation_edges` right below it did). Fixed at +write time: + +- **Deterministic identity** — `fact_identity(user, subj_key, predicate, obj_key)` + hashes the canonical triple, so the same assertion MERGEs to ONE fact and each + new memory only adds a `V7_FACT_FROM` citation edge. +- **Canonical predicates** — `canon_predicate()` folds copulas and singularizes + the verb (`includes`→`include`, `is located in`→`located in`) before storage. +- **Tautology guard** — `sk == ok` triples are dropped, matching the existing + relation-edge guard. +- **Role/time moved to the citation** — they are properties of *this memory + asserting the fact*, not of the fact, now that one fact spans memories. The + node keeps first-seen values via `ON CREATE SET` (nothing else read them). + +Verified end-to-end: the same triple submitted from two memories with different +surface forms plus a tautology yields **1 fact node**, predicate `include`, cited +by both memories with per-citation roles (`user`, `assistant`). + +**The 4th (dead-weight anchors) is not preventable at write time** — whether an +anchor ever gets a fact or a second memory is a corpus-global property unknown +when it is created. That one stays a periodic maintenance pass +(`refine_hypergraph.py`, pass 4, and `maintain_graph` in the auto_dream cycle). + +### Verified by a full rebuild — clean from birth + +Rebuilding all 962 memories with the new write path, then checking **without +running any cleanup**: + +| check | result | +|---|---| +| tautologies | **0** | +| duplicate triples | **0** | +| `includes`-style predicate variants | **0** | +| citation edges carrying their own role | **4,471 / 4,471 (100%)** | + +Final shape: 4,305 facts · 4,862 anchors → **3,689 after** the maintenance pass, +which reported `{tautologies: 0, duplicates: 0, dead_anchors_pruned: 1,173}` — +exactly the intended division of labour: ingest prevents three kinds, the periodic +pass collects the one it cannot. 137 facts are cited by >1 memory (max 14), each +citation keeping its own role (shared 2,619 / assistant 1,048 / user 804). + +**A real bug the dedup change introduced.** Deterministic ids mean concurrent +ingests MERGE onto the *same* node, so they contend for its lock — a rebuild at +concurrency 10 started failing with `Neo.TransientError.Transaction. +DeadlockDetected`. The old random-id scheme never collided, so this only appears +once dedup actually works, and only under concurrency: a serial test cannot catch +it. Fixed by sorting rows by fact id (uniform lock-acquisition order) plus +exponential-backoff retry on transient errors. Re-verified: 962 memories at +concurrency 10, **0 failures**. + +### How this interacts with auto_dream + +`auto_dream` is the PG-side analogue of this work: an LLM agent that reviews +memories for duplicates/overlaps/conflicts and merges them via +`episodic_memory_replace` / `semantic_memory_update` (prefer merging over +deletion; keep the uncertainty when a conflict is unresolvable). + +Tracing that path matters for graph coherence: +- `episodic_memory_replace` **hard-deletes** the old rows and touches nothing in + the graph → dangling refs. This is what `maintain_graph`'s orphan sweep exists + to clean. +- It then **re-inserts** the merged item through `insert_event`, which *does* call + `process_memory` → the merged memory gets fresh graph refs. + +So the graph stays coherent across a dream cycle, provided the sweep runs. Note +the standing assumption: `process_memory` is wired only to the `insert_*` paths, +so any future in-place memory update would silently leave the graph stale. + +(auto_dream has never run on the eval store — 0 checkpoints — so none of the +measurements in these docs are affected by it.) +- The **`query_facts` answerer tool** over it was **rejected**: QA 36 → 32. It + returned descriptive/off-topic facts that misled the answerer. Superseded by + `consolidate` (below), which is a better mechanism but also nets out negative. + +## 2. `consolidate` — exhaustive distinct-instance enumerator (answerer tool) + +Root-cause hypothesis it was built to fix: countable *personal* instances (the 4 +bikes, 5 fitness classes) are **scattered across memories and never aggregated**, +so top-k flat search under-counts. + +`TaskAgent._consolidate(topic)`: embed topic → (a) graph anchor→memory recall + +(b) pgvector recall over memory `summary_embedding`, union → fetch full PG text → +LLM de-dup into `{items, count}`. Three real bugs were found and fixed along the +way — each is a reusable lesson: + +1. **Embedding-model mismatch.** MIRIX's `embeddings.embedding_model()` never + passes `config.embedding_model` to llama-index's `OpenAIEmbedding`, so it + silently defaults to **`text-embedding-ada-002`**, not the configured + `text-embedding-3-small`. Both are 1536-dim, which hid it. The **entire graph + + PG store is ada-002**; a raw 3-small query lands in a different space + (anchor match cos 0.52 vs 0.94). Any hand-written retrieval MUST use ada-002. +2. **Detail truncation.** Enumerations live deep in `details` + ("...four bikes: a road bike, mountain bike, commuter bike, and a new hybrid + bike") — truncating details to 300 chars dropped the count. +3. **Graph-only recall is incomplete.** The "four bikes" memory was anchored + under trip *locations* (San Francisco, Jackson Hole), never linked to a *bike* + anchor — an extraction-linking gap. Adding a pgvector recall channel + (zero-padded to the store's 4096-dim column) recovered it. + +**Mechanism validated** on hand-picked topics: bikes 4/4, art 4/4, jewelry 3/3, +fitness 5/5. + +## 3. End-to-end QA — rejected + +| answerer config | QA /60 | +|---|---:| +| enumerate-then-count (baseline) | **36** | +| + consolidate, trust its count | 30 | +| + consolidate, scoped to distinct-instance + advisory | 31 | + +Both variants regress. Gated behind `MIRIX_ENABLE_CONSOLIDATE` (default off = +the 36 answerer). + +## 4. Why it can't help — 3-run consensus decomposition + +Scoring three full runs (36 / 30 / 31) per-question and partitioning: + +| partition | # of 60 | meaning | +|---|---:|---| +| stably correct (1/1/1) | 23 | always answered right | +| **stably wrong (0/0/0)** | **17** | **the true ceiling** | +| **flips across runs** | **20** | **answerer-LLM noise** | + +**Layer A — the metric is noise-dominated (biggest cause).** Score = 23 + +(hits on the 20 unstable). Floor 23, ceiling 43; 36/30/31 = 23 + {13, 7, 8}. +**"36" was a lucky draw on the unstable set, not a real win.** Any answerer +change smaller than ≈±6 is undetectable in a single run. This retroactively +explains the whole answerer line (v7.9 34→30, v7.10 `query_facts` 36→32, +v7.11 36→30/31): **all within one noise band — we were chasing noise.** + +**Layer B — consolidate's addressable set is tiny and qualifier-gated.** The +distinct-instance counting questions it targets (Q17 art, Q26 fitness, Q59 +jewelry, Q54 devices) all carry temporal/scope qualifiers — "in the past month", +"in a typical week", "in the last two months", "in a day" — that a flat topic +enumerator ignores. The hand-tests looked perfect only because they *dropped the +qualifier*. And those questions sit in the noise band or the ceiling anyway. + +**Layer C — the true ceiling isn't an enumeration problem.** The 17 stably-wrong: +cross-session aggregation (7), preference synthesis (4), quantity-sums (fish=17, +luxury total=$2500, discount %), stored-value/extraction-miss (50 hours, 500 +Mbps, 7 shirts). **None** is "scattered distinct instances need enumerating" — +consolidate's target mode is essentially absent from the ceiling. + +## 5. Implications (what to actually do next) + +1. **Validate answerer changes with ≥3 runs / averaging.** Single-run deltas of + ±6 are noise. Every prior "version" QA number is a noisy point estimate. +2. **Attack the stable ceiling, not counting.** Cross-session aggregation (7) + + preference synthesis (4) = 11 questions, 65% of the ceiling. That is the real + target for the paper, not distinct-instance enumeration. +3. Keep the hypergraph as structure and `consolidate` as an A/B-able tool; give + consolidate temporal/scope filtering before re-testing it. diff --git a/docs/graph_memory_v7/archive/lm10_v7_graph.png b/docs/graph_memory_v7/archive/lm10_v7_graph.png new file mode 100644 index 000000000..d409255e7 Binary files /dev/null and b/docs/graph_memory_v7/archive/lm10_v7_graph.png differ diff --git a/docs/graph_memory_v7/archive/v74_v78_summary.md b/docs/graph_memory_v7/archive/v74_v78_summary.md new file mode 100644 index 000000000..ac5c770ec --- /dev/null +++ b/docs/graph_memory_v7/archive/v74_v78_summary.md @@ -0,0 +1,114 @@ +# v7.4–v7.8 + QA failure study — consolidated summary + +One clean record of the graph-extractor/resolution line (v7.4–v7.8), the QA-ceiling +diagnosis, and the verified research on how to break the ceiling. All numbers are on +the same 962-memory LongMemEval-S store (`mirix_lm114_pm`, 632 episodic + 330 semantic) +unless noted, so cross-version QA is a clean same-memory comparison. + +## 1. The version line at a glance + +| ver | change | extract /mem | build 962 mem | anchors | singleton | QA (/60) | verdict | +|---|---|---:|---:|---:|---:|---:|---| +| v7 | LightRAG extractor + `anchor_canonical_key` (surface dedup) — **baseline** | 15.25s | ~24min (×10, extrap.) | 5165 | 66% | 30 | baseline (post-merge full) | +| **v7.4** | **GLiNER** encoder extractor (local, deterministic) | **0.09s (~170×)** | **1m38s** (×10) | 2473 | 66% | 32 | **superseded** — fast + kills User/Assistant hubs, but a span tagger **can't abstract** → drops LightRAG's concept anchors (networking/team-collab = 0); concept-heavy retrieval degrades (masked by net QA) | +| **v7.6** | **LLM triple extraction (direction D)** — abstracts + keeps relations as `V7_RELATION` edges | 5.9s (2.6×) | **9m41s** (×10) | 5777 | 81% | 31 | **foundation** — concept anchors back (41% concept), **3667 relation edges** (v7 had none); real value = relations, not speed | +| **v7.7** | **PPR retrieval** over v7.6's relation graph (networkx, no GDS) | — | — (retrieval 1.6s/query) | — | — | 30 | retrieval **much** better (colleague query: fitness→social; context 500→37k chars) but **QA flat** → **pivotal finding** | +| **v7.8** | **registry-guided entity resolution** — embedding candidates + **LLM verify-merge**, rename to canonical at extract | ~9s (5.9 + resolve) | **36m08s** (×4; ≈14min if ×10) | 4744 (−18%) | 66% | **34 (best)** | cleaned the redundancy v7.6 introduced; **first change to move QA** (+3 vs v7.6; re-run to confirm vs answerer-LLM noise) | + +*Build times measured on the 962-memory rebuild; "×N" = the `rebuild_graph.py` concurrency +used (v7.8 was run at 4 for registry consistency, so its 36min ≠ apples-to-apples with the +×10 runs — per-memory it's ~9s, i.e. ~14min at ×10). v7 LightRAG's 962-build is extrapolated +from its measured 15.25s/mem (no clean full rebuild was run at that cost).* + +(Earlier: v7.1 rerank, v7.2 coverage — retrieval tweaks; v7.3 proposition ingest — rejected, collapsed edge structure. v7.5 role/domain scoping — planned, not built.) + +QA line: **v7 30 · v7.4 32 · v7.6 31 · v7.7 30 · v7.8 34.** + +## 2. Key findings per version + +- **v7.4 (GLiNER) — the "faster but can't abstract" lesson.** ~170× faster and removes the + User/Assistant noise mega-hubs, but a span tagger only tags literal spans. v7's concept + anchors ("Networking", "Team Collaboration") were LightRAG **abstractions** — the words + aren't in the text — so GLiNER cannot reproduce them (verified: aggressive tuning can't + recover them; hard limit). Kept only as a cheap named-entity supplement. +- **v7.6 (D) — LLM triples, the SOTA-aligned extractor.** Every recent top-venue KG method + (HippoRAG 2, KGGen, AutoSchemaKG, EDC) uses LLM triple extraction; GLiNER is the outlier. + D restores abstraction **and** keeps the relations v7 discarded (→ `V7_RELATION` edges, + the substrate for multi-hop). Corrected speed claim: only **2.6×** faster than LightRAG + (I first said 34× by comparing concurrent-D against sequential-LightRAG — an error). +- **v7.7 (PPR) — the pivotal finding.** PPR made retrieval clearly better, yet QA stayed + flat across all four graph variants (30–32). Root cause, verified from transcripts: + **the answerer calls its own `search_memory` (flat pgvector) on 60/60 questions**, so the + graph context we inject is only a *secondary* source — the parallel flat search dominates. + Retrieval/graph-context quality is **not** the QA bottleneck. +- **v7.8 (entity resolution) — the redundancy fix, and it moved QA.** v7.6's LLM naming was + inconsistent → 81% singleton, ~30% near-duplicate anchors ("Outward Hound Brick Puzzle" + vs "…'s Brick Puzzle", cos 1.00). Surface `anchor_canonical_key` can't fix this and + embedding-cosine merge can't decide identity (Monday≈Tuesday, 42≈46 campsites are close + but distinct). v7.8 = **embedding finds candidates, an LLM decides same-or-new** (EDC/KARMA + style). Result: anchors −18%, singleton 81%→66%, **no over-merge** (numbers/days kept + apart), and QA 34 (best). This is the paper's canonicalization layer — and the only + mechanism that can also *split* same-string entities (G1), because the LLM has the names. + +## 3. Why QA is stuck ~30/60 — the ceiling diagnosis + +23 questions fail across **all** retrieval variants (systematic ceiling; the other ~13 that +differ between versions are answerer-LLM noise, no consistent graph-strength pattern). Each +of the 23 was judged against the retrieved evidence **and** the raw memory store: + +| failure category | # | who can fix it | +|---|---:|---| +| **reasoning error** — correct evidence retrieved, wrong answer (evidence "American Airlines" → answered "JetBlue"; memory "50 hours" → "45"; over-committed on an abstention question) | 5 | **answerer only** | +| **counting / aggregation** — instances exist & are retrieved but the LLM under-counts (3 of 4 art events; 1 of 3 jewelry; $2000 of $2500) | 5 | answerer (enumerate-then-count); graph enumeration as a completeness aid | +| **preference synthesis** — "suggest X for me" needs the user's history synthesized; gets generic advice | 4 | answerer + a persona store | +| **extraction miss** — the fact was never written to memory ("500 Mbps" = 0 memories; "7 shirts" = 0) | 4 | **write-time extraction only** — unrecoverable by any retriever/graph | +| **temporal** — date arithmetic / event ordering | 3 | temporal index + code date-math | +| **retrieval miss** — fact in memory but not surfaced | ~2 | (nearly none) | + +**Bottom line: ~61% answerer-side, ~17% extraction, ~0% pure retrieval.** No graph/retrieval +change can move the answerer/extraction failures. (Illustrative: the "5 fitness classes" +miss — the 5th is yoga, which is *all over* memory; the answerer just didn't count Sunday/ +app yoga as a "class." Aggregation reasoning, not fragmentation or retrieval.) + +## 4. QA research — how to break the ceiling (verified, 2024–2026) + +The strongest external anchor: **LongMemEval (ICLR 2025) itself shows the bottleneck is +reading/reasoning, not retrieval**, and its own fix (Chain-of-Note reading) gives up to +**+10pp**. And **Emergence AI's independent LongMemEval harness: SOTA 86% = session-granular +retrieval + rerank + a reasoning read step — NOT a fancier graph** (graph systems like Zep +sit at 71%). Preference is the hardest category even for SOTA (60%). + +| our failure mode | fix (method @ verified venue) | how it plugs into "answerer + search_memory" | +|---|---|---| +| reasoning error (5) | **Chain-of-Note** (EMNLP'24; LongMemEval-native, +10pp) → cite-memory-id + **Self-RAG ISSUP** self-check → **CoVe** (Findings ACL'24) for the hardest | per-memory "reading notes" before answering + a grounding self-check pass | +| counting (5) | **enumerate-then-count**: force a JSON list of every supporting memory, then **PAL/PoT** (ICML'23) counts with code | new count/aggregate tool + prompt; graph `enumerate_entity` as completeness backend | +| abstention (subset of reasoning) | **Sufficient-Context gate** (ICLR'25): "is this enough to answer? if not → not-enough-info". ⚠️ **AbstentionBench (NeurIPS'25): CoT *hurts* abstention** → need an explicit gate, not more reasoning | post-retrieval LLM-judge gate that can emit abstain | +| preference (4) | **persona/preference store**: LaMP-PAG (ACL'24, minimal) / PersonaAgent (NeurIPS'25) | ingestion-time preference extraction into a persona sub-store; **inject** the profile for advice-shaped questions (don't rely on search) | +| extraction miss (4) | **Dense-X propositions** (EMNLP'24) + **SeCom** segment fallback (ICLR'25) + LongMemEval fact-key expansion | write-time: atomic-proposition extraction so cold numerics each get a row (reconnects the v7.3 line) | +| temporal (3) | **TReMu** (Findings ACL'25): resolve relative→absolute dates at ingest + **code** date math; **bi-temporal Neo4j edges** (Zep) | ingest stores absolute dates; answerer gets a date-math tool; graph edges carry valid-time | + +**Cross-cutting:** one "read → structure → verify" answerer stage (Chain-of-Note JSON notes) +feeds counting (enumerate-then-count), grounding (cite + ISSUP), and abstention (sufficiency +gate) at once — hits ~10 of the 23 (reasoning 5 + counting 5), cheapest (prompt/tool, no +re-ingest), and CoN is the only method with a published LongMemEval result. **Start there.** + +## 5. Where we are / next + +- **Graph line (v7.4–v7.8): done and committed.** v7.6 (relations) is the foundation; v7.8 + (entity resolution) is the clean version and the paper's canonicalization contribution, and + it moved QA (34, best — confirm with a re-run to rule out answerer-LLM noise). +- **The QA lever is the answerer, not the graph.** To go past ~34: (1) Chain-of-Note + + enumerate-then-count + sufficiency gate (biggest, cheapest — ~10 questions); (2) proposition + extraction for the 4 unrecoverable extraction misses; (3) persona store for preference; + (4) temporal absolute-dates + code math. +- **Graph's genuine role going forward:** structured **tools** for the answerer (an + `enumerate_entity` completeness backend for counting; bi-temporal edges for ordering; a + persona subgraph) — expose the graph as tools the answerer calls, not context it ignores. + +### Commits +v7.4, v7.6, v7.7 and v7.8 all land in `pre-squash tag` (the extractor-line commit) — the +branch history was consolidated into six thematic commits, so a version no longer +owns a commit one-to-one. The original per-version commits (`73c5207` v7.4, +`7fb68fc` v7.6, `dcefe3e` v7.7, `a04de8f` v7.8) are preserved at the tag +`graph_revision_pre_squash`; check that out to get a single version's exact code +state. Branch `graph_revision`, ahead of `origin/main` (unpushed). diff --git a/docs/graph_memory_v7/development_history.md b/docs/graph_memory_v7/development_history.md new file mode 100644 index 000000000..1e575b090 --- /dev/null +++ b/docs/graph_memory_v7/development_history.md @@ -0,0 +1,186 @@ +# Development history — how the graph memory got here + +The research arc from the v7 baseline to the current system (hypergraph + +reranked retrieval + lossless interleaved consolidation), including the failed +branches and the measurements that killed them. Written so a reader can follow +*why* each design exists, not just what it is. Companion records: +`version_ledger.md` (per-version table), `extraction_recovery.md` (LongMemEval +results), `locomo_dream_ablation.md` (LoCoMo ablation), `archive/` (superseded +design-era docs). + +## Phase 1 — baseline and retrieval variants (v7 – v7.3) + +v7's founding principle: **content stays in flat PG; graph nodes must earn +their place by creating a retrieval path**. LightRAG extraction, anchors with +surface dedup, memory refs. + +- v7.1 reranked anchor-collected candidates by query text-cosine — **SH-Doc +4, + the first graph change to beat flat on held memories**. (This rerank later + went silently dead for every newer version; see Phase 6.) +- v7.2 per-anchor coverage round-robin — neutral. +- v7.3 proposition-based ingest — **rejected**: collapsed the edge structure + (only DESCRIBED_BY survived, singletons ↑77%). + +## Phase 2 — the extractor line (v7.4 – v7.11) + +- v7.4 GLiNER: ~200× faster, hub-free — but a span tagger cannot abstract; + concept anchors vanished. Kept only as a named-entity supplement. +- v7.6 "direction D" LLM triples: concept anchors back **plus relation edges** + the graph never had. The honest accounting: 2.6× faster than LightRAG (an + earlier 34× claim compared concurrent vs sequential runs and was retracted). +- v7.7 Personalized PageRank over those relations: retrieval visibly richer, QA + flat — first hint that the answerer, not the graph, was the bottleneck. +- v7.8 registry-guided entity resolution at extract time: 18% anchor reduction + where cosine-only managed 2%. +- v7.9 role-split retrieval context — rejected (34→30). +- v7.10 **hypergraph**: each triple reified as a V7Fact node joining subject + anchor, object anchor and source memory, with role + timestamp. Facts become + citable, dedupable, governable — the structural foundation everything later + stands on. +- v7.11 `consolidate` answerer tool for counting questions — rejected (36→30/31). + +## Phase 3 — the measurement crisis + +Running the *same* config four times gave 36/30/33/30: **mean 32.2, sd 2.5, a +third of questions flipping run to run**. Every prior version-to-version QA +delta was inside this band. Two rules came out of it and governed everything +after: (1) no conclusion from a single run; (2) a real fix both raises the mean +and tightens the sd (deterministic wins), which is how signal is told from +noise. Per-question forensics then split the stably-wrong questions into a +taxonomy: ~61% answerer-side, ~17% write-time extraction loss, ~13% temporal — +and ~0% pure retrieval misses. + +## Phase 4 — the write-time turn + +Answerer-side interventions (counting tool, temporal prompt, persona store, +cold-fact *injection*) all landed at or below baseline. What worked was +recovering what ingest had dropped: + +- **Cold-fact recovery +4.4** (32.2→36.7, sd 0.5): a second pass over raw + source extracts literal-bearing facts; merged into `search_memory` results + **as ordinary evidence**. Framing was load-bearing — the same facts injected + as "ground truth" netted zero because they overrode correct answers. +- **Rebuilt clean graph +4.0** (→40.7): the gains clustered exactly where graph + structure should help (temporal ordering, multi-session aggregation). + +Lesson that held from here on: **every real gain came from what gets written, +never from how the answerer is told to read.** + +## Phase 5 — the consolidation saga + +auto_dream (upstream: an LLM agent that merges memories) was made to run at +scale (proportional batching for a 142k-token overflow; a stale version guard +that routed v7.4+ to a dead legacy graph builder; invisible logging), then +measured: **no-dream 40.3 vs with-dream 32.7 — consolidation cost −7.7.** + +The diagnosis went through three stages, each forced by a user challenge: + +1. "Merging shouldn't lose anything" → strengthened preserve-facts prompt. + Facts survived in the store, QA didn't recover — the harm was also + *retrieval degradation* (blended embeddings can't find what they contain). +2. **graph_only mode**: skip the flat merge entirely, refine only the graph. + Byte-identical PG proven with before/after hashes → QA break-even *by + construction*, anchors −27%. First dream mode that satisfied "至少持平, + 結構更精煉". +3. On LoCoMo the flat merge was caught **literally deleting** single-occurrence + facts (sunflower/figurine/pride-festival grep to 0 rows post-dream; the + answerer's "there is no record" was true). Root cause: "merge" was an LLM + rewrite + hard delete, with removal as the prompt's first goal — a soft + constraint fighting the primary objective. + +The fix made the constraint mechanical: the **union-coverage gate**. Before any +replace/update deletes, every deterministic specific of the old rows (numbers, +dates, month/weekday words, word-numbers, multi-word proper names) must appear +in the replacement text, else the call is rejected pre-delete and the error +steers the model to rewrite. Verified live: 29 lossy merges rejected, temporal +accuracy recovered from .838 to .919 (the exact no-dream level), consolidation +still happening. + +## Phase 6 — structural hygiene, found by measuring invariants + +Measuring the graph's degree distribution and post-dream state kept exposing +silent defects, each with the same signature — an invariant that should hold, +didn't: + +- A **"Users" anchor at degree 1016** (7.4× the biggest real hub, 10.5% of all + facts): the per-extractor noise blocklists missed plurals and LightRAG had no + filter at all → one canonical-key gate at `_select_anchors`, plus a + retroactive purge pass in maintenance. +- The **v7.1 rerank had been dead** for every version after v7.1 (`== "v7.1"` + guard), and the retriever dispatcher stranded v7.4–v7.10 on a dead v5 + pipeline. Same stale-exact-match bug class in four sites; all now + `startswith`. Paired A/B after revival: +2~3 on LoCoMo. +- **Consolidation grew the graph** (+27% facts) instead of shrinking it — + merged-away memories left zombie facts with no citations, and DETACH-deleting + refs shredded the temporal chain (52 edges/100 refs). Two maintenance passes + restored the invariant (post-fix: dreamed graph converges to the no-dream + graph's size; chain n−1). +- **Live ingest hooks never passed `role`** — every organically built graph had + silently lost user/assistant attribution; only rebuilt graphs had it, because + the rebuild script passed it itself. Found when the offline-rebuild shortcut + was banned (see below). + +## Phase 7 — LoCoMo cross-validation and the ablation + +A user-enforced methodology rule paid off twice: **evals must build PG and +graph together through the real ingest hooks** (entity resolution is +path-dependent; the paper describes the online system). Enforcing it exposed +the role-loss bug above, and the fresh-ingest discipline made the six-arm +ablation clean (each arm its own end-to-end build): + +no-graph .8816 / graph .8882 / **graph+rerank .9079** / ungated dream .8618 / +ungated+rerank .8750 / **gated dream+rerank 134-137-135 ≈ no-dream band**. + +Three durable findings: the reranker is real (+2~3 paired, second benchmark +after SH-Doc); the graph's per-category value is multi-hop (.846→.923 in every +graph arm); and the graph is **insurance** — worth ~0 when the flat store is +intact, ~+24/152 when the flat store has been consolidated (its facts were +extracted before the merge). Consolidation damage is question-type-dependent: +LongMemEval (cold specifics) −7.7/60 vs LoCoMo (salient gist) −2~5/152, and the +gate closes most of the latter. + +## Phase 8 — the regime lesson + +A LongMemEval rerun accidentally used a different chunking path (session-split +4096-char, 534 chunks) and scored 38/60 where the canonical baseline was ~30 — +because fine-grained ingest natively captured the cold facts (500 Mbps, 7 +shirts, $420: 0 rows in the canonical store, 2–7 rows in the fine-grained one). +Two lessons: **chunking granularity moved QA more than most retrieval work** +(worth a controlled study), and benchmark numbers are only comparable within a +regime — the canonical one is `evals/mab/longmem_eval.py`, 114 × 4096-token +sentence-aware chunks, timestamps attached. + +## Phase 9 — current direction: incremental semantic consolidation (v2) + +The accepted redesign of auto_dream, synthesizing everything measured: + +- **every 5 chunks** (affordable because incremental); +- new memories probe the existing graph by **embedding + shared-anchor + neighborhood** instead of whole-store batching (fixes batch-blindness: the + same topic weeks apart never met inside a batch); +- **episodic is immutable** — the event log and temporal chain are never + rewritten (temporal damage removed by construction); +- consolidation output is **just a semantic memory** (no new node type): + additive insert (or in-place update of an existing consolidation row), + `source="auto_dream"`, provenance via `V7_SUPPORTED_BY(reason=consolidation)` + edges to every raw source ref — original rows never modified; +- the union-coverage gate is reused as the constructor invariant (the + synthesized text must cover all source specifics), and conflicts are resolved + as recorded history ("was X (5/06) → now Y (6/20)"). + +## Meta-lessons + +1. **Noise discipline first.** Without the 32.2±2.5 floor, half the rejected + ideas above would have "worked". +2. **Mechanical guarantees beat instructions.** The preserve-facts prompt + reduced loss; the coverage gate ended it. Same pattern as the noise-anchor + gate vs per-extractor blocklists. +3. **Write-time beats read-time.** Cold facts, rebuilt graph, chunking + granularity — the levers were all on the write side. +4. **Measure invariants, not just accuracy.** Zombie facts, chain fragmentation, + role loss and the dead rerank were all invisible in QA numbers and obvious + in structure checks. +5. **The user's design instincts repeatedly beat the implementation's + defaults** — "merging shouldn't lose", "build PG and graph together", + "consolidate only the knowledge layer" each exposed a real defect or a + better architecture; the record is in Phases 5–9. diff --git a/docs/graph_memory_v7/extraction_recovery.md b/docs/graph_memory_v7/extraction_recovery.md new file mode 100644 index 000000000..c9a3923b8 --- /dev/null +++ b/docs/graph_memory_v7/extraction_recovery.md @@ -0,0 +1,184 @@ +# Cold-fact recovery — the first real QA gain (+4.4) + +The answerer-side result line for LongMemEval-S (60 QA, `mirix_lm114_pm`, 962 +memories). After establishing that the QA metric is noise-bound and that the +failure floor is **write-time fact-loss**, a selective cold-fact recovery pass +gives the project's first reproducible QA improvement. + +## 1. The measurement is noise-bound — validate with multi-run + +Running the **same** answerer config (enumerate-then-count) four times: **36, 30, +33, 30** — mean **32.2**, sd **2.5**, 33% of questions (20/60) flip run-to-run. +Any single-run delta < ~±6 is noise. Every prior "version" QA number (v7 30, v7.4 +32, v7.6 31, v7.7 30, v7.8 34, enumerate 36) is a draw from this band — they are +statistically indistinguishable. **All answerer/graph comparisons must be +multi-run.** + +## 2. The failure floor is write-time fact-loss, not reasoning + +3-/4-run consensus splits the 60 into ~22 stably-correct, ~18 stably-wrong (the +true ceiling), ~20 noise. The stably-wrong floor is dominated by facts the +**summarizing ingest dropped**. Verified against the raw HF source — the exact +value is present verbatim but absent from the stored memory: + +| Q | gold | in raw source | +|---|---|---| +| Q46 internet | 500 Mbps | "upgraded to **500 Mbps** about three weeks ago" | +| Q56 shirts | 7 | "brought **7 shirts** and 5 pairs of shorts" | +| Q1 work hours | 50 | "working up to **50 hours per week**" | +| Q15 cameras | 3 months | "collecting vintage cameras for **three months**" | +| Q57 farmers | $420 | "earned **$420** at the Downtown Farmers Market" | + +MIRIX's multi-agent ingest writes *summaries* ("User discussed packing for Costa +Rica") and drops the peripheral specifics. This is the Dense-X / LongMemEval +"summaries lose cold facts" problem, confirmed on our own store. + +## 3. What did NOT work (all multi-run, all gated-off) + +Three answerer-side interventions, each targeting a slice of the floor — **all +land at or below 32.2**, because the floor is retrieval/extraction, not answerer +reasoning, and adding context perturbs the 20 noisy questions: + +- **v7.11 `consolidate`** (distinct-instance counting tool): 30/31. Mechanism + works on hand-picked topics but the benchmark's counting Qs are mostly + quantity-sums it can't help; wins already captured by enumerate-then-count. +- **temporal prompt** (use given Current Date, exact-date filter, explicit + subtraction): 31. The "temporal" failures are actually **retrieval/grounding** + (Q48 retrieved the wrong workshop date; Q29 confused considered-vs-flew) — a + date-math prompt can't fix a fact it can't find. +- **persona store** (LaMP-PAG-style profile injected for advice Qs): 30. Barely + moved its targets (the profile lacked the exact details — "lemon poppyseed", + "30-day challenge" — because *those were summarized away too*) and broke + already-passing advice Qs. + +## 4. What worked — selective cold-fact recovery + search-merge (+4.4) + +Two ingredients: + +1. **Selective cold-fact extraction** (`evals/build_coldfacts.py`): a second pass + over raw USER turns, keeping only turns bearing a literal (digit / price / % / + **word-number** like "three months") — most turns emit nothing, so no Dense-X + row explosion. An LLM turns each into a self-contained fact preserving the + verbatim value ("The user upgraded to 500 Mbps", "…collecting cameras for + three months"). 129 facts, embedded with **ada-002** (the model MIRIX's ingest + actually uses — `embedding_model()` silently defaults llama-index to ada-002, + never passing `config.embedding_model`; the whole store is ada-002). +2. **Search-merge, not injection** (`TaskAgent`, gated `MIRIX_COLDFACT`): cold + facts matching the answerer's *search query* are appended to `search_memory` + results **as regular retrieved evidence** — not force-injected as "verified + ground truth". This is the load-bearing design choice. + +**Injection vs merge — the decisive comparison (both recover the same facts):** + +| | mean | note | +|---|---|---| +| baseline | 32.2 | — | +| cold-fact **injection** (system prompt, "treat as ground truth") | ~31.7 | flat — flips the 4 targets but "ground truth" framing *overrides correct answers* (Q36/Q40: 4/4→0/3) and conflicting facts mislead (Q16) | +| cold-fact **search-merge** (regular evidence) | **36.7** | **+4.4** — same target wins, but retrieval-competition framing avoids the over-trust collateral | + +Force-injection and merge recover the *identical* facts; the only difference is +whether the answerer treats them as commands or as evidence. Evidence wins. + +**Result (search-merge + refined extractor), 3-run:** **37 / 36 / 37 — mean 36.7, +sd 0.5.** vs baseline 32.2 / sd 2.5. The distributions barely overlap (cold-fact +min 36 = baseline max 36), and variance collapses because the wins are +**deterministic** (the recovered fact flips the same question every run). + +Per-question: **+7 won / −3 lost = +4 net deterministic.** +- Won: Q46/Q56/Q57/Q1/Q15 (0→3/3, previously impossible — the fact wasn't in + memory), plus Q48/Q4. +- Lost: Q16 ($800 vs $1,200 conflicting handbag prices), Q28 (both "three bikes" + and "four bikes" facts collide), Q33 (ordering). Residual collateral = + conflicting/ambiguous cold facts; future work = dedup-by-recency + skip + ordering/abstention Qs. + +## 4b. Rebuilding the graph adds another +4.0 (best: 40.7) + +Rebuilding the hypergraph with the clean write path (deterministic fact ids, +canonical predicates, tautology guard, per-citation roles) and re-running the same +cold-fact answerer: + +| config | runs | mean | sd | +|---|---|---:|---:| +| baseline (no cold-fact) | 36 / 30 / 33 / 30 | 32.2 | 2.5 | +| cold-fact + old graph | 37 / 36 / 37 | 36.7 | 0.5 | +| **cold-fact + rebuilt graph** | **39 / 42 / 41** | **40.7** | 1.2 | + +**+8.5 over baseline**, and the three distributions do not overlap (rebuilt min 39 +> old-graph max 37), so this is not noise. All five recovered-fact targets still +pass. + +The gains (+7 / −2 vs the old graph) cluster tellingly in **temporal-ordering** +(Q25 Spanish-classes 1/3→3/3, Q33 Page-Turners 0/3→3/3, Q39 camping-days 0/3→2/3) +and **multi-session aggregation** (Q17 art-events 0/3→3/3, Q59 jewelry 0/3→2/3), +plus two recall questions (Q52, Q42). That is precisely where graph structure — +the temporal chain and cross-memory links — should help, and it is the **first +time in this project that graph work moved QA at all**, contradicting the earlier +"retrieval/graph is not the bottleneck" finding. + +⚠️ **Attribution is not isolated.** The rebuild changed two things at once: +(a) structure (dedup, canonical predicates, per-citation roles) and (b) content +(a completely fresh, non-deterministic LLM extraction). This experiment cannot +separate them — the +4.0 could be largely a luckier extraction draw. The +consistency of the temporal flips (0/3 → 3/3, not a scattered 1-or-2) *suggests* a +structural cause, but suggestion is not proof. The controlled test is to rebuild +once more with the **old** write path and re-run 3× QA; until then this number +should be reported as "rebuilt graph", not "clean graph caused it". + +## 5. Takeaways + +- **The lever is write-time, not the answerer.** Recovering summarized-away facts + is the only intervention that beat the noise band — and rebuilding the graph + (also write-time) added the next +4.0. Every gain in this project came from what + gets *written*, never from how the answerer was told to *read*. +- **Provide recovered facts as evidence, not ground truth.** Same facts, +5 + swing between the two framings. +- **Deterministic wins shrink variance** — a real fix both raises the mean and + tightens sd, a useful signature to distinguish signal from noise. +- Next: fold cold-fact extraction into the ingest proper (write to Knowledge + Vault + retrieval key, per LongMemEval key-expansion), and resolve conflicting + literals by recency. + +### Repro +`evals/build_coldfacts.py ` → `coldfacts_.json`; run the answerer with +`MIRIX_COLDFACT=1`. Negatives are gated `MIRIX_ENABLE_CONSOLIDATE` / `MIRIX_PERSONA`. + +## 4c. Making the answerer actually USE the graph — still no gain + +A standing caveat on every "the graph doesn't move QA" result was that the answerer +never really consumed the graph: its output was injected as a prompt context blob +that the model could skip, while the answerer's own `search_memory` (flat pgvector) +was what it actually read. That excuse is now removed. + +Two changes: +1. `V7Retriever.retrieve_rows()` — the retrieval pipeline split so it can return + **structured rows** (anchors, episodic, semantic) instead of only a rendered blob; + `retrieve()` is now a thin formatting wrapper over it. +2. The eval answerer merges those rows into its **own `search_memory` results** — + the exact delivery that made cold-facts work (+4.4), deduped against flat hits. + +Wiring that up surfaced a silent bug worth recording: the answerer is a separate +process from the server and **never initialised the neo4j client**, so +`get_neo4j_driver()` returned `None` and the retriever returned empty on every call, +without an error or a log line. The graph had been contributing literally nothing +through this path. After initialising it, the graph does return real memories +(e.g. "vintage camera collection" → 6 memories flat search missed). + +**A/B on one restored store, one variable, 3 runs each:** + +| | runs | mean | sd | +|---|---|---:|---:| +| control (graph off) | 38 / 45 / 38 | 40.3 | 3.3 | +| graph merged into search results | 39 / 40 / 40 | **39.7** | 0.5 | + +**−0.7 — no gain.** Per question it is a wash: 2 won (Q33, Q39), 2 lost (Q3, Q26). +(The control's 45 is an outlier; that arm's sd is 3.3.) + +The mechanism behind the null result was measured directly: the graph's memories are +**largely already in the flat results**. On three probe queries the graph returned +15–16 memories and, after dedup, contributed 6 / 0 / 0 new ones. The graph is not +adding recall that flat vector search lacks. + +So the conclusion survives its strongest test: it is not that the graph was wired up +wrong — once genuinely wired, it still does not help on this benchmark. Kept behind +`MIRIX_GRAPH_SEARCH` (default off). diff --git a/docs/graph_memory_v7/locomo_dream_ablation.md b/docs/graph_memory_v7/locomo_dream_ablation.md new file mode 100644 index 000000000..bc275c613 --- /dev/null +++ b/docs/graph_memory_v7/locomo_dream_ablation.md @@ -0,0 +1,152 @@ +# LoCoMo conv-26 — graph / reranker / consolidation ablation, and making the dream lossless + +Six arms on LoCoMo conversation 26 (199 questions; the MAB-style judge scores +152 — category 5 adversarial is excluded), hypergraph `v7.10`, answerer +gpt-4.1-mini, embeddings text-embedding-3-small. **Every arm with its own store +was ingested fresh, end to end, with the graph built in-line by the real insert +hooks** — the offline rebuild shortcut is banned for evals (it is a different +construction process: v7.8 entity resolution is path-dependent, and the paper +describes the online system). Arms 4/5 reuse the store+graph of 2/3 and change +only retrieval; arm 6 is the final recipe. + +## 1. The table + +| # | config | retrieval | store (mem) | graph core nodes | anchors/facts/refs | QA (152 judged) | +|---|---|---|---|---|---|---| +| 1 | no graph | flat only | 227 | 0 (disabled) | — | .8816 (134) | +| 2 | graph | inject, no rerank | 228 | 2310 | ~780/~1400/228 | .8882 (135) | +| **4** | **graph + reranker** | inject + rerank | 228 (=2) | 2310 (=2) | as above | **.9079 (138)** | +| 3 | graph + dream (ungated) | inject, no rerank | 205 (−10%) | 2136 (−7.5%) | 676/1255/205 | .8618 (131) | +| 5 | graph + dream (ungated) | inject + rerank | 205 (=3) | 2136 (=3) | as above | .8750 (133) | +| **6** | **graph + dream (union gate)** | inject + rerank | **214 (−6%)** | **2269 (−1.8%)** | 717/1338/214 | **134 / 137 / 135 (x̄ 135.3)** | + +Arm 2's graph composition is a proportional estimate (only the wipe total was +recorded before arm 3 rebuilt the per-user subgraph); the total and every arm +3/6 number are measured. Arm 6 graph invariants at rest: 0 zombie facts, full +98/98 temporal chain. "dream" = interleaved auto_dream, one cycle after every +10th ingested chunk plus one after the final chunk +(`MIRIX_DREAM_EVERY_N_CHUNKS=10` in `evals/main_eval.py`). + +Per-category, on the rerank arms (directly comparable): + +| | single_hop (32) | temporal (37) | multi_hop (13) | open_domain (70) | +|---|---|---|---|---| +| 4 no-dream | .906 | .919 | .923 | .900 | +| 5 dream ungated | .781 | **.838** | .923 | .929 | +| 6 dream gated | .875 | **.919** | .923 | .857 | + +## 2. Findings + +### The reranker is real: +2~3, paired + +Same store, same graph, only the ordering toggled +(`MIRIX_GRAPH_RERANK`): 135→138 on the clean store, 131→133 on the dreamed +store. Second benchmark to confirm the v7.1 rerank (SH-Doc was +4). It had been +dead code for every version after v7.1 — a stale `== "v7.1"` guard — until the +guard fix; the same stale-tuple class also had the retriever dispatcher sending +v7.4–v7.10 to the dead v5 pipeline (an LLM call for empty context on every +`wrap_user_prompt`). + +### The graph's contribution is multi-hop — and insurance + +multi_hop is .846 with no graph and .923 in **all four** graph arms. Overall +the graph alone is +1 (noise) when the flat store is intact. But on a +consolidated store the injection carried ~24/152: an arm that accidentally ran +with an empty graph injection against a dreamed store scored 0.7039 vs 0.8618 +for the same condition with its graph — the graph's facts (extracted before the +merge) supply what the merged flat rows lost. Flat-intact: redundant. +Flat-consolidated: load-bearing. + +### Why consolidation loses facts: it is a rewrite + hard delete, not a merge + +The dream agent's "merge" is: LLM rewrites N rows into M --limit 1 --run-llm \ + --mirix_config_path ./configs/0201c_v6.yaml --output_path +python organize_results.py results/locomo/ + +# arm toggles (server env unless noted): +# arm 1: MIRIX_ENABLE_GRAPH_MEMORY=false +# arm 2: graph on, MIRIX_GRAPH_RERANK=0 +# arm 4: rerank default on; QA-only rerun (prefill responses, empty records) +# arm 3: MIRIX_DREAM_EVERY_N_CHUNKS=10 (eval env), MIRIX_GRAPH_RERANK=0 +# arm 6: MIRIX_DREAM_EVERY_N_CHUNKS=10, gate on by default +# (MIRIX_MERGE_COVERAGE_GATE=0 to disable) +``` diff --git a/docs/graph_memory_v7/version_ledger.md b/docs/graph_memory_v7/version_ledger.md new file mode 100644 index 000000000..2b04e6249 --- /dev/null +++ b/docs/graph_memory_v7/version_ledger.md @@ -0,0 +1,138 @@ +# v7 Graph Memory — Version Ledger + +Each **successful** change to the graph pipeline mints one version (v7.4, v7.5, …). +A change is "successful" only if it beats the baseline on its *target* metric +**without regressing QA beyond the noise floor**. Failed experiments are recorded +here with a `rejected` verdict but do **not** consume a version number (precedent: +the v8.1 User/Assistant role-strip was net-negative and never became a code +version). + +## How a version is minted (checklist) + +1. Implement behind the existing switch — add the version string to the + `graph_version` guard tuple in `graph_memory_manager_v7.py::process_memory` + and `graph_retriever_v7.py` (currently `("v7","v7.1","v7.2","v7.3","v8")`). +2. Ingest the **same first 10 LongMemEval-S chunks** into an isolated DB + (`mirix_lm10_`), graph wiped first. +3. **Tier-1 (cheap, on 10-chunk)** — record vs baseline: extraction time / LLM + calls, anchor count, singleton %, hub composition, new-edge counts. Plus the + **G1/G2 probe** score where relevant. +4. **Tier-1 gate**: effect must exceed the **noise floor** (see below) and show + no structural regression. +5. **Tier-2 (expensive, only if gated)** — full 114-chunk LongMem-S (60 QA) + + MH-Doc + SH-Doc as applicable. QA judged by the MAB judge. +6. If it wins: `git commit` (`feat: v7.x `), save a DB+graph snapshot + under `~/MIRIX_eval/saved/lm10_/`, add a ledger row. + If it loses: add a ledger row with `rejected` + the reason; revert the guard. + +## Noise floor + +Extraction is LLM-based and non-deterministic (the meta-agent's per-chunk routing +and each sub-agent's yield both vary). Measured over **5 fresh post-merge 10-chunk +builds** (2026-07-18, code pre-squash tag): a delta smaller than this spread is **not** a +real effect. + +| run (10-chunk, post-merge v7) | ep+sem memories | +|---|---:| +| nf1 / nf2 / nf3 / nf4 / nf5 | 93 / 79 / 118 / 106 / 78 | +| **mean ± sd** | **95 ± 15.5** | +| **spread (min–max)** | **78–118 = 42% of mean** | + +Reference: baseline #1 (pre-merge) = 116; baseline #2 (post-merge) = 58 (a low +outlier). The 5 post-merge runs (78–118) bracket the pre-merge 116 → the merge had +**zero** effect on extraction; the 116-vs-58 gap was pure noise. + +**Consequence for the eval plan**: run-to-run memory volume swings ±42%, so a +single 10-chunk build **cannot** detect a change smaller than that. Anchor count +scales ~linearly with memories, so anchor-count deltas inherit the same ~42% floor. +→ 10-chunk intrinsic counts are only usable for **large** effects (v7.4's User/ +Assistant hub removal, extraction-time win); subtle changes (disambiguation) must +be measured on the deterministic **G1/G2 probe**, not 10-chunk anchor counts. +(Anchor/singleton for the 5 runs was lost to a `measure` bug — missing `AS` in the +Cypher — so only PG-side ep+sem survived; fixed in `overnight_c.sh`.) + +### Full 114-chunk post-merge v7 baseline (2026-07-18, `mirix_lm114_pm`) + +Real QA reference for v7.4+. 962 memories (632 ep + 330 sem), 5165 anchors, 66% +singleton. **QA = 30/60 (50%)**, judged by MAB judge. + +| category | acc | | category | acc | +|---|---:|---|---|---:| +| knowledge-update | 8/9 (89%) | | temporal-reasoning | 7/15 (47%) | +| single-session-assistant | 5/6 (83%) | | **multi-session** | **4/15 (27%)** | +| single-session-user | 5/9 (56%) | | single-session-preference | 1/6 (17%) | + +**multi-session (multi-hop) = 27% is the weakest** — exactly the target of v7.5 (PPR) +and v7.7 (disambiguation). (Pre-merge full run was 35/60; this 30/60 is a low +extraction draw within the ±42% noise, not a regression.) + +## Version map + +> **On the `commit` column.** The branch history was consolidated into six thematic +> commits, so a version no longer owns a commit one-to-one: v7/v7.1/v7.2/v7.3 all sit +> in `pre-squash tag` and v7.4/v7.6/v7.7/v7.8 all sit in `pre-squash tag`. The original +> one-commit-per-version history is preserved at the tag `graph_revision_pre_squash` +> — check it out when you need a single version's exact code state. Push that tag +> alongside the branch, or those states become unreachable on the remote. + +| ver | change | targets gap | status | anchors | singleton% | QA (full / MH / SH) | commit | +|---|---|---|---|---:|---:|---|---| +| v7 | LightRAG extractor + `anchor_canonical_key` (surface dedup) | baseline | **current** | 5165 (full) | 66% | **30/60 (post-merge full)** | pre-squash tag | +| v7.1 | rerank candidates by query text-cosine | retrieval | shipped | — | — | SH-Doc +4 | pre-squash tag | +| v7.2 | per-anchor coverage round-robin | retrieval | shipped (neutral) | — | — | +0 | pre-squash tag | +| v7.3 | proposition ingest, no LightRAG | G6 / extraction | **rejected** — collapsed edge structure (only DESCRIBED_BY survived; no episodic → lost APPEARS_IN / SUPPORTED_BY / NEXT_MEMORY); singleton ↑77% | 439 | 77% | not run (structure not comparable) | pre-squash tag | +| **v7.4** | **GLiNER extractor** (local encoder) — dialogue roles (User/Assistant) filtered out | **G6** cost + **G4** noise hubs | **shipped-but-superseded** — ~200× faster, hubs gone, deterministic; **BUT GLiNER can't abstract → drops LightRAG's concept anchors (networking/team-collaboration=0), degrading concept-heavy retrieval (masked by net-neutral QA). See `archive/extractor_direction_D.md`.** Kept as a cheap named-entity supplement. | 2473 (−52%) | 66% | **32/60 (vs v7 30/60; concept-loss caveat)** | pre-squash tag | +| **v7.6** | **LLM triple extraction (direction D)** — abstracts concepts (GLiNER can't) + keeps relations as `V7_RELATION` anchor→anchor edges; User/Assistant filtered | concept coverage + **relations (for PPR)** | **foundation shipped** — concept anchors back (2374, 41% vs v7.4's 294/12%), **3667 relation edges** (v7 had none), hubs gone. Only **2.6× faster than LightRAG** per-memory (5.9s vs 15.25s — measured; NOT the 34× I first claimed, which mixed concurrent-D vs sequential-LightRAG). **The real value is the RELATIONS, not speed.** QA 31/60 = flat, BY DESIGN (retrieval didn't traverse V7_RELATION until v7.7). | 5777 | 81% | 31/60 | pre-squash tag | +| **v7.7** | **PPR retrieval** over the v7.6 relation graph (query→anchor seed + Personalized PageRank, networkx — no GDS) | multi-session (27%) | **implemented; retrieval much better, QA flat (30/60)** — the colleague query reversed from off-topic fitness to on-topic social; context went from ~500-char titles to 37k-char rich. But QA = v7 30 / v7.4 32 / v7.6 31 / v7.7 30, **all flat.** | — | — | 30/60 | pending | + +| **v7.8** | **registry-guided entity resolution** (canonicalization at extract) — embedding retrieves candidate existing anchors, an **LLM decides same-or-new** and renames to the canonical (relation endpoints too). Closes a gap present since v7, amplified by v7.6. | graph quality / redundancy (the long-standing canonicalization gap; enables G1/G2) | **shipped (graph quality)** — anchors **5777→4744 (−18%)**, singleton **81%→66%** (back to v7's level), V7_RELATION 3667→4317 (endpoints consolidate). No over-merge (42 vs 46 campsites kept apart; max hub deg 14). **18% vs the 2% a pure embedding-cosine merge could safely reach** — the LLM makes the identity decision cosine can't. QA not yet measured (user deferred). | 4744 | 66% | (deferred) | pending | + +### ⚠️ Pivotal finding: graph-context quality is NOT the QA bottleneck + +Four retrieval variants (v7 anchor-search, v7.4 GLiNER, v7.6 D, v7.7 PPR) span a +huge range of graph-context quality yet **all land 30–32/60**. Root cause, verified +from the QA transcripts: **the answerer calls its own `search_memory` (flat pgvector) +tool on 60/60 questions.** The graph context we inject via `wrap_user_prompt` is only +*one of two* retrieval sources — the answerer's parallel flat search does the heavy +lifting, so improving the graph context barely moves QA. + +Implication — the whole v7.4→v7.7 line optimized a **secondary** path. To make the +graph move QA it must either (a) provide what flat search cannot (true multi-hop +bridges) AND be surfaced so the answerer uses it, or (b) *replace* the answerer's flat +search (the earlier "graph-routed search" did this and was −18pp — flat search is a +strong baseline). Next investigation should target the answerer's retrieval path, not +graph-context quality. The v7.6 relation graph + v7.7 PPR remain a sound foundation +*if* retrieval is rerouted; as a supplement to flat search they are ~neutral. +**Remaining planned versions (priorities under review after the pivotal finding — the +answerer's own flat search, not graph-context quality, is the QA lever):** + +| ver | change | targets gap | status | +|---|---|---|---| +| **v7.8** | **reroute the answerer's `search_memory` through the graph** (or supply multi-hop bridges flat search misses) — the ACTUAL QA lever per the pivotal finding | the flat-search bottleneck | **planned — new top priority** | +| v7.5 | role/domain scoping — User/Assistant as a `role` scope attribute (from `episodic.actor`), not an entity anchor | single-session-user/assistant/preference | planned (orthogonal, still valid) | +| v7.9 | synonym edges (`name_embedding` cos ≥ τ, edge not merge) | G2 alias, G4 | planned | +| v7.10 | accumulated registry + embedding candidates + **LLM verify-merge gate** | **G1 + G2 + G3** | planned — paper core (only mechanism that can *split* → G1) | +| v7.11 | schema induction (AutoSchemaKG-style) | G5 | deferred | + +Ordering notes: +- **v7.5 role/domain** reframes the User/Assistant problem: v7 makes them mega-hub + anchors (deg 452/233, a category error — a dialogue role is not an entity); v8.1 + *deleted* them and was net-negative (lost aggregation value). v7.5 keeps the + signal as a scope attribute instead. Targets the benchmark's user/assistant/ + preference categories directly. +- **v7.6 (retrieval/PPR) ships before v7.7/v7.8** because synonym/resolution edges + are inert until retrieval traverses the graph (PPR). Until then, evaluate + v7.7/v7.8 on the G1/G2 probe (intrinsic), not on QA. + +## G1/G2 probe set + +A small labelled set that measures disambiguation *independently of retrieval* +(end-to-end QA cannot isolate it). Seeded from the SH-Doc same-name breaks. + +- **G1 (same surface name → different real entity)**: `(name, context, gold entity)`; + metric = were they wrongly merged into one anchor? e.g. `Margaret` (Thatcher vs + Margaret of Scotland), `naval base` (Kings Bay vs Dyrrachium). +- **G2 (one entity → different names)**: `(name_a, name_b, same?)`; metric = were + they linked? e.g. `Thatcher` ↔ `Margaret Thatcher`. + +Stored at `~/MIRIX_eval/probes/g1g2_probe.jsonl` (see `build_probe.py`). diff --git a/docs/mab_conflict_resolution_and_provenance.md b/docs/mab_conflict_resolution_and_provenance.md new file mode 100644 index 000000000..3b89f9e85 --- /dev/null +++ b/docs/mab_conflict_resolution_and_provenance.md @@ -0,0 +1,451 @@ +# Patch note: conflict resolution + source provenance for semantic and episodic memory + +**Status.** Opt-in at meta-agent **create time** for the conflict +resolution policy section; source provenance is **always-on** server-side +(no opt-in needed). Default behaviour is preserved: existing user flows +keep their semantics, existing items get `source_refs=[]` / +`prior_values=[]` after migration and stay opaque to the new paths. + +**Scope.** + +- Three new persisted columns (`semantic_memory.source_refs`, + `semantic_memory.prior_values`, `episodic_memory.source_refs`). +- Two new per-user counter columns (`users.turn_counter`, + `users.chunk_counter`). +- A new manager method `SemanticMemoryManager.upsert_with_conflict_resolution`, + an auto-route in the existing `insert_semantic_item`, and an + `additional_source_ref` parameter on `EpisodicMemoryManager.update_event`. +- A server-side helper `_augment_source_meta_with_server_fallbacks` + invoked from both `/memory/add` entry points. +- A `UserManager.reserve_source_ids` helper that atomically bumps the + per-user counters. +- One ~1 KB prompt section appended to the semantic agent's stored + system prompt when `enable_conflict_resolution=True` is passed to + `create_meta_agent`. + +No new tool, no new validator, no `semantic_memory_*` tool list change, +no `update_meta_agent` rewiring. + +## The problem + +MIRIX's `semantic_memory_agent` resolves conflicts using LLM free-text +merge with no notion of recency, version, or provenance: + +- Multiple conflicting facts about an entity collapse into one + `summary` / `details` string. FactConsolidation entries like + `0. Thomas Kyd was born in London` and + `306. Thomas Kyd was born in Leeds` become + `"Thomas Kyd was born in London, though some data says Leeds"`. +- The merging LLM uses its world-knowledge prior as a tie-breaker, often + marking the dataset's authoritative value as + `"conflicting"` / `"incorrectly attributed"` / `"erroneously"`. +- After delete-then-insert, the old value is gone — no audit trail. + +Three concrete cases caught from `prompt_debug` in a prior run: + + - `Thomas Kyd born in` → MIRIX summary said `London`, suppressed `Leeds`. + - `Japan official language` → kept `Japanese`, dropped `Swedish`. + - `Microsoft CEO` → kept `Satya Nadella`, dropped `Steve Jobs`. + +Correct behaviour for a personal assistant. Wrong for any system that +needs to honour the user's most recent statement when it contradicts +world knowledge, or to recall *when* a fact was first / last asserted. + +## The change + +Two independent but cooperating mechanisms: + +### A. Source provenance (always-on, general) + +Every `/memory/add` request — whether from the MAB adapter, a personal +assistant SDK, or any other caller — ends up with a `filter_tags["source_meta"]` +dict carrying at least `turn_id`, `chunk_id`, and `occurred_at`. The +fields the caller already supplied win; the server fills in the rest. + +The pipeline: + +1. **Client may pre-fill** any subset of + `filter_tags["source_meta"] = {turn_id, chunk_id, serial, occurred_at}`. + - The MAB adapter populates `chunk_id`, `serial_first`, `serial_last` + because it knows the chunk's internal structure. + - A personal-assistant SDK can leave it empty. +2. **Server `/memory/add` merges with fallbacks** via + `_augment_source_meta_with_server_fallbacks`: + - `turn_id` missing → call `UserManager.reserve_source_ids(n_turns)` + and use `turn_id_start`. + - `chunk_id` missing → use the reservation's `chunk_id`. + - `occurred_at` missing → use the request's top-level `occurred_at` + if present, else wall-clock UTC ISO 8601. + - `serial` is never auto-filled; it stays present iff the caller put + it there (FactConsolidation-style numbered input). +3. **Counters are persisted** in two new `users` columns + (`turn_counter`, `chunk_counter`), bumped atomically by + `reserve_source_ids`. +4. **`SemanticMemoryManager.insert_semantic_item` copies `source_meta` + into `source_refs`** when the auto-route fires (see B below). +5. **`EpisodicMemoryManager.insert_event` copies `source_meta` into + `source_refs`** unconditionally — every event gets a provenance trail, + not just CR-eligible ones. +6. **`EpisodicMemoryManager.update_event` accepts + `additional_source_ref`** so `episodic_memory_merge` can append the + current ingest's pointer onto an already-existing event's + `source_refs`. Multi-batch events therefore preserve every chunk + that contributed. + +### B. Conflict resolution (opt-in at create time) + +A second path through the existing `semantic_memory_insert` call, +selected once at meta-agent create: + +1. **Schema.** `semantic_memory.source_refs JSON NOT NULL DEFAULT '[]'` + and `semantic_memory.prior_values JSON NOT NULL DEFAULT '[]'`. + Legacy rows default to empty and stay opaque. +2. **Manager.** `SemanticMemoryManager.upsert_with_conflict_resolution( + entity, relation, value, source_ref, ...)` does deterministic merge: + priority `occurred_at > serial > created_at`, newer wins as + `summary`, older goes into `prior_values` with status + `superseded` (or `corrected` when the caller asks). +3. **Auto-route.** `SemanticMemoryManager.insert_semantic_item` checks + the incoming `filter_tags["source_meta"]` dict. If it is present AND + `name` is shaped like `" / "`, the call is + forwarded to `upsert_with_conflict_resolution`. Otherwise the + legacy free-form path runs unchanged. +4. **Prompt.** When `enable_conflict_resolution=True` is passed to + `create_meta_agent`, a ~1 KB policy section is appended to the + semantic agent's stored system prompt. The section tells the agent + to write facts as `name=" / ", summary=`, + verbatim, no hedging. + +The conflict-resolution path is selected **once**, at meta-agent +create. When off, the policy section is not in the prompt and the agent +never writes the triple-shape names, so the auto-route in +`insert_semantic_item` never fires. + +## Files touched + +| File | Change | +| --- | --- | +| `mirix/orm/user.py` | + `turn_counter INT NOT NULL DEFAULT 0`, + `chunk_counter INT NOT NULL DEFAULT 0` | +| `mirix/orm/semantic_memory.py` | + `source_refs JSON NOT NULL DEFAULT '[]'`, + `prior_values JSON NOT NULL DEFAULT '[]'` | +| `mirix/orm/episodic_memory.py` | + `source_refs JSON NOT NULL DEFAULT '[]'` | +| `mirix/schemas/user.py` | Surface `turn_counter` + `chunk_counter` on `User` | +| `mirix/schemas/semantic_memory.py` | Surface both new fields on `SemanticMemoryItem` + `SemanticMemoryItemUpdate` | +| `mirix/schemas/episodic_memory.py` | Surface `source_refs` on `EpisodicEvent` + `EpisodicEventUpdate` | +| `mirix/services/user_manager.py` | + `reserve_source_ids(user_id, n_turns)` — atomic counter bump used by the `/memory/add` fallback. | +| `mirix/services/semantic_memory_manager.py` | + `upsert_with_conflict_resolution(...)`, + `_find_by_entity_relation`, + `_build_cr_filter_tags`, + `_source_ref_key`; auto-route inside `insert_semantic_item`. | +| `mirix/services/episodic_memory_manager.py` | `insert_event` copies `filter_tags["source_meta"]` into the new `source_refs` column; `update_event` accepts `additional_source_ref` so merge appends the current ingest's pointer. | +| `mirix/agent/meta_agent.py` | + module-level `_CONFLICT_RESOLUTION_POLICY_PROMPT` (~1 KB). No flag plumbing through the class. | +| `mirix/schemas/agent.py` | + `enable_conflict_resolution: bool = False` on `CreateMetaAgent` only | +| `mirix/services/agent_manager.py` | `create_meta_agent`: when the flag is set, append the policy section to the semantic agent's stored system prompt at creation time | +| `mirix/server/rest_api.py` | + `_augment_source_meta_with_server_fallbacks(...)` helper, called from both `/memory/add` and `/memory/add_sync`. Pass `enable_conflict_resolution` from `meta_agent_config` into `CreateMetaAgent`. | +| `mirix/functions/function_sets/memory_tools.py` | `episodic_memory_merge` reads `self.filter_tags["source_meta"]` and forwards it to `update_event` as `additional_source_ref`. | +| `scripts/migrate_add_provenance_columns.py` | One-shot, idempotent ALTER TABLE for the five new columns. Safe to re-run. | +| `samples/memoryagentbench/mirix_adapter.py` | + `mirix_enable_conflict_resolution` YAML key; ingest sends `filter_tags={"source_meta": {chunk_id, serial_first, serial_last}}` and ISO-8601 `occurred_at`. `update_agents=False` — flag is set at create only. | +| `samples/memoryagentbench/run_bench.py` | + `--enable-conflict-resolution` / `--no-enable-conflict-resolution` CLI flag | +| `samples/memoryagentbench/run_ablation.py` | Forward `--enable-conflict-resolution` to every spawned `run_bench` | + +Nothing was changed in `mirix/agent/tool_validators.py` or +`mirix/constants.py`'s `SEMANTIC_MEMORY_TOOLS`. + +## Data model + +`SemanticMemoryItem.source_refs : list[dict]` — provenance pointers for +the current value. Each entry is a small dict; any subset of +`{turn_id, chunk_id, serial, occurred_at}` may be present. + +`SemanticMemoryItem.prior_values : list[dict]` — values that used to be +canonical. Shape: + +```python +[ + { + "value": str, # the prior canonical value + "source_refs": list[dict], # provenance for that prior value + "status": "superseded" # OR "corrected" + | "corrected", + "moved_at": "2026-05-15T01:00:00", # when the demotion happened + "note": Optional[str], # e.g. "late-arrived older fact" + }, +] +``` + +`EpisodicEvent.source_refs : list[dict]` — same shape as on semantic. + +All three columns are non-null with `default=list` / `DEFAULT '[]'`. +Legacy items written before this change get `[]` after the migration. + +## Deterministic ordering + +`SemanticMemoryManager._source_ref_key(source_ref) -> tuple` produces a +lexicographic sort key: + +```python +return ( + 1 if occurred_at else 0, occurred_at, + 1 if serial is not None else 0, serial if serial is not None else -1, + 1 if created_at else 0, created_at, +) +``` + +Priority: `occurred_at > serial > created_at`. The MAB adapter sets all +three where available (`occurred_at = now_iso8601()`, `serial = +serial_last` extracted from the chunk text, `created_at` fills in at +DB write). + +## Auto-route inside `insert_semantic_item` + +The behaviour change is fully contained in one method: + +```python +async def insert_semantic_item(self, ..., name, summary, filter_tags=None, ...): + source_meta = (filter_tags or {}).get("source_meta") + if source_meta and isinstance(name, str) and " / " in name: + entity, _, relation = name.partition(" / ") + if entity.strip() and relation.strip(): + return await self.upsert_with_conflict_resolution( + entity=entity.strip(), + relation=relation.strip(), + value=summary, + source_ref=dict(source_meta), + extra_filter_tags={k: v for k, v in (filter_tags or {}).items() + if k != "source_meta"}, + ... + ) + # legacy free-form path unchanged + ... +``` + +Two conditions both need to hold to enter the conflict-resolution path: + +1. The caller passed `filter_tags["source_meta"]` (the MAB adapter, the + only caller that knows what source the input came from, does this + when its `mirix_enable_conflict_resolution` flag is on). +2. The agent put `" / "` in `name` (the policy section in the system + prompt teaches it to do this for triple-shaped facts). + +If either condition is missing, the legacy `insert_semantic_item` +path runs as before. This means: + +- Concept items the agent writes without the triple shape + (`name="Crystal chandelier care"`) → legacy path, unchanged. +- Triple-shaped items written without source provenance (no adapter, no + flag) → legacy path, unchanged. +- Both present → conflict-resolution path. + +## Agent prompt section + +When `enable_conflict_resolution=True` is passed to `create_meta_agent`, +`agent_manager.create_meta_agent` appends +`_CONFLICT_RESOLUTION_POLICY_PROMPT` to the semantic agent's stored +system prompt at creation time. The section, in full: + +``` +## Conflict resolution policy + +When ingesting a fact that asserts a value for some (entity, relation) +already covered by an existing semantic item: + +- DO NOT merge the new value into a hedging free-text summary + ("X, though some data says Y", "incorrectly attributed", + "according to some sources"). +- DO NOT use your own world knowledge to choose which value is "correct". +- The user's most recent assertion is authoritative. + +When you call semantic_memory_insert for a fact of this shape, write: + + - name: " / " (e.g. "Thomas Kyd / born in") + - summary: the raw value, verbatim (e.g. "Leeds"). No paraphrasing. + - details: short context only. + +The manager will then preserve any prior canonical value with a +"superseded" marker in prior_values based on source ordering — you +do not have to hedge in summary to keep the old value safe. + +For free-form concepts that do not fit a triple shape (multi-paragraph +how-tos, abstract topics), keep calling semantic_memory_insert +normally; the manager will route those down the legacy free-form path +unchanged. +``` + +When `enable_conflict_resolution=False`, this section is never emitted; +the semantic agent sees the unaltered base prompt and behaves exactly +as before. No tool changes, so the agent's tool list is identical in +both modes. + +## How to enable + +### Python client / SDK + +```python +client = await MirixClient.create(...) +await client.initialize_meta_agent( + config={ + "llm_config": {...}, + "embedding_config": {...}, + "meta_agent_config": { + "agents": [...], + "enable_conflict_resolution": True, # <-- create-time only + }, + }, +) +``` + +If a meta-agent already exists for this client, you must delete and +re-create to switch the flag. + +### MAB adapter (YAML) + +```yaml +mirix_enable_conflict_resolution: true +``` + +### MAB adapter (CLI override) + +``` +python samples/memoryagentbench/run_bench.py ... --enable-conflict-resolution +python samples/memoryagentbench/run_ablation.py ... --enable-conflict-resolution +``` + +### Migration + +```bash +python scripts/migrate_add_provenance_columns.py +``` + +Run once per Postgres deployment. Idempotent. Existing rows get +`source_refs=[]`, `prior_values=[]`. No backfill. + +## Validation + +Three things were verified end-to-end against the running server: + +### 1. Server-side source_meta fallback (always-on path) + +Two consecutive `/memory/add` calls for a fresh user with no +`filter_tags` on the request side at all. Result: + +``` +ep_TY7K "User went hiking at Mt Rainier ..." + source_refs = [{"chunk_id": 0, "turn_id": 0, "occurred_at": "...09:24:49..."}] + +ep_OCEO "User went to a yoga class downtown ..." + source_refs = [{"chunk_id": 1, "turn_id": 1, "occurred_at": "...09:24:49..."}] +``` + +`user.turn_counter` and `user.chunk_counter` both advanced 0→1→2. No +client-side cooperation needed. + +### 2. Client-provided source_meta (MAB path) + +Sent `filter_tags={"source_meta": {"serial_last": 500, "chunk_id": 99}}`. +After the call: + +- The event's `source_refs` carried `serial_last=500` (preserved) and + `chunk_id=99` (preserved). +- `turn_id` and `occurred_at` were filled by the server fallback. + +### 3. Conflict resolution policy (opt-in path) + +With `enable_conflict_resolution=True` passed to +`initialize_meta_agent`, the semantic agent's stored system prompt +grew from 5 554 chars to 6 674 chars (= base + 1 118-char policy +section + 2 separator). On a FactConsolidation `sh_6k` smoke run, the +agent wrote items with names like `"Thomas Kyd / born in"` and the +manager's auto-route fired (`cr_entity` / `cr_relation` populated in +`filter_tags`). For that entity, the canonical value was `"Leeds"` +(the higher-serial value), not the world-knowledge `"London"`. + +End-to-end EM numbers depend additionally on how many facts the agent +writes within its ingest token budget (separate concern from conflict +resolution itself) and remain a tuning question for individual +benchmarks. + +## What this does *not* fix + +- Multi-hop reasoning. Conflict resolution is per `(entity, relation)` + pair — chained `(entity_A, rel_1, ?)` → `(?, rel_2, ?)` lookups are + out of scope. +- Verbatim quote recall (LongMemEval `single-session-assistant`). The + agent still paraphrases the assistant's prior wording when storing + semantic items; only the *value* slot of triple-shaped facts gets the + no-hedging guarantee. +- Per-request toggling. The flag is read once at create time and baked + into the agent's stored prompt; changing it requires re-creating the + meta-agent. +- The recommended ingest density. Triggering the conflict-resolution + branch still depends on the agent writing the right set of facts; + the policy nudges the *shape* and the *value choice*, not the + *coverage* of what gets written. + +## Known limitations + +### Chunk-level source_ref is not fact-level + +The MAB adapter's `source_meta` carries +``{chunk_id, serial_first, serial_last, occurred_at}`` for the whole +chunk. Inside one `client.add` call, every individual fact the +semantic agent extracts ends up with the **same** `source_ref` — there +is no per-fact serial yet. + +Consequence: when the agent writes two competing items with the same +`name` (e.g. `"Thomas Kyd / born in" → "London"` from fact #0 and +`"Thomas Kyd / born in" → "Leeds"` from fact #306) in the **same** +ingest, the deterministic merge in +`upsert_with_conflict_resolution` cannot distinguish them on +`source_ref` alone: + +- `occurred_at` is identical (same wall-clock instant). +- `serial_last` is identical (always `306` for the whole chunk). +- Only `created_at` differs → tie broken by **insert order**. + +If the agent writes the higher-serial fact last, the right value wins. +If it writes them in any other order, the wrong value wins. Either +way, the outcome is not really deterministic on the input contents — +it is determined by the agent's traversal order. For real conversational +inputs (each new fact is a separate `client.add` call with its own +`occurred_at`), the gap does not exist: timestamps separate the +ingests cleanly. + +Fix paths considered (not in this patch): + +1. Agent extracts the per-fact serial from the chunk text and puts it + on each item it writes (prompt-engineering only, but + `semantic_memory_insert`'s `items[]` schema currently has no + per-item `source_ref` field). +2. Add an optional per-item `source_ref` to `SemanticMemoryItemBase` + so the agent can override the chunk-level one. +3. Server-side: `insert_semantic_item` regex-scans the original chunk + text to recover the serial. Requires also persisting the raw chunk + on the agent context, which we are otherwise trying to avoid. + +### Agent does not always write both sides of a conflict + +The new policy section tells the agent to write facts verbatim with no +hedging. Empirically the agent will sometimes write only the value it +considers most plausible (often the one matching world knowledge), +skipping the other side of the conflict entirely. When that happens +the deterministic merge has nothing to merge — `prior_values` stays +`[]` and the result reflects the agent's pick, not the data. + +This is independent from the chunk-vs-fact source_ref limitation above. +It is a prompt-following gap; mitigations are prompt-engineering or +a finer-grained tool surface, neither of which is in scope here. + +### `serial` is never auto-derived by the server + +Only the caller can populate `source_meta["serial"]` (or +`serial_first` / `serial_last`). The server fallback in +`_augment_source_meta_with_server_fallbacks` only fills `turn_id`, +`chunk_id`, and `occurred_at`. This is deliberate — `serial` is a +domain-specific signal — but it does mean that benchmarks with +implicit numbered facts (FactConsolidation) require client-side +support to surface that signal. + +### `prior_values` is not yet surfaced on retrieval + +Items written via the conflict-resolution path persist their history +in `prior_values`, but `retrieve_with_conversation` currently only +returns the current `summary`. Time-travel queries +("Where did I used to live?") would need a separate retrieval path +that exposes `prior_values` and lets the LLM see the timeline. Not +in this patch. diff --git a/docs/mab_raw_chunk_side_channel.md b/docs/mab_raw_chunk_side_channel.md new file mode 100644 index 000000000..de8f7120e --- /dev/null +++ b/docs/mab_raw_chunk_side_channel.md @@ -0,0 +1,141 @@ +# Patch note: raw-chunk side channel for the MemoryAgentBench adapter + +**Scope.** `samples/memoryagentbench/mirix_adapter.py` only. No changes to +MIRIX core, MAB, or any prompt template. + +**Status.** Opt-in. Default behaviour (`mirix_preserve_raw_chunks` unset) +keeps the adapter on the pure MIRIX retrieval path. + +## Background + +MIRIX's `add` endpoint pushes every ingested message through the meta agent +and its six sub-agents, which **abstract** the input into structured memory +items: `{name, summary, details, tree_path}` for semantic, event summaries +for episodic, and so on. The original chunk text is not retained verbatim +anywhere in the database, and `retrieve_with_conversation` returns these +abstracted items, never the source string. + +That is the right behaviour for a personal assistant: a few months in, +nobody wants to grep raw screen captures for "what was my Wi-Fi password" — +they want a deduplicated, summarised memory. + +It is the wrong shape for some MemoryAgentBench sub-datasets. The most +extreme case is **Conflict_Resolution / FactConsolidation**: the adapter +ingests a numbered list of contradicting facts, + +``` +0. Thomas Kyd was born in the city of London. +... +306. Thomas Kyd was born in the city of Leeds. +``` + +and the gold answer is the entry with the **largest serial number** +(`Leeds`, even though the world-knowledge answer is `London`). MIRIX's +`semantic_memory_agent` collapses both entries into one summary item and +will sometimes annotate it with its own world-knowledge belief +(`"Thomas Kyd was born in London; some sources incorrectly claim Leeds"`). +Both the serial number and the verbatim wording — the only signals the +benchmark scores — are gone by the time `retrieve_with_conversation` +serves a query. + +This affects any MAB sub-dataset whose gold answer depends on token-exact +content the summarising agents will discard: serial numbers, verbatim +excerpts, exact label-to-class mappings. + +## Change + +When `preserve_raw_chunks` is on, the adapter additionally keeps the +un-templated chunk in a per-`user_id` Python list at ingest time: + +```python +# inside _memorize, after the regular client.add(...) call +if self.preserve_raw_chunks: + self._raw_chunks[user_id].append(message) +``` + +At query time it BM25-ranks those raw chunks against the question, picks +the top-k (default 5), and prepends them to the retrieved-memory block +that goes into the prompt: + +``` +--- Raw ingested chunks (verbatim, ordered by BM25 relevance) --- + + + + +--- MIRIX memory retrieval --- + +``` + +The MAB query template — the prompt with the rules and the `{question}` +slot — is **unchanged**. `get_template(...)` still resolves to MAB's +`templates.py` verbatim. Only the contents that fill the +"retrieved memory" slot in the prompt are augmented. + +## Configuration + +`mirix_preserve_raw_chunks` in the agent YAML, or `--preserve-raw-chunks` +on `run_bench.py` / `run_ablation.py`: + +| value | effect | +| -------------- | ------------------------------------------------------------------------------------------------------- | +| unset / `null` | **off** (default). No local raw cache, no BM25, no extra tokens. Pure MIRIX semantics. | +| `false` | Same as off, but explicit. | +| `true` | Force on for every sub-dataset. | +| `"auto"` | Adapter decides per sub-dataset using `mirix_adapter._RAW_CHUNK_RECOMMENDED_SUBDATASETS`. | + +The recommended list currently turns the side channel on for sub-datasets +matching `factconsolidation*`, `ruler_qa*`, `eventqa*`, `icl_*`, +`recsys_*`. Adding a new benchmark to that list is the only change needed +to opt it in to `auto`. CLI override beats YAML; YAML beats `auto`. + +`mirix_raw_chunk_topk` (default 5) controls how many raw chunks BM25 +returns per query. + +## Why this is a fair comparison + +MAB's other agentic-memory backends already do the equivalent verbatim +storage: + +- **letta** in `insert` mode (the configuration used for MAB's main + results) calls `passage_manager.insert_passage(text=formatted_message)` + directly. The full chunk goes into letta's archival memory verbatim; + letta's own memory-agent loop is bypassed. +- **mem0** writes the templated message into its vector store, which + tokenises the chunk verbatim before embedding. + +MIRIX exposes no such verbatim-passthrough lane in the public HTTP API: +the only writer endpoint is `add`, and `add` unconditionally routes +through the abstracting meta agent. The side channel is the smallest +external work-around that puts MIRIX on the same footing as those +backends for verbatim-critical benchmarks. With it disabled, MIRIX is +benchmarked on its own native retrieval semantics. + +## Cost + +Empirical numbers from FactConsolidation `sh_6k` (100 questions, +gpt-4o-mini, top-5 raw chunks): + +| mode | EM | F1 | input tokens / question | +| ----- | ---- | ----- | ----------------------- | +| off | 14% | 16.8% | ~4,700 | +| auto | 71% | 71.9% | ~17,100 | + +Per-question wall time rises by less than a second on this dataset. + +For larger contexts (`sh_64k`, `sh_262k`), the BM25 selection becomes the +operative knob — a context split into 64 chunks with `topk=5` still puts +~20k tokens into the prompt regardless of context size, but the +likelihood that the right chunks are in the top-5 falls. `topk` is the +lever there. + +## What this is not + +- It is not a change to MAB's prompt templates. `templates.py` is + imported and used unchanged. +- It is not a change to MIRIX core. Nothing under `mirix/` is touched. +- It is not a backdoor that lets MIRIX "cheat" — letta and mem0 already + store chunks verbatim in their stores, and the side channel is the + adapter's only way to put MIRIX on parity with that. +- It is not on by default. A benchmark run with no flags measures pure + MIRIX retrieval. diff --git a/docs/mab_user_id_isolation_fix.md b/docs/mab_user_id_isolation_fix.md new file mode 100644 index 000000000..b8ede48a0 --- /dev/null +++ b/docs/mab_user_id_isolation_fix.md @@ -0,0 +1,146 @@ +# Patch note: per-sub_dataset user_id isolation + memory purge for the MAB adapter + +**Scope.** `samples/memoryagentbench/mirix_adapter.py`, +`samples/memoryagentbench/run_bench.py`, +`samples/memoryagentbench/configs/mirix_gpt-4o-mini.yaml`. No changes to +MIRIX core or MAB. + +**Status.** Bug fix. Every MAB result produced before this patch is +contaminated (see "Impact" below) and must be re-run. + +## The bug + +The MAB adapter wrote every benchmark's memory into the **same MIRIX +`user_id`**. Three things combined to make this silently corrupt results: + +1. **A single shared user_id.** The adapter computed `user_id` from + `mirix_user_prefix`, which the config set to a constant (`mab`). So + every sub_dataset and every context resolved to `mab-ctx0`. + +2. **`add` is purely additive.** MIRIX's `/memory/add` never replaces; + it appends. Nothing ever cleared prior memory. + +3. **`--force` did not force a re-ingest.** `--force` deleted the result + JSON and skipped the "context already complete" check, but the + per-context agent-state sentinel folder was left on disk. The runner + then hit `if os.path.exists(save_folder): agent.load_agent()` and + reused the stale server-side memory instead of re-ingesting. + +The net effect: every MAB run accumulated on top of every previous run's +memory, under one user_id, with no way to reset. + +### How it surfaced + +A LongMemEval-S* run scored 10%. Inspecting a failing question +("How long have I had my cat, Luna?") showed `retrieve_with_conversation` +returning, as its top episodic item: + +> "User shared further extensive learned factual data, expanding the list +> to over 18331 items covering ... American Locomotive Company was +> created in the country of Soviet Union; Taoism was founded by Juliette +> Gordon Low; ..." + +That is FactConsolidation's `sh_262k` data. It had been ingested into +`mab-ctx0` by an earlier run, was newer than the LongMemEval episodics, +and so dominated the `recent` ordering and flooded the retrieval window. +The actual cat-Luna memory (`ep_AVF5`) never made it into the top-10. +The LongMemEval run was effectively being evaluated against a memory +store that was ~99% unrelated FactConsolidation facts. + +## Impact + +Contaminated — must be re-run: + +- FactConsolidation `sh_6k` (off / auto), `sh_32k`, `sh_64k`, `sh_262k` +- FactConsolidation `mh_6k` +- LongMemEval-S* (1 sample) + +`sh_6k` was the first MAB run and may have started against an empty +store, but the sweep that followed (`sh_32k` → `sh_64k` → `sh_262k`) +each ran on top of all prior sub_datasets' memory, so even the +FactConsolidation length-sweep numbers are not trustworthy. + +Not affected: all LoCoMo results. The LoCoMo pipeline +(`evals/main_eval.py` via `eval_locomo_single.py`) already uses a +per-sample `user_id` (`locomo-user-`), so its 10-conversation +run was never cross-contaminated. + +## The fix + +### 1. user_id is namespaced by sub_dataset and not configurable + +`mirix_adapter.py` — `_user_prefix` is now hard-coded: + +```python +# user_id is ALWAYS namespaced by sub_dataset. This is deliberately not +# configurable ... +self._user_prefix = f"mab-{self.sub_dataset}" +``` + +`_user_id_for_context` then yields `mab--ctx`. Each +sub_dataset gets its own user_id space; each context gets its own +user_id within it. The `mirix_user_prefix` config key is removed. + +### 2. Server-side memory is purged before the first ingest + +`mirix_adapter.py` — new `_purge_user_memory(user_id)`: + +```python +def _purge_user_memory(self, user_id: str) -> None: + if user_id in self._purged_user_ids: + return + self._purged_user_ids.add(user_id) + try: + self._run(self._client._request("DELETE", f"/users/{user_id}/memories")) + except Exception as exc: + # 404 just means the user has no memory yet — fine. + ... +``` + +It calls the existing server endpoint `DELETE /users/{user_id}/memories` +(hard-deletes all episodic / semantic / procedural / resource / +knowledge-vault memory, messages and blocks for the user; preserves the +user record). `_purged_user_ids` guards it so it fires at most once per +user_id per process. `_memorize` calls it before its first `add` for a +user, so every ingest starts from a clean slate. + +### 3. `--force` now actually forces a re-ingest + +`run_bench.py` — before constructing the adapter for a context: + +```python +if args.force and os.path.isdir(save_folder): + shutil.rmtree(save_folder, ignore_errors=True) +``` + +Dropping the local sentinel folder makes the runner take the +`_memorize` path instead of `load_agent`, which in turn triggers the +server-side purge from fix #2. Without this, `--force` would re-create +the result file but keep reusing stale server memory. + +`run_ablation.py` needs no change: it spawns `run_bench.py` with +`--force`, so it inherits the corrected behaviour. + +### 4. Config cleanup + +`configs/mirix_gpt-4o-mini.yaml` — the `mirix_user_prefix: mab` line is +removed and replaced with a comment explaining that user_id is not +configurable and that memory is purged before re-ingest. + +## Behaviour after the patch + +- `sh_6k` writes to `mab-factconsolidation_sh_6k-ctx0`, `sh_32k` to + `mab-factconsolidation_sh_32k-ctx0`, LongMemEval-S* to + `mab-longmemeval_s*-ctx0`, and so on — no cross-talk. +- The first ingest into any user_id hard-deletes whatever memory was + there, so re-runs start clean. +- `--force` is a true from-scratch re-ingest. + +## Follow-ups (not done in this patch) + +- The legacy `mab-ctx0` user still holds the ~25k contaminated mixed + memories from pre-patch runs. It can be purged with + `DELETE /users/mab-ctx0/memories`; new runs no longer touch it. +- All MAB benchmarks (FactConsolidation sweep, `mh_6k`, LongMemEval-S*) + need to be re-run; the pre-patch numbers in + `evals/results/mab/.../RESULTS.md` should be regenerated. diff --git a/evals/build_coldfacts.py b/evals/build_coldfacts.py new file mode 100644 index 000000000..68b8a1c12 --- /dev/null +++ b/evals/build_coldfacts.py @@ -0,0 +1,118 @@ +"""PoC: build a selective COLD-FACT index for a LongMemEval user from the RAW +conversation (not the summarized store). Recovers verbatim specifics — exact +numbers, quantities, prices, durations, specs, product/proper names — that +MIRIX's summarizing ingest drops. LongMemEval (ICLR'25) + Dense-X (EMNLP'24): +keep facts as a SEPARATE retrieval index alongside summaries, don't replace them. + +Selective: only user turns that actually contain a literal are sent to the LLM, +so the fact count stays small (no Dense-X row explosion). Output: a JSON list of +{fact, ts, emb(ada-002)} the answerer retrieves from under MIRIX_COLDFACT. +""" +import ast +import json +import os +import re +import sys + +from openai import OpenAI + +USER = sys.argv[1] if len(sys.argv) > 1 else "longmem_s_0" +OUT = os.path.expanduser(f"~/MIRIX_eval/coldfacts_{USER}.json") + +sys.path.insert(0, "/home/lj/code/MIRIX/evals") +from longmem_eval import load_longmem_s # noqa: E402 + +client = OpenAI(api_key=os.environ["OPENAI_API_KEY"]) + +item = load_longmem_s(limit=1)[0] +parsed = ast.literal_eval(item["context"]) + +# A turn is "literal-bearing" if it has a digit, price, %, or a WORD-number +# (three months, five sessions) — word-numbers were the biggest missed-fact gap. +LITERAL = re.compile( + r"\b\d+\b|\$\d|\d+\s*%|" + r"\b(one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|" + r"fifteen|sixteen|seventeen|eighteen|nineteen|twenty|thirty|forty|fifty|" + r"first|second|third|fourth|fifth|couple|dozen|several)\b", re.I) + + +def chat_time(session): + for m in session if isinstance(session, list) else []: + if isinstance(m, dict): + mt = re.search(r"(\d{4})/(\d{1,2})/(\d{1,2})", str(m.get("content", ""))) + if mt: + return mt.group(0) + return None + + +# Collect literal-bearing USER turns with their session date. +turns = [] +for session in parsed: + msgs = session if isinstance(session, list) else session.get("messages", []) if isinstance(session, dict) else [] + ts = chat_time(session) + for m in msgs: + if not isinstance(m, dict): + continue + if str(m.get("role", "")).lower() != "user": + continue + content = str(m.get("content", "")).strip() + if content and LITERAL.search(content): + turns.append((ts, content)) + +print(f"literal-bearing user turns: {len(turns)}", flush=True) + +# Batch into ~16k-char groups, extract self-contained cold facts per batch. +PROMPT = ( + "From the USER's own messages below, extract every SPECIFIC personal fact the user " + "states about themselves — prioritizing exact NUMBERS, quantities, prices, DURATIONS, " + "dates, speeds, measurements, POSSESSION COUNTS, and product/proper NAMES. Preserve " + "word-numbers too (e.g. 'collecting cameras for three months', 'attended five sessions', " + "'owns four bikes: road, mountain, commuter, hybrid'). Write each as ONE short " + "self-contained sentence that PRESERVES THE EXACT VALUE verbatim (e.g. 'The user " + "brought 7 shirts to Costa Rica', 'The user upgraded to 500 Mbps internet', 'The user " + "has been collecting vintage cameras for three months', 'The user made a lemon " + "poppyseed cake for a colleague'). Only facts about the user's own life, " + "possessions, activities, and preferences. Skip generic/assistant content. Output one " + "fact per line, no numbering.\n\nUSER MESSAGES:\n") + +facts = [] +batch, blen = [], 0 +def flush(batch): + if not batch: + return + txt = "\n".join(f"[{ts or '?'}] {c}" for ts, c in batch) + resp = client.chat.completions.create( + model="gpt-4.1-mini", temperature=0, + messages=[{"role": "user", "content": PROMPT + txt}]) + for line in resp.choices[0].message.content.splitlines(): + line = line.strip().lstrip("-*• ").strip() + if len(line) > 8: + facts.append(line) + +for ts, c in turns: + if blen + len(c) > 16000: + flush(batch); batch, blen = [], 0 + batch.append((ts, c)); blen += len(c) +flush(batch) + +# Dedup + embed (ada-002, matching the store's space). +seen, uniq = set(), [] +for f in facts: + k = f.lower() + if k not in seen: + seen.add(k); uniq.append(f) +print(f"extracted {len(facts)} facts -> {len(uniq)} unique; embedding...", flush=True) + +embs = [] +for i in range(0, len(uniq), 256): + chunk = uniq[i:i + 256] + r = client.embeddings.create(model="text-embedding-ada-002", input=chunk) + embs.extend(d.embedding for d in r.data) + +index = [{"fact": f, "emb": e} for f, e in zip(uniq, embs)] +json.dump(index, open(OUT, "w")) +print(f"wrote {OUT} ({len(index)} cold facts)") +# sanity: are the known targets captured? +for probe in ["500 mbps", "7 shirts", "poppyseed", "50 hours", "three months", "420"]: + hit = [f for f in uniq if probe in f.lower()] + print(f" probe '{probe}': {hit[:1]}") diff --git a/evals/configs/0201c_v6.yaml b/evals/configs/0201c_v6.yaml new file mode 100644 index 000000000..4da332448 --- /dev/null +++ b/evals/configs/0201c_v6.yaml @@ -0,0 +1,42 @@ +# Mirix Configuration - v6 graph (lean entity index) +# Same model setup as 0201c. Switch graph_version=v6 via env var: +# MIRIX_ENABLE_GRAPH_MEMORY=true MIRIX_GRAPH_VERSION=v6 + +llm_config: + model: "gpt-4.1-mini" + model_endpoint_type: "openai" + api_key: your-api-key + model_endpoint: "https://api.openai.com/v1" + context_window: 128000 + +topic_extraction_llm_config: + model: "gpt-4.1-nano" + model_endpoint_type: "openai" + api_key: your-api-key + model_endpoint: "https://api.openai.com/v1" + context_window: 128000 + +embedding_config: + embedding_model: "text-embedding-3-small" + embedding_endpoint: "https://api.openai.com/v1" + api_key: your-api-key + embedding_endpoint_type: "openai" + embedding_dim: 1536 + +build_embeddings_for_memory: true + +meta_agent_config: + system_prompts_folder: prompts/0201a/ + agents: + - core_memory_agent + - resource_memory_agent + - semantic_memory_agent + - episodic_memory_agent + - procedural_memory_agent + - knowledge_vault_memory_agent + memory: + core: + - label: "human" + value: "" + - label: "persona" + value: "I am a helpful assistant." diff --git a/evals/longmem_eval.py b/evals/longmem_eval.py new file mode 100644 index 000000000..94b09f0c1 --- /dev/null +++ b/evals/longmem_eval.py @@ -0,0 +1,507 @@ +"""Evaluate Mirix memory on MemoryAgentBench LongMemEval-S. + +Sits alongside main_eval.py (LoCoMo runner). Reuses the same scaffolding — +MirixMemorySystem, TaskAgent, server-side token tracker — and writes the same +per-sample JSON schema, so organize_results.py works on its output unchanged. + +Only the data layer differs: LongMemEval-S comes from the HuggingFace dataset +`ai-hyz/MemoryAgentBench` (split `Accurate_Retrieval`, rows whose +metadata.source starts with `longmemeval_s`). Each row is one long context +(~1.6M chars) plus a list of questions / answers / per-question types. + +The long context is a list of conversation sessions, each prefixed with a +"Chat Time". It is parsed into one chunk per session and ingested chunk by +chunk, with each session's chat time passed to MIRIX as `occurred_at` — so the +episodic agent anchors the real year instead of guessing it. + +Usage (smoke test — 1 conv, 15 chunks, 5 questions): + python longmem_eval.py --limit 1 --max-chunks 15 --max-questions 5 \ + --run-llm --mirix_config_path ./configs/0201c.yaml \ + --output_path results/0201c_longmem + +Output lands in evals/results/longmem// so it cannot collide +with the LoCoMo namespace used by main_eval.py. +""" + +import argparse +import json +import os +import time +from pathlib import Path +from typing import Dict, List, Optional + +from mirix_memory_system import MirixMemorySystem +from task_agent import TaskAgent + +# LongMemEval rows in MemoryAgentBench are tagged metadata.source = "longmemeval_s*". +LONGMEM_SOURCE_PREFIX = "longmemeval_s" + + +def load_longmem_s(limit: Optional[int] = None) -> List[Dict]: + """Load LongMemEval-S samples from HuggingFace MemoryAgentBench. + + Returns a list of dicts shaped as: + {sample_id, context, questions, answers, question_types} + """ + try: + from datasets import load_dataset + except ImportError as exc: # pragma: no cover + raise SystemExit( + "The `datasets` package is required for LongMemEval-S. " + "Install it with: uv pip install datasets" + ) from exc + + ds = load_dataset("ai-hyz/MemoryAgentBench", split="Accurate_Retrieval") + + samples: List[Dict] = [] + for row in ds: + meta = row.get("metadata") or {} + source = meta.get("source") + if not (isinstance(source, str) and source.startswith(LONGMEM_SOURCE_PREFIX)): + continue + idx = len(samples) + samples.append( + { + "sample_id": f"longmem_s_{idx}", + "context": row.get("context") or "", + "questions": list(row.get("questions") or []), + "answers": list(row.get("answers") or []), + "question_types": list(meta.get("question_types") or []), + } + ) + if limit is not None and len(samples) >= limit: + break + + if not samples: + raise SystemExit( + "No LongMemEval-S rows found in ai-hyz/MemoryAgentBench " + f"(looked for metadata.source starting with {LONGMEM_SOURCE_PREFIX!r})." + ) + return samples + + +def parse_sessions(context: str, max_chunk_chars: int = 4096) -> List[Dict]: + """Parse a LongMemEval context into timestamped ingest chunks. + + The HF `context` field is the repr of a Python list shaped: + ['Chat Time: 2022/11/17 (Thu) 12:04', [{'role','content'}, ...], + 'Chat Time: 2022/12/28 (Wed) 16:10', [ ... ], ...] + i.e. alternating (chat-time string, message list) pairs — one pair per + conversation session. + + The session structure is used ONLY to recover each session's real + timestamp (the chat time). A whole session averages ~14k chars / ~3.6k + tokens — far larger than the old 4096-char chunk — and feeding such large + blocks to the LightRAG extractor measurably dilutes recall of small + concrete facts (a counting list, a specific cake, a date). So each session + is further split into <= `max_chunk_chars` chunks on message boundaries, + and every sub-chunk inherits its session's `occurred_at`. + + Net effect: episodic agent still gets the correct year (occurred_at), + AND the extractor sees small chunks again. + + Returns: list of {occurred_at: Optional[str], text: str}. + """ + import ast + import re + from datetime import datetime + + try: + parsed = ast.literal_eval(context) + except (ValueError, SyntaxError): + # Fallback: treat the whole context as one undated chunk. + return [{"occurred_at": None, "text": context}] + + if not isinstance(parsed, list): + return [{"occurred_at": None, "text": str(context)}] + + def _parse_chat_time(s: str) -> Optional[str]: + # "Chat Time: 2022/11/17 (Thu) 12:04" -> "2022-11-17T12:04:00" + m = re.search(r"(\d{4})/(\d{1,2})/(\d{1,2}).*?(\d{1,2}):(\d{2})", s) + if not m: + return None + y, mo, d, hh, mm = (int(x) for x in m.groups()) + try: + return datetime(y, mo, d, hh, mm).isoformat() + except ValueError: + return None + + def _msg_lines(msgs) -> List[str]: + lines = [] + for msg in msgs: + if not isinstance(msg, dict): + continue + role = str(msg.get("role", "")).strip() + content = str(msg.get("content", "")).strip() + lines.append(f"{role}: {content}") + return lines + + def _split_session(header: str, msgs, occurred: Optional[str]) -> List[Dict]: + """Split one session into <= max_chunk_chars chunks on message + boundaries. Each chunk keeps the session header + occurred_at.""" + lines = _msg_lines(msgs) + chunks: List[Dict] = [] + buf: List[str] = [] + buf_len = len(header) + for ln in lines: + # +1 for the newline join + if buf and buf_len + len(ln) + 1 > max_chunk_chars: + chunks.append({"occurred_at": occurred, + "text": header + "\n" + "\n".join(buf)}) + buf = [] + buf_len = len(header) + # A single message longer than the budget still goes in alone. + buf.append(ln) + buf_len += len(ln) + 1 + if buf: + chunks.append({"occurred_at": occurred, + "text": header + "\n" + "\n".join(buf)}) + return chunks or [{"occurred_at": occurred, "text": header}] + + sessions: List[Dict] = [] + i = 0 + while i < len(parsed): + item = parsed[i] + if isinstance(item, str) and "Chat Time" in item and i + 1 < len(parsed) \ + and isinstance(parsed[i + 1], list): + occurred = _parse_chat_time(item) + sessions.extend(_split_session(item, parsed[i + 1], occurred)) + i += 2 + else: + # Unexpected shape — keep it as an undated chunk rather than drop. + if isinstance(item, list): + for ln in _msg_lines(item): + sessions.append({"occurred_at": None, "text": ln}) + elif isinstance(item, str): + sessions.append({"occurred_at": None, "text": item}) + i += 1 + return sessions + + +def flatten_answer(answer) -> Optional[str]: + """LongMemEval answers are list-of-list (e.g. ['50']). Flatten to a string.""" + if answer is None: + return None + if isinstance(answer, list): + flat = [] + for a in answer: + if isinstance(a, list): + flat.extend(str(x) for x in a) + else: + flat.append(str(a)) + return "; ".join(flat) if flat else None + return str(answer) + + +def measure_memory_size(sample_id: str) -> Dict: + """Common-unit memory size: total stored characters + tokens. + + Both backends are measured on the SAME yardstick so no-graph (PG flat + memory) and graph (Neo4j entities/relations) are directly comparable: + we concatenate every stored text field and count chars/tokens. + + - PG flat memory: episodic summary+details, semantic name+summary+details + - Neo4j graph: entity descriptions + relation descriptions + + Also keeps the raw counts (rows / nodes / edges) for reference. + """ + stats: Dict = {"unit": "chars+tokens"} + pg_bin = os.environ.get("PSQL_BIN", "/usr/local/opt/postgresql@17/bin/psql") + pg_host = os.environ.get("MIRIX_PG_HOST", "localhost") + pg_port = os.environ.get("MIRIX_PG_PORT", "5432") + pg_user = os.environ.get("MIRIX_PG_USER", "mirix") + pg_db = os.environ.get("MIRIX_PG_DB", "mirix") + pg_password = os.environ.get("MIRIX_PG_PASSWORD", "mirix") + import subprocess + + def _pg(sql: str) -> str: + try: + out = subprocess.run( + [pg_bin, "-h", pg_host, "-p", pg_port, "-U", pg_user, "-d", pg_db, "-tAc", sql], + capture_output=True, text=True, timeout=30, + env={**os.environ, "PGPASSWORD": pg_password}, + ) + return out.stdout.strip() + except Exception: + return "" + + # --- PG flat memory: rows + concatenated text size --- + flat: Dict = {} + flat_chars = 0 + for table, cols in ( + ("episodic_memory", "coalesce(summary,'')||coalesce(details,'')"), + ("semantic_memory", "coalesce(name,'')||coalesce(summary,'')||coalesce(details,'')"), + ): + row_n = _pg(f"SELECT count(*) FROM {table} WHERE user_id='{sample_id}';") + chars = _pg(f"SELECT coalesce(sum(length({cols})),0) FROM {table} WHERE user_id='{sample_id}';") + n = int(row_n) if row_n.isdigit() else 0 + c = int(chars) if chars.lstrip('-').isdigit() else 0 + flat[table] = {"rows": n, "chars": c} + flat_chars += c + stats["flat"] = flat + stats["flat_total_chars"] = flat_chars + + # --- Neo4j graph: node/edge counts + concatenated description size --- + try: + from neo4j import GraphDatabase + from mirix.settings import settings + driver = GraphDatabase.driver( + settings.neo4j_uri, auth=(settings.neo4j_user, settings.neo4j_password) + ) + with driver.session(database=settings.neo4j_database) as ses: + rec = ses.run( + """ + MATCH (n) WHERE n.user_id=$uid + WITH count(n) AS nodes, + sum(size(coalesce(n.description,'')) + size(coalesce(n.summary,''))) AS node_chars + RETURN nodes, node_chars + """, uid=sample_id + ).single() + erec = ses.run( + """ + MATCH (a)-[r]->(b) WHERE a.user_id=$uid + RETURN count(r) AS edges, + sum(size(coalesce(r.description,''))) AS edge_chars + """, uid=sample_id + ).single() + driver.close() + node_chars = (rec["node_chars"] or 0) if rec else 0 + edge_chars = (erec["edge_chars"] or 0) if erec else 0 + stats["graph"] = { + "nodes": (rec["nodes"] or 0) if rec else 0, + "edges": (erec["edges"] or 0) if erec else 0, + "node_chars": node_chars, + "edge_chars": edge_chars, + } + stats["graph_total_chars"] = node_chars + edge_chars + except Exception as exc: + stats["graph"] = {"error": str(exc)} + stats["graph_total_chars"] = 0 + + # Common-unit summary: whichever backend stored memory, in chars + tokens. + total_chars = max(stats["flat_total_chars"], stats["graph_total_chars"]) + stats["total_chars"] = total_chars + stats["total_tokens"] = total_chars // 4 # cheap estimate; exact below if small + return stats + + +def load_sample_result(path: Path) -> Optional[Dict]: + if not path.exists(): + return None + try: + with path.open("r", encoding="utf-8") as handle: + data = json.load(handle) + if isinstance(data, dict): + return data + except (OSError, json.JSONDecodeError): + return None + return None + + +def save_sample_result(path: Path, data: Dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as handle: + json.dump(data, handle, ensure_ascii=False, indent=2) + + +def print_qa(qidx: int, question: str, expected: Optional[str], predicted: Optional[str]) -> None: + print(f"[{qidx}] question: {str(question)[:120]}") + print(f"[{qidx}] expected_answer: {expected}") + print(f"[{qidx}] predicted_answer: {str(predicted)[:200] if predicted else predicted}") + + +def main() -> None: + parser = argparse.ArgumentParser(description="Evaluate Mirix memory on LongMemEval-S.") + parser.add_argument("--limit", type=int, default=None, + help="Limit number of conversations (samples) to evaluate.") + parser.add_argument("--max-chunks", type=int, default=None, + help="Limit session chunks ingested per sample (smoke-test knob).") + parser.add_argument("--max-questions", type=int, default=None, + help="Limit number of questions per sample.") + parser.add_argument("--run-llm", action="store_true", default=True, + help="Call the LLM to answer questions.") + parser.add_argument("--output_path", type=Path, default=Path("longmem_run"), + help="Output sub-folder, resolved under evals/results/longmem/.") + parser.add_argument("--mirix_config_path", type=Path, default=None, + help="Path to Mirix config file.") + args = parser.parse_args() + + items = load_longmem_s(limit=args.limit) + print(f"[longmem_eval] loaded {len(items)} LongMemEval-S conversation(s)") + + mirix_client_id = os.environ.get("MIRIX_CLIENT_ID", "mirix-eval-client") + mirix_org_id = os.environ.get("MIRIX_ORG_ID", "mirix-eval-org") + + # Keep LongMemEval output in its own namespace, away from LoCoMo's. + longmem_root = Path(__file__).resolve().parent / "results" / "longmem" + if args.output_path.is_absolute(): + print(f"[longmem_eval] WARNING: --output_path is absolute ({args.output_path}); " + "writing outside evals/results/longmem/ namespace.") + output_path = args.output_path + else: + output_path = longmem_root / args.output_path + output_path.mkdir(parents=True, exist_ok=True) + print(f"[longmem_eval] writing per-sample results to {output_path}") + + import httpx + server_base = "http://127.0.0.1:8531" + + def _reset_tokens(): + try: + httpx.post(f"{server_base}/debug/token_stats/reset", timeout=10) + except Exception: + pass + + def _snapshot_tokens(): + try: + r = httpx.get(f"{server_base}/debug/token_stats", timeout=10) + return r.json().get("stats", {}) + except Exception: + return {} + + def _sum_tokens(stats): + s = {"prompt": 0, "completion": 0, "total": 0, "calls": 0} + for v in stats.values(): + for k in s: + s[k] += v.get(k, 0) + return s + + for item in items: + sample_id = item["sample_id"] + sample_path = output_path / f"{sample_id}.json" + + task_agent = ( + TaskAgent(mirix_config_path=str(args.mirix_config_path), + client_id=mirix_client_id, org_id=mirix_org_id, user_id=sample_id) + if args.run_llm else None + ) + + sample_result = load_sample_result(sample_path) or { + "sample_id": sample_id, + "timings": {"add_chunk": {}, "wrap_user_prompt": {}, "answer": {}}, + "responses": {}, + "records": {}, + } + sample_result.setdefault("sample_id", sample_id) + + memory_system = MirixMemorySystem( + user_id=sample_id, + mirix_config_path=str(args.mirix_config_path), + client=task_agent.mirix_client if task_agent else None, + ) + + _reset_tokens() + + # ---- ingest: one chunk per conversation session, WITH timestamps ---- + chunks = parse_sessions(item["context"]) + if args.max_chunks is not None: + chunks = chunks[: args.max_chunks] + dated = sum(1 for c in chunks if c["occurred_at"]) + print(f"[longmem_eval] {sample_id}: ingesting {len(chunks)} session chunk(s) " + f"({dated} with timestamps)") + + for idx, chunk in enumerate(chunks, start=1): + idx_key = str(idx) + if idx_key in sample_result["responses"]: + continue + start = time.perf_counter() + response = memory_system.add_chunk( + chunk["text"], raw_input=chunk["text"], + occurred_at=chunk["occurred_at"], + ) + elapsed = time.perf_counter() - start + sample_result["responses"][idx_key] = { + "type": "add_chunk", + "chunk_index": idx, + "question_index": None, + "occurred_at": chunk["occurred_at"], + "response": response, + } + sample_result["timings"]["add_chunk"][idx_key] = elapsed + save_sample_result(sample_path, sample_result) + + build_stats = _snapshot_tokens() + sample_result["token_stats"] = {"build_raw": build_stats, "build_sum": _sum_tokens(build_stats)} + save_sample_result(sample_path, sample_result) + + # Memory size in a common unit (stored chars) so no-graph (flat PG) and + # graph (Neo4j) runs are directly comparable. See measure_memory_size(). + sample_result["memory_stats"] = measure_memory_size(sample_id) + print(f"[longmem_eval] {sample_id}: memory_stats = {sample_result['memory_stats']}") + save_sample_result(sample_path, sample_result) + + # ---- QA ---- + questions = item["questions"] + answers = item["answers"] + qtypes = item["question_types"] + if args.max_questions is not None: + questions = questions[: args.max_questions] + + for qidx, question in enumerate(questions, start=1): + qidx_key = str(qidx) + if qidx_key in sample_result["records"]: + rec = sample_result["records"][qidx_key] + print_qa(qidx, rec.get("question", ""), + rec.get("expected_answer"), rec.get("predicted_answer")) + continue + + expected_answer = flatten_answer(answers[qidx - 1] if qidx - 1 < len(answers) else None) + # category: organize_results.py groups metrics by this. LongMemEval + # uses string question types (multi-session, temporal-reasoning, ...). + category = qtypes[qidx - 1] if qidx - 1 < len(qtypes) else None + + start = time.perf_counter() + input_messages = memory_system.wrap_user_prompt(question) + sample_result["timings"]["wrap_user_prompt"][qidx_key] = time.perf_counter() - start + + predicted = None + message_trace = None + usage_trace = None + usage_total = None + if task_agent: + start = time.perf_counter() + trace = task_agent.answer(input_messages, user_id=sample_id) + predicted = trace.get("answer") + message_trace = trace.get("messages") + usage_trace = trace.get("usage") + usage_total = trace.get("usage_total") + sample_result["timings"]["answer"][qidx_key] = time.perf_counter() - start + + sample_result["records"][qidx_key] = { + "sample_id": sample_id, + "question_index": qidx, + "question": question, + "expected_answer": expected_answer, + "evidence": None, + "category": category, + "input_messages": input_messages, + "predicted_answer": predicted, + "messages": message_trace, + "usage": usage_trace, + "usage_total": usage_total, + } + save_sample_result(sample_path, sample_result) + print_qa(qidx, question, expected_answer, predicted) + + try: + all_memories = memory_system.list_all_memories(limit=0) + except Exception as exc: + all_memories = {"success": False, "error": str(exc), "user_id": sample_id} + with (output_path / f"{sample_id}_memories.json").open("w", encoding="utf-8") as handle: + json.dump(all_memories, handle, ensure_ascii=False, indent=2) + + post_qa_stats = _snapshot_tokens() + post_qa_sum = _sum_tokens(post_qa_stats) + build_sum = sample_result.get("token_stats", {}).get("build_sum", {}) + query_sum = { + k: max(post_qa_sum.get(k, 0) - build_sum.get(k, 0), 0) + for k in ("prompt", "completion", "total", "calls") + } + sample_result.setdefault("token_stats", {}) + sample_result["token_stats"]["query_raw"] = post_qa_stats + sample_result["token_stats"]["query_sum"] = query_sum + save_sample_result(sample_path, sample_result) + + +if __name__ == "__main__": + main() diff --git a/evals/mab/longmem_eval.py b/evals/mab/longmem_eval.py index 12151f339..c63f994a6 100644 --- a/evals/mab/longmem_eval.py +++ b/evals/mab/longmem_eval.py @@ -121,14 +121,22 @@ def parse_sessions(context: str, max_chunk_tokens: int = DEFAULT_CHUNK_TOKENS) - import re from datetime import datetime + def _token_chunk_raw(raw: str) -> List[Dict]: + # Raw-string context (e.g. RULER / EventQA "Document N:" passages — not a + # LongMemEval session list). Token-chunk into max_chunk_tokens pieces, the + # way MAB segments a long context to simulate incremental multi-turn input. + pieces = chunk_text_into_sentences(raw, chunk_size=max_chunk_tokens) + return [{"occurred_at": None, "text": p} for p in pieces] or [ + {"occurred_at": None, "text": raw} + ] + try: parsed = ast.literal_eval(context) except (ValueError, SyntaxError): - # Fallback: treat the whole context as one undated chunk. - return [{"occurred_at": None, "text": context}] + return _token_chunk_raw(context) if not isinstance(parsed, list): - return [{"occurred_at": None, "text": str(context)}] + return _token_chunk_raw(str(context)) def _parse_chat_time(s: str) -> Optional[str]: # "Chat Time: 2022/11/17 (Thu) 12:04" -> "2022-11-17T12:04:00" @@ -346,6 +354,14 @@ def _sum_tokens(stats): sample_result["timings"]["add_chunk"][idx_key] = elapsed save_sample_result(sample_path, sample_result) + # v8 finalize: prune singleton anchors now that ingestion is complete + # (no-op for v5/v6/v7). An anchor's final degree is only known here. + try: + compact = memory_system.compact_graph() + print(f"[longmem_eval] {sample_id}: graph compact -> {compact}") + except Exception as exc: + print(f"[longmem_eval] {sample_id}: graph compact skipped ({exc})") + build_stats = _snapshot_tokens() sample_result["token_stats"] = {"build_raw": build_stats, "build_sum": _sum_tokens(build_stats)} save_sample_result(sample_path, sample_result) diff --git a/evals/main_eval.py b/evals/main_eval.py index 3862d1129..2f42ec205 100644 --- a/evals/main_eval.py +++ b/evals/main_eval.py @@ -153,8 +153,13 @@ def main() -> None: parser.add_argument( "--output_path", type=Path, - default=Path("results"), - help="Output folder for per-sample JSON results.", + default=Path("locomo_run"), + help=( + "Output sub-folder name. The path is resolved relative to " + "/evals/results/locomo/, so passing 'foo' writes to " + "evals/results/locomo/foo. Absolute paths are still honored " + "but warned about, since they bypass the locomo namespace." + ), ) parser.add_argument( "--mirix_config_path", @@ -171,8 +176,43 @@ def main() -> None: mirix_client_id = os.environ.get("MIRIX_CLIENT_ID", "mirix-eval-client") mirix_org_id = os.environ.get("MIRIX_ORG_ID", "mirix-eval-org") - output_path = args.output_path + # Force every main_eval run into the LoCoMo namespace so MAB and LoCoMo + # outputs cannot bleed into each other. The user can still pass an + # absolute path to break out (e.g. for one-off experiments), but a warning + # makes the divergence explicit. + locomo_root = Path(__file__).resolve().parent / "results" / "locomo" + if args.output_path.is_absolute(): + print( + f"[main_eval] WARNING: --output_path is absolute ({args.output_path}); " + f"writing outside evals/results/locomo/ namespace.", + ) + output_path = args.output_path + else: + output_path = locomo_root / args.output_path output_path.mkdir(parents=True, exist_ok=True) + print(f"[main_eval] writing per-sample results to {output_path}") + + # Server-side token tracker is always-on (see mirix/database/token_tracker.py). + # We just need to (a) reset before each sample's ingest, (b) snapshot after + # ingest to get "build" tokens, (c) snapshot after QA to get "query" tokens. + import httpx + server_base = "http://127.0.0.1:8531" + def _reset_tokens(): + try: + httpx.post(f"{server_base}/debug/token_stats/reset", timeout=10) + except Exception: + pass + def _snapshot_tokens(): + try: + r = httpx.get(f"{server_base}/debug/token_stats", timeout=10) + return r.json().get("stats", {}) + except Exception: + return {} + def _sum_tokens(stats): + s = {"prompt": 0, "completion": 0, "total": 0, "calls": 0} + for v in stats.values(): + for k in s: s[k] += v.get(k, 0) + return s for item in items: sample_id = item.get("sample_id") @@ -198,7 +238,46 @@ def main() -> None: mirix_config_path=str(args.mirix_config_path), client=task_agent.mirix_client) + # Reset server-side token counter so build_tokens reflects only this sample's ingest + _reset_tokens() + + # Interleaved consolidation (MIRIX_DREAM_EVERY_N_CHUNKS=N): fire one + # auto_dream cycle after every Nth ingested chunk, plus one final cycle + # after the last chunk if the total is not a multiple of N. This models + # the ONLINE periodic-reconsolidation design (consolidate while + # ingesting), not a one-shot post-hoc dream on a finished store. + dream_every = int(os.environ.get("MIRIX_DREAM_EVERY_N_CHUNKS", "0") or 0) + + def _fire_dream(after_idx: int) -> None: + dream_key = f"dream_{after_idx}" + if dream_key in sample_result["responses"]: + return + start = time.perf_counter() + try: + r = httpx.post( + f"{server_base}/memory/auto_dream", + params={"user_id": sample_id}, + headers={"x-client-id": mirix_client_id, "x-org-id": mirix_org_id}, + json={"mode": "experience"}, + timeout=3000, + ) + payload = r.json() + except Exception as exc: # noqa: BLE001 + payload = {"error": str(exc)} + elapsed = time.perf_counter() - start + print(f"[main_eval] auto_dream after chunk {after_idx}: " + f"{elapsed:.0f}s {str(payload)[:160]}", flush=True) + sample_result["responses"][dream_key] = { + "type": "auto_dream", + "chunk_index": after_idx, + "question_index": None, + "response": payload, + "elapsed_seconds": elapsed, + } + save_sample_result(sample_path, sample_result) + conversation = item.get("conversation", {}) + total_chunks = sum(1 for _ in iter_sessions(conversation)) for idx, session in enumerate(iter_sessions(conversation), start=1): idx_key = str(idx) if idx_key in sample_result["responses"]: @@ -224,6 +303,18 @@ def main() -> None: sample_result["timings"]["add_chunk"][idx_key] = elapsed save_sample_result(sample_path, sample_result) + if dream_every and idx % dream_every == 0: + _fire_dream(idx) + + if dream_every and total_chunks % dream_every != 0: + # final consolidation so the tail chunks are dreamed before QA + _fire_dream(total_chunks) + + # Snapshot build tokens (everything since reset, before any QA runs) + build_stats = _snapshot_tokens() + sample_result["token_stats"] = {"build_raw": build_stats, "build_sum": _sum_tokens(build_stats)} + save_sample_result(sample_path, sample_result) + qa_list = item.get("qa", []) if args.max_questions is not None: qa_list = qa_list[: args.max_questions] @@ -309,6 +400,22 @@ def main() -> None: with memories_path.open("w", encoding="utf-8") as handle: json.dump(all_memories, handle, ensure_ascii=False, indent=2) + # Snapshot post-QA total tokens. "query_tokens" is server-side retrieval + # cost only (keyword extraction + LightRAG sub-calls). The actual QA + # answer LLM call goes through task_agent (client-side OpenAI), tracked + # separately in records[*].usage_total. + post_qa_stats = _snapshot_tokens() + post_qa_sum = _sum_tokens(post_qa_stats) + build_sum = sample_result.get("token_stats", {}).get("build_sum", {}) + query_sum = { + k: max(post_qa_sum.get(k, 0) - build_sum.get(k, 0), 0) + for k in ("prompt", "completion", "total", "calls") + } + sample_result.setdefault("token_stats", {}) + sample_result["token_stats"]["query_raw"] = post_qa_stats + sample_result["token_stats"]["query_sum"] = query_sum + save_sample_result(sample_path, sample_result) + if __name__ == "__main__": main() diff --git a/evals/memory_snapshot.py b/evals/memory_snapshot.py new file mode 100644 index 000000000..c22fd288f --- /dev/null +++ b/evals/memory_snapshot.py @@ -0,0 +1,280 @@ +"""Save / load Mirix memory snapshots so an expensive ingest can be reused. + +Ingesting one LongMemEval-S conversation takes ~2h (no-graph) to ~9h (graph). +This script captures the post-ingest state — PostgreSQL flat-memory tables and +the Neo4j graph — into a named snapshot, and restores it later so a QA-only +re-run can skip ingest entirely. + +Usage: + python memory_snapshot.py save [--agents] + python memory_snapshot.py load + python memory_snapshot.py list + python memory_snapshot.py delete + +A snapshot lives in evals/snapshots// and contains: + pg_memory.dump — pg_dump of the six memory tables (+ agents/messages + when --agents is given, needed for a true QA-only run) + neo4j_graph.json — every node + relationship, exported via Cypher + +Notes +----- +* `load` REPLACES current memory: it truncates the same tables / wipes the + Neo4j graph before restoring, so the snapshot is the exact state afterwards. +* PG connection + Neo4j auth are read from the same env / settings the server + uses (.env in repo root). Defaults match docker/env.example. +* A snapshot is mode-specific: a no-graph snapshot has an empty graph, a graph + snapshot has both. Restoring a no-graph snapshot then running graph-mode QA + will NOT magically produce a graph. +""" + +import argparse +import json +import os +import subprocess +import sys +from pathlib import Path + +# Load .env the same way the server does. +REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT)) +try: + from dotenv import load_dotenv + load_dotenv(REPO_ROOT / ".env") +except Exception: + pass + +SNAP_ROOT = Path(__file__).resolve().parent / "snapshots" + +# Mirix memory tables. Core memory lives in `block`; `raw_memory` holds the +# verbatim ingested chunks. agents/messages are optional (only needed for a +# fully ingest-free QA run that also skips meta-agent recreation). +# Names verified against the live schema (pg_tables). +MEMORY_TABLES = [ + "episodic_memory", + "semantic_memory", + "procedural_memory", + "resource_memory", + "knowledge_vault", + "block", + "raw_memory", +] +AGENT_TABLES = ["agents", "messages"] + +# --- PG / Neo4j connection (env first, then docker/env.example defaults) --- +PG = { + "host": os.environ.get("MIRIX_PG_HOST", "localhost"), + "port": os.environ.get("MIRIX_PG_PORT", "5432"), + "user": os.environ.get("MIRIX_PG_USER", "mirix"), + "password": os.environ.get("MIRIX_PG_PASSWORD", "mirix"), + "db": os.environ.get("MIRIX_PG_DB", "mirix"), +} +PG_BIN = os.environ.get("MIRIX_PG_BIN", "/usr/local/opt/postgresql@17/bin") +NEO4J = { + "uri": os.environ.get("MIRIX_NEO4J_URI", "bolt://localhost:7687"), + "user": os.environ.get("MIRIX_NEO4J_USER", "neo4j"), + "password": os.environ.get("MIRIX_NEO4J_PASSWORD", "mirix_neo4j_dev"), + "database": os.environ.get("MIRIX_NEO4J_DATABASE", "neo4j"), +} + + +def _pg_env(): + return {**os.environ, "PGPASSWORD": PG["password"]} + + +def _pg_args(tool: str): + return [ + f"{PG_BIN}/{tool}", + "-h", PG["host"], "-p", PG["port"], "-U", PG["user"], "-d", PG["db"], + ] + + +def _table_exists(table: str) -> bool: + out = subprocess.run( + _pg_args("psql") + ["-tAc", f"SELECT to_regclass('public.{table}');"], + capture_output=True, text=True, env=_pg_env(), timeout=30, + ) + return out.stdout.strip() not in ("", "NULL") + + +# ---------------------------------------------------------------- save ---- +def save(name: str, include_agents: bool) -> None: + snap = SNAP_ROOT / name + snap.mkdir(parents=True, exist_ok=True) + + tables = [t for t in MEMORY_TABLES if _table_exists(t)] + if include_agents: + tables += [t for t in AGENT_TABLES if _table_exists(t)] + if not tables: + raise SystemExit("No memory tables found in the database.") + + # --- PostgreSQL: pg_dump the selected tables --- + dump_path = snap / "pg_memory.dump" + table_flags = [] + for t in tables: + table_flags += ["-t", t] + print(f"[snapshot] pg_dump {len(tables)} table(s) -> {dump_path}") + res = subprocess.run( + _pg_args("pg_dump") + ["-Fc", "--data-only", "-f", str(dump_path)] + table_flags, + capture_output=True, text=True, env=_pg_env(), timeout=600, + ) + if res.returncode != 0: + raise SystemExit(f"pg_dump failed: {res.stderr}") + + # --- Neo4j: export all nodes + relationships via Cypher --- + graph = _neo4j_export() + graph_path = snap / "neo4j_graph.json" + graph_path.write_text(json.dumps(graph, ensure_ascii=False)) + print(f"[snapshot] neo4j export: {len(graph['nodes'])} nodes, " + f"{len(graph['relationships'])} relationships -> {graph_path}") + + meta = { + "name": name, + "pg_tables": tables, + "includes_agents": include_agents, + "neo4j_nodes": len(graph["nodes"]), + "neo4j_relationships": len(graph["relationships"]), + } + (snap / "meta.json").write_text(json.dumps(meta, indent=2)) + print(f"[snapshot] saved '{name}' -> {snap}") + + +def _neo4j_export() -> dict: + try: + from neo4j import GraphDatabase + except ImportError: + print("[snapshot] neo4j driver not installed; graph export skipped") + return {"nodes": [], "relationships": []} + try: + driver = GraphDatabase.driver(NEO4J["uri"], auth=(NEO4J["user"], NEO4J["password"])) + nodes, rels = [], [] + with driver.session(database=NEO4J["database"]) as ses: + for rec in ses.run( + "MATCH (n) RETURN id(n) AS id, labels(n) AS labels, properties(n) AS props" + ): + nodes.append({"id": rec["id"], "labels": rec["labels"], "props": dict(rec["props"])}) + for rec in ses.run( + "MATCH (a)-[r]->(b) RETURN id(a) AS src, id(b) AS dst, " + "type(r) AS type, properties(r) AS props" + ): + rels.append({"src": rec["src"], "dst": rec["dst"], + "type": rec["type"], "props": dict(rec["props"])}) + driver.close() + return {"nodes": nodes, "relationships": rels} + except Exception as exc: + print(f"[snapshot] neo4j export failed ({exc}); graph snapshot empty") + return {"nodes": [], "relationships": []} + + +# ---------------------------------------------------------------- load ---- +def load(name: str) -> None: + snap = SNAP_ROOT / name + if not snap.exists(): + raise SystemExit(f"Snapshot '{name}' not found at {snap}") + meta = json.loads((snap / "meta.json").read_text()) + tables = meta["pg_tables"] + + # --- PostgreSQL: truncate the snapshot's tables, then pg_restore data --- + print(f"[snapshot] truncating {len(tables)} table(s) before restore") + subprocess.run( + _pg_args("psql") + ["-c", f"TRUNCATE {', '.join(tables)} CASCADE;"], + capture_output=True, text=True, env=_pg_env(), timeout=120, check=True, + ) + print(f"[snapshot] pg_restore <- {snap / 'pg_memory.dump'}") + res = subprocess.run( + _pg_args("pg_restore") + ["--data-only", "--disable-triggers", + "-d", PG["db"], str(snap / "pg_memory.dump")], + capture_output=True, text=True, env=_pg_env(), timeout=600, + ) + # pg_restore returns non-zero on benign warnings; surface stderr but continue + if res.returncode != 0 and res.stderr.strip(): + print(f"[snapshot] pg_restore warnings:\n{res.stderr.strip()[:500]}") + + # --- Neo4j: wipe graph, recreate nodes + relationships --- + _neo4j_import(json.loads((snap / "neo4j_graph.json").read_text())) + print(f"[snapshot] loaded '{name}'") + + +def _neo4j_import(graph: dict) -> None: + if not graph["nodes"]: + return + try: + from neo4j import GraphDatabase + except ImportError: + print("[snapshot] neo4j driver not installed; graph restore skipped") + return + driver = GraphDatabase.driver(NEO4J["uri"], auth=(NEO4J["user"], NEO4J["password"])) + with driver.session(database=NEO4J["database"]) as ses: + ses.run("MATCH (n) DETACH DELETE n") + # Recreate nodes; keep the original id() under _snap_id to rewire edges. + for n in graph["nodes"]: + labels = ":".join(n["labels"]) if n["labels"] else "Node" + ses.run( + f"CREATE (x:{labels}) SET x = $props, x._snap_id = $sid", + props=n["props"], sid=n["id"], + ) + for r in graph["relationships"]: + ses.run( + f"MATCH (a {{_snap_id: $src}}), (b {{_snap_id: $dst}}) " + f"CREATE (a)-[r:{r['type']}]->(b) SET r = $props", + src=r["src"], dst=r["dst"], props=r["props"], + ) + ses.run("MATCH (n) REMOVE n._snap_id") + driver.close() + print(f"[snapshot] neo4j restored: {len(graph['nodes'])} nodes, " + f"{len(graph['relationships'])} relationships") + + +# --------------------------------------------------------------- list/rm -- +def list_snapshots() -> None: + if not SNAP_ROOT.exists(): + print("(no snapshots)") + return + for snap in sorted(SNAP_ROOT.iterdir()): + meta_file = snap / "meta.json" + if meta_file.exists(): + m = json.loads(meta_file.read_text()) + print(f" {m['name']:30s} pg_tables={len(m['pg_tables'])} " + f"graph={m['neo4j_nodes']}n/{m['neo4j_relationships']}r " + f"agents={'yes' if m.get('includes_agents') else 'no'}") + + +def delete(name: str) -> None: + import shutil + snap = SNAP_ROOT / name + if snap.exists(): + shutil.rmtree(snap) + print(f"[snapshot] deleted '{name}'") + else: + print(f"[snapshot] '{name}' not found") + + +def main() -> None: + parser = argparse.ArgumentParser(description="Save/load Mirix memory snapshots.") + sub = parser.add_subparsers(dest="cmd", required=True) + + p_save = sub.add_parser("save", help="Snapshot current memory state.") + p_save.add_argument("name") + p_save.add_argument("--agents", action="store_true", + help="Also snapshot agents/messages (for fully ingest-free QA).") + + p_load = sub.add_parser("load", help="Restore a snapshot (REPLACES current memory).") + p_load.add_argument("name") + + sub.add_parser("list", help="List snapshots.") + + p_del = sub.add_parser("delete", help="Delete a snapshot.") + p_del.add_argument("name") + + args = parser.parse_args() + if args.cmd == "save": + save(args.name, include_agents=args.agents) + elif args.cmd == "load": + load(args.name) + elif args.cmd == "list": + list_snapshots() + elif args.cmd == "delete": + delete(args.name) + + +if __name__ == "__main__": + main() diff --git a/evals/mirix_memory_system.py b/evals/mirix_memory_system.py index 8255844ce..5e231abce 100644 --- a/evals/mirix_memory_system.py +++ b/evals/mirix_memory_system.py @@ -50,7 +50,8 @@ def __init__(self, user_id: Optional[str] = None, mirix_config_path: Optional[st if client is None: # timeout: per-request budget. With sentence-token chunking # at 4096 gpt-4o-mini tokens (~16k chars), one /memory/add_sync - # takes 3-6 min server-side. Keep 30 min headroom. + # takes 3-6 min server-side. Graph hooks can add per-chunk LLM + # extraction + Neo4j writes, so keep 30 min headroom. self.client = MirixClient(client_id=client_id, org_id=org_id, base_url="http://127.0.0.1:8531", write_scope="read_write", timeout=1800) config_path = Path(mirix_config_path) if mirix_config_path else Path(__file__).with_name("mirix_openai.yaml") with config_path.open("r", encoding="utf-8") as handle: @@ -89,10 +90,14 @@ def add_chunk(self, chunk: str, raw_input: Optional[str] = None, async_add: bool return response def wrap_user_prompt(self, prompt: str): + # The retrieve endpoint's topic-extraction step iterates msg["content"] + # expecting a list of {type, text} dicts (multimodal format). Passing a + # bare string here silently degrades to topics="" → LightRAG retrieve + # gets an empty query → empty graph context. memories = asyncio.run(self.client.retrieve_with_conversation( user_id=self.user_id, messages=[ - {'role': 'user', 'content': prompt} + {'role': 'user', 'content': [{'type': 'text', 'text': prompt}]} ] )) @@ -101,7 +106,18 @@ def wrap_user_prompt(self, prompt: str): if memories.get("memories"): for memory_type, data in memories["memories"].items(): - if not data or data.get("total_count", 0) == 0: + if not data: + continue + + # Graph memory: pre-formatted context string, not structured items + if memory_type == "graph": + graph_ctx = data.get("context", "") + if graph_ctx: + memories_found = True + memory_context_lines.append(graph_ctx) + continue + + if data.get("total_count", 0) == 0: continue # Prefer items, but fall back to recent/relevant shapes Mirix may return @@ -167,3 +183,18 @@ def list_all_memories(self, memory_type: str = "all", limit: int = 0): memory_type=memory_type, limit=limit, )) + + def compact_graph(self): + """v8 finalize: prune singleton anchors (no-op for other graph versions). + + Uses a standalone synchronous HTTP call instead of the shared async + MirixClient: routing it through asyncio.run(self.client...) would reuse + the client's event-loop-bound connection pool and break the subsequent + QA requests ("Event loop is closed"). + """ + import httpx + base = self.client.base_url.rstrip("/") + with httpx.Client(timeout=120) as c: + resp = c.post(f"{base}/memory/graph/compact", json={"user_id": self.user_id}) + resp.raise_for_status() + return resp.json() diff --git a/evals/organize_results.py b/evals/organize_results.py index 34f97416f..517be05b0 100644 --- a/evals/organize_results.py +++ b/evals/organize_results.py @@ -299,7 +299,11 @@ def main() -> None: parser.add_argument( "input_dir", type=Path, - help="Path to results folder (e.g., results/0124a).", + help=( + "Results folder. Relative paths resolve against " + "/evals/results/locomo/, so 'foo' -> evals/results/locomo/foo. " + "Existing-as-given paths are also accepted for backwards compatibility." + ), ) parser.add_argument( "--output-file", @@ -331,7 +335,12 @@ def main() -> None: ) args = parser.parse_args() - input_dir = args.input_dir + locomo_root = Path(__file__).resolve().parent / "results" / "locomo" + requested = args.input_dir + if requested.is_absolute() or requested.exists(): + input_dir = requested + else: + input_dir = locomo_root / requested output_file = args.output_file or (input_dir / "metrics.json") # Honour the legacy boolean alias. diff --git a/evals/task_agent.py b/evals/task_agent.py index c16fcbe3e..67b9be042 100644 --- a/evals/task_agent.py +++ b/evals/task_agent.py @@ -32,10 +32,12 @@ def __init__( self.model = model self.user_id = user_id self.max_tool_rounds = max_tool_rounds + self._coldfacts = None # lazy-loaded cold-fact index (MIRIX_COLDFACT gate) # timeout: per-request budget. Question-answer calls are short, # but this client is shared with the ingest path (see # MirixMemorySystem) where a single /memory/add_sync on a 4096- - # token chunk takes 3-6 min server-side. Keep 30 min headroom. + # token chunk takes 3-6 min server-side. Graph hooks can add + # extraction + Neo4j writes, so keep 30 min headroom. self.mirix_client = MirixClient(client_id=client_id, org_id=org_id, base_url="http://127.0.0.1:8531", write_scope="read_write", timeout=1800) self.user_id = user_id if user_id is not None else str(uuid.uuid4()) config_path = Path(mirix_config_path) @@ -49,7 +51,7 @@ def __init__( def _build_tools(self) -> list: if not self.mirix_client: return [] - return [ + tools = [ { "type": "function", "function": { @@ -138,6 +140,10 @@ def _build_tools(self) -> list: }, }, ] + # (rejected answerer experiments — v7.11 consolidate tool, persona store, + # temporal prompt, graph-search merge — are archived; see + # docs/graph_memory_v7/development_history.md) + return tools def _search_memory( self, user_id: Optional[str], params: Optional[Dict[str, Any]] @@ -185,17 +191,61 @@ def _search_memory( del result["id"] if "actor" in result: del result["actor"] - return results['results'] + out = results['results'] + # Hybrid retrieval (MIRIX_HYBRID_SEARCH): the default is embedding-only, which + # misses a memory whose embedding is DILUTED even though the literal term is in + # its text — the exact failure consolidation creates (merging 5 topics into one + # broad memory blurs its vector, so "Glass Menagerie" no longer ranks, though + # the words are right there). A BM25 full-text pass over the same query recovers + # those by exact term. Union + dedup against the embedding hits. + if os.environ.get("MIRIX_HYBRID_SEARCH"): + bm = dict(params) + bm["search_method"] = "bm25" + try: + bres = asyncio.run(self.mirix_client.search(user_id=resolved_user_id, **bm)) + except Exception: # noqa: BLE001 + bres = None + if bres and bres.get("success"): + seen = {(r.get("summary") or "")[:120] for r in out if isinstance(r, dict)} + for r in bres["results"]: + s = (r.get("summary") or "")[:120] + if not s or s in seen: + continue + seen.add(s) + if "occurred_at" in r and r.get("occurred_at_description"): + r["occurred_at"] = f"{r['occurred_at']} ({r['occurred_at_description']})" + for k in ("id", "actor", "occurred_at_description"): + r.pop(k, None) + out.append(r) + # Cold-fact merge (MIRIX_COLDFACT): surface verbatim specifics the summarizing + # ingest dropped, AS REGULAR retrieved evidence competing with summaries for THIS + # search query — not force-injected ground truth. Lets normal retrieval filtering + # decide relevance (avoids the over-trust collateral of prompt injection). + for f in self._retrieve_coldfacts(resolved_user_id, params.get("query", ""), + k=3, thresh=0.82): + out.append({"memory_type": "semantic", "summary": f, + "source": "recovered detail from original conversation"}) + return out return results + def _check_raw_item(self, params: Dict[str, Any]) -> Dict[str, Any]: if not self.mirix_client: return {"success": False, "error": "Mirix client not configured."} raw_input_id = params.get("raw_input_id") if not raw_input_id: return {"success": False, "error": "raw_input_id is required."} - return self.mirix_client.check_raw_item(raw_input_id) + fn = getattr(self.mirix_client, "check_raw_item", None) + if fn is None: + return {"success": False, "error": "Raw item lookup is unavailable; answer from the memories already retrieved."} + try: + import inspect + res = fn(raw_input_id) + return asyncio.run(res) if inspect.isawaitable(res) else res + except Exception as e: + return {"success": False, "error": f"Raw item lookup failed: {e}"} + def _serialize_tool_calls(self, tool_calls: Any) -> list: serialized = [] @@ -206,6 +256,41 @@ def _serialize_tool_calls(self, tool_calls: Any) -> list: serialized.append(call) return serialized + + def _load_coldfacts(self, user_id: Optional[str]): + """Load the cold-fact index (MIRIX_COLDFACT gate): verbatim numbers/names the + summarizing ingest dropped, kept as a SEPARATE retrieval index (LongMemEval + key-expansion / Dense-X style). Cached per instance as (facts, np.ndarray).""" + if not os.environ.get("MIRIX_COLDFACT"): + return None + if self._coldfacts is None: + uid = user_id or self.user_id or "unknown" + path = os.path.expanduser(f"~/MIRIX_eval/coldfacts_{uid}.json") + try: + import numpy as np + idx = json.load(open(path, encoding="utf-8")) + mat = np.array([x["emb"] for x in idx], dtype="float32") + mat /= (np.linalg.norm(mat, axis=1, keepdims=True) + 1e-8) + self._coldfacts = ([x["fact"] for x in idx], mat) + except (OSError, ValueError, KeyError): + self._coldfacts = ([], None) + return self._coldfacts + + def _retrieve_coldfacts(self, user_id, q_text, k=3, thresh=0.83): + """Top-k cold facts for the question by ada-002 cosine (only strongly-relevant + ones, to avoid perturbing questions with no matching literal).""" + cf = self._load_coldfacts(user_id) + if not cf or cf[1] is None or not q_text.strip(): + return [] + facts, mat = cf + import numpy as np + qe = self.client.embeddings.create(model="text-embedding-ada-002", + input=q_text[:400]).data[0].embedding + q = np.array(qe, dtype="float32"); q /= (np.linalg.norm(q) + 1e-8) + sims = mat @ q + order = np.argsort(-sims)[:k] + return [facts[i] for i in order if sims[i] >= thresh] + def answer(self, input_messages: List[Dict[str, Any]], user_id: Optional[str] = None) -> Dict[str, Any]: tools = self._build_tools() system_prompt = ( @@ -253,6 +338,13 @@ def answer(self, input_messages: List[Dict[str, Any]], user_id: Optional[str] = "4. Be VERY CONCISE in your response, only output the answer and nothing else.\n" "5. There are some open-ended questions where you may not find explicit evidences, you still need to answer it based on your understanding. Never say you don't know or 'there is no specific information', ...\n" "6. If there is no information found, you still need to answer it. Guess an answer if you don't have enough information.\n" + "\n\nCOUNTING / AGGREGATION QUESTIONS (how many, how much, total, number of):\n" + "- These fail when you estimate a number in your head. DO NOT estimate.\n" + "- First search exhaustively with multiple keyword variants so NO instance is missed.\n" + "- Then write out EVERY distinct matching instance as an explicit numbered list (1., 2., 3., ...).\n" + "- Your final answer's number = the count of items in that list (or their sum for amounts). Count the list, never guess.\n" + "- Include instances that are phrased differently or belong to the same category even if not obviously a match (e.g. a yoga session counts as a 'fitness class').\n" + + "\n\nAnswer Format Guidelines (CRITICAL):\n" "- For list questions (What books, What instruments, What activities, etc.), provide a simple comma-separated list or use 'and' between items.\n" " Example: \"clarinet and violin\" NOT \"She plays clarinet\"\n" @@ -290,6 +382,8 @@ def answer(self, input_messages: List[Dict[str, Any]], user_id: Optional[str] = tools=None if is_last_round else (tools or None), tool_choice=None if is_last_round else ("auto" if tools else None), max_completion_tokens=128, + temperature=0, + seed=42, ) usage = getattr(response, "usage", None) diff --git a/mirix/agent/meta_agent.py b/mirix/agent/meta_agent.py index ae85b5c2e..051a5fd57 100644 --- a/mirix/agent/meta_agent.py +++ b/mirix/agent/meta_agent.py @@ -153,6 +153,39 @@ def get_all_agent_states_list(self) -> List[Optional[AgentState]]: ] +# Appended to the semantic_memory_agent system prompt when +# enable_conflict_resolution=True is passed at create_meta_agent time. +# Steers the agent away from free-form merge of conflicting facts. +# See docs/mab_conflict_resolution_and_provenance.md. +_CONFLICT_RESOLUTION_POLICY_PROMPT = """\ +## Conflict resolution policy + +When ingesting a fact that asserts a value for some (entity, relation) +already covered by an existing semantic item: + +- DO NOT merge the new value into a hedging free-text summary + ("X, though some data says Y", "incorrectly attributed", + "according to some sources"). +- DO NOT use your own world knowledge to choose which value is "correct". +- The user's most recent assertion is authoritative. + +When you call ``semantic_memory_insert`` for a fact of this shape, write: + + - ``name``: ``" / "`` (e.g. ``"Thomas Kyd / born in"``) + - ``summary``: the raw value, verbatim (e.g. ``"Leeds"``). No paraphrasing. + - ``details``: short context only. + +The manager will then preserve any prior canonical value with a +``superseded`` marker in ``prior_values`` based on source ordering — you +do not have to hedge in ``summary`` to keep the old value safe. + +For free-form concepts that do not fit a triple shape (multi-paragraph +how-tos, abstract topics), keep calling ``semantic_memory_insert`` +normally; the manager will route those down the legacy free-form path +unchanged. +""" + + class MetaAgent(BaseAgent): """ MetaAgent manages all memory-related sub-agents for coordinated memory operations. diff --git a/mirix/client/remote_client.py b/mirix/client/remote_client.py index 9bbf83838..8099d89c6 100644 --- a/mirix/client/remote_client.py +++ b/mirix/client/remote_client.py @@ -1467,6 +1467,15 @@ async def retrieve_with_conversation( return await self._request("POST", "/memory/retrieve/conversation", json=request_data, headers=headers) + async def compact_graph(self, user_id: str, headers: Optional[Dict[str, str]] = None) -> Dict[str, Any]: + """v8 finalize: prune singleton anchors after ingestion. + + No-op for other graph versions (server returns ``{"skipped": ...}``). + """ + return await self._request( + "POST", "/memory/graph/compact", json={"user_id": user_id}, headers=headers + ) + async def retrieve_with_topic( self, user_id: str, diff --git a/mirix/database/neo4j_client.py b/mirix/database/neo4j_client.py new file mode 100644 index 000000000..6c04502c7 --- /dev/null +++ b/mirix/database/neo4j_client.py @@ -0,0 +1,197 @@ +""" +Neo4j async client for MIRIX graph memory (v4: two independent graphs). + +Used only when ``settings.enable_graph_memory`` is True. Provides: +- Singleton AsyncDriver wrapping the bolt connection +- Schema bootstrap (constraints + vector indexes for both graphs) +- Health check + +Two independent graphs, dispatched by which MIRIX memory layer wrote the data: + +**G_episodic** (written by EpisodicMemoryManager.insert_event): +- (:Episode {id, user_id, organization_id, summary, occurred_at}) +- (:EpisodicEntity {id, user_id, organization_id, name, name_lower, + entity_type, description, rank, name_embedding, + created_at, updated_at}) +- (:Episode)-[:NEXT]->(:Episode) # auto, per user, by occurred_at +- (:Episode)-[:CAUSED_BY]->(:Episode) # optional, LLM-judged +- (:Episode)-[:MENTIONS {role}]->(:EpisodicEntity) +- (:EpisodicEntity)-[:EP_RELATES {id, keywords, description, weight, + source_episode_ids, valid_at, invalid_at, + expired_at, keywords_embedding}] + ->(:EpisodicEntity) + +**G_semantic** (written by SemanticMemoryManager.insert_semantic_item): +- (:Concept {id, user_id, organization_id, name, summary, created_at}) +- (:SemanticEntity {id, user_id, organization_id, name, name_lower, + entity_type, description, rank, name_embedding, + created_at, updated_at}) +- (:Concept)-[:CONCEPT_RELATES {keywords, description, weight}]->(:Concept) +- (:Concept)-[:MENTIONS]->(:SemanticEntity) +- (:SemanticEntity)-[:SEM_RELATES {id, keywords, description, weight, + source_concept_ids, keywords_embedding}] + ->(:SemanticEntity) + +Two independent graphs with disjoint labels AND disjoint edge types — vector +indexes can be queried per-graph without endpoint-label post-filtering. +EpisodicEntity and SemanticEntity are independent even when they share a name +("Apple" as a fruit Caroline ate vs "Apple" the company concept are distinct). +""" + +from typing import Optional + +from neo4j import AsyncDriver, AsyncGraphDatabase + +from mirix.log import get_logger +from mirix.settings import settings + +logger = get_logger(__name__) + +_neo4j_driver: Optional[AsyncDriver] = None + + +# Constraint + non-vector index DDL. Idempotent. +_SCHEMA_STATEMENTS = [ + # G_v7 (minimal semantic+episodic linkage graph). Separate labels so v7 + # can be compared against v5/v6 without deleting earlier experiments. + "CREATE CONSTRAINT v7_anchor_id_unique IF NOT EXISTS " + "FOR (a:V7Anchor) REQUIRE a.id IS UNIQUE", + "CREATE CONSTRAINT v7_anchor_user_name_unique IF NOT EXISTS " + "FOR (a:V7Anchor) REQUIRE (a.user_id, a.name_lower) IS UNIQUE", + "CREATE CONSTRAINT v7_memory_ref_id_unique IF NOT EXISTS " + "FOR (m:V7MemoryRef) REQUIRE m.id IS UNIQUE", + "CREATE INDEX v7_memory_ref_user_source IF NOT EXISTS " + "FOR (m:V7MemoryRef) ON (m.user_id, m.source_key)", +] + + +# Migration: drop v3 schema if it exists (old single-graph design used +# :Entity, :Event, and shared :RELATES). Also drops any old v4-pre-confirmation +# shared :RELATES vector index. Safe to run on a fresh DB — all OPTIONAL. +_V3_CLEANUP_STATEMENTS = [ + "MATCH (n:Entity) DETACH DELETE n", + "MATCH (n:Event) DETACH DELETE n", + "DROP CONSTRAINT entity_id_unique IF EXISTS", + "DROP CONSTRAINT event_id_unique IF EXISTS", + "DROP CONSTRAINT entity_user_name_unique IF EXISTS", + "DROP INDEX event_user_time IF EXISTS", + "DROP INDEX rel_expired IF EXISTS", + "DROP INDEX entity_name_emb IF EXISTS", + # Old v4-draft used a shared :RELATES vector index — drop in favor of + # ep_rel_kw_emb / sem_rel_kw_emb / concept_rel_kw_emb. + "DROP INDEX rel_kw_emb IF EXISTS", +] + + +def _vector_index_statement(name: str, label_or_rel: str, prop: str, dim: int, is_rel: bool) -> str: + """Build a CREATE VECTOR INDEX statement for nodes or relationships.""" + target = f"()-[r:{label_or_rel}]-()" if is_rel else f"(n:{label_or_rel})" + var = "r" if is_rel else "n" + return ( + f"CREATE VECTOR INDEX {name} IF NOT EXISTS " + f"FOR {target} ON {var}.{prop} " + f"OPTIONS {{indexConfig: {{" + f"`vector.dimensions`: {dim}, " + f"`vector.similarity_function`: 'cosine'" + f"}}}}" + ) + + +# Vector indexes — one per (graph, target) pair. Disjoint relationship types +# (:EP_RELATES vs :SEM_RELATES) let us keep separate vector indexes so +# queryRelationships returns episodic-only or semantic-only hits without any +# post-filtering by endpoint label. Concept-Concept edges also get their own +# vector index in case we want to do hl-style retrieval on concept relations +# in the future (P3 may or may not use it). +def _vector_indexes(dim: int) -> list[str]: + return [ + # G_v7 — minimal linkage graph. Only anchors are vector searched; + # memory refs point back to PG flat memory rows for details. + _vector_index_statement("v7_anchor_name_emb", "V7Anchor", "name_embedding", dim, is_rel=False), + ] + + +async def init_neo4j_client() -> Optional[AsyncDriver]: + """Initialize the Neo4j async driver and bootstrap v4 schema. + + Returns the driver, or ``None`` if graph memory is disabled or connection + fails. Failures are logged but do not raise — the rest of MIRIX must + continue working without graph memory. + """ + global _neo4j_driver + if not settings.enable_graph_memory: + logger.debug("Graph memory disabled; skipping Neo4j init") + return None + + if _neo4j_driver is not None: + return _neo4j_driver + + try: + driver = AsyncGraphDatabase.driver( + settings.neo4j_uri, + auth=(settings.neo4j_user, settings.neo4j_password), + ) + await driver.verify_connectivity() + logger.info("Neo4j async driver connected at %s", settings.neo4j_uri) + + await _bootstrap_schema(driver, settings.neo4j_vector_dim, settings.neo4j_database) + + _neo4j_driver = driver + return driver + except Exception as e: + logger.error("Neo4j init failed: %s — graph memory will be unavailable", e) + _neo4j_driver = None + return None + + +async def _bootstrap_schema(driver: AsyncDriver, vector_dim: int, database: str) -> None: + """Run DDL in order: v3 cleanup → v4 constraints/indexes → vector indexes.""" + async with driver.session(database=database) as session: + # Step 1: clean up v3 schema (no-op on fresh DB) + for stmt in _V3_CLEANUP_STATEMENTS: + try: + await session.run(stmt) + except Exception as e: + logger.debug("v3 cleanup stmt skipped (%s): %s", stmt[:50], e) + + # Step 2: v4 constraints + plain indexes + for stmt in _SCHEMA_STATEMENTS: + try: + await session.run(stmt) + except Exception as e: + logger.warning("v4 schema stmt failed (%s): %s", stmt[:60], e) + + # Step 3: vector indexes + for stmt in _vector_indexes(vector_dim): + try: + await session.run(stmt) + except Exception as e: + logger.warning("vector index stmt failed (%s): %s", stmt[:60], e) + + logger.info("Neo4j v4 schema bootstrap complete (vector_dim=%d)", vector_dim) + + +def get_neo4j_driver() -> Optional[AsyncDriver]: + """Get the global Neo4j driver. Returns None if not initialized.""" + return _neo4j_driver + + +async def close_neo4j_driver() -> None: + """Close the global driver. Safe to call when not initialized.""" + global _neo4j_driver + if _neo4j_driver is not None: + try: + await _neo4j_driver.close() + except Exception as e: + logger.warning("Error closing Neo4j driver: %s", e) + _neo4j_driver = None + + + try: + async with driver.session(database=settings.neo4j_database) as session: + result = await session.run("RETURN 1 AS ok") + record = await result.single() + return record is not None and record["ok"] == 1 + except Exception as e: + logger.warning("Neo4j healthcheck failed: %s", e) + return False diff --git a/mirix/database/startup_migrations.py b/mirix/database/startup_migrations.py new file mode 100644 index 000000000..823e654bd --- /dev/null +++ b/mirix/database/startup_migrations.py @@ -0,0 +1,51 @@ +""" +Lightweight startup migrations. + +MIRIX has no Alembic — schema is created via ``Base.metadata.create_all`` in +``ensure_tables_created``. This module runs idempotent DROP/ALTER statements +that need to happen *before* ``create_all`` so the new ORM state takes hold. + +Add a migration by appending to ``MIGRATIONS`` below. Each migration is a +``(name, sql_statements)`` tuple. Each statement runs in its own transaction; +failures are logged but do not raise (so a partial drop on dev DBs doesn't +brick startup). +""" + +from typing import List, Tuple + +from sqlalchemy.ext.asyncio import AsyncEngine + +from mirix.log import get_logger + +logger = get_logger(__name__) + + +# (migration_name, [statements]) +MIGRATIONS: List[Tuple[str, List[str]]] = [ + ( + # v2 graph memory tables — replaced by Neo4j-backed implementation + # (see lightrag_graph_manager). Drop in dependency order: edges that + # FK into entity_nodes / episode_nodes must go first. + "drop_v2_graph_memory_tables", + [ + "DROP TABLE IF EXISTS involves_edges CASCADE", + "DROP TABLE IF EXISTS entity_edges CASCADE", + "DROP TABLE IF EXISTS episode_nodes CASCADE", + "DROP TABLE IF EXISTS entity_nodes CASCADE", + ], + ), +] + + +async def run_startup_migrations(engine: AsyncEngine) -> None: + """Run all pending migrations against ``engine``. Safe to call repeatedly.""" + for name, statements in MIGRATIONS: + logger.info("Startup migration: %s", name) + for stmt in statements: + try: + async with engine.begin() as conn: + from sqlalchemy import text as sa_text + await conn.execute(sa_text(stmt)) + except Exception as e: + # Most likely on fresh DBs: table never existed. That's fine. + logger.debug("Migration stmt skipped (%s): %s", stmt, e) diff --git a/mirix/database/token_tracker.py b/mirix/database/token_tracker.py new file mode 100644 index 000000000..f208523db --- /dev/null +++ b/mirix/database/token_tracker.py @@ -0,0 +1,110 @@ +""" +Global token-usage tracker for instrumenting MIRIX's LLM calls. + +Designed so call-sites can blindly call ``record(...)`` and external code (evals, +benchmarks) decides what counts as "build" vs "query" via context-managed phases. + +Why a tracker module instead of LangFuse: + - LangFuse is heavy (network round-trips, project setup, env vars). + - For evals we just want a per-run integer total. A process-global dict + that records by ``(phase, user_id)`` is enough. + +Usage in eval: + + from mirix.database.token_tracker import set_phase, snapshot, reset + + reset() # at process start, optional + with set_phase("build"): + await client.add(...) # all server LLM calls recorded under "build" + with set_phase("query"): + await task_agent.answer(...) + stats = snapshot() # {(phase, user_id): {prompt, completion, total, calls}} + +Usage in call-sites (one-liner): + + from mirix.database.token_tracker import record + record(prompt_tokens=..., completion_tokens=...) + +Thread-safe via a single lock; concurrency-safe via contextvars for ``_phase_var``. +""" + +from __future__ import annotations + +import contextlib +import threading +from collections import defaultdict +from contextvars import ContextVar +from typing import Optional + +# Process-wide enable flag. Default OFF so the tracker is a true no-op for +# anyone not running an eval. Flip via enable()/disable() — typically called +# from an eval harness (see evals/main_eval.py) or from the +# /debug/token_stats/* REST endpoints. +_enabled: bool = False + +# Current logical phase, propagated through asyncio tasks via contextvar. +# When tracker is enabled and no explicit phase is set, falls back to "server". +# Evals call set_phase("build") or set_phase("query") to bucket more finely. +_phase_var: ContextVar[Optional[str]] = ContextVar("mirix_token_phase", default=None) + +# Stable buckets keyed by (phase, user_id). user_id is optional — calls from +# server endpoints that don't know the user just bucket as user_id="*". +_lock = threading.Lock() +_stats: dict[tuple[str, str], dict[str, int]] = defaultdict( + lambda: {"prompt": 0, "completion": 0, "total": 0, "calls": 0} +) + + +def enable() -> None: + """Turn the tracker on. ``record()`` becomes a real write after this.""" + global _enabled + _enabled = True + + +def disable() -> None: + """Turn the tracker off. ``record()`` becomes a no-op.""" + global _enabled + _enabled = False + + +def is_enabled() -> bool: + return _enabled + + +def record( + prompt_tokens: int = 0, + completion_tokens: int = 0, + total_tokens: Optional[int] = None, + user_id: str = "*", +) -> None: + """Add one OpenAI/Anthropic ``usage`` payload to the active phase bucket. + + No-op unless ``enable()`` has been called. Phase defaults to "server" + when enabled but no ``set_phase`` context is active. + Robust to ``None`` / negative inputs. + """ + if not _enabled: + return + phase = _phase_var.get() or "server" + p = max(int(prompt_tokens or 0), 0) + c = max(int(completion_tokens or 0), 0) + t = int(total_tokens) if total_tokens is not None else p + c + with _lock: + bucket = _stats[(phase, user_id)] + bucket["prompt"] += p + bucket["completion"] += c + bucket["total"] += t + bucket["calls"] += 1 + + + +def snapshot() -> dict[str, dict[str, int]]: + """Return a copy of current stats keyed by ``"phase|user_id"`` strings.""" + with _lock: + return {f"{phase}|{uid}": dict(v) for (phase, uid), v in _stats.items()} + + +def reset() -> None: + """Wipe all buckets. Use at the start of a fresh eval run.""" + with _lock: + _stats.clear() diff --git a/mirix/functions/function_sets/memory_tools.py b/mirix/functions/function_sets/memory_tools.py index 6260c67e4..b55921f98 100644 --- a/mirix/functions/function_sets/memory_tools.py +++ b/mirix/functions/function_sets/memory_tools.py @@ -26,6 +26,11 @@ logger = get_logger(__name__) +# NB: no module-level helper functions in this file — the tool schema generator +# scans every function defined here and requires tool-style type annotations. +# The merge-coverage gate helpers live in mirix.services.merge_coverage and are +# imported locally inside the tool bodies. + async def core_memory_append( self: "Agent", blocks_in_memory: "Memory", label: str, content: str @@ -183,6 +188,15 @@ async def episodic_memory_merge( Optional[str]: None is always returned as this function does not produce a response. """ + # Carry the ingest's source_meta (if any) through to update_event so + # the merged episodic event records the current chunk/turn as + # additional provenance. + _filter_tags = getattr(self, "filter_tags", None) or {} + _additional_source_ref = ( + dict(_filter_tags["source_meta"]) + if isinstance(_filter_tags.get("source_meta"), dict) + else None + ) try: episodic_memory = await self.episodic_memory_manager.update_event( event_id=event_id, @@ -191,6 +205,7 @@ async def episodic_memory_merge( actor=self.actor, agent_state=self.agent_state, update_mode="replace", + additional_source_ref=_additional_source_ref, ) except Exception as e: print( @@ -236,9 +251,23 @@ async def episodic_memory_replace(self: "Agent", event_ids: List[str], new_items if self.actor.organization_id is None: raise ValueError("Organization ID is required to access episodic memory") + old_texts = [] for event_id in event_ids: # It will raise an error if the event_id is not found in the episodic memory. - await self.episodic_memory_manager.get_episodic_memory_by_id(event_id, user=self.user) + ev = await self.episodic_memory_manager.get_episodic_memory_by_id(event_id, user=self.user) + if ev is not None: + old_texts.append(f"{ev.summary or ''}\n{ev.details or ''}") + + # Union-coverage gate: a consolidation merge must not destroy specifics. + # Raises (rejecting the whole call, BEFORE any deletion) if the replacement + # text drops a number/date/name present in the originals. + from mirix.services.merge_coverage import enforce_merge_coverage, merge_item_text + + enforce_merge_coverage( + self, old_texts, + [merge_item_text(ni, ("summary", "details")) for ni in new_items], + "episodic_memory_replace", + ) for event_id in event_ids: try: @@ -652,8 +681,12 @@ async def semantic_memory_insert(self: "Agent", items: List[SemanticMemoryItemBa agent_state=self.agent_state, agent_id=agent_id, name=item["name"], - summary=item["summary"], - details=item["details"], + summary=item.get("summary", ""), + details=item.get("details", ""), + # The LLM sometimes omits `source` (it is the least + # semantically essential field, and is sometimes folded + # into details). Default to "" so the whole item is not + # dropped over a missing provenance string. source=item.get("source", ""), organization_id=self.actor.organization_id, actor=self.actor, @@ -705,6 +738,25 @@ async def semantic_memory_update( client_id = getattr(self, "client_id", None) user_id = getattr(self, "user_id", None) + # Union-coverage gate (see episodic_memory_replace): fetch the old items' + # text and reject the call BEFORE any deletion if the replacement drops + # specifics. This function previously deleted without ever reading the old + # rows, so nothing could notice a lossy rewrite. + old_texts = [] + for old_id in old_semantic_item_ids: + it = await self.semantic_memory_manager.get_semantic_item_by_id( + old_id, user=self.user, timezone_str="UTC" + ) + if it is not None: + old_texts.append(f"{it.name or ''}\n{it.summary or ''}\n{it.details or ''}") + from mirix.services.merge_coverage import enforce_merge_coverage, merge_item_text + + enforce_merge_coverage( + self, old_texts, + [merge_item_text(ni, ("name", "summary", "details")) for ni in new_items], + "semantic_memory_update", + ) + for old_id in old_semantic_item_ids: try: await self.semantic_memory_manager.delete_semantic_item_by_id( @@ -853,6 +905,24 @@ async def knowledge_vault_update(self: "Agent", old_ids: List[str], new_items: L client_id = getattr(self, "client_id", None) user_id = getattr(self, "user_id", None) + # Union-coverage gate (see episodic_memory_replace). + kv_old_texts = [] + for old_id in old_ids: + it = await self.knowledge_vault_manager.get_item_by_id( + old_id, user=self.user, timezone_str="UTC" + ) + if it is not None: + kv_old_texts.append( + f"{getattr(it, 'caption', '') or ''}\n{getattr(it, 'secret_value', '') or ''}" + ) + from mirix.services.merge_coverage import enforce_merge_coverage, merge_item_text + + enforce_merge_coverage( + self, kv_old_texts, + [merge_item_text(ni, ("caption", "secret_value")) for ni in new_items], + "knowledge_vault_update", + ) + for old_id in old_ids: try: await self.knowledge_vault_manager.delete_knowledge_by_id( diff --git a/mirix/llm_api/openai.py b/mirix/llm_api/openai.py index 30c148fcb..7fffe64cc 100755 --- a/mirix/llm_api/openai.py +++ b/mirix/llm_api/openai.py @@ -536,6 +536,18 @@ async def openai_chat_completions_request( response_json = await make_post_request(url, headers, data) + # Record token usage for instrumented eval runs (no-op outside) + try: + from mirix.database.token_tracker import record as _record_tokens + usage = (response_json.get("usage") or {}) + _record_tokens( + prompt_tokens=usage.get("prompt_tokens", 0), + completion_tokens=usage.get("completion_tokens", 0), + total_tokens=usage.get("total_tokens"), + ) + except Exception: + pass + return ChatCompletionResponse(**response_json) diff --git a/mirix/orm/episodic_memory.py b/mirix/orm/episodic_memory.py index 341fcb1da..8d3f75b7e 100755 --- a/mirix/orm/episodic_memory.py +++ b/mirix/orm/episodic_memory.py @@ -85,6 +85,18 @@ class EpisodicEvent(SqlalchemyBase, OrganizationMixin, UserMixin): JSON, nullable=True, default=None, doc="Custom filter tags for filtering and categorization" ) + # Provenance pointers for this event. Same shape as the equivalent field + # on SemanticMemoryItem: a list of small dicts pointing back to the + # input units (turn_id / chunk_id / serial / occurred_at) that the + # event was extracted from. Empty when the legacy free-form ingest path + # is used. + source_refs: Mapped[list] = mapped_column( + JSON, + nullable=False, + default=list, + doc="Provenance pointers (turn_id / chunk_id / serial / occurred_at).", + ) + embedding_config: Mapped[Optional[dict]] = mapped_column( EmbeddingConfigColumn, nullable=True, doc="Embedding configuration" ) diff --git a/mirix/orm/semantic_memory.py b/mirix/orm/semantic_memory.py index 1ee83d578..8c8645dcc 100755 --- a/mirix/orm/semantic_memory.py +++ b/mirix/orm/semantic_memory.py @@ -75,6 +75,28 @@ class SemanticMemoryItem(SqlalchemyBase, OrganizationMixin, UserMixin): JSON, nullable=True, default=None, doc="Custom filter tags for filtering and categorization" ) + # Provenance pointers for this item. Each ref is a small dict like + # ``{"turn_id": int, "chunk_id": int, "serial": int, "occurred_at": iso8601}``. + # Only populated when the new conflict-resolution / provenance path is + # used; legacy free-form inserts leave it empty. + source_refs: Mapped[list] = mapped_column( + JSON, + nullable=False, + default=list, + doc="Provenance pointers (turn_id / chunk_id / serial / occurred_at) for this item.", + ) + + # Prior values that have been superseded by the current ``summary`` / + # ``details``. Used by the conflict-resolution path; legacy items keep + # this empty. Each entry: ``{"value": str, "source_refs": [...], + # "status": "superseded"|"corrected"|"coexists", "moved_at": iso8601}``. + prior_values: Mapped[list] = mapped_column( + JSON, + nullable=False, + default=list, + doc="History of replaced values from the conflict-resolution path.", + ) + # When was this item last modified and what operation? last_modify: Mapped[dict] = mapped_column( JSON, diff --git a/mirix/orm/user.py b/mirix/orm/user.py index 93d7d4e0c..ec11a7e97 100755 --- a/mirix/orm/user.py +++ b/mirix/orm/user.py @@ -20,6 +20,23 @@ class User(SqlalchemyBase, OrganizationMixin): status: Mapped[str] = mapped_column(nullable=False, doc="Whether the user is active or not.") timezone: Mapped[str] = mapped_column(nullable=False, doc="The timezone of the user.") is_admin: Mapped[bool] = mapped_column(nullable=False, default=False, doc="Whether this is an admin user.") + # Per-user monotonically-increasing counters used by the + # /memory/add fallback to fill in source_meta.turn_id and + # source_meta.chunk_id when the client does not provide them. Bumped + # atomically at /memory/add time. Used by the conflict-resolution + # path in `SemanticMemoryManager.insert_semantic_item` and by the + # general source-provenance mechanism documented in + # docs/mab_conflict_resolution_and_provenance.md. + turn_counter: Mapped[int] = mapped_column( + nullable=False, + default=0, + doc="Next turn_id to hand out for this user's next /memory/add request.", + ) + chunk_counter: Mapped[int] = mapped_column( + nullable=False, + default=0, + doc="Next chunk_id to hand out for this user's next /memory/add request.", + ) # relationships organization: Mapped["Organization"] = relationship("Organization", back_populates="users") diff --git a/mirix/prompts/lightrag_prompts.py b/mirix/prompts/lightrag_prompts.py new file mode 100644 index 000000000..432249c91 --- /dev/null +++ b/mirix/prompts/lightrag_prompts.py @@ -0,0 +1,174 @@ +""" +LightRAG prompt templates, adapted for MIRIX graph memory. + +Source: https://github.com/HKUDS/LightRAG (MIT License) — see prompt.py. +The structure (delimiters, system/user split, examples format) is preserved +verbatim so output parsers can be shared. Entity types are tuned for +MIRIX's conversational corpus (added "Date", "Quantity"; dropped +"NaturalObject" / "Artifact" which rarely appear in chat). +""" + +# Delimiters used inside extracted tuples. Must match the parser in +# lightrag_extractor._parse_extraction_output. +TUPLE_DELIMITER = "<|#|>" +COMPLETION_DELIMITER = "<|COMPLETE|>" + +# Default entity types — tuned for personal-assistant conversation memory. +DEFAULT_ENTITY_TYPES = [ + "Person", + "Organization", + "Location", + "Event", + "Concept", + "Method", + "Content", + "Date", + "Quantity", + "Other", +] + + +ENTITY_EXTRACTION_SYSTEM_PROMPT = """---Role--- +You are a Knowledge Graph Specialist responsible for extracting entities and relationships from the input text. + +---Instructions--- +1. **Entity Extraction & Output:** + * **Identification:** Identify clearly defined and meaningful entities in the input text. + * **Entity Details:** For each identified entity, extract the following information: + * `entity_name`: The name of the entity. If the entity name is case-insensitive, capitalize the first letter of each significant word (title case). Ensure **consistent naming** across the entire extraction process. + * `entity_type`: Categorize the entity using one of the following types: `{entity_types}`. If none of the provided entity types apply, do not add new entity type and classify it as `Other`. + * `entity_description`: Provide a concise yet comprehensive description of the entity's attributes and activities, based *solely* on the information present in the input text. + * **Output Format - Entities:** Output a total of 4 fields for each entity, delimited by `{tuple_delimiter}`, on a single line. The first field *must* be the literal string `entity`. + * Format: `entity{tuple_delimiter}entity_name{tuple_delimiter}entity_type{tuple_delimiter}entity_description` + +2. **Relationship Extraction & Output:** + * **Identification:** Identify direct, clearly stated, and meaningful relationships between previously extracted entities. + * **N-ary Relationship Decomposition:** If a single statement describes a relationship involving more than two entities (an N-ary relationship), decompose it into multiple binary (two-entity) relationship pairs for separate description. + * **Example:** For "Alice, Bob, and Carol collaborated on Project X," extract binary relationships such as "Alice collaborated with Project X," "Bob collaborated with Project X," and "Carol collaborated with Project X," or "Alice collaborated with Bob," based on the most reasonable binary interpretations. + * **Relationship Details:** For each binary relationship, extract the following fields: + * `source_entity`: The name of the source entity. Ensure **consistent naming** with entity extraction. Capitalize the first letter of each significant word (title case) if the name is case-insensitive. + * `target_entity`: The name of the target entity. Ensure **consistent naming** with entity extraction. Capitalize the first letter of each significant word (title case) if the name is case-insensitive. + * `relationship_keywords`: One or more high-level keywords summarizing the overarching nature, concepts, or themes of the relationship. Multiple keywords within this field must be separated by a comma `,`. **DO NOT use `{tuple_delimiter}` for separating multiple keywords within this field.** + * `relationship_description`: A concise explanation of the nature of the relationship between the source and target entities, providing a clear rationale for their connection. + * `relationship_strength`: A floating point value between 0.0 and 1.0 estimating how strong/important this relationship is. + * **Output Format - Relationships:** Output a total of 6 fields for each relationship, delimited by `{tuple_delimiter}`, on a single line. The first field *must* be the literal string `relation`. + * Format: `relation{tuple_delimiter}source_entity{tuple_delimiter}target_entity{tuple_delimiter}relationship_keywords{tuple_delimiter}relationship_description{tuple_delimiter}relationship_strength` + +3. **Delimiter Usage Protocol:** + * The `{tuple_delimiter}` is a complete, atomic marker and **must not be filled with content**. It serves strictly as a field separator. + * **Incorrect Example:** `entity{tuple_delimiter}Tokyo<|location|>Tokyo is the capital of Japan.` + * **Correct Example:** `entity{tuple_delimiter}Tokyo{tuple_delimiter}Location{tuple_delimiter}Tokyo is the capital of Japan.` + +4. **Relationship Direction & Duplication:** + * Treat all relationships as **undirected** unless explicitly stated otherwise. Swapping the source and target entities for an undirected relationship does not constitute a new relationship. + * Avoid outputting duplicate relationships. + +5. **Output Order & Prioritization:** + * Output all extracted entities first, followed by all extracted relationships. + * Within the list of relationships, prioritize and output those relationships that are **most significant** to the core meaning of the input text first. + +6. **Context & Objectivity:** + * Ensure all entity names and descriptions are written in the **third person**. + * Explicitly name the subject or object; **avoid using pronouns** such as `this article`, `this paper`, `our company`, `I`, `you`, and `he/she`. + +7. **Language & Proper Nouns:** + * The entire output (entity names, keywords, and descriptions) must be written in `{language}`. + * Proper nouns (e.g., personal names, place names, organization names) should be retained in their original language if a proper, widely accepted translation is not available or would cause ambiguity. + +8. **Completion Signal:** Output the literal string `{completion_delimiter}` only after all entities and relationships, following all criteria, have been completely extracted and outputted. + +---Examples--- +{examples} +""" + + +ENTITY_EXTRACTION_USER_PROMPT = """---Task--- +Extract entities and relationships from the input text in Data to be Processed below. + +---Instructions--- +1. **Strict Adherence to Format:** Strictly adhere to all format requirements for entity and relationship lists, including output order, field delimiters, and proper noun handling, as specified in the system prompt. +2. **Output Content Only:** Output *only* the extracted list of entities and relationships. Do not include any introductory or concluding remarks, explanations, or additional text before or after the list. +3. **Completion Signal:** Output `{completion_delimiter}` as the final line after all relevant entities and relationships have been extracted and presented. +4. **Output Language:** Ensure the output language is {language}. Proper nouns (e.g., personal names, place names, organization names) must be kept in their original language and not translated. + +---Data to be Processed--- + +[{entity_types}] + + +``` +{input_text} +``` + + +""" + + +# A single conversational example to keep the system prompt small. Adding more +# examples helps consistency but inflates the prompt cost on every chunk. +ENTITY_EXTRACTION_EXAMPLES = [ + """ +["Person","Organization","Location","Event","Concept","Method","Content","Date","Quantity","Other"] + + +``` +Caroline mentioned that her cousin Melanie just moved to Berlin to start a job at SAP last month. They used to live together in Munich while Caroline was finishing her PhD on quantum optics. +``` + + +entity{tuple_delimiter}Caroline{tuple_delimiter}Person{tuple_delimiter}Caroline is the speaker; she previously lived in Munich while pursuing a PhD on quantum optics. +entity{tuple_delimiter}Melanie{tuple_delimiter}Person{tuple_delimiter}Melanie is Caroline's cousin who recently moved to Berlin to start a job at SAP. +entity{tuple_delimiter}Berlin{tuple_delimiter}Location{tuple_delimiter}Berlin is the city Melanie moved to for her new job at SAP. +entity{tuple_delimiter}Munich{tuple_delimiter}Location{tuple_delimiter}Munich is the city where Caroline and Melanie used to live together while Caroline was a PhD student. +entity{tuple_delimiter}SAP{tuple_delimiter}Organization{tuple_delimiter}SAP is the organization where Melanie recently started working. +entity{tuple_delimiter}Quantum Optics{tuple_delimiter}Concept{tuple_delimiter}Quantum optics is the subject of Caroline's PhD research. +relation{tuple_delimiter}Caroline{tuple_delimiter}Melanie{tuple_delimiter}family relation, cohabitation{tuple_delimiter}Caroline and Melanie are cousins who previously lived together in Munich.{tuple_delimiter}0.9 +relation{tuple_delimiter}Melanie{tuple_delimiter}Berlin{tuple_delimiter}relocation, residence{tuple_delimiter}Melanie recently moved to Berlin.{tuple_delimiter}0.8 +relation{tuple_delimiter}Melanie{tuple_delimiter}SAP{tuple_delimiter}employment, new job{tuple_delimiter}Melanie started a job at SAP.{tuple_delimiter}0.85 +relation{tuple_delimiter}Caroline{tuple_delimiter}Munich{tuple_delimiter}past residence, education{tuple_delimiter}Caroline lived in Munich while completing her PhD.{tuple_delimiter}0.7 +relation{tuple_delimiter}Caroline{tuple_delimiter}Quantum Optics{tuple_delimiter}academic research, PhD topic{tuple_delimiter}Caroline pursued a PhD on quantum optics.{tuple_delimiter}0.8 +{completion_delimiter} +""", +] + + + + + + + +def render_extraction_system_prompt( + entity_types: list[str] | None = None, + language: str = "English", +) -> str: + """Render the system prompt with entity types and example bodies inlined.""" + types = entity_types or DEFAULT_ENTITY_TYPES + types_str = ", ".join(types) + example_ctx = { + "tuple_delimiter": TUPLE_DELIMITER, + "completion_delimiter": COMPLETION_DELIMITER, + } + examples = "\n".join(ex.format(**example_ctx) for ex in ENTITY_EXTRACTION_EXAMPLES) + return ENTITY_EXTRACTION_SYSTEM_PROMPT.format( + entity_types=types_str, + tuple_delimiter=TUPLE_DELIMITER, + completion_delimiter=COMPLETION_DELIMITER, + language=language, + examples=examples, + ) + + +def render_extraction_user_prompt( + input_text: str, + entity_types: list[str] | None = None, + language: str = "English", +) -> str: + types = entity_types or DEFAULT_ENTITY_TYPES + return ENTITY_EXTRACTION_USER_PROMPT.format( + entity_types=", ".join(types), + completion_delimiter=COMPLETION_DELIMITER, + language=language, + input_text=input_text, + ) + + diff --git a/mirix/prompts/system/base/auto_dream_agent/episodic.txt b/mirix/prompts/system/base/auto_dream_agent/episodic.txt index 8ad4285ad..d585a9819 100644 --- a/mirix/prompts/system/base/auto_dream_agent/episodic.txt +++ b/mirix/prompts/system/base/auto_dream_agent/episodic.txt @@ -9,3 +9,10 @@ Rules: - Prefer merging over deletion unless an entry is a pure duplicate. - If two events conflict and the correct answer is unclear, keep the uncertainty in the merged details. - Call finish_memory_update when done. + +CRITICAL — MERGING REMOVES REDUNDANT WORDING, NEVER FACTS: +- The merged item MUST contain, VERBATIM, every specific from ALL the originals: each named entity (person, place, venue, brand, product, band, event, title), and every date, number, price, quantity, duration, and measurement. +- Only collapse text that restates the SAME fact. If two entries each carry distinct specifics, the merged entry keeps BOTH sets — do not summarise them into a vaguer sentence. +- A specific is NOT redundant just because it looks minor. "Imagine Dragons at Xfinity Center on June 15th", "Spanish classes", "$420", "7 shirts" — these are exactly what later questions ask about. Dropping one is a failure, not a simplification. +- Prefer keeping the union of details over a shorter summary. When unsure whether to keep a detail, KEEP IT. +- For countable things (how many doctors/bikes/classes), preserve each distinct instance so the count is still recoverable after merging. diff --git a/mirix/prompts/system/base/auto_dream_agent/experience.txt b/mirix/prompts/system/base/auto_dream_agent/experience.txt index 368f19085..80b607a57 100644 --- a/mirix/prompts/system/base/auto_dream_agent/experience.txt +++ b/mirix/prompts/system/base/auto_dream_agent/experience.txt @@ -19,3 +19,10 @@ Rules: - Use batch updates where possible. - Do not process procedural, resource, or core memory in this mode. - Call finish_memory_update when done. + +CRITICAL — MERGING REMOVES REDUNDANT WORDING, NEVER FACTS: +- The merged item MUST contain, VERBATIM, every specific from ALL the originals: each named entity (person, place, venue, brand, product, band, event, title), and every date, number, price, quantity, duration, and measurement. +- Only collapse text that restates the SAME fact. If two entries each carry distinct specifics, the merged entry keeps BOTH sets — do not summarise them into a vaguer sentence. +- A specific is NOT redundant just because it looks minor. "Imagine Dragons at Xfinity Center on June 15th", "Spanish classes", "$420", "7 shirts" — these are exactly what later questions ask about. Dropping one is a failure, not a simplification. +- Prefer keeping the union of details over a shorter summary. When unsure whether to keep a detail, KEEP IT. +- For countable things (how many doctors/bikes/classes), preserve each distinct instance so the count is still recoverable after merging. diff --git a/mirix/prompts/system/base/auto_dream_agent/semantic.txt b/mirix/prompts/system/base/auto_dream_agent/semantic.txt index 95167af98..f435a65e1 100644 --- a/mirix/prompts/system/base/auto_dream_agent/semantic.txt +++ b/mirix/prompts/system/base/auto_dream_agent/semantic.txt @@ -9,3 +9,10 @@ Rules: - Prefer merging over deletion unless an entry is a pure duplicate. - If conflicting facts are unresolved, keep the discrepancy in details. - Call finish_memory_update when done. + +CRITICAL — MERGING REMOVES REDUNDANT WORDING, NEVER FACTS: +- The merged item MUST contain, VERBATIM, every specific from ALL the originals: each named entity (person, place, venue, brand, product, band, event, title), and every date, number, price, quantity, duration, and measurement. +- Only collapse text that restates the SAME fact. If two entries each carry distinct specifics, the merged entry keeps BOTH sets — do not summarise them into a vaguer sentence. +- A specific is NOT redundant just because it looks minor. "Imagine Dragons at Xfinity Center on June 15th", "Spanish classes", "$420", "7 shirts" — these are exactly what later questions ask about. Dropping one is a failure, not a simplification. +- Prefer keeping the union of details over a shorter summary. When unsure whether to keep a detail, KEEP IT. +- For countable things (how many doctors/bikes/classes), preserve each distinct instance so the count is still recoverable after merging. diff --git a/mirix/schemas/agent.py b/mirix/schemas/agent.py index bdc0a1858..8446d8a2c 100755 --- a/mirix/schemas/agent.py +++ b/mirix/schemas/agent.py @@ -283,6 +283,15 @@ class CreateMetaAgent(BaseModel): None, description="Embedding configuration for memory agents. Required if no default is set.", ) + enable_conflict_resolution: bool = Field( + False, + description=( + "Opt in to the deterministic conflict-resolution + source-provenance path. " + "When True, the semantic_memory_agent's system prompt is augmented to " + "prefer the new `semantic_memory_upsert_fact` tool for triple-shaped " + "facts. See docs/mab_conflict_resolution_and_provenance.md." + ), + ) class UpdateMetaAgent(BaseModel): @@ -308,7 +317,6 @@ class UpdateMetaAgent(BaseModel): None, description="Embedding configuration for meta agent and its sub-agents.", ) - class Config: extra = "ignore" # Ignores extra fields diff --git a/mirix/schemas/auto_dream.py b/mirix/schemas/auto_dream.py index 78d0870ba..51c39ef81 100644 --- a/mirix/schemas/auto_dream.py +++ b/mirix/schemas/auto_dream.py @@ -18,6 +18,16 @@ class AutoDreamRequest(BaseModel): ), ) dry_run: bool = Field(False, description="If true, return plan without applying changes") + graph_only: bool = Field( + False, + description=( + "If true, skip the LLM memory-merge pass entirely and only refine the graph " + "(maintenance + semantic reconsolidation). The flat PG memories — their ids, " + "content and embeddings — are left untouched, so flat retrieval is unchanged; " + "only the hypergraph structure is consolidated. This avoids the retrieval " + "degradation that memory merging causes on fact-recall QA." + ), + ) model: Optional[str] = Field(None, description="Override LLM model (e.g. gpt-4.1-mini for testing)") @field_validator("mode") diff --git a/mirix/schemas/episodic_memory.py b/mirix/schemas/episodic_memory.py index 5b0b9ade2..56588d63c 100755 --- a/mirix/schemas/episodic_memory.py +++ b/mirix/schemas/episodic_memory.py @@ -1,137 +1,144 @@ -from datetime import datetime -from typing import Any, Dict, List, Optional - -from pydantic import Field, field_validator - -from mirix.client.utils import get_utc_time -from mirix.constants import MAX_EMBEDDING_DIM -from mirix.schemas.embedding_config import EmbeddingConfig -from mirix.schemas.mirix_base import MirixBase - - -class EpisodicEventBase(MirixBase): - """ - Base schema for episodic memory events containing common fields. - """ - - __id_prefix__ = "ep_mem" - event_type: str = Field( - ..., - description="Type/category of the episodic event (e.g., user_message, inference, system_notification)", - ) - summary: str = Field(..., description="Short textual summary of the event") - details: str = Field(..., description="Detailed description or text for the event") - actor: str = Field(..., description="The actor who generated the event (user or assistant)") - - -class EpisodicEventForLLM(EpisodicEventBase): - """ - Schema for creating a new episodic memory record. - """ - - # TODO: make `occurred_at` optional - occurred_at: str = Field( - ..., - description="When the event happened (it should be mentioned in the user's response and it should be in the format of 'YYYY-MM-DD HH:MM:SS')", - ) - - -class EpisodicEvent(EpisodicEventBase): - """ - Representation of a single episodic memory event in the system. - - Additional Parameters: - id (str): Unique identifier for this memory item - occurred_at (datetime): When the event occurred or was recorded - created_at (datetime): When the memory record was created in the system - updated_at (Optional[datetime]): Last update timestamp - """ - - id: Optional[str] = Field(None, description="Unique identifier for the episodic event") - - agent_id: Optional[str] = Field(None, description="The id of the agent this episodic event belongs to") - - client_id: Optional[str] = Field(None, description="The id of the client application that created this event") - - user_id: str = Field(..., description="The id of the user who generated the episodic event") - - occurred_at: datetime = Field( - default_factory=get_utc_time, - description="When the event actually happened (recorded or user-labeled).", - ) - created_at: datetime = Field( - default_factory=get_utc_time, - description="Timestamp when this memory record was created", - ) - updated_at: Optional[datetime] = Field(None, description="When this memory record was last updated") - last_modify: Dict[str, Any] = Field( - default_factory=lambda: { - "timestamp": get_utc_time().isoformat(), - "operation": "created", - }, - description="Last modification info including timestamp and operation type", - ) - organization_id: str = Field(..., description="Unique identifier of the organization") - details_embedding: Optional[List[float]] = Field(None, description="The embedding of the event") - summary_embedding: Optional[List[float]] = Field(None, description="The embedding of the summary") - embedding_config: Optional[EmbeddingConfig] = Field( - None, description="The embedding configuration used by the event" - ) - - # NEW: Filter tags for flexible filtering and categorization - filter_tags: Optional[Dict[str, Any]] = Field( - default=None, - description="Custom filter tags for filtering and categorization", - examples=[ - {"project_id": "proj-abc", "session_id": "sess-xyz", "tags": ["important", "work"], "priority": "high"} - ], - ) - - # need to validate both details_embedding and summary_embedding to ensure they are the same size - @field_validator("details_embedding", "summary_embedding") - @classmethod - def pad_embeddings(cls, embedding: List[float]) -> List[float]: - """Pad embeddings to `MAX_EMBEDDING_SIZE`. This is necessary to ensure all stored embeddings are the same size.""" - import numpy as np - - if embedding and len(embedding) != MAX_EMBEDDING_DIM: - np_embedding = np.array(embedding) - padded_embedding = np.pad( - np_embedding, - (0, MAX_EMBEDDING_DIM - np_embedding.shape[0]), - mode="constant", - ) - return padded_embedding.tolist() - return embedding - - -class EpisodicEventUpdate(MirixBase): - """ - Schema for updating an existing episodic memory record. - - All fields (except id) are optional so that only provided fields are updated. - """ - - id: str = Field(..., description="Unique ID for this episodic memory record") - agent_id: Optional[str] = Field(None, description="The id of the agent this episodic event belongs to") - event_type: Optional[str] = Field(None, description="Type/category of the event") - summary: Optional[str] = Field(None, description="Short textual summary of the event") - details: Optional[str] = Field(None, description="Detailed text describing the event") - organization_id: Optional[str] = Field(None, description="Unique identifier of the organization") - occurred_at: Optional[datetime] = Field(None, description="If the event's time is updated") - updated_at: datetime = Field( - default_factory=get_utc_time, - description="Timestamp when this memory record was last updated", - ) - last_modify: Optional[Dict[str, Any]] = Field( - None, - description="Last modification info including timestamp and operation type", - ) - summary_embedding: Optional[List[float]] = Field(None, description="The embedding of the summary") - details_embedding: Optional[List[float]] = Field(None, description="The embedding of the event") - embedding_config: Optional[EmbeddingConfig] = Field( - None, description="The embedding configuration used by the event" - ) - filter_tags: Optional[Dict[str, Any]] = Field( - None, description="Custom filter tags for filtering and categorization" - ) +from datetime import datetime +from typing import Any, Dict, List, Optional + +from pydantic import Field, field_validator + +from mirix.client.utils import get_utc_time +from mirix.constants import MAX_EMBEDDING_DIM +from mirix.schemas.embedding_config import EmbeddingConfig +from mirix.schemas.mirix_base import MirixBase + + +class EpisodicEventBase(MirixBase): + """ + Base schema for episodic memory events containing common fields. + """ + + __id_prefix__ = "ep_mem" + event_type: str = Field( + ..., + description="Type/category of the episodic event (e.g., user_message, inference, system_notification)", + ) + summary: str = Field(..., description="Short textual summary of the event") + details: str = Field(..., description="Detailed description or text for the event") + actor: str = Field(..., description="The actor who generated the event (user or assistant)") + + +class EpisodicEventForLLM(EpisodicEventBase): + """ + Schema for creating a new episodic memory record. + """ + + # TODO: make `occurred_at` optional + occurred_at: str = Field( + ..., + description="When the event happened (it should be mentioned in the user's response and it should be in the format of 'YYYY-MM-DD HH:MM:SS')", + ) + + +class EpisodicEvent(EpisodicEventBase): + """ + Representation of a single episodic memory event in the system. + + Additional Parameters: + id (str): Unique identifier for this memory item + occurred_at (datetime): When the event occurred or was recorded + created_at (datetime): When the memory record was created in the system + updated_at (Optional[datetime]): Last update timestamp + """ + + id: Optional[str] = Field(None, description="Unique identifier for the episodic event") + + agent_id: Optional[str] = Field(None, description="The id of the agent this episodic event belongs to") + + client_id: Optional[str] = Field(None, description="The id of the client application that created this event") + + user_id: str = Field(..., description="The id of the user who generated the episodic event") + + occurred_at: datetime = Field( + default_factory=get_utc_time, + description="When the event actually happened (recorded or user-labeled).", + ) + created_at: datetime = Field( + default_factory=get_utc_time, + description="Timestamp when this memory record was created", + ) + updated_at: Optional[datetime] = Field(None, description="When this memory record was last updated") + last_modify: Dict[str, Any] = Field( + default_factory=lambda: { + "timestamp": get_utc_time().isoformat(), + "operation": "created", + }, + description="Last modification info including timestamp and operation type", + ) + organization_id: str = Field(..., description="Unique identifier of the organization") + details_embedding: Optional[List[float]] = Field(None, description="The embedding of the event") + summary_embedding: Optional[List[float]] = Field(None, description="The embedding of the summary") + embedding_config: Optional[EmbeddingConfig] = Field( + None, description="The embedding configuration used by the event" + ) + + # NEW: Filter tags for flexible filtering and categorization + filter_tags: Optional[Dict[str, Any]] = Field( + default=None, + description="Custom filter tags for filtering and categorization", + examples=[ + {"project_id": "proj-abc", "session_id": "sess-xyz", "tags": ["important", "work"], "priority": "high"} + ], + ) + + # Provenance pointers for this event. Same shape as the equivalent field + # on SemanticMemoryItem; see `mirix.orm.episodic_memory`. + source_refs: List[Dict[str, Any]] = Field( + default_factory=list, + description="Provenance pointers (turn_id / chunk_id / serial / occurred_at).", + ) + + # need to validate both details_embedding and summary_embedding to ensure they are the same size + @field_validator("details_embedding", "summary_embedding") + @classmethod + def pad_embeddings(cls, embedding: List[float]) -> List[float]: + """Pad embeddings to `MAX_EMBEDDING_SIZE`. This is necessary to ensure all stored embeddings are the same size.""" + import numpy as np + + if embedding and len(embedding) != MAX_EMBEDDING_DIM: + np_embedding = np.array(embedding) + padded_embedding = np.pad( + np_embedding, + (0, MAX_EMBEDDING_DIM - np_embedding.shape[0]), + mode="constant", + ) + return padded_embedding.tolist() + return embedding + + +class EpisodicEventUpdate(MirixBase): + """ + Schema for updating an existing episodic memory record. + + All fields (except id) are optional so that only provided fields are updated. + """ + + id: str = Field(..., description="Unique ID for this episodic memory record") + agent_id: Optional[str] = Field(None, description="The id of the agent this episodic event belongs to") + event_type: Optional[str] = Field(None, description="Type/category of the event") + summary: Optional[str] = Field(None, description="Short textual summary of the event") + details: Optional[str] = Field(None, description="Detailed text describing the event") + organization_id: Optional[str] = Field(None, description="Unique identifier of the organization") + occurred_at: Optional[datetime] = Field(None, description="If the event's time is updated") + updated_at: datetime = Field( + default_factory=get_utc_time, + description="Timestamp when this memory record was last updated", + ) + last_modify: Optional[Dict[str, Any]] = Field( + None, + description="Last modification info including timestamp and operation type", + ) + summary_embedding: Optional[List[float]] = Field(None, description="The embedding of the summary") + details_embedding: Optional[List[float]] = Field(None, description="The embedding of the event") + embedding_config: Optional[EmbeddingConfig] = Field( + None, description="The embedding configuration used by the event" + ) + filter_tags: Optional[Dict[str, Any]] = Field( + None, description="Custom filter tags for filtering and categorization" + ) diff --git a/mirix/schemas/semantic_memory.py b/mirix/schemas/semantic_memory.py index 428d3061e..dc8d50007 100755 --- a/mirix/schemas/semantic_memory.py +++ b/mirix/schemas/semantic_memory.py @@ -59,6 +59,29 @@ class SemanticMemoryItem(SemanticMemoryItemBase): ], ) + # Provenance pointers for this item. See `mirix.orm.semantic_memory`. + source_refs: List[Dict[str, Any]] = Field( + default_factory=list, + description=( + "Provenance pointers for the input units this item was extracted from. " + "Each entry is a small dict like " + "{'turn_id': int, 'chunk_id': int, 'serial': int, 'occurred_at': iso8601}; " + "any subset of those keys may be present. Populated only by the " + "conflict-resolution / provenance path; legacy free-form inserts leave it empty." + ), + ) + + # Prior values that have been superseded by the current ``summary`` / ``details``. + prior_values: List[Dict[str, Any]] = Field( + default_factory=list, + description=( + "History of replaced values from the conflict-resolution path. " + "Each entry: {'value': str, 'source_refs': list, " + "'status': 'superseded'|'corrected'|'coexists', 'moved_at': iso8601}. " + "Empty for legacy items." + ), + ) + # need to validate both details_embedding and summary_embedding to ensure they are the same size @field_validator("details_embedding", "summary_embedding", "name_embedding") @classmethod @@ -110,6 +133,12 @@ class SemanticMemoryItemUpdate(MirixBase): filter_tags: Optional[Dict[str, Any]] = Field( None, description="Custom filter tags for filtering and categorization" ) + source_refs: Optional[List[Dict[str, Any]]] = Field( + None, description="Replace the item's source_refs list (conflict-resolution path)." + ) + prior_values: Optional[List[Dict[str, Any]]] = Field( + None, description="Replace the item's prior_values list (conflict-resolution path)." + ) class SemanticMemoryItemResponse(SemanticMemoryItem): diff --git a/mirix/schemas/user.py b/mirix/schemas/user.py index a631135a7..13b9d9e35 100755 --- a/mirix/schemas/user.py +++ b/mirix/schemas/user.py @@ -50,6 +50,21 @@ class User(UserBase): created_at: Optional[datetime] = Field(default_factory=get_utc_time, description="The creation date of the user.") updated_at: Optional[datetime] = Field(default_factory=get_utc_time, description="The update date of the user.") is_deleted: bool = Field(default=False, description="Whether this user is deleted or not.") + turn_counter: int = Field( + default=0, + description=( + "Next turn_id to hand out for this user's next /memory/add request. " + "Used by the source-provenance fallback in conflict resolution; the " + "server bumps this atomically when assigning fallback turn_ids." + ), + ) + chunk_counter: int = Field( + default=0, + description=( + "Next chunk_id to hand out for this user's next /memory/add request. " + "Same provenance fallback as turn_counter." + ), + ) class UserCreate(UserBase): diff --git a/mirix/server/rest_api.py b/mirix/server/rest_api.py index 6c7e0b4e7..98221ef08 100644 --- a/mirix/server/rest_api.py +++ b/mirix/server/rest_api.py @@ -8,6 +8,7 @@ import copy import functools import json +import os import traceback from contextlib import asynccontextmanager from datetime import datetime @@ -109,6 +110,14 @@ async def initialize(): except Exception as e: logger.warning("Redis async init failed: %s", e) + # Initialize Neo4j driver if graph memory is enabled. No-op otherwise. + try: + from mirix.database.neo4j_client import init_neo4j_client + + await init_neo4j_client() + except Exception as e: + logger.warning("Neo4j init failed: %s — graph memory will be unavailable", e) + # Initialize AsyncServer (singleton) and create default org/user/client server = get_server() await server.ensure_defaults() @@ -155,6 +164,14 @@ async def cleanup(): await queue_manager.cleanup() logger.info("Queue service stopped") + # Close Neo4j driver if initialized + try: + from mirix.database.neo4j_client import close_neo4j_driver + + await close_neo4j_driver() + except Exception as e: + logger.warning("Error closing Neo4j driver: %s", e) + @asynccontextmanager async def lifespan(app: FastAPI): @@ -416,7 +433,21 @@ async def extract_topics_and_temporal_info( new_messages = [] for msg in messages: prefix = "[USER]" if msg["role"] == "user" else "[ASSISTANT]" - new_messages.extend([{"type": "text", "text": prefix + " " + part} for part in msg["content"]]) + content = msg["content"] + # content may be a bare string, a list of strings, or the + # multimodal list-of-dicts format ([{"type": "text", "text": ...}]). + if isinstance(content, str): + parts = [content] + elif isinstance(content, list): + parts = [ + part.get("text", "") if isinstance(part, dict) else str(part) + for part in content + ] + else: + parts = [str(content)] + new_messages.extend( + [{"type": "text", "text": prefix + " " + part} for part in parts if part] + ) messages = new_messages temporary_messages = convert_message_to_mirix_message(messages) @@ -677,6 +708,34 @@ async def health_check(): return {"status": "healthy", "service": "mirix-api"} +@router.get("/debug/token_stats") +async def debug_token_stats(): + """Return cumulative LLM token usage recorded server-side since last reset. + + Tracker is off by default; only counts data after a POST to + /debug/token_stats/reset (which enables it). + """ + from mirix.database.token_tracker import is_enabled, snapshot + return {"enabled": is_enabled(), "stats": snapshot()} + + +@router.post("/debug/token_stats/reset") +async def debug_token_stats_reset(): + """Wipe counters and enable the tracker. Idempotent.""" + from mirix.database.token_tracker import enable, reset + reset() + enable() + return {"status": "reset", "enabled": True} + + +@router.post("/debug/token_stats/disable") +async def debug_token_stats_disable(): + """Turn the tracker off (recording becomes a no-op again).""" + from mirix.database.token_tracker import disable + disable() + return {"status": "disabled"} + + # ============================================================================ # Agent Endpoints # ============================================================================ @@ -1940,6 +1999,10 @@ async def initialize_meta_agent( create_params["agents"] = meta_config["agents"] if "system_prompts" in meta_config: create_params["system_prompts"] = meta_config["system_prompts"] + if "enable_conflict_resolution" in meta_config: + create_params["enable_conflict_resolution"] = bool( + meta_config["enable_conflict_resolution"] + ) # Check if meta agent already exists for this client # list_agents now automatically filters by client (organization_id + _created_by_id) @@ -1981,6 +2044,68 @@ async def initialize_meta_agent( return meta_agent +async def _augment_source_meta_with_server_fallbacks( + filter_tags: dict, + user_id: str, + n_turns: int, + request_occurred_at: Optional[str], + server: AsyncServer, +) -> None: + """Mutate ``filter_tags`` in place so it carries a ``source_meta`` dict + with at least ``turn_id``, ``chunk_id``, and ``occurred_at`` set. + + Policy: + + 1. Anything the client already put in ``filter_tags["source_meta"]`` + wins. This lets callers with domain knowledge (e.g. the MAB + adapter which knows the serial range of a chunk) carry their + fields through unchanged. + 2. Fields the client did NOT set get filled from the server: + - ``turn_id`` : next per-user counter (one per input message) + - ``chunk_id`` : next per-user counter (one per /memory/add call) + - ``occurred_at`` : the request's ``occurred_at`` if provided, + else server wall-clock ISO 8601. + 3. ``serial`` is never auto-filled. It is a domain-specific signal + (e.g. FactConsolidation's numbered fact list) and only present + when the caller explicitly set it. + + This is the single point that makes conflict resolution + source + provenance general: every ``/memory/add`` (sync or async) ends up + with the same ``source_meta`` contract, regardless of which client + sent it. + """ + from datetime import timezone as _dt_tz + + existing = filter_tags.get("source_meta") + if not isinstance(existing, dict): + existing = {} + else: + existing = dict(existing) # don't mutate the caller's dict + + needs_turn = "turn_id" not in existing + needs_chunk = "chunk_id" not in existing + if needs_turn or needs_chunk: + reserved = await server.user_manager.reserve_source_ids( + user_id=user_id, n_turns=max(n_turns, 1) + ) + if needs_turn: + # For a multi-message batch we record the *first* turn_id of + # the batch; the agent is free to walk the message list if it + # needs per-message granularity. Single-message ingests are + # the common case and this is exact. + existing["turn_id"] = reserved["turn_id_start"] + if needs_chunk: + existing["chunk_id"] = reserved["chunk_id"] + + if "occurred_at" not in existing: + if request_occurred_at: + existing["occurred_at"] = request_occurred_at + else: + existing["occurred_at"] = datetime.now(_dt_tz.utc).isoformat() + + filter_tags["source_meta"] = existing + + class AddMemoryRequest(BaseModel): """Request model for adding memory.""" @@ -2091,6 +2216,20 @@ async def add_memory( raise HTTPException(status_code=403, detail="Client has no write_scope - cannot create memories") filter_tags["scope"] = client.write_scope + # Merge client-provided source_meta with server-side fallbacks (turn_id, + # chunk_id, occurred_at). This is what makes conflict resolution + + # source provenance general: clients with their own source knowledge + # (e.g. the MAB adapter knows the chunk's serial range) keep what they + # passed; clients that pass nothing still get turn_id / chunk_id / + # occurred_at auto-filled from the server. + await _augment_source_meta_with_server_fallbacks( + filter_tags=filter_tags, + user_id=user_id, + n_turns=len(input_messages), + request_occurred_at=request.occurred_at, + server=server, + ) + # Queue for async processing instead of synchronous execution # Note: actor is Client for org-level access control # user_id represents the actual end-user (or admin user if not provided) @@ -2119,6 +2258,24 @@ async def add_memory( } +class CompactGraphRequest(BaseModel): + """Request model for the v8 graph-compaction finalize step.""" + + user_id: str + + +@router.post("/memory/graph/compact") +async def compact_graph_memory(request: CompactGraphRequest): + """v8 finalize: prune singleton (degree-1) anchors from a user's graph. + + No-op unless MIRIX_GRAPH_VERSION == 'v8'. Call once after all of a user's + memories have been ingested (an anchor's final degree is only known then). + """ + from mirix.services.graph_memory_manager_v7 import V7GraphManager + + return await V7GraphManager().prune_singletons(request.user_id) + + @router.post("/memory/add_sync") @with_langfuse_tracing async def add_memory_sync( @@ -2179,6 +2336,16 @@ async def add_memory_sync( raise HTTPException(status_code=403, detail="Client has no write_scope - cannot create memories") filter_tags["scope"] = client.write_scope + # Same server-side source_meta fallback as the async path; see helper + # docstring for details. + await _augment_source_meta_with_server_fallbacks( + filter_tags=filter_tags, + user_id=user_id, + n_turns=len(input_messages), + request_occurred_at=request.occurred_at, + server=server, + ) + from mirix.services.user_manager import UserManager user_manager = UserManager() @@ -2287,97 +2454,128 @@ async def retrieve_memories_by_keywords( timezone_str = "UTC" memories = {} - # Get episodic memories (recent + relevant) with optional temporal filtering - try: - episodic_manager = server.episodic_memory_manager + # LightRAG-style dual-level graph retrieval (P3). Supplements flat memory + # retrieval with KG entities/relations + episodic chunks. Returns an empty + # context string when no hits — caller is robust to that. + if settings.enable_graph_memory: + try: + from mirix.services.graph_retriever_dispatcher import GraphRetrieverDispatcher - # Get recent episodic memories with temporal filter - recent_episodic = await episodic_manager.list_episodic_memory( - agent_state=agent_state, # Not accessed during BM25 search - user=user, - limit=limit, - timezone_str=timezone_str, - filter_tags=filter_tags, - scopes=scopes, - use_cache=use_cache, - start_date=start_date, - end_date=end_date, - ) + logger.info( + "Graph retrieve: user_id=%s, key_words=%r (len=%d)", + user_id, (key_words or "")[:120], len(key_words or ""), + ) + graph_context = await GraphRetrieverDispatcher().retrieve( + query=key_words, + user_id=user_id, + agent_state=agent_state, + ) + logger.info("Graph retrieve result: ctx_len=%d", len(graph_context or "")) + if graph_context: + memories["graph"] = {"context": graph_context} + except Exception as e: + logger.error("Graph retrieval failed: %s", e, exc_info=True) + + # v5: when graph memory is enabled, episodic + semantic retrieval is served + # entirely by the dual-graph retriever above. The flat episodic/semantic + # search below is skipped (kept as a fallback for graph-disabled mode). + # The other four memory types (resource / procedural / knowledge_vault / + # core) have no graph counterpart and are always retrieved flat. + if settings.enable_graph_memory: + memories["episodic"] = {"total_count": 0, "recent": [], "relevant": []} + memories["semantic"] = {"total_count": 0, "items": []} + else: + # Get episodic memories (recent + relevant) with optional temporal filtering + try: + episodic_manager = server.episodic_memory_manager - # Get relevant episodic memories based on keywords with temporal filter - relevant_episodic = [] - if key_words: - relevant_episodic = await episodic_manager.list_episodic_memory( + # Get recent episodic memories with temporal filter + recent_episodic = await episodic_manager.list_episodic_memory( agent_state=agent_state, # Not accessed during BM25 search user=user, - query=key_words, - search_field="details", - search_method=search_method, limit=limit, timezone_str=timezone_str, filter_tags=filter_tags, scopes=scopes, + use_cache=use_cache, start_date=start_date, end_date=end_date, ) - memories["episodic"] = { - "total_count": await episodic_manager.get_total_number_of_items(user=user), - "recent": [ - { - "id": event.id, - "timestamp": (event.occurred_at.isoformat() if event.occurred_at else None), - "summary": event.summary, - "details": event.details, - } - for event in recent_episodic - ], - "relevant": [ - { - "id": event.id, - "timestamp": (event.occurred_at.isoformat() if event.occurred_at else None), - "summary": event.summary, - "details": event.details, - } - for event in relevant_episodic - ], - } - except Exception as e: - logger.error("Error retrieving episodic memories: %s", e) - memories["episodic"] = {"total_count": 0, "recent": [], "relevant": []} + # Get relevant episodic memories based on keywords with temporal filter + relevant_episodic = [] + if key_words: + relevant_episodic = await episodic_manager.list_episodic_memory( + agent_state=agent_state, # Not accessed during BM25 search + user=user, + query=key_words, + search_field="details", + search_method=search_method, + limit=limit, + timezone_str=timezone_str, + filter_tags=filter_tags, + scopes=scopes, + start_date=start_date, + end_date=end_date, + ) - # Get semantic memories - try: - semantic_manager = server.semantic_memory_manager + memories["episodic"] = { + "total_count": await episodic_manager.get_total_number_of_items(user=user), + "recent": [ + { + "id": event.id, + "timestamp": (event.occurred_at.isoformat() if event.occurred_at else None), + "summary": event.summary, + "details": event.details, + } + for event in recent_episodic + ], + "relevant": [ + { + "id": event.id, + "timestamp": (event.occurred_at.isoformat() if event.occurred_at else None), + "summary": event.summary, + "details": event.details, + } + for event in relevant_episodic + ], + } + except Exception as e: + logger.error("Error retrieving episodic memories: %s", e) + memories["episodic"] = {"total_count": 0, "recent": [], "relevant": []} - semantic_items = await semantic_manager.list_semantic_items( - agent_state=agent_state, # Not accessed during BM25 search - user=user, - query=key_words, - search_field="details", - search_method=search_method, - limit=limit, - timezone_str=timezone_str, - filter_tags=filter_tags, - scopes=scopes, - use_cache=use_cache, - ) + # Get semantic memories + try: + semantic_manager = server.semantic_memory_manager - memories["semantic"] = { - "total_count": await semantic_manager.get_total_number_of_items(user=user), - "items": [ - { - "id": item.id, - "name": item.name, - "summary": item.summary, - "details": item.details, - } - for item in semantic_items - ], - } - except Exception as e: - logger.error("Error retrieving semantic memories: %s", e) - memories["semantic"] = {"total_count": 0, "items": []} + semantic_items = await semantic_manager.list_semantic_items( + agent_state=agent_state, # Not accessed during BM25 search + user=user, + query=key_words, + search_field="details", + search_method=search_method, + limit=limit, + timezone_str=timezone_str, + filter_tags=filter_tags, + scopes=scopes, + use_cache=use_cache, + ) + + memories["semantic"] = { + "total_count": await semantic_manager.get_total_number_of_items(user=user), + "items": [ + { + "id": item.id, + "name": item.name, + "summary": item.summary, + "details": item.details, + } + for item in semantic_items + ], + } + except Exception as e: + logger.error("Error retrieving semantic memories: %s", e) + memories["semantic"] = {"total_count": 0, "items": []} # Get resource memories try: @@ -2785,6 +2983,8 @@ async def _precompute_embedding_for_search( return embedded_text, embedded_text_padded + + @router.get("/memory/search") @with_langfuse_tracing async def search_memory( @@ -2950,6 +3150,7 @@ async def search_memory( # Pre-compute embedding once if using embedding search (to avoid redundant embeddings) embedded_text, embedded_text_padded = await _precompute_embedding_for_search(search_method, query, agent_state) + # Collect results from requested memory types all_results = [] @@ -5084,6 +5285,9 @@ async def auto_dream_handler( resource, procedural, knowledge, experience. experience processes episodic, semantic, and knowledge together in one agent pass. dry_run: If true, return counts without applying any changes + graph_only: If true, skip the LLM memory-merge pass and only refine the graph + (maintenance + reconsolidation). Flat PG memories are left untouched, so + flat retrieval is unchanged and only the hypergraph structure is consolidated. model: Override the LLM model (e.g. "gpt-4.1-mini" for testing) """ from mirix.schemas.auto_dream import AutoDreamRequest, AutoDreamResponse diff --git a/mirix/server/server.py b/mirix/server/server.py index f8de97c3d..2213121d1 100644 --- a/mirix/server/server.py +++ b/mirix/server/server.py @@ -454,9 +454,15 @@ async def db_context(): async def ensure_tables_created(): - """Create all tables on the async engine. Call from FastAPI lifespan startup.""" + """Create all tables on the async engine. Call from FastAPI lifespan startup. + + Order matters: startup migrations (e.g. dropping retired tables) must run + *before* ``create_all`` so the new ORM state is what gets materialized. + """ if USE_PGLITE: return + from mirix.database.startup_migrations import run_startup_migrations + await run_startup_migrations(engine) async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) diff --git a/mirix/services/_graph_common.py b/mirix/services/_graph_common.py new file mode 100644 index 000000000..5da6a73c5 --- /dev/null +++ b/mirix/services/_graph_common.py @@ -0,0 +1,82 @@ +""" +Shared helpers for v4 graph managers (episodic + semantic). + +Both managers do roughly the same things but write into disjoint Neo4j labels: + - episodic: (:Episode), (:EpisodicEntity), [:EP_RELATES], [:MENTIONS], [:NEXT] + - semantic: (:Concept), (:SemanticEntity), [:SEM_RELATES], [:MENTIONS], + [:CONCEPT_RELATES] + +This module hosts the parts that don't care which label set is in play: +helpers for id generation, name normalization, embedding batching, and the +LLM model resolution from an AgentState. +""" + +from __future__ import annotations + +import asyncio +import uuid +from datetime import datetime, timezone +from typing import Optional + +from mirix.embeddings import embedding_model +from mirix.log import get_logger +from mirix.schemas.agent import AgentState + +logger = get_logger(__name__) + + +def gen_id(prefix: str) -> str: + return f"{prefix}-{uuid.uuid4().hex[:24]}" + + +def normalize_name(name: str) -> str: + return (name or "").strip().lower() + + +def iso(ts: datetime) -> str: + """Neo4j datetime properties want ISO-8601 strings (with tz).""" + if ts.tzinfo is None: + ts = ts.replace(tzinfo=timezone.utc) + return ts.isoformat() + + +def llm_model_from_agent(agent_state: AgentState, default: str = "gpt-4.1-mini") -> str: + """Pull LLM model name from agent_state, falling back to default.""" + try: + cfg = getattr(agent_state, "llm_config", None) + if cfg is not None and getattr(cfg, "model", None): + return cfg.model + except Exception: + pass + return default + + +async def embed_batch( + texts: list[str], agent_state: AgentState, *, max_concurrency: int = 8 +) -> list[Optional[list[float]]]: + """ + Compute embeddings for many short strings via the agent's configured model. + + MIRIX's embedding adapter is single-text only, so we fan out with bounded + concurrency. Returns ``None`` for failed entries so callers can decide + whether to drop the row or store without a vector. + """ + if not texts: + return [] + try: + model = await embedding_model(agent_state.embedding_config) + except Exception as e: + logger.warning("Embedding model init failed: %s", e) + return [None] * len(texts) + + sem = asyncio.Semaphore(max_concurrency) + + async def one(t: str) -> Optional[list[float]]: + async with sem: + try: + return await model.get_text_embedding(t) + except Exception as e: + logger.debug("Embed failed for '%s...': %s", (t or "")[:40], e) + return None + + return await asyncio.gather(*(one(t) for t in texts)) diff --git a/mirix/services/agent_manager.py b/mirix/services/agent_manager.py index 51787fa57..a3e87ffef 100644 --- a/mirix/services/agent_manager.py +++ b/mirix/services/agent_manager.py @@ -293,6 +293,18 @@ async def create_meta_agent( elif agent_name in default_system_prompts: custom_system = default_system_prompts[agent_name] + # Opt-in: append the conflict-resolution policy to the semantic + # memory agent's system prompt so the agent prefers the new + # `semantic_memory_upsert_fact` tool. Default off — legacy + # behaviour is preserved. + if ( + getattr(meta_agent_create, "enable_conflict_resolution", False) + and agent_name == "semantic_memory_agent" + and custom_system + ): + from mirix.agent.meta_agent import _CONFLICT_RESOLUTION_POLICY_PROMPT + custom_system = custom_system + "\n\n" + _CONFLICT_RESOLUTION_POLICY_PROMPT + # Create the agent using CreateAgent schema with parent_id agent_create = CreateAgent( name=f"{meta_agent_name}_{agent_name}", @@ -479,6 +491,16 @@ async def update_meta_agent( elif agent_name in default_system_prompts: custom_system = default_system_prompts[agent_name] + # Mirror the create path: if conflict-resolution is enabled + # on this update, append the policy to the semantic agent. + if ( + getattr(meta_agent_update, "enable_conflict_resolution", False) + and agent_name == "semantic_memory_agent" + and custom_system + ): + from mirix.agent.meta_agent import _CONFLICT_RESOLUTION_POLICY_PROMPT + custom_system = custom_system + "\n\n" + _CONFLICT_RESOLUTION_POLICY_PROMPT + # Use the updated configs or fall back to meta agent's configs llm_config = meta_agent_update.llm_config or meta_agent_state.llm_config embedding_config = meta_agent_update.embedding_config or meta_agent_state.embedding_config @@ -538,6 +560,11 @@ async def update_meta_agent( actor=actor, ) + # When conflict_resolution is toggled, re-apply the semantic agent's + # system prompt AND make sure the new upsert tool is attached. + # Without the latter, the agent sees the policy but has no + # `semantic_memory_upsert_fact` in its tool list and falls back to + # `semantic_memory_insert`. # Refresh the meta agent state with updated children meta_agent_state = await self.get_agent_by_id(agent_id=meta_agent_id, actor=actor) updated_children = await self.list_agents(actor=actor, parent_id=meta_agent_id) diff --git a/mirix/services/auto_dream_manager.py b/mirix/services/auto_dream_manager.py index b08e3f4b9..fd09675a6 100644 --- a/mirix/services/auto_dream_manager.py +++ b/mirix/services/auto_dream_manager.py @@ -12,7 +12,6 @@ import datetime as dt import json -import logging import os from typing import List, Optional @@ -22,8 +21,12 @@ from mirix.schemas.message import MessageCreate from mirix.schemas.enums import MessageRole from mirix.schemas.user import User as PydanticUser +from mirix.log import get_logger -logger = logging.getLogger(__name__) +# Use Mirix's configured logger, not stdlib logging.getLogger — the latter's records +# never reach the server log, so batch progress, the graph-maintenance hook results and +# any batch failures were all invisible when this ran. +logger = get_logger(__name__) # event_type used to tag auto_dream checkpoint records in episodic memory _CHECKPOINT_EVENT_TYPE = "auto_dream_checkpoint" @@ -111,6 +114,46 @@ async def write_checkpoint( # Memory fetching # # ------------------------------------------------------------------ # + async def _graph_memory_ids(self, user: PydanticUser) -> Optional[set]: + """Complete id set of the memories that can own a graph ref. + + Only episodic and semantic memories call ``V7GraphManager.process_memory``, + so only those two can have a ``V7MemoryRef``. + + This set MUST be complete: ``maintain_graph`` deletes every ref NOT in it, so + a truncated list would destroy live refs. That is why this queries the tables + directly instead of reusing the ``list_*`` helpers, which cap at limit=500 and + would silently truncate any store larger than that. + + Returns ``None`` (meaning "skip the orphan sweep") on any failure or if the + result is empty — an empty set is far more likely a query bug than a real + store with graph refs but no memories, and acting on it would wipe the graph. + """ + try: + from sqlalchemy import text as sa_text + + from mirix.server.server import db_context + + ids: set = set() + async with db_context() as session: + for table in ("episodic_memory", "semantic_memory"): + res = await session.execute( + sa_text(f"SELECT id FROM {table} " + f"WHERE user_id = :uid AND NOT is_deleted"), + {"uid": user.id}, + ) + ids.update(row[0] for row in res.fetchall()) + if not ids: + logger.warning( + "Auto dream: memory-id enumeration came back empty for user=%s; " + "skipping orphan sweep rather than deleting every graph ref", user.id) + return None + return ids + except Exception as exc: # noqa: BLE001 + logger.warning("Auto dream: could not enumerate memory ids (%s); " + "skipping orphan sweep", exc) + return None + async def _fetch_episodic( self, user: PydanticUser, @@ -262,6 +305,57 @@ async def get_or_create_dream_agent_state( ) return await server.agent_manager.create_agent(agent_create=agent_create, actor=actor) + # ------------------------------------------------------------------ # + # Graph refinement # + # ------------------------------------------------------------------ # + + async def _refine_graph(self, user: PydanticUser, dream_agent_state: AgentState) -> dict: + """Refine the hypergraph only — never touches the flat PG memories. + + Two passes, both self-contained on the graph and both fail-soft (a graph error + must never fail the dream cycle): + + 1. ``maintain_graph`` — structural cleanup: sweeps refs orphaned by any memory + deletion, drops tautologies and duplicate triples, prunes dead-weight anchors + (a corpus-global redundancy that cannot be prevented at write time). + 2. ``reconsolidate_graph`` — semantic cleanup the structural pass cannot see: + clusters anchors that say the same thing in different words (LLM-verified, + because cosine alone would merge "5-10% of budget" with "10-20% of budget") + and reports — never resolves — apparent contradictions. Self-gated to every + N new memories since it costs LLM calls. + + Because this only redirects graph edges to surviving anchors, every + anchor→memory_id link is preserved, so the graph→memory_id→PG retrieval path + keeps reaching the same memories. Returns the merged stats dict. + """ + stats: dict = {} + try: + from mirix.services.graph_memory_manager_v7 import V7GraphManager + + graph_stats = await V7GraphManager().maintain_graph( + user.id, valid_memory_ids=await self._graph_memory_ids(user) + ) + stats["maintenance"] = graph_stats + logger.info("Auto dream: graph maintenance %s", graph_stats) + except Exception as exc: # noqa: BLE001 + logger.warning("Auto dream: graph maintenance skipped (%s)", exc) + + try: + from mirix.database.neo4j_client import get_neo4j_driver + from mirix.services.graph_reconsolidator import reconsolidate_graph + + recon_stats = await reconsolidate_graph( + get_neo4j_driver(), user_id=user.id, agent_state=dream_agent_state, + every_n_memories=int(os.environ.get("MIRIX_GRAPH_RECONSOLIDATE_EVERY", "10")), + ) + stats["reconsolidation"] = recon_stats + logger.info("Auto dream: graph reconsolidation %s", + {k: v for k, v in recon_stats.items() if k != "conflict_samples"}) + except Exception as exc: # noqa: BLE001 + logger.warning("Auto dream: graph reconsolidation skipped (%s)", exc) + + return stats + # ------------------------------------------------------------------ # # Main entry point # # ------------------------------------------------------------------ # @@ -293,6 +387,43 @@ async def run( if end_date.tzinfo is not None: end_date = end_date.astimezone(dt.timezone.utc).replace(tzinfo=None) + # -- graph-only dream: refine the graph, leave the flat PG memories alone -- + # The LLM memory-merge pass (below) is what degrades fact-recall QA: it blends + # peripheral specifics into coarser merged memories whose embeddings no longer + # retrieve them. Retrieval is graph→memory_id→PG *and* flat pgvector, both keyed + # on those per-memory rows/embeddings; mutating them is what hurts. This mode + # skips the merge entirely and only consolidates the hypergraph structure — + # break-even QA, cleaner graph. reconsolidate still needs the dream agent state + # (for its LLM verification + embeddings), so we resolve it here too. + if request.graph_only: + # dry_run must resolve NOTHING and mutate NOTHING — return before + # get_or_create_dream_agent_state, which is not read-only (it can insert or + # update an agent row). The normal path likewise returns its dry_run before + # resolving the agent state. + if request.dry_run: + return AutoDreamResponse( + start_date=None, end_date=None, processed={}, last_dream_at=now, + dry_run=True, + message="Dry run (graph_only) — would refine the graph; no memory changes.", + ) + dream_agent_state = await self.get_or_create_dream_agent_state(actor, meta_agent_state) + if request.model: + from copy import deepcopy + + dream_agent_state = deepcopy(dream_agent_state) + dream_agent_state.llm_config.model = request.model + # No checkpoint write here: the checkpoint only seeds the default window for + # the merge path, and that path fetches ALL memories regardless of window + # anyway — so in graph_only it is a pure no-op PG insert (plus a stray graph + # write for its own row). Skipping it makes the invariant exact: graph_only + # issues ZERO writes to the flat memory store, only graph mutations. + await self._refine_graph(user, dream_agent_state) + return AutoDreamResponse( + start_date=None, end_date=None, processed={}, last_dream_at=now, + dry_run=False, + message="Auto dream (graph_only) completed — graph refined, flat memories untouched.", + ) + components = _MODE_COMPONENTS[request.mode] logger.info("Auto dream: window %s → %s, mode=%s, components=%s", start_date, end_date, request.mode, components) @@ -327,8 +458,15 @@ async def run( message="Dry run — no changes applied.", ) - # -- build input message for the agent -- - payload = _format_memories_as_message(memories, start_date, end_date, request.mode) + # -- split into batches -- + # A single payload does not fit: this store's 962 memories serialise to ~569k + # chars (~142k tokens) against a 128k window, and the summariser cannot rescue + # it because there is only one message to compress (num_candidate_messages=0), + # so the whole run used to die with CONTEXT_WINDOW_EXCEEDED before touching + # anything. Batching makes the pass scale-independent. + batches = _batch_memories(memories, _batch_char_budget()) + logger.info("Auto dream: %d batch(es) over %d items", + len(batches), sum(len(v) for v in memories.values())) # -- get or create agent state -- dream_agent_state = await self.get_or_create_dream_agent_state(actor, meta_agent_state) @@ -347,19 +485,38 @@ async def run( dream_agent_state = deepcopy(dream_agent_state) dream_agent_state.llm_config.model = request.model - # -- load and run agent -- - dream_agent = await server.load_agent( - agent_id=dream_agent_state.id, - actor=actor, - user=user, - use_cache=False, - ) - input_msg = MessageCreate(role=MessageRole.user, content=payload) - await dream_agent.step(input_messages=input_msg, actor=actor, user=user) + # -- run the agent once per batch -- + # Each batch gets a freshly loaded agent so context does not accumulate across + # batches (which would reintroduce the overflow). A failing batch is logged and + # skipped rather than aborting the cycle — partial consolidation beats none. + batches_ok = batches_failed = 0 + for idx, batch in enumerate(batches, start=1): + try: + dream_agent = await server.load_agent( + agent_id=dream_agent_state.id, + actor=actor, + user=user, + use_cache=False, + ) + payload = _format_memories_as_message(batch, start_date, end_date, request.mode) + await dream_agent.step( + input_messages=MessageCreate(role=MessageRole.user, content=payload), + actor=actor, user=user, + ) + batches_ok += 1 + logger.info("Auto dream: batch %d/%d ok (%d items)", + idx, len(batches), sum(len(v) for v in batch.values())) + except Exception as exc: # noqa: BLE001 + batches_failed += 1 + logger.warning("Auto dream: batch %d/%d failed (%s)", idx, len(batches), exc) + logger.info("Auto dream: %d batch(es) ok, %d failed", batches_ok, batches_failed) # -- write checkpoint -- await self.write_checkpoint(user, actor, meta_agent_state, now) + # -- refine the graph (maintenance + semantic reconsolidation) -- + await self._refine_graph(user, dream_agent_state) + # -- build response (stats are approximate: we report totals fetched) -- processed = {t: MemoryTypeStats(total=len(items)) for t, items in memories.items()} return AutoDreamResponse( @@ -388,6 +545,53 @@ def _serialize_item(item) -> dict: return data +def _batch_char_budget() -> int: + """Serialised chars allowed per batch. ~80k chars ≈ 20k tokens, leaving the rest of + a 128k window for the system prompt, tool schemas, the agent's reasoning and its + tool results. Override with MIRIX_AUTO_DREAM_BATCH_CHARS.""" + try: + return max(5_000, int(os.environ.get("MIRIX_AUTO_DREAM_BATCH_CHARS", "80000"))) + except ValueError: + return 80_000 + + +def _item_chars(item) -> int: + d = _serialize_item(item) + return sum(len(str(v)) for v in d.values()) if isinstance(d, dict) else len(str(d)) + + +def _batch_memories(memories: dict, budget_chars: int) -> list[dict]: + """Split memories into batches that each hold a slice of EVERY component. + + Not split by component: `experience` mode exists to review episodic, semantic and + knowledge *together*, so a batch that contained only one type could never merge + across types. Slicing every component proportionally keeps that cross-type view + inside each batch. + + Order within a component is preserved (episodic arrives time-ordered, and duplicate + events tend to sit near each other in time, so they usually land in the same batch). + + Known limitation: duplicates that fall in *different* batches are not merged in this + cycle — the agent only ever sees one batch. Successive runs will still converge, and + a smaller batch count (a larger budget) widens the window. + """ + total = sum(_item_chars(it) for items in memories.values() for it in items) + if total <= budget_chars: + return [memories] if total else [] + n = max(1, -(-total // budget_chars)) # ceil + batches: list[dict] = [] + for b in range(n): + batch = {} + for comp, items in memories.items(): + lo = (len(items) * b) // n + hi = (len(items) * (b + 1)) // n + if items[lo:hi]: + batch[comp] = items[lo:hi] + if batch: + batches.append(batch) + return batches + + def _format_memories_as_message( memories: dict, start_date: dt.datetime, diff --git a/mirix/services/entity_resolver.py b/mirix/services/entity_resolver.py new file mode 100644 index 000000000..689a4846f --- /dev/null +++ b/mirix/services/entity_resolver.py @@ -0,0 +1,92 @@ +"""v7.8 entity resolution / canonicalization at extraction time. + +Closes a long-standing gap (present since v7, amplified by v7.6's LLM triple +extraction): entities are named inconsistently across independently-extracted +memories ("Fitness Class" vs "Workout Session"; "Outward Hound Brick Puzzle" vs +"Outward Hound's Brick Puzzle"), so near-duplicate anchors fragment the graph +(81% singletons under v7.6). Surface `anchor_canonical_key` can't fix this (it's +semantic, not string), and embedding-cosine merge can't DECIDE identity (Monday +~ Tuesday, "42 campsites" ~ "46 campsites" are close but distinct). + +This does what the research canonicalization layer (EDC / KARMA) does: embedding +retrieves CANDIDATE existing anchors, then an LLM DECIDES same-or-new and renames +each new entity to an existing canonical name when they denote the same entity. +Registry-guided — candidates are the anchors already in the graph — so naming +stays consistent as the graph grows. Because the LLM sees the names (and could see +context), this is also the only mechanism able to keep distinct same-string +entities apart (G1) rather than only merging (G2). +""" +from __future__ import annotations + +import json +from typing import List + +from mirix.log import get_logger +from mirix.services._graph_common import embed_batch +from mirix.services.lightrag_extractor import ExtractedEntity, call_openai_chat +from mirix.settings import settings + +logger = get_logger(__name__) + +_CAND_COS = 0.86 # only entities with a candidate at least this close go to the LLM +_TOPK = 4 + +RESOLVE_PROMPT = """You canonicalize entity names for a knowledge graph. For each NEW entity you are given candidate EXISTING entity names. For each new entity, decide whether it denotes the SAME real-world entity/concept as one of its candidates. +- If yes, output that existing candidate's exact name (reuse it). +- If there is no true match, output the new entity's own name unchanged. +Be CONSERVATIVE — only merge when they are truly the same entity. Do NOT merge different quantities ("42 campsites" vs "46 campsites"), different days/months, different form/model codes ("I-601" vs "I-601A"), or merely related-but-distinct concepts ("Comedy" vs "Comedians", "Monday" vs "Tuesday"). +Return a JSON object mapping every new entity name to its chosen canonical name: {"new name": "canonical name", ...}""" + + +async def _candidates(driver, user_id: str, embs, names) -> dict: + """{new_name: [existing anchor names]} for entities with a close existing match.""" + out: dict = {} + async with driver.session(database=settings.neo4j_database) as session: + for nm, emb in zip(names, embs): + if emb is None: + continue + res = await session.run( + """ + CALL db.index.vector.queryNodes('v7_anchor_name_emb', $k, $emb) + YIELD node AS a, score AS sc + WHERE a.user_id = $u AND sc >= $th AND a.name <> $nm + RETURN a.name AS n ORDER BY sc DESC + """, + k=_TOPK, emb=emb, u=user_id, th=_CAND_COS, nm=nm) + cands = [r["n"] async for r in res] + if cands: + out[nm] = cands + return out + + +async def canonicalize_entities( + entities: List[ExtractedEntity], *, driver, user_id: str, agent_state +) -> dict: + """Rename entities in place to existing canonical anchors when they match. + Returns the {original_name: canonical_name} map (for renaming relation endpoints).""" + if not entities: + return {} + names = [e.name for e in entities] + embs = await embed_batch(names, agent_state) + cand = await _candidates(driver, user_id, embs, names) + if not cand: + return {} + payload = [{"new": nm, "candidates": cs} for nm, cs in cand.items()] + try: + raw = await call_openai_chat( + RESOLVE_PROMPT, json.dumps(payload, ensure_ascii=False), "gpt-4.1-mini", temperature=0.0) + blob = raw if raw.strip().startswith("{") else raw[raw.find("{"): raw.rfind("}") + 1] + mapping = json.loads(blob) + except Exception as e: # noqa: BLE001 + logger.warning("entity resolve failed: %s", e) + return {} + + applied: dict = {} + valid_cands = {c for cs in cand.values() for c in cs} + for e in entities: + canon = mapping.get(e.name) + # only accept a rename to an actual candidate (guard against LLM inventing names) + if isinstance(canon, str) and canon.strip() and canon != e.name and canon in valid_cands: + applied[e.name] = canon.strip() + e.name = canon.strip() + return applied diff --git a/mirix/services/episodic_memory_manager.py b/mirix/services/episodic_memory_manager.py index 4109b065c..c6712f74c 100755 --- a/mirix/services/episodic_memory_manager.py +++ b/mirix/services/episodic_memory_manager.py @@ -565,6 +565,16 @@ async def insert_event( summary_embedding = None embedding_config = None + # Source provenance: when the /memory/add caller (or the + # server-side fallback in rest_api._augment_source_meta_with_ + # server_fallbacks) attaches a ``source_meta`` dict to + # filter_tags, copy it onto the episodic event's + # ``source_refs``. We do not strip it from filter_tags so that + # downstream filtering / debug still sees it. + source_refs_for_event: list = [] + if filter_tags and isinstance(filter_tags.get("source_meta"), dict): + source_refs_for_event = [dict(filter_tags["source_meta"])] + event = await self.create_episodic_memory( PydanticEpisodicEvent( occurred_at=timestamp, @@ -580,6 +590,7 @@ async def insert_event( details_embedding=details_embedding, embedding_config=embedding_config, filter_tags=filter_tags, + source_refs=source_refs_for_event, last_modify={ "timestamp": datetime.now(dt.timezone.utc).isoformat(), "operation": "created", @@ -591,6 +602,54 @@ async def insert_event( use_cache=use_cache, ) + # Graph memory write path. Routed by settings.graph_version: + # v5 → full LightRAG dual-graph (Episode + EpisodicEntity + EP_RELATES) + # v6 → lean entity index (V6Entity + V6_COOCCUR), no item nodes + # v7 → minimal semantic+episodic linkage graph; details stay in PG + # Sync hook — failures logged but do not affect the PG insert that + # already completed. + if settings.enable_graph_memory: + try: + # Prefix match, not a version list: the old explicit tuple + # ("v7","v7.1","v7.2","v7.3","v8") silently drifted six versions out of + # date, so a server configured for v7.4+ fell through to the legacy + # branch and built the OLD schema. Measured on the eval store: an + # auto_dream cycle under v7.10 created 1,101 EpisodicEntity + 611 + # SemanticEntity legacy nodes and left 185 memories with no V7 ref. + # The v5/v6 builders that branch dispatched to are archived under + # archive/legacy_graph/; unrecognised versions now skip graph writes. + if settings.graph_version.startswith("v7") or settings.graph_version == "v8": + from mirix.services.graph_memory_manager_v7 import V7GraphManager + + source_meta = ( + dict(filter_tags["source_meta"]) + if filter_tags and isinstance(filter_tags.get("source_meta"), dict) + else None + ) + await V7GraphManager().process_memory( + source_kind="episodic", + source_id=event.id, + text=(summary or "") + ("\n" + details if details else ""), + title=summary, + summary=summary, + occurred_at=timestamp, + source_meta=source_meta, + agent_state=agent_state, + organization_id=organization_id, + user_id=user_id or "unknown", + # Role provenance for the hypergraph (fact.role + + # per-citation cite.role). Without this the organically + # built graph loses all user/assistant attribution — the + # offline rebuild script passed it, the live hook didn't. + role=event.actor, + ) + else: + logger.warning( + "Episodic graph write skipped: graph_version=%r has no live " + "builder (v5/v6 are archived)", settings.graph_version) + except Exception as graph_err: + logger.warning("Episodic graph write failed (non-fatal): %s", graph_err) + return event except Exception as e: @@ -857,6 +916,11 @@ async def list_episodic_memory( EpisodicEvent.last_modify.label("last_modify"), EpisodicEvent.user_id.label("user_id"), EpisodicEvent.agent_id.label("agent_id"), + # source_refs must be selected explicitly: it is NOT + # nullable on the Pydantic schema, so leaving it + # unloaded makes to_pydantic() pass source_refs=None + # and fail validation (empties the whole search). + EpisodicEvent.source_refs.label("source_refs"), ) .where(EpisodicEvent.user_id == user.id) .where(EpisodicEvent.organization_id == organization_id) @@ -1270,6 +1334,7 @@ async def update_event( actor: PydanticClient = None, agent_state: AgentState = None, update_mode: str = "append", + additional_source_ref: Optional[Dict[str, Any]] = None, ): """ Update the selected events @@ -1282,6 +1347,11 @@ async def update_event( agent_state: Agent state containing embedding configuration (needed for embedding regeneration) update_mode: How to handle new_details - "append" (default) appends to existing, "replace" overwrites existing details entirely + additional_source_ref: Optional source-provenance dict from the + current ingest call (turn_id / chunk_id / serial / + occurred_at). When supplied, it is appended to the event's + ``source_refs`` list so a merged event still carries the + trail of every ingest that contributed to it. """ async with self.session_maker() as session: @@ -1315,6 +1385,15 @@ async def update_event( ) selected_event.embedding_config = agent_state.embedding_config + # Append the current ingest's source_ref to the event's + # provenance trail (if provided). Late-arriving ingests that + # merge into an existing event keep their pointer in the list + # rather than being lost. + if additional_source_ref: + existing_refs = list(selected_event.source_refs or []) + existing_refs.append(dict(additional_source_ref)) + selected_event.source_refs = existing_refs + # Update last_modify field with timestamp and operation info selected_event.last_modify = { "timestamp": datetime.now(dt.timezone.utc).isoformat(), diff --git a/mirix/services/graph_memory_manager_v7.py b/mirix/services/graph_memory_manager_v7.py new file mode 100644 index 000000000..0353602d0 --- /dev/null +++ b/mirix/services/graph_memory_manager_v7.py @@ -0,0 +1,871 @@ +""" +v7 graph manager - minimal semantic+episodic linkage graph. + +v7 keeps the useful part of v6 (Neo4j as an index into PG flat memory), but +tightens the ontology: + +- Details stay in PostgreSQL. Graph nodes store ids, types, canonical names, + timestamps, and a short title/preview only for debugging. +- Anchors must be specific enough to be useful. Generic noun phrases are + discarded instead of becoming graph nodes. +- Semantic and episodic memory refs live in one graph and share the same + anchors. Semantic refs are linked back to episodic refs from the same + source chunk when provenance is available. +- No entity-entity co-occurrence edges. Every edge must be a retrieval path: + anchor -> memory ref, semantic ref -> supporting episode, or temporal next. +""" + +from __future__ import annotations + +import re +import asyncio +import hashlib +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, Literal, Optional + +from mirix.log import get_logger +from mirix.schemas.agent import AgentState +from mirix.services._graph_common import ( + embed_batch, + gen_id, + iso, + llm_model_from_agent, + normalize_name, +) +from mirix.services.lightrag_extractor import ExtractedEntity, extract_entities_and_relations +from mirix.settings import settings + +logger = get_logger(__name__) + + +SourceKind = Literal["episodic", "semantic"] + +MAX_ANCHORS_PER_EPISODE = 8 +MAX_ANCHORS_PER_SEMANTIC = 10 +PREVIEW_CHARS = 160 + +_GENERIC_NAMES = { + "advice", "approach", "benefits", "best practices", "challenge", + "challenges", "concept", "considerations", "details", "example", + "examples", "experience", "feedback", "flexibility", "goal", "goals", + "guidance", "habit", "help", "idea", "ideas", "information", + "insights", "issue", "issues", "method", "methods", "option", + "options", "plan", "plans", "practice", "practices", "preference", + "recommendation", "recommendations", "routine", "schedule", "skills", + "social media", "steps", "strategy", "support", "task", "tasks", + "thing", "things", "tips", "topic", "topics", "update", "updates", + "way", "ways", +} + +_GENERIC_SUFFIXES = ( + " advice", " approach", " benefits", " considerations", " details", + " examples", " experience", " feedback", " guidance", " ideas", + " information", " method", " methods", " options", " plan", " plans", + " recommendations", " routine", " schedule", " strategy", " tips", +) + +_SPECIFIC_HINT_RE = re.compile(r"(\d|[A-Z][a-z]+(?:\s+[A-Z][a-z]+)+|['\u2019])") + +# Anchor merge key (A): collapses case / punctuation / word-order and +# singular/plural so "Mobile App"/"Mobile Apps", "Flight"/"Flights", +# "Price"/"Prices" merge onto ONE V7Anchor instead of fragmenting into +# near-duplicate nodes. Deliberately conservative \u2014 numbers are preserved +# (so "10 Gallons" != "20 Gallons", and distinct dates stay distinct) and only +# plural endings are normalized (no verb stemming, so "Training" != "Trains"). +_ANCHOR_STOPWORDS = {"the", "a", "an", "of", "and", "or", "to", "for", "in", "on", "with"} + + +def _singularize(token: str) -> str: + if token.isdigit() or len(token) < 4: + return token + if token.endswith(("ss", "us", "is")): + return token + if token.endswith("ies"): + return token[:-3] + "y" + if re.search(r"(ses|xes|zes|ches|shes)$", token): + return token[:-2] + if token.endswith("s"): + return token[:-1] + return token + + +def anchor_canonical_key(name: str) -> str: + """Canonical merge key for a V7 anchor name (see note above).""" + cleaned = re.sub(r"[^a-z0-9 ]", " ", (name or "").lower()) + tokens = {_singularize(t) for t in cleaned.split() if t and t not in _ANCHOR_STOPWORDS} + return " ".join(sorted(tokens)) + + +# Canonical keys of dialogue-role names that must never become anchors — checked in +# _select_anchors so it covers EVERY extractor path (LightRAG has no _NOISE filter of +# its own). anchor_canonical_key folds case, articles and plurals, so "user" here +# blocks "User", "Users", "The User", "the users", ... in one entry. +# NB: entries must be in the key's own form — canonical keys are SORTED token sets, +# so the combined role is "assistant user" (never "user assistant"), and possessives +# leave a stray "s" token ("User's" -> "s user") because the bare s survives +# _singularize's length guard. +_ROLE_NOISE_KEYS = {"user", "assistant", "assistant user", "s user", "assistant s"} + +_PRED_COPULA = re.compile(r"^(is|are|was|were|be|been|being)\s+", re.I) + + +def canon_predicate(pred: str) -> str: + """Canonical predicate so surface variants are ONE relation, not several: + ``includes``->``include``, ``is located in``->``located in``, ``offers``->``offer``. + Applied at write time so the graph never accumulates the variants in the first + place (a corpus-wide cleanup previously had to fold 99 of them).""" + if not pred: + return "related to" + p = _PRED_COPULA.sub("", pred.lower().strip()) + tokens = p.split() + if tokens: + head = tokens[0] + # singularize the verb, but keep has/is/ss-words intact + if len(head) > 4 and head.endswith("s") and not head.endswith(("ss", "us", "is", "as")): + tokens[0] = head[:-1] + return " ".join(tokens) or "related to" + + +def fact_identity(user_id: str, subj_key: str, predicate: str, obj_key: str) -> str: + """Deterministic id for a fact, so the SAME (subject, predicate, object) asserted + in N memories MERGEs to ONE V7Fact cited N times — instead of N duplicate nodes. + A random id here was what generated the duplicate-triple redundancy.""" + raw = f"{user_id}|{subj_key}|{predicate}|{obj_key}" + return "v7fact-" + hashlib.sha1(raw.encode("utf-8")).hexdigest()[:20] + + +@dataclass(frozen=True) +class V7AnchorCandidate: + name: str + name_lower: str + anchor_type: str + score: float + + +class V7GraphManager: + """Stateless. Construct one per graph write.""" + + async def process_memory( + self, + *, + source_kind: SourceKind, + source_id: str, + text: str, + agent_state: AgentState, + organization_id: str, + user_id: str, + title: Optional[str] = None, + summary: Optional[str] = None, + occurred_at: Optional[object] = None, + source_meta: Optional[dict[str, Any]] = None, + entities: Optional[list[ExtractedEntity]] = None, + role: Optional[str] = None, + ) -> dict[str, Any]: + # startswith, not an exact tuple: the old tuple silently skipped ingest for any + # version it didn't list (it already omitted v7.5, and every new v7.x had to + # remember to add itself) while the retrieval side accepts startswith("v7") — + # a version outside the tuple would retrieve against a graph nothing ingests to. + if not settings.enable_graph_memory or not ( + settings.graph_version.startswith("v7") or settings.graph_version == "v8" + ): + return {"skipped": "disabled"} + + from mirix.database.neo4j_client import get_neo4j_driver + + driver = get_neo4j_driver() + if driver is None: + return {"skipped": "no_driver"} + if not text or not text.strip(): + return {"anchors": 0} + + # Only name/entity_type are consumed downstream, so a caller that already + # knows the entities can pass them and skip the extraction round-trip. + # (The v7.3 proposition and v7.4 GLiNER extraction branches are archived + # under archive/legacy_graph/ — both were superseded by direction D.) + relations: list[tuple[str, str, str]] = [] + if entities is None: + if settings.graph_version in ("v7.6", "v7.8", "v7.10"): + # v7.6 (direction D): one LLM call -> typed entities + relations. + # Keeps abstraction (GLiNER can't) and the relations v7 discarded, + # which become anchor->anchor edges below. + from mirix.services.triple_extractor import extract_triples + res = await extract_triples(text, model=llm_model_from_agent(agent_state)) + entities = res.entities + relations = res.relations + if settings.graph_version in ("v7.8", "v7.10"): + # v7.8: registry-guided canonicalization — reuse existing anchor + # names for the same entity so the graph stops fragmenting into + # near-duplicate singletons. Also rename relation endpoints so the + # anchor->anchor edges land on the canonical anchors. + from mirix.services.entity_resolver import canonicalize_entities + rename = await canonicalize_entities( + entities, driver=driver, user_id=user_id, agent_state=agent_state) + if rename: + relations = [(rename.get(s, s), r, rename.get(o, o)) for s, r, o in relations] + else: + extraction = await extract_entities_and_relations( + text=text, llm_model=llm_model_from_agent(agent_state) + ) + entities = extraction.entities + candidates = self._select_anchors( + entities, + max_anchors=MAX_ANCHORS_PER_EPISODE if source_kind == "episodic" else MAX_ANCHORS_PER_SEMANTIC, + ) + + memory_ref_id = f"{source_kind}:{source_id}" + source_key = self._source_key(source_meta) + timestamp = self._to_iso(occurred_at) or (source_meta or {}).get("occurred_at") + preview = self._preview(summary or title or text) + + await self._upsert_memory_ref( + driver, + source_kind=source_kind, + ref_id=memory_ref_id, + memory_id=source_id, + title=title or "", + preview=preview, + timestamp=timestamp, + source_key=source_key, + source_meta=source_meta or {}, + organization_id=organization_id, + user_id=user_id, + ) + + if candidates: + await self._upsert_anchors_and_edges( + driver, + anchors=candidates, + source_kind=source_kind, + memory_ref_id=memory_ref_id, + agent_state=agent_state, + organization_id=organization_id, + user_id=user_id, + ) + + # v7.6 (direction D): materialise the extracted relations as anchor->anchor + # edges. v7 discarded relations entirely; these give retrieval a graph to + # propagate over (PPR) and are what make multi-hop paths traversable. + if relations: + await self._link_relation_edges(driver, relations=relations, user_id=user_id) + + # v7.10 (hypergraph): reify each triple as a V7Fact HYPEREDGE node linking its + # subject entity, object entity, the source memory, plus role (who) + time (when) + # as node properties — a multi-dimensional (entity × person/role × time) index + # over facts, instead of the binary anchor→anchor edge alone. + if settings.graph_version == "v7.10" and relations: + await self._upsert_facts( + driver, relations=relations, memory_ref_id=memory_ref_id, + role=role, timestamp=timestamp, user_id=user_id) + + await self._link_support_edges( + driver, + source_kind=source_kind, + memory_ref_id=memory_ref_id, + user_id=user_id, + source_key=source_key, + ) + if source_kind == "episodic": + await self._link_temporal_edge(driver, memory_ref_id=memory_ref_id, user_id=user_id, timestamp=timestamp) + + return { + "anchors": len(candidates), + "memory_ref": memory_ref_id, + "source_key": source_key, + } + + # ------------------------------------------------------------------ gate + + def _select_anchors(self, entities: list[ExtractedEntity], *, max_anchors: int) -> list[V7AnchorCandidate]: + by_name: dict[str, V7AnchorCandidate] = {} + for entity in entities: + name = self._clean_name(entity.name) + nl = anchor_canonical_key(name) + if not name or not nl: + continue + # Dialogue roles must never anchor, regardless of which extractor produced + # them. The per-extractor _NOISE sets are surface-form blocklists and keep + # leaking variants ("Users" reached degree 1016 through triple extraction; + # LightRAG has no filter at all, so plain v7/v8 could mint the same hub). + # Gating on the canonical key here — the funnel every path goes through — + # is case/article/plural-insensitive by construction ("The Users" -> "user"). + if nl in _ROLE_NOISE_KEYS: + continue + score = self._specificity_score(name, entity.entity_type or "Other") + if score <= 0: + continue + candidate = V7AnchorCandidate( + name=name, + name_lower=nl, + anchor_type=entity.entity_type or "Other", + score=score, + ) + existing = by_name.get(nl) + if existing is None or candidate.score > existing.score: + by_name[nl] = candidate + + return sorted(by_name.values(), key=lambda c: (-c.score, c.name_lower))[:max_anchors] + + def _specificity_score(self, name: str, entity_type: str) -> float: + nl = normalize_name(name) + if not nl or nl in _GENERIC_NAMES: + return 0.0 + if any(nl.endswith(suffix) for suffix in _GENERIC_SUFFIXES): + return 0.0 + if len(nl) < 3: + return 0.0 + + score = 0.0 + type_norm = entity_type.strip().lower() + if type_norm in {"person", "location", "organization", "event"}: + score += 4.0 + elif type_norm in {"content", "object"}: + score += 3.0 + elif type_norm in {"concept", "method"}: + score += 1.0 + else: + score += 0.5 + + words = nl.split() + if len(words) >= 2: + score += 1.5 + if len(words) >= 3: + score += 0.5 + if any(ch.isdigit() for ch in name): + score += 2.0 + if _SPECIFIC_HINT_RE.search(name): + score += 1.0 + if name[:1].isupper(): + score += 0.5 + if len(words) == 1 and name[:1].isupper() and len(nl) >= 4: + score += 2.5 + if len(words) == 1 and type_norm in {"concept", "method", "other"} and score < 3.0: + return 0.0 + return score + + @staticmethod + def _clean_name(name: str) -> str: + return " ".join((name or "").strip().strip("\"'`").split()) + + # ------------------------------------------------------------- neo4j write + + async def _upsert_memory_ref( + self, + driver, + *, + source_kind: SourceKind, + ref_id: str, + memory_id: str, + title: str, + preview: str, + timestamp: Optional[str], + source_key: Optional[str], + source_meta: dict[str, Any], + organization_id: str, + user_id: str, + ) -> None: + label = "V7EpisodeRef" if source_kind == "episodic" else "V7ConceptRef" + now = iso(datetime.now(timezone.utc)) + async with driver.session(database=settings.neo4j_database) as session: + await session.run( + f""" + MERGE (m:V7MemoryRef:{label} {{id: $id}}) + SET m.memory_id = $memory_id, + m.memory_type = $memory_type, + m.user_id = $user_id, + m.organization_id = $organization_id, + m.preview = $preview, + m.source_key = $source_key, + m.updated_at = $now, + m.created_at = coalesce(m.created_at, $now) + SET m.timestamp = $timestamp + """, + # NOTE: title and source_meta_json are intentionally NOT stored. + # The retriever only reads memory_id (then fetches full details + # from PG), and V7_SUPPORTED_BY is built from source_key — so both + # fields were write-only dead weight. preview is kept as a short + # debug snippet (it already falls back to title when no summary). + id=ref_id, + memory_id=memory_id, + memory_type=source_kind, + user_id=user_id, + organization_id=organization_id, + preview=preview, + source_key=source_key, + timestamp=timestamp, + now=now, + ) + + async def _upsert_anchors_and_edges( + self, + driver, + *, + anchors: list[V7AnchorCandidate], + source_kind: SourceKind, + memory_ref_id: str, + agent_state: AgentState, + organization_id: str, + user_id: str, + ) -> None: + existing = await self._fetch_existing_anchors(driver, user_id, [a.name_lower for a in anchors]) + new_anchors = [a for a in anchors if a.name_lower not in existing] + embeddings = await embed_batch([a.name for a in new_anchors], agent_state) if new_anchors else [] + emb_by_lower = {a.name_lower: emb for a, emb in zip(new_anchors, embeddings)} + + now = iso(datetime.now(timezone.utc)) + rows = [ + { + "id": existing.get(a.name_lower, {}).get("id") or gen_id("v7anc"), + "name": a.name, + "name_lower": a.name_lower, + "anchor_type": a.anchor_type, + "score": a.score, + "name_embedding": emb_by_lower.get(a.name_lower), + } + for a in anchors + ] + rel_type = "V7_APPEARS_IN" if source_kind == "episodic" else "V7_DESCRIBED_BY" + + async with driver.session(database=settings.neo4j_database) as session: + await session.run( + f""" + UNWIND $rows AS row + MERGE (a:V7Anchor {{user_id: $user_id, name_lower: row.name_lower}}) + ON CREATE SET + a.id = row.id, + a.name = row.name, + a.anchor_type = row.anchor_type, + a.organization_id = $organization_id, + a.created_at = $now, + a.mention_count = 0 + SET a.updated_at = $now, + a.admission_score = row.score, + a.mention_count = coalesce(a.mention_count, 0) + 1 + WITH a, row + CALL (a, row) {{ + WITH a, row WHERE row.name_embedding IS NOT NULL + CALL db.create.setNodeVectorProperty(a, 'name_embedding', row.name_embedding) + RETURN count(*) AS _ + }} + WITH a + MATCH (m:V7MemoryRef {{id: $memory_ref_id}}) + MERGE (a)-[r:{rel_type}]->(m) + ON CREATE SET r.created_at = $now + """, + rows=rows, + user_id=user_id, + organization_id=organization_id, + memory_ref_id=memory_ref_id, + now=now, + ) + + async def _fetch_existing_anchors( + self, driver, user_id: str, name_lowers: list[str] + ) -> dict[str, dict[str, Any]]: + if not name_lowers: + return {} + async with driver.session(database=settings.neo4j_database) as session: + result = await session.run( + """ + UNWIND $names AS nl + MATCH (a:V7Anchor {user_id: $user_id, name_lower: nl}) + RETURN a.id AS id, a.name_lower AS name_lower + """, + names=name_lowers, + user_id=user_id, + ) + return {rec["name_lower"]: dict(rec) async for rec in result} + + async def _link_support_edges( + self, + driver, + *, + source_kind: SourceKind, + memory_ref_id: str, + user_id: str, + source_key: Optional[str], + ) -> None: + if not source_key: + return + now = iso(datetime.now(timezone.utc)) + if source_kind == "semantic": + cypher = """ + MATCH (sem:V7ConceptRef {id: $memory_ref_id, user_id: $user_id}) + MATCH (ep:V7EpisodeRef {user_id: $user_id, source_key: $source_key}) + MERGE (sem)-[r:V7_SUPPORTED_BY]->(ep) + ON CREATE SET r.created_at = $now, r.reason = 'same_source_chunk' + """ + else: + cypher = """ + MATCH (ep:V7EpisodeRef {id: $memory_ref_id, user_id: $user_id}) + MATCH (sem:V7ConceptRef {user_id: $user_id, source_key: $source_key}) + MERGE (sem)-[r:V7_SUPPORTED_BY]->(ep) + ON CREATE SET r.created_at = $now, r.reason = 'same_source_chunk' + """ + async with driver.session(database=settings.neo4j_database) as session: + await session.run( + cypher, + memory_ref_id=memory_ref_id, + user_id=user_id, + source_key=source_key, + now=now, + ) + + async def _link_temporal_edge( + self, driver, *, memory_ref_id: str, user_id: str, timestamp: Optional[str] + ) -> None: + if not timestamp: + return + now = iso(datetime.now(timezone.utc)) + async with driver.session(database=settings.neo4j_database) as session: + await session.run( + """ + MATCH (cur:V7EpisodeRef {id: $memory_ref_id, user_id: $user_id}) + MATCH (prev:V7EpisodeRef {user_id: $user_id}) + WHERE prev.id <> cur.id AND prev.timestamp <= $timestamp + WITH cur, prev + ORDER BY prev.timestamp DESC + LIMIT 1 + MERGE (prev)-[r:V7_NEXT_MEMORY]->(cur) + ON CREATE SET r.created_at = $now + """, + memory_ref_id=memory_ref_id, + user_id=user_id, + timestamp=timestamp, + now=now, + ) + + async def _upsert_facts( + self, driver, *, relations: list[tuple[str, str, str]], memory_ref_id: str, + role: Optional[str], timestamp: Optional[str], user_id: str, + ) -> None: + """v7.10 hypergraph: each triple becomes a V7Fact HYPEREDGE node connecting its + subject anchor, object anchor and the source memory, carrying role (user/ + assistant/shared) and time as properties. This reifies the n-ary fact so it can + be queried on any dimension (entity, who, when) — e.g. "assistant-role facts + about X in month M" — which a binary anchor→anchor edge cannot express.""" + rows = [] + seen: set[str] = set() + for subj, rel, obj in relations: + sk = anchor_canonical_key(self._clean_name(subj)) + ok = anchor_canonical_key(self._clean_name(obj)) + # Skip tautologies ("french -include-> french"): an extraction artifact that + # asserts nothing. Mirrors the sk != ok guard in _link_relation_edges. + if not sk or not ok or sk == ok: + continue + pred = canon_predicate(rel)[:80] + fid = fact_identity(user_id, sk, pred, ok) + if fid in seen: # same triple repeated inside one memory + continue + seen.add(fid) + rows.append({"fid": fid, "s": sk, "o": ok, "pred": pred}) + if not rows: + return + # Deterministic ids mean concurrent ingests now MERGE onto the SAME fact node + # (that is the point — one fact, many citations), so they contend for its lock. + # Sorting gives every transaction the same lock-acquisition order, which removes + # the classic lock-ordering deadlock; the retry covers what is left. + rows.sort(key=lambda r: r["fid"]) + now = iso(datetime.now(timezone.utc)) + role_norm = (role or "shared").lower() + cypher = """ + MATCH (m:V7MemoryRef {id: $mref, user_id: $user_id}) + UNWIND $rows AS row + MATCH (s:V7Anchor {user_id: $user_id, name_lower: row.s}) + MATCH (o:V7Anchor {user_id: $user_id, name_lower: row.o}) + MERGE (f:V7Fact {id: row.fid}) + ON CREATE SET f.user_id = $user_id, f.predicate = row.pred, + f.role = $role, f.timestamp = $ts, f.created_at = $now + MERGE (f)-[:V7_FACT_SUBJECT]->(s) + MERGE (f)-[:V7_FACT_OBJECT]->(o) + MERGE (f)-[cite:V7_FACT_FROM]->(m) + ON CREATE SET cite.role = $role, cite.timestamp = $ts + """ + for attempt in range(5): + try: + async with driver.session(database=settings.neo4j_database) as session: + await session.run( + cypher, mref=memory_ref_id, user_id=user_id, rows=rows, + role=role_norm, ts=timestamp, now=now) + return + except Exception as exc: # noqa: BLE001 + transient = "Deadlock" in type(exc).__name__ or "Deadlock" in str(exc) \ + or "TransientError" in type(exc).__name__ or "TransientError" in str(exc) + if not transient or attempt == 4: + raise + await asyncio.sleep(0.1 * (2 ** attempt)) + + async def _link_relation_edges( + self, driver, *, relations: list[tuple[str, str, str]], user_id: str + ) -> None: + """v7.6: materialise (subject, relation, object) triples as anchor->anchor + V7_RELATION edges. Endpoints are matched by the same canonical key anchors + merge on, so an edge is only created when both entities survived as anchors. + Distinct relation phrases between the same pair are distinct edges.""" + rows = [] + for subj, rel, obj in relations: + sk = anchor_canonical_key(self._clean_name(subj)) + ok = anchor_canonical_key(self._clean_name(obj)) + if sk and ok and sk != ok: + rows.append({"s": sk, "o": ok, "r": (rel or "related to")[:80]}) + if not rows: + return + now = iso(datetime.now(timezone.utc)) + async with driver.session(database=settings.neo4j_database) as session: + await session.run( + """ + UNWIND $rows AS row + MATCH (a:V7Anchor {user_id: $user_id, name_lower: row.s}) + MATCH (b:V7Anchor {user_id: $user_id, name_lower: row.o}) + MERGE (a)-[e:V7_RELATION {rel: row.r}]->(b) + ON CREATE SET e.created_at = $now + """, + rows=rows, + user_id=user_id, + now=now, + ) + + # ---------------------------------------------------------------- utils + + @staticmethod + def _preview(text: str) -> str: + return " ".join((text or "").split())[:PREVIEW_CHARS] + + @staticmethod + def _to_iso(value: object) -> Optional[str]: + if value is None: + return None + if isinstance(value, datetime): + return value.isoformat() + return str(value) + + @staticmethod + def _source_key(source_meta: Optional[dict[str, Any]]) -> Optional[str]: + if not source_meta: + return None + for key in ("chunk_id", "turn_id", "serial"): + if source_meta.get(key) is not None: + return f"{key}:{source_meta[key]}" + if source_meta.get("occurred_at"): + return f"occurred_at:{source_meta['occurred_at']}" + return None + + # ------------------------------------------------------------- v8 finalize + + async def maintain_graph( + self, user_id: str, *, valid_memory_ids: Optional[set[str]] = None + ) -> dict[str, Any]: + """Periodic graph maintenance — the redundancy that CANNOT be prevented at + write time. Wired into the auto_dream cycle because that is where the memory + store gets mutated. + + NB: auto_dream is **not** self-scheduling — nothing in the codebase invokes it + on a timer; it is a REST endpoint (`/memory/auto_dream`) somebody has to call. + So this maintenance runs exactly as often as auto_dream is triggered, which may + be never. Call it directly if you need it on a real schedule. + + Ingest-time guards (``fact_identity`` / ``canon_predicate`` / the tautology + skip in ``_upsert_facts``) keep NEW facts clean, but two things are only + knowable corpus-globally, after the fact: + + * **orphaned memory refs** — auto_dream consolidates/deletes PG memories, so + graph refs to them dangle. Pass ``valid_memory_ids`` to sweep them. + * **dead-weight anchors** — whether an anchor ever earns a fact or a second + memory depends on the whole corpus, unknowable when it was created. + + The tautology/duplicate passes are defensive: those are prevented at ingest + now, but graphs built before that still carry them. + + Idempotent and cheap (a handful of scans) relative to the LLM step + auto_dream already performs. Only ever removes graph nodes that can no + longer contribute — never PG rows. + """ + if not settings.enable_graph_memory: + return {"skipped": "graph_disabled"} + + from mirix.database.neo4j_client import get_neo4j_driver + + driver = get_neo4j_driver() + if driver is None: + return {"skipped": "no_driver"} + + stats: dict[str, Any] = {} + async with driver.session(database=settings.neo4j_database) as session: + # 1. refs whose source memory is gone (auto_dream deletions) + if valid_memory_ids is not None: + res = await session.run( + """ + MATCH (m:V7MemoryRef {user_id: $uid}) + WHERE NOT m.memory_id IN $valid + WITH m, count(*) AS _ + DETACH DELETE m + RETURN count(*) AS n + """, + uid=user_id, valid=list(valid_memory_ids), + ) + rec = await res.single() + stats["orphan_refs_removed"] = int(rec["n"]) if rec else 0 + + # 2. tautological facts (subject == object) — asserts nothing + res = await session.run( + """ + MATCH (x)<-[:V7_FACT_SUBJECT]-(f:V7Fact {user_id: $uid})-[:V7_FACT_OBJECT]->(y) + WHERE toLower(x.name) = toLower(y.name) + DETACH DELETE f + RETURN count(*) AS n + """, + uid=user_id, + ) + rec = await res.single() + stats["tautologies_removed"] = int(rec["n"]) if rec else 0 + + # 3. duplicate triples -> one fact, every source kept as a citation edge + res = await session.run( + """ + MATCH (su)<-[:V7_FACT_SUBJECT]-(f:V7Fact {user_id: $uid})-[:V7_FACT_OBJECT]->(o) + WITH toLower(su.name) + '|' + coalesce(f.predicate, '') + '|' + toLower(o.name) AS k, + collect(f) AS fs + WHERE size(fs) > 1 + WITH head(fs) AS keep, tail(fs) AS dupes + UNWIND dupes AS dupe + OPTIONAL MATCH (dupe)-[:V7_FACT_FROM]->(m:V7MemoryRef) + FOREACH (_ IN CASE WHEN m IS NULL THEN [] ELSE [1] END | + MERGE (keep)-[:V7_FACT_FROM]->(m)) + WITH DISTINCT dupe + DETACH DELETE dupe + RETURN count(*) AS n + """, + uid=user_id, + ) + rec = await res.single() + stats["duplicate_facts_merged"] = int(rec["n"]) if rec else 0 + + # 3b. zombie facts: a fact whose every V7_FACT_FROM citation died (its + # source memories were consolidated away) is unverifiable residue — in + # v7.10 a fact's existence is justified by its citations. Without this + # sweep the graph GROWS through consolidation instead of shrinking: + # merged memories re-extract new facts while the old ones linger + # (measured on LoCoMo conv-26: 1583 facts post-dream vs 1249 no-dream, + # +27% for a 2% smaller store). Runs before the dead-anchor pass so + # anchors orphaned by this deletion are pruned in the same cycle. + res = await session.run( + """ + MATCH (f:V7Fact {user_id: $uid}) + WHERE NOT (f)-[:V7_FACT_FROM]->() + DETACH DELETE f + RETURN count(*) AS n + """, + uid=user_id, + ) + rec = await res.single() + stats["zombie_facts_removed"] = int(rec["n"]) if rec else 0 + + # 4. dead-weight anchors: no fact touches them AND they link <=1 memory, + # so they can neither answer nor bridge + res = await session.run( + """ + MATCH (a:V7Anchor {user_id: $uid}) + WHERE NOT (a)<-[:V7_FACT_SUBJECT|V7_FACT_OBJECT]-(:V7Fact) + WITH a, size([(a)-[:V7_APPEARS_IN|V7_DESCRIBED_BY]->(:V7MemoryRef) | 1]) AS deg + WHERE deg <= 1 + DETACH DELETE a + RETURN count(*) AS n + """, + uid=user_id, + ) + rec = await res.single() + stats["dead_anchors_pruned"] = int(rec["n"]) if rec else 0 + + # 5. role-noise anchors: dialogue roles that slipped past older extractor + # filters ("Users" once reached degree 1016 with 504 role-noise facts — + # 10.5% of all facts). The _select_anchors gate stops NEW ones, but on a + # graph built before that fix the existing anchor keeps accreting edges + # (relation/fact endpoints MATCH it by canonical name). Purge it and the + # facts that cite it as subject/object — those facts are role noise by + # construction ("Users interested in X" carries no entity identity). + res = await session.run( + """ + MATCH (a:V7Anchor {user_id: $uid}) + WHERE a.name_lower IN $noise + OPTIONAL MATCH (f:V7Fact)-[:V7_FACT_SUBJECT|V7_FACT_OBJECT]->(a) + WITH a, collect(DISTINCT f) AS facts + FOREACH (f IN facts | DETACH DELETE f) + DETACH DELETE a + RETURN count(a) AS anchors, sum(size(facts)) AS facts + """, + uid=user_id, noise=sorted(_ROLE_NOISE_KEYS), + ) + rec = await res.single() + stats["role_noise_anchors_purged"] = int(rec["anchors"] or 0) if rec else 0 + stats["role_noise_facts_purged"] = int(rec["facts"] or 0) if rec else 0 + + # 6. temporal-chain repair: consolidation deletes episode refs, and the + # V7_NEXT_MEMORY edges through them die with the DETACH — leaving the + # chain fragmented (measured: 52 edges over 100 refs post-dream where a + # full chain has n-1 = 99). Rebuild it from ref timestamps, per user. + # Idempotent; same construction the ingest path produces incrementally. + await session.run( + "MATCH (e:V7EpisodeRef {user_id: $uid})-[r:V7_NEXT_MEMORY]->() DELETE r", + uid=user_id, + ) + res = await session.run( + """ + MATCH (e:V7EpisodeRef {user_id: $uid}) WHERE e.timestamp IS NOT NULL + WITH e ORDER BY e.timestamp ASC + WITH collect(e) AS refs + UNWIND range(0, size(refs) - 2) AS i + WITH refs[i] AS prev, refs[i + 1] AS cur + MERGE (prev)-[:V7_NEXT_MEMORY]->(cur) + RETURN count(*) AS n + """, + uid=user_id, + ) + rec = await res.single() + stats["temporal_chain_edges"] = int(rec["n"]) if rec else 0 + + logger.info("graph maintenance for user=%s: %s", user_id, stats) + return stats + + async def prune_singletons(self, user_id: str) -> dict[str, Any]: + """v8 finalize pass: delete degree-1 anchors — anchors that link only a + single memory ref and therefore create no cross-memory retrieval path + (the value a graph adds over flat PG vector search). An anchor's final + degree is only known after ALL of a user's memories are ingested, so + this must run ONCE at the end of ingestion, not incrementally. + + No-op unless ``graph_version == 'v8'`` — v7 keeps every admitted anchor. + Safe: only anchor nodes are removed (never memory refs or PG rows), and + every memory remains reachable via its multi-degree anchors, the + V7_SUPPORTED_BY / V7_NEXT_MEMORY bridges, and flat PG search. + """ + if not settings.enable_graph_memory or settings.graph_version != "v8": + return {"skipped": "not_v8"} + + from mirix.database.neo4j_client import get_neo4j_driver + + driver = get_neo4j_driver() + if driver is None: + return {"skipped": "no_driver"} + + async with driver.session(database=settings.neo4j_database) as session: + count_res = await session.run( + """ + MATCH (a:V7Anchor {user_id: $uid})-[:V7_APPEARS_IN|V7_DESCRIBED_BY]->(m:V7MemoryRef) + WITH a, count(DISTINCT m) AS deg WHERE deg = 1 + RETURN count(a) AS n + """, + uid=user_id, + ) + rec = await count_res.single() + n = int(rec["n"]) if rec else 0 + if n: + await session.run( + """ + MATCH (a:V7Anchor {user_id: $uid})-[:V7_APPEARS_IN|V7_DESCRIBED_BY]->(m:V7MemoryRef) + WITH a, count(DISTINCT m) AS deg WHERE deg = 1 + DETACH DELETE a + """, + uid=user_id, + ) + logger.info("v8 prune_singletons: removed %d degree-1 anchors for user=%s", n, user_id) + return {"pruned": n} diff --git a/mirix/services/graph_reconsolidator.py b/mirix/services/graph_reconsolidator.py new file mode 100644 index 000000000..d999a4297 --- /dev/null +++ b/mirix/services/graph_reconsolidator.py @@ -0,0 +1,289 @@ +"""Periodic SEMANTIC reconsolidation of the v7 graph. + +``maintain_graph`` handles the *structural* redundancy — byte-identical triples, +tautologies, dead weight. This module handles what it cannot see: + +* **Pass A — cluster near-duplicates.** Anchors that mean the same thing but were + written differently ("nasal irrigation" / "nasal saline irrigation", + "Duracell" / "Duracell batteries"). Ingest-time resolution + (:mod:`entity_resolver`) only compares against whatever registry existed at the + time, so late-arriving variants are never reconciled. Embedding similarity only + proposes candidates — an LLM decides, because cosine cannot: in this store + ``"10-20% of Overall Marketing Budget"`` and ``"5-10% of Overall Marketing + Budget"`` sit at cos 0.975 and must NOT be merged. + +* **Pass B — flag conflicts, do not resolve them.** Same subject + predicate with + different objects looks like a contradiction but usually is not: ``include`` / + ``offers`` / ``enhanced with`` are legitimately multi-valued, and "resolving" + them would delete real list structure (697 such groups here, nearly all + legitimate). Genuine contradictions live on *functional* predicates — a count, a + price, a speed. Those are flagged for review only; nothing is deleted, because + the single- vs multi-valued classifier has not been validated yet. +""" +from __future__ import annotations + +import json + +from typing import Any, Optional + +from mirix.log import get_logger +from mirix.services._graph_common import llm_model_from_agent +from mirix.settings import settings + +logger = get_logger(__name__) + +# Cosine only proposes; the LLM disposes. Set well above the ingest-time gate so a +# periodic sweep re-examines only the genuinely close pairs. +_PAIR_COS = 0.93 +_MAX_VERIFY = 120 # cap LLM calls per cycle +_BATCH = 20 + +MERGE_PROMPT = """You canonicalize entity names for a knowledge graph. For each PAIR below, decide whether the two names denote the SAME real-world entity/concept. +Be CONSERVATIVE — only say true when they are truly the same thing. Say false for different quantities or ranges ("5-10% of budget" vs "10-20% of budget", "42 campsites" vs "46 campsites"), different days/months, different form/model codes ("I-601" vs "I-601A"), and merely related-but-distinct concepts ("Comedy" vs "Comedians"). +Prefer the more complete/specific name as the canonical one when they DO match. +Return JSON: {"results": [{"a": "...", "b": "...", "same": true/false, "canonical": "..."}, ...]}""" + +# Whether a predicate is single-valued is NOT expressible as a regex — measured on this +# store, a loose pattern flagged ~5x too much ("charge fees for [Checked Bags, carry-on]", +# "emit carbon -> [40g, 120g, 16.4g]" for different transport modes — legitimate lists a +# resolver would have destroyed), while a tight one flagged nothing at all, missing even +# a genuine "shelf life -> ['4-year shelf life', '4 years']". So an LLM classifies the +# predicates instead; there are only a few hundred distinct ones and the verdict caches. +CONFLICT_PROMPT = """Each item below is one subject, one predicate, and the several values recorded for it. Decide whether the values CONTRADICT each other or are a legitimate LIST. + +CONTRADICTION: they are competing answers to the same question, so at most one can be true — "costs -> [$800, $1200]", "owns -> [three bikes, four bikes]", "shelf life -> [4 years, 6 years]". +LIST: they coexist happily — themes of a book, steps of a process, benefits of a card, things a role is responsible for. + +Judge the VALUES, not the wording of the predicate. When unsure answer LIST: wrongly calling a list a contradiction is the costly error. +Return JSON: {"results": [{"i": , "contradiction": true/false}, ...]}""" + + +async def _anchor_pairs(driver, user_id: str) -> list[tuple[str, str, float]]: + """Near-duplicate anchor candidates via the existing name-embedding index.""" + pairs: dict[tuple[str, str], float] = {} + async with driver.session(database=settings.neo4j_database) as session: + res = await session.run( + """ + MATCH (a:V7Anchor {user_id: $u}) WHERE a.name_embedding IS NOT NULL + CALL db.index.vector.queryNodes('v7_anchor_name_emb', 4, a.name_embedding) + YIELD node AS b, score AS sc + WHERE b.user_id = $u AND b.name <> a.name AND sc >= $th + RETURN a.name AS a, b.name AS b, sc + """, + u=user_id, th=_PAIR_COS) + async for r in res: + key = tuple(sorted((r["a"], r["b"]))) # undirected, dedup both directions + pairs[key] = max(pairs.get(key, 0.0), float(r["sc"])) + return [(a, b, s) for (a, b), s in sorted(pairs.items(), key=lambda kv: -kv[1])] + + +async def _verify(pairs, agent_state) -> list[tuple[str, str, str]]: + """LLM-confirmed merges: [(drop, keep, canonical)]. Cosine cannot decide these.""" + from mirix.services.lightrag_extractor import call_openai_chat + + model = llm_model_from_agent(agent_state, default="gpt-4.1-mini") + confirmed: list[tuple[str, str, str]] = [] + for i in range(0, len(pairs), _BATCH): + chunk = pairs[i:i + _BATCH] + payload = json.dumps([{"a": a, "b": b} for a, b, _ in chunk], ensure_ascii=False) + try: + raw = await call_openai_chat(MERGE_PROMPT, payload, model, temperature=0.0) + blob = raw if raw.strip().startswith("{") else raw[raw.find("{"): raw.rfind("}") + 1] + for item in (json.loads(blob) or {}).get("results", []): + if not item.get("same"): + continue + a, b = item.get("a"), item.get("b") + canon = item.get("canonical") or b + # only accept a canonical that is one of the two names actually shown, + # so the LLM cannot invent a third name and orphan both anchors + if a and b and canon in (a, b): + confirmed.append((a if canon == b else b, canon, canon)) + except Exception as exc: # noqa: BLE001 + logger.warning("reconsolidate: verify batch failed (%s)", exc) + return confirmed + + +async def _confirm_conflicts(groups: list[dict], agent_state) -> list[dict]: + """Stage 2 of the funnel: judge the actual competing VALUES. + + Asking an LLM to label a bare predicate does not work — it called "include step" + and "include specific percentages for" single-valued. Shown the values instead, + the question becomes concrete. Fails CLOSED (returns nothing on error).""" + if not groups: + return [] + from mirix.services.lightrag_extractor import call_openai_chat + + model = llm_model_from_agent(agent_state, default="gpt-4.1-mini") + out: list[dict] = [] + for i in range(0, len(groups), 25): + chunk = groups[i:i + 25] + payload = json.dumps( + [{"i": j, "subject": g["subject"], "predicate": g["predicate"], + "values": g["objects"]} for j, g in enumerate(chunk)], ensure_ascii=False) + try: + raw = await call_openai_chat(CONFLICT_PROMPT, payload, model, temperature=0.0) + blob = raw if raw.strip().startswith("{") else raw[raw.find("{"): raw.rfind("}") + 1] + for item in (json.loads(blob) or {}).get("results", []): + idx = item.get("i") + if item.get("contradiction") and isinstance(idx, int) and 0 <= idx < len(chunk): + out.append(chunk[idx]) + except Exception as exc: # noqa: BLE001 + logger.warning("reconsolidate: conflict judging failed (%s)", exc) + return out + + +async def _arity_candidates(driver, user_id: str) -> list[dict]: + """Stage 1 of the funnel: predicates that are USUALLY single-object, judged from + observed data rather than their wording. `include` is 75% multi-object across its + 259 uses (so: a list); `cost` is 17% (so: a candidate contradiction). This alone + cuts 1,213 predicates to ~29 and is what makes stage 2 affordable and accurate.""" + async with driver.session(database=settings.neo4j_database) as session: + res = await session.run( + """ + MATCH (su)<-[:V7_FACT_SUBJECT]-(f:V7Fact {user_id:$u})-[:V7_FACT_OBJECT]->(o) + WITH f.predicate AS p, su.name AS s, collect(DISTINCT o.name) AS objs + WITH p, count(*) AS uses, + sum(CASE WHEN size(objs) > 1 THEN 1 ELSE 0 END) AS multi, + collect(CASE WHEN size(objs) > 1 THEN {s: s, objs: objs} END) AS hits + WHERE uses >= 3 AND multi >= 1 AND toFloat(multi) / uses <= 0.25 + RETURN p, [h IN hits WHERE h IS NOT NULL] AS hits + """, u=user_id) + out: list[dict] = [] + async for r in res: + for h in r["hits"]: + if len(h["objs"]) <= 3: + out.append({"subject": h["s"], "predicate": r["p"] or "", + "objects": h["objs"]}) + return out + + +async def _merge_anchor(session, user_id: str, drop: str, keep: str) -> None: + """Redirect every edge off `drop` onto `keep`, then remove `drop`. + + Done per relationship type because APOC is not installed. MERGE (not CREATE) + on the target so redirecting never duplicates an edge that already exists. + """ + if drop == keep: + return + stmts = [ + """MATCH (f:V7Fact)-[r:V7_FACT_SUBJECT]->(d:V7Anchor {user_id:$u, name:$drop}) + MATCH (k:V7Anchor {user_id:$u, name:$keep}) + MERGE (f)-[:V7_FACT_SUBJECT]->(k) DELETE r""", + """MATCH (f:V7Fact)-[r:V7_FACT_OBJECT]->(d:V7Anchor {user_id:$u, name:$drop}) + MATCH (k:V7Anchor {user_id:$u, name:$keep}) + MERGE (f)-[:V7_FACT_OBJECT]->(k) DELETE r""", + """MATCH (d:V7Anchor {user_id:$u, name:$drop})-[r:V7_APPEARS_IN]->(m) + MATCH (k:V7Anchor {user_id:$u, name:$keep}) + MERGE (k)-[:V7_APPEARS_IN]->(m) DELETE r""", + """MATCH (d:V7Anchor {user_id:$u, name:$drop})-[r:V7_DESCRIBED_BY]->(m) + MATCH (k:V7Anchor {user_id:$u, name:$keep}) + MERGE (k)-[:V7_DESCRIBED_BY]->(m) DELETE r""", + """MATCH (d:V7Anchor {user_id:$u, name:$drop})-[r:V7_RELATION]->(o) + MATCH (k:V7Anchor {user_id:$u, name:$keep}) WHERE o <> k + MERGE (k)-[:V7_RELATION]->(o) DELETE r""", + """MATCH (o)-[r:V7_RELATION]->(d:V7Anchor {user_id:$u, name:$drop}) + MATCH (k:V7Anchor {user_id:$u, name:$keep}) WHERE o <> k + MERGE (o)-[:V7_RELATION]->(k) DELETE r""", + """MATCH (d:V7Anchor {user_id:$u, name:$drop}) DETACH DELETE d""", + ] + for q in stmts: + await session.run(q, u=user_id, drop=drop, keep=keep) + + +async def _due(driver, user_id: str, every_n: int) -> tuple[bool, int]: + """Churn gate. Reconsolidation is LLM-priced, so it runs per N memories CHANGED + rather than per elapsed time: cost tracks activity instead of firing on an idle + store or falling far behind during a burst. The mark lives on a marker node so the + graph carries its own maintenance state. + + Compares |now - mark|, not now - mark. Consolidation makes the store SHRINK — an + auto_dream cycle here took it 962 -> 678 — so a growth-only test goes negative and + the gate can then never fire again. Shrinking is exactly when reconsolidation is + most warranted, since merges are what create new near-duplicates. + """ + async with driver.session(database=settings.neo4j_database) as session: + rec = await (await session.run( + """ + MATCH (m:V7MemoryRef {user_id: $u}) + WITH count(m) AS now + OPTIONAL MATCH (k:V7Meta {user_id: $u}) + RETURN now, coalesce(k.last_reconsolidated_at_count, 0) AS mark + """, u=user_id)).single() + now = int(rec["now"]) if rec else 0 + mark = int(rec["mark"]) if rec else 0 + return abs(now - mark) >= every_n, now + + +async def _mark_done(driver, user_id: str, count: int) -> None: + async with driver.session(database=settings.neo4j_database) as session: + await session.run( + """MERGE (k:V7Meta {user_id: $u}) + SET k.last_reconsolidated_at_count = $c""", u=user_id, c=count) + + +async def reconsolidate_graph( + driver, *, user_id: str, agent_state: Any, dry_run: bool = False, + every_n_memories: Optional[int] = None, +) -> dict[str, Any]: + """Run one semantic reconsolidation cycle. Returns stats. + + Pass ``every_n_memories`` to self-gate: the cycle is skipped unless that many + memories have been added since the last run. + """ + stats: dict[str, Any] = {"dry_run": dry_run} + memory_count = None + if every_n_memories: + due, memory_count = await _due(driver, user_id, every_n_memories) + if not due: + return {"skipped": "not_due", "memory_count": memory_count} + + # ---- Pass A: cluster near-duplicate anchors ---- + pairs = await _anchor_pairs(driver, user_id) + stats["candidate_pairs"] = len(pairs) + confirmed = await _verify(pairs[:_MAX_VERIFY], agent_state) if pairs else [] + stats["llm_confirmed_merges"] = len(confirmed) + merged = 0 + if confirmed and not dry_run: + async with driver.session(database=settings.neo4j_database) as session: + done: set[str] = set() + for drop, keep, _ in confirmed: + if drop in done or keep in done: + continue # avoid chaining onto an anchor already removed + await _merge_anchor(session, user_id, drop, keep) + done.add(drop) + merged += 1 + # merging can make two facts identical — collapse, keeping every citation + res = await session.run( + """ + MATCH (su)<-[:V7_FACT_SUBJECT]-(f:V7Fact {user_id:$u})-[:V7_FACT_OBJECT]->(o) + WITH toLower(su.name)+'|'+coalesce(f.predicate,'')+'|'+toLower(o.name) AS k, + collect(f) AS fs + WHERE size(fs) > 1 + WITH head(fs) AS keep, tail(fs) AS dupes + UNWIND dupes AS dupe + OPTIONAL MATCH (dupe)-[:V7_FACT_FROM]->(m:V7MemoryRef) + FOREACH (_ IN CASE WHEN m IS NULL THEN [] ELSE [1] END | + MERGE (keep)-[:V7_FACT_FROM]->(m)) + WITH DISTINCT dupe + DETACH DELETE dupe + RETURN count(*) AS n + """, u=user_id) + rec = await res.single() + stats["facts_collapsed_after_merge"] = int(rec["n"]) if rec else 0 + stats["anchors_merged"] = merged + + # ---- Pass B: REPORT conflicts. Never resolves, never deletes. ---- + # Two-stage funnel: observed arity narrows the field, then an LLM judges the actual + # competing values. Report-only by design — see the module docstring for why. + candidates = await _arity_candidates(driver, user_id) + stats["conflict_candidates"] = len(candidates) + conflicts = await _confirm_conflicts(candidates, agent_state) + stats["conflicts_reported"] = len(conflicts) + stats["conflict_samples"] = conflicts[:6] + + if every_n_memories and not dry_run and memory_count is not None: + await _mark_done(driver, user_id, memory_count) + + logger.info("graph reconsolidation user=%s: %s", user_id, + {k: v for k, v in stats.items() if k != "conflict_samples"}) + return stats diff --git a/mirix/services/graph_retriever_dispatcher.py b/mirix/services/graph_retriever_dispatcher.py new file mode 100644 index 000000000..260c39b5e --- /dev/null +++ b/mirix/services/graph_retriever_dispatcher.py @@ -0,0 +1,54 @@ +""" +Top-level graph-retrieval dispatcher. + +Entry point from rest_api.retrieve_memories_by_keywords. Routes to the retriever +for the configured ``graph_version`` and returns its markdown context string. + +Only the v7 family (v7, v7.x, v8) is live. The earlier generations this file +used to fan out to — the v5 dual-graph pipeline (keyword-LLM call + +EpisodicRetriever/SemanticRetriever over v5 node labels) and the v6 lean entity +index — are archived under ``archive/legacy_graph/``; an unrecognised +``graph_version`` now returns empty context instead of silently running a +pipeline whose node labels no ingest path writes anymore. + +Returns an empty string when graph memory is disabled, when the version is not +recognised, or when there are no hits. Callers treat empty as "no graph context". +""" + +from __future__ import annotations + +from mirix.log import get_logger +from mirix.schemas.agent import AgentState +from mirix.settings import settings + +logger = get_logger(__name__) + + + +class GraphRetrieverDispatcher: + """Stateless. Create one per request.""" + + async def retrieve( + self, + *, + query: str, + user_id: str, + agent_state: AgentState, + item_top_k: int = 15, + ) -> str: + if not settings.enable_graph_memory: + return "" + + if settings.graph_version.startswith("v7") or settings.graph_version == "v8": + from mirix.services.graph_retriever_v7 import V7Retriever + + return await V7Retriever().retrieve( + query=query, user_id=user_id, agent_state=agent_state, + top_k=item_top_k, + ) + + logger.warning( + "Graph retrieve: graph_version=%r has no live retriever (v5/v6 are " + "archived); returning empty context", settings.graph_version, + ) + return "" diff --git a/mirix/services/graph_retriever_v7.py b/mirix/services/graph_retriever_v7.py new file mode 100644 index 000000000..b529d0dfa --- /dev/null +++ b/mirix/services/graph_retriever_v7.py @@ -0,0 +1,374 @@ +""" +v7 graph retriever - minimal graph links, full details from flat memory. + +Retrieval path: + 1. query embedding -> V7Anchor vector search + 2. anchors -> episodic refs / semantic refs + 3. semantic refs -> supporting episodic refs, when provenance edges exist + 4. fetch full rows from PostgreSQL episodic_memory / semantic_memory + 5. format a compact context for QA +""" + +from __future__ import annotations + +import asyncio +import os +from dataclasses import dataclass, field +from typing import Optional + +from mirix.log import get_logger +from mirix.schemas.agent import AgentState +from mirix.services._graph_common import embed_batch +from mirix.settings import settings + +logger = get_logger(__name__) + + +DEFAULT_MAX_ITEMS_PER_KIND = 36 + + +@dataclass +class V7AnchorHit: + id: str + name: str + anchor_type: str + cosine: float + + +@dataclass +class V7MemoryRow: + id: str + kind: str + summary: str + details: str + timestamp: Optional[str] = None + extra: dict = field(default_factory=dict) + + +class V7Retriever: + async def retrieve_rows( + self, + *, + query: str, + user_id: str, + agent_state: AgentState, + top_k: int = 18, + max_items_per_kind: int = DEFAULT_MAX_ITEMS_PER_KIND, + ) -> tuple[list, list, list]: + """Graph retrieval as STRUCTURED rows: (anchors, episodic_rows, semantic_rows). + + `retrieve()` renders these into a prompt blob. That blob turned out to be easy + for an answerer to ignore — measured: the graph context was injected on 60/60 + questions and moved nothing, while the same facts merged into the answerer's own + search RESULTS did move QA. Callers that want the graph to actually influence an + answer should use these rows and merge them into the tool output. + """ + if not settings.enable_graph_memory or not ( + settings.graph_version.startswith("v7") or settings.graph_version == "v8" + ): + return [], [], [] + + from mirix.database.neo4j_client import get_neo4j_driver + + driver = get_neo4j_driver() + if driver is None or not query or not query.strip(): + return [], [], [] + + embs = await embed_batch([query], agent_state) + q_emb = embs[0] if embs else None + if q_emb is None: + return [], [], [] + + anchors = await self._search_anchors(driver, user_id, q_emb, top_k) + if not anchors: + return anchors, [], [] + + # Single retrieval path for the whole v7 family. The experimental variants + # that used to branch here — v7.7 Personalized PageRank (retrieval richer, QA + # flat) and v7.2 per-anchor coverage round-robin (neutral) — are archived; see + # docs/graph_memory_v7/development_history.md. + episodic_ids, semantic_ids = await self._collect_memory_refs( + driver, user_id=user_id, anchor_ids=[a.id for a in anchors], + ) + if not episodic_ids and not semantic_ids: + return anchors, [], [] + # v7.1+: rerank the anchor-collected candidates by query full-text + # similarity (anchor match = recall, text-cosine = precision) so a + # salient-but-wrong same-name entity from another document sinks below + # the true answer. Only plain v7 keeps the original anchor-traversal/date + # order (the unranked baseline). This used to be `== "v7.1"`, which + # silently switched the rerank OFF for every later version (v7.3+, v7.10, + # v8): they fell through to a plain traversal-order truncation — the same + # stale-exact-match guard bug as the ingest routing one fixed earlier. + # MIRIX_GRAPH_RERANK=0 restores the unranked traversal-order truncation — + # an A/B toggle so "graph without reranker" can be measured explicitly. + rerank = None if ( + settings.graph_version == "v7" + or os.environ.get("MIRIX_GRAPH_RERANK") == "0" + ) else q_emb + ep_arg = episodic_ids if rerank else episodic_ids[:max_items_per_kind] + sem_arg = semantic_ids if rerank else semantic_ids[:max_items_per_kind] + ep_task = asyncio.create_task( + self._fetch_episodic(user_id, ep_arg, q_emb=rerank, limit=max_items_per_kind)) + sem_task = asyncio.create_task( + self._fetch_semantic(user_id, sem_arg, q_emb=rerank, limit=max_items_per_kind)) + ep_rows, sem_rows = await asyncio.gather(ep_task, sem_task, return_exceptions=True) + if isinstance(ep_rows, Exception): + logger.warning("v7 episodic PG fetch failed: %s", ep_rows) + ep_rows = [] + if isinstance(sem_rows, Exception): + logger.warning("v7 semantic PG fetch failed: %s", sem_rows) + sem_rows = [] + + logger.info("v7 retrieve_rows: %d anchors, %d ep, %d sem", + len(anchors), len(ep_rows), len(sem_rows)) + return anchors, ep_rows, sem_rows + + async def retrieve( + self, + *, + query: str, + user_id: str, + agent_state: AgentState, + top_k: int = 18, + max_items_per_kind: int = DEFAULT_MAX_ITEMS_PER_KIND, + ) -> str: + """Graph retrieval rendered as a prompt context blob (the original API).""" + anchors, ep_rows, sem_rows = await self.retrieve_rows( + query=query, user_id=user_id, agent_state=agent_state, + top_k=top_k, max_items_per_kind=max_items_per_kind) + if not anchors: + return "" + ctx = self._format_context(anchors, ep_rows, sem_rows) + + # Hybrid wrap: append flat query-similarity memories alongside the graph + # context. The graph anchors on entity NAMES (precise where flat hits a + # same-name collision, but mis-fires where the query term matches a + # salient-but-wrong entity in another document); flat anchors on full + # memory TEXT (the complementary disambiguation). Giving the answerer both + # recovers the union of their correct answers. Gated for clean A/B. + if os.getenv("MIRIX_GRAPH_HYBRID_WRAP") == "1": + embs = await embed_batch([query], agent_state) + if embs and embs[0] is not None: + flat = await self._flat_section(user_id, embs[0]) + if flat: + ctx = ctx + "\n\n" + flat + + logger.info("v7 retrieve: %d anchors -> %d chars", len(anchors), len(ctx)) + return ctx + + async def _flat_section(self, user_id: str, q_emb: list[float], limit: int = 8) -> str: + """Top-k episodic + semantic by raw query-embedding similarity (flat + pgvector) — the disambiguation signal the graph alone lacks.""" + from sqlalchemy import text as sa_text + from mirix.constants import MAX_EMBEDDING_DIM + from mirix.server.server import db_context + + qp = str(list(q_emb) + [0.0] * (MAX_EMBEDDING_DIM - len(q_emb))) + try: + async with db_context() as session: + ep = (await session.execute(sa_text( + "SELECT summary, details FROM episodic_memory " + "WHERE user_id = :u AND summary_embedding IS NOT NULL " + "ORDER BY summary_embedding <=> CAST(:q AS vector) LIMIT :k" + ), {"u": user_id, "q": qp, "k": limit})).fetchall() + sem = (await session.execute(sa_text( + "SELECT name, summary, details FROM semantic_memory " + "WHERE user_id = :u AND summary_embedding IS NOT NULL " + "ORDER BY summary_embedding <=> CAST(:q AS vector) LIMIT :k" + ), {"u": user_id, "q": qp, "k": limit})).fetchall() + except Exception as e: + logger.warning("v7 hybrid flat section failed: %s", e) + return "" + + lines: list[str] = ["## Most similar memories (flat text match)"] + for name, summary, details in sem: + head = f"- {name}: {summary}" if name else f"- {summary}" + lines.append(head.rstrip()) + if details and details != summary: + lines.append(f" {details[:400]}") + for summary, details in ep: + lines.append(f"- {summary}".rstrip()) + if details and details != summary: + lines.append(f" {details[:400]}") + return "\n".join(lines) + + async def _search_anchors( + self, driver, user_id: str, emb: list[float], top_k: int + ) -> list[V7AnchorHit]: + cypher = """ + CALL db.index.vector.queryNodes('v7_anchor_name_emb', $top_k, $emb) + YIELD node AS a, score AS sim + WHERE a.user_id = $user_id + RETURN a.id AS id, a.name AS name, a.anchor_type AS anchor_type, sim AS sim + ORDER BY sim DESC + """ + async with driver.session(database=settings.neo4j_database) as session: + result = await session.run(cypher, top_k=top_k, emb=emb, user_id=user_id) + return [ + V7AnchorHit( + id=rec["id"], + name=rec["name"] or "", + anchor_type=rec["anchor_type"] or "Other", + cosine=float(rec["sim"] or 0.0), + ) + async for rec in result + ] + + async def _collect_memory_refs( + self, driver, *, user_id: str, anchor_ids: list[str] + ) -> tuple[list[str], list[str]]: + if not anchor_ids: + return [], [] + cypher = """ + UNWIND $anchor_ids AS aid + MATCH (a:V7Anchor {id: aid, user_id: $user_id}) + OPTIONAL MATCH (a)-[:V7_APPEARS_IN]->(ep:V7EpisodeRef) + OPTIONAL MATCH (a)-[:V7_DESCRIBED_BY]->(sem:V7ConceptRef) + OPTIONAL MATCH (sem)-[:V7_SUPPORTED_BY]->(support_ep:V7EpisodeRef) + OPTIONAL MATCH (ep)<-[:V7_SUPPORTED_BY]-(support_sem:V7ConceptRef) + OPTIONAL MATCH (ep)-[:V7_NEXT_MEMORY]-(near_ep:V7EpisodeRef) + RETURN + collect(DISTINCT ep.memory_id) AS direct_ep, + collect(DISTINCT support_ep.memory_id) AS support_ep, + collect(DISTINCT near_ep.memory_id) AS near_ep, + collect(DISTINCT sem.memory_id) AS direct_sem, + collect(DISTINCT support_sem.memory_id) AS support_sem + """ + ep_ids: list[str] = [] + sem_ids: list[str] = [] + + def add_unique(target: list[str], values: list[object]) -> None: + seen = set(target) + for raw in values or []: + if raw is None: + continue + value = str(raw) + if value and value not in seen: + target.append(value) + seen.add(value) + + async with driver.session(database=settings.neo4j_database) as session: + result = await session.run(cypher, anchor_ids=anchor_ids, user_id=user_id) + async for rec in result: + add_unique(ep_ids, rec["direct_ep"]) + add_unique(ep_ids, rec["support_ep"]) + add_unique(ep_ids, rec["near_ep"]) + add_unique(sem_ids, rec["direct_sem"]) + add_unique(sem_ids, rec["support_sem"]) + return ep_ids, sem_ids + + # (v7.7 PPR and v7.2 coverage retrieval helpers — _load_ppr_graph, _retrieve_ppr, + # _collect_per_anchor, _retrieve_coverage, _round_robin — are archived; see + # docs/graph_memory_v7/development_history.md.) + + async def _fetch_episodic( + self, user_id: str, ids: list[str], + q_emb: Optional[list[float]] = None, limit: Optional[int] = None, + ) -> list[V7MemoryRow]: + if not ids: + return [] + from sqlalchemy import text as sa_text + from mirix.server.server import db_context + + if q_emb is not None: # v7.1: rank candidates by query full-text similarity + from mirix.constants import MAX_EMBEDDING_DIM + qp = str(list(q_emb) + [0.0] * (MAX_EMBEDDING_DIM - len(q_emb))) + sql = ( + "SELECT id, summary, details, occurred_at, actor FROM episodic_memory " + "WHERE user_id = :u AND id = ANY(:ids) AND summary_embedding IS NOT NULL " + "ORDER BY summary_embedding <=> CAST(:q AS vector) LIMIT :k" + ) + params = {"u": user_id, "ids": ids, "q": qp, "k": limit or DEFAULT_MAX_ITEMS_PER_KIND} + else: + sql = ( + "SELECT id, summary, details, occurred_at, actor FROM episodic_memory " + "WHERE user_id = :u AND id = ANY(:ids) ORDER BY occurred_at DESC NULLS LAST" + ) + params = {"u": user_id, "ids": ids} + + async with db_context() as session: + result = await session.execute(sa_text(sql), params) + return [ + V7MemoryRow( + id=row[0], + kind="episodic", + summary=row[1] or "", + details=row[2] or "", + timestamp=row[3].isoformat() if row[3] is not None else None, + extra={"actor": row[4] or ""}, + ) + for row in result.fetchall() + ] + + async def _fetch_semantic( + self, user_id: str, ids: list[str], + q_emb: Optional[list[float]] = None, limit: Optional[int] = None, + ) -> list[V7MemoryRow]: + if not ids: + return [] + from sqlalchemy import text as sa_text + from mirix.server.server import db_context + + if q_emb is not None: # v7.1: rank candidates by query full-text similarity + from mirix.constants import MAX_EMBEDDING_DIM + qp = str(list(q_emb) + [0.0] * (MAX_EMBEDDING_DIM - len(q_emb))) + sql = ( + "SELECT id, name, summary, details, source, created_at FROM semantic_memory " + "WHERE user_id = :u AND id = ANY(:ids) AND summary_embedding IS NOT NULL " + "ORDER BY summary_embedding <=> CAST(:q AS vector) LIMIT :k" + ) + params = {"u": user_id, "ids": ids, "q": qp, "k": limit or DEFAULT_MAX_ITEMS_PER_KIND} + else: + sql = ( + "SELECT id, name, summary, details, source, created_at FROM semantic_memory " + "WHERE user_id = :u AND id = ANY(:ids) ORDER BY created_at DESC NULLS LAST" + ) + params = {"u": user_id, "ids": ids} + + async with db_context() as session: + result = await session.execute(sa_text(sql), params) + return [ + V7MemoryRow( + id=row[0], + kind="semantic", + summary=row[2] or "", + details=row[3] or "", + timestamp=row[5].isoformat() if row[5] is not None else None, + extra={"name": row[1] or "", "source": row[4] or ""}, + ) + for row in result.fetchall() + ] + + def _format_context( + self, + anchors: list[V7AnchorHit], + ep_rows: list[V7MemoryRow], + sem_rows: list[V7MemoryRow], + ) -> str: + lines: list[str] = ["## Memory Linkage Graph (v7)"] + if anchors: + names = [f"{a.name} ({a.anchor_type})" for a in anchors[:18]] + lines.append(f"**Matched anchors:** {', '.join(names)}") + + if sem_rows: + lines.append("\n### Semantic memories (PG flat)") + for row in sem_rows: + name = row.extra.get("name", "") + head = f"- {name}: {row.summary}" if name else f"- {row.summary}" + lines.append(head.rstrip()) + if row.details and row.details != row.summary: + lines.append(f" {row.details[:500]}") + + if ep_rows: + # (The v7.9 role-split rendering — user-domain vs assistant-domain + # sections — was rejected at QA 34→30 and is archived.) + lines.append("\n### Episodic memories (PG flat evidence)") + for row in ep_rows: + ts = row.timestamp[:10] if row.timestamp else "" + head = f"- [{ts}] {row.summary}" if ts else f"- {row.summary}" + lines.append(head.rstrip()) + if row.details and row.details != row.summary: + lines.append(f" {row.details[:500]}") + + return "\n".join(lines) diff --git a/mirix/services/lightrag_extractor.py b/mirix/services/lightrag_extractor.py new file mode 100644 index 000000000..0a249d022 --- /dev/null +++ b/mirix/services/lightrag_extractor.py @@ -0,0 +1,181 @@ +""" +LightRAG-style entity & relation extractor (W2 of the write path). + +Adapted from LightRAG operate.py:extract_entities. One LLM call per event; +output is delimiter-separated tuples that are parsed into structured dicts. +Optional gleaning pass (default off) re-prompts the LLM to catch misses. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from typing import Any, Optional + +import httpx + +from mirix.log import get_logger +from mirix.prompts.lightrag_prompts import ( + COMPLETION_DELIMITER, + DEFAULT_ENTITY_TYPES, + TUPLE_DELIMITER, + render_extraction_system_prompt, + render_extraction_user_prompt, +) + +logger = get_logger(__name__) + + +@dataclass +class ExtractedEntity: + name: str + entity_type: str + description: str + + + +@dataclass +class ExtractionResult: + entities: list[ExtractedEntity] = field(default_factory=list) + + +def _strip_quotes(s: str) -> str: + s = s.strip() + if len(s) >= 2 and s[0] in {'"', "'"} and s[-1] == s[0]: + return s[1:-1].strip() + return s + + + +def parse_extraction_output(raw: str) -> ExtractionResult: + """ + Parse LightRAG-style delimiter output into structured entities & relations. + + Each line should look like: + entity<|#|>NAME<|#|>TYPE<|#|>DESCRIPTION + relation<|#|>SRC<|#|>TGT<|#|>KEYWORDS<|#|>DESCRIPTION<|#|>STRENGTH + Lines that do not parse cleanly are logged and skipped. + """ + result = ExtractionResult() + if not raw: + return result + + # Stop at the completion delimiter if the model emitted it + cut = raw.find(COMPLETION_DELIMITER) + if cut >= 0: + raw = raw[:cut] + + seen_entity_names: set[str] = set() + + for raw_line in raw.splitlines(): + line = raw_line.strip() + if not line or TUPLE_DELIMITER not in line: + continue + parts = [p.strip() for p in line.split(TUPLE_DELIMITER)] + kind = parts[0].lower().strip("()`* ") + if kind == "entity" and len(parts) >= 4: + name = _strip_quotes(parts[1]) + entity_type = _strip_quotes(parts[2]) or "Other" + description = _strip_quotes(parts[3]) + if not name or name in seen_entity_names: + continue + seen_entity_names.add(name) + result.entities.append( + ExtractedEntity(name=name, entity_type=entity_type, description=description) + ) + elif kind == "relation": + # Relations from the LightRAG format are no longer surfaced — nothing in + # the live v7/v8 path reads them (relation edges come from the v7.6 triple + # extractor). Skip instead of accumulating a dead output. + continue + else: + # Unknown leading token — skip silently to avoid log spam on + # benign formatting variations. + continue + + return result + + +async def call_openai_chat( + system_prompt: str, + user_prompt: str, + model: str, + *, + temperature: float = 0.0, + max_tokens: int = 4000, + timeout: float = 180.0, + api_key: Optional[str] = None, + api_base: Optional[str] = None, +) -> str: + """Bare-metal OpenAI chat completion. Mirrors v2 graph_memory_manager.""" + api_key = api_key or os.environ.get("OPENAI_API_KEY", "") + api_base = api_base or os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1") + endpoint = f"{api_base.rstrip('/')}/chat/completions" + + headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} + payload: dict[str, Any] = { + "model": model, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + "temperature": temperature, + "max_tokens": max_tokens, + } + async with httpx.AsyncClient(timeout=timeout) as client: + resp = await client.post(endpoint, headers=headers, json=payload) + resp.raise_for_status() + data = resp.json() + + # Record token usage if a phase is active (no-op outside instrumented evals) + try: + from mirix.database.token_tracker import record as _record_tokens + usage = (data.get("usage") or {}) + _record_tokens( + prompt_tokens=usage.get("prompt_tokens", 0), + completion_tokens=usage.get("completion_tokens", 0), + total_tokens=usage.get("total_tokens"), + ) + except Exception: + pass + + return data["choices"][0]["message"]["content"] + + +async def extract_entities_and_relations( + text: str, + *, + llm_model: str = "gpt-4.1-mini", + entity_types: Optional[list[str]] = None, + language: str = "English", + max_input_chars: int = 12000, +) -> ExtractionResult: + """ + Run a single LLM extraction pass over ``text`` and parse the result. + + Returns an empty ``ExtractionResult`` on error so the caller can carry on. + """ + if not text or not text.strip(): + return ExtractionResult() + + types = entity_types or DEFAULT_ENTITY_TYPES + system_prompt = render_extraction_system_prompt(entity_types=types, language=language) + user_prompt = render_extraction_user_prompt( + input_text=text[:max_input_chars], + entity_types=types, + language=language, + ) + + try: + raw = await call_openai_chat(system_prompt, user_prompt, model=llm_model) + except Exception as e: + logger.warning("LightRAG extraction LLM call failed: %s: %s", type(e).__name__, e) + return ExtractionResult() + + parsed = parse_extraction_output(raw) + logger.info( + "LightRAG extraction: %d entities from %d chars", + len(parsed.entities), + len(text), + ) + return parsed diff --git a/mirix/services/merge_coverage.py b/mirix/services/merge_coverage.py new file mode 100644 index 000000000..c8da82058 --- /dev/null +++ b/mirix/services/merge_coverage.py @@ -0,0 +1,153 @@ +"""Union-coverage gate for consolidation merges. + +The auto_dream agent "merges" memories by rewriting N rows into M=2 capitalised words, optionally joined by a +# lowercase connector ("House of Blues", "Summer Sounds", "Downtown Farmers +# Market"). Same shape the cold-fact extractor uses. Case-sensitive on purpose. +_PROPER = re.compile(r"\b[A-Z][a-zA-Z]+(?:\s+(?:of|the|and|&)?\s*[A-Z][a-zA-Z]+)+\b") + +# Digit-bearing token: 420, 3.5, 1,000, 2023, 19:30. Trailing punctuation excluded. +_NUMBER = re.compile(r"\d+(?:[.,:]\d+)*") + +_MONTHS = { + "january", "february", "march", "april", "may", "june", "july", + "august", "september", "october", "november", "december", +} +_WEEKDAYS = {"monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"} +# Word-numbers that carry answerable quantities. The very common/ambiguous ones +# (one, first, second, couple, several) are excluded — requiring them would +# reject legitimate rewording far more often than it would save an answer. +_WORD_NUMBERS = { + "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", + "eleven", "twelve", "twenty", "thirty", "forty", "fifty", "dozen", "twice", +} +_WORDS = re.compile(r"[a-z]+") + + +def _normalize(text: str) -> str: + # lowercase, thousands-commas stripped, whitespace collapsed — so "1,000" in + # the original matches "1000" in the rewrite and line breaks don't matter. + t = re.sub(r"(?<=\d),(?=\d)", "", (text or "").lower()) + return re.sub(r"\s+", " ", t) + + +def extract_specifics(text: str) -> dict: + """Map of match-key -> display form for every deterministic specific in text.""" + out: dict = {} + if not text: + return out + for m in _PROPER.finditer(text): + phrase = re.sub(r"\s+", " ", m.group(0)) + out[_normalize(phrase)] = phrase + for m in _NUMBER.finditer(text): + tok = m.group(0).rstrip(".,:") + if tok: + out[_normalize(tok)] = tok + for w in _WORDS.findall((text or "").lower()): + if w in _MONTHS or w in _WEEKDAYS or w in _WORD_NUMBERS: + out[w] = w + return out + + +def merge_item_text(item, fields) -> str: + """Concatenate an LLM-provided new_item's text fields (dict or pydantic).""" + vals = [] + for f in fields: + v = item.get(f, "") if isinstance(item, dict) else getattr(item, f, "") + vals.append(str(v or "")) + return "\n".join(vals) + + +def enforce_merge_coverage(agent, old_texts, new_texts, tool_name: str) -> None: + """Reject a consolidation merge that would destroy specifics. + + The auto_dream agent "merges" by rewriting N rows into M 12 else "" + raise ValueError( + f"{tool_name} REJECTED — nothing was deleted. The replacement text drops these " + f"specifics that exist in the originals: {shown}{more}. A merge must preserve every " + f"named entity, date, number and quantity VERBATIM. Rewrite new_items to include all " + f"of them; if a value is outdated and superseded, keep it in details as history " + f"(e.g. 'previously ...'). If these entries are not actually redundant, do not merge " + f"them." + ) + + +def coverage_gaps(old_texts: Iterable[str], new_text: str) -> list: + """Specifics present in the union of old_texts but missing from new_text. + + Containment is checked on normalized text; word-level keys (months, + weekdays, word-numbers) require a word-boundary match so "may" the month + does not get satisfied by "maybe". + """ + new_norm = _normalize(new_text) + new_words = set(_WORDS.findall(new_norm)) + _connectors = {"of", "the", "and"} + gaps: dict = {} + for text in old_texts: + for key, display in extract_specifics(text).items(): + if key in gaps: + continue + if " " in key: + # Proper phrase: covered by the exact phrase, OR by every + # non-connector component word appearing somewhere — so + # "Caroline and Melanie" is satisfied by separate mentions of + # Caroline and Melanie, without demanding the conjunction. + if key in new_norm: + continue + comps = [w for w in key.split() if w not in _connectors] + if comps and all(c in new_words for c in comps): + continue + gaps[key] = display + elif not key.isalpha(): + if key not in new_norm: + gaps[key] = display + else: + if key not in new_words: + gaps[key] = display + return sorted(gaps.values()) diff --git a/mirix/services/semantic_memory_manager.py b/mirix/services/semantic_memory_manager.py index c43c98b17..a5cea344f 100755 --- a/mirix/services/semantic_memory_manager.py +++ b/mirix/services/semantic_memory_manager.py @@ -803,6 +803,11 @@ async def list_semantic_items( SemanticMemoryItem.last_modify.label("last_modify"), SemanticMemoryItem.user_id.label("user_id"), SemanticMemoryItem.agent_id.label("agent_id"), + # source_refs / prior_values are non-nullable on the + # Pydantic schema; selecting them explicitly avoids + # to_pydantic() passing None and failing validation. + SemanticMemoryItem.source_refs.label("source_refs"), + SemanticMemoryItem.prior_values.label("prior_values"), ) .where(SemanticMemoryItem.user_id == user.id) .where(SemanticMemoryItem.organization_id == organization_id) @@ -959,7 +964,40 @@ async def insert_semantic_item( ) -> PydanticSemanticMemoryItem: """ Create a new semantic memory entry using provided parameters. + + Auto-route: when ``filter_tags`` contains a ``source_meta`` dict + (chunk_id / serial / occurred_at) AND ``name`` is shaped like + ``" / "``, the call is forwarded to + ``upsert_with_conflict_resolution`` for deterministic merge with + ``prior_values`` history. Otherwise the legacy free-form path is + used unchanged. """ + # ---- conflict-resolution auto-route ------------------------------ + source_meta = (filter_tags or {}).get("source_meta") + if source_meta and isinstance(name, str) and " / " in name: + entity, _, relation = name.partition(" / ") + entity, relation = entity.strip(), relation.strip() + if entity and relation: + # The ``source_meta`` dict is the per-ingest payload sent by + # the client. Carry every field through as the source_ref + # so the ordering tuple (occurred_at > serial > created_at) + # in the manager can use whichever fields are present. + return await self.upsert_with_conflict_resolution( + actor=actor, + agent_state=agent_state, + agent_id=agent_id, + entity=entity, + relation=relation, + value=summary, + source_ref=dict(source_meta), + organization_id=organization_id, + extra_filter_tags={ + k: v for k, v in (filter_tags or {}).items() if k != "source_meta" + }, + use_cache=use_cache, + client_id=client_id, + user_id=user_id, + ) try: # Set defaults for required fields from mirix.services.user_manager import UserManager @@ -1007,10 +1045,270 @@ async def insert_semantic_item( ) # Note: Item is already added to clustering tree in create_item() + + # Graph memory write path. Routed by settings.graph_version: + # v5 → full LightRAG dual-graph (Concept + SemanticEntity + SEM_RELATES + # + CONCEPT_RELATES via LLM judgement) + # v6 → lean entity index (V6Entity + V6_COOCCUR), shared with episodic + # v7 → minimal semantic+episodic linkage graph; details stay in PG + # Sync hook — failures logged but do not affect the PG insert that + # already completed. + if settings.enable_graph_memory: + try: + # Prefix match, not a version list — see the note in + # episodic_memory_manager: the explicit tuple drifted six versions out + # of date and silently routed v7.4+ servers to the legacy builder. + # The v5/v6 builders are archived under archive/legacy_graph/; + # unrecognised versions now skip graph writes. + if settings.graph_version.startswith("v7") or settings.graph_version == "v8": + from mirix.services.graph_memory_manager_v7 import V7GraphManager + + source_meta = ( + dict(filter_tags["source_meta"]) + if filter_tags and isinstance(filter_tags.get("source_meta"), dict) + else None + ) + await V7GraphManager().process_memory( + source_kind="semantic", + source_id=semantic_item.id, + text=(name or "") + "\n" + (summary or "") + "\n" + (details or ""), + title=name, + summary=summary, + source_meta=source_meta, + agent_state=agent_state, + organization_id=organization_id, + user_id=user_id or "unknown", + # Semantic knowledge is distilled from the whole dialogue, + # so its role provenance is "shared" (same convention as + # the rebuild script). The live hook previously passed + # nothing, losing role attribution on organic builds. + role="shared", + ) + else: + logger.warning( + "Semantic graph write skipped: graph_version=%r has no live " + "builder (v5/v6 are archived)", settings.graph_version) + except Exception as graph_err: + logger.warning("Semantic graph write failed (non-fatal): %s", graph_err) + return semantic_item except Exception as e: raise e + @staticmethod + def _build_cr_filter_tags( + entity: str, + relation: str, + existing: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + """Merge the conflict-resolution lookup keys into a filter_tags dict. + + ``cr_entity`` and ``cr_relation`` are the index used by + ``upsert_with_conflict_resolution`` to find the canonical item for a + given (entity, relation) pair within a user_id. Other tags + (scope, project_id, ...) are preserved. + """ + out: Dict[str, Any] = dict(existing or {}) + out["cr_entity"] = entity + out["cr_relation"] = relation + return out + + @staticmethod + def _source_ref_key(source_ref: Optional[Dict[str, Any]]) -> tuple: + """Total ordering for source refs. + + Priority: occurred_at > serial > created_at (caller fills created_at + when nothing else is available). All missing → very small key, so + the caller's new ref wins ties via the explicit ``-1`` fallback. + """ + if not source_ref: + return (0, "", -1, "") + # occurred_at: ISO 8601 strings compare lexicographically when in UTC. + occurred = source_ref.get("occurred_at") or "" + serial = source_ref.get("serial") + created = source_ref.get("created_at") or "" + # Each tier becomes its own sort key; "" sorts before any real value. + return ( + 1 if occurred else 0, occurred, + 1 if serial is not None else 0, serial if serial is not None else -1, + 1 if created else 0, created, + ) + + async def _find_by_entity_relation( + self, + entity: str, + relation: str, + user_id: str, + actor: PydanticClient, + ) -> Optional[SemanticMemoryItem]: + """Lookup the existing canonical item for (entity, relation) under + this user, or None. Uses the ``cr_entity`` / ``cr_relation`` keys + the upsert path writes into ``filter_tags``. + """ + async with self.session_maker() as session: + # Postgres: filter_tags is JSONB; use ->> operator. SQLite path + # falls back to a Python-side filter for the small subset that + # already matches user_id. + if settings.mirix_pg_uri_no_default: + stmt = ( + select(SemanticMemoryItem) + .where(SemanticMemoryItem.user_id == user_id) + .where(text("(filter_tags->>'cr_entity') = :ent")) + .where(text("(filter_tags->>'cr_relation') = :rel")) + .params(ent=entity, rel=relation) + .limit(1) + ) + result = await session.execute(stmt) + row = result.scalar_one_or_none() + return row + # SQLite fallback + stmt = select(SemanticMemoryItem).where( + SemanticMemoryItem.user_id == user_id + ) + result = await session.execute(stmt) + for row in result.scalars().all(): + ft = row.filter_tags or {} + if ft.get("cr_entity") == entity and ft.get("cr_relation") == relation: + return row + return None + + async def upsert_with_conflict_resolution( + self, + actor: PydanticClient, + agent_state: AgentState, + agent_id: str, + entity: str, + relation: str, + value: str, + source_ref: Dict[str, Any], + organization_id: str, + status: str = "asserted", + extra_filter_tags: Optional[Dict[str, Any]] = None, + use_cache: bool = True, + client_id: Optional[str] = None, + user_id: Optional[str] = None, + ) -> PydanticSemanticMemoryItem: + """Deterministic upsert of a (entity, relation, value) fact. + + Lookup the existing canonical item for this (entity, relation) and: + + - If no existing item, insert a new one with ``name = " / + "``, ``summary = value``, ``source_refs = [source_ref]``, + and ``filter_tags`` carrying ``cr_entity``/``cr_relation``. + - If the new source_ref has a strictly larger sort key than the + existing canonical's most recent ref, replace the canonical: + old summary/source_refs move into ``prior_values`` with status + ``"superseded"``; new value becomes the current ``summary``. + - Otherwise append the new ref to ``prior_values`` as a late-arriving + older version (so the audit trail is preserved without changing + the current canonical). + - ``status="corrected"`` forces a replace and marks the displaced + version with ``status="corrected"`` regardless of source_ref order. + + Returns the canonical item after the upsert. + + No LLM is involved in this method — the merge is deterministic on + the contents of ``source_ref``. + """ + from mirix.services.user_manager import UserManager + + if client_id is None: + client_id = actor.id + if user_id is None: + user_id = UserManager.ADMIN_USER_ID + + existing = await self._find_by_entity_relation(entity, relation, user_id, actor) + merged_tags = self._build_cr_filter_tags(entity, relation, extra_filter_tags) + + if existing is None: + # Cold path: behave like a regular insert, but seed source_refs + # and stash the cr_entity/cr_relation in filter_tags. + name = f"{entity} / {relation}" + details = f"Current value: {value}" + item = await self.insert_semantic_item( + actor=actor, + agent_state=agent_state, + agent_id=agent_id, + name=name, + summary=value, + details=details, + source=str(source_ref) if source_ref else "", + organization_id=organization_id, + filter_tags=merged_tags, + use_cache=use_cache, + client_id=client_id, + user_id=user_id, + ) + # Patch source_refs onto the row in-place; insert_semantic_item + # doesn't take it as a parameter to keep the legacy surface stable. + async with self.session_maker() as session: + db_row = await SemanticMemoryItem.read( + db_session=session, identifier=item.id, actor=actor + ) + db_row.source_refs = [source_ref] if source_ref else [] + await session.commit() + await session.refresh(db_row) + return db_row.to_pydantic() + + # Hot path: an existing canonical exists. Compare source_refs and + # decide whether the new ref supersedes it. + existing_refs: List[Dict[str, Any]] = list(existing.source_refs or []) + # The "most recent" existing ref is the max under our ordering. + existing_top = max(existing_refs, key=self._source_ref_key) if existing_refs else None + new_wins = ( + status == "corrected" + or existing_top is None + or self._source_ref_key(source_ref) > self._source_ref_key(existing_top) + ) + + async with self.session_maker() as session: + db_row = await SemanticMemoryItem.read( + db_session=session, identifier=existing.id, actor=actor + ) + now_iso = datetime.now(timezone.utc).isoformat() + + if new_wins: + # Move the current value into prior_values, swap in the new. + prior_entry = { + "value": db_row.summary, + "source_refs": list(db_row.source_refs or []), + "status": "corrected" if status == "corrected" else "superseded", + "moved_at": now_iso, + } + db_row.prior_values = list(db_row.prior_values or []) + [prior_entry] + db_row.summary = value + db_row.details = f"Current value: {value}" + db_row.source_refs = [source_ref] if source_ref else [] + # Keep the cr_entity/cr_relation tags intact while merging + # any extra tags from this update. + merged_existing = self._build_cr_filter_tags( + entity, relation, {**(db_row.filter_tags or {}), **(extra_filter_tags or {})} + ) + db_row.filter_tags = merged_existing + db_row.last_modify = { + "timestamp": now_iso, + "operation": "cr_supersede" if status != "corrected" else "cr_correct", + } + else: + # Late-arriving older fact — record in prior_values, don't + # touch the canonical. + prior_entry = { + "value": value, + "source_refs": [source_ref] if source_ref else [], + "status": "superseded", + "moved_at": now_iso, + "note": "late-arrived older fact", + } + db_row.prior_values = list(db_row.prior_values or []) + [prior_entry] + db_row.last_modify = { + "timestamp": now_iso, + "operation": "cr_record_late", + } + + await session.commit() + await session.refresh(db_row) + return db_row.to_pydantic() + async def delete_semantic_item_by_id(self, semantic_memory_id: str, actor: PydanticClient) -> None: """Delete a semantic memory item by ID (removes from cache).""" async with self.session_maker() as session: @@ -1378,4 +1676,4 @@ async def list_semantic_items_by_org( result = await session.execute(base_query) items = result.scalars().all() - return [item.to_pydantic() for item in items] \ No newline at end of file + return [item.to_pydantic() for item in items] diff --git a/mirix/services/triple_extractor.py b/mirix/services/triple_extractor.py new file mode 100644 index 000000000..e8fed0e21 --- /dev/null +++ b/mirix/services/triple_extractor.py @@ -0,0 +1,120 @@ +"""Direction D extractor — one LLM call emits knowledge-graph triples. + +Replaces the v7.4 GLiNER extractor as the main path. GLiNER (a span tagger) +cannot abstract — it drops LightRAG's concept anchors (networking, team +collaboration…) and can't produce relations. An LLM, by contrast, both abstracts +("socializing with colleagues while remote" -> concept "Workplace Socializing") +and yields the relation (predicate), which v7 extracted but discarded. Every +recent top-venue KG method (HippoRAG 2, KGGen, AutoSchemaKG, EDC) uses LLM triple +extraction for exactly these reasons; see docs/graph_memory_v7/extractor_direction_D.md. + +Output feeds the existing anchor pipeline: unique entities -> `_select_anchors` +(unchanged), and relations -> new anchor->anchor `V7_RELATION` edges (which v7 +lacked, and which PPR retrieval needs). +""" +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field +from typing import List, Optional, Tuple + +from mirix.log import get_logger +from mirix.services.lightrag_extractor import ExtractedEntity, call_openai_chat + +logger = get_logger(__name__) + +# Same type vocabulary _specificity_score scores; the LLM maps to these. +ENTITY_TYPES = ( + "person", "organization", "location", "event", + "content", "object", "concept", "method", "date", +) + +# Dialogue roles carry no entity identity — as anchors they are the v7 noise +# mega-hubs (deg 42/24, connected to everything). Dropped here; the user/assistant +# provenance is captured properly by the role attribute (from episodic.actor), +# not a "User" anchor. A relation whose endpoint is one of these simply won't form +# an anchor->anchor edge (the endpoint isn't an anchor), which is correct. +_NOISE = { + "user", "assistant", "you", "i", "me", "we", "us", "they", "them", + "he", "she", "it", "my", "your", "our", "their", "the user", "the assistant", + # Plurals slipped this filter: a lone "Users" anchor reached degree 1016 — + # 7.4x the biggest real hub — built purely from role-noise predicates + # ("interested in", "inquire about", "plan"). Same dialogue role, same fate. + "users", "assistants", "the users", "the assistants", +} + +TRIPLE_PROMPT = """Extract knowledge-graph triples from the text: every meaningful (subject, relation, object) fact. +Subjects/objects are ENTITIES: named things (people/places/orgs/products), OR the underlying CONCEPT/THEME. +For concepts, output a CONCISE CANONICAL name (2-4 words, the general theme), NOT a copied phrase: + "misses watercooler chats with colleagues while remote" -> concept "Remote Team Networking" + "socializing with colleagues while working from home" -> concept "Workplace Socializing" +Each entity has name + type from EXACTLY this list: +person, organization, location, event, concept, method, content, object, date +Keep the relation a SHORT verb phrase. Return JSON: +{"triples":[{"s":{"name":"...","type":"..."},"r":"short relation","o":{"name":"...","type":"..."}}]}""" + + +@dataclass +class TripleResult: + entities: List[ExtractedEntity] = field(default_factory=list) + # (subject_name, relation, object_name) in the entities' surface names + relations: List[Tuple[str, str, str]] = field(default_factory=list) + + +def _norm_type(t: object) -> str: + t = str(t or "").strip().lower() + return t if t in ENTITY_TYPES else "other" + + +def _parse(raw: str) -> TripleResult: + """Tolerant parse: JSON object, or the first {...} block if the model wrapped it.""" + try: + data = json.loads(raw) + except Exception: # noqa: BLE001 + m = re.search(r"\{.*\}", raw, re.S) + if not m: + return TripleResult() + try: + data = json.loads(m.group(0)) + except Exception: # noqa: BLE001 + return TripleResult() + + ents: dict[str, ExtractedEntity] = {} + rels: List[Tuple[str, str, str]] = [] + for tr in data.get("triples", []) or []: + if not isinstance(tr, dict): + continue + s, o = tr.get("s") or {}, tr.get("o") or {} + sn = str((s or {}).get("name") or "").strip() + on = str((o or {}).get("name") or "").strip() + rel = str(tr.get("r") or "").strip() + for nm, spec in ((sn, s), (on, o)): + if nm and nm.lower() not in _NOISE and nm.lower() not in ents: + ents[nm.lower()] = ExtractedEntity( + name=nm, entity_type=_norm_type((spec or {}).get("type")), description="") + # Keep the relation even if one endpoint is a dialogue role — _link_relation_edges + # matches endpoints against existing anchors, so a User/Assistant endpoint just + # yields no edge (its concept object is still anchored via the entity list). + if sn and on and sn.lower() != on.lower(): + rels.append((sn, rel, on)) + return TripleResult(entities=list(ents.values()), relations=rels) + + +async def extract_triples( + text: str, + *, + model: str = "gpt-4.1-mini", + api_key: Optional[str] = None, + max_chars: int = 12000, +) -> TripleResult: + """Single LLM call -> typed entities + relations. Empty result on failure.""" + if not text or not text.strip(): + return TripleResult() + try: + raw = await call_openai_chat( + TRIPLE_PROMPT, text[:max_chars], model, temperature=0.0, api_key=api_key) + except Exception as e: # noqa: BLE001 + logger.warning("triple extraction failed: %s", e) + return TripleResult() + return _parse(raw) diff --git a/mirix/services/user_manager.py b/mirix/services/user_manager.py index 45f50144c..bd87ca04e 100755 --- a/mirix/services/user_manager.py +++ b/mirix/services/user_manager.py @@ -107,6 +107,42 @@ async def update_user_status( return existing_user.to_pydantic() @enforce_types + async def reserve_source_ids( + self, + user_id: str, + n_turns: int = 1, + ) -> dict: + """Atomically reserve the next ``n_turns`` turn_ids and one chunk_id + for ``user_id``. Used by ``/memory/add`` to fill in + ``source_meta.turn_id`` / ``source_meta.chunk_id`` when the client + did not supply them. + + Returns ``{"turn_id_start", "turn_id_end", "chunk_id"}``. Counters + are bumped to ``turn_counter += n_turns`` and ``chunk_counter += 1``. + Concurrent ``/memory/add`` calls for the same user are serialised + by the underlying UPDATE ... RETURNING (PostgreSQL) / + SELECT FOR UPDATE in a single transaction. + """ + from sqlalchemy import update + from mirix.orm.user import User as UserModel + + if n_turns < 1: + n_turns = 1 + async with self.session_maker() as session: + existing_user = await UserModel.read( + db_session=session, identifier=user_id + ) + turn_id_start = existing_user.turn_counter + chunk_id = existing_user.chunk_counter + existing_user.turn_counter = turn_id_start + n_turns + existing_user.chunk_counter = chunk_id + 1 + await existing_user.update_with_redis(session, actor=None) + return { + "turn_id_start": turn_id_start, + "turn_id_end": turn_id_start + n_turns - 1, + "chunk_id": chunk_id, + } + async def delete_user_by_id(self, user_id: str): """ Soft delete a user and cascade soft delete to all associated records using memory managers. diff --git a/mirix/settings.py b/mirix/settings.py index 8b8c05566..209a8caf6 100755 --- a/mirix/settings.py +++ b/mirix/settings.py @@ -226,6 +226,25 @@ def mirix_redis_uri(self) -> Optional[str]: llm_retry_backoff_factor: float = Field(0.5, env="MIRIX_LLM_RETRY_BACKOFF_FACTOR") # Exponential backoff multiplier llm_retry_max_delay: float = Field(10.0, env="MIRIX_LLM_RETRY_MAX_DELAY") # Max delay between retries (seconds) + # Graph memory: LightRAG-style dual-level retrieval over Neo4j. + # When enabled, episodic event inserts also extract entities/relations into + # Neo4j; retrieval supplements flat memory with graph context. + enable_graph_memory: bool = Field(False, env="MIRIX_ENABLE_GRAPH_MEMORY") + # Graph schema variant. "v5" = full LightRAG dual-graph (Episode/Concept + + # EpisodicEntity/SemanticEntity with relation edges). "v6" = lean entity + # index with PG backrefs. "v7" = minimal semantic+episodic linkage graph: + # specific anchors + memory refs + support/temporal edges; details stay in + # flat PG memory. Only meaningful when enable_graph_memory is True. + graph_version: str = Field("v5", env="MIRIX_GRAPH_VERSION") + neo4j_uri: str = Field("bolt://localhost:7687", env="MIRIX_NEO4J_URI") + neo4j_user: str = Field("neo4j", env="MIRIX_NEO4J_USER") + neo4j_password: str = Field("mirix_neo4j_dev", env="MIRIX_NEO4J_PASSWORD") + neo4j_database: str = Field("neo4j", env="MIRIX_NEO4J_DATABASE") + # Dimension of embeddings used for entity/relation vector indexes in Neo4j. + # Must match the embedding model in use (1536 for text-embedding-3-small, + # 3072 for text-embedding-3-large). + neo4j_vector_dim: int = Field(1536, env="MIRIX_NEO4J_VECTOR_DIM") + # cron job parameters enable_batch_job_polling: bool = False poll_running_llm_batches_interval_seconds: int = 5 * 60 diff --git a/requirements.txt b/requirements.txt index 90b1f7082..81a875a4b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -48,6 +48,7 @@ httpx ipdb protobuf>=5.0.0,<6.0.0 redis[hiredis]>=7.0.1,<8.0.0 +neo4j>=5.20.0,<6.0.0 aiokafka>=0.13.0,<0.14.0 asyncddgs google-auth