deepagents-graph-memory 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,199 @@
1
+ # - Turns graph data into readable pages, with links between related items.
2
+ # - Tests: test_renderers.py checks pages for graph structure, items, connections, and search results;
3
+ # test_backend_read.py checks those pages through the agent-facing file view.
4
+
5
+ """Markdown renderers for virtual graph files."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from collections import defaultdict
11
+ from collections.abc import Iterable, Mapping
12
+
13
+ from deepagents_graph_memory.paths import node_path
14
+ from deepagents_graph_memory.stores import GraphEdge, GraphNode, NeighborhoodResult, SearchResult
15
+
16
+
17
+ def render_index() -> str:
18
+ """Render the graph memory index page."""
19
+ return "\n".join(
20
+ [
21
+ "# Graph Memory",
22
+ "",
23
+ "Graph memory exposes relationship-oriented facts as read-only markdown files.",
24
+ "",
25
+ "## Paths",
26
+ "- `/graph/schema.md` - graph schema",
27
+ "- `/graph/nodes/{label}/{id}.md` - node page",
28
+ "- `/graph/search/{query}.md` - graph search results",
29
+ "",
30
+ "Use graph memory tools to add or update graph facts. Generated graph views are read-only.",
31
+ ]
32
+ )
33
+
34
+
35
+ def render_schema(schema: str) -> str:
36
+ """Render a schema page.
37
+
38
+ Args:
39
+ schema: Schema text from the graph store.
40
+
41
+ Returns:
42
+ Markdown schema page.
43
+ """
44
+ body = schema.strip() or "No graph schema has been created yet."
45
+ return f"# Graph Schema\n\n```text\n{body}\n```"
46
+
47
+
48
+ def render_node(node: GraphNode, neighborhood: NeighborhoodResult | None = None) -> str:
49
+ """Render a node page.
50
+
51
+ Args:
52
+ node: Node to render.
53
+ neighborhood: Optional immediate neighborhood.
54
+
55
+ Returns:
56
+ Markdown node page.
57
+ """
58
+ lines = [f"# {node.label}: {node.id}", ""]
59
+ lines.extend(_render_properties(node.properties))
60
+ provenance_keys = ("source", "source_agent", "created_by", "created_by_agent", "created_at", "updated_at")
61
+ provenance = {key: node.properties[key] for key in provenance_keys if key in node.properties}
62
+ if provenance:
63
+ lines.extend(["", "## Provenance"])
64
+ lines.extend(f"- **{key}**: {_format_value(value)}" for key, value in provenance.items())
65
+ edges = neighborhood.edges if neighborhood else []
66
+ if edges:
67
+ lines.append("")
68
+ lines.extend(_render_edge_sections(node, edges))
69
+ if neighborhood and (neighborhood.truncated_edges or neighborhood.truncated_nodes):
70
+ lines.append("")
71
+ lines.append(_truncation_note(neighborhood.truncated_nodes, neighborhood.truncated_edges))
72
+ return "\n".join(lines).rstrip() + "\n"
73
+
74
+
75
+ def render_search(query: str, result: SearchResult) -> str:
76
+ """Render graph search results.
77
+
78
+ Args:
79
+ query: Search query.
80
+ result: Search result.
81
+
82
+ Returns:
83
+ Markdown search page.
84
+ """
85
+ lines = [f"# Graph Search: {query}", ""]
86
+ if not result.items:
87
+ lines.append("No matching graph facts found.")
88
+ else:
89
+ for item in result.items:
90
+ suffix = f" - {item.text}" if item.text else ""
91
+ lines.append(f"- [{item.title}](/graph{item.path}){suffix}")
92
+ if result.truncated:
93
+ lines.append("")
94
+ lines.append("Results truncated. Refine the query or increase the limit.")
95
+ return "\n".join(lines).rstrip() + "\n"
96
+
97
+
98
+ def _render_properties(properties: Mapping[str, object]) -> list[str]:
99
+ public = {
100
+ key: value
101
+ for key, value in properties.items()
102
+ if key
103
+ not in {
104
+ "scope_key",
105
+ "created_at",
106
+ "updated_at",
107
+ "created_by",
108
+ "created_by_agent",
109
+ "source_agent",
110
+ "source",
111
+ "search_text",
112
+ }
113
+ }
114
+ if not public:
115
+ return []
116
+ lines = ["## Properties"]
117
+ for key in sorted(public):
118
+ lines.append(f"- **{key}**: {_format_value(public[key])}")
119
+ return lines
120
+
121
+
122
+ def _render_edge_sections(node: GraphNode, edges: Iterable[GraphEdge]) -> list[str]:
123
+ grouped: dict[str, list[str]] = defaultdict(list)
124
+ for edge in edges:
125
+ details = {key: value for key, value in edge.properties.items() if key not in {"scope_key", "search_text"}}
126
+ suffix = f" — {', '.join(f'{key}: {_format_value(value)}' for key, value in sorted(details.items()))}" if details else ""
127
+ if edge.source_label == node.label and edge.source_id == node.id:
128
+ heading = _outgoing_heading(edge.relationship)
129
+ grouped[heading].append(_node_link(edge.target_label, edge.target_id) + suffix)
130
+ elif edge.target_label == node.label and edge.target_id == node.id:
131
+ heading = _incoming_heading(edge.relationship)
132
+ grouped[heading].append(_node_link(edge.source_label, edge.source_id) + suffix)
133
+ lines: list[str] = []
134
+ for heading in sorted(grouped):
135
+ lines.append(f"## {heading}")
136
+ for item in sorted(set(grouped[heading])):
137
+ lines.append(f"- {item}")
138
+ lines.append("")
139
+ if lines and lines[-1] == "":
140
+ lines.pop()
141
+ return lines
142
+
143
+
144
+ def _node_link(label: str, node_id: str) -> str:
145
+ return f"[{node_id}](/graph{node_path(label, node_id)})"
146
+
147
+
148
+ def _outgoing_heading(relationship: str) -> str:
149
+ mapping = {
150
+ "HAS_ACTION": "Action",
151
+ "HAS_OUTCOME": "Outcome",
152
+ "HAS_RATIONALE": "Rationale",
153
+ "HAS_SITUATION": "Situation",
154
+ "HAS_TRACE": "Traces",
155
+ "INVOLVED": "Involved",
156
+ "JUSTIFIED": "Justified",
157
+ "LED_TO": "Led to",
158
+ "PRODUCED": "Produced",
159
+ "RECORDED": "Recorded",
160
+ "SUPPORTS": "Supports",
161
+ }
162
+ return mapping.get(relationship, _humanize_relationship(relationship))
163
+
164
+
165
+ def _incoming_heading(relationship: str) -> str:
166
+ mapping = {
167
+ "HAS_ACTION": "Action for",
168
+ "HAS_OUTCOME": "Outcome for",
169
+ "HAS_RATIONALE": "Rationale for",
170
+ "HAS_SITUATION": "Situation for",
171
+ "HAS_TRACE": "Trace of",
172
+ "INVOLVED": "Involved in",
173
+ "JUSTIFIED": "Justified by",
174
+ "LED_TO": "Led from",
175
+ "PRODUCED": "Produced by",
176
+ "RECORDED": "Recorded by",
177
+ "SUPPORTS": "Supported by",
178
+ }
179
+ return mapping.get(relationship, f"{_humanize_relationship(relationship)} (incoming)")
180
+
181
+
182
+ def _humanize_relationship(relationship: str) -> str:
183
+ return relationship.replace("_", " ").capitalize()
184
+
185
+
186
+ def _format_value(value: object) -> str:
187
+ if isinstance(value, str):
188
+ return value
189
+ return f"`{json.dumps(value, sort_keys=True)}`"
190
+
191
+
192
+ def _truncation_note(truncated_nodes: bool, truncated_edges: bool) -> str:
193
+ targets = []
194
+ if truncated_nodes:
195
+ targets.append("nodes")
196
+ if truncated_edges:
197
+ targets.append("edges")
198
+ label = " and ".join(targets) if targets else "results"
199
+ return f"Results truncated for {label}. Refine the query or increase the limit."
@@ -0,0 +1,378 @@
1
+ # - Defines the shared shapes for graph items, connections, and search results.
2
+ # - Also checks saved details and helps decide which text matches a question.
3
+ # - Tests: test_validation.py checks bad data; test_renderers.py uses the shared graph shapes.
4
+ # test_scope.py and test_recall.py exercise the helpers through saving and finding context.
5
+
6
+ """Internal graph integration adapters used by the graph memory backend."""
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import math
12
+ import re
13
+ from collections.abc import Mapping, Sequence
14
+ from contextlib import AbstractContextManager
15
+ from dataclasses import dataclass, field
16
+ from datetime import UTC, datetime
17
+ from typing import Any, Protocol
18
+
19
+ from deepagents_graph_memory.errors import GraphMemoryValidationError
20
+
21
+ JsonScalar = str | int | float | bool | None
22
+ JsonValue = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"]
23
+ Properties = dict[str, JsonValue]
24
+
25
+ SEARCH_TEXT_FIELDS = {
26
+ "action",
27
+ "aliases",
28
+ "artifact",
29
+ "artifacts",
30
+ "description",
31
+ "error",
32
+ "evidence",
33
+ "name",
34
+ "outcome",
35
+ "rationale",
36
+ "summary",
37
+ "situation",
38
+ "text",
39
+ "title",
40
+ "value",
41
+ }
42
+ SEARCH_METADATA_FIELDS = {
43
+ "scope_key",
44
+ "created_at",
45
+ "updated_at",
46
+ "created_by",
47
+ "created_by_agent",
48
+ "source_agent",
49
+ "source",
50
+ }
51
+ SEARCH_STOPWORDS = {
52
+ "about",
53
+ "after",
54
+ "and",
55
+ "did",
56
+ "for",
57
+ "from",
58
+ "how",
59
+ "the",
60
+ "this",
61
+ "try",
62
+ "was",
63
+ "what",
64
+ "when",
65
+ "where",
66
+ "which",
67
+ "why",
68
+ }
69
+ SEARCH_TERM_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.:-]*")
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class GraphNode:
74
+ """A graph node returned by an internal adapter."""
75
+
76
+ label: str
77
+ id: str
78
+ properties: Properties = field(default_factory=dict)
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class GraphEdge:
83
+ """A graph edge returned by an internal adapter."""
84
+
85
+ source_label: str
86
+ source_id: str
87
+ relationship: str
88
+ target_label: str
89
+ target_id: str
90
+ properties: Properties = field(default_factory=dict)
91
+
92
+
93
+ @dataclass(frozen=True)
94
+ class LimitedResult:
95
+ """A bounded list result."""
96
+
97
+ items: list[str]
98
+ truncated: bool = False
99
+
100
+
101
+ @dataclass(frozen=True)
102
+ class SearchItem:
103
+ """A graph search result."""
104
+
105
+ path: str
106
+ title: str
107
+ text: str
108
+
109
+
110
+ @dataclass(frozen=True)
111
+ class SearchResult:
112
+ """A bounded graph search result."""
113
+
114
+ items: list[SearchItem]
115
+ truncated: bool = False
116
+
117
+
118
+ @dataclass(frozen=True)
119
+ class NeighborhoodResult:
120
+ """A bounded node neighborhood result."""
121
+
122
+ node: GraphNode
123
+ edges: list[GraphEdge]
124
+ truncated_nodes: bool = False
125
+ truncated_edges: bool = False
126
+
127
+
128
+ @dataclass(frozen=True)
129
+ class EdgeResult:
130
+ """A bounded list of focused graph relationships."""
131
+
132
+ items: list[GraphEdge]
133
+ truncated: bool = False
134
+
135
+
136
+ class GraphStoreAdapter(Protocol):
137
+ """Internal adapter boundary for the LadybugDB graph store.
138
+
139
+ This is not a public graph database protocol. It keeps GraphMemoryBackend
140
+ testable while the real store uses LadybugDB's native Python connection.
141
+ """
142
+
143
+ def get_schema(self, *, scope_key: str | None = None) -> str:
144
+ """Return graph schema text."""
145
+
146
+ def list_labels(self, *, scope_key: str | None = None, limit: int = 50) -> LimitedResult:
147
+ """List known node labels."""
148
+
149
+ def list_node_ids(self, label: str, *, scope_key: str | None = None, limit: int = 50) -> LimitedResult:
150
+ """List ids for a node label."""
151
+
152
+ def list_subject_trace_ids(self, subject_id: str, *, scope_key: str | None = None, limit: int = 50) -> LimitedResult:
153
+ """List subject traces with current findings first."""
154
+
155
+ def get_node(self, label: str, node_id: str, *, scope_key: str | None = None) -> GraphNode | None:
156
+ """Return a single node."""
157
+
158
+ def get_neighbors(
159
+ self,
160
+ label: str,
161
+ node_id: str,
162
+ *,
163
+ scope_key: str | None = None,
164
+ depth: int = 1,
165
+ max_nodes: int = 50,
166
+ max_edges: int = 100,
167
+ ) -> NeighborhoodResult | None:
168
+ """Return a bounded node neighborhood."""
169
+
170
+ def list_trace_edges(
171
+ self, trace_id: str, relationship: str, *, incoming: bool = False, scope_key: str | None = None, limit: int = 50
172
+ ) -> EdgeResult:
173
+ """Return one trace's relationships without unrelated neighbors using the budget."""
174
+
175
+ def search(self, query: str, *, scope_key: str | None = None, limit: int = 20) -> SearchResult:
176
+ """Search graph metadata."""
177
+
178
+ def add_node(self, label: str, node_id: str, *, properties: Properties | None = None, scope_key: str | None = None) -> None:
179
+ """Add or update a node."""
180
+
181
+ def add_edge(
182
+ self,
183
+ source_label: str,
184
+ source_id: str,
185
+ relationship: str,
186
+ target_label: str,
187
+ target_id: str,
188
+ *,
189
+ properties: Properties | None = None,
190
+ scope_key: str | None = None,
191
+ ) -> None:
192
+ """Add or update an edge."""
193
+
194
+ def add_graph_documents(self, documents: Sequence[Any], *, scope_key: str | None = None) -> None:
195
+ """Add graph documents."""
196
+
197
+ def transaction(self) -> AbstractContextManager[None]:
198
+ """Atomically group graph writes."""
199
+
200
+
201
+ def utc_now() -> str:
202
+ """Return the current UTC time in ISO 8601 format."""
203
+ return datetime.now(UTC).isoformat()
204
+
205
+
206
+ def finding_observed_timestamp(node: GraphNode) -> float | None:
207
+ """Return an aware observation's timestamp, or unknown for missing or invalid time."""
208
+ value = node.properties.get("observed_at")
209
+ try:
210
+ observed = datetime.fromisoformat(value) if isinstance(value, str) else None
211
+ return observed.timestamp() if observed is not None and observed.tzinfo is not None else None
212
+ except (TypeError, ValueError):
213
+ return None
214
+
215
+
216
+ def valid_finding_link(newer: GraphNode, older: GraphNode, relationship: str, *, reviewed_count: int = 0) -> bool:
217
+ """Check whether a finding update or resolution has valid recorded semantics."""
218
+ subject = newer.properties.get("subject")
219
+ if not isinstance(subject, str) or not subject.strip() or subject != older.properties.get("subject"):
220
+ return False
221
+ if not (newer.properties.get("evidence") or newer.properties.get("evidence_refs")):
222
+ return False
223
+ if relationship == "RESOLVES":
224
+ return reviewed_count >= 2
225
+ if relationship != "SUPERSEDES" or newer.properties.get("finding_type") != "state" or older.properties.get("finding_type") != "state":
226
+ return False
227
+ try:
228
+ new_time = datetime.fromisoformat(newer.properties["observed_at"])
229
+ old_time = datetime.fromisoformat(older.properties["observed_at"])
230
+ return new_time.tzinfo is not None and old_time.tzinfo is not None and new_time > old_time
231
+ except (KeyError, TypeError, ValueError):
232
+ return False
233
+
234
+
235
+ def validate_properties(properties: Mapping[str, Any] | None) -> Properties:
236
+ """Validate a graph properties mapping.
237
+
238
+ Args:
239
+ properties: Properties supplied by an agent or caller.
240
+
241
+ Returns:
242
+ A JSON-serializable properties dict.
243
+
244
+ Raises:
245
+ GraphMemoryValidationError: If the payload is not a safe JSON object.
246
+ """
247
+ if properties is None:
248
+ return {}
249
+ if not isinstance(properties, Mapping):
250
+ msg = "properties must be a JSON object."
251
+ raise GraphMemoryValidationError(msg)
252
+ try:
253
+ json.dumps(dict(properties), allow_nan=False)
254
+ except (TypeError, ValueError, RecursionError) as exc:
255
+ msg = "properties must be JSON serializable."
256
+ raise GraphMemoryValidationError(msg) from exc
257
+ result: Properties = {}
258
+ for key, value in properties.items():
259
+ if not isinstance(key, str) or not key:
260
+ msg = "property keys must be non-empty strings."
261
+ raise GraphMemoryValidationError(msg)
262
+ if key.startswith("_"):
263
+ msg = "property keys must not start with underscore."
264
+ raise GraphMemoryValidationError(msg)
265
+ result[key] = _validate_json_value(value, path=key)
266
+ return result
267
+
268
+
269
+ def merge_metadata(properties: Mapping[str, Any] | None, *, scope_key: str | None = None, metadata: Mapping[str, Any] | None = None) -> Properties:
270
+ """Merge caller properties with graph-memory metadata.
271
+
272
+ Args:
273
+ properties: Caller properties.
274
+ scope_key: Optional scope key.
275
+ metadata: Additional metadata.
276
+
277
+ Returns:
278
+ Validated merged properties.
279
+ """
280
+ merged: dict[str, Any] = dict(validate_properties(properties))
281
+ if "scope_key" in merged and merged["scope_key"] != scope_key:
282
+ msg = "scope_key cannot differ from the active namespace."
283
+ raise GraphMemoryValidationError(msg)
284
+ if metadata and "scope_key" in metadata and metadata["scope_key"] != scope_key:
285
+ msg = "scope_key cannot differ from the active namespace."
286
+ raise GraphMemoryValidationError(msg)
287
+ now = utc_now()
288
+ merged.setdefault("created_at", now)
289
+ merged["updated_at"] = now
290
+ if scope_key is not None:
291
+ merged["scope_key"] = scope_key
292
+ if metadata:
293
+ for key, value in metadata.items():
294
+ if value is not None:
295
+ merged[key] = value
296
+ return validate_properties(merged)
297
+
298
+
299
+ def node_search_text(label: str, node_id: str, properties: Mapping[str, JsonValue]) -> str:
300
+ """Build the normalized text indexed for graph-memory node recall."""
301
+ parts = [label, node_id]
302
+ for key in sorted(SEARCH_TEXT_FIELDS):
303
+ if key in properties:
304
+ parts.extend(_flatten_search_value(properties[key]))
305
+ public = {key: value for key, value in properties.items() if key not in SEARCH_METADATA_FIELDS and key != "search_text"}
306
+ if public:
307
+ parts.append(json.dumps(public, sort_keys=True))
308
+ return " ".join(part for part in parts if part).strip()
309
+
310
+
311
+ def lexical_search_score(query: str, text: str) -> int:
312
+ """Score a query against searchable text without adding another package."""
313
+ normalized_query = query.casefold().strip()
314
+ haystack = text.casefold()
315
+ if not normalized_query or not haystack:
316
+ return 0
317
+ score = 0
318
+ if normalized_query in haystack:
319
+ score += 100
320
+ terms = _search_terms(normalized_query)
321
+ tokens = set(_search_terms(haystack))
322
+ for term in terms:
323
+ if term in tokens:
324
+ score += 20
325
+ elif term in haystack:
326
+ score += 8
327
+ return score
328
+
329
+
330
+ def _validate_json_value(value: Any, *, path: str) -> JsonValue:
331
+ if isinstance(value, float) and not math.isfinite(value):
332
+ msg = f"property {path!r} must be finite."
333
+ raise GraphMemoryValidationError(msg)
334
+ if isinstance(value, str | int | float | bool) or value is None:
335
+ return value
336
+ if isinstance(value, list):
337
+ return [_validate_json_value(item, path=path) for item in value]
338
+ if isinstance(value, dict):
339
+ if any(not isinstance(key, str) for key in value):
340
+ msg = f"property {path!r} must have string keys."
341
+ raise GraphMemoryValidationError(msg)
342
+ return {key: _validate_json_value(item, path=f"{path}.{key}") for key, item in value.items()}
343
+ msg = f"property {path!r} must be JSON serializable."
344
+ raise GraphMemoryValidationError(msg)
345
+
346
+
347
+ def _flatten_search_value(value: JsonValue) -> list[str]:
348
+ if isinstance(value, str):
349
+ return [value]
350
+ if isinstance(value, list):
351
+ items: list[str] = []
352
+ for item in value:
353
+ items.extend(_flatten_search_value(item))
354
+ return items
355
+ if isinstance(value, dict):
356
+ items = []
357
+ for item in value.values():
358
+ items.extend(_flatten_search_value(item))
359
+ return items
360
+ if value is None:
361
+ return []
362
+ return [str(value)]
363
+
364
+
365
+ def _search_terms(value: str) -> list[str]:
366
+ terms = []
367
+ seen = set()
368
+ for raw in SEARCH_TERM_RE.findall(value.casefold()):
369
+ if len(raw) < 2 or raw in SEARCH_STOPWORDS:
370
+ continue
371
+ variants = [raw, raw.replace("-", "_")]
372
+ if raw.endswith("s") and len(raw) > 3:
373
+ variants.append(raw[:-1])
374
+ for term in variants:
375
+ if term not in seen:
376
+ terms.append(term)
377
+ seen.add(term)
378
+ return terms