● Zion Boggan
repos/Darwin/cortex/adapters/memgraph.py
zionboggan.com ↗
178 lines · python
History for this file →
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]