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,936 @@
|
|
|
1
|
+
# - Finds context for a question, then follows useful connections to fill in the story.
|
|
2
|
+
# - Stops when the next connection is unhelpful or the answer reaches its size limit.
|
|
3
|
+
# - Tests: test_recall.py covers finding matches, following connections, and stopping;
|
|
4
|
+
# test_trace.py checks bringing back the reasons and outcomes behind past work.
|
|
5
|
+
|
|
6
|
+
"""Adaptive graph memory recall."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
from collections.abc import Iterable, Sequence
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Literal, cast
|
|
16
|
+
|
|
17
|
+
from deepagents_graph_memory.errors import GraphMemoryPathError, GraphMemoryValidationError
|
|
18
|
+
from deepagents_graph_memory.paths import node_path, parse_graph_path
|
|
19
|
+
from deepagents_graph_memory.stores import GraphEdge, GraphNode, GraphStoreAdapter, JsonValue, finding_observed_timestamp, valid_finding_link
|
|
20
|
+
|
|
21
|
+
RecallMode = Literal["auto", "local", "deep"]
|
|
22
|
+
|
|
23
|
+
_VALID_MODES = {"auto", "local", "deep"}
|
|
24
|
+
_TERM_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.:-]*")
|
|
25
|
+
_MAX_QUERY_LENGTH = 1000
|
|
26
|
+
_MAX_DEPTH = 10
|
|
27
|
+
_TRACE_COMPONENTS = {"Situation": "situation", "Rationale": "rationale", "Action": "action", "Outcome": "outcome"}
|
|
28
|
+
_GENERATED_TRACE_LINKS = {"HAS_SITUATION", "HAS_RATIONALE", "HAS_ACTION", "HAS_OUTCOME", "LED_TO", "JUSTIFIED", "PRODUCED"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, order=True)
|
|
32
|
+
class _NodeRef:
|
|
33
|
+
label: str
|
|
34
|
+
node_id: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class _NodeRecord:
|
|
39
|
+
node: GraphNode
|
|
40
|
+
distance: int
|
|
41
|
+
score: int
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class _EdgeRecord:
|
|
46
|
+
edge: GraphEdge
|
|
47
|
+
distance: int
|
|
48
|
+
score: int
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class _RecallState:
|
|
53
|
+
nodes: dict[_NodeRef, _NodeRecord]
|
|
54
|
+
edges: dict[tuple[str, str, str, str, str], _EdgeRecord]
|
|
55
|
+
search_truncated: bool = False
|
|
56
|
+
truncated_nodes: bool = False
|
|
57
|
+
truncated_edges: bool = False
|
|
58
|
+
stopped_reason: str = ""
|
|
59
|
+
related_findings: bool = False
|
|
60
|
+
related_incomplete: bool = False
|
|
61
|
+
omitted_anchors: list[_NodeRef] | None = None
|
|
62
|
+
dependency_notices: list[str] | None = None
|
|
63
|
+
dependency_unknown: bool = False
|
|
64
|
+
dependency_unknown_roots: list[str] | None = None
|
|
65
|
+
explicit_anchors: set[_NodeRef] | None = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def recall_graph_memory(
|
|
69
|
+
store: GraphStoreAdapter,
|
|
70
|
+
query: str,
|
|
71
|
+
*,
|
|
72
|
+
scope_key: str | None = None,
|
|
73
|
+
anchors: Sequence[str] | None = None,
|
|
74
|
+
mode: RecallMode = "auto",
|
|
75
|
+
token_budget: int = 2000,
|
|
76
|
+
max_depth: int = 3,
|
|
77
|
+
max_nodes: int = 50,
|
|
78
|
+
max_edges: int = 100,
|
|
79
|
+
) -> str:
|
|
80
|
+
"""Recall a relevant slice of graph memory.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
store: Internal graph integration adapter.
|
|
84
|
+
query: Natural-language recall query.
|
|
85
|
+
scope_key: Optional scope key used by the configured backend.
|
|
86
|
+
anchors: Optional concrete starting hints such as file paths, run ids, task ids, or subagent ids.
|
|
87
|
+
mode: Recall expansion mode. `auto` expands while relevant, `local` reads one hop, and `deep` expands to `max_depth`.
|
|
88
|
+
token_budget: Approximate output token budget.
|
|
89
|
+
max_depth: Maximum traversal depth.
|
|
90
|
+
max_nodes: Maximum nodes to include.
|
|
91
|
+
max_edges: Maximum edges to include.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
Compact markdown with source graph paths.
|
|
95
|
+
"""
|
|
96
|
+
query = _validate_recall_query(query)
|
|
97
|
+
anchors = [_validate_anchor(anchor) for anchor in anchors or []]
|
|
98
|
+
mode = _validate_mode(mode)
|
|
99
|
+
token_budget, max_depth, max_nodes, max_edges = _validate_budgets(token_budget, max_depth, max_nodes, max_edges)
|
|
100
|
+
|
|
101
|
+
terms = _query_terms(query)
|
|
102
|
+
state = _RecallState(nodes={}, edges={})
|
|
103
|
+
state.explicit_anchors = {ref for anchor in anchors if (ref := _seed_from_anchor(anchor)) is not None}
|
|
104
|
+
seeds = _find_seed_nodes(store, query, terms, anchors=anchors, scope_key=scope_key, limit=min(max_nodes, 20), state=state)
|
|
105
|
+
if not seeds:
|
|
106
|
+
return _render_recall(query, state, token_budget=token_budget)
|
|
107
|
+
|
|
108
|
+
processed_subjects: set[str] = set()
|
|
109
|
+
anchored_refs = {_seed_from_anchor(anchor) for anchor in anchors}
|
|
110
|
+
_expand_subject_findings(
|
|
111
|
+
store,
|
|
112
|
+
seeds,
|
|
113
|
+
state,
|
|
114
|
+
processed_subjects=processed_subjects,
|
|
115
|
+
scope_key=scope_key,
|
|
116
|
+
max_nodes=max_nodes,
|
|
117
|
+
max_edges=max_edges,
|
|
118
|
+
anchored_refs=anchored_refs,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
for index, seed in enumerate(seeds):
|
|
122
|
+
node = store.get_node(seed.label, seed.node_id, scope_key=scope_key)
|
|
123
|
+
if node is None:
|
|
124
|
+
continue
|
|
125
|
+
_add_node(state, node, distance=0, score=100 - index, max_nodes=max_nodes)
|
|
126
|
+
if seed in anchored_refs and seed not in state.nodes:
|
|
127
|
+
state.related_incomplete = True
|
|
128
|
+
state.omitted_anchors = [*(state.omitted_anchors or []), seed]
|
|
129
|
+
|
|
130
|
+
target_depth = 1 if mode == "local" else max_depth
|
|
131
|
+
frontier = list(seeds)
|
|
132
|
+
seen_frontier = set(frontier)
|
|
133
|
+
|
|
134
|
+
for depth in range(1, target_depth + 1):
|
|
135
|
+
if not frontier:
|
|
136
|
+
state.stopped_reason = "No new graph neighbors remained to inspect."
|
|
137
|
+
break
|
|
138
|
+
next_frontier: list[_NodeRef] = []
|
|
139
|
+
layer_added_relevant_edge = False
|
|
140
|
+
|
|
141
|
+
for ref in frontier:
|
|
142
|
+
if len(state.edges) >= max_edges:
|
|
143
|
+
state.truncated_edges = True
|
|
144
|
+
break
|
|
145
|
+
neighborhood = store.get_neighbors(ref.label, ref.node_id, scope_key=scope_key, depth=1, max_nodes=max_nodes, max_edges=max_edges)
|
|
146
|
+
if neighborhood is None:
|
|
147
|
+
continue
|
|
148
|
+
if neighborhood.truncated_nodes:
|
|
149
|
+
state.truncated_nodes = True
|
|
150
|
+
if neighborhood.truncated_edges:
|
|
151
|
+
state.truncated_edges = True
|
|
152
|
+
_add_node(state, neighborhood.node, distance=depth - 1, score=_score_node(neighborhood.node, terms, query), max_nodes=max_nodes)
|
|
153
|
+
|
|
154
|
+
for edge in neighborhood.edges:
|
|
155
|
+
if len(state.edges) >= max_edges:
|
|
156
|
+
state.truncated_edges = True
|
|
157
|
+
break
|
|
158
|
+
edge_score = _score_edge(edge, terms, query)
|
|
159
|
+
include_edge = mode in {"deep", "local"} or depth == 1 or edge_score > 0
|
|
160
|
+
if not include_edge:
|
|
161
|
+
continue
|
|
162
|
+
_add_edge(state, edge, distance=depth, score=edge_score, max_edges=max_edges)
|
|
163
|
+
if edge_score > 0:
|
|
164
|
+
layer_added_relevant_edge = True
|
|
165
|
+
for neighbor in _edge_neighbors(edge, ref):
|
|
166
|
+
node = store.get_node(neighbor.label, neighbor.node_id, scope_key=scope_key)
|
|
167
|
+
if node is None:
|
|
168
|
+
node = GraphNode(label=neighbor.label, id=neighbor.node_id)
|
|
169
|
+
node_score = _score_node(node, terms, query)
|
|
170
|
+
_add_node(state, node, distance=depth, score=node_score, max_nodes=max_nodes)
|
|
171
|
+
should_expand = mode == "deep" or (mode == "auto" and (depth == 1 or edge_score > 0 or node_score > 0))
|
|
172
|
+
if should_expand and neighbor not in seen_frontier:
|
|
173
|
+
next_frontier.append(neighbor)
|
|
174
|
+
seen_frontier.add(neighbor)
|
|
175
|
+
|
|
176
|
+
if state.truncated_edges or state.truncated_nodes:
|
|
177
|
+
state.stopped_reason = "Stopped at the configured node or edge safety budget."
|
|
178
|
+
break
|
|
179
|
+
if mode == "auto" and depth > 1 and not layer_added_relevant_edge:
|
|
180
|
+
state.stopped_reason = "Stopped because the next hop did not add relevant graph facts."
|
|
181
|
+
break
|
|
182
|
+
frontier = next_frontier
|
|
183
|
+
|
|
184
|
+
if not state.stopped_reason:
|
|
185
|
+
state.stopped_reason = f"Reached traversal depth {target_depth}."
|
|
186
|
+
_expand_subject_findings(
|
|
187
|
+
store,
|
|
188
|
+
list(state.nodes),
|
|
189
|
+
state,
|
|
190
|
+
processed_subjects=processed_subjects,
|
|
191
|
+
scope_key=scope_key,
|
|
192
|
+
max_nodes=max_nodes,
|
|
193
|
+
max_edges=max_edges,
|
|
194
|
+
anchored_refs=anchored_refs,
|
|
195
|
+
)
|
|
196
|
+
_review_dependencies(store, state, scope_key=scope_key, max_depth=max_depth, max_nodes=max_nodes, max_edges=max_edges)
|
|
197
|
+
return _render_recall(query, state, token_budget=token_budget)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _review_dependencies(
|
|
201
|
+
store: GraphStoreAdapter,
|
|
202
|
+
state: _RecallState,
|
|
203
|
+
*,
|
|
204
|
+
scope_key: str | None,
|
|
205
|
+
max_depth: int,
|
|
206
|
+
max_nodes: int,
|
|
207
|
+
max_edges: int,
|
|
208
|
+
) -> None:
|
|
209
|
+
"""Flag conclusions that rely on changed premises using focused, bounded graph links."""
|
|
210
|
+
roots = list(
|
|
211
|
+
dict.fromkeys(
|
|
212
|
+
_NodeRef("Trace", trace_id)
|
|
213
|
+
for ref, record in state.nodes.items()
|
|
214
|
+
if (trace_id := ref.node_id if ref.label == "Trace" else record.node.properties.get("trace_id")) is not None and isinstance(trace_id, str)
|
|
215
|
+
)
|
|
216
|
+
)
|
|
217
|
+
notices: list[str] = []
|
|
218
|
+
unknown_roots: list[str] = []
|
|
219
|
+
for root in roots:
|
|
220
|
+
inspected: set[str] = set()
|
|
221
|
+
read_nodes: set[str] = set()
|
|
222
|
+
used_edges = 0
|
|
223
|
+
unknown = False
|
|
224
|
+
has_dependencies = False
|
|
225
|
+
changes: set[tuple[str, str, str]] = set()
|
|
226
|
+
|
|
227
|
+
def visit(
|
|
228
|
+
trace_id: str,
|
|
229
|
+
path: set[str],
|
|
230
|
+
depth: int,
|
|
231
|
+
inspected: set[str] = inspected,
|
|
232
|
+
read_nodes: set[str] = read_nodes,
|
|
233
|
+
changes: set[tuple[str, str, str]] = changes,
|
|
234
|
+
) -> None:
|
|
235
|
+
nonlocal has_dependencies, used_edges, unknown
|
|
236
|
+
if trace_id in path:
|
|
237
|
+
unknown = True
|
|
238
|
+
return
|
|
239
|
+
if trace_id in inspected:
|
|
240
|
+
return
|
|
241
|
+
if len(read_nodes) >= max_nodes and trace_id not in read_nodes:
|
|
242
|
+
unknown = True
|
|
243
|
+
return
|
|
244
|
+
trace = store.get_node("Trace", trace_id, scope_key=scope_key)
|
|
245
|
+
if trace is None:
|
|
246
|
+
unknown = True
|
|
247
|
+
return
|
|
248
|
+
inspected.add(trace_id)
|
|
249
|
+
read_nodes.add(trace_id)
|
|
250
|
+
if depth >= max_depth:
|
|
251
|
+
if store.list_trace_edges(trace_id, "BASED_ON", scope_key=scope_key, limit=1).items:
|
|
252
|
+
has_dependencies = True
|
|
253
|
+
unknown = True
|
|
254
|
+
return
|
|
255
|
+
remaining = max_edges - used_edges
|
|
256
|
+
if remaining <= 0:
|
|
257
|
+
unknown = True
|
|
258
|
+
return
|
|
259
|
+
dependencies = store.list_trace_edges(trace_id, "BASED_ON", scope_key=scope_key, limit=remaining)
|
|
260
|
+
has_dependencies |= bool(dependencies.items) or dependencies.truncated
|
|
261
|
+
unknown |= dependencies.truncated
|
|
262
|
+
used_edges += len(dependencies.items)
|
|
263
|
+
next_path = path | {trace_id}
|
|
264
|
+
for dependency in dependencies.items:
|
|
265
|
+
premise_id = dependency.target_id
|
|
266
|
+
if len(read_nodes) >= max_nodes and premise_id not in read_nodes:
|
|
267
|
+
unknown = True
|
|
268
|
+
continue
|
|
269
|
+
premise = store.get_node("Trace", premise_id, scope_key=scope_key)
|
|
270
|
+
if premise is None:
|
|
271
|
+
unknown = True
|
|
272
|
+
continue
|
|
273
|
+
read_nodes.add(premise_id)
|
|
274
|
+
_add_node(state, trace, distance=0, score=125, max_nodes=max_nodes)
|
|
275
|
+
_add_node(state, premise, distance=1, score=125, max_nodes=max_nodes)
|
|
276
|
+
_add_edge(state, dependency, distance=1, score=125, max_edges=max_edges)
|
|
277
|
+
if _NodeRef("Trace", premise_id) not in state.nodes:
|
|
278
|
+
unknown = True
|
|
279
|
+
if ("Trace", trace_id, "BASED_ON", "Trace", premise_id) not in state.edges:
|
|
280
|
+
unknown = True
|
|
281
|
+
if not (premise.properties.get("evidence") or premise.properties.get("evidence_refs") or premise.properties.get("depends_on")) or (
|
|
282
|
+
premise.properties.get("finding_type") == "state" and finding_observed_timestamp(premise) is None
|
|
283
|
+
):
|
|
284
|
+
unknown = True
|
|
285
|
+
for relationship in ("SUPERSEDES", "RESOLVES"):
|
|
286
|
+
remaining = max_edges - used_edges
|
|
287
|
+
if remaining <= 0:
|
|
288
|
+
unknown = True
|
|
289
|
+
break
|
|
290
|
+
updates = store.list_trace_edges(premise_id, relationship, incoming=True, scope_key=scope_key, limit=remaining)
|
|
291
|
+
unknown |= updates.truncated
|
|
292
|
+
used_edges += len(updates.items)
|
|
293
|
+
for update in updates.items:
|
|
294
|
+
if len(read_nodes) >= max_nodes and update.source_id not in read_nodes:
|
|
295
|
+
unknown = True
|
|
296
|
+
continue
|
|
297
|
+
successor = store.get_node("Trace", update.source_id, scope_key=scope_key)
|
|
298
|
+
if successor is None:
|
|
299
|
+
unknown = True
|
|
300
|
+
continue
|
|
301
|
+
read_nodes.add(successor.id)
|
|
302
|
+
reviewed_count = 0
|
|
303
|
+
if relationship == "RESOLVES":
|
|
304
|
+
remaining = max_edges - used_edges
|
|
305
|
+
if remaining <= 0:
|
|
306
|
+
unknown = True
|
|
307
|
+
continue
|
|
308
|
+
reviewed = store.list_trace_edges(successor.id, "RESOLVES", scope_key=scope_key, limit=remaining)
|
|
309
|
+
used_edges += len(reviewed.items)
|
|
310
|
+
for reviewed_edge in reviewed.items:
|
|
311
|
+
if len(read_nodes) >= max_nodes and reviewed_edge.target_id not in read_nodes:
|
|
312
|
+
unknown = True
|
|
313
|
+
break
|
|
314
|
+
target = store.get_node("Trace", reviewed_edge.target_id, scope_key=scope_key)
|
|
315
|
+
if target is None:
|
|
316
|
+
unknown = True
|
|
317
|
+
continue
|
|
318
|
+
read_nodes.add(target.id)
|
|
319
|
+
reviewed_count += target.properties.get("subject") == successor.properties.get("subject")
|
|
320
|
+
unknown |= reviewed.truncated and reviewed_count < 2
|
|
321
|
+
if valid_finding_link(successor, premise, relationship, reviewed_count=reviewed_count):
|
|
322
|
+
changes.add((premise_id, successor.id, relationship))
|
|
323
|
+
if store.list_trace_edges(successor.id, "BASED_ON", scope_key=scope_key, limit=1).items:
|
|
324
|
+
unknown = True
|
|
325
|
+
_add_node(state, successor, distance=1, score=125, max_nodes=max_nodes)
|
|
326
|
+
_add_edge(state, update, distance=1, score=125, max_edges=max_edges)
|
|
327
|
+
if _NodeRef("Trace", successor.id) not in state.nodes:
|
|
328
|
+
unknown = True
|
|
329
|
+
if ("Trace", successor.id, relationship, "Trace", premise_id) not in state.edges:
|
|
330
|
+
unknown = True
|
|
331
|
+
visit(premise_id, next_path, depth + 1)
|
|
332
|
+
|
|
333
|
+
visit(root.node_id, set(), 0)
|
|
334
|
+
if changes:
|
|
335
|
+
details = ", ".join(f"{old} updated by {new} via {relationship}" for old, new, relationship in sorted(changes))
|
|
336
|
+
notices.append(f"Trace {root.node_id} needs recheck: supporting premise changed ({details}). The conclusion remains recorded.")
|
|
337
|
+
if has_dependencies and unknown:
|
|
338
|
+
state.dependency_unknown = True
|
|
339
|
+
unknown_roots.append(root.node_id)
|
|
340
|
+
state.dependency_notices = notices
|
|
341
|
+
state.dependency_unknown_roots = unknown_roots
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _expand_subject_findings(
|
|
345
|
+
store: GraphStoreAdapter,
|
|
346
|
+
seeds: Sequence[_NodeRef],
|
|
347
|
+
state: _RecallState,
|
|
348
|
+
*,
|
|
349
|
+
processed_subjects: set[str],
|
|
350
|
+
scope_key: str | None,
|
|
351
|
+
max_nodes: int,
|
|
352
|
+
max_edges: int,
|
|
353
|
+
anchored_refs: set[_NodeRef | None],
|
|
354
|
+
) -> None:
|
|
355
|
+
"""Bring findings for a retrieved subject together before normal traversal uses budgets."""
|
|
356
|
+
subjects: set[str] = set()
|
|
357
|
+
for seed in seeds:
|
|
358
|
+
node = store.get_node(seed.label, seed.node_id, scope_key=scope_key)
|
|
359
|
+
if node is None:
|
|
360
|
+
continue
|
|
361
|
+
if seed.label == "Subject":
|
|
362
|
+
subjects.add(seed.node_id)
|
|
363
|
+
continue
|
|
364
|
+
trace = node if seed.label == "Trace" else None
|
|
365
|
+
trace_id = node.properties.get("trace_id")
|
|
366
|
+
if trace is None and isinstance(trace_id, str):
|
|
367
|
+
trace = store.get_node("Trace", trace_id, scope_key=scope_key)
|
|
368
|
+
if trace is None and seed.label in {"Artifact", "Evidence", "EvidenceSource"}:
|
|
369
|
+
neighbors = store.get_neighbors(seed.label, seed.node_id, scope_key=scope_key, max_nodes=max_nodes, max_edges=max_edges)
|
|
370
|
+
if neighbors is not None:
|
|
371
|
+
state.related_incomplete |= neighbors.truncated_nodes or neighbors.truncated_edges
|
|
372
|
+
for edge in neighbors.edges:
|
|
373
|
+
for ref in _edge_neighbors(edge, seed):
|
|
374
|
+
connected = store.get_node(ref.label, ref.node_id, scope_key=scope_key)
|
|
375
|
+
if connected is None:
|
|
376
|
+
continue
|
|
377
|
+
connected_trace_id = connected.id if connected.label == "Trace" else connected.properties.get("trace_id")
|
|
378
|
+
if isinstance(connected_trace_id, str):
|
|
379
|
+
connected_trace = store.get_node("Trace", connected_trace_id, scope_key=scope_key)
|
|
380
|
+
connected_subject = connected_trace.properties.get("subject") if connected_trace is not None else None
|
|
381
|
+
if isinstance(connected_subject, str):
|
|
382
|
+
subjects.add(f"subject-{hashlib.sha256(connected_subject.encode('utf-8')).hexdigest()}")
|
|
383
|
+
subject = trace.properties.get("subject") if trace is not None else None
|
|
384
|
+
if isinstance(subject, str):
|
|
385
|
+
subjects.add(f"subject-{hashlib.sha256(subject.encode('utf-8')).hexdigest()}")
|
|
386
|
+
|
|
387
|
+
for subject_id in sorted(subjects):
|
|
388
|
+
if subject_id in processed_subjects:
|
|
389
|
+
continue
|
|
390
|
+
processed_subjects.add(subject_id)
|
|
391
|
+
subject = store.get_node("Subject", subject_id, scope_key=scope_key)
|
|
392
|
+
if subject is None:
|
|
393
|
+
continue
|
|
394
|
+
state.related_findings = True
|
|
395
|
+
anchored_traces = [
|
|
396
|
+
ref
|
|
397
|
+
for ref in seeds
|
|
398
|
+
if ref in anchored_refs
|
|
399
|
+
and ref.label == "Trace"
|
|
400
|
+
and (anchor := store.get_node("Trace", ref.node_id, scope_key=scope_key)) is not None
|
|
401
|
+
and anchor.properties.get("subject") == subject.properties.get("value")
|
|
402
|
+
]
|
|
403
|
+
available = max_nodes - len(state.nodes)
|
|
404
|
+
reserve_anchor = bool(anchored_traces and available > 1)
|
|
405
|
+
selected = store.list_subject_trace_ids(subject_id, scope_key=scope_key, limit=max(available - int(reserve_anchor), 0))
|
|
406
|
+
state.related_incomplete |= selected.truncated
|
|
407
|
+
trace_ids = selected.items
|
|
408
|
+
if reserve_anchor:
|
|
409
|
+
trace_ids.extend(ref.node_id for ref in anchored_traces if ref.node_id not in trace_ids)
|
|
410
|
+
for trace_id in trace_ids:
|
|
411
|
+
trace = store.get_node("Trace", trace_id, scope_key=scope_key)
|
|
412
|
+
if trace is None:
|
|
413
|
+
state.related_incomplete = True
|
|
414
|
+
continue
|
|
415
|
+
_add_node(state, trace, distance=1, score=110, max_nodes=max_nodes)
|
|
416
|
+
if _NodeRef("Trace", trace_id) not in state.nodes:
|
|
417
|
+
state.related_incomplete = True
|
|
418
|
+
if len(state.nodes) < max_nodes:
|
|
419
|
+
_add_node(state, subject, distance=0, score=120, max_nodes=max_nodes)
|
|
420
|
+
for trace_id in trace_ids:
|
|
421
|
+
trace_links = store.get_neighbors("Trace", trace_id, scope_key=scope_key, max_nodes=max_nodes, max_edges=max(max_edges, 100))
|
|
422
|
+
if trace_links is None:
|
|
423
|
+
continue
|
|
424
|
+
if trace_links.truncated_edges:
|
|
425
|
+
state.related_incomplete = True
|
|
426
|
+
for link in trace_links.edges:
|
|
427
|
+
if link.relationship in {"SUPERSEDES", "RESOLVES"} and link.source_id == trace_id:
|
|
428
|
+
if _NodeRef("Trace", link.target_id) in state.nodes:
|
|
429
|
+
_add_edge(state, link, distance=1, score=110, max_edges=max_edges)
|
|
430
|
+
else:
|
|
431
|
+
state.related_incomplete = True
|
|
432
|
+
elif link.relationship == "ABOUT" and _NodeRef("Subject", subject_id) in state.nodes:
|
|
433
|
+
_add_edge(state, link, distance=1, score=110, max_edges=max_edges)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _find_seed_nodes(
|
|
437
|
+
store: GraphStoreAdapter,
|
|
438
|
+
query: str,
|
|
439
|
+
terms: set[str],
|
|
440
|
+
*,
|
|
441
|
+
anchors: Sequence[str],
|
|
442
|
+
scope_key: str | None,
|
|
443
|
+
limit: int,
|
|
444
|
+
state: _RecallState,
|
|
445
|
+
) -> list[_NodeRef]:
|
|
446
|
+
seeds: list[_NodeRef] = []
|
|
447
|
+
seen: set[_NodeRef] = set()
|
|
448
|
+
for anchor in anchors:
|
|
449
|
+
ref = _seed_from_anchor(anchor)
|
|
450
|
+
if ref is None or ref in seen:
|
|
451
|
+
continue
|
|
452
|
+
if store.get_node(ref.label, ref.node_id, scope_key=scope_key) is None:
|
|
453
|
+
continue
|
|
454
|
+
seeds.append(ref)
|
|
455
|
+
seen.add(ref)
|
|
456
|
+
if len(seeds) >= limit:
|
|
457
|
+
return seeds
|
|
458
|
+
for search_query in _search_queries(query, terms, anchors):
|
|
459
|
+
result = store.search(search_query, scope_key=scope_key, limit=limit)
|
|
460
|
+
state.search_truncated = state.search_truncated or result.truncated
|
|
461
|
+
for item in result.items:
|
|
462
|
+
try:
|
|
463
|
+
parsed = parse_graph_path(item.path)
|
|
464
|
+
except GraphMemoryPathError:
|
|
465
|
+
continue
|
|
466
|
+
if parsed.kind != "node" or parsed.label is None or parsed.node_id is None:
|
|
467
|
+
continue
|
|
468
|
+
ref = _NodeRef(parsed.label, parsed.node_id)
|
|
469
|
+
if ref not in seen:
|
|
470
|
+
seeds.append(ref)
|
|
471
|
+
seen.add(ref)
|
|
472
|
+
if len(seeds) >= limit:
|
|
473
|
+
return seeds
|
|
474
|
+
return seeds
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _seed_from_anchor(anchor: str) -> _NodeRef | None:
|
|
478
|
+
try:
|
|
479
|
+
parsed = parse_graph_path(anchor)
|
|
480
|
+
except GraphMemoryPathError:
|
|
481
|
+
return None
|
|
482
|
+
if parsed.kind != "node" or parsed.label is None or parsed.node_id is None:
|
|
483
|
+
return None
|
|
484
|
+
return _NodeRef(parsed.label, parsed.node_id)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _search_queries(query: str, terms: set[str], anchors: Sequence[str]) -> list[str]:
|
|
488
|
+
queries = list(anchors)
|
|
489
|
+
queries.append(query)
|
|
490
|
+
queries.extend(sorted(terms, key=lambda item: (-len(item), item)))
|
|
491
|
+
result: list[str] = []
|
|
492
|
+
seen: set[str] = set()
|
|
493
|
+
for item in queries:
|
|
494
|
+
normalized = item.strip()
|
|
495
|
+
if normalized and normalized.casefold() not in seen:
|
|
496
|
+
result.append(normalized)
|
|
497
|
+
seen.add(normalized.casefold())
|
|
498
|
+
return result
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _add_node(state: _RecallState, node: GraphNode, *, distance: int, score: int, max_nodes: int) -> None:
|
|
502
|
+
ref = _NodeRef(node.label, node.id)
|
|
503
|
+
existing = state.nodes.get(ref)
|
|
504
|
+
if existing is None:
|
|
505
|
+
if len(state.nodes) >= max_nodes:
|
|
506
|
+
state.truncated_nodes = True
|
|
507
|
+
return
|
|
508
|
+
state.nodes[ref] = _NodeRecord(node=node, distance=distance, score=score)
|
|
509
|
+
return
|
|
510
|
+
existing.distance = min(existing.distance, distance)
|
|
511
|
+
existing.score = max(existing.score, score)
|
|
512
|
+
if existing.node.properties == {} and node.properties:
|
|
513
|
+
existing.node = node
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _add_edge(state: _RecallState, edge: GraphEdge, *, distance: int, score: int, max_edges: int) -> None:
|
|
517
|
+
key = (edge.source_label, edge.source_id, edge.relationship, edge.target_label, edge.target_id)
|
|
518
|
+
existing = state.edges.get(key)
|
|
519
|
+
if existing is None:
|
|
520
|
+
if len(state.edges) >= max_edges:
|
|
521
|
+
state.truncated_edges = True
|
|
522
|
+
return
|
|
523
|
+
state.edges[key] = _EdgeRecord(edge=edge, distance=distance, score=score)
|
|
524
|
+
return
|
|
525
|
+
existing.distance = min(existing.distance, distance)
|
|
526
|
+
existing.score = max(existing.score, score)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _edge_neighbors(edge: GraphEdge, source_ref: _NodeRef) -> list[_NodeRef]:
|
|
530
|
+
source = _NodeRef(edge.source_label, edge.source_id)
|
|
531
|
+
target = _NodeRef(edge.target_label, edge.target_id)
|
|
532
|
+
if source == source_ref:
|
|
533
|
+
return [target]
|
|
534
|
+
if target == source_ref:
|
|
535
|
+
return [source]
|
|
536
|
+
return [source, target]
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _score_node(node: GraphNode, terms: set[str], query: str) -> int:
|
|
540
|
+
return _score_text(" ".join([node.label, node.id, _properties_text(node.properties)]), terms, query)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _score_edge(edge: GraphEdge, terms: set[str], query: str) -> int:
|
|
544
|
+
return _score_text(
|
|
545
|
+
" ".join(
|
|
546
|
+
[
|
|
547
|
+
edge.source_label,
|
|
548
|
+
edge.source_id,
|
|
549
|
+
edge.relationship,
|
|
550
|
+
edge.target_label,
|
|
551
|
+
edge.target_id,
|
|
552
|
+
_properties_text(edge.properties),
|
|
553
|
+
]
|
|
554
|
+
),
|
|
555
|
+
terms,
|
|
556
|
+
query,
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _score_text(text: str, terms: set[str], query: str) -> int:
|
|
561
|
+
haystack = text.casefold()
|
|
562
|
+
score = 0
|
|
563
|
+
if query.casefold() in haystack:
|
|
564
|
+
score += 5
|
|
565
|
+
for term in terms:
|
|
566
|
+
if term in haystack:
|
|
567
|
+
score += 1
|
|
568
|
+
return score
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _properties_text(properties: dict[str, JsonValue]) -> str:
|
|
572
|
+
public = {key: value for key, value in properties.items() if key not in {"scope_key", "created_at", "updated_at", "search_text"}}
|
|
573
|
+
return json.dumps(public, sort_keys=True)
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _query_terms(query: str) -> set[str]:
|
|
577
|
+
terms: set[str] = set()
|
|
578
|
+
for raw in _TERM_RE.findall(query.casefold()):
|
|
579
|
+
if len(raw) < 2:
|
|
580
|
+
continue
|
|
581
|
+
terms.add(raw)
|
|
582
|
+
terms.add(raw.replace("-", "_"))
|
|
583
|
+
if raw.endswith("s") and len(raw) > 3:
|
|
584
|
+
terms.add(raw[:-1])
|
|
585
|
+
return terms
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _render_recall(query: str, state: _RecallState, *, token_budget: int) -> str:
|
|
589
|
+
lines = [f"# Graph Memory Recall: {query}", ""]
|
|
590
|
+
if state.dependency_notices:
|
|
591
|
+
lines.extend(["## Dependency review", *state.dependency_notices, ""])
|
|
592
|
+
if state.dependency_unknown_roots:
|
|
593
|
+
lines.extend(
|
|
594
|
+
["## Dependency coverage", *(f"Trace {trace_id}: dependency status unknown." for trace_id in state.dependency_unknown_roots), ""]
|
|
595
|
+
)
|
|
596
|
+
if not state.nodes and not state.edges:
|
|
597
|
+
lines.append("No matching graph memory found.")
|
|
598
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
599
|
+
|
|
600
|
+
if state.related_findings:
|
|
601
|
+
for ref in state.omitted_anchors or []:
|
|
602
|
+
path = _prefixed(node_path(ref.label, ref.node_id))
|
|
603
|
+
lines.append(f"Anchor omitted from bounded history: `{path}`. Read it directly for earlier context.")
|
|
604
|
+
if state.omitted_anchors:
|
|
605
|
+
lines.append("")
|
|
606
|
+
lines.extend(_finding_history(state))
|
|
607
|
+
|
|
608
|
+
covered_traces = {
|
|
609
|
+
ref.node_id
|
|
610
|
+
for ref, record in state.nodes.items()
|
|
611
|
+
if ref.label == "Trace" and state.related_findings and isinstance(record.node.properties.get("subject"), str)
|
|
612
|
+
}
|
|
613
|
+
rendered_traces = {
|
|
614
|
+
ref.node_id
|
|
615
|
+
for ref, record in state.nodes.items()
|
|
616
|
+
if ref.label == "Trace"
|
|
617
|
+
and record.node.properties.get("kind") == "reasoning_trace"
|
|
618
|
+
and all(field in record.node.properties for field in _TRACE_COMPONENTS.values())
|
|
619
|
+
}
|
|
620
|
+
hidden_components = _redundant_trace_components(state, rendered_traces)
|
|
621
|
+
visible_nodes = [
|
|
622
|
+
record for ref, record in state.nodes.items() if ref not in hidden_components and not (ref.label == "Trace" and ref.node_id in covered_traces)
|
|
623
|
+
]
|
|
624
|
+
visible_edges = [record for record in state.edges.values() if not _redundant_trace_link(record.edge, state, hidden_components)]
|
|
625
|
+
|
|
626
|
+
if visible_nodes:
|
|
627
|
+
lines.append("## Nodes")
|
|
628
|
+
for record in _sorted_nodes(visible_nodes):
|
|
629
|
+
path = _prefixed(node_path(record.node.label, record.node.id))
|
|
630
|
+
suffix = _property_suffix(record.node.properties)
|
|
631
|
+
if record.node.label == "Trace" and _has_recorded_reasoning_chain(state, record.node.id):
|
|
632
|
+
suffix += "; Situation -LED_TO-> Rationale -JUSTIFIED-> Action -PRODUCED-> Outcome"
|
|
633
|
+
lines.append(f"- [{record.node.label}: {record.node.id}]({path}){suffix}")
|
|
634
|
+
lines.append("")
|
|
635
|
+
|
|
636
|
+
if visible_edges:
|
|
637
|
+
lines.append("## Relationships")
|
|
638
|
+
for record in _sorted_edges(visible_edges):
|
|
639
|
+
edge = record.edge
|
|
640
|
+
source_path = _prefixed(node_path(edge.source_label, edge.source_id))
|
|
641
|
+
target_path = _prefixed(node_path(edge.target_label, edge.target_id))
|
|
642
|
+
lines.append(
|
|
643
|
+
f"- [{edge.source_label}: {edge.source_id}]({source_path}) -[{edge.relationship}]-> "
|
|
644
|
+
f"[{edge.target_label}: {edge.target_id}]({target_path}){_generated_edge_detail(edge, state)}"
|
|
645
|
+
)
|
|
646
|
+
lines.append("")
|
|
647
|
+
|
|
648
|
+
if state.stopped_reason:
|
|
649
|
+
lines.append(f"Traversal note: {state.stopped_reason}")
|
|
650
|
+
notes = _truncation_notes(state)
|
|
651
|
+
if notes:
|
|
652
|
+
lines.extend(notes)
|
|
653
|
+
if state.search_truncated:
|
|
654
|
+
lines.append("Search results were truncated before traversal.")
|
|
655
|
+
incomplete = state.related_findings and (state.related_incomplete or state.truncated_nodes or state.truncated_edges)
|
|
656
|
+
prefix = ""
|
|
657
|
+
if state.dependency_unknown:
|
|
658
|
+
prefix += "Dependency status unknown; inspect dependencies.\n"
|
|
659
|
+
if state.dependency_notices:
|
|
660
|
+
prefix += "Some conclusions need recheck.\n"
|
|
661
|
+
available_tokens = max(1, token_budget - (len(prefix) + 3) // 4)
|
|
662
|
+
if state.related_findings and not incomplete and not state.search_truncated:
|
|
663
|
+
source_ids = {
|
|
664
|
+
ref["source_id"]
|
|
665
|
+
for record in state.nodes.values()
|
|
666
|
+
if record.node.label == "Trace"
|
|
667
|
+
if isinstance(record.node.properties.get("evidence_refs"), list)
|
|
668
|
+
for ref in record.node.properties.get("evidence_refs", [])
|
|
669
|
+
if isinstance(ref, dict) and isinstance(ref.get("source_id"), str)
|
|
670
|
+
}
|
|
671
|
+
count_line = f"Distinct cited source IDs in returned findings: {len(source_ids)}; independence not established."
|
|
672
|
+
if source_ids and sum(len(line) + 1 for line in [*lines, count_line]) <= max(available_tokens * 4, 80):
|
|
673
|
+
lines.append(count_line)
|
|
674
|
+
return prefix + _fit_token_budget(
|
|
675
|
+
lines,
|
|
676
|
+
available_tokens,
|
|
677
|
+
related_findings=state.related_findings,
|
|
678
|
+
related_incomplete=incomplete,
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _finding_history(state: _RecallState) -> list[str]:
|
|
683
|
+
traces = {
|
|
684
|
+
ref.node_id: record.node
|
|
685
|
+
for ref, record in state.nodes.items()
|
|
686
|
+
if ref.label == "Trace" and isinstance(record.node.properties.get("subject"), str)
|
|
687
|
+
}
|
|
688
|
+
successors: dict[str, list[str]] = {}
|
|
689
|
+
resolution_targets: dict[str, set[str]] = {}
|
|
690
|
+
for record in state.edges.values():
|
|
691
|
+
edge = record.edge
|
|
692
|
+
if edge.source_label != "Trace" or edge.target_label != "Trace":
|
|
693
|
+
continue
|
|
694
|
+
newer, older = traces.get(edge.source_id), traces.get(edge.target_id)
|
|
695
|
+
if newer is None or older is None:
|
|
696
|
+
continue
|
|
697
|
+
if edge.relationship == "SUPERSEDES" and valid_finding_link(newer, older, "SUPERSEDES"):
|
|
698
|
+
successors.setdefault(edge.target_id, []).append(edge.source_id)
|
|
699
|
+
elif edge.relationship == "RESOLVES" and valid_finding_link(newer, older, "RESOLVES", reviewed_count=2):
|
|
700
|
+
resolution_targets.setdefault(edge.source_id, set()).add(edge.target_id)
|
|
701
|
+
reviewed: dict[str, list[str]] = {}
|
|
702
|
+
for resolution_id, targets in resolution_targets.items():
|
|
703
|
+
if len(targets) >= 2:
|
|
704
|
+
for target_id in targets:
|
|
705
|
+
reviewed.setdefault(target_id, []).append(resolution_id)
|
|
706
|
+
ordered = sorted(
|
|
707
|
+
traces.values(),
|
|
708
|
+
key=lambda node: (
|
|
709
|
+
node.id in successors or node.id in reviewed,
|
|
710
|
+
finding_observed_timestamp(node) is None,
|
|
711
|
+
-(finding_observed_timestamp(node) or 0),
|
|
712
|
+
node.id,
|
|
713
|
+
),
|
|
714
|
+
)
|
|
715
|
+
lines = ["## Finding history"]
|
|
716
|
+
for trace in ordered:
|
|
717
|
+
path = _prefixed(node_path("Trace", trace.id))
|
|
718
|
+
later = successors.get(trace.id, [])
|
|
719
|
+
resolutions = reviewed.get(trace.id, [])
|
|
720
|
+
statuses = []
|
|
721
|
+
if later:
|
|
722
|
+
statuses.append(
|
|
723
|
+
"superseded by " + ", ".join(f"[Trace: {node_id}]({_prefixed(node_path('Trace', node_id))})" for node_id in sorted(later))
|
|
724
|
+
)
|
|
725
|
+
if resolutions:
|
|
726
|
+
statuses.append(
|
|
727
|
+
"reviewed in resolution "
|
|
728
|
+
+ ", ".join(f"[Trace: {node_id}]({_prefixed(node_path('Trace', node_id))})" for node_id in sorted(resolutions))
|
|
729
|
+
)
|
|
730
|
+
status = "; ".join(statuses) if statuses else "not superseded (requires comparison)"
|
|
731
|
+
if not statuses and state.related_incomplete:
|
|
732
|
+
status = "status unknown in partial history (requires comparison)"
|
|
733
|
+
observed = trace.properties.get("observed_at", "unknown")
|
|
734
|
+
details = _property_suffix({key: value for key, value in trace.properties.items() if key not in {"observed_at", "evidence_refs"}})
|
|
735
|
+
refs = trace.properties.get("evidence_refs")
|
|
736
|
+
citations = f"; cited sources: {json.dumps(refs, sort_keys=True)}" if refs else ""
|
|
737
|
+
lines.append(f"- [Trace: {trace.id}]({path}) — {status}; observed_at: {observed}{details}{citations}")
|
|
738
|
+
return [*lines, ""]
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def _sorted_nodes(records: Iterable[_NodeRecord]) -> list[_NodeRecord]:
|
|
742
|
+
return sorted(records, key=lambda item: (-item.score, item.distance, item.node.label, item.node.id))
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def _sorted_edges(records: Iterable[_EdgeRecord]) -> list[_EdgeRecord]:
|
|
746
|
+
return sorted(
|
|
747
|
+
records,
|
|
748
|
+
key=lambda item: (
|
|
749
|
+
-item.score,
|
|
750
|
+
item.distance,
|
|
751
|
+
item.edge.relationship,
|
|
752
|
+
item.edge.source_label,
|
|
753
|
+
item.edge.source_id,
|
|
754
|
+
item.edge.target_label,
|
|
755
|
+
item.edge.target_id,
|
|
756
|
+
),
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def _redundant_trace_components(state: _RecallState, rendered_traces: set[str]) -> set[_NodeRef]:
|
|
761
|
+
hidden: set[_NodeRef] = set()
|
|
762
|
+
for ref, record in state.nodes.items():
|
|
763
|
+
field = _TRACE_COMPONENTS.get(ref.label)
|
|
764
|
+
owner_id = record.node.properties.get("trace_id")
|
|
765
|
+
if field is None or not isinstance(owner_id, str) or owner_id not in rendered_traces:
|
|
766
|
+
continue
|
|
767
|
+
owner = state.nodes[_NodeRef("Trace", owner_id)].node
|
|
768
|
+
if ref.node_id != f"{owner_id}-{field}" or ref in (state.explicit_anchors or set()):
|
|
769
|
+
continue
|
|
770
|
+
if record.node.properties.get("text") != owner.properties.get(field):
|
|
771
|
+
continue
|
|
772
|
+
if all(
|
|
773
|
+
key in {"text", "trace_id", "created_at", "updated_at"} or owner.properties.get(key) == value
|
|
774
|
+
for key, value in record.node.properties.items()
|
|
775
|
+
):
|
|
776
|
+
hidden.add(ref)
|
|
777
|
+
for record in state.edges.values():
|
|
778
|
+
edge = record.edge
|
|
779
|
+
owner_id = _generated_trace_link_owner(edge)
|
|
780
|
+
if owner_id is None or owner_id not in rendered_traces:
|
|
781
|
+
continue
|
|
782
|
+
owner = state.nodes[_NodeRef("Trace", owner_id)].node
|
|
783
|
+
if not _generated_edge_metadata_matches(edge, owner):
|
|
784
|
+
hidden.discard(_NodeRef(edge.source_label, edge.source_id))
|
|
785
|
+
hidden.discard(_NodeRef(edge.target_label, edge.target_id))
|
|
786
|
+
return hidden
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def _generated_trace_link_owner(edge: GraphEdge) -> str | None:
|
|
790
|
+
if edge.relationship not in _GENERATED_TRACE_LINKS:
|
|
791
|
+
return None
|
|
792
|
+
owner_id = edge.properties.get("trace_id")
|
|
793
|
+
if not isinstance(owner_id, str):
|
|
794
|
+
return None
|
|
795
|
+
expected = {
|
|
796
|
+
"HAS_SITUATION": ("Trace", owner_id, "Situation", f"{owner_id}-situation"),
|
|
797
|
+
"HAS_RATIONALE": ("Trace", owner_id, "Rationale", f"{owner_id}-rationale"),
|
|
798
|
+
"HAS_ACTION": ("Trace", owner_id, "Action", f"{owner_id}-action"),
|
|
799
|
+
"HAS_OUTCOME": ("Trace", owner_id, "Outcome", f"{owner_id}-outcome"),
|
|
800
|
+
"LED_TO": ("Situation", f"{owner_id}-situation", "Rationale", f"{owner_id}-rationale"),
|
|
801
|
+
"JUSTIFIED": ("Rationale", f"{owner_id}-rationale", "Action", f"{owner_id}-action"),
|
|
802
|
+
"PRODUCED": ("Action", f"{owner_id}-action", "Outcome", f"{owner_id}-outcome"),
|
|
803
|
+
}
|
|
804
|
+
return owner_id if (edge.source_label, edge.source_id, edge.target_label, edge.target_id) == expected[edge.relationship] else None
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def _has_recorded_reasoning_chain(state: _RecallState, trace_id: str) -> bool:
|
|
808
|
+
relationships = {
|
|
809
|
+
edge.relationship
|
|
810
|
+
for record in state.edges.values()
|
|
811
|
+
if (edge := record.edge).relationship in {"LED_TO", "JUSTIFIED", "PRODUCED"} and _generated_trace_link_owner(edge) == trace_id
|
|
812
|
+
}
|
|
813
|
+
return relationships == {"LED_TO", "JUSTIFIED", "PRODUCED"}
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def _redundant_trace_link(edge: GraphEdge, state: _RecallState, hidden_components: set[_NodeRef]) -> bool:
|
|
817
|
+
owner_id = _generated_trace_link_owner(edge)
|
|
818
|
+
if owner_id is None:
|
|
819
|
+
return False
|
|
820
|
+
owner_record = state.nodes.get(_NodeRef("Trace", owner_id))
|
|
821
|
+
if owner_record is None:
|
|
822
|
+
return False
|
|
823
|
+
if not _generated_edge_metadata_matches(edge, owner_record.node):
|
|
824
|
+
return False
|
|
825
|
+
endpoints = (_NodeRef(edge.source_label, edge.source_id), _NodeRef(edge.target_label, edge.target_id))
|
|
826
|
+
return all(ref.label == "Trace" or ref in hidden_components for ref in endpoints)
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def _generated_edge_metadata_matches(edge: GraphEdge, trace: GraphNode) -> bool:
|
|
830
|
+
return all(
|
|
831
|
+
key in {"created_at", "updated_at"} or (key == "trace_id" and value == trace.id) or trace.properties.get(key) == value
|
|
832
|
+
for key, value in edge.properties.items()
|
|
833
|
+
)
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def _generated_edge_detail(edge: GraphEdge, state: _RecallState) -> str:
|
|
837
|
+
owner_id = _generated_trace_link_owner(edge)
|
|
838
|
+
owner = state.nodes.get(_NodeRef("Trace", owner_id)) if owner_id is not None else None
|
|
839
|
+
if owner is None:
|
|
840
|
+
return ""
|
|
841
|
+
distinct = {
|
|
842
|
+
key: value
|
|
843
|
+
for key, value in edge.properties.items()
|
|
844
|
+
if key not in {"created_at", "updated_at", "trace_id"} and owner.node.properties.get(key) != value
|
|
845
|
+
}
|
|
846
|
+
return f" - `{json.dumps(distinct, sort_keys=True)}`" if distinct else ""
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def _property_suffix(properties: dict[str, JsonValue]) -> str:
|
|
850
|
+
public = {key: value for key, value in properties.items() if key not in {"scope_key", "created_at", "updated_at", "search_text"}}
|
|
851
|
+
if not public:
|
|
852
|
+
return ""
|
|
853
|
+
return f" - `{json.dumps(public, sort_keys=True)}`"
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
def _truncation_notes(state: _RecallState) -> list[str]:
|
|
857
|
+
targets = []
|
|
858
|
+
if state.truncated_nodes:
|
|
859
|
+
targets.append("nodes")
|
|
860
|
+
if state.truncated_edges:
|
|
861
|
+
targets.append("edges")
|
|
862
|
+
if not targets:
|
|
863
|
+
return []
|
|
864
|
+
return [f"Results truncated for {' and '.join(targets)}. Ask a narrower question or increase the recall budgets."]
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
def _fit_token_budget(lines: list[str], token_budget: int, *, related_findings: bool = False, related_incomplete: bool = False) -> str:
|
|
868
|
+
char_budget = max(token_budget * 4, 80)
|
|
869
|
+
if related_incomplete or (related_findings and sum(len(line) + 1 for line in lines) > char_budget):
|
|
870
|
+
lines.insert(0, "Related findings incomplete; no resolved/current answer. Fetch more context.")
|
|
871
|
+
output: list[str] = []
|
|
872
|
+
total = 0
|
|
873
|
+
for line in lines:
|
|
874
|
+
projected = total + len(line) + 1
|
|
875
|
+
if projected > char_budget:
|
|
876
|
+
output.append("")
|
|
877
|
+
output.append("Results truncated for token budget. Ask a narrower question or increase token_budget.")
|
|
878
|
+
break
|
|
879
|
+
output.append(line)
|
|
880
|
+
total = projected
|
|
881
|
+
return "\n".join(output).rstrip() + "\n"
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
def _prefixed(path: str) -> str:
|
|
885
|
+
return f"/graph{path}"
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
def _validate_recall_query(query: str) -> str:
|
|
889
|
+
if not isinstance(query, str):
|
|
890
|
+
msg = "query must be a string."
|
|
891
|
+
raise GraphMemoryValidationError(msg)
|
|
892
|
+
normalized = query.strip()
|
|
893
|
+
if not normalized or len(normalized) > _MAX_QUERY_LENGTH:
|
|
894
|
+
msg = f"query must be between 1 and {_MAX_QUERY_LENGTH} characters."
|
|
895
|
+
raise GraphMemoryValidationError(msg)
|
|
896
|
+
if "\x00" in normalized or any(ord(char) < 32 for char in normalized):
|
|
897
|
+
msg = "query must not contain NUL bytes or control characters."
|
|
898
|
+
raise GraphMemoryValidationError(msg)
|
|
899
|
+
return normalized
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
def _validate_anchor(anchor: str) -> str:
|
|
903
|
+
if not isinstance(anchor, str):
|
|
904
|
+
msg = "anchors must be strings."
|
|
905
|
+
raise GraphMemoryValidationError(msg)
|
|
906
|
+
normalized = anchor.strip()
|
|
907
|
+
if not normalized or len(normalized) > _MAX_QUERY_LENGTH:
|
|
908
|
+
msg = f"anchors must be between 1 and {_MAX_QUERY_LENGTH} characters."
|
|
909
|
+
raise GraphMemoryValidationError(msg)
|
|
910
|
+
if "\x00" in normalized or any(ord(char) < 32 for char in normalized):
|
|
911
|
+
msg = "anchors must not contain NUL bytes or control characters."
|
|
912
|
+
raise GraphMemoryValidationError(msg)
|
|
913
|
+
return normalized
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
def _validate_mode(mode: str) -> RecallMode:
|
|
917
|
+
if mode not in _VALID_MODES:
|
|
918
|
+
msg = "mode must be one of: auto, local, deep."
|
|
919
|
+
raise GraphMemoryValidationError(msg)
|
|
920
|
+
return cast("RecallMode", mode)
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
def _validate_budgets(token_budget: int, max_depth: int, max_nodes: int, max_edges: int) -> tuple[int, int, int, int]:
|
|
924
|
+
for name, value in {
|
|
925
|
+
"token_budget": token_budget,
|
|
926
|
+
"max_depth": max_depth,
|
|
927
|
+
"max_nodes": max_nodes,
|
|
928
|
+
"max_edges": max_edges,
|
|
929
|
+
}.items():
|
|
930
|
+
if not isinstance(value, int) or value < 1:
|
|
931
|
+
msg = f"{name} must be a positive integer."
|
|
932
|
+
raise GraphMemoryValidationError(msg)
|
|
933
|
+
if max_depth > _MAX_DEPTH:
|
|
934
|
+
msg = f"max_depth must be less than or equal to {_MAX_DEPTH}."
|
|
935
|
+
raise GraphMemoryValidationError(msg)
|
|
936
|
+
return token_budget, max_depth, max_nodes, max_edges
|