mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-13 12:14:00 +00:00
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:
1 parent
daddbeb062
commit
2594bb0bed
14 files changed
+1202
-6
No files matched your search
@@ -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)
|
||||
@@ -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)."""
|
||||
|
||||
Generated
+188
-2
@@ -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",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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> =>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
|
||||
Reference in new issue
Block a user