feat(graphrag): graph-view endpoints + network visualization

GraphStore.get_graph_overview (top-N by degree, bounded) + get_node_detail (description + linked chunks). GET /api/sources/<id>/graph + /graph/node/<id> (read-access gated, node scoped to source_id; empty graph -> {nodes:[],edges:[]}). Frontend GraphView (react-force-graph-2d) wired to config.kind=graphrag via card-click/'View graph'; node-click -> description + chunks; tooltip renders untrusted names as text (no innerHTML XSS). All read-only GraphStore methods rollback their txn (no idle-in-transaction lock). Unit G7. (Also a pre-existing prettier fix in WorkflowPreview to keep lint green.)
This commit is contained in:
Alex committed 2026-06-23 02:48:18 +01:00
1 parent daddbeb062
commit 2594bb0bed
14 files changed
+1202 -6

No files matched your search

+112
View File
@@ -1169,3 +1169,115 @@ class EnableSourceGraphRAG(Resource):
return make_response(
jsonify({"success": True, "task_id": task.id}), 200
)
def _graph_overview_payload(source_id, limit):
"""Return a bounded ``{nodes, edges}`` overview for a graphrag source.
An empty graph (extraction pending/capped/failed) yields empty lists rather
than an error, mirroring the ClassicRAG degradation guarantee.
"""
from application.graphrag.store import GraphStore
store = GraphStore()
if store.count_nodes(source_id) == 0:
return {"nodes": [], "edges": []}
return store.get_graph_overview(source_id, limit)
@sources_ns.route("/sources/<string:source_id>/graph")
class SourceGraph(Resource):
@api.doc(
description="Bounded knowledge-graph overview for a graphrag source: "
"top nodes by degree and the edges among them (read access: owner or "
"shared). Returns empty lists when no graph has been built yet."
)
def get(self, source_id):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
try:
limit = int(request.args.get("limit", 0)) or None
except (TypeError, ValueError):
limit = None
try:
with db_readonly() as conn:
doc = _resolve_readable_source(conn, source_id, user)
if doc is None:
return make_response(
jsonify({"success": False, "message": "Source not found"}),
404,
)
resolved_source_id = str(doc["id"])
except Exception as err:
current_app.logger.error(
f"Error resolving source {source_id} for graph: {err}",
exc_info=True,
)
return make_response(jsonify({"success": False}), 400)
try:
from application.graphrag.store import GRAPH_OVERVIEW_DEFAULT_LIMIT
overview = _graph_overview_payload(
resolved_source_id, limit or GRAPH_OVERVIEW_DEFAULT_LIMIT
)
except Exception as err:
current_app.logger.error(
f"Error building graph overview for {source_id}: {err}",
exc_info=True,
)
return make_response(jsonify({"success": False}), 400)
return make_response(
jsonify(
{
"success": True,
"nodes": overview["nodes"],
"edges": overview["edges"],
}
),
200,
)
@sources_ns.route("/sources/<string:source_id>/graph/node/<string:node_id>")
class SourceGraphNode(Resource):
@api.doc(
description="A graph node's description and its linked chunks (read "
"access: owner or shared)."
)
def get(self, source_id, node_id):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
try:
with db_readonly() as conn:
doc = _resolve_readable_source(conn, source_id, user)
if doc is None:
return make_response(
jsonify({"success": False, "message": "Source not found"}),
404,
)
resolved_source_id = str(doc["id"])
except Exception as err:
current_app.logger.error(
f"Error resolving source {source_id} for graph node: {err}",
exc_info=True,
)
return make_response(jsonify({"success": False}), 400)
try:
from application.graphrag.store import GraphStore
node = GraphStore().get_node_detail(resolved_source_id, node_id)
except Exception as err:
current_app.logger.error(
f"Error fetching graph node {node_id} for {source_id}: {err}",
exc_info=True,
)
return make_response(jsonify({"success": False}), 400)
if node is None:
return make_response(
jsonify({"success": False, "message": "Node not found"}), 404
)
return make_response(jsonify({"success": True, "node": node}), 200)
+131
View File
@@ -24,6 +24,9 @@ DEFAULT_NAME_EMBEDDING_DIM = 768
MAX_SUBGRAPH_NODES = 500
MAX_SUBGRAPH_EDGES = 2000
GRAPH_OVERVIEW_DEFAULT_LIMIT = 100
GRAPH_OVERVIEW_MAX_LIMIT = 250
PGVECTOR_SOURCE_COLUMN = "source_id"
@@ -376,6 +379,7 @@ class GraphStore:
return None
finally:
cursor.close()
conn.rollback()
def count_nodes(self, source_id: str) -> int:
"""Number of nodes for a source. Zero drives the ClassicRAG fallback (G5)."""
@@ -392,6 +396,7 @@ class GraphStore:
return 0
finally:
cursor.close()
conn.rollback()
def search_nodes_by_embedding(
self, source_id: str, query_embedding: List[float], k: int = 10
@@ -426,6 +431,7 @@ class GraphStore:
return []
finally:
cursor.close()
conn.rollback()
def get_subgraph(
self, source_id: str, node_ids: List[str], hops: int = 1
@@ -519,6 +525,71 @@ class GraphStore:
return {"nodes": [], "edges": []}
finally:
cursor.close()
conn.rollback()
def get_graph_overview(
self, source_id: str, limit: int = GRAPH_OVERVIEW_DEFAULT_LIMIT
) -> Dict[str, List[Dict[str, Any]]]:
"""Top-``limit`` nodes by degree and the edges among them.
Bounds the visualization: the top nodes by degree are selected, then only
edges whose endpoints are both in that set are returned (edge ids
reference node ids). ``limit`` is clamped to ``GRAPH_OVERVIEW_MAX_LIMIT``.
"""
limit = max(1, min(int(limit), GRAPH_OVERVIEW_MAX_LIMIT))
conn = self._get_connection()
cursor = conn.cursor()
try:
cursor.execute(
"""
SELECT id, name, type, description, degree
FROM graph_nodes
WHERE source_id = %s
ORDER BY degree DESC, id
LIMIT %s;
""",
(source_id, limit),
)
nodes = [
{
"id": str(row[0]),
"name": row[1],
"type": row[2],
"description": row[3],
"degree": row[4],
}
for row in cursor.fetchall()
]
if not nodes:
return {"nodes": [], "edges": []}
node_ids = [n["id"] for n in nodes]
cursor.execute(
"""
SELECT src_node_id, dst_node_id, type, weight
FROM graph_edges
WHERE source_id = %s
AND src_node_id = ANY(%s) AND dst_node_id = ANY(%s)
LIMIT %s;
""",
(source_id, node_ids, node_ids, MAX_SUBGRAPH_EDGES),
)
edges = [
{
"source": str(row[0]),
"target": str(row[1]),
"type": row[2],
"weight": row[3],
}
for row in cursor.fetchall()
]
return {"nodes": nodes, "edges": edges}
except Exception as e:
logging.error(f"Error getting graph overview: {e}")
return {"nodes": [], "edges": []}
finally:
cursor.close()
conn.rollback()
def get_chunk_ids_for_nodes(
self, source_id: str, node_ids: List[str]
@@ -545,6 +616,7 @@ class GraphStore:
return {}
finally:
cursor.close()
conn.rollback()
def get_chunk_texts(
self,
@@ -581,6 +653,63 @@ class GraphStore:
return {}
finally:
cursor.close()
conn.rollback()
def get_node_detail(
self, source_id: str, node_id: str, max_chunks: int = 20
) -> Optional[Dict[str, Any]]:
"""A node's full record plus a bounded list of its linked chunks.
Returns ``None`` when the node does not belong to the source. Chunk texts
are read from the co-located pgvector table; at most ``max_chunks`` are
returned so a hub node never streams an unbounded payload.
"""
conn = self._get_connection()
try:
cursor = conn.cursor()
try:
cursor.execute(
"""
SELECT id, name, type, description, degree, doc_freq
FROM graph_nodes
WHERE source_id = %s AND id = %s;
""",
(source_id, node_id),
)
row = cursor.fetchone()
finally:
cursor.close()
if row is None:
return None
node = {
"id": str(row[0]),
"name": row[1],
"type": row[2],
"description": row[3],
"degree": row[4],
"doc_freq": row[5],
}
chunk_ids = self.get_chunk_ids_for_nodes(source_id, [node_id]).get(
str(node_id), []
)[: max(0, int(max_chunks))]
texts = (
self.get_chunk_texts(source_id, chunk_ids) if chunk_ids else {}
)
node["chunks"] = [
{
"chunk_id": cid,
"text": texts.get(cid, {}).get("text", ""),
"metadata": texts.get(cid, {}).get("metadata", {}),
}
for cid in chunk_ids
]
return node
except Exception as e:
logging.error(f"Error getting node detail: {e}")
return None
finally:
conn.rollback()
def set_node_degrees(self, source_id: str):
"""Recompute every node's degree from its incident edges for a source.
@@ -660,6 +789,7 @@ class GraphStore:
return [str(c) for c in all_chunk_ids]
finally:
cursor.close()
conn.rollback()
def get_progress(self, source_id: str) -> Dict[str, str]:
conn = self._get_connection()
@@ -676,6 +806,7 @@ class GraphStore:
return {}
finally:
cursor.close()
conn.rollback()
def delete_by_source(self, source_id: str):
"""Remove every graph row for a source (no FK cascade across clusters)."""
+188 -2
View File
@@ -34,6 +34,7 @@
"react-day-picker": "^10.0.1",
"react-dom": "^19.2.5",
"react-dropzone": "^15.0.0",
"react-force-graph-2d": "^1.29.1",
"react-google-drive-picker": "^1.2.2",
"react-i18next": "^17.0.6",
"react-markdown": "^9.0.1",
@@ -4281,6 +4282,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@tweenjs/tween.js": {
"version": "25.0.0",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz",
"integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==",
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@@ -4654,6 +4661,7 @@
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -4663,7 +4671,7 @@
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
@@ -5124,6 +5132,15 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/accessor-fn": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/accessor-fn/-/accessor-fn-1.5.3.tgz",
"integrity": "sha512-rkAofCwe/FvYFUlMB0v0gWmhqtfAtV1IUkdPbfhTUyYniu5LrC0A0UJkTH0Jv3S8SvwkmfuAlY+mQIJATdocMA==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@@ -5459,6 +5476,16 @@
"baseline-browser-mapping": "dist/cli.js"
}
},
"node_modules/bezier-js": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/bezier-js/-/bezier-js-6.1.4.tgz",
"integrity": "sha512-PA0FW9ZpcHbojUCMu28z9Vg/fNkwTj5YhusSAjHHDfHDGLxJ6YUKrAN2vk1fP2MMOxVw4Oko16FMlRGVBGqLKg==",
"license": "MIT",
"funding": {
"type": "individual",
"url": "https://github.com/Pomax/bezierjs/blob/master/FUNDING.md"
}
},
"node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
@@ -5620,6 +5647,18 @@
],
"license": "CC-BY-4.0"
},
"node_modules/canvas-color-tracker": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/canvas-color-tracker/-/canvas-color-tracker-1.3.2.tgz",
"integrity": "sha512-ryQkDX26yJ3CXzb3hxUVNlg1NKE4REc5crLBq661Nxzr8TNd236SaEf2ffYLXyI5tSABSeguHLqcVq4vf9L3Zg==",
"license": "MIT",
"dependencies": {
"tinycolor2": "^1.6.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/ccount": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
@@ -5974,6 +6013,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/cytoscape": {
@@ -6086,6 +6126,12 @@
"node": ">=12"
}
},
"node_modules/d3-binarytree": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/d3-binarytree/-/d3-binarytree-1.0.2.tgz",
"integrity": "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==",
"license": "MIT"
},
"node_modules/d3-brush": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz",
@@ -6238,6 +6284,22 @@
"node": ">=12"
}
},
"node_modules/d3-force-3d": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/d3-force-3d/-/d3-force-3d-3.0.6.tgz",
"integrity": "sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA==",
"license": "MIT",
"dependencies": {
"d3-binarytree": "1",
"d3-dispatch": "1 - 3",
"d3-octree": "1",
"d3-quadtree": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
@@ -6280,6 +6342,12 @@
"node": ">=12"
}
},
"node_modules/d3-octree": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/d3-octree/-/d3-octree-1.1.0.tgz",
"integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==",
"license": "MIT"
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
@@ -7727,6 +7795,20 @@
"dev": true,
"license": "ISC"
},
"node_modules/float-tooltip": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/float-tooltip/-/float-tooltip-1.7.5.tgz",
"integrity": "sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg==",
"license": "MIT",
"dependencies": {
"d3-selection": "2 - 3",
"kapsule": "^1.16",
"preact": "10"
},
"engines": {
"node": ">=12"
}
},
"node_modules/for-each": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@@ -7743,6 +7825,32 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/force-graph": {
"version": "1.51.4",
"resolved": "https://registry.npmjs.org/force-graph/-/force-graph-1.51.4.tgz",
"integrity": "sha512-TdJ2KbkoiDQ7NIRx8IPGD0mAXXpLhamS7c+b7W98b0MHG7lphnda1VOQX/98UDTsttIAdH4TcP0l0MauSnLK8w==",
"license": "MIT",
"dependencies": {
"@tweenjs/tween.js": "18 - 25",
"accessor-fn": "1",
"bezier-js": "3 - 6",
"canvas-color-tracker": "^1.3",
"d3-array": "1 - 3",
"d3-drag": "2 - 3",
"d3-force-3d": "2 - 3",
"d3-scale": "1 - 4",
"d3-scale-chromatic": "1 - 3",
"d3-selection": "2 - 3",
"d3-zoom": "2 - 3",
"float-tooltip": "^1.7",
"index-array-by": "1",
"kapsule": "^1.16",
"lodash-es": "4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/format": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz",
@@ -8420,6 +8528,15 @@
"node": ">=0.8.19"
}
},
"node_modules/index-array-by": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/index-array-by/-/index-array-by-1.4.2.tgz",
"integrity": "sha512-SP23P27OUKzXWEC/TOyWlwLviofQkCSCKONnc62eItjp69yCZZPqDQtr3Pw5gJDnPeUMqExmKydNZaJO0FU9pw==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/inline-style-parser": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.6.tgz",
@@ -8943,6 +9060,15 @@
"node": ">= 0.4"
}
},
"node_modules/jerrypick": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/jerrypick/-/jerrypick-1.1.2.tgz",
"integrity": "sha512-YKnxXEekXKzhpf7CLYA0A+oDP8V0OhICNCr5lv96FvSsDEmrb0GKM776JgQvHTMjr7DTTPEVv/1Ciaw0uEWzBA==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
@@ -9042,6 +9168,18 @@
"node": ">=4.0"
}
},
"node_modules/kapsule": {
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/kapsule/-/kapsule-1.16.3.tgz",
"integrity": "sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg==",
"license": "MIT",
"dependencies": {
"lodash-es": "4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/katex": {
"version": "0.16.25",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.25.tgz",
@@ -11116,6 +11254,16 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/preact": {
"version": "10.29.2",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.2.tgz",
"integrity": "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -11459,6 +11607,23 @@
"react": ">= 16.8 || 18.0.0"
}
},
"node_modules/react-force-graph-2d": {
"version": "1.29.1",
"resolved": "https://registry.npmjs.org/react-force-graph-2d/-/react-force-graph-2d-1.29.1.tgz",
"integrity": "sha512-1Rl/1Z3xy2iTHKj6a0jRXGyiI86xUti81K+jBQZ+Oe46csaMikp47L5AjrzA9hY9fNGD63X8ffrqnvaORukCuQ==",
"license": "MIT",
"dependencies": {
"force-graph": "^1.51",
"prop-types": "15",
"react-kapsule": "^2.5"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"react": "*"
}
},
"node_modules/react-google-drive-picker": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/react-google-drive-picker/-/react-google-drive-picker-1.2.2.tgz",
@@ -11502,6 +11667,21 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/react-kapsule": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/react-kapsule/-/react-kapsule-2.6.0.tgz",
"integrity": "sha512-HzLJoYb1n1kfwjXbqFFcRR0EA6oPsJ64tNdDmCSaL/bz2o9wUZRSb0cMe//grLFeF9EVoL4CD/e6ozLyzEv+PQ==",
"license": "MIT",
"dependencies": {
"jerrypick": "^1.1.2"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"react": ">=16.13.1"
}
},
"node_modules/react-markdown": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.1.0.tgz",
@@ -12706,6 +12886,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/tinycolor2": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz",
"integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==",
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz",
@@ -12970,7 +13156,7 @@
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"devOptional": true,
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
+1
View File
@@ -47,6 +47,7 @@
"react-day-picker": "^10.0.1",
"react-dom": "^19.2.5",
"react-dropzone": "^15.0.0",
"react-force-graph-2d": "^1.29.1",
"react-google-drive-picker": "^1.2.2",
"react-i18next": "^17.0.6",
"react-markdown": "^9.0.1",
@@ -526,7 +526,7 @@ export default function WorkflowPreview({
Workflow
</h3>
</div>
<div className="scrollbar-thin flex-1 overflow-y-auto p-3">
<div className="flex-1 scrollbar-thin overflow-y-auto p-3">
<WorkflowMiniMap
nodes={workflowData.nodes}
activeNodeId={activeNodeId}
@@ -541,7 +541,7 @@ export default function WorkflowPreview({
<div className="relative flex min-w-0 flex-1 flex-col">
<div
ref={chatContainerRef}
className="scrollbar-thin absolute inset-0 bottom-[100px] overflow-y-auto px-4 pt-4"
className="absolute inset-0 bottom-[100px] scrollbar-thin overflow-y-auto px-4 pt-4"
>
{queries.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center">
+4
View File
@@ -59,6 +59,10 @@ const endpoints = {
CREATE_WIKI: '/api/sources/wiki',
CONVERT_TO_WIKI: (id: string) => `/api/sources/${id}/wiki/convert`,
ENABLE_GRAPHRAG: (id: string) => `/api/sources/${id}/graphrag/enable`,
SOURCE_GRAPH: (id: string, limit?: number) =>
`/api/sources/${id}/graph${limit ? `?limit=${limit}` : ''}`,
SOURCE_GRAPH_NODE: (id: string, nodeId: string) =>
`/api/sources/${id}/graph/node/${encodeURIComponent(nodeId)}`,
TASK_STATUS: (taskId: string) => `/api/task_status?task_id=${taskId}`,
WIKI_PAGES: (id: string) => `/api/sources/${id}/wiki/pages`,
WIKI_PAGE: (id: string, path: string) =>
+15
View File
@@ -111,6 +111,21 @@ const userService = {
apiClient.post(endpoints.USER.CONVERT_TO_WIKI(sourceId), {}, token),
enableGraphRAG: (sourceId: string, token: string | null): Promise<Response> =>
apiClient.post(endpoints.USER.ENABLE_GRAPHRAG(sourceId), {}, token),
getSourceGraph: (
sourceId: string,
token: string | null,
limit?: number,
): Promise<Response> =>
throttledApiClient.get(endpoints.USER.SOURCE_GRAPH(sourceId, limit), token),
getSourceGraphNode: (
sourceId: string,
nodeId: string,
token: string | null,
): Promise<Response> =>
throttledApiClient.get(
endpoints.USER.SOURCE_GRAPH_NODE(sourceId, nodeId),
token,
),
getTaskStatus: (taskId: string, token: string | null): Promise<Response> =>
apiClient.get(endpoints.USER.TASK_STATUS(taskId), token),
getWikiPages: (sourceId: string, token: string | null): Promise<Response> =>
+252
View File
@@ -0,0 +1,252 @@
import { Network, X } from 'lucide-react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import ForceGraph2D from 'react-force-graph-2d';
import userService from '../api/services/userService';
import ArrowLeft from '../assets/arrow-left.svg';
import { selectToken } from '../preferences/preferenceSlice';
import { Button } from './ui/button';
import SkeletonLoader from './SkeletonLoader';
import {
ForceGraphData,
GraphNode,
GraphNodeDetail,
GraphOverview,
maxDegree,
nodeLabelEl,
nodeRadius,
toForceGraphData,
} from './graphViewUtils';
interface GraphViewProps {
docId: string;
sourceName: string;
onBackToDocuments: () => void;
}
const GRAPH_LIMIT = 100;
const GraphView: React.FC<GraphViewProps> = ({
docId,
sourceName,
onBackToDocuments,
}) => {
const { t } = useTranslation();
const token = useSelector(selectToken);
const [data, setData] = useState<ForceGraphData>({ nodes: [], links: [] });
const [loading, setLoading] = useState(true);
const [selectedNode, setSelectedNode] = useState<GraphNodeDetail | null>(
null,
);
const [loadingNode, setLoadingNode] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const [size, setSize] = useState({ width: 0, height: 480 });
useEffect(() => {
let cancelled = false;
setLoading(true);
userService
.getSourceGraph(docId, token, GRAPH_LIMIT)
.then((response) => response.json())
.then((body) => {
if (cancelled) return;
const overview: GraphOverview = {
nodes: body?.nodes ?? [],
edges: body?.edges ?? [],
};
setData(toForceGraphData(overview));
})
.catch((error) => console.error('Error loading graph:', error))
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [docId, token]);
useEffect(() => {
if (!containerRef.current) return;
const element = containerRef.current;
const observer = new ResizeObserver(() => {
setSize({ width: element.clientWidth, height: 480 });
});
observer.observe(element);
setSize({ width: element.clientWidth, height: 480 });
return () => observer.disconnect();
}, []);
const maxNodeDegree = useMemo(() => maxDegree(data.nodes), [data.nodes]);
const handleNodeClick = (node: GraphNode) => {
setLoadingNode(true);
setSelectedNode(null);
userService
.getSourceGraphNode(docId, node.id, token)
.then((response) => response.json())
.then((body) => {
if (body?.node) setSelectedNode(body.node as GraphNodeDetail);
})
.catch((error) => console.error('Error loading graph node:', error))
.finally(() => setLoadingNode(false));
};
const isEmpty = !loading && data.nodes.length === 0;
return (
<div className="flex flex-col">
<div className="mb-4 flex items-center">
<Button
type="button"
variant="outline"
size="icon-sm"
className="text-muted-foreground mr-3 h-[29px] w-[29px] rounded-full p-2 dark:border-0"
onClick={onBackToDocuments}
aria-label={t('settings.sources.backToAll')}
>
<img src={ArrowLeft} alt="left-arrow" className="h-3 w-3" />
</Button>
<span className="text-primary font-semibold wrap-break-word">
{sourceName}
</span>
</div>
<div className="bg-muted/60 text-muted-foreground dark:bg-accent/40 mb-4 flex items-start gap-2 rounded-xl px-4 py-3 text-xs">
<Network
size={16}
strokeWidth={1.75}
className="mt-0.5 shrink-0"
aria-hidden="true"
/>
<p>
<span className="text-foreground font-medium">
{t('settings.sources.graphrag.view.title')}
</span>{' '}
{t('settings.sources.graphrag.view.explainer')}
</p>
</div>
{loading ? (
<SkeletonLoader count={4} />
) : isEmpty ? (
<div className="border-border text-muted-foreground flex flex-col items-center gap-2 rounded-xl border border-dashed px-6 py-12 text-center text-sm">
<Network size={28} strokeWidth={1.5} aria-hidden="true" />
<p>{t('settings.sources.graphrag.view.empty')}</p>
</div>
) : (
<div className="flex flex-col gap-4 lg:flex-row">
<div
ref={containerRef}
className="border-border bg-card relative min-h-[480px] flex-1 overflow-hidden rounded-xl border"
>
{size.width > 0 && (
<ForceGraph2D
graphData={data}
width={size.width}
height={size.height}
nodeRelSize={1}
nodeVal={(node) =>
nodeRadius((node as GraphNode).degree, maxNodeDegree)
}
nodeLabel={(node) =>
nodeLabelEl(
(node as GraphNode).name,
) as unknown as React.ReactHTMLElement<HTMLElement>
}
linkColor={() => 'rgba(150,150,150,0.35)'}
cooldownTicks={80}
onNodeClick={(node) => handleNodeClick(node as GraphNode)}
nodeCanvasObjectMode={() => 'after'}
nodeCanvasObject={(node, ctx, globalScale) => {
const graphNode = node as GraphNode & {
x?: number;
y?: number;
};
if (globalScale < 1.2 || graphNode.x == null) return;
const label = graphNode.name ?? '';
ctx.font = `${10 / globalScale}px sans-serif`;
ctx.fillStyle = 'rgba(120,120,120,0.95)';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillText(
label,
graphNode.x,
(graphNode.y ?? 0) +
nodeRadius(graphNode.degree, maxNodeDegree) /
globalScale +
1,
);
}}
/>
)}
</div>
<aside className="border-border bg-card flex w-full shrink-0 flex-col rounded-xl border p-4 lg:w-80">
{loadingNode ? (
<SkeletonLoader count={3} />
) : selectedNode ? (
<div className="flex flex-col">
<div className="mb-2 flex items-start justify-between gap-2">
<div className="min-w-0">
<h3 className="text-foreground text-sm font-semibold wrap-break-word">
{selectedNode.name}
</h3>
{selectedNode.type && (
<span className="bg-muted-foreground/10 text-muted-foreground mt-1 inline-block rounded-full px-2 py-0.5 text-xs font-medium">
{selectedNode.type}
</span>
)}
</div>
<button
type="button"
onClick={() => setSelectedNode(null)}
aria-label={t('settings.sources.graphrag.view.close')}
className="text-muted-foreground hover:text-foreground shrink-0"
>
<X size={16} aria-hidden="true" />
</button>
</div>
{selectedNode.description && (
<p className="text-muted-foreground mb-3 text-sm leading-relaxed wrap-break-word">
{selectedNode.description}
</p>
)}
<h4 className="text-foreground mb-2 text-xs font-semibold">
{t('settings.sources.graphrag.view.linkedChunks')}
</h4>
{selectedNode.chunks.length === 0 ? (
<p className="text-muted-foreground text-xs">
{t('settings.sources.graphrag.view.noChunks')}
</p>
) : (
<ul className="flex flex-col gap-2">
{selectedNode.chunks.map((chunk) => (
<li
key={chunk.chunk_id}
className="border-border text-muted-foreground rounded-md border px-3 py-2 text-xs leading-relaxed wrap-break-word"
>
{chunk.text}
</li>
))}
</ul>
)}
</div>
) : (
<p className="text-muted-foreground py-2 text-sm">
{t('settings.sources.graphrag.view.selectNode')}
</p>
)}
</aside>
</div>
)}
</div>
);
};
export default GraphView;
@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest';
import {
GraphOverview,
maxDegree,
nodeLabelEl,
nodeRadius,
toForceGraphData,
} from './graphViewUtils';
describe('toForceGraphData', () => {
it('keeps nodes and maps edges to links', () => {
const overview: GraphOverview = {
nodes: [
{ id: 'a', name: 'A', degree: 2 },
{ id: 'b', name: 'B', degree: 1 },
],
edges: [{ source: 'a', target: 'b', type: 'rel', weight: 1 }],
};
const data = toForceGraphData(overview);
expect(data.nodes).toHaveLength(2);
expect(data.links).toEqual([
{ source: 'a', target: 'b', type: 'rel', weight: 1 },
]);
});
it('drops edges that reference nodes outside the bounded set', () => {
const overview: GraphOverview = {
nodes: [{ id: 'a', name: 'A', degree: 1 }],
edges: [{ source: 'a', target: 'ghost' }],
};
const data = toForceGraphData(overview);
expect(data.links).toEqual([]);
});
it('handles an empty graph', () => {
const data = toForceGraphData({ nodes: [], edges: [] });
expect(data).toEqual({ nodes: [], links: [] });
});
});
describe('maxDegree', () => {
it('returns the highest degree', () => {
expect(
maxDegree([
{ id: 'a', name: 'A', degree: 3 },
{ id: 'b', name: 'B', degree: 7 },
]),
).toBe(7);
});
it('returns 0 for an empty list', () => {
expect(maxDegree([])).toBe(0);
});
});
describe('nodeLabelEl', () => {
it('renders an untrusted name as text, not live HTML', () => {
const payload = '<img src=x onerror=alert(1)>';
const el = nodeLabelEl(payload);
expect(el.textContent).toBe(payload);
expect(el.querySelector('img')).toBeNull();
expect(el.innerHTML).not.toContain('<img');
});
it('handles a missing name', () => {
expect(nodeLabelEl('').textContent).toBe('');
});
});
describe('nodeRadius', () => {
it('returns the minimum radius when there is no spread', () => {
expect(nodeRadius(0, 0)).toBe(3);
});
it('scales monotonically with degree', () => {
const low = nodeRadius(1, 10);
const high = nodeRadius(9, 10);
expect(high).toBeGreaterThan(low);
expect(nodeRadius(10, 10)).toBeCloseTo(12);
});
});
+62
View File
@@ -0,0 +1,62 @@
export interface GraphNode {
id: string;
name: string;
type?: string | null;
description?: string | null;
degree: number;
}
export interface GraphEdge {
source: string;
target: string;
type?: string | null;
weight?: number | null;
}
export interface GraphOverview {
nodes: GraphNode[];
edges: GraphEdge[];
}
export interface GraphNodeChunk {
chunk_id: string;
text: string;
metadata?: Record<string, unknown>;
}
export interface GraphNodeDetail extends GraphNode {
doc_freq?: number;
chunks: GraphNodeChunk[];
}
export interface ForceGraphData {
nodes: GraphNode[];
links: GraphEdge[];
}
export function toForceGraphData(overview: GraphOverview): ForceGraphData {
const nodeIds = new Set(overview.nodes.map((node) => node.id));
const links = overview.edges.filter(
(edge) => nodeIds.has(edge.source) && nodeIds.has(edge.target),
);
return { nodes: overview.nodes, links };
}
const MIN_NODE_RADIUS = 3;
const MAX_NODE_RADIUS = 12;
export function nodeRadius(degree: number, maxDegree: number): number {
if (maxDegree <= 0) return MIN_NODE_RADIUS;
const scale = Math.sqrt(Math.max(0, degree) / maxDegree);
return MIN_NODE_RADIUS + scale * (MAX_NODE_RADIUS - MIN_NODE_RADIUS);
}
export function maxDegree(nodes: GraphNode[]): number {
return nodes.reduce((acc, node) => Math.max(acc, node.degree || 0), 0);
}
export function nodeLabelEl(name: string): HTMLDivElement {
const el = document.createElement('div');
el.textContent = name ?? '';
return el;
}
+10
View File
@@ -174,6 +174,16 @@
"forbidden": "You don't have permission to enable GraphRAG on this source.",
"conflict": "This source is still ingesting. Wait for it to finish before enabling GraphRAG."
}
},
"view": {
"action": "View graph",
"title": "Knowledge graph",
"explainer": "Nodes are entities and edges are their relationships. Click a node to see its description and the chunks it was extracted from.",
"empty": "No graph yet — extraction may still be running.",
"selectNode": "Select a node to see its details.",
"linkedChunks": "Linked chunks",
"noChunks": "No linked chunks for this entity.",
"close": "Close"
}
},
"editConfig": "Source settings",
+11 -2
View File
@@ -52,6 +52,7 @@ import FileTree from '../components/FileTree';
import ConnectorTree from '../components/ConnectorTree';
import Chunks from '../components/Chunks';
import WikiViewer from '../components/WikiViewer';
import GraphView from '../components/GraphView';
import ConvertToWikiModal from './ConvertToWikiModal';
import EnableGraphRAGModal from './EnableGraphRAGModal';
import SourceConfigModal from './SourceConfigModal';
@@ -355,10 +356,12 @@ export default function Sources({
document.ownership !== 'team' || document.team_access === 'editor';
const actions: SourceMenuOption[] = [
{
icon: EyeView,
icon: isGraphRAG ? Network : EyeView,
label: isWiki
? t('settings.sources.wiki.view')
: t('settings.sources.view'),
: isGraphRAG
? t('settings.sources.graphrag.view.action')
: t('settings.sources.view'),
onClick: () => {
setDocumentToView(document);
},
@@ -523,6 +526,12 @@ export default function Sources({
}
onBackToDocuments={() => setDocumentToView(undefined)}
/>
) : documentToView.config?.kind === 'graphrag' ? (
<GraphView
docId={documentToView.id || ''}
sourceName={documentToView.name}
onBackToDocuments={() => setDocumentToView(undefined)}
/>
) : documentToView.isNested ? (
documentToView.type === 'connector:file' ? (
<ConnectorTree
+269
View File
@@ -0,0 +1,269 @@
"""Tests for the GraphRAG graph-view routes in
application/api/user/sources/routes.py.
The endpoints are read-access gated (owner or team grant). The ``GraphStore`` is
mocked so no live vector store, embeddings, or LLM calls run; the ``sources`` row
is real so the authz lookup resolves. A separate suite exercises
``GraphStore.get_graph_overview`` against a live pgvector store (skipped when
unreachable) and asserts the SQL is parameterized via a mock cursor.
"""
import uuid
from contextlib import contextmanager
from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from application.storage.db.repositories.sources import SourcesRepository
@pytest.fixture
def app():
return Flask(__name__)
@contextmanager
def _patch_db(conn):
@contextmanager
def _yield():
yield conn
with patch(
"application.api.user.sources.routes.db_session", _yield
), patch(
"application.api.user.sources.routes.db_readonly", _yield
):
yield
def _grant_team_access(pg_conn, owner, member, source_id, access_level):
from application.storage.db.repositories.team_members import (
TeamMembersRepository,
)
from application.storage.db.repositories.team_resource_grants import (
TeamResourceGrantsRepository,
)
from application.storage.db.repositories.teams import TeamsRepository
team = TeamsRepository(pg_conn).create(
"Acme", f"acme-{uuid.uuid4().hex[:8]}", owner
)
TeamMembersRepository(pg_conn).add_member(
team["id"], member, role="team_member"
)
TeamResourceGrantsRepository(pg_conn).grant(
team["id"], "source", source_id, owner_id=owner, granted_by=owner,
access_level=access_level,
)
def _graphrag_source(pg_conn, user):
src = SourcesRepository(pg_conn).create(
"graph-src", user_id=user, type="file",
config={
"kind": "graphrag",
"retrieval": {"retriever": "graphrag"},
},
directory_structure={"a.md": {"type": "text/markdown"}},
)
return str(src["id"])
@pytest.mark.unit
class TestSourceGraph:
def test_returns_401_unauthenticated(self, app):
from application.api.user.sources.routes import SourceGraph
with app.test_request_context("/api/sources/x/graph"):
from flask import request
request.decoded_token = None
response = SourceGraph().get("x")
assert response.status_code == 401
def test_owner_gets_bounded_overview(self, app, pg_conn):
from application.api.user.sources.routes import SourceGraph
user = "u-graph-view-owner"
sid = _graphrag_source(pg_conn, user)
store = MagicMock()
store.count_nodes.return_value = 3
store.get_graph_overview.return_value = {
"nodes": [
{"id": "n1", "name": "A", "type": "person",
"description": "d", "degree": 2},
{"id": "n2", "name": "B", "type": "org",
"description": "e", "degree": 1},
],
"edges": [
{"source": "n1", "target": "n2", "type": "rel", "weight": 1.0},
],
}
with _patch_db(pg_conn), patch(
"application.graphrag.store.GraphStore", return_value=store
), app.test_request_context(
f"/api/sources/{sid}/graph?limit=9999"
):
from flask import request
request.decoded_token = {"sub": user}
response = SourceGraph().get(sid)
assert response.status_code == 200
assert response.json["success"] is True
assert {n["id"] for n in response.json["nodes"]} == {"n1", "n2"}
assert response.json["edges"][0]["source"] == "n1"
# The store receives the source's resolved id and the clamped limit.
args = store.get_graph_overview.call_args.args
assert args[0] == sid
# The route forwards the raw limit; clamping is the store's job (tested
# below) but a sane request limit must reach it.
assert args[1] == 9999
def test_empty_graph_returns_empty_lists(self, app, pg_conn):
from application.api.user.sources.routes import SourceGraph
user = "u-graph-view-empty"
sid = _graphrag_source(pg_conn, user)
store = MagicMock()
store.count_nodes.return_value = 0
with _patch_db(pg_conn), patch(
"application.graphrag.store.GraphStore", return_value=store
), app.test_request_context(f"/api/sources/{sid}/graph"):
from flask import request
request.decoded_token = {"sub": user}
response = SourceGraph().get(sid)
assert response.status_code == 200
assert response.json["nodes"] == []
assert response.json["edges"] == []
# No graph rows → never query the overview.
store.get_graph_overview.assert_not_called()
def test_non_owner_without_grant_404(self, app, pg_conn):
from application.api.user.sources.routes import SourceGraph
owner = "u-graph-view-owner2"
stranger = "u-graph-view-stranger"
sid = _graphrag_source(pg_conn, owner)
with _patch_db(pg_conn), patch(
"application.graphrag.store.GraphStore"
) as mock_store, app.test_request_context(
f"/api/sources/{sid}/graph"
):
from flask import request
request.decoded_token = {"sub": stranger}
response = SourceGraph().get(sid)
assert response.status_code == 404
mock_store.assert_not_called()
def test_team_viewer_can_read(self, app, pg_conn):
from application.api.user.sources.routes import SourceGraph
owner = "alice-graph-view"
viewer = "bob-graph-view-viewer"
sid = _graphrag_source(pg_conn, owner)
_grant_team_access(pg_conn, owner, viewer, sid, "viewer")
store = MagicMock()
store.count_nodes.return_value = 1
store.get_graph_overview.return_value = {
"nodes": [
{"id": "n1", "name": "A", "type": None,
"description": None, "degree": 0},
],
"edges": [],
}
with _patch_db(pg_conn), patch(
"application.graphrag.store.GraphStore", return_value=store
), app.test_request_context(f"/api/sources/{sid}/graph"):
from flask import request
request.decoded_token = {"sub": viewer}
response = SourceGraph().get(sid)
assert response.status_code == 200
assert {n["id"] for n in response.json["nodes"]} == {"n1"}
@pytest.mark.unit
class TestSourceGraphNode:
def test_returns_401_unauthenticated(self, app):
from application.api.user.sources.routes import SourceGraphNode
with app.test_request_context("/api/sources/x/graph/node/n"):
from flask import request
request.decoded_token = None
response = SourceGraphNode().get("x", "n")
assert response.status_code == 401
def test_owner_gets_node_detail_with_chunks(self, app, pg_conn):
from application.api.user.sources.routes import SourceGraphNode
user = "u-graph-node-owner"
sid = _graphrag_source(pg_conn, user)
store = MagicMock()
store.get_node_detail.return_value = {
"id": "n1",
"name": "Ada",
"type": "person",
"description": "A mathematician.",
"degree": 3,
"doc_freq": 2,
"chunks": [{"chunk_id": "5", "text": "body", "metadata": {}}],
}
with _patch_db(pg_conn), patch(
"application.graphrag.store.GraphStore", return_value=store
), app.test_request_context(f"/api/sources/{sid}/graph/node/n1"):
from flask import request
request.decoded_token = {"sub": user}
response = SourceGraphNode().get(sid, "n1")
assert response.status_code == 200
assert response.json["node"]["name"] == "Ada"
assert response.json["node"]["chunks"][0]["text"] == "body"
store.get_node_detail.assert_called_once_with(sid, "n1")
def test_unknown_node_404(self, app, pg_conn):
from application.api.user.sources.routes import SourceGraphNode
user = "u-graph-node-missing"
sid = _graphrag_source(pg_conn, user)
store = MagicMock()
store.get_node_detail.return_value = None
with _patch_db(pg_conn), patch(
"application.graphrag.store.GraphStore", return_value=store
), app.test_request_context(f"/api/sources/{sid}/graph/node/nope"):
from flask import request
request.decoded_token = {"sub": user}
response = SourceGraphNode().get(sid, "nope")
assert response.status_code == 404
def test_non_owner_without_grant_404(self, app, pg_conn):
from application.api.user.sources.routes import SourceGraphNode
owner = "u-graph-node-owner2"
stranger = "u-graph-node-stranger"
sid = _graphrag_source(pg_conn, owner)
with _patch_db(pg_conn), patch(
"application.graphrag.store.GraphStore"
) as mock_store, app.test_request_context(
f"/api/sources/{sid}/graph/node/n1"
):
from flask import request
request.decoded_token = {"sub": stranger}
response = SourceGraphNode().get(sid, "n1")
assert response.status_code == 404
mock_store.assert_not_called()
+63
View File
@@ -235,6 +235,50 @@ class TestGraphStoreLive:
finally:
store.delete_by_source(source_id)
def test_get_graph_overview_bounded_by_degree(self, store, source_id):
try:
hub = store.upsert_node(source_id, "Hub", "hub")
leaves = [
store.upsert_node(source_id, f"L{i}", f"l{i}") for i in range(4)
]
for leaf in leaves:
store.add_edge(source_id, hub, leaf, "rel")
store.set_node_degrees(source_id)
overview = store.get_graph_overview(source_id, limit=3)
node_ids = [n["id"] for n in overview["nodes"]]
assert len(node_ids) == 3
# The hub has the highest degree, so it must lead the bounded set.
assert node_ids[0] == hub
# Edges only connect nodes that survived the limit.
for edge in overview["edges"]:
assert edge["source"] in node_ids
assert edge["target"] in node_ids
finally:
store.delete_by_source(source_id)
def test_get_graph_overview_empty_source(self, store, source_id):
overview = store.get_graph_overview(source_id)
assert overview == {"nodes": [], "edges": []}
def test_get_node_detail_with_linked_chunks(self, store, source_id):
try:
node = store.upsert_node(
source_id, "Ada", "ada", "person", "A mathematician."
)
store.link_node_chunk(source_id, node, "chunk-1")
detail = store.get_node_detail(source_id, node)
assert detail is not None
assert detail["name"] == "Ada"
assert detail["description"] == "A mathematician."
chunk_ids = [c["chunk_id"] for c in detail["chunks"]]
assert "chunk-1" in chunk_ids
assert store.get_node_detail(source_id, str(uuid.uuid4())) is None
finally:
store.delete_by_source(source_id)
def test_checkpoint_pending_and_mark(self, store, source_id):
try:
all_chunks = ["c1", "c2", "c3"]
@@ -317,6 +361,25 @@ class TestGraphStoreParameterization:
assert str(embedding) not in sql
assert params == (embedding, sid, embedding, 5)
def test_graph_overview_binds_source_and_clamps_limit(self):
from application.graphrag.store import GRAPH_OVERVIEW_MAX_LIMIT
store, cursor = self._store_with_mock_conn()
cursor.fetchall.return_value = []
sid = str(uuid.uuid4())
store.get_graph_overview(sid, limit=10_000)
sql, params = (
cursor.execute.call_args.args[0],
cursor.execute.call_args.args[1],
)
assert "source_id = %s" in sql
assert sid not in sql
# An empty node fetch short-circuits; only the node query ran, and the
# limit is clamped to the hard cap before binding.
assert params == (sid, GRAPH_OVERVIEW_MAX_LIMIT)
def test_upsert_node_binds_all_values(self):
store, cursor = self._store_with_mock_conn()
sid = str(uuid.uuid4())