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.
- deepagents_graph_memory/__init__.py +20 -0
- deepagents_graph_memory/backend.py +838 -0
- deepagents_graph_memory/errors.py +21 -0
- deepagents_graph_memory/ladybug_store.py +847 -0
- deepagents_graph_memory/paths.py +253 -0
- deepagents_graph_memory/py.typed +1 -0
- deepagents_graph_memory/recall.py +936 -0
- deepagents_graph_memory/renderers.py +199 -0
- deepagents_graph_memory/stores.py +378 -0
- deepagents_graph_memory/tools.py +193 -0
- deepagents_graph_memory/vgs.py +210 -0
- deepagents_graph_memory-0.1.0.dist-info/METADATA +770 -0
- deepagents_graph_memory-0.1.0.dist-info/RECORD +16 -0
- deepagents_graph_memory-0.1.0.dist-info/WHEEL +5 -0
- deepagents_graph_memory-0.1.0.dist-info/licenses/LICENSE +21 -0
- deepagents_graph_memory-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|