Ashutosh1975270 commited on
Commit
a67b1ec
·
1 Parent(s): 9b7dcbd

fix: resolve graph edges rendering, fallback query search, community generation timeouts, query session persistence, and new chat functionality

Browse files
backend/graphrag/services/community_detector.py CHANGED
@@ -88,14 +88,31 @@ class CommunityDetector:
88
  "relationship_count": relationship_count
89
  })
90
 
91
- # 6. Generate LLM labels and summaries for each community
92
- for comm in community_list:
93
- label_summary = self._generate_community_label_summary(comm)
94
- comm["label"] = label_summary.get("label", f"Community {comm['id']}")
95
- comm["summary"] = label_summary.get("summary", "")
96
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  logger.info("Detected %d communities for user: %s", len(community_list), user_id)
98
  cache.set(f"communities_{user_id}", community_list, COMMUNITY_CACHE_TTL)
 
 
 
 
 
 
 
 
99
  return community_list
100
 
101
  def _label_propagation(self, nodes: set, adjacency: dict, max_iterations: int = 20) -> Dict[int, set]:
@@ -214,15 +231,15 @@ class CommunityDetector:
214
  return None
215
 
216
  def get_all_communities(self, user_id: str) -> List[Dict]:
217
- """Returns all communities, using cache if available."""
218
- cached = cache.get(f"communities_{user_id}", [])
219
- if not cached:
220
- cached = self.detect_communities(user_id)
221
- return cached
222
 
223
  def get_document_summary(self, user_id: str) -> str:
 
 
 
 
224
  """Generate a document-level summary by combining all community summaries."""
225
- communities = self.get_all_communities(user_id)
226
  if not communities:
227
  return ""
228
 
@@ -235,7 +252,7 @@ class CommunityDetector:
235
  community_texts.append(f"**{label}** ({member_count} entities): {summary}")
236
 
237
  if not community_texts:
238
- return ""
239
 
240
  prompt = ChatPromptTemplate.from_messages([
241
  ("system", (
 
88
  "relationship_count": relationship_count
89
  })
90
 
91
+ # 6. Cache the raw structure immediately (so fast requests get data right away)
92
+ cache.set(f"communities_{user_id}", community_list, COMMUNITY_CACHE_TTL)
 
 
 
93
 
94
+ # 7. Generate LLM labels and summaries for each community (may be slow)
95
+ for comm in community_list:
96
+ try:
97
+ label_summary = self._generate_community_label_summary(comm)
98
+ comm["label"] = label_summary.get("label", f"Community {comm['id']}")
99
+ comm["summary"] = label_summary.get("summary", "")
100
+ except Exception as e:
101
+ logger.warning("LLM label generation failed for community %s: %s", comm['id'], str(e))
102
+ comm["label"] = f"Community {comm['id']}"
103
+ comm["summary"] = ""
104
+
105
+ # 8. Cache final labeled result
106
  logger.info("Detected %d communities for user: %s", len(community_list), user_id)
107
  cache.set(f"communities_{user_id}", community_list, COMMUNITY_CACHE_TTL)
108
+
109
+ # 9. Generate and cache document summary
110
+ try:
111
+ doc_summary = self._build_doc_summary_from_communities(community_list)
112
+ cache.set(f"doc_summary_{user_id}", doc_summary, COMMUNITY_CACHE_TTL)
113
+ except Exception:
114
+ pass
115
+
116
  return community_list
117
 
118
  def _label_propagation(self, nodes: set, adjacency: dict, max_iterations: int = 20) -> Dict[int, set]:
 
231
  return None
232
 
233
  def get_all_communities(self, user_id: str) -> List[Dict]:
234
+ """Returns all communities from cache only. Caller must trigger detect_communities() separately."""
235
+ return cache.get(f"communities_{user_id}", [])
 
 
 
236
 
237
  def get_document_summary(self, user_id: str) -> str:
238
+ """Returns cached document summary (never blocks on LLM)."""
239
+ return cache.get(f"doc_summary_{user_id}", "")
240
+
241
+ def _build_doc_summary_from_communities(self, communities: List[Dict]) -> str:
242
  """Generate a document-level summary by combining all community summaries."""
 
243
  if not communities:
244
  return ""
245
 
 
252
  community_texts.append(f"**{label}** ({member_count} entities): {summary}")
253
 
254
  if not community_texts:
255
+ return f"Document contains {len(communities)} topic clusters."
256
 
257
  prompt = ChatPromptTemplate.from_messages([
258
  ("system", (
backend/graphrag/services/graph_retriever.py CHANGED
@@ -39,15 +39,12 @@ class GraphRetriever:
39
  """
40
  Extracts entities from the query, traverses their Neo4j subgraphs,
41
  and returns a serialized text block representing the graph context.
 
42
  """
43
  logger.info("Retrieving graph context for query: '%s' (User: %s, Docs: %s)", query, user_id, doc_names)
44
 
45
  # 1. Extract entities from query using LLM
46
  query_entities = self._extract_entities_from_query(query)
47
- if not query_entities:
48
- logger.info("No entities extracted from user query. Returning empty graph context.")
49
- return ""
50
-
51
  logger.info("Extracted query entities: %s", query_entities)
52
 
53
  unique_nodes: Dict[str, dict] = {}
@@ -61,7 +58,65 @@ class GraphRetriever:
61
  except Exception as e:
62
  logger.error("Failed to query subgraph for entity: %s. Error: %s", entity_name, str(e))
63
 
64
- # 3. Serialize extracted graph information into a readable markdown string
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  if not unique_nodes:
66
  logger.info("No matching entities or paths found in the graph for query.")
67
  return ""
@@ -78,7 +133,7 @@ class GraphRetriever:
78
  context_lines.append(f"* {rel}")
79
 
80
  serialized_context = "\n".join(context_lines)
81
- logger.info("Generated graph context (%d characters).", len(serialized_context))
82
  return serialized_context
83
 
84
  def get_graph_as_json(self, user_id: str, doc_ids: List[str] = None) -> Dict[str, Any]:
 
39
  """
40
  Extracts entities from the query, traverses their Neo4j subgraphs,
41
  and returns a serialized text block representing the graph context.
42
+ Falls back to a keyword entity search if no exact entity match is found.
43
  """
44
  logger.info("Retrieving graph context for query: '%s' (User: %s, Docs: %s)", query, user_id, doc_names)
45
 
46
  # 1. Extract entities from query using LLM
47
  query_entities = self._extract_entities_from_query(query)
 
 
 
 
48
  logger.info("Extracted query entities: %s", query_entities)
49
 
50
  unique_nodes: Dict[str, dict] = {}
 
58
  except Exception as e:
59
  logger.error("Failed to query subgraph for entity: %s. Error: %s", entity_name, str(e))
60
 
61
+ # 3. FALLBACK: If no graph nodes found from entity extraction, try keyword search
62
+ # This handles broad queries like "list all persons" or "what companies are mentioned"
63
+ if not unique_nodes:
64
+ logger.info("No exact entity matches — falling back to keyword search across graph.")
65
+ try:
66
+ # Search across all entity names using the query words as keywords
67
+ keywords = [w for w in query.split() if len(w) > 3]
68
+ search_hits = []
69
+ for kw in keywords[:5]: # Limit to 5 keywords
70
+ results = self.neo4j_client.search_entities(kw, user_id, limit=10)
71
+ search_hits.extend(results)
72
+
73
+ # De-duplicate by name
74
+ seen = set()
75
+ for hit in search_hits:
76
+ name = hit.get("name", "")
77
+ if name and name not in seen:
78
+ seen.add(name)
79
+ unique_nodes[name] = {
80
+ "type": hit.get("type", "Unknown"),
81
+ "description": hit.get("description", "")
82
+ }
83
+
84
+ # If still nothing, do a broad entity-type match (e.g., "person" → fetch all PERSON nodes)
85
+ if not unique_nodes:
86
+ entity_type_map = {
87
+ "person": "PERSON", "people": "PERSON", "persons": "PERSON",
88
+ "company": "ORGANIZATION", "companies": "ORGANIZATION", "organizations": "ORGANIZATION",
89
+ "product": "PRODUCT", "products": "PRODUCT",
90
+ "technology": "TECHNOLOGY", "technologies": "TECHNOLOGY",
91
+ "location": "LOCATION", "locations": "LOCATION",
92
+ "event": "EVENT", "events": "EVENT",
93
+ "concept": "CONCEPT", "concepts": "CONCEPT",
94
+ }
95
+ query_lower = query.lower()
96
+ target_type = None
97
+ for kw, etype in entity_type_map.items():
98
+ if kw in query_lower:
99
+ target_type = etype
100
+ break
101
+
102
+ if target_type:
103
+ type_results = self.neo4j_client.execute_query(
104
+ "MATCH (e:Entity {user_id: $user_id, type: $type}) "
105
+ "RETURN e.name AS name, e.type AS type, e.description AS description "
106
+ "LIMIT 30",
107
+ {"user_id": str(user_id), "type": target_type}
108
+ )
109
+ for r in type_results:
110
+ name = r.get("name", "")
111
+ if name:
112
+ unique_nodes[name] = {
113
+ "type": r.get("type", "Unknown"),
114
+ "description": r.get("description", "")
115
+ }
116
+ except Exception as e:
117
+ logger.error("Keyword fallback search failed: %s", str(e))
118
+
119
+ # 4. Serialize extracted graph information into a readable markdown string
120
  if not unique_nodes:
121
  logger.info("No matching entities or paths found in the graph for query.")
122
  return ""
 
133
  context_lines.append(f"* {rel}")
134
 
135
  serialized_context = "\n".join(context_lines)
136
+ logger.info("Generated graph context (%d characters, %d entities).", len(serialized_context), len(unique_nodes))
137
  return serialized_context
138
 
139
  def get_graph_as_json(self, user_id: str, doc_ids: List[str] = None) -> Dict[str, Any]:
backend/graphrag/services/neo4j_client.py CHANGED
@@ -270,12 +270,13 @@ class Neo4jClient:
270
  " e.source_doc AS source_doc, e.source_doc_id AS source_doc_id, e.page AS page "
271
  "LIMIT 500"
272
  )
 
273
  edges_query = (
274
  "MATCH (s:Entity {user_id: $user_id})-[r]->(t:Entity {user_id: $user_id}) "
275
- "WHERE r.source_doc_id IN $doc_ids "
276
  "RETURN s.name AS source, t.name AS target, "
277
  " type(r) AS relationship_type, r.description AS description, "
278
- " r.confidence AS confidence, r.source_doc AS source_doc, r.source_doc_id AS source_doc_id "
279
  "LIMIT 1000"
280
  )
281
  params = {"user_id": str(user_id), "doc_ids": [str(d) for d in doc_ids]}
@@ -286,11 +287,12 @@ class Neo4jClient:
286
  " e.source_doc AS source_doc, e.source_doc_id AS source_doc_id, e.page AS page "
287
  "LIMIT 500"
288
  )
 
289
  edges_query = (
290
  "MATCH (s:Entity {user_id: $user_id})-[r]->(t:Entity {user_id: $user_id}) "
291
  "RETURN s.name AS source, t.name AS target, "
292
  " type(r) AS relationship_type, r.description AS description, "
293
- " r.confidence AS confidence, r.source_doc AS source_doc, r.source_doc_id AS source_doc_id "
294
  "LIMIT 1000"
295
  )
296
  params = {"user_id": str(user_id)}
@@ -298,6 +300,7 @@ class Neo4jClient:
298
  try:
299
  nodes = self.execute_query(nodes_query, params)
300
  edges = self.execute_query(edges_query, params)
 
301
  return {"nodes": nodes, "edges": edges}
302
  except Exception as e:
303
  logger.error("Failed to get all graph data: %s", str(e))
 
270
  " e.source_doc AS source_doc, e.source_doc_id AS source_doc_id, e.page AS page "
271
  "LIMIT 500"
272
  )
273
+ # Filter edges by checking that BOTH endpoint nodes belong to the filtered docs
274
  edges_query = (
275
  "MATCH (s:Entity {user_id: $user_id})-[r]->(t:Entity {user_id: $user_id}) "
276
+ "WHERE s.source_doc_id IN $doc_ids AND t.source_doc_id IN $doc_ids "
277
  "RETURN s.name AS source, t.name AS target, "
278
  " type(r) AS relationship_type, r.description AS description, "
279
+ " r.confidence AS confidence, r.source_doc AS source_doc "
280
  "LIMIT 1000"
281
  )
282
  params = {"user_id": str(user_id), "doc_ids": [str(d) for d in doc_ids]}
 
287
  " e.source_doc AS source_doc, e.source_doc_id AS source_doc_id, e.page AS page "
288
  "LIMIT 500"
289
  )
290
+ # No doc filter — return ALL edges between user's nodes
291
  edges_query = (
292
  "MATCH (s:Entity {user_id: $user_id})-[r]->(t:Entity {user_id: $user_id}) "
293
  "RETURN s.name AS source, t.name AS target, "
294
  " type(r) AS relationship_type, r.description AS description, "
295
+ " r.confidence AS confidence, r.source_doc AS source_doc "
296
  "LIMIT 1000"
297
  )
298
  params = {"user_id": str(user_id)}
 
300
  try:
301
  nodes = self.execute_query(nodes_query, params)
302
  edges = self.execute_query(edges_query, params)
303
+ logger.info("Graph data fetched: %d nodes, %d edges for user %s", len(nodes), len(edges), user_id)
304
  return {"nodes": nodes, "edges": edges}
305
  except Exception as e:
306
  logger.error("Failed to get all graph data: %s", str(e))
backend/graphrag/views.py CHANGED
@@ -509,6 +509,7 @@ class QueryCompareView(APIView):
509
  elapsed = time.time() - start
510
  return mode, {
511
  "answer": result.get("answer", ""),
 
512
  "sources": result.get("sources", []),
513
  "strategy": result.get("strategy", mode.upper()),
514
  "response_time": round(elapsed, 3),
@@ -694,27 +695,55 @@ class CommunityListView(APIView):
694
 
695
  def get(self, request):
696
  try:
697
- detector = CommunityDetector()
698
- communities = detector.get_all_communities(request.user.id)
699
- doc_summary = detector.get_document_summary(request.user.id)
 
 
 
 
 
 
 
 
 
 
 
 
700
 
701
- # Simplify response — don't send full member_details in list
 
 
 
 
 
 
 
 
 
 
 
702
  summary_list = []
703
- for comm in communities:
704
  summary_list.append({
705
- "id": comm["id"],
706
- "label": comm.get("label", ""),
707
  "summary": comm.get("summary", ""),
708
- "member_count": comm["member_count"],
709
  "relationship_count": comm.get("relationship_count", 0),
710
- "members": comm["members"]
711
  })
712
 
 
 
 
713
  return Response({
714
  "communities": summary_list,
715
  "count": len(summary_list),
716
- "document_summary": doc_summary
 
717
  }, status=status.HTTP_200_OK)
 
718
  except Exception as e:
719
  logger.error("Error in CommunityListView: %s", str(e), exc_info=True)
720
  return Response(
 
509
  elapsed = time.time() - start
510
  return mode, {
511
  "answer": result.get("answer", ""),
512
+ "context": result.get("context", ""),
513
  "sources": result.get("sources", []),
514
  "strategy": result.get("strategy", mode.upper()),
515
  "response_time": round(elapsed, 3),
 
695
 
696
  def get(self, request):
697
  try:
698
+ from django.core.cache import cache
699
+ user_id = str(request.user.id)
700
+ cache_key = f"communities_{user_id}"
701
+
702
+ # Return cached communities immediately
703
+ cached = cache.get(cache_key, [])
704
+
705
+ if not cached:
706
+ # Cache is empty — trigger background detection and return loading state
707
+ def _detect_in_background():
708
+ try:
709
+ detector = CommunityDetector()
710
+ detector.detect_communities(user_id)
711
+ except Exception as bg_err:
712
+ logger.error("Background community detection failed: %s", str(bg_err))
713
 
714
+ bg_thread = threading.Thread(target=_detect_in_background, daemon=True)
715
+ bg_thread.start()
716
+
717
+ return Response({
718
+ "communities": [],
719
+ "count": 0,
720
+ "document_summary": "",
721
+ "loading": True,
722
+ "message": "Communities are being generated. Please refresh in 30 seconds."
723
+ }, status=status.HTTP_200_OK)
724
+
725
+ # Build response from cache
726
  summary_list = []
727
+ for comm in cached:
728
  summary_list.append({
729
+ "id": comm.get("id", 0),
730
+ "label": comm.get("label", f"Community {comm.get('id', '?')}"),
731
  "summary": comm.get("summary", ""),
732
+ "member_count": comm.get("member_count", 0),
733
  "relationship_count": comm.get("relationship_count", 0),
734
+ "members": comm.get("members", []),
735
  })
736
 
737
+ # Get document summary from cache (fast — no LLM call)
738
+ doc_summary = cache.get(f"doc_summary_{user_id}", "")
739
+
740
  return Response({
741
  "communities": summary_list,
742
  "count": len(summary_list),
743
+ "document_summary": doc_summary,
744
+ "loading": False
745
  }, status=status.HTTP_200_OK)
746
+
747
  except Exception as e:
748
  logger.error("Error in CommunityListView: %s", str(e), exc_info=True)
749
  return Response(
frontend/src/components/communities/CommunityView.tsx CHANGED
@@ -29,28 +29,50 @@ export function CommunityView() {
29
  const setHighlighted = useGraphStore((s) => s.setHighlighted);
30
 
31
  useEffect(() => {
32
- setLoading(true);
33
- api
34
- .get('/graph/communities/')
35
- .then(({ data }) => {
36
- const c = data.communities || data.results || data;
37
- if (Array.isArray(c) && c.length) {
38
- setCommunities(
39
- c.map((x: any) => ({
40
- id: x.id,
41
- label: x.label || `Community ${x.id}`,
42
- entityCount: x.member_count ?? x.entity_count ?? x.entityCount ?? 0,
43
- relationshipCount: x.relationship_count ?? x.relationshipCount ?? 0,
44
- summary: x.summary || '',
45
- keyEntities: x.members ?? x.key_entities ?? x.keyEntities ?? [],
46
- }))
47
- );
48
- setDocSummary(data.document_summary || data.summary || '');
49
- }
50
- setFetchError(null);
51
- })
52
- .catch(() => setFetchError('Failed to load communities. Backend may be unavailable.'))
53
- .finally(() => setLoading(false));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  }, []);
55
 
56
  function expand(id: number) {
@@ -95,7 +117,11 @@ export function CommunityView() {
95
 
96
  <TabsContent value="cards" className="mt-0 flex-1 overflow-y-auto scrollbar-thin">
97
  {loading ? (
98
- <div className="flex items-center justify-center py-12 text-sm text-text-muted">Loading communities...</div>
 
 
 
 
99
  ) : communities.length === 0 ? (
100
  <div className="flex items-center justify-center py-12 text-sm text-text-muted">No communities found. Upload documents to generate communities.</div>
101
  ) : (
 
29
  const setHighlighted = useGraphStore((s) => s.setHighlighted);
30
 
31
  useEffect(() => {
32
+ let pollTimer: ReturnType<typeof setTimeout>;
33
+
34
+ function fetchCommunities() {
35
+ setLoading(true);
36
+ api
37
+ .get('/graph/communities/')
38
+ .then(({ data }) => {
39
+ const isGenerating = data.loading === true;
40
+
41
+ if (isGenerating) {
42
+ // Backend is generating in background poll again in 15s
43
+ setLoading(true);
44
+ pollTimer = setTimeout(fetchCommunities, 15000);
45
+ return;
46
+ }
47
+
48
+ const c = data.communities || data.results || data;
49
+ if (Array.isArray(c) && c.length) {
50
+ setCommunities(
51
+ c.map((x: any) => ({
52
+ id: x.id,
53
+ label: x.label || `Community ${x.id}`,
54
+ entityCount: x.member_count ?? x.entity_count ?? x.entityCount ?? 0,
55
+ relationshipCount: x.relationship_count ?? x.relationshipCount ?? 0,
56
+ summary: x.summary || '',
57
+ keyEntities: x.members ?? x.key_entities ?? x.keyEntities ?? [],
58
+ }))
59
+ );
60
+ setDocSummary(data.document_summary || data.summary || '');
61
+ }
62
+ setFetchError(null);
63
+ setLoading(false);
64
+ })
65
+ .catch(() => {
66
+ setFetchError('Failed to load communities. Backend may be unavailable.');
67
+ setLoading(false);
68
+ });
69
+ }
70
+
71
+ fetchCommunities();
72
+
73
+ return () => {
74
+ if (pollTimer) clearTimeout(pollTimer);
75
+ };
76
  }, []);
77
 
78
  function expand(id: number) {
 
117
 
118
  <TabsContent value="cards" className="mt-0 flex-1 overflow-y-auto scrollbar-thin">
119
  {loading ? (
120
+ <div className="flex flex-col items-center justify-center gap-3 py-12">
121
+ <div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-violet border-t-transparent" />
122
+ <p className="text-sm text-text-muted">Generating communities from your knowledge graph…</p>
123
+ <p className="text-xs text-text-muted">This may take up to 30 seconds. Page will auto-refresh.</p>
124
+ </div>
125
  ) : communities.length === 0 ? (
126
  <div className="flex items-center justify-center py-12 text-sm text-text-muted">No communities found. Upload documents to generate communities.</div>
127
  ) : (
frontend/src/components/compare/ComparisonView.tsx CHANGED
@@ -78,19 +78,19 @@ export function ComparisonView() {
78
  answer: comps.graph?.answer || '',
79
  confidence: Math.round((comps.graph?.confidence ?? 0) * 100),
80
  timeMs: Math.round((comps.graph?.response_time ?? 0) * 1000),
81
- context: comps.graph?.strategy || 'GRAPH',
82
  },
83
  vector: {
84
  answer: comps.vector?.answer || '',
85
  confidence: Math.round((comps.vector?.confidence ?? 0) * 100),
86
  timeMs: Math.round((comps.vector?.response_time ?? 0) * 1000),
87
- context: comps.vector?.strategy || 'VECTOR',
88
  },
89
  hybrid: {
90
  answer: comps.hybrid?.answer || '',
91
  confidence: Math.round((comps.hybrid?.confidence ?? 0) * 100),
92
  timeMs: Math.round((comps.hybrid?.response_time ?? 0) * 1000),
93
- context: comps.hybrid?.strategy || 'HYBRID',
94
  },
95
  },
96
  verdict: res.verdict || generateVerdict(comps),
 
78
  answer: comps.graph?.answer || '',
79
  confidence: Math.round((comps.graph?.confidence ?? 0) * 100),
80
  timeMs: Math.round((comps.graph?.response_time ?? 0) * 1000),
81
+ context: comps.graph?.context || comps.graph?.strategy || 'GRAPH',
82
  },
83
  vector: {
84
  answer: comps.vector?.answer || '',
85
  confidence: Math.round((comps.vector?.confidence ?? 0) * 100),
86
  timeMs: Math.round((comps.vector?.response_time ?? 0) * 1000),
87
+ context: comps.vector?.context || comps.vector?.strategy || 'VECTOR',
88
  },
89
  hybrid: {
90
  answer: comps.hybrid?.answer || '',
91
  confidence: Math.round((comps.hybrid?.confidence ?? 0) * 100),
92
  timeMs: Math.round((comps.hybrid?.response_time ?? 0) * 1000),
93
+ context: comps.hybrid?.context || comps.hybrid?.strategy || 'HYBRID',
94
  },
95
  },
96
  verdict: res.verdict || generateVerdict(comps),
frontend/src/components/dashboard/MainQueryView.tsx CHANGED
@@ -1,6 +1,6 @@
1
  'use client';
2
 
3
- import { useState, useEffect } from 'react';
4
  import { useGraphData } from '@/hooks/useGraphData';
5
  import { useGraphStore } from '@/store/graph';
6
  import { QueryPanel, QueryMode } from '@/components/query/QueryPanel';
@@ -10,10 +10,11 @@ import { SourceToggle } from '@/components/query/SourceToggle';
10
  import { QueryHistory } from '@/components/query/QueryHistory';
11
  import { EntityPanel } from '@/components/entities/EntityPanel';
12
  import { GraphVisualization } from '@/components/graph/GraphVisualization';
13
- import { Upload, Sparkles } from 'lucide-react';
14
  import Link from 'next/link';
15
  import api from '@/lib/axios';
16
  import { useDocumentsStore } from '@/store/documents';
 
17
 
18
  interface QueryResult {
19
  answer: string;
@@ -26,40 +27,83 @@ interface QueryResult {
26
  context?: string;
27
  }
28
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  export function MainQueryView() {
30
  useGraphData();
31
  const setHighlighted = useGraphStore((s) => s.setHighlighted);
32
  const selectEntity = useGraphStore((s) => s.selectEntity);
33
  const data = useGraphStore((s) => s.data);
34
-
35
  const selectedIds = useDocumentsStore((s) => s.selectedDocumentIds);
 
36
 
 
37
  const [query, setQuery] = useState('');
38
  const [mode, setMode] = useState<QueryMode>('hybrid');
39
  const [loading, setLoading] = useState(false);
40
  const [result, setResult] = useState<QueryResult | null>(null);
41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  // Listen for right-click "Ask about entity" events from graph
43
  useEffect(() => {
44
  function handleAskEntity(e: Event) {
45
  const name = (e as CustomEvent).detail;
46
- if (name) {
47
- setQuery(name);
48
- }
49
  }
50
  window.addEventListener('graphrag:ask-entity', handleAskEntity);
51
  return () => window.removeEventListener('graphrag:ask-entity', handleAskEntity);
52
  }, []);
53
 
54
- async function runQuery() {
55
- if (!query.trim()) return;
 
56
  setLoading(true);
57
  setResult(null);
58
  try {
59
  const { data: res } = await api.post('/query/', {
60
- query,
61
  mode,
62
- document_ids: selectedIds.length > 0 ? selectedIds : undefined
63
  });
64
  const mapped: QueryResult = {
65
  answer: res.answer,
@@ -73,35 +117,83 @@ export function MainQueryView() {
73
  };
74
  setResult(mapped);
75
  setHighlighted(mapped.entities || [], mapped.paths || []);
76
- } catch (err: any) {
77
- const errorMsg = err.response?.data?.error || 'Failed to process query. Please check your API keys and Neo4j connection.';
78
- setResult({
79
- answer: `**Error:** ${errorMsg}`,
80
- method: mode,
 
 
 
 
81
  });
 
 
 
 
 
 
 
 
 
82
  } finally {
83
  setLoading(false);
84
  }
85
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
 
87
  function askEntity(name: string) {
88
  setQuery(name);
89
  selectEntity(null);
90
- setTimeout(runQuery, 50);
91
  }
92
 
93
  const empty = data.nodes.length === 0;
94
 
95
  return (
96
  <div className="flex h-full flex-col lg:flex-row">
 
97
  <div className="flex w-full flex-col border-b border-border lg:w-2/5 lg:border-b-0 lg:border-r">
 
 
 
 
 
 
 
 
 
 
 
98
  <div className="flex-1 space-y-4 overflow-y-auto p-4 scrollbar-thin">
99
  <QueryPanel
100
  value={query}
101
  onChange={setQuery}
102
  mode={mode}
103
  onModeChange={setMode}
104
- onSubmit={runQuery}
105
  loading={loading}
106
  />
107
 
@@ -137,7 +229,7 @@ export function MainQueryView() {
137
  )}
138
  </div>
139
 
140
- {!empty && <QueryHistory onSelect={setQuery} />}
141
 
142
  {result && (
143
  <SourceToggle
@@ -149,6 +241,7 @@ export function MainQueryView() {
149
  )}
150
  </div>
151
 
 
152
  <div className="relative w-full flex-1">
153
  {data.nodes.length > 0 ? (
154
  <GraphVisualization />
 
1
  'use client';
2
 
3
+ import { useState, useEffect, useCallback } from 'react';
4
  import { useGraphData } from '@/hooks/useGraphData';
5
  import { useGraphStore } from '@/store/graph';
6
  import { QueryPanel, QueryMode } from '@/components/query/QueryPanel';
 
10
  import { QueryHistory } from '@/components/query/QueryHistory';
11
  import { EntityPanel } from '@/components/entities/EntityPanel';
12
  import { GraphVisualization } from '@/components/graph/GraphVisualization';
13
+ import { Upload, Sparkles, PlusCircle } from 'lucide-react';
14
  import Link from 'next/link';
15
  import api from '@/lib/axios';
16
  import { useDocumentsStore } from '@/store/documents';
17
+ import { useHistoryStore, HistoryItem } from '@/store/history';
18
 
19
  interface QueryResult {
20
  answer: string;
 
27
  context?: string;
28
  }
29
 
30
+ // Persist last query session in localStorage
31
+ const LAST_SESSION_KEY = 'graphrag-last-session';
32
+
33
+ function loadLastSession(): { query: string; mode: QueryMode; result: QueryResult | null } | null {
34
+ try {
35
+ const raw = localStorage.getItem(LAST_SESSION_KEY);
36
+ return raw ? JSON.parse(raw) : null;
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ function saveLastSession(query: string, mode: QueryMode, result: QueryResult | null) {
43
+ try {
44
+ localStorage.setItem(LAST_SESSION_KEY, JSON.stringify({ query, mode, result }));
45
+ } catch {}
46
+ }
47
+
48
+ function clearLastSession() {
49
+ try {
50
+ localStorage.removeItem(LAST_SESSION_KEY);
51
+ } catch {}
52
+ }
53
+
54
  export function MainQueryView() {
55
  useGraphData();
56
  const setHighlighted = useGraphStore((s) => s.setHighlighted);
57
  const selectEntity = useGraphStore((s) => s.selectEntity);
58
  const data = useGraphStore((s) => s.data);
 
59
  const selectedIds = useDocumentsStore((s) => s.selectedDocumentIds);
60
+ const addHistoryItem = useHistoryStore((s) => s.addItem);
61
 
62
+ // Restore last session state from localStorage
63
  const [query, setQuery] = useState('');
64
  const [mode, setMode] = useState<QueryMode>('hybrid');
65
  const [loading, setLoading] = useState(false);
66
  const [result, setResult] = useState<QueryResult | null>(null);
67
 
68
+ useEffect(() => {
69
+ const session = loadLastSession();
70
+ if (session) {
71
+ setQuery(session.query);
72
+ setMode(session.mode);
73
+ setResult(session.result);
74
+ if (session.result?.entities) {
75
+ setHighlighted(session.result.entities, session.result.paths || []);
76
+ }
77
+ }
78
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
79
+
80
+ // Persist session whenever result changes
81
+ useEffect(() => {
82
+ if (query || result) {
83
+ saveLastSession(query, mode, result);
84
+ }
85
+ }, [query, mode, result]);
86
+
87
  // Listen for right-click "Ask about entity" events from graph
88
  useEffect(() => {
89
  function handleAskEntity(e: Event) {
90
  const name = (e as CustomEvent).detail;
91
+ if (name) setQuery(name);
 
 
92
  }
93
  window.addEventListener('graphrag:ask-entity', handleAskEntity);
94
  return () => window.removeEventListener('graphrag:ask-entity', handleAskEntity);
95
  }, []);
96
 
97
+ const runQuery = useCallback(async (overrideQuery?: string) => {
98
+ const q = overrideQuery ?? query;
99
+ if (!q.trim()) return;
100
  setLoading(true);
101
  setResult(null);
102
  try {
103
  const { data: res } = await api.post('/query/', {
104
+ query: q,
105
  mode,
106
+ document_ids: selectedIds.length > 0 ? selectedIds : undefined,
107
  });
108
  const mapped: QueryResult = {
109
  answer: res.answer,
 
117
  };
118
  setResult(mapped);
119
  setHighlighted(mapped.entities || [], mapped.paths || []);
120
+
121
+ // Add to local history store immediately
122
+ addHistoryItem({
123
+ id: Date.now().toString(),
124
+ query_text: q,
125
+ retrieval_mode: (res.strategy || mode).toUpperCase(),
126
+ answer_text: res.answer || '',
127
+ response_time: res.response_time ?? 0,
128
+ created_at: new Date().toISOString(),
129
  });
130
+ } catch (err: any) {
131
+ const backendError = err.response?.data?.error;
132
+ let errorMsg = backendError || 'Failed to process query. Please check your API keys and Neo4j connection.';
133
+
134
+ // Friendly message for 401 (session expired)
135
+ if (err.response?.status === 401) {
136
+ errorMsg = 'Your session has expired. Please log in again.';
137
+ }
138
+ setResult({ answer: `**Error:** ${errorMsg}`, method: mode });
139
  } finally {
140
  setLoading(false);
141
  }
142
+ }, [query, mode, selectedIds, setHighlighted, addHistoryItem]);
143
+
144
+ // New Chat — clear everything
145
+ const handleNewChat = useCallback(() => {
146
+ setQuery('');
147
+ setResult(null);
148
+ setHighlighted([], []);
149
+ selectEntity(null);
150
+ clearLastSession();
151
+ }, [setHighlighted, selectEntity]);
152
+
153
+ // History item selected — restore query + answer
154
+ const handleHistorySelect = useCallback((queryText: string, item?: HistoryItem) => {
155
+ setQuery(queryText);
156
+ if (item?.answer_text) {
157
+ const modeFromItem = (item.retrieval_mode || 'hybrid').toLowerCase() as QueryMode;
158
+ setMode(modeFromItem);
159
+ setResult({
160
+ answer: item.answer_text,
161
+ method: item.retrieval_mode,
162
+ confidence: undefined,
163
+ });
164
+ }
165
+ }, []);
166
 
167
  function askEntity(name: string) {
168
  setQuery(name);
169
  selectEntity(null);
170
+ setTimeout(() => runQuery(name), 50);
171
  }
172
 
173
  const empty = data.nodes.length === 0;
174
 
175
  return (
176
  <div className="flex h-full flex-col lg:flex-row">
177
+ {/* Left Panel: Query + Answer */}
178
  <div className="flex w-full flex-col border-b border-border lg:w-2/5 lg:border-b-0 lg:border-r">
179
+ {/* New Chat button */}
180
+ <div className="flex items-center justify-end border-b border-border px-4 py-2">
181
+ <button
182
+ onClick={handleNewChat}
183
+ className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium text-text-muted hover:bg-bg-surface hover:text-text-primary transition-colors"
184
+ >
185
+ <PlusCircle className="h-3.5 w-3.5" />
186
+ New Chat
187
+ </button>
188
+ </div>
189
+
190
  <div className="flex-1 space-y-4 overflow-y-auto p-4 scrollbar-thin">
191
  <QueryPanel
192
  value={query}
193
  onChange={setQuery}
194
  mode={mode}
195
  onModeChange={setMode}
196
+ onSubmit={() => runQuery()}
197
  loading={loading}
198
  />
199
 
 
229
  )}
230
  </div>
231
 
232
+ {!empty && <QueryHistory onSelect={handleHistorySelect} />}
233
 
234
  {result && (
235
  <SourceToggle
 
241
  )}
242
  </div>
243
 
244
+ {/* Right Panel: Graph */}
245
  <div className="relative w-full flex-1">
246
  {data.nodes.length > 0 ? (
247
  <GraphVisualization />
frontend/src/components/query/QueryHistory.tsx CHANGED
@@ -1,12 +1,13 @@
1
  'use client';
2
 
3
  import { useEffect, useState } from 'react';
4
- import { History, RotateCcw, ChevronDown, ChevronUp } from 'lucide-react';
5
  import { useHistoryStore, HistoryItem } from '@/store/history';
6
  import api from '@/lib/axios';
 
7
 
8
  interface QueryHistoryProps {
9
- onSelect: (query: string) => void;
10
  }
11
 
12
  export function QueryHistory({ onSelect }: QueryHistoryProps) {
@@ -14,29 +15,31 @@ export function QueryHistory({ onSelect }: QueryHistoryProps) {
14
  const [open, setOpen] = useState(false);
15
  const [loading, setLoading] = useState(false);
16
 
 
17
  useEffect(() => {
18
- if (loaded) return;
19
  setLoading(true);
20
  api
21
  .get('/query/history/')
22
  .then(({ data }) => {
23
  const list = data.results || data || [];
24
  if (Array.isArray(list)) {
25
- setItems(list.map((item: any) => ({
26
- id: item.id,
27
- query_text: item.query_text,
28
- retrieval_mode: item.retrieval_mode,
29
- answer_text: item.answer_text,
30
- response_time: item.response_time,
31
- created_at: item.created_at,
32
- })));
 
 
33
  }
34
  })
35
  .catch(() => {})
36
  .finally(() => setLoading(false));
37
- }, [loaded, setItems]);
38
 
39
- if (!loaded && !loading) return null;
40
 
41
  return (
42
  <div className="border-t border-border">
@@ -46,25 +49,41 @@ export function QueryHistory({ onSelect }: QueryHistoryProps) {
46
  >
47
  <History className="h-3.5 w-3.5" />
48
  Recent Queries ({items.length})
49
- {open ? <ChevronDown className="ml-auto h-3.5 w-3.5" /> : <ChevronUp className="ml-auto h-3.5 w-3.5" />}
 
 
 
 
50
  </button>
 
51
  {open && (
52
- <div className="max-h-48 overflow-y-auto border-t border-border scrollbar-thin">
53
  {items.length === 0 ? (
54
  <p className="px-4 py-3 text-xs text-text-muted">No queries yet.</p>
55
  ) : (
56
  items.map((item) => (
57
  <button
58
  key={item.id}
59
- onClick={() => { onSelect(item.query_text); setOpen(false); }}
60
- className="flex w-full items-start gap-2 px-4 py-2 text-left hover:bg-bg-surface transition-colors"
61
  >
62
- <RotateCcw className="mt-0.5 h-3 w-3 shrink-0 text-text-muted" />
63
  <div className="min-w-0 flex-1">
64
  <p className="truncate text-xs font-medium text-text-primary">{item.query_text}</p>
65
- <p className="text-[10px] text-text-muted">
66
- {item.retrieval_mode} · {(item.response_time * 1000).toFixed(0)}ms
67
- </p>
 
 
 
 
 
 
 
 
 
 
 
68
  </div>
69
  </button>
70
  ))
 
1
  'use client';
2
 
3
  import { useEffect, useState } from 'react';
4
+ import { History, RotateCcw, ChevronDown, ChevronUp, Clock } from 'lucide-react';
5
  import { useHistoryStore, HistoryItem } from '@/store/history';
6
  import api from '@/lib/axios';
7
+ import { cn } from '@/lib/utils';
8
 
9
  interface QueryHistoryProps {
10
+ onSelect: (query: string, item?: HistoryItem) => void;
11
  }
12
 
13
  export function QueryHistory({ onSelect }: QueryHistoryProps) {
 
15
  const [open, setOpen] = useState(false);
16
  const [loading, setLoading] = useState(false);
17
 
18
+ // Sync fresh history from backend on mount (but items from localStorage are already shown)
19
  useEffect(() => {
 
20
  setLoading(true);
21
  api
22
  .get('/query/history/')
23
  .then(({ data }) => {
24
  const list = data.results || data || [];
25
  if (Array.isArray(list)) {
26
+ setItems(
27
+ list.map((item: any) => ({
28
+ id: item.id,
29
+ query_text: item.query_text,
30
+ retrieval_mode: item.retrieval_mode,
31
+ answer_text: item.answer_text,
32
+ response_time: item.response_time,
33
+ created_at: item.created_at,
34
+ }))
35
+ );
36
  }
37
  })
38
  .catch(() => {})
39
  .finally(() => setLoading(false));
40
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
41
 
42
+ if (items.length === 0 && !loading) return null;
43
 
44
  return (
45
  <div className="border-t border-border">
 
49
  >
50
  <History className="h-3.5 w-3.5" />
51
  Recent Queries ({items.length})
52
+ {open ? (
53
+ <ChevronUp className="ml-auto h-3.5 w-3.5" />
54
+ ) : (
55
+ <ChevronDown className="ml-auto h-3.5 w-3.5" />
56
+ )}
57
  </button>
58
+
59
  {open && (
60
+ <div className="max-h-56 overflow-y-auto border-t border-border scrollbar-thin">
61
  {items.length === 0 ? (
62
  <p className="px-4 py-3 text-xs text-text-muted">No queries yet.</p>
63
  ) : (
64
  items.map((item) => (
65
  <button
66
  key={item.id}
67
+ onClick={() => { onSelect(item.query_text, item); setOpen(false); }}
68
+ className="flex w-full items-start gap-2 px-4 py-2.5 text-left hover:bg-bg-surface transition-colors group"
69
  >
70
+ <RotateCcw className="mt-0.5 h-3 w-3 shrink-0 text-text-muted group-hover:text-accent-violet transition-colors" />
71
  <div className="min-w-0 flex-1">
72
  <p className="truncate text-xs font-medium text-text-primary">{item.query_text}</p>
73
+ <div className="mt-0.5 flex items-center gap-2">
74
+ <span className={cn(
75
+ 'rounded px-1 py-px text-[9px] font-semibold uppercase',
76
+ item.retrieval_mode === 'HYBRID' ? 'bg-accent-violet/20 text-accent-violet' :
77
+ item.retrieval_mode === 'GRAPH' ? 'bg-accent-cyan/20 text-accent-cyan' :
78
+ 'bg-emerald-500/20 text-emerald-400'
79
+ )}>
80
+ {item.retrieval_mode}
81
+ </span>
82
+ <span className="flex items-center gap-1 text-[10px] text-text-muted">
83
+ <Clock className="h-2.5 w-2.5" />
84
+ {(item.response_time * 1000).toFixed(0)}ms
85
+ </span>
86
+ </div>
87
  </div>
88
  </button>
89
  ))
frontend/src/store/history.ts CHANGED
@@ -1,4 +1,5 @@
1
  import { create } from 'zustand';
 
2
 
3
  export interface HistoryItem {
4
  id: string;
@@ -15,13 +16,22 @@ interface HistoryState {
15
  setItems: (items: HistoryItem[]) => void;
16
  addItem: (item: HistoryItem) => void;
17
  clear: () => void;
 
18
  }
19
 
20
- export const useHistoryStore = create<HistoryState>((set) => ({
21
- items: [],
22
- loaded: false,
23
- setItems: (items) => set({ items, loaded: true }),
24
- addItem: (item) =>
25
- set((state) => ({ items: [item, ...state.items].slice(0, 50) })),
26
- clear: () => set({ items: [], loaded: false }),
27
- }));
 
 
 
 
 
 
 
 
 
1
  import { create } from 'zustand';
2
+ import { persist } from 'zustand/middleware';
3
 
4
  export interface HistoryItem {
5
  id: string;
 
16
  setItems: (items: HistoryItem[]) => void;
17
  addItem: (item: HistoryItem) => void;
18
  clear: () => void;
19
+ resetLoaded: () => void;
20
  }
21
 
22
+ export const useHistoryStore = create<HistoryState>()(
23
+ persist(
24
+ (set) => ({
25
+ items: [],
26
+ loaded: false,
27
+ setItems: (items) => set({ items, loaded: true }),
28
+ addItem: (item) =>
29
+ set((state) => ({ items: [item, ...state.items].slice(0, 50), loaded: true })),
30
+ clear: () => set({ items: [], loaded: false }),
31
+ resetLoaded: () => set({ loaded: false }),
32
+ }),
33
+ {
34
+ name: 'graphrag-query-history',
35
+ }
36
+ )
37
+ );