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/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..665df59ff --- /dev/null +++ b/docs/graph_memory_v7/README.md @@ -0,0 +1,219 @@ +# 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. + +## 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/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/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/main_eval.py b/evals/main_eval.py index 3862d1129..6218863fc 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,6 +238,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) @@ -224,6 +267,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] @@ -309,6 +357,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..336d4284a 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 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/run_v4_sample0.sh b/evals/run_v4_sample0.sh new file mode 100755 index 000000000..1581449eb --- /dev/null +++ b/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/evals/task_agent.py b/evals/task_agent.py index c16fcbe3e..c27183f12 100644 --- a/evals/task_agent.py +++ b/evals/task_agent.py @@ -35,7 +35,8 @@ def __init__( # 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) diff --git a/mirix/agent/meta_agent.py b/mirix/agent/meta_agent.py index 6392bd8bb..f49678f8f 100644 --- a/mirix/agent/meta_agent.py +++ b/mirix/agent/meta_agent.py @@ -144,6 +144,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/database/neo4j_client.py b/mirix/database/neo4j_client.py new file mode 100644 index 000000000..9c2544454 --- /dev/null +++ b/mirix/database/neo4j_client.py @@ -0,0 +1,242 @@ +""" +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)", + + # G_v6 (lean entity index) — orthogonal to v5 labels so both can coexist. + # Only built when graph_version=v6; harmless when graph_version=v5. + "CREATE CONSTRAINT v6_entity_id_unique IF NOT EXISTS " + "FOR (e:V6Entity) REQUIRE e.id IS UNIQUE", + "CREATE CONSTRAINT v6_entity_user_name_unique IF NOT EXISTS " + "FOR (e:V6Entity) REQUIRE (e.user_id, e.name_lower) IS UNIQUE", + + # 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_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), + + # G_v6 — lean entity index. Single vector index on V6Entity.name_embedding. + # No edge index — V6_COOCCUR carries only a count, no embedding. + _vector_index_statement("v6_entity_name_emb", "V6Entity", "name_embedding", dim, is_rel=False), + + # 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 + + +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 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..73148fc27 --- /dev/null +++ b/mirix/database/token_tracker.py @@ -0,0 +1,124 @@ +""" +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() diff --git a/mirix/functions/function_sets/memory_tools.py b/mirix/functions/function_sets/memory_tools.py index 6260c67e4..1d19fcc20 100644 --- a/mirix/functions/function_sets/memory_tools.py +++ b/mirix/functions/function_sets/memory_tools.py @@ -183,6 +183,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 +200,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( @@ -652,8 +662,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, 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..cf5d992b5 --- /dev/null +++ b/mirix/prompts/lightrag_prompts.py @@ -0,0 +1,253 @@ +""" +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, + ) diff --git a/mirix/schemas/agent.py b/mirix/schemas/agent.py index 00aaf650c..b3503ebda 100755 --- a/mirix/schemas/agent.py +++ b/mirix/schemas/agent.py @@ -282,6 +282,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): @@ -307,7 +316,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/episodic_memory.py b/mirix/schemas/episodic_memory.py index 5b0b9ade2..e4012e0f9 100755 --- a/mirix/schemas/episodic_memory.py +++ b/mirix/schemas/episodic_memory.py @@ -87,6 +87,13 @@ class EpisodicEvent(EpisodicEventBase): ], ) + # 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 @@ -135,3 +142,6 @@ class EpisodicEventUpdate(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 event's source_refs list (conflict-resolution path)." + ) 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 bee18161a..d8e21d513 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): @@ -676,6 +692,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 # ============================================================================ @@ -1939,6 +1983,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) @@ -1980,6 +2028,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.""" @@ -2090,6 +2200,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) @@ -2178,6 +2302,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() @@ -2286,97 +2420,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: diff --git a/mirix/server/server.py b/mirix/server/server.py index 3a200188c..ce13e101a 100644 --- a/mirix/server/server.py +++ b/mirix/server/server.py @@ -453,9 +453,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/_graph_retriever_base.py b/mirix/services/_graph_retriever_base.py new file mode 100644 index 000000000..2b1086bf2 --- /dev/null +++ b/mirix/services/_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/mirix/services/agent_manager.py b/mirix/services/agent_manager.py index 3250146e6..9933ce042 100644 --- a/mirix/services/agent_manager.py +++ b/mirix/services/agent_manager.py @@ -289,6 +289,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}", @@ -474,6 +486,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 @@ -533,6 +555,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/episodic_graph_manager.py b/mirix/services/episodic_graph_manager.py new file mode 100644 index 000000000..967a0b68d --- /dev/null +++ b/mirix/services/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/mirix/services/episodic_graph_retriever.py b/mirix/services/episodic_graph_retriever.py new file mode 100644 index 000000000..e257112c4 --- /dev/null +++ b/mirix/services/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/mirix/services/episodic_memory_manager.py b/mirix/services/episodic_memory_manager.py index 4109b065c..873a30ca5 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,60 @@ 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: + if settings.graph_version == "v6": + from mirix.services.graph_memory_manager_v6 import V6GraphManager + + await V6GraphManager().process_chunk( + source_kind="episodic", + source_id=event.id, + text=(summary or "") + ("\n" + details if details else ""), + agent_state=agent_state, + organization_id=organization_id, + user_id=user_id or "unknown", + ) + elif settings.graph_version == "v7": + 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", + ) + else: + from mirix.services.episodic_graph_manager import EpisodicGraphManager + + await EpisodicGraphManager().process_episode( + episode_id=event.id, + summary=summary, + details=details, + occurred_at=timestamp, + agent_state=agent_state, + organization_id=organization_id, + user_id=user_id or "unknown", + ) + except Exception as graph_err: + logger.warning("Episodic graph write failed (non-fatal): %s", graph_err) + return event except Exception as e: @@ -857,6 +922,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 +1340,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 +1353,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 +1391,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_v6.py b/mirix/services/graph_memory_manager_v6.py new file mode 100644 index 000000000..c2f4c55d3 --- /dev/null +++ b/mirix/services/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/mirix/services/graph_memory_manager_v7.py b/mirix/services/graph_memory_manager_v7.py new file mode 100644 index 000000000..65282dd1d --- /dev/null +++ b/mirix/services/graph_memory_manager_v7.py @@ -0,0 +1,435 @@ +""" +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 json +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])") + + +@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, + ) -> dict[str, Any]: + if not settings.enable_graph_memory or settings.graph_version != "v7": + 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} + + extraction = await extract_entities_and_relations( + text=text, llm_model=llm_model_from_agent(agent_state) + ) + candidates = self._select_anchors( + extraction.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, + ) + + 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 = normalize_name(name) + if not name or not nl: + 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.title = $title, + m.preview = $preview, + m.source_key = $source_key, + m.source_meta_json = $source_meta_json, + m.updated_at = $now, + m.created_at = coalesce(m.created_at, $now) + SET m.timestamp = $timestamp + """, + id=ref_id, + memory_id=memory_id, + memory_type=source_kind, + user_id=user_id, + organization_id=organization_id, + title=title[:120], + preview=preview, + source_key=source_key, + source_meta_json=json.dumps(source_meta, sort_keys=True), + 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, + ) + + # ---------------------------------------------------------------- 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 diff --git a/mirix/services/graph_retriever_dispatcher.py b/mirix/services/graph_retriever_dispatcher.py new file mode 100644 index 000000000..bb6378ce1 --- /dev/null +++ b/mirix/services/graph_retriever_dispatcher.py @@ -0,0 +1,196 @@ +""" +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). +# Raised 12k -> 24k: at 12k the formatted graph context measured ~13k tokens +# on LongMemEval-S, i.e. already over budget — apply_budget_to_search was +# truncating the tail, which starves counting/enumeration questions whose +# evidence is spread across many sessions. 24k doubles recall headroom while +# staying well under the ~32k "graph context should be <= 1/3 of the window" +# discipline for a 128k-window model. +DEFAULT_MAX_TOTAL_TOKENS = 24000 + + +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 "" + + # v6/v7 short-circuit the v5 dual-graph pipeline entirely. They own + # driver fetch, embedding, vector search, and PG enrichment. + if settings.graph_version == "v6": + from mirix.services.graph_retriever_v6 import V6Retriever + + return await V6Retriever().retrieve( + query=query, user_id=user_id, agent_state=agent_state, + top_k=item_top_k, + ) + if settings.graph_version == "v7": + 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, + ) + + 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) diff --git a/mirix/services/graph_retriever_v6.py b/mirix/services/graph_retriever_v6.py new file mode 100644 index 000000000..5cca83d56 --- /dev/null +++ b/mirix/services/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/mirix/services/graph_retriever_v7.py b/mirix/services/graph_retriever_v7.py new file mode 100644 index 000000000..7ebeb3565 --- /dev/null +++ b/mirix/services/graph_retriever_v7.py @@ -0,0 +1,249 @@ +""" +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 +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( + self, + *, + query: str, + user_id: str, + agent_state: AgentState, + top_k: int = 18, + max_items_per_kind: int = DEFAULT_MAX_ITEMS_PER_KIND, + ) -> str: + if not settings.enable_graph_memory or settings.graph_version != "v7": + 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 "" + + 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 self._format_context(anchors, [], []) + + 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("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 = [] + + ctx = self._format_context(anchors, ep_rows, sem_rows) + logger.info( + "v7 retrieve: %d anchors, %d ep, %d sem -> %d chars", + len(anchors), len(ep_rows), len(sem_rows), len(ctx), + ) + return ctx + + 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 + + async def _fetch_episodic(self, user_id: str, ids: list[str]) -> list[V7MemoryRow]: + 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 [ + 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, + ) + for row in result.fetchall() + ] + + async def _fetch_semantic(self, user_id: str, ids: list[str]) -> list[V7MemoryRow]: + 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 [ + 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: + 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..fd741a4d7 --- /dev/null +++ b/mirix/services/lightrag_extractor.py @@ -0,0 +1,224 @@ +""" +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 diff --git a/mirix/services/lightrag_keyword_extractor.py b/mirix/services/lightrag_keyword_extractor.py new file mode 100644 index 000000000..ef476e30f --- /dev/null +++ b/mirix/services/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/mirix/services/lightrag_merger.py b/mirix/services/lightrag_merger.py new file mode 100644 index 000000000..c2ce177fd --- /dev/null +++ b/mirix/services/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/mirix/services/semantic_graph_manager.py b/mirix/services/semantic_graph_manager.py new file mode 100644 index 000000000..540772174 --- /dev/null +++ b/mirix/services/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/mirix/services/semantic_graph_retriever.py b/mirix/services/semantic_graph_retriever.py new file mode 100644 index 000000000..669aa80ac --- /dev/null +++ b/mirix/services/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/mirix/services/semantic_memory_manager.py b/mirix/services/semantic_memory_manager.py index c43c98b17..c8cbac908 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,279 @@ 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: + if settings.graph_version == "v6": + from mirix.services.graph_memory_manager_v6 import V6GraphManager + + await V6GraphManager().process_chunk( + source_kind="semantic", + source_id=semantic_item.id, + text=(name or "") + "\n" + (summary or "") + "\n" + (details or ""), + agent_state=agent_state, + organization_id=organization_id, + user_id=user_id or "unknown", + ) + elif settings.graph_version == "v7": + 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", + ) + else: + from mirix.services.semantic_graph_manager import SemanticGraphManager + + await SemanticGraphManager().process_concept( + concept_id=semantic_item.id, + name=name, + summary=summary, + details=details or "", + agent_state=agent_state, + organization_id=organization_id, + user_id=user_id or "unknown", + ) + 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 +1685,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/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 diff --git a/scripts/visualize_v6_graph.py b/scripts/visualize_v6_graph.py new file mode 100644 index 000000000..126ffc62a --- /dev/null +++ b/scripts/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()