| 1 | """memory-graph adapter - SSE :9879. |
| 2 | |
| 3 | Calls query_entities + query_relations. Authority tier: agent-memory. |
| 4 | |
| 5 | RESPONSE SHAPE: The memory-graph MCP server returns raw JSON arrays, not wrapped |
| 6 | dicts. call_mcp_tool json.loads each block text, so `blocks` is a list where |
| 7 | each element is the parsed payload - which for query_entities is a list of entity |
| 8 | dicts directly (not {"entities": [...]}): |
| 9 | |
| 10 | blocks = [[{entity}, {entity}], ...] |
| 11 | |
| 12 | We extract entities from this by treating each block that is a list as the |
| 13 | entity list, and each block that is a dict as a single entity fallback. |
| 14 | |
| 15 | For query terms, the server supports: |
| 16 | - type= filter by entity type (project, decision, person, etc.) |
| 17 | - name= substring match on entity name |
| 18 | - q= free-text search (may not be implemented in all versions) |
| 19 | Omitting all filters returns all entities (use limit to cap). |
| 20 | |
| 21 | Strategy: fetch all entities (limit=k*3) and filter client-side for relevance, |
| 22 | plus fetch relations for the most relevant entity ids. |
| 23 | """ |
| 24 | from __future__ import annotations |
| 25 | import asyncio |
| 26 | import os |
| 27 | |
| 28 | from datetime import datetime, timezone |
| 29 | |
| 30 | from models import Hit |
| 31 | from adapters.base import BaseAdapter |
| 32 | from adapters.mcp_base import call_mcp_tool |
| 33 | |
| 34 | MEMGRAPH_URL = os.environ.get("MEMGRAPH_URL", "http://REDACTED-IP:9879/sse") |
| 35 | |
| 36 | |
| 37 | def _parse_dt(raw) -> datetime | None: |
| 38 | if not raw: |
| 39 | return None |
| 40 | try: |
| 41 | return datetime.fromisoformat(str(raw).replace("Z", "+00:00")) |
| 42 | except Exception: |
| 43 | return None |
| 44 | |
| 45 | |
| 46 | def _entity_to_hit(e: dict) -> Hit | None: |
| 47 | """Convert a memory-graph entity dict to a Hit.""" |
| 48 | if not isinstance(e, dict): |
| 49 | return None |
| 50 | entity_id = e.get("id", "") |
| 51 | name = e.get("name") or entity_id |
| 52 | attributes = e.get("attributes") or {} |
| 53 | snippet_parts = [] |
| 54 | for key in ("status", "summary", "description", "lesson", "notes"): |
| 55 | val = attributes.get(key) |
| 56 | if val: |
| 57 | snippet_parts.append(f"{key}: {val}") |
| 58 | if not snippet_parts: |
| 59 | snippet_parts = [f"{k}: {v}" for k, v in attributes.items()] |
| 60 | snippet = " | ".join(snippet_parts)[:400] |
| 61 | |
| 62 | dt = _parse_dt( |
| 63 | e.get("updated_at") or e.get("created_at") or e.get("last_seen") |
| 64 | ) |
| 65 | return Hit( |
| 66 | engine="memgraph", |
| 67 | title=str(name), |
| 68 | path=f"memory://{entity_id}", |
| 69 | snippet=snippet or str(attributes)[:300], |
| 70 | score=0.6, |
| 71 | last_seen=dt, |
| 72 | confidence=0.75, |
| 73 | authority_tier="agent-memory", |
| 74 | ) |
| 75 | |
| 76 | |
| 77 | def _relation_to_hit(r: dict) -> Hit | None: |
| 78 | """Convert a memory-graph relation dict to a Hit.""" |
| 79 | if not isinstance(r, dict): |
| 80 | return None |
| 81 | from_id = r.get("from_id", "") |
| 82 | to_id = r.get("to_id", "") |
| 83 | relation = r.get("relation", "") |
| 84 | if not (from_id and to_id and relation): |
| 85 | return None |
| 86 | snippet = f"{from_id} --[{relation}]--> {to_id}" |
| 87 | attrs = r.get("attributes") or {} |
| 88 | if attrs: |
| 89 | snippet += f" | {attrs}" |
| 90 | dt = _parse_dt(r.get("created_at") or r.get("valid_from")) |
| 91 | return Hit( |
| 92 | engine="memgraph", |
| 93 | title=f"{from_id} → {to_id}", |
| 94 | path=f"memory://relation/{r.get('id', '')}", |
| 95 | snippet=snippet[:400], |
| 96 | score=0.5, |
| 97 | last_seen=dt, |
| 98 | confidence=0.65, |
| 99 | authority_tier="agent-memory", |
| 100 | ) |
| 101 | |
| 102 | |
| 103 | def _extract_items(blocks: list) -> list[dict]: |
| 104 | """Extract entity/relation dicts from call_mcp_tool return value. |
| 105 | |
| 106 | Handles both: |
| 107 | - blocks = [[{entity}, ...], ...] (list of parsed JSON arrays) |
| 108 | - blocks = [{"entities": [{...}]}, ...] (list of parsed dicts with key) |
| 109 | - blocks = [{entity}] (flat list of dicts) |
| 110 | """ |
| 111 | items: list[dict] = [] |
| 112 | for block in blocks: |
| 113 | if isinstance(block, list): |
| 114 | items.extend(block) |
| 115 | elif isinstance(block, dict): |
| 116 | inner = block.get("entities") or block.get("results") or block.get("relations") |
| 117 | if isinstance(inner, list): |
| 118 | items.extend(inner) |
| 119 | elif block.get("id"): |
| 120 | items.append(block) |
| 121 | return items |
| 122 | |
| 123 | |
| 124 | def _score_by_query(entity: dict, query_lower: str) -> float: |
| 125 | """Rough relevance score for an entity against the query.""" |
| 126 | text = " ".join([ |
| 127 | str(entity.get("name") or ""), |
| 128 | str(entity.get("id") or ""), |
| 129 | str(entity.get("type") or ""), |
| 130 | str(entity.get("attributes") or ""), |
| 131 | ]).lower() |
| 132 | query_words = query_lower.split() |
| 133 | matched = sum(1 for w in query_words if w in text and len(w) > 2) |
| 134 | return min(1.0, 0.4 + matched * 0.15) |
| 135 | |
| 136 | |
| 137 | class MemgraphAdapter(BaseAdapter): |
| 138 | engine = "memgraph" |
| 139 | authority_tier = "agent-memory" |
| 140 | |
| 141 | async def _fetch(self, query: str, k: int) -> list[Hit]: |
| 142 | query_lower = query.lower() |
| 143 | |
| 144 | entity_task = asyncio.create_task( |
| 145 | call_mcp_tool(MEMGRAPH_URL, "query_entities", {"limit": k * 4}, timeout=3.5) |
| 146 | ) |
| 147 | relation_task = asyncio.create_task( |
| 148 | call_mcp_tool(MEMGRAPH_URL, "query_relations", {"limit": k * 2}, timeout=3.5) |
| 149 | ) |
| 150 | entity_blocks, relation_blocks = await asyncio.gather(entity_task, relation_task) |
| 151 | |
| 152 | entities = _extract_items(entity_blocks) |
| 153 | entity_hits: list[Hit] = [] |
| 154 | for e in entities: |
| 155 | hit = _entity_to_hit(e) |
| 156 | if hit: |
| 157 | hit.score = _score_by_query(e, query_lower) |
| 158 | entity_hits.append(hit) |
| 159 | |
| 160 | relations = _extract_items(relation_blocks) |
| 161 | relation_hits: list[Hit] = [] |
| 162 | for r in relations: |
| 163 | hit = _relation_to_hit(r) |
| 164 | if hit: |
| 165 | relation_hits.append(hit) |
| 166 | |
| 167 | all_hits = entity_hits + relation_hits |
| 168 | all_hits.sort(key=lambda h: h.score, reverse=True) |
| 169 | |
| 170 | seen: set[str] = set() |
| 171 | deduped: list[Hit] = [] |
| 172 | for h in all_hits: |
| 173 | key = h.path or h.title |
| 174 | if key not in seen: |
| 175 | seen.add(key) |
| 176 | deduped.append(h) |
| 177 | |
| 178 | return deduped[:k] |