graph-engine-memory 0.6.1__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.
- graph_engine/__init__.py +1 -0
- graph_engine/__main__.py +1275 -0
- graph_engine/access.py +202 -0
- graph_engine/agent_memory.py +103 -0
- graph_engine/brain.py +731 -0
- graph_engine/brain_engine.py +559 -0
- graph_engine/communities.py +503 -0
- graph_engine/consolidation.py +191 -0
- graph_engine/context.py +203 -0
- graph_engine/decay.py +65 -0
- graph_engine/demo.py +165 -0
- graph_engine/dream.py +408 -0
- graph_engine/embedder.py +84 -0
- graph_engine/engine.py +58 -0
- graph_engine/entities.py +376 -0
- graph_engine/evals.py +1206 -0
- graph_engine/feedback.py +155 -0
- graph_engine/gaps.py +209 -0
- graph_engine/graph.py +133 -0
- graph_engine/hygiene.py +213 -0
- graph_engine/inference.py +251 -0
- graph_engine/intent.py +207 -0
- graph_engine/mcp/__init__.py +1 -0
- graph_engine/mcp/agent/instructions.md +34 -0
- graph_engine/mcp/format.py +66 -0
- graph_engine/mcp/server.py +407 -0
- graph_engine/merge.py +220 -0
- graph_engine/model.py +56 -0
- graph_engine/namespaces.py +74 -0
- graph_engine/recall.py +161 -0
- graph_engine/report.py +401 -0
- graph_engine/reranker.py +86 -0
- graph_engine/retrieval.py +296 -0
- graph_engine/review.py +95 -0
- graph_engine/runtime.py +83 -0
- graph_engine/server.py +239 -0
- graph_engine/similarity.py +42 -0
- graph_engine/store.py +63 -0
- graph_engine/suggester.py +63 -0
- graph_engine/temporal.py +216 -0
- graph_engine/tiers.py +135 -0
- graph_engine/web/app.js +578 -0
- graph_engine/web/index.html +169 -0
- graph_engine/web/report.html +39 -0
- graph_engine/web/report.js +50 -0
- graph_engine/web/review.html +104 -0
- graph_engine/web/review.js +211 -0
- graph_engine_memory-0.6.1.dist-info/METADATA +125 -0
- graph_engine_memory-0.6.1.dist-info/RECORD +53 -0
- graph_engine_memory-0.6.1.dist-info/WHEEL +5 -0
- graph_engine_memory-0.6.1.dist-info/entry_points.txt +3 -0
- graph_engine_memory-0.6.1.dist-info/licenses/LICENSE +21 -0
- graph_engine_memory-0.6.1.dist-info/top_level.txt +1 -0
graph_engine/server.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""FastAPI server on top of the Brain (private git repo as storage).
|
|
2
|
+
|
|
3
|
+
Env control:
|
|
4
|
+
IG_BRAIN_PATH — path to the brain clone (default: ~/graph-engine-brain)
|
|
5
|
+
IG_BRAIN_REMOTE — SSH/GitHub URL (only used for `git clone` on first
|
|
6
|
+
setup; no personal default, existing clones keep
|
|
7
|
+
their own origin)
|
|
8
|
+
IG_BRAIN_MODE — "git" (real repo) or "local" (FS only, for tests)
|
|
9
|
+
IDEAGRAPH_EMBEDDER — "st" (sentence-transformers) or "hash" (demo/tests)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
|
17
|
+
from fastapi.concurrency import run_in_threadpool
|
|
18
|
+
from fastapi.responses import FileResponse, JSONResponse
|
|
19
|
+
from pydantic import BaseModel
|
|
20
|
+
|
|
21
|
+
from . import runtime, access, namespaces
|
|
22
|
+
|
|
23
|
+
# Shipped UI assets live INSIDE the package so a pip install serves them too
|
|
24
|
+
# (repo-root docs/ would not exist in site-packages).
|
|
25
|
+
DOCS_DIR = Path(__file__).resolve().parent / "web"
|
|
26
|
+
|
|
27
|
+
app = FastAPI(title="GraphEngine")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@app.middleware("http")
|
|
31
|
+
async def access_control(request, call_next):
|
|
32
|
+
# Pin selection before any awaits: switching the CLI while an ingest runs
|
|
33
|
+
# must not send the old graph's payload to the new graph's subscribers.
|
|
34
|
+
graph_token = namespaces.request_graph.set(namespaces.selected())
|
|
35
|
+
identity_token = None
|
|
36
|
+
try:
|
|
37
|
+
if access.policy_path():
|
|
38
|
+
try:
|
|
39
|
+
identity = access.token_principal(request.headers.get("authorization", ""))
|
|
40
|
+
except PermissionError:
|
|
41
|
+
access.audit("http", "", "authenticate", False)
|
|
42
|
+
return JSONResponse({"error": "Authentication required"}, status_code=401)
|
|
43
|
+
identity_token = access.request_principal.set(identity)
|
|
44
|
+
return await call_next(request)
|
|
45
|
+
finally:
|
|
46
|
+
if identity_token is not None:
|
|
47
|
+
access.request_principal.reset(identity_token)
|
|
48
|
+
namespaces.request_graph.reset(graph_token)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@app.exception_handler(PermissionError)
|
|
52
|
+
async def permission_error_handler(_request, _exc):
|
|
53
|
+
return JSONResponse({"error": "Access denied"}, status_code=403)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ConnectionManager:
|
|
58
|
+
def __init__(self):
|
|
59
|
+
self.active: list[WebSocket] = []
|
|
60
|
+
self.graphs: dict[WebSocket, str] = {}
|
|
61
|
+
|
|
62
|
+
async def connect(self, ws: WebSocket):
|
|
63
|
+
await ws.accept()
|
|
64
|
+
self.active.append(ws)
|
|
65
|
+
self.graphs[ws] = runtime.brain_path()
|
|
66
|
+
|
|
67
|
+
def disconnect(self, ws: WebSocket):
|
|
68
|
+
if ws in self.active:
|
|
69
|
+
self.active.remove(ws)
|
|
70
|
+
self.graphs.pop(ws, None)
|
|
71
|
+
|
|
72
|
+
async def broadcast(self, message: dict):
|
|
73
|
+
if access.policy_path():
|
|
74
|
+
return # protected realtime transport is intentionally disabled
|
|
75
|
+
for ws in list(self.active):
|
|
76
|
+
if self.graphs.get(ws) != runtime.brain_path():
|
|
77
|
+
continue
|
|
78
|
+
try:
|
|
79
|
+
await ws.send_json(message)
|
|
80
|
+
except Exception:
|
|
81
|
+
self.disconnect(ws)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
manager = ConnectionManager()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
NO_CACHE = {"Cache-Control": "no-store"}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@app.get("/")
|
|
91
|
+
def index():
|
|
92
|
+
return FileResponse(DOCS_DIR / "index.html", headers=NO_CACHE)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@app.get("/app.js")
|
|
96
|
+
def app_js():
|
|
97
|
+
return FileResponse(DOCS_DIR / "app.js", headers=NO_CACHE)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@app.get("/review")
|
|
101
|
+
def review():
|
|
102
|
+
return FileResponse(DOCS_DIR / "review.html", headers=NO_CACHE)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@app.get("/review.js")
|
|
106
|
+
def review_js():
|
|
107
|
+
return FileResponse(DOCS_DIR / "review.js", headers=NO_CACHE)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@app.get("/api/graph")
|
|
111
|
+
def graph():
|
|
112
|
+
return JSONResponse(runtime.make_brain().graph_state())
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@app.get("/report")
|
|
116
|
+
def report_page():
|
|
117
|
+
return FileResponse(DOCS_DIR / "report.html", headers=NO_CACHE)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@app.get("/report.js")
|
|
121
|
+
def report_js():
|
|
122
|
+
return FileResponse(DOCS_DIR / "report.js", headers=NO_CACHE)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@app.get("/api/report")
|
|
126
|
+
def api_report(since: str | None = None, top: int = 5):
|
|
127
|
+
"""Read-only BRAIN_REPORT digest (report #7). Never mutates the brain."""
|
|
128
|
+
from .report import report_data, render_report
|
|
129
|
+
brain = runtime.make_brain()
|
|
130
|
+
try:
|
|
131
|
+
data = report_data(brain, since=since, top=top)
|
|
132
|
+
data["markdown"] = render_report(brain, since=since, top=top)
|
|
133
|
+
return JSONResponse(data)
|
|
134
|
+
finally:
|
|
135
|
+
if hasattr(brain, "close"):
|
|
136
|
+
brain.close()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class IngestBody(BaseModel):
|
|
140
|
+
text: str
|
|
141
|
+
source: str = "human"
|
|
142
|
+
tags: list[str] = []
|
|
143
|
+
allow_duplicates: bool = False
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@app.exception_handler(ValueError)
|
|
147
|
+
async def value_error_handler(_req, exc: ValueError):
|
|
148
|
+
"""Audit #57: engine ValueErrors (e.g. empty ingest text) are client errors,
|
|
149
|
+
not 500s — return a clean 400 with the message."""
|
|
150
|
+
from fastapi.responses import JSONResponse
|
|
151
|
+
return JSONResponse({"error": str(exc)}, status_code=400)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@app.post("/api/ingest")
|
|
155
|
+
async def ingest(body: IngestBody):
|
|
156
|
+
# Audit #16: git pull/push + embedding inference are blocking I/O — they
|
|
157
|
+
# run in the threadpool, not on the event loop (otherwise a slow ingest
|
|
158
|
+
# stalls all requests and WS broadcasts).
|
|
159
|
+
engine = runtime.make_engine()
|
|
160
|
+
node, edges, is_dup = await run_in_threadpool(
|
|
161
|
+
engine.ingest, body.text, body.source, body.tags,
|
|
162
|
+
body.allow_duplicates)
|
|
163
|
+
await manager.broadcast({
|
|
164
|
+
"type": "ingested",
|
|
165
|
+
"node": node.to_dict(),
|
|
166
|
+
"edges": [e.to_dict() for e in edges],
|
|
167
|
+
"duplicate": is_dup,
|
|
168
|
+
})
|
|
169
|
+
return {"node": node.to_dict(), "suggested": [e.to_dict() for e in edges],
|
|
170
|
+
"duplicate": is_dup}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@app.post("/api/edge/{edge_id}/accept")
|
|
174
|
+
async def accept_edge(edge_id: str):
|
|
175
|
+
edge = await run_in_threadpool(runtime.make_engine().resolve, edge_id, True)
|
|
176
|
+
if edge is None:
|
|
177
|
+
return JSONResponse({"error": "edge not found or not pending"}, status_code=404)
|
|
178
|
+
await manager.broadcast({"type": "edge_resolved", "edge": edge.to_dict(), "accepted": True})
|
|
179
|
+
return edge.to_dict()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@app.post("/api/edge/{edge_id}/reject")
|
|
183
|
+
async def reject_edge(edge_id: str):
|
|
184
|
+
edge = await run_in_threadpool(runtime.make_engine().resolve, edge_id, False)
|
|
185
|
+
if edge is None:
|
|
186
|
+
return JSONResponse({"error": "edge not found or not pending"}, status_code=404)
|
|
187
|
+
await manager.broadcast({"type": "edge_resolved", "edge": edge.to_dict(), "accepted": False})
|
|
188
|
+
return {"rejected": edge_id}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class LinkBody(BaseModel):
|
|
192
|
+
source: str
|
|
193
|
+
target: str
|
|
194
|
+
kind: str = "same_as"
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@app.post("/api/edge")
|
|
198
|
+
async def link_edge(body: LinkBody):
|
|
199
|
+
def _link():
|
|
200
|
+
try:
|
|
201
|
+
return runtime.make_engine().link(body.source, body.target, body.kind), None
|
|
202
|
+
except ValueError as exc:
|
|
203
|
+
return None, str(exc)
|
|
204
|
+
edge, err = await run_in_threadpool(_link)
|
|
205
|
+
if err is not None or edge is None:
|
|
206
|
+
return JSONResponse({"error": err or "link failed"}, status_code=400)
|
|
207
|
+
await manager.broadcast({"type": "edge_linked", "edge": edge.to_dict()})
|
|
208
|
+
return edge.to_dict()
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@app.websocket("/ws")
|
|
212
|
+
async def ws_endpoint(ws: WebSocket):
|
|
213
|
+
if access.policy_path():
|
|
214
|
+
await ws.close(code=1008)
|
|
215
|
+
return
|
|
216
|
+
await manager.connect(ws)
|
|
217
|
+
try:
|
|
218
|
+
while True:
|
|
219
|
+
# Audit #29: catching only WebSocketDisconnect left zombies behind
|
|
220
|
+
# (binary frame → KeyError, TCP reset → RuntimeError) — the socket
|
|
221
|
+
# stayed in manager.active forever and was never closed.
|
|
222
|
+
await ws.receive_text()
|
|
223
|
+
except WebSocketDisconnect:
|
|
224
|
+
manager.disconnect(ws)
|
|
225
|
+
except Exception:
|
|
226
|
+
manager.disconnect(ws)
|
|
227
|
+
try:
|
|
228
|
+
await ws.close()
|
|
229
|
+
except Exception:
|
|
230
|
+
pass
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@app.post("/api/edge/{edge_id}/undo")
|
|
234
|
+
async def undo_edge(edge_id: str):
|
|
235
|
+
edge = await run_in_threadpool(runtime.make_engine().undo, edge_id)
|
|
236
|
+
if edge is None:
|
|
237
|
+
return JSONResponse({"error": "edge not found or already resolved"}, status_code=409)
|
|
238
|
+
await manager.broadcast({"type": "edge_restored", "edge": edge.to_dict()})
|
|
239
|
+
return edge.to_dict()
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Similarity: cosine similarity + k nearest neighbors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def cosine(a: list[float], b: list[float]) -> float:
|
|
9
|
+
"""Cosine similarity. Audit #8: mismatched dimensions are a data error
|
|
10
|
+
(e.g. 384-dim ST vector vs. 64-dim hash vector in one brain) — a silent
|
|
11
|
+
truncation to the shorter length produces plausible-looking scores and can
|
|
12
|
+
trigger a wrong auto-merge at the dedup threshold. Therefore: hard fail."""
|
|
13
|
+
if len(a) != len(b):
|
|
14
|
+
raise ValueError(
|
|
15
|
+
f"cosine: dimension mismatch ({len(a)} vs {len(b)}) — "
|
|
16
|
+
"inhomogeneous embedders in the same brain? Vectors are not comparable.")
|
|
17
|
+
dot = sum(x * y for x, y in zip(a, b))
|
|
18
|
+
na = math.sqrt(sum(x * x for x in a))
|
|
19
|
+
nb = math.sqrt(sum(x * x for x in b))
|
|
20
|
+
if na == 0.0 or nb == 0.0:
|
|
21
|
+
return 0.0
|
|
22
|
+
return dot / (na * nb)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def knn(query: list[float], candidates: dict[str, list[float]], k: int = 3) -> list[tuple[str, float]]:
|
|
26
|
+
"""Returns the k nearest neighbors as (id, similarity), sorted descending.
|
|
27
|
+
|
|
28
|
+
Audit #8 follow-up (fix wave 2): foreign-dimension candidates are
|
|
29
|
+
skipped instead of crashing the whole query — the same two-level
|
|
30
|
+
decision as in _find_duplicate: the primitive cosine() is strict,
|
|
31
|
+
the call sites are tolerant. A brain with a few legacy vectors of the
|
|
32
|
+
wrong dimension (e.g. after an embedder switch) degrades cleanly to the
|
|
33
|
+
compatible neighbors.
|
|
34
|
+
Audit #60: k<=0 returns [] instead of all items (k=0) or the last one
|
|
35
|
+
being dropped (k=-1) — a limit means limit."""
|
|
36
|
+
if k <= 0:
|
|
37
|
+
return []
|
|
38
|
+
query_dim = len(query)
|
|
39
|
+
scored = [(nid, cosine(query, vec)) for nid, vec in candidates.items()
|
|
40
|
+
if vec and len(vec) == query_dim]
|
|
41
|
+
scored.sort(key=lambda t: t[1], reverse=True)
|
|
42
|
+
return scored[:k]
|
graph_engine/store.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""JSONL store: one JSON line per object. No DB overhead."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .model import Node, Edge
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Store:
|
|
12
|
+
"""Holds nodes and edges as JSONL (separate files)."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, path: str | Path = "data.jsonl"):
|
|
15
|
+
self.path = Path(path)
|
|
16
|
+
self.edges_path = self.path.with_suffix(".edges.jsonl")
|
|
17
|
+
self.nodes: list[Node] = []
|
|
18
|
+
self.edges: list[Edge] = []
|
|
19
|
+
self._load()
|
|
20
|
+
|
|
21
|
+
def _load(self) -> None:
|
|
22
|
+
if self.path.exists():
|
|
23
|
+
for line in self.path.read_text(encoding="utf-8").splitlines():
|
|
24
|
+
if line.strip():
|
|
25
|
+
self.nodes.append(Node.from_dict(json.loads(line)))
|
|
26
|
+
if self.edges_path.exists():
|
|
27
|
+
for line in self.edges_path.read_text(encoding="utf-8").splitlines():
|
|
28
|
+
if line.strip():
|
|
29
|
+
self.edges.append(Edge.from_dict(json.loads(line)))
|
|
30
|
+
|
|
31
|
+
def add_node(self, node: Node) -> None:
|
|
32
|
+
self.nodes.append(node)
|
|
33
|
+
with self.path.open("a", encoding="utf-8") as f:
|
|
34
|
+
f.write(json.dumps(node.to_dict(), ensure_ascii=False) + "\n")
|
|
35
|
+
|
|
36
|
+
def add_edge(self, edge: Edge) -> None:
|
|
37
|
+
self.edges.append(edge)
|
|
38
|
+
with self.edges_path.open("a", encoding="utf-8") as f:
|
|
39
|
+
f.write(json.dumps(edge.to_dict(), ensure_ascii=False) + "\n")
|
|
40
|
+
|
|
41
|
+
def resolve_edge(self, edge_id: str, accept: bool) -> Edge | None:
|
|
42
|
+
"""Accept a pending edge (pending=False) or discard it (remove)."""
|
|
43
|
+
edge = next((e for e in self.edges if e.id == edge_id), None)
|
|
44
|
+
if edge is None or not edge.pending:
|
|
45
|
+
return None
|
|
46
|
+
if accept:
|
|
47
|
+
edge.pending = False
|
|
48
|
+
self._rewrite_edges()
|
|
49
|
+
return edge
|
|
50
|
+
self.edges.remove(edge)
|
|
51
|
+
self._rewrite_edges()
|
|
52
|
+
return edge
|
|
53
|
+
|
|
54
|
+
def _rewrite_edges(self) -> None:
|
|
55
|
+
with self.edges_path.open("w", encoding="utf-8") as f:
|
|
56
|
+
for e in self.edges:
|
|
57
|
+
f.write(json.dumps(e.to_dict(), ensure_ascii=False) + "\n")
|
|
58
|
+
|
|
59
|
+
def graph_state(self) -> dict:
|
|
60
|
+
return {
|
|
61
|
+
"nodes": [n.to_dict() for n in self.nodes],
|
|
62
|
+
"edges": [e.to_dict() for e in self.edges],
|
|
63
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Edge suggestions: the k nearest neighbors become typed suggestions.
|
|
2
|
+
|
|
3
|
+
Rules (V2#3 — confidence bands instead of bare thresholds):
|
|
4
|
+
- sim >= 0.95 → "similar", AUTO-ACCEPT (pending=False), confidence=sim
|
|
5
|
+
- 0.75 <= sim < 0.95 → "similar", pending
|
|
6
|
+
- 0.45 <= sim < 0.75 → "extends", pending
|
|
7
|
+
- sim < 0.45 → no suggestion
|
|
8
|
+
|
|
9
|
+
Every suggestion carries a confidence (the cosine similarity). The caller
|
|
10
|
+
(BrainEngine) decides from the band + env override whether it stays pending.
|
|
11
|
+
|
|
12
|
+
"same_as" is never suggested automatically — it is created manually
|
|
13
|
+
(CLI `link`, cockpit) for translation/alias pairs.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
|
|
20
|
+
from .similarity import knn
|
|
21
|
+
|
|
22
|
+
THRESHOLD_SIMILAR = 0.75
|
|
23
|
+
THRESHOLD_EXTEND = 0.45
|
|
24
|
+
AUTO_ACCEPT_CONFIDENCE = 0.95
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Suggestion:
|
|
29
|
+
source: str
|
|
30
|
+
target: str
|
|
31
|
+
kind: str
|
|
32
|
+
confidence: float = 0.0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def suggest(source_id: str, query_vec: list[float],
|
|
36
|
+
candidates: dict[str, list[float]], k: int = 3) -> list[Suggestion]:
|
|
37
|
+
out: list[Suggestion] = []
|
|
38
|
+
seen: set[str] = set()
|
|
39
|
+
for nid, sim in knn(query_vec, candidates, k):
|
|
40
|
+
if nid == source_id or nid in seen:
|
|
41
|
+
continue
|
|
42
|
+
if sim >= THRESHOLD_SIMILAR:
|
|
43
|
+
kind = "similar"
|
|
44
|
+
elif sim >= THRESHOLD_EXTEND:
|
|
45
|
+
kind = "extends"
|
|
46
|
+
else:
|
|
47
|
+
continue
|
|
48
|
+
seen.add(nid)
|
|
49
|
+
out.append(Suggestion(source=source_id, target=nid, kind=kind, confidence=sim))
|
|
50
|
+
return out
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def is_auto_accept(confidence: float) -> bool:
|
|
54
|
+
"""Confidence band: >=0.95 is accepted without HITL."""
|
|
55
|
+
return confidence >= AUTO_ACCEPT_CONFIDENCE
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# Backwards-compatible for the JSONL engine (v0.0.1)
|
|
59
|
+
def suggest_edges(source_id: str, query_vec: list[float],
|
|
60
|
+
candidates: dict[str, list[float]], k: int = 3):
|
|
61
|
+
from .model import Edge
|
|
62
|
+
return [Edge(source=s.source, target=s.target, kind=s.kind, pending=True)
|
|
63
|
+
for s in suggest(source_id, query_vec, candidates, k)]
|
graph_engine/temporal.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Temporal reasoning (Phase 2): bi-temporal queries over the graph.
|
|
2
|
+
|
|
3
|
+
The engine has bi-temporal edges (valid_from/valid_to) since the Zep/Graphiti
|
|
4
|
+
lesson, but no way to QUERY them. This module adds the read side:
|
|
5
|
+
|
|
6
|
+
valid_at(brain, timestamp) — the graph as it was at a point in time
|
|
7
|
+
history(brain, node_id) — how a node's edges evolved over time
|
|
8
|
+
when(engine, query, at) — retrieval restricted to what was known then
|
|
9
|
+
|
|
10
|
+
All functions are read-only. They never modify the brain.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import datetime
|
|
16
|
+
from typing import TYPE_CHECKING
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from .brain import Brain, Edge, Node
|
|
20
|
+
from .brain_engine import BrainEngine
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _parse_ts(ts: str) -> datetime.datetime:
|
|
24
|
+
"""Parse ISO-8601 timestamp, tolerating 'Z' suffix."""
|
|
25
|
+
return datetime.datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _is_valid_at(edge: "Edge", ts: datetime.datetime) -> bool:
|
|
29
|
+
"""Was this edge valid at the given timestamp?
|
|
30
|
+
|
|
31
|
+
An edge is valid at T when:
|
|
32
|
+
- valid_from <= T (it had been created by then)
|
|
33
|
+
- valid_to is None OR valid_to > T (its validity had not ended)
|
|
34
|
+
- invalidated_at is None OR invalidated_at > T
|
|
35
|
+
"""
|
|
36
|
+
return edge.is_valid_at(ts)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def valid_at(brain: "Brain", timestamp: str) -> dict:
|
|
40
|
+
"""The graph as it was at a point in time.
|
|
41
|
+
|
|
42
|
+
Returns {nodes: [...], edges: [...]} — only nodes and edges that were
|
|
43
|
+
live at the given timestamp. Nodes are included if they existed (created
|
|
44
|
+
<= T) and were not yet tombstoned. Edges are included if they were valid
|
|
45
|
+
at T (see _is_valid_at).
|
|
46
|
+
"""
|
|
47
|
+
try:
|
|
48
|
+
ts = _parse_ts(timestamp)
|
|
49
|
+
except (ValueError, TypeError):
|
|
50
|
+
raise ValueError(f"Invalid timestamp: {timestamp!r}")
|
|
51
|
+
|
|
52
|
+
nodes: list[Node] = []
|
|
53
|
+
for n in brain.read_nodes():
|
|
54
|
+
if n.status == "tombstone":
|
|
55
|
+
continue
|
|
56
|
+
try:
|
|
57
|
+
created = _parse_ts(n.created) if n.created else None
|
|
58
|
+
except (ValueError, TypeError):
|
|
59
|
+
created = None
|
|
60
|
+
if created and created > ts:
|
|
61
|
+
continue
|
|
62
|
+
nodes.append(n)
|
|
63
|
+
|
|
64
|
+
edges: list[Edge] = []
|
|
65
|
+
for e in brain.read_edges(include_rejected=True):
|
|
66
|
+
if e.pending or e.rejected:
|
|
67
|
+
continue
|
|
68
|
+
if _is_valid_at(e, ts):
|
|
69
|
+
edges.append(e)
|
|
70
|
+
|
|
71
|
+
return {"nodes": nodes, "edges": edges}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def history(brain: "Brain", node_id: str) -> list[dict]:
|
|
75
|
+
"""How a node's edges evolved over time.
|
|
76
|
+
|
|
77
|
+
Returns a chronological list of edge events (creation, expiry, invalidation)
|
|
78
|
+
involving this node, sorted by timestamp. Each entry has:
|
|
79
|
+
{timestamp, event, edge_id, kind, other_node, origin}
|
|
80
|
+
"""
|
|
81
|
+
events: list[dict] = []
|
|
82
|
+
for e in brain.read_edges(include_rejected=True):
|
|
83
|
+
if node_id not in (e.source, e.target):
|
|
84
|
+
continue
|
|
85
|
+
other = e.target if e.source == node_id else e.source
|
|
86
|
+
# Creation event
|
|
87
|
+
events.append({
|
|
88
|
+
"timestamp": e.valid_from,
|
|
89
|
+
"event": "created",
|
|
90
|
+
"edge_id": e.id,
|
|
91
|
+
"kind": e.kind,
|
|
92
|
+
"other_node": other,
|
|
93
|
+
"origin": e.origin,
|
|
94
|
+
"rejected": e.rejected,
|
|
95
|
+
})
|
|
96
|
+
# Keep an extracted expiry separate from a later review invalidation.
|
|
97
|
+
endings = [(e.valid_to, "expired" if e.extracted_validity is not None else "invalidated")]
|
|
98
|
+
if e.extracted_validity is not None:
|
|
99
|
+
endings.append((e.invalidated_at, "invalidated"))
|
|
100
|
+
for timestamp, event in endings:
|
|
101
|
+
if not timestamp:
|
|
102
|
+
continue
|
|
103
|
+
events.append({
|
|
104
|
+
"timestamp": timestamp,
|
|
105
|
+
"event": event,
|
|
106
|
+
"edge_id": e.id,
|
|
107
|
+
"kind": e.kind,
|
|
108
|
+
"other_node": other,
|
|
109
|
+
"origin": e.origin,
|
|
110
|
+
"rejected": e.rejected,
|
|
111
|
+
})
|
|
112
|
+
events.sort(key=lambda x: x["timestamp"] or "")
|
|
113
|
+
return events
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def when(engine: "BrainEngine", query: str, at: str, k: int = 5) -> list[tuple[str, float]]:
|
|
117
|
+
"""Retrieval restricted to what was known at a point in time (Phase 2).
|
|
118
|
+
|
|
119
|
+
Runs the normal hybrid retrieval, then filters the results to only
|
|
120
|
+
include nodes that existed at the given timestamp. This is the
|
|
121
|
+
"what did I know then?" query — useful for understanding how the
|
|
122
|
+
brain's knowledge has evolved.
|
|
123
|
+
"""
|
|
124
|
+
from .retrieval import retrieve
|
|
125
|
+
|
|
126
|
+
# Parse timestamp FIRST — an invalid timestamp must raise even when
|
|
127
|
+
# there are no results to filter.
|
|
128
|
+
try:
|
|
129
|
+
ts = _parse_ts(at)
|
|
130
|
+
except (ValueError, TypeError):
|
|
131
|
+
raise ValueError(f"Invalid timestamp: {at!r}")
|
|
132
|
+
|
|
133
|
+
# Get the full ranking (no tracking — this is a historical query)
|
|
134
|
+
results = retrieve(engine, query, k=k * 3, track=False)
|
|
135
|
+
if not results:
|
|
136
|
+
return []
|
|
137
|
+
|
|
138
|
+
# Filter to nodes that existed at the timestamp
|
|
139
|
+
filtered: list[tuple[str, float]] = []
|
|
140
|
+
for node_id, score in results:
|
|
141
|
+
node = engine.brain.read_node(node_id)
|
|
142
|
+
if node is None:
|
|
143
|
+
continue
|
|
144
|
+
if node.status == "tombstone":
|
|
145
|
+
continue
|
|
146
|
+
try:
|
|
147
|
+
created = _parse_ts(node.created) if node.created else None
|
|
148
|
+
except (ValueError, TypeError):
|
|
149
|
+
created = None
|
|
150
|
+
if created and created > ts:
|
|
151
|
+
continue
|
|
152
|
+
filtered.append((node_id, score))
|
|
153
|
+
if len(filtered) >= k:
|
|
154
|
+
break
|
|
155
|
+
|
|
156
|
+
return filtered
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def edge_timeline(brain: "Brain", *, since: str | None = None,
|
|
160
|
+
until: str | None = None) -> list[dict]:
|
|
161
|
+
"""All edge events (created/expired/invalidated) in a time range.
|
|
162
|
+
|
|
163
|
+
Useful for "what changed in the brain recently?" — the temporal
|
|
164
|
+
equivalent of `ig report --since`.
|
|
165
|
+
"""
|
|
166
|
+
events: list[dict] = []
|
|
167
|
+
for e in brain.read_edges(include_rejected=True):
|
|
168
|
+
if e.pending or e.rejected:
|
|
169
|
+
continue
|
|
170
|
+
try:
|
|
171
|
+
vf = _parse_ts(e.valid_from) if e.valid_from else None
|
|
172
|
+
except (ValueError, TypeError):
|
|
173
|
+
vf = None
|
|
174
|
+
|
|
175
|
+
if vf:
|
|
176
|
+
if since and vf < _parse_ts(since):
|
|
177
|
+
pass # still include, filtered below
|
|
178
|
+
if until and vf > _parse_ts(until):
|
|
179
|
+
pass
|
|
180
|
+
events.append({
|
|
181
|
+
"timestamp": e.valid_from,
|
|
182
|
+
"event": "created",
|
|
183
|
+
"edge_id": e.id,
|
|
184
|
+
"kind": e.kind,
|
|
185
|
+
"source": e.source,
|
|
186
|
+
"target": e.target,
|
|
187
|
+
"origin": e.origin,
|
|
188
|
+
})
|
|
189
|
+
endings = [(e.valid_to, "expired" if e.extracted_validity is not None else "invalidated")]
|
|
190
|
+
if e.extracted_validity is not None:
|
|
191
|
+
endings.append((e.invalidated_at, "invalidated"))
|
|
192
|
+
for timestamp, event in endings:
|
|
193
|
+
if not timestamp:
|
|
194
|
+
continue
|
|
195
|
+
try:
|
|
196
|
+
_parse_ts(timestamp)
|
|
197
|
+
except (ValueError, TypeError):
|
|
198
|
+
continue
|
|
199
|
+
events.append({
|
|
200
|
+
"timestamp": timestamp,
|
|
201
|
+
"event": event,
|
|
202
|
+
"edge_id": e.id,
|
|
203
|
+
"kind": e.kind,
|
|
204
|
+
"source": e.source,
|
|
205
|
+
"target": e.target,
|
|
206
|
+
"origin": e.origin,
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
# Filter by range
|
|
210
|
+
if since:
|
|
211
|
+
events = [e for e in events if (e["timestamp"] or "") >= since]
|
|
212
|
+
if until:
|
|
213
|
+
events = [e for e in events if (e["timestamp"] or "") <= until]
|
|
214
|
+
|
|
215
|
+
events.sort(key=lambda x: x["timestamp"] or "")
|
|
216
|
+
return events
|