Ashutosh1975270 commited on
Commit
feca495
·
1 Parent(s): 8380b8a

Fix: Resolve 0-entity extraction error by adding NVIDIA LLM integration and robust error handling

Browse files
.env.example CHANGED
@@ -47,6 +47,9 @@ GROQ_MODEL=llama-3.3-70b-versatile
47
  GOOGLE_API_KEY=your_gemini_api_key_here
48
  GOOGLE_MODEL=gemini-2.0-flash
49
 
 
 
 
50
  # ============================================================
51
  # LangSmith Observability & Tracing (Optional)
52
  # ============================================================
 
47
  GOOGLE_API_KEY=your_gemini_api_key_here
48
  GOOGLE_MODEL=gemini-2.0-flash
49
 
50
+ OPENAI_API_KEY=your_openai_api_key_here
51
+ OPENAI_MODEL=gpt-4o-mini
52
+
53
  # ============================================================
54
  # LangSmith Observability & Tracing (Optional)
55
  # ============================================================
backend/Dockerfile CHANGED
@@ -21,6 +21,9 @@ RUN pip install --no-cache-dir --upgrade pip && \
21
  # Copy backend codebase
22
  COPY . /app/
23
 
 
 
 
24
  # Collect static files (no-op if no static files, but ready for future)
25
  RUN python manage.py collectstatic --noinput 2>/dev/null || true
26
 
 
21
  # Copy backend codebase
22
  COPY . /app/
23
 
24
+ # Create volume directories so they exist with the correct permissions in the image
25
+ RUN mkdir -p /app/chroma_db /app/uploaded_documents /app/logs /app/db
26
+
27
  # Collect static files (no-op if no static files, but ready for future)
28
  RUN python manage.py collectstatic --noinput 2>/dev/null || true
29
 
backend/clear_all_data.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import shutil
3
+ import django
4
+
5
+ # Set up Django settings context
6
+ os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
7
+ django.setup()
8
+
9
+ from django.conf import settings
10
+ from graphrag.services.neo4j_client import Neo4jClient
11
+
12
+ def wipe_all_data():
13
+ print("=== STARTING FULL CLEANUP ===")
14
+
15
+ # 1. Wipe Neo4j Database
16
+ try:
17
+ print("Connecting to Neo4j...")
18
+ neo4j_client = Neo4jClient()
19
+ print("Wiping all nodes and relationships in Neo4j...")
20
+ neo4j_client.execute_query("MATCH (n) DETACH DELETE n")
21
+ print("✓ Neo4j database successfully wiped.")
22
+ except Exception as e:
23
+ print(f"✗ Failed to wipe Neo4j: {e}")
24
+
25
+ # 2. Delete ChromaDB folder
26
+ chroma_dir = settings.CHROMADB_DIR
27
+ if os.path.exists(chroma_dir):
28
+ try:
29
+ print(f"Removing ChromaDB directory: {chroma_dir}...")
30
+ shutil.rmtree(chroma_dir)
31
+ print("✓ ChromaDB vectors successfully wiped.")
32
+ except Exception as e:
33
+ print(f"✗ Failed to delete ChromaDB folder: {e}")
34
+ else:
35
+ print("ChromaDB directory does not exist, skipping.")
36
+
37
+ # 3. Delete Uploaded Documents
38
+ media_root = settings.MEDIA_ROOT
39
+ if os.path.exists(media_root):
40
+ try:
41
+ print(f"Emptying media root directory: {media_root}...")
42
+ shutil.rmtree(media_root)
43
+ os.makedirs(media_root, exist_ok=True)
44
+ print("✓ Uploaded documents successfully wiped.")
45
+ except Exception as e:
46
+ print(f"✗ Failed to empty media root: {e}")
47
+ else:
48
+ print("Media root directory does not exist, skipping.")
49
+
50
+ # 4. Delete SQLite Database file
51
+ sqlite_db = settings.DATABASES['default'].get('NAME')
52
+ if sqlite_db and os.path.exists(sqlite_db):
53
+ try:
54
+ print(f"Removing SQLite database: {sqlite_db}...")
55
+ os.remove(sqlite_db)
56
+ print("✓ Django SQLite database successfully wiped.")
57
+ except Exception as e:
58
+ print(f"✗ Failed to delete SQLite file: {e}")
59
+ else:
60
+ print("SQLite database file not found, skipping.")
61
+
62
+ print("\n=== CLEANUP COMPLETE ===")
63
+ print("To finalize the fresh start, run the migrations to rebuild your database tables:")
64
+ print(" python manage.py migrate")
65
+ print("=========================")
66
+
67
+ if __name__ == "__main__":
68
+ wipe_all_data()
backend/config/settings.py CHANGED
@@ -101,13 +101,18 @@ else:
101
  DATABASES = {
102
  'default': {
103
  'ENGINE': 'django.db.backends.sqlite3',
104
- 'NAME': BASE_DIR / 'db.sqlite3',
105
  }
106
  }
107
 
108
  # Custom User Model
109
  AUTH_USER_MODEL = 'graphrag.User'
110
 
 
 
 
 
 
111
  # Password validation
112
  AUTH_PASSWORD_VALIDATORS = [
113
  {
 
101
  DATABASES = {
102
  'default': {
103
  'ENGINE': 'django.db.backends.sqlite3',
104
+ 'NAME': BASE_DIR / 'db' / 'db.sqlite3',
105
  }
106
  }
107
 
108
  # Custom User Model
109
  AUTH_USER_MODEL = 'graphrag.User'
110
 
111
+ AUTHENTICATION_BACKENDS = [
112
+ 'graphrag.views.EmailOrUsernameModelBackend',
113
+ 'django.contrib.auth.backends.ModelBackend',
114
+ ]
115
+
116
  # Password validation
117
  AUTH_PASSWORD_VALIDATORS = [
118
  {
backend/graphrag/services/graph_builder.py CHANGED
@@ -27,9 +27,9 @@ class GraphBuilder:
27
  """Update document processing progress for frontend polling."""
28
  doc.processing_step = step
29
  doc.processing_progress = progress
30
- doc.save(update_fields=['processing_step', 'processing_progress', 'updated_at'])
31
 
32
- def _batch_create_entities(self, entities: List[dict], user_id: str):
33
  """Batch create entities using UNWIND to reduce N+1 queries."""
34
  if not entities:
35
  return
@@ -40,8 +40,8 @@ class GraphBuilder:
40
  "UNWIND $entities AS ent "
41
  "MERGE (e:Entity {name: ent.name, user_id: $user_id}) "
42
  "ON CREATE SET e.type = ent.type, e.description = ent.description, "
43
- " e.source_doc = ent.source_doc, e.page = ent.page, e.created_at = timestamp() "
44
- "ON MATCH SET e.description = coalesce(e.description, ent.description)"
45
  )
46
  params = {
47
  "entities": [
@@ -54,11 +54,12 @@ class GraphBuilder:
54
  }
55
  for e in batch
56
  ],
57
- "user_id": str(user_id)
 
58
  }
59
  self.neo4j_client.execute_query(query, params)
60
 
61
- def _batch_create_relationships(self, relationships: List[dict], user_id: str):
62
  """Batch create relationships grouped by type using UNWIND."""
63
  if not relationships:
64
  return
@@ -82,7 +83,8 @@ class GraphBuilder:
82
  "MATCH (target:Entity {name: rel.target, user_id: $user_id}) "
83
  f"MERGE (source)-[r:{rel_type}]->(target) "
84
  "ON CREATE SET r.description = rel.description, r.confidence = rel.confidence, "
85
- " r.source_doc = rel.source_doc, r.page = rel.page, r.created_at = timestamp() "
 
86
  "RETURN r LIMIT 1"
87
  )
88
  params = {
@@ -97,7 +99,8 @@ class GraphBuilder:
97
  }
98
  for rel in batch
99
  ],
100
- "user_id": str(user_id)
 
101
  }
102
  self.neo4j_client.execute_query(query, params)
103
 
@@ -161,6 +164,8 @@ class GraphBuilder:
161
 
162
  # 3. Perform Entity and Relationship Extraction per section (parallel)
163
  completed = 0
 
 
164
  with ThreadPoolExecutor(max_workers=5) as executor:
165
  futures = {executor.submit(process_section, sec): sec for sec in sections}
166
  for future in as_completed(futures):
@@ -172,10 +177,15 @@ class GraphBuilder:
172
  all_relationships.extend(rels)
173
  except Exception as e:
174
  logger.error("Section processing failed: %s", str(e))
 
 
175
  completed += 1
176
  extraction_progress = 20 + int(55 * (completed / total_sections)) if total_sections > 0 else 20
177
  self._update_progress(doc, f"Extracting entities... ({completed}/{total_sections})", extraction_progress)
178
 
 
 
 
179
  # 4. Run entity resolution (deduplicate entities and rewrite relationships)
180
  self._update_progress(doc, "Resolving duplicates...", 80)
181
  resolved_ents, rewritten_rels = self.entity_resolver.resolve_entities(
@@ -185,12 +195,12 @@ class GraphBuilder:
185
  # 5. Batch store resolved nodes inside Neo4j
186
  self._update_progress(doc, "Building knowledge graph...", 85)
187
  logger.info("Writing %d resolved entities to Neo4j...", len(resolved_ents))
188
- self._batch_create_entities(resolved_ents, user_id)
189
 
190
  # 6. Batch store rewritten edges inside Neo4j
191
  self._update_progress(doc, "Writing relationships...", 92)
192
  logger.info("Writing %d rewritten relationships to Neo4j...", len(rewritten_rels))
193
- self._batch_create_relationships(rewritten_rels, user_id)
194
 
195
  # 7. Update status to COMPLETED and record counts
196
  doc.entity_count = len(resolved_ents)
@@ -223,7 +233,7 @@ class GraphBuilder:
223
  vector_ok = True
224
 
225
  try:
226
- self.neo4j_client.delete_document_nodes(doc.name, user_id)
227
  except Exception as e:
228
  logger.error("Neo4j cleanup failed for Document: %s. Error: %s", doc.name, str(e))
229
  neo4j_ok = False
 
27
  """Update document processing progress for frontend polling."""
28
  doc.processing_step = step
29
  doc.processing_progress = progress
30
+ doc.save(update_fields=['processing_step', 'processing_progress'])
31
 
32
+ def _batch_create_entities(self, entities: List[dict], user_id: str, document_id: str):
33
  """Batch create entities using UNWIND to reduce N+1 queries."""
34
  if not entities:
35
  return
 
40
  "UNWIND $entities AS ent "
41
  "MERGE (e:Entity {name: ent.name, user_id: $user_id}) "
42
  "ON CREATE SET e.type = ent.type, e.description = ent.description, "
43
+ " e.source_doc = ent.source_doc, e.source_doc_id = $document_id, e.page = ent.page, e.created_at = timestamp() "
44
+ "ON MATCH SET e.description = coalesce(e.description, ent.description), e.source_doc_id = $document_id, e.source_doc = ent.source_doc"
45
  )
46
  params = {
47
  "entities": [
 
54
  }
55
  for e in batch
56
  ],
57
+ "user_id": str(user_id),
58
+ "document_id": str(document_id)
59
  }
60
  self.neo4j_client.execute_query(query, params)
61
 
62
+ def _batch_create_relationships(self, relationships: List[dict], user_id: str, document_id: str):
63
  """Batch create relationships grouped by type using UNWIND."""
64
  if not relationships:
65
  return
 
83
  "MATCH (target:Entity {name: rel.target, user_id: $user_id}) "
84
  f"MERGE (source)-[r:{rel_type}]->(target) "
85
  "ON CREATE SET r.description = rel.description, r.confidence = rel.confidence, "
86
+ " r.source_doc = rel.source_doc, r.source_doc_id = $document_id, r.page = rel.page, r.created_at = timestamp() "
87
+ "ON MATCH SET r.source_doc_id = $document_id, r.source_doc = rel.source_doc "
88
  "RETURN r LIMIT 1"
89
  )
90
  params = {
 
99
  }
100
  for rel in batch
101
  ],
102
+ "user_id": str(user_id),
103
+ "document_id": str(document_id)
104
  }
105
  self.neo4j_client.execute_query(query, params)
106
 
 
164
 
165
  # 3. Perform Entity and Relationship Extraction per section (parallel)
166
  completed = 0
167
+ failed_count = 0
168
+ last_error = None
169
  with ThreadPoolExecutor(max_workers=5) as executor:
170
  futures = {executor.submit(process_section, sec): sec for sec in sections}
171
  for future in as_completed(futures):
 
177
  all_relationships.extend(rels)
178
  except Exception as e:
179
  logger.error("Section processing failed: %s", str(e))
180
+ failed_count += 1
181
+ last_error = e
182
  completed += 1
183
  extraction_progress = 20 + int(55 * (completed / total_sections)) if total_sections > 0 else 20
184
  self._update_progress(doc, f"Extracting entities... ({completed}/{total_sections})", extraction_progress)
185
 
186
+ if failed_count == total_sections and total_sections > 0:
187
+ raise RuntimeError(f"All sections failed to process. Last error: {last_error}")
188
+
189
  # 4. Run entity resolution (deduplicate entities and rewrite relationships)
190
  self._update_progress(doc, "Resolving duplicates...", 80)
191
  resolved_ents, rewritten_rels = self.entity_resolver.resolve_entities(
 
195
  # 5. Batch store resolved nodes inside Neo4j
196
  self._update_progress(doc, "Building knowledge graph...", 85)
197
  logger.info("Writing %d resolved entities to Neo4j...", len(resolved_ents))
198
+ self._batch_create_entities(resolved_ents, user_id, document_id)
199
 
200
  # 6. Batch store rewritten edges inside Neo4j
201
  self._update_progress(doc, "Writing relationships...", 92)
202
  logger.info("Writing %d rewritten relationships to Neo4j...", len(rewritten_rels))
203
+ self._batch_create_relationships(rewritten_rels, user_id, document_id)
204
 
205
  # 7. Update status to COMPLETED and record counts
206
  doc.entity_count = len(resolved_ents)
 
233
  vector_ok = True
234
 
235
  try:
236
+ self.neo4j_client.delete_document_nodes(doc.id, user_id)
237
  except Exception as e:
238
  logger.error("Neo4j cleanup failed for Document: %s. Error: %s", doc.name, str(e))
239
  neo4j_ok = False
backend/graphrag/services/graph_retriever.py CHANGED
@@ -35,12 +35,12 @@ class GraphRetriever:
35
 
36
  self.chain = self.prompt | self.structured_llm
37
 
38
- def retrieve_graph_context(self, query: str, user_id: str, hops: int = 2) -> str:
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)", query, user_id)
44
 
45
  # 1. Extract entities from query using LLM
46
  query_entities = self._extract_entities_from_query(query)
@@ -56,7 +56,7 @@ class GraphRetriever:
56
  # 2. Query Neo4j for each entity's neighborhood
57
  for entity_name in query_entities:
58
  try:
59
- paths = self.neo4j_client.get_entity_subgraph(entity_name, user_id, hops=hops)
60
  self._parse_subgraph_paths(paths, unique_nodes, unique_rels)
61
  except Exception as e:
62
  logger.error("Failed to query subgraph for entity: %s. Error: %s", entity_name, str(e))
@@ -81,12 +81,12 @@ class GraphRetriever:
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) -> Dict[str, Any]:
85
  """
86
  Serializes the full graph as JSON for frontend visualization.
87
  Endpoint: GET /api/graph/
88
  """
89
- raw_data = self.neo4j_client.get_all_graph_data(user_id)
90
 
91
  # Assign numeric IDs for vis.js / react-force-graph
92
  node_id_map = {}
 
35
 
36
  self.chain = self.prompt | self.structured_llm
37
 
38
+ def retrieve_graph_context(self, query: str, user_id: str, hops: int = 2, doc_names: List[str] = None) -> str:
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)
 
56
  # 2. Query Neo4j for each entity's neighborhood
57
  for entity_name in query_entities:
58
  try:
59
+ paths = self.neo4j_client.get_entity_subgraph(entity_name, user_id, hops=hops, doc_names=doc_names)
60
  self._parse_subgraph_paths(paths, unique_nodes, unique_rels)
61
  except Exception as e:
62
  logger.error("Failed to query subgraph for entity: %s. Error: %s", entity_name, str(e))
 
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]:
85
  """
86
  Serializes the full graph as JSON for frontend visualization.
87
  Endpoint: GET /api/graph/
88
  """
89
+ raw_data = self.neo4j_client.get_all_graph_data(user_id, doc_ids=doc_ids)
90
 
91
  # Assign numeric IDs for vis.js / react-force-graph
92
  node_id_map = {}
backend/graphrag/services/hybrid_retriever.py CHANGED
@@ -11,24 +11,24 @@ class HybridRetriever:
11
  self.graph_retriever = GraphRetriever()
12
  self.vector_retriever = VectorRetriever()
13
 
14
- def retrieve_combined_context(self, query: str, user_id: str) -> Dict[str, Any]:
15
  """
16
  Runs both graph and vector search and returns a combined context dictionary.
17
  Determines the retrieval strategy route (GRAPH_ONLY, VECTOR_ONLY, or HYBRID).
18
  """
19
- logger.info("Running hybrid retrieval for query: '%s' (User: %s)", query, user_id)
20
 
21
  # 1. Execute Graph Retrieval
22
  graph_context = ""
23
  try:
24
- graph_context = self.graph_retriever.retrieve_graph_context(query, user_id, hops=2)
25
  except Exception as e:
26
  logger.error("Graph retrieval failed during hybrid step. Error: %s", str(e))
27
 
28
  # 2. Execute Vector Retrieval
29
  vector_chunks = []
30
  try:
31
- vector_chunks = self.vector_retriever.retrieve_relevant_chunks(query, user_id, limit=4)
32
  except Exception as e:
33
  logger.error("Vector retrieval failed during hybrid step. Error: %s", str(e))
34
 
 
11
  self.graph_retriever = GraphRetriever()
12
  self.vector_retriever = VectorRetriever()
13
 
14
+ def retrieve_combined_context(self, query: str, user_id: str, doc_names: List[str] = None) -> Dict[str, Any]:
15
  """
16
  Runs both graph and vector search and returns a combined context dictionary.
17
  Determines the retrieval strategy route (GRAPH_ONLY, VECTOR_ONLY, or HYBRID).
18
  """
19
+ logger.info("Running hybrid retrieval for query: '%s' (User: %s, Docs: %s)", query, user_id, doc_names)
20
 
21
  # 1. Execute Graph Retrieval
22
  graph_context = ""
23
  try:
24
+ graph_context = self.graph_retriever.retrieve_graph_context(query, user_id, hops=2, doc_names=doc_names)
25
  except Exception as e:
26
  logger.error("Graph retrieval failed during hybrid step. Error: %s", str(e))
27
 
28
  # 2. Execute Vector Retrieval
29
  vector_chunks = []
30
  try:
31
+ vector_chunks = self.vector_retriever.retrieve_relevant_chunks(query, user_id, limit=4, doc_names=doc_names)
32
  except Exception as e:
33
  logger.error("Vector retrieval failed during hybrid step. Error: %s", str(e))
34
 
backend/graphrag/services/llm_client.py CHANGED
@@ -1,8 +1,9 @@
1
  import os
2
  import logging
 
3
  from langchain_groq import ChatGroq
4
  from langchain_google_genai import ChatGoogleGenerativeAI
5
- from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
6
 
7
  logger = logging.getLogger(__name__)
8
 
@@ -10,39 +11,134 @@ logger = logging.getLogger(__name__)
10
  _llm_cache = {}
11
 
12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  def get_llm(temperature: float = 0.0):
14
  """
15
- Returns a configured LangChain LLM instance.
16
- Prioritizes Groq (Llama 3) and falls back to Gemini if GROQ_API_KEY is missing.
17
  Caches instances to avoid reloading models repeatedly.
18
  """
19
  cache_key = f"{temperature}"
20
  if cache_key in _llm_cache:
21
  return _llm_cache[cache_key]
22
 
 
23
  groq_api_key = os.getenv("GROQ_API_KEY", "")
24
  google_api_key = os.getenv("GOOGLE_API_KEY", "")
 
25
 
26
- llm = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  if groq_api_key:
28
- model_name = os.getenv("GROQ_MODEL", "llama-3.3-70b-versatile")
29
- logger.info("Initializing Groq Chat Model (%s)", model_name)
30
- llm = ChatGroq(
31
- model=model_name,
32
- api_key=groq_api_key,
33
- temperature=temperature
34
- )
35
- elif google_api_key:
36
- model_name = os.getenv("GOOGLE_MODEL", "gemini-1.5-flash")
37
- logger.info("Initializing Google Gemini Chat Model (%s)", model_name)
38
- llm = ChatGoogleGenerativeAI(
39
- model=model_name,
40
- api_key=google_api_key,
41
- temperature=temperature
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  )
 
 
 
 
43
  else:
44
- logger.error("No API keys found for either GROQ_API_KEY or GOOGLE_API_KEY.")
45
- raise ValueError("Missing LLM API keys. Please configure GROQ_API_KEY or GOOGLE_API_KEY in your .env file.")
46
 
47
  _llm_cache[cache_key] = llm
48
  return llm
 
 
1
  import os
2
  import logging
3
+ from langchain_openai import ChatOpenAI
4
  from langchain_groq import ChatGroq
5
  from langchain_google_genai import ChatGoogleGenerativeAI
6
+ from langchain_core.runnables import Runnable
7
 
8
  logger = logging.getLogger(__name__)
9
 
 
11
  _llm_cache = {}
12
 
13
 
14
+
15
+ class FallbackLLMWrapper(Runnable):
16
+ """
17
+ A wrapper around multiple LangChain ChatModels to provide dynamic
18
+ fallback when invoking models. Particularly useful for handling
19
+ API rate limits (e.g., HTTP 429) and transient errors.
20
+ """
21
+ def __init__(self, primary: Runnable, fallbacks: list):
22
+ self.primary = primary
23
+ self.fallbacks = fallbacks
24
+
25
+ def invoke(self, input, config=None, **kwargs):
26
+ last_exception = None
27
+
28
+ # Try primary model
29
+ try:
30
+ primary_name = getattr(self.primary, "model", getattr(self.primary, "model_name", "Unknown"))
31
+ logger.info("Attempting primary LLM invocation (%s)...", primary_name)
32
+ return self.primary.invoke(input, config, **kwargs)
33
+ except Exception as e:
34
+ logger.error("Primary LLM invocation failed: %s. Trying fallbacks...", str(e))
35
+ last_exception = e
36
+
37
+ # Try fallback models in order
38
+ for i, fallback_llm in enumerate(self.fallbacks, 1):
39
+ try:
40
+ fb_name = getattr(fallback_llm, "model", getattr(fallback_llm, "model_name", "Unknown"))
41
+ logger.info("Attempting fallback LLM invocation #%d (%s)...", i, fb_name)
42
+ return fallback_llm.invoke(input, config, **kwargs)
43
+ except Exception as e:
44
+ logger.error("Fallback LLM invocation #%d failed: %s", i, str(e))
45
+ last_exception = e
46
+
47
+ raise last_exception
48
+
49
+ def with_structured_output(self, schema, **kwargs):
50
+ """
51
+ Applies structure requirements to both primary and fallback models.
52
+ """
53
+ structured_primary = self.primary.with_structured_output(schema, **kwargs)
54
+ structured_fallbacks = [
55
+ fb.with_structured_output(schema, **kwargs) for fb in self.fallbacks
56
+ ]
57
+ return FallbackLLMWrapper(structured_primary, structured_fallbacks)
58
+
59
+
60
  def get_llm(temperature: float = 0.0):
61
  """
62
+ Returns a configured LangChain LLM instance (wrapped in FallbackLLMWrapper if multiple providers are configured).
63
+ Prioritizes OpenAI, falling back to Groq, then Gemini if errors occur or keys are missing.
64
  Caches instances to avoid reloading models repeatedly.
65
  """
66
  cache_key = f"{temperature}"
67
  if cache_key in _llm_cache:
68
  return _llm_cache[cache_key]
69
 
70
+ openai_api_key = os.getenv("OPENAI_API_KEY", "")
71
  groq_api_key = os.getenv("GROQ_API_KEY", "")
72
  google_api_key = os.getenv("GOOGLE_API_KEY", "")
73
+ nvidia_api_key = os.getenv("NVIDIA_API_KEY", "")
74
 
75
+ available_models = []
76
+
77
+ # 1. OpenAI (Primary option)
78
+ if openai_api_key:
79
+ try:
80
+ model_name = os.getenv("OPENAI_MODEL", "gpt-4o-mini")
81
+ logger.info("Initializing OpenAI Chat Model (%s) for fallback list", model_name)
82
+ available_models.append(ChatOpenAI(
83
+ model=model_name,
84
+ api_key=openai_api_key,
85
+ temperature=temperature
86
+ ))
87
+ except Exception as e:
88
+ logger.error("Failed to initialize OpenAI client: %s", str(e))
89
+
90
+ # 2. Groq (Secondary option)
91
  if groq_api_key:
92
+ try:
93
+ model_name = os.getenv("GROQ_MODEL", "llama-3.3-70b-versatile")
94
+ logger.info("Initializing Groq Chat Model (%s) for fallback list", model_name)
95
+ available_models.append(ChatGroq(
96
+ model=model_name,
97
+ api_key=groq_api_key,
98
+ temperature=temperature
99
+ ))
100
+ except Exception as e:
101
+ logger.error("Failed to initialize Groq client: %s", str(e))
102
+
103
+ # 3. Google Gemini (Tertiary option)
104
+ if google_api_key:
105
+ try:
106
+ model_name = os.getenv("GOOGLE_MODEL", "gemini-1.5-flash")
107
+ logger.info("Initializing Google Gemini Chat Model (%s) for fallback list", model_name)
108
+ available_models.append(ChatGoogleGenerativeAI(
109
+ model=model_name,
110
+ api_key=google_api_key,
111
+ temperature=temperature
112
+ ))
113
+ except Exception as e:
114
+ logger.error("Failed to initialize Google Gemini client: %s", str(e))
115
+
116
+ # 4. NVIDIA (OpenAI-compatible option)
117
+ if nvidia_api_key:
118
+ try:
119
+ model_name = os.getenv("NVIDIA_MODEL", "meta/llama-3.1-70b-instruct")
120
+ logger.info("Initializing NVIDIA Chat Model (%s) for fallback list", model_name)
121
+ available_models.append(ChatOpenAI(
122
+ model=model_name,
123
+ api_key=nvidia_api_key,
124
+ base_url="https://integrate.api.nvidia.com/v1",
125
+ temperature=temperature
126
+ ))
127
+ except Exception as e:
128
+ logger.error("Failed to initialize NVIDIA client: %s", str(e))
129
+
130
+ if not available_models:
131
+ logger.error("No API keys found or all LLM provider initializations failed.")
132
+ raise ValueError(
133
+ "Missing or invalid LLM API keys. Please configure OPENAI_API_KEY, GROQ_API_KEY, GOOGLE_API_KEY, or NVIDIA_API_KEY in your .env file."
134
  )
135
+
136
+ # Wrap in FallbackLLMWrapper if more than one model is available
137
+ if len(available_models) == 1:
138
+ llm = available_models[0]
139
  else:
140
+ llm = FallbackLLMWrapper(available_models[0], available_models[1:])
 
141
 
142
  _llm_cache[cache_key] = llm
143
  return llm
144
+
backend/graphrag/services/neo4j_client.py CHANGED
@@ -119,14 +119,14 @@ class Neo4jClient:
119
  result = session.run(query, parameters)
120
  return [record.data() for record in result]
121
 
122
- def create_entity_node(self, name, entity_type, description, user_id, source_doc=None, page=None):
123
  """
124
  Create or update (MERGE) an Entity node, isolating by user_id.
125
  """
126
  query = (
127
  "MERGE (e:Entity {name: $name, user_id: $user_id}) "
128
  "ON CREATE SET e.type = $type, e.description = $description, "
129
- " e.source_doc = $source_doc, e.page = $page, e.created_at = timestamp() "
130
  "ON MATCH SET e.description = coalesce(e.description, $description) "
131
  "RETURN e"
132
  )
@@ -136,11 +136,12 @@ class Neo4jClient:
136
  "description": description.strip(),
137
  "user_id": str(user_id),
138
  "source_doc": source_doc,
 
139
  "page": page
140
  }
141
  self.execute_query(query, params)
142
 
143
- def create_relationship_edge(self, source_name, target_name, rel_type, description, confidence, user_id, source_doc=None, page=None):
144
  """
145
  Create a directed relationship edge between two existing Entity nodes.
146
  Uses strict allowlist to prevent Cypher injection.
@@ -155,7 +156,7 @@ class Neo4jClient:
155
  f"MATCH (target:Entity {{name: $target_name, user_id: $user_id}}) "
156
  f"MERGE (source)-[r:{clean_rel_type}]->(target) "
157
  f"ON CREATE SET r.description = $description, r.confidence = $confidence, "
158
- f" r.source_doc = $source_doc, r.page = $page, r.created_at = timestamp() "
159
  f"RETURN r"
160
  )
161
  params = {
@@ -165,20 +166,29 @@ class Neo4jClient:
165
  "confidence": float(confidence),
166
  "user_id": str(user_id),
167
  "source_doc": source_doc,
 
168
  "page": page
169
  }
170
  self.execute_query(query, params)
171
 
172
- def get_entity_subgraph(self, name, user_id, hops=2):
173
  """
174
  Retrieve all connected entities and relationships up to N hops.
175
  """
176
  hops = max(1, min(int(hops), 10))
177
- query = (
178
- f"MATCH path = (e:Entity {{name: $name, user_id: $user_id}})-[*1..{hops}]-(neighbor:Entity {{user_id: $user_id}}) "
179
- f"RETURN path LIMIT 50"
180
- )
181
- params = {"name": name, "user_id": str(user_id)}
 
 
 
 
 
 
 
 
182
  return self.execute_query(query, params)
183
 
184
  def find_shortest_path(self, start_name, end_name, user_id, max_hops=5):
@@ -247,27 +257,47 @@ class Neo4jClient:
247
  # Additional Methods — used by new endpoints (graph, search, etc.)
248
  # ----------------------------------------------------------------
249
 
250
- def get_all_graph_data(self, user_id):
251
  """
252
  Returns all nodes and relationships for frontend visualization.
253
- Endpoint: GET /api/graph/
254
  """
255
- nodes_query = (
256
- "MATCH (e:Entity {user_id: $user_id}) "
257
- "RETURN e.name AS name, e.type AS type, e.description AS description, "
258
- " e.source_doc AS source_doc, e.page AS page "
259
- "LIMIT 500"
260
- )
261
- edges_query = (
262
- "MATCH (s:Entity {user_id: $user_id})-[r]->(t:Entity {user_id: $user_id}) "
263
- "RETURN s.name AS source, t.name AS target, "
264
- " type(r) AS relationship_type, r.description AS description, "
265
- " r.confidence AS confidence, r.source_doc AS source_doc "
266
- "LIMIT 1000"
267
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
268
  try:
269
- nodes = self.execute_query(nodes_query, {"user_id": str(user_id)})
270
- edges = self.execute_query(edges_query, {"user_id": str(user_id)})
271
  return {"nodes": nodes, "edges": edges}
272
  except Exception as e:
273
  logger.error("Failed to get all graph data: %s", str(e))
@@ -384,26 +414,25 @@ class Neo4jClient:
384
 
385
  return ft_results[:limit]
386
 
387
- def delete_document_nodes(self, document_name, user_id):
388
  """
389
- Wipe all nodes and relationships associated with a deleted document.
390
- Removes orphan nodes that have no other remaining connections.
391
  """
392
- logger.info("Executing Cypher delete query for document: %s, User: %s", document_name, user_id)
393
 
394
- # 1. Delete relationships pointing from or to nodes created by this document
395
  delete_rels_query = (
396
  "MATCH (a:Entity {user_id: $user_id})-[r]->(b:Entity {user_id: $user_id}) "
397
- "WHERE r.source_doc = $document_name "
398
  "DELETE r"
399
  )
400
- # 2. Delete nodes created solely by this document
401
  delete_nodes_query = (
402
  "MATCH (e:Entity {user_id: $user_id}) "
403
- "WHERE e.source_doc = $document_name "
404
- "DETACH DELETE e"
405
  )
406
 
407
- params = {"document_name": document_name, "user_id": str(user_id)}
408
  self.execute_query(delete_rels_query, params)
409
  self.execute_query(delete_nodes_query, params)
 
119
  result = session.run(query, parameters)
120
  return [record.data() for record in result]
121
 
122
+ def create_entity_node(self, name, entity_type, description, user_id, source_doc=None, source_doc_id=None, page=None):
123
  """
124
  Create or update (MERGE) an Entity node, isolating by user_id.
125
  """
126
  query = (
127
  "MERGE (e:Entity {name: $name, user_id: $user_id}) "
128
  "ON CREATE SET e.type = $type, e.description = $description, "
129
+ " e.source_doc = $source_doc, e.source_doc_id = $source_doc_id, e.page = $page, e.created_at = timestamp() "
130
  "ON MATCH SET e.description = coalesce(e.description, $description) "
131
  "RETURN e"
132
  )
 
136
  "description": description.strip(),
137
  "user_id": str(user_id),
138
  "source_doc": source_doc,
139
+ "source_doc_id": str(source_doc_id) if source_doc_id else None,
140
  "page": page
141
  }
142
  self.execute_query(query, params)
143
 
144
+ def create_relationship_edge(self, source_name, target_name, rel_type, description, confidence, user_id, source_doc=None, source_doc_id=None, page=None):
145
  """
146
  Create a directed relationship edge between two existing Entity nodes.
147
  Uses strict allowlist to prevent Cypher injection.
 
156
  f"MATCH (target:Entity {{name: $target_name, user_id: $user_id}}) "
157
  f"MERGE (source)-[r:{clean_rel_type}]->(target) "
158
  f"ON CREATE SET r.description = $description, r.confidence = $confidence, "
159
+ f" r.source_doc = $source_doc, r.source_doc_id = $source_doc_id, r.page = $page, r.created_at = timestamp() "
160
  f"RETURN r"
161
  )
162
  params = {
 
166
  "confidence": float(confidence),
167
  "user_id": str(user_id),
168
  "source_doc": source_doc,
169
+ "source_doc_id": str(source_doc_id) if source_doc_id else None,
170
  "page": page
171
  }
172
  self.execute_query(query, params)
173
 
174
+ def get_entity_subgraph(self, name, user_id, hops=2, doc_names=None):
175
  """
176
  Retrieve all connected entities and relationships up to N hops.
177
  """
178
  hops = max(1, min(int(hops), 10))
179
+ if doc_names:
180
+ query = (
181
+ f"MATCH path = (e:Entity {{name: $name, user_id: $user_id}})-[*1..{hops}]-(neighbor:Entity {{user_id: $user_id}}) "
182
+ f"WHERE all(r in relationships(path) WHERE r.source_doc IN $doc_names) "
183
+ f"RETURN path LIMIT 50"
184
+ )
185
+ params = {"name": name, "user_id": str(user_id), "doc_names": [str(d) for d in doc_names]}
186
+ else:
187
+ query = (
188
+ f"MATCH path = (e:Entity {{name: $name, user_id: $user_id}})-[*1..{hops}]-(neighbor:Entity {{user_id: $user_id}}) "
189
+ f"RETURN path LIMIT 50"
190
+ )
191
+ params = {"name": name, "user_id": str(user_id)}
192
  return self.execute_query(query, params)
193
 
194
  def find_shortest_path(self, start_name, end_name, user_id, max_hops=5):
 
257
  # Additional Methods — used by new endpoints (graph, search, etc.)
258
  # ----------------------------------------------------------------
259
 
260
+ def get_all_graph_data(self, user_id, doc_ids=None):
261
  """
262
  Returns all nodes and relationships for frontend visualization.
263
+ Allows filtering by a list of document IDs (doc_ids).
264
  """
265
+ if doc_ids:
266
+ nodes_query = (
267
+ "MATCH (e:Entity {user_id: $user_id}) "
268
+ "WHERE e.source_doc_id IN $doc_ids "
269
+ "RETURN e.name AS name, e.type AS type, e.description AS description, "
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]}
282
+ else:
283
+ nodes_query = (
284
+ "MATCH (e:Entity {user_id: $user_id}) "
285
+ "RETURN e.name AS name, e.type AS type, e.description AS description, "
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)}
297
+
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))
 
414
 
415
  return ft_results[:limit]
416
 
417
+ def delete_document_nodes(self, document_id, user_id):
418
  """
419
+ Wipe all nodes and relationships associated with a deleted document ID.
 
420
  """
421
+ logger.info("Executing Cypher delete query for document ID: %s, User: %s", document_id, user_id)
422
 
423
+ # 1. Delete relationships pointing from or to nodes created by this document ID
424
  delete_rels_query = (
425
  "MATCH (a:Entity {user_id: $user_id})-[r]->(b:Entity {user_id: $user_id}) "
426
+ "WHERE r.source_doc_id = $document_id "
427
  "DELETE r"
428
  )
429
+ # 2. Delete nodes created by this document ID that are now orphans
430
  delete_nodes_query = (
431
  "MATCH (e:Entity {user_id: $user_id}) "
432
+ "WHERE e.source_doc_id = $document_id AND NOT (e)-[]-() "
433
+ "DELETE e"
434
  )
435
 
436
+ params = {"document_id": str(document_id), "user_id": str(user_id)}
437
  self.execute_query(delete_rels_query, params)
438
  self.execute_query(delete_nodes_query, params)
backend/graphrag/services/rag_chain.py CHANGED
@@ -57,7 +57,7 @@ class RAGChain:
57
  )
58
  }
59
 
60
- def generate_answer(self, query: str, user_id: str, mode: str = "hybrid") -> Dict[str, Any]:
61
  """
62
  Retrieves context according to the selected mode, invokes the LLM, and returns the response.
63
  """
@@ -66,7 +66,17 @@ class RAGChain:
66
  logger.warning("Invalid retrieval mode '%s' requested. Defaulting to 'hybrid'.", mode)
67
  mode = "hybrid"
68
 
69
- logger.info("Generating RAG answer in '%s' mode for query: '%s' (User: %s)", mode, query, user_id)
 
 
 
 
 
 
 
 
 
 
70
 
71
  context = ""
72
  sources = []
@@ -76,7 +86,7 @@ class RAGChain:
76
  # 1. Fetch Context depending on the Retrieval Mode
77
  try:
78
  if mode == "vector":
79
- chunks = self.vector_retriever.retrieve_relevant_chunks(query, user_id, limit=5)
80
  context_lines = []
81
  for c in chunks:
82
  context_lines.append(f"Document: {c['source_doc']} (Page: {c['page']}): \"{c['text']}\"")
@@ -84,7 +94,7 @@ class RAGChain:
84
  context = "### TEXT PASSAGES:\n" + "\n\n".join(context_lines)
85
 
86
  elif mode == "graph":
87
- graph_context = self.graph_retriever.retrieve_graph_context(query, user_id, hops=2)
88
  context = graph_context
89
  # Extract entity names as sources
90
  for line in graph_context.split("\n"):
@@ -93,7 +103,7 @@ class RAGChain:
93
  sources.append(f"Graph Node: {ent_name}")
94
 
95
  else: # hybrid
96
- hybrid_result = self.hybrid_retriever.retrieve_combined_context(query, user_id)
97
  context = hybrid_result["combined_context"]
98
  strategy_used = hybrid_result["strategy"]
99
 
 
57
  )
58
  }
59
 
60
+ def generate_answer(self, query: str, user_id: str, mode: str = "hybrid", doc_ids: List[str] = None) -> Dict[str, Any]:
61
  """
62
  Retrieves context according to the selected mode, invokes the LLM, and returns the response.
63
  """
 
66
  logger.warning("Invalid retrieval mode '%s' requested. Defaulting to 'hybrid'.", mode)
67
  mode = "hybrid"
68
 
69
+ logger.info("Generating RAG answer in '%s' mode for query: '%s' (User: %s, Doc IDs: %s)", mode, query, user_id, doc_ids)
70
+
71
+ # Resolve doc_ids to names
72
+ doc_names = None
73
+ if doc_ids:
74
+ try:
75
+ from graphrag.models import Document
76
+ doc_names = list(Document.objects.filter(id__in=doc_ids).values_list('name', flat=True))
77
+ logger.info("Resolved filter doc IDs to names: %s", doc_names)
78
+ except Exception as e:
79
+ logger.error("Failed to resolve doc_ids to names: %s", str(e))
80
 
81
  context = ""
82
  sources = []
 
86
  # 1. Fetch Context depending on the Retrieval Mode
87
  try:
88
  if mode == "vector":
89
+ chunks = self.vector_retriever.retrieve_relevant_chunks(query, user_id, limit=5, doc_names=doc_names)
90
  context_lines = []
91
  for c in chunks:
92
  context_lines.append(f"Document: {c['source_doc']} (Page: {c['page']}): \"{c['text']}\"")
 
94
  context = "### TEXT PASSAGES:\n" + "\n\n".join(context_lines)
95
 
96
  elif mode == "graph":
97
+ graph_context = self.graph_retriever.retrieve_graph_context(query, user_id, hops=2, doc_names=doc_names)
98
  context = graph_context
99
  # Extract entity names as sources
100
  for line in graph_context.split("\n"):
 
103
  sources.append(f"Graph Node: {ent_name}")
104
 
105
  else: # hybrid
106
+ hybrid_result = self.hybrid_retriever.retrieve_combined_context(query, user_id, doc_names=doc_names)
107
  context = hybrid_result["combined_context"]
108
  strategy_used = hybrid_result["strategy"]
109
 
backend/graphrag/services/vector_retriever.py CHANGED
@@ -19,7 +19,7 @@ class VectorRetriever:
19
 
20
  # 2. Load the Embedding Model via ChromaDB's built-in ONNX path
21
  # Uses ONNX runtime only — no torch/CUDA needed, no sentence-transformers package
22
- self.embedding_fn = ONNXMiniLM_L6_V2(preferred_provider="CPUExecutionProvider")
23
 
24
  # 3. Setup text splitter for document chunking
25
  def chunk_text(text: str, chunk_size: int = 800, chunk_overlap: int = 100) -> List[str]:
@@ -81,19 +81,23 @@ class VectorRetriever:
81
  logger.error("Failed to index document in ChromaDB. Error: %s", str(e), exc_info=True)
82
  raise e
83
 
84
- def retrieve_relevant_chunks(self, query: str, user_id: str, limit: int = 5) -> List[dict]:
85
  """
86
  Queries ChromaDB to retrieve the most semantically relevant text passages.
87
  """
88
- logger.info("Searching ChromaDB for query: '%s' (Limit: %d, User: %s)", query, limit, user_id)
89
  try:
90
  collection = self._get_user_collection(user_id)
91
  query_vector = self.embedding_fn([query])[0]
92
 
93
- results = collection.query(
94
- query_embeddings=[query_vector],
95
- n_results=limit
96
- )
 
 
 
 
97
 
98
  retrieved = []
99
  if results and results["documents"]:
 
19
 
20
  # 2. Load the Embedding Model via ChromaDB's built-in ONNX path
21
  # Uses ONNX runtime only — no torch/CUDA needed, no sentence-transformers package
22
+ self.embedding_fn = ONNXMiniLM_L6_V2(preferred_providers=["CPUExecutionProvider"])
23
 
24
  # 3. Setup text splitter for document chunking
25
  def chunk_text(text: str, chunk_size: int = 800, chunk_overlap: int = 100) -> List[str]:
 
81
  logger.error("Failed to index document in ChromaDB. Error: %s", str(e), exc_info=True)
82
  raise e
83
 
84
+ def retrieve_relevant_chunks(self, query: str, user_id: str, limit: int = 5, doc_names: List[str] = None) -> List[dict]:
85
  """
86
  Queries ChromaDB to retrieve the most semantically relevant text passages.
87
  """
88
+ logger.info("Searching ChromaDB for query: '%s' (Limit: %d, User: %s, Docs: %s)", query, limit, user_id, doc_names)
89
  try:
90
  collection = self._get_user_collection(user_id)
91
  query_vector = self.embedding_fn([query])[0]
92
 
93
+ query_params = {
94
+ "query_embeddings": [query_vector],
95
+ "n_results": limit
96
+ }
97
+ if doc_names:
98
+ query_params["where"] = {"source_doc": {"$in": [str(d) for d in doc_names]}}
99
+
100
+ results = collection.query(**query_params)
101
 
102
  retrieved = []
103
  if results and results["documents"]:
backend/graphrag/tests.py CHANGED
@@ -76,6 +76,17 @@ class GraphRAGTests(APITestCase):
76
  self.assertIn("access", response.data)
77
  self.assertIn("refresh", response.data)
78
 
 
 
 
 
 
 
 
 
 
 
 
79
  # ================= DOCUMENT & INGESTION TESTS =================
80
 
81
  @patch('graphrag.views.trigger_ingestion_background')
 
76
  self.assertIn("access", response.data)
77
  self.assertIn("refresh", response.data)
78
 
79
+ def test_login_with_email_and_jwt_issuance(self):
80
+ url = reverse('auth_login')
81
+ data = {
82
+ "username": self.email,
83
+ "password": self.password
84
+ }
85
+ response = self.client.post(url, data, format='json')
86
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
87
+ self.assertIn("access", response.data)
88
+ self.assertIn("refresh", response.data)
89
+
90
  # ================= DOCUMENT & INGESTION TESTS =================
91
 
92
  @patch('graphrag.views.trigger_ingestion_background')
backend/graphrag/views.py CHANGED
@@ -37,8 +37,29 @@ _ingestion_semaphore = threading.Semaphore(settings.MAX_INGESTION_WORKERS)
37
 
38
  logger = logging.getLogger(__name__)
39
 
 
 
 
40
  User = get_user_model()
41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
 
43
  # ============================================================
44
  # Custom Throttle Classes
@@ -229,6 +250,14 @@ class DocumentUploadView(APIView):
229
  status=status.HTTP_400_BAD_REQUEST
230
  )
231
 
 
 
 
 
 
 
 
 
232
  # Save document record with initial PENDING status
233
  doc = Document.objects.create(
234
  user=request.user,
@@ -317,6 +346,7 @@ class QueryView(APIView):
317
  def post(self, request):
318
  query = request.data.get("query")
319
  mode = request.data.get("mode", "hybrid")
 
320
 
321
  if not query or not query.strip():
322
  return Response(
@@ -324,11 +354,11 @@ class QueryView(APIView):
324
  status=status.HTTP_400_BAD_REQUEST
325
  )
326
 
327
- logger.info("Executing RAG Query for user: %s | Mode: %s", request.user.username, mode)
328
  start_time = time.time()
329
  try:
330
  rag_chain = RAGChain()
331
- result = rag_chain.generate_answer(query, request.user.id, mode)
332
  elapsed = time.time() - start_time
333
 
334
  # Log query
@@ -518,8 +548,13 @@ class GraphDataView(APIView):
518
 
519
  def get(self, request):
520
  try:
 
 
 
 
 
521
  graph_retriever = GraphRetriever()
522
- graph_json = graph_retriever.get_graph_as_json(request.user.id)
523
  return Response(graph_json, status=status.HTTP_200_OK)
524
  except Exception as e:
525
  logger.error("Error in GraphDataView: %s", str(e), exc_info=True)
 
37
 
38
  logger = logging.getLogger(__name__)
39
 
40
+ from django.contrib.auth.backends import ModelBackend
41
+ from django.db.models import Q
42
+
43
  User = get_user_model()
44
 
45
+ class EmailOrUsernameModelBackend(ModelBackend):
46
+ """
47
+ Custom authentication backend that allows authenticating with either
48
+ a username or an email address.
49
+ """
50
+ def authenticate(self, request, username=None, password=None, **kwargs):
51
+ if username is None:
52
+ username = kwargs.get('username')
53
+
54
+ # Check if input matches username OR email (case-insensitive)
55
+ user = User.objects.filter(
56
+ Q(username__iexact=username) | Q(email__iexact=username)
57
+ ).first()
58
+
59
+ if user and user.check_password(password):
60
+ return user
61
+ return None
62
+
63
 
64
  # ============================================================
65
  # Custom Throttle Classes
 
250
  status=status.HTTP_400_BAD_REQUEST
251
  )
252
 
253
+ # --- Duplicate Check ---
254
+ if Document.objects.filter(user=request.user, name=file_obj.name).exists():
255
+ logger.warning("Document upload request rejected: Duplicate filename '%s' found for user: %s", file_obj.name, request.user.username)
256
+ return Response(
257
+ {"error": "A document with this name has already been uploaded."},
258
+ status=status.HTTP_409_CONFLICT
259
+ )
260
+
261
  # Save document record with initial PENDING status
262
  doc = Document.objects.create(
263
  user=request.user,
 
346
  def post(self, request):
347
  query = request.data.get("query")
348
  mode = request.data.get("mode", "hybrid")
349
+ document_ids = request.data.get("document_ids", None)
350
 
351
  if not query or not query.strip():
352
  return Response(
 
354
  status=status.HTTP_400_BAD_REQUEST
355
  )
356
 
357
+ logger.info("Executing RAG Query for user: %s | Mode: %s | Docs: %s", request.user.username, mode, document_ids)
358
  start_time = time.time()
359
  try:
360
  rag_chain = RAGChain()
361
+ result = rag_chain.generate_answer(query, request.user.id, mode, doc_ids=document_ids)
362
  elapsed = time.time() - start_time
363
 
364
  # Log query
 
548
 
549
  def get(self, request):
550
  try:
551
+ document_ids_str = request.GET.get("document_ids", None)
552
+ doc_ids = None
553
+ if document_ids_str:
554
+ doc_ids = [d.strip() for d in document_ids_str.split(",") if d.strip()]
555
+
556
  graph_retriever = GraphRetriever()
557
+ graph_json = graph_retriever.get_graph_as_json(request.user.id, doc_ids=doc_ids)
558
  return Response(graph_json, status=status.HTTP_200_OK)
559
  except Exception as e:
560
  logger.error("Error in GraphDataView: %s", str(e), exc_info=True)
backend/requirements.txt CHANGED
@@ -15,3 +15,4 @@ pydantic>=2.5.0
15
  python-dotenv>=1.0.0
16
  gunicorn>=21.2.0
17
  psycopg2-binary>=2.9.9
 
 
15
  python-dotenv>=1.0.0
16
  gunicorn>=21.2.0
17
  psycopg2-binary>=2.9.9
18
+ langchain-openai>=0.1.0
docker-compose.yml CHANGED
@@ -39,6 +39,9 @@ services:
39
  context: ./backend
40
  dockerfile: Dockerfile
41
  container_name: graphrag_backend
 
 
 
42
  restart: unless-stopped
43
  ports:
44
  # Only expose to localhost — not 0.0.0.0
@@ -52,7 +55,7 @@ services:
52
  - ALLOWED_HOSTS=localhost,127.0.0.1,backend
53
  volumes:
54
  # Persist SQLite database (user accounts, document metadata)
55
- - sqlite_data:/app
56
  # Persist ChromaDB vector store
57
  - chroma_data:/app/chroma_db
58
  # Persist uploaded documents
 
39
  context: ./backend
40
  dockerfile: Dockerfile
41
  container_name: graphrag_backend
42
+ command: >
43
+ sh -c "python manage.py migrate --noinput &&
44
+ gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers 4 --threads 2 --timeout 300 --access-logfile - --error-logfile -"
45
  restart: unless-stopped
46
  ports:
47
  # Only expose to localhost — not 0.0.0.0
 
55
  - ALLOWED_HOSTS=localhost,127.0.0.1,backend
56
  volumes:
57
  # Persist SQLite database (user accounts, document metadata)
58
+ - sqlite_data:/app/db
59
  # Persist ChromaDB vector store
60
  - chroma_data:/app/chroma_db
61
  # Persist uploaded documents
eval_dataset/eval_questions.json DELETED
@@ -1,182 +0,0 @@
1
- [
2
- {
3
- "id": 1,
4
- "category": "single_entity",
5
- "difficulty": "easy",
6
- "question": "Who is the CEO of Google?",
7
- "expected_answer": "Sundar Pichai is the CEO of Google.",
8
- "retrieval_mode": "graph",
9
- "notes": "Tests basic entity-relationship traversal"
10
- },
11
- {
12
- "id": 2,
13
- "category": "single_entity",
14
- "difficulty": "easy",
15
- "question": "What technology does React depend on?",
16
- "expected_answer": "React depends on JavaScript and the Virtual DOM.",
17
- "retrieval_mode": "graph",
18
- "notes": "Tests DEPENDS_ON relationship traversal"
19
- },
20
- {
21
- "id": 3,
22
- "category": "multi_hop",
23
- "difficulty": "medium",
24
- "question": "Who manages the team that works on Project X?",
25
- "expected_answer": "The project manager of the team working on Project X is [extracted name].",
26
- "retrieval_mode": "graph",
27
- "notes": "Tests 2-hop traversal: Person MANAGES Person WORKS_AT Project"
28
- },
29
- {
30
- "id": 4,
31
- "category": "multi_hop",
32
- "difficulty": "hard",
33
- "question": "What skills does the manager of the team working on the authentication service have?",
34
- "expected_answer": "The manager has skills in [extracted from graph].",
35
- "retrieval_mode": "graph",
36
- "notes": "Tests 3-hop traversal: Service → Team → Manager → Skills"
37
- },
38
- {
39
- "id": 5,
40
- "category": "multi_hop",
41
- "difficulty": "hard",
42
- "question": "Which organizations are located in the same city as the headquarters of the company that created ChatGPT?",
43
- "expected_answer": "Organizations located in San Francisco include [extracted names].",
44
- "retrieval_mode": "graph",
45
- "notes": "Tests 3-hop: Product CREATED_BY Org LOCATED_IN Location ← Org"
46
- },
47
- {
48
- "id": 6,
49
- "category": "relationship",
50
- "difficulty": "easy",
51
- "question": "What companies are competitors of Google?",
52
- "expected_answer": "Google's competitors include Microsoft, Apple, and Meta.",
53
- "retrieval_mode": "graph",
54
- "notes": "Tests COMPETES_WITH relationship"
55
- },
56
- {
57
- "id": 7,
58
- "category": "relationship",
59
- "difficulty": "medium",
60
- "question": "List all partnerships mentioned in the document.",
61
- "expected_answer": "The partnerships include [list of PARTNER_OF relationships].",
62
- "retrieval_mode": "graph",
63
- "notes": "Tests PARTNER_OF relationship enumeration"
64
- },
65
- {
66
- "id": 8,
67
- "category": "summarization",
68
- "difficulty": "medium",
69
- "question": "Give me an overview of the organizational structure described in the document.",
70
- "expected_answer": "The document describes [hierarchy summary with entities and relationships].",
71
- "retrieval_mode": "hybrid",
72
- "notes": "Tests community-level summarization capability"
73
- },
74
- {
75
- "id": 9,
76
- "category": "summarization",
77
- "difficulty": "hard",
78
- "question": "Summarize the key technology stack and dependencies mentioned across all documents.",
79
- "expected_answer": "The technology stack includes [technologies] with dependencies on [dependencies].",
80
- "retrieval_mode": "hybrid",
81
- "notes": "Tests cross-document synthesis"
82
- },
83
- {
84
- "id": 10,
85
- "category": "vector_specific",
86
- "difficulty": "easy",
87
- "question": "What is the main topic of the uploaded documents?",
88
- "expected_answer": "The documents primarily discuss [topic].",
89
- "retrieval_mode": "vector",
90
- "notes": "Tests basic vector similarity retrieval"
91
- },
92
- {
93
- "id": 11,
94
- "category": "vector_specific",
95
- "difficulty": "medium",
96
- "question": "Find all sections that discuss machine learning concepts.",
97
- "expected_answer": "Machine learning is discussed in [sections with ML-related content].",
98
- "retrieval_mode": "vector",
99
- "notes": "Tests semantic similarity for topic-specific retrieval"
100
- },
101
- {
102
- "id": 12,
103
- "category": "hybrid",
104
- "difficulty": "medium",
105
- "question": "How does the authentication system work and who is responsible for it?",
106
- "expected_answer": "The authentication system [description] and is managed by [person/team].",
107
- "retrieval_mode": "hybrid",
108
- "notes": "Tests combining vector context with graph relationships"
109
- },
110
- {
111
- "id": 13,
112
- "category": "hybrid",
113
- "difficulty": "hard",
114
- "question": "What are the risks to the project timeline and which teams are affected?",
115
- "expected_answer": "Risks include [risks from text] affecting [teams from graph].",
116
- "retrieval_mode": "hybrid",
117
- "notes": "Tests combining textual risk analysis with organizational graph"
118
- },
119
- {
120
- "id": 14,
121
- "category": "community",
122
- "difficulty": "medium",
123
- "question": "What are the main topic clusters in this knowledge graph?",
124
- "expected_answer": "The graph contains communities focused on [community themes].",
125
- "retrieval_mode": "graph",
126
- "notes": "Tests community detection and thematic grouping"
127
- },
128
- {
129
- "id": 15,
130
- "category": "community",
131
- "difficulty": "hard",
132
- "question": "Which community of entities is most central to the overall knowledge graph?",
133
- "expected_answer": "The most central community focuses on [theme] with [key entities].",
134
- "retrieval_mode": "graph",
135
- "notes": "Tests community importance ranking via degree centrality"
136
- },
137
- {
138
- "id": 16,
139
- "category": "comparison",
140
- "difficulty": "medium",
141
- "question": "Compare graph-based retrieval with vector-based retrieval for this question: What is the company structure?",
142
- "expected_answer": "Graph retrieval provides structured org chart data; vector retrieval provides relevant text passages.",
143
- "retrieval_mode": "compare",
144
- "notes": "Tests retrieval mode comparison capability"
145
- },
146
- {
147
- "id": 17,
148
- "category": "cypher",
149
- "difficulty": "medium",
150
- "question": "Show me all Person nodes that have more than 3 connections.",
151
- "expected_answer": "Cypher: MATCH (p:Person)-[r]-() WITH p, count(r) AS degree WHERE degree > 3 RETURN p.name, degree",
152
- "retrieval_mode": "graph",
153
- "notes": "Tests NL-to-Cypher translation"
154
- },
155
- {
156
- "id": 18,
157
- "category": "cypher",
158
- "difficulty": "hard",
159
- "question": "Find all entities that are within 2 hops of 'Google' and belong to the TECHNOLOGY type.",
160
- "expected_answer": "Cypher: MATCH path = (g:Organization {name: 'Google'})-[*1..2]-(t:Technology) RETURN t",
161
- "retrieval_mode": "graph",
162
- "notes": "Tests multi-hop Cypher with type filtering"
163
- },
164
- {
165
- "id": 19,
166
- "category": "edge_case",
167
- "difficulty": "hard",
168
- "question": "What entities exist in the graph that have no relationships?",
169
- "expected_answer": "Orphan entities include [list of disconnected nodes].",
170
- "retrieval_mode": "graph",
171
- "notes": "Tests handling of disconnected graph components"
172
- },
173
- {
174
- "id": 20,
175
- "category": "edge_case",
176
- "difficulty": "hard",
177
- "question": "Which entities appear in multiple documents?",
178
- "expected_answer": "Entities found in multiple documents: [list with document references].",
179
- "retrieval_mode": "graph",
180
- "notes": "Tests cross-document entity resolution"
181
- }
182
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/package-lock.json CHANGED
@@ -260,6 +260,66 @@
260
  "glob": "10.3.10"
261
  }
262
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
263
  "node_modules/@next/swc-linux-x64-gnu": {
264
  "version": "14.2.5",
265
  "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.5.tgz",
@@ -276,6 +336,66 @@
276
  "node": ">= 10"
277
  }
278
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
279
  "node_modules/@nodelib/fs.scandir": {
280
  "version": "2.1.5",
281
  "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
 
260
  "glob": "10.3.10"
261
  }
262
  },
263
+ "node_modules/@next/swc-darwin-arm64": {
264
+ "version": "14.2.5",
265
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.5.tgz",
266
+ "integrity": "sha512-/9zVxJ+K9lrzSGli1///ujyRfon/ZneeZ+v4ptpiPoOU+GKZnm8Wj8ELWU1Pm7GHltYRBklmXMTUqM/DqQ99FQ==",
267
+ "cpu": [
268
+ "arm64"
269
+ ],
270
+ "optional": true,
271
+ "os": [
272
+ "darwin"
273
+ ],
274
+ "engines": {
275
+ "node": ">= 10"
276
+ }
277
+ },
278
+ "node_modules/@next/swc-darwin-x64": {
279
+ "version": "14.2.5",
280
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.5.tgz",
281
+ "integrity": "sha512-vXHOPCwfDe9qLDuq7U1OYM2wUY+KQ4Ex6ozwsKxp26BlJ6XXbHleOUldenM67JRyBfVjv371oneEvYd3H2gNSA==",
282
+ "cpu": [
283
+ "x64"
284
+ ],
285
+ "optional": true,
286
+ "os": [
287
+ "darwin"
288
+ ],
289
+ "engines": {
290
+ "node": ">= 10"
291
+ }
292
+ },
293
+ "node_modules/@next/swc-linux-arm64-gnu": {
294
+ "version": "14.2.5",
295
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.5.tgz",
296
+ "integrity": "sha512-vlhB8wI+lj8q1ExFW8lbWutA4M2ZazQNvMWuEDqZcuJJc78iUnLdPPunBPX8rC4IgT6lIx/adB+Cwrl99MzNaA==",
297
+ "cpu": [
298
+ "arm64"
299
+ ],
300
+ "optional": true,
301
+ "os": [
302
+ "linux"
303
+ ],
304
+ "engines": {
305
+ "node": ">= 10"
306
+ }
307
+ },
308
+ "node_modules/@next/swc-linux-arm64-musl": {
309
+ "version": "14.2.5",
310
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.5.tgz",
311
+ "integrity": "sha512-NpDB9NUR2t0hXzJJwQSGu1IAOYybsfeB+LxpGsXrRIb7QOrYmidJz3shzY8cM6+rO4Aojuef0N/PEaX18pi9OA==",
312
+ "cpu": [
313
+ "arm64"
314
+ ],
315
+ "optional": true,
316
+ "os": [
317
+ "linux"
318
+ ],
319
+ "engines": {
320
+ "node": ">= 10"
321
+ }
322
+ },
323
  "node_modules/@next/swc-linux-x64-gnu": {
324
  "version": "14.2.5",
325
  "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.5.tgz",
 
336
  "node": ">= 10"
337
  }
338
  },
339
+ "node_modules/@next/swc-linux-x64-musl": {
340
+ "version": "14.2.5",
341
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.5.tgz",
342
+ "integrity": "sha512-6QLwi7RaYiQDcRDSU/os40r5o06b5ue7Jsk5JgdRBGGp8l37RZEh9JsLSM8QF0YDsgcosSeHjglgqi25+m04IQ==",
343
+ "cpu": [
344
+ "x64"
345
+ ],
346
+ "optional": true,
347
+ "os": [
348
+ "linux"
349
+ ],
350
+ "engines": {
351
+ "node": ">= 10"
352
+ }
353
+ },
354
+ "node_modules/@next/swc-win32-arm64-msvc": {
355
+ "version": "14.2.5",
356
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.5.tgz",
357
+ "integrity": "sha512-1GpG2VhbspO+aYoMOQPQiqc/tG3LzmsdBH0LhnDS3JrtDx2QmzXe0B6mSZZiN3Bq7IOMXxv1nlsjzoS1+9mzZw==",
358
+ "cpu": [
359
+ "arm64"
360
+ ],
361
+ "optional": true,
362
+ "os": [
363
+ "win32"
364
+ ],
365
+ "engines": {
366
+ "node": ">= 10"
367
+ }
368
+ },
369
+ "node_modules/@next/swc-win32-ia32-msvc": {
370
+ "version": "14.2.5",
371
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.5.tgz",
372
+ "integrity": "sha512-Igh9ZlxwvCDsu6438FXlQTHlRno4gFpJzqPjSIBZooD22tKeI4fE/YMRoHVJHmrQ2P5YL1DoZ0qaOKkbeFWeMg==",
373
+ "cpu": [
374
+ "ia32"
375
+ ],
376
+ "optional": true,
377
+ "os": [
378
+ "win32"
379
+ ],
380
+ "engines": {
381
+ "node": ">= 10"
382
+ }
383
+ },
384
+ "node_modules/@next/swc-win32-x64-msvc": {
385
+ "version": "14.2.5",
386
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.5.tgz",
387
+ "integrity": "sha512-tEQ7oinq1/CjSG9uSTerca3v4AZ+dFa+4Yu6ihaG8Ud8ddqLQgFGcnwYls13H5X5CPDPZJdYxyeMui6muOLd4g==",
388
+ "cpu": [
389
+ "x64"
390
+ ],
391
+ "optional": true,
392
+ "os": [
393
+ "win32"
394
+ ],
395
+ "engines": {
396
+ "node": ">= 10"
397
+ }
398
+ },
399
  "node_modules/@nodelib/fs.scandir": {
400
  "version": "2.1.5",
401
  "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
frontend/src/app/(dashboard)/documents/page.tsx CHANGED
@@ -1,31 +1,56 @@
1
  'use client';
2
 
3
  import { useEffect, useState } from 'react';
4
- import { useRouter } from 'next/navigation';
5
  import api from '@/lib/axios';
6
  import { useDocumentPolling } from '@/hooks/useDocumentPolling';
7
- import { useDocumentsStore } from '@/store/documents';
8
  import { useGraphStore } from '@/store/graph';
9
  import { DocumentUpload } from '@/components/documents/DocumentUpload';
10
  import { DocumentRow } from '@/components/documents/DocumentRow';
11
  import { Card, CardContent } from '@/components/ui/card';
 
 
 
12
 
13
  export default function DocumentsPage() {
14
  useDocumentPolling();
 
 
15
  const documents = useDocumentsStore((s) => s.documents);
16
- const setHighlighted = useGraphStore((s) => s.setHighlighted);
 
17
  const graphData = useGraphStore((s) => s.data);
18
- const router = useRouter();
19
  const [notice, setNotice] = useState('');
 
 
20
 
21
  const completed = documents.filter((d) => d.status === 'COMPLETED').length;
22
 
23
- function filterToDoc(docName: string) {
24
- const ids = graphData.nodes
25
- .filter((n) => n.sourceDoc === docName)
26
- .map((n) => n.id);
27
- if (ids.length) setHighlighted(ids);
28
- router.push('/');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  }
30
 
31
  async function retryDocument(id: string) {
@@ -41,62 +66,291 @@ export default function DocumentsPage() {
41
  setTimeout(() => setNotice(''), 4000);
42
  }
43
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  return (
45
- <div className="flex h-full flex-col gap-4 overflow-y-auto p-4 scrollbar-thin">
46
- <div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
47
- <Card>
48
- <CardContent className="p-4">
49
- <h2 className="mb-3 text-sm font-semibold">Upload Document</h2>
50
- <DocumentUpload />
51
- </CardContent>
52
- </Card>
53
-
54
- <Card>
55
- <CardContent className="p-4">
56
- <div className="grid grid-cols-3 gap-3 text-center">
57
- <div className="rounded-md bg-bg-elevated p-3">
58
- <p className="text-xl font-bold text-accent-violet">{documents.length}</p>
59
- <p className="text-xs text-text-muted">Total</p>
60
- </div>
61
- <div className="rounded-md bg-bg-elevated p-3">
62
- <p className="text-xl font-bold text-success">{completed}</p>
63
- <p className="text-xs text-text-muted">Completed</p>
64
- </div>
65
- <div className="rounded-md bg-bg-elevated p-3">
66
- <p className="text-xl font-bold text-warning">
67
- {documents.filter((d) => d.status === 'PROCESSING' || d.status === 'PENDING').length}
68
- </p>
69
- <p className="text-xs text-text-muted">Active</p>
 
 
 
70
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  </div>
72
- <p className="mt-3 text-xs text-text-muted">
73
- Click a completed document to filter the graph to its entities.
74
- </p>
75
- </CardContent>
76
- </Card>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  </div>
78
 
79
- <div>
80
- <h2 className="mb-2 text-sm font-semibold">Document List</h2>
81
- <div className="space-y-2">
82
- {documents.length === 0 && (
83
- <p className="text-sm text-text-muted">No documents yet. Upload one to begin.</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  )}
85
- {documents.map((doc) => (
86
- <DocumentRow
87
- key={doc.id}
88
- doc={doc}
89
- onFilter={filterToDoc}
90
- onRetry={retryDocument}
91
- />
92
- ))}
93
- {notice && (
94
- <p className="rounded-md border border-accent-cyan/30 bg-accent-cyan/10 px-3 py-2 text-xs text-accent-cyan">
95
- {notice}
 
96
  </p>
97
- )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  </div>
99
- </div>
100
  </div>
101
  );
102
  }
 
1
  'use client';
2
 
3
  import { useEffect, useState } from 'react';
 
4
  import api from '@/lib/axios';
5
  import { useDocumentPolling } from '@/hooks/useDocumentPolling';
6
+ import { useDocumentsStore, DocumentItem } from '@/store/documents';
7
  import { useGraphStore } from '@/store/graph';
8
  import { DocumentUpload } from '@/components/documents/DocumentUpload';
9
  import { DocumentRow } from '@/components/documents/DocumentRow';
10
  import { Card, CardContent } from '@/components/ui/card';
11
+ import { Search, X, Layers, AlertCircle } from 'lucide-react';
12
+ import { cn } from '@/lib/utils';
13
+ import { useGraphData } from '@/hooks/useGraphData';
14
 
15
  export default function DocumentsPage() {
16
  useDocumentPolling();
17
+ useGraphData(); // Keep graph data updated
18
+
19
  const documents = useDocumentsStore((s) => s.documents);
20
+ const selectedIds = useDocumentsStore((s) => s.selectedDocumentIds);
21
+ const toggleSelectDocument = useDocumentsStore((s) => s.toggleSelectDocument);
22
  const graphData = useGraphStore((s) => s.data);
 
23
  const [notice, setNotice] = useState('');
24
+ const [selectedDetailDoc, setSelectedDetailDoc] = useState<DocumentItem | null>(null);
25
+ const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
26
 
27
  const completed = documents.filter((d) => d.status === 'COMPLETED').length;
28
 
29
+ async function deleteDocument(id: string) {
30
+ try {
31
+ await api.delete(`/documents/${id}/`);
32
+
33
+ // Update documents list
34
+ useDocumentsStore.getState().setDocuments(
35
+ useDocumentsStore.getState().documents.filter((d) => d.id !== id)
36
+ );
37
+
38
+ // Deselect if currently selected
39
+ const store = useDocumentsStore.getState();
40
+ if (store.selectedDocumentIds.includes(id)) {
41
+ store.toggleSelectDocument(id);
42
+ }
43
+
44
+ // Close details panel if this document was open
45
+ if (selectedDetailDoc?.id === id) {
46
+ setSelectedDetailDoc(null);
47
+ }
48
+
49
+ setNotice('Document successfully deleted.');
50
+ } catch {
51
+ setNotice('Could not delete the document. Please try again.');
52
+ }
53
+ setTimeout(() => setNotice(''), 4000);
54
  }
55
 
56
  async function retryDocument(id: string) {
 
66
  setTimeout(() => setNotice(''), 4000);
67
  }
68
 
69
+ // Filter local entities and relations for the selected detail document
70
+ const docEntities = selectedDetailDoc
71
+ ? graphData.nodes.filter((n) => n.sourceDoc === selectedDetailDoc.name)
72
+ : [];
73
+ const docLinks = selectedDetailDoc
74
+ ? graphData.links.filter((l) => l.sourceDoc === selectedDetailDoc.name)
75
+ : [];
76
+
77
+ const [entityQuery, setEntityQuery] = useState('');
78
+ const [relationQuery, setRelationQuery] = useState('');
79
+ const [activeTab, setActiveTab] = useState<'entities' | 'relations'>('entities');
80
+
81
+ const filteredEntities = docEntities.filter(
82
+ (e) =>
83
+ e.name.toLowerCase().includes(entityQuery.toLowerCase()) ||
84
+ e.type.toLowerCase().includes(entityQuery.toLowerCase())
85
+ );
86
+
87
+ const filteredLinks = docLinks.filter(
88
+ (l) =>
89
+ l.source.toLowerCase().includes(relationQuery.toLowerCase()) ||
90
+ l.target.toLowerCase().includes(relationQuery.toLowerCase()) ||
91
+ l.type.toLowerCase().includes(relationQuery.toLowerCase())
92
+ );
93
+
94
+ // Sync selectedDetailDoc when document properties change
95
+ useEffect(() => {
96
+ if (selectedDetailDoc) {
97
+ const match = documents.find((d) => d.id === selectedDetailDoc.id);
98
+ if (match) setSelectedDetailDoc(match);
99
+ else setSelectedDetailDoc(null);
100
+ }
101
+ }, [documents]);
102
+
103
  return (
104
+ <div className="flex h-full gap-4 p-4 overflow-hidden">
105
+ {/* Left side master view */}
106
+ <div className="flex-1 flex flex-col gap-4 overflow-y-auto scrollbar-thin pr-1">
107
+ <div className="grid grid-cols-1 gap-4 lg:grid-cols-2 shrink-0">
108
+ <Card>
109
+ <CardContent className="p-4">
110
+ <h2 className="mb-3 text-sm font-semibold text-text-primary">Upload Document</h2>
111
+ <DocumentUpload />
112
+ </CardContent>
113
+ </Card>
114
+
115
+ <Card>
116
+ <CardContent className="p-4">
117
+ <div className="grid grid-cols-3 gap-3 text-center">
118
+ <div className="rounded-md bg-bg-elevated p-3 border border-border/20">
119
+ <p className="text-xl font-bold text-accent-violet">{documents.length}</p>
120
+ <p className="text-xs text-text-muted font-medium">Total</p>
121
+ </div>
122
+ <div className="rounded-md bg-bg-elevated p-3 border border-border/20">
123
+ <p className="text-xl font-bold text-success">{completed}</p>
124
+ <p className="text-xs text-text-muted font-medium">Completed</p>
125
+ </div>
126
+ <div className="rounded-md bg-bg-elevated p-3 border border-border/20">
127
+ <p className="text-xl font-bold text-warning">
128
+ {documents.filter((d) => d.status === 'PROCESSING' || d.status === 'PENDING').length}
129
+ </p>
130
+ <p className="text-xs text-text-muted font-medium">Active</p>
131
+ </div>
132
  </div>
133
+ <p className="mt-3 text-[11px] text-text-muted leading-relaxed">
134
+ Check boxes to select source context. Click rows to view extracted entities and relationships.
135
+ </p>
136
+ </CardContent>
137
+ </Card>
138
+ </div>
139
+
140
+ <div className="flex-1 flex flex-col min-h-0 bg-bg-surface rounded-lg border border-border p-4">
141
+ <div className="flex items-center justify-between pb-3 mb-3 border-b border-border shrink-0">
142
+ <h2 className="text-sm font-semibold text-text-primary">Document List</h2>
143
+ <div className="flex items-center gap-3">
144
+ <button
145
+ type="button"
146
+ onClick={() => useDocumentsStore.getState().selectAllDocuments()}
147
+ className="text-[11px] text-accent-violet hover:underline font-medium"
148
+ >
149
+ Select All
150
+ </button>
151
+ <span className="text-[11px] text-text-muted">|</span>
152
+ <button
153
+ type="button"
154
+ onClick={() => useDocumentsStore.getState().deselectAllDocuments()}
155
+ className="text-[11px] text-text-muted hover:underline font-medium"
156
+ >
157
+ Deselect All
158
+ </button>
159
  </div>
160
+ </div>
161
+
162
+ <div className="flex-1 overflow-y-auto space-y-2 pr-1 scrollbar-thin min-h-0">
163
+ {documents.length === 0 ? (
164
+ <p className="text-sm text-text-muted py-8 text-center">No documents uploaded yet.</p>
165
+ ) : (
166
+ documents.map((doc) => (
167
+ <DocumentRow
168
+ key={doc.id}
169
+ doc={doc}
170
+ isSelected={selectedIds.includes(doc.id)}
171
+ onToggleSelect={toggleSelectDocument}
172
+ onViewDetails={setSelectedDetailDoc}
173
+ onDelete={setDeleteConfirmId}
174
+ onRetry={retryDocument}
175
+ />
176
+ ))
177
+ )}
178
+
179
+ {notice && (
180
+ <div className="mt-3 p-2.5 rounded-md border border-accent-cyan/20 bg-accent-cyan/10 text-xs text-accent-cyan font-medium">
181
+ {notice}
182
+ </div>
183
+ )}
184
+ </div>
185
+ </div>
186
  </div>
187
 
188
+ {/* Right side details card */}
189
+ <Card className="w-[380px] shrink-0 hidden lg:flex flex-col h-full overflow-hidden border border-border bg-bg-surface">
190
+ <CardContent className="p-4 flex flex-col h-full overflow-hidden min-h-0">
191
+ {selectedDetailDoc ? (
192
+ <div className="flex flex-col h-full overflow-hidden min-h-0">
193
+ <div className="flex items-center justify-between pb-3 border-b border-border shrink-0">
194
+ <div className="min-w-0 flex-1">
195
+ <h3 className="text-sm font-semibold truncate text-text-primary pr-2" title={selectedDetailDoc.name}>
196
+ {selectedDetailDoc.name}
197
+ </h3>
198
+ <p className="text-[10px] text-text-muted font-medium mt-0.5">Extracted graph metadata</p>
199
+ </div>
200
+ <button
201
+ type="button"
202
+ onClick={() => setSelectedDetailDoc(null)}
203
+ className="p-1 hover:bg-bg-elevated rounded transition-colors text-text-muted hover:text-text-primary"
204
+ >
205
+ <X className="h-4 w-4" />
206
+ </button>
207
+ </div>
208
+
209
+ <div className="flex border-b border-border text-xs mt-3 shrink-0">
210
+ <button
211
+ type="button"
212
+ onClick={() => setActiveTab('entities')}
213
+ className={cn(
214
+ 'flex-1 pb-2 font-semibold border-b-2 transition-colors text-center',
215
+ activeTab === 'entities'
216
+ ? 'border-accent-violet text-accent-violet'
217
+ : 'border-transparent text-text-muted hover:text-text-primary'
218
+ )}
219
+ >
220
+ Entities ({docEntities.length})
221
+ </button>
222
+ <button
223
+ type="button"
224
+ onClick={() => setActiveTab('relations')}
225
+ className={cn(
226
+ 'flex-1 pb-2 font-semibold border-b-2 transition-colors text-center',
227
+ activeTab === 'relations'
228
+ ? 'border-accent-violet text-accent-violet'
229
+ : 'border-transparent text-text-muted hover:text-text-primary'
230
+ )}
231
+ >
232
+ Relationships ({docLinks.length})
233
+ </button>
234
+ </div>
235
+
236
+ <div className="flex-1 mt-3 overflow-hidden flex flex-col min-h-0">
237
+ {activeTab === 'entities' ? (
238
+ <div className="flex flex-col h-full min-h-0">
239
+ <div className="relative mb-2 shrink-0">
240
+ <Search className="absolute left-2.5 top-2 h-3.5 w-3.5 text-text-muted" />
241
+ <input
242
+ type="text"
243
+ placeholder="Search entities..."
244
+ value={entityQuery}
245
+ onChange={(e) => setEntityQuery(e.target.value)}
246
+ className="w-full pl-8 pr-3 py-1.5 text-xs rounded border border-border bg-bg-elevated focus:outline-none focus:ring-1 focus:ring-accent-violet text-text-primary placeholder:text-text-muted/65"
247
+ />
248
+ </div>
249
+ <div className="flex-1 overflow-y-auto space-y-2 pr-1 scrollbar-thin min-h-0">
250
+ {filteredEntities.length === 0 ? (
251
+ <p className="text-xs text-text-muted text-center py-6">No matching entities found.</p>
252
+ ) : (
253
+ filteredEntities.map((ent, i) => (
254
+ <div key={i} className="p-2.5 rounded bg-bg-elevated border border-border/40 text-xs">
255
+ <div className="flex items-center justify-between gap-2 mb-1">
256
+ <span className="font-semibold text-text-primary truncate">{ent.name}</span>
257
+ <span className="px-1.5 py-0.5 rounded-full bg-accent-violet/15 text-accent-violet text-[9px] uppercase font-bold shrink-0">
258
+ {ent.type}
259
+ </span>
260
+ </div>
261
+ {ent.description && (
262
+ <p className="text-text-muted text-[11px] leading-relaxed mt-1 font-normal">{ent.description}</p>
263
+ )}
264
+ </div>
265
+ ))
266
+ )}
267
+ </div>
268
+ </div>
269
+ ) : (
270
+ <div className="flex flex-col h-full min-h-0">
271
+ <div className="relative mb-2 shrink-0">
272
+ <Search className="absolute left-2.5 top-2 h-3.5 w-3.5 text-text-muted" />
273
+ <input
274
+ type="text"
275
+ placeholder="Search relationships..."
276
+ value={relationQuery}
277
+ onChange={(e) => setRelationQuery(e.target.value)}
278
+ className="w-full pl-8 pr-3 py-1.5 text-xs rounded border border-border bg-bg-elevated focus:outline-none focus:ring-1 focus:ring-accent-violet text-text-primary placeholder:text-text-muted/65"
279
+ />
280
+ </div>
281
+ <div className="flex-1 overflow-y-auto space-y-2 pr-1 scrollbar-thin min-h-0">
282
+ {filteredLinks.length === 0 ? (
283
+ <p className="text-xs text-text-muted text-center py-6">No matching relationships found.</p>
284
+ ) : (
285
+ filteredLinks.map((link, i) => (
286
+ <div key={i} className="p-2.5 rounded bg-bg-elevated border border-border/40 text-xs space-y-1">
287
+ <div className="flex flex-wrap items-center gap-1.5 text-[11px] leading-relaxed">
288
+ <span className="font-semibold text-text-primary">{link.source}</span>
289
+ <span className="px-1.5 py-0.5 rounded bg-accent-cyan/15 text-accent-cyan text-[9px] font-bold shrink-0">
290
+ {link.type}
291
+ </span>
292
+ <span className="font-semibold text-text-primary">{link.target}</span>
293
+ </div>
294
+ {link.description && (
295
+ <p className="text-text-muted text-[11px] leading-relaxed mt-1 font-normal">{link.description}</p>
296
+ )}
297
+ {link.confidence !== undefined && (
298
+ <p className="text-[10px] text-text-muted/75 font-medium mt-1">Confidence: {link.confidence.toFixed(2)}</p>
299
+ )}
300
+ </div>
301
+ ))
302
+ )}
303
+ </div>
304
+ </div>
305
+ )}
306
+ </div>
307
+ </div>
308
+ ) : (
309
+ <div className="flex flex-col items-center justify-center h-full text-center p-4">
310
+ <div className="w-12 h-12 rounded-full bg-accent-violet/10 flex items-center justify-center text-accent-violet mb-3 shrink-0">
311
+ <Layers className="h-5 w-5" />
312
+ </div>
313
+ <h4 className="text-xs font-semibold text-text-primary mb-1">No Document Selected</h4>
314
+ <p className="text-[11px] text-text-muted max-w-[220px] leading-relaxed">
315
+ Click a completed document card on the left to explore its extracted entities and relationships.
316
+ </p>
317
+ </div>
318
  )}
319
+ </CardContent>
320
+ </Card>
321
+
322
+ {deleteConfirmId && (
323
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
324
+ <div className="w-full max-w-md rounded-lg border border-border bg-bg-surface p-6 shadow-2xl animate-in fade-in zoom-in-95 duration-150">
325
+ <div className="flex items-center gap-3 text-error mb-4">
326
+ <AlertCircle className="h-6 w-6" />
327
+ <h3 className="text-lg font-bold text-text-primary">Confirm Deletion</h3>
328
+ </div>
329
+ <p className="text-sm text-text-secondary leading-relaxed mb-6">
330
+ Are you sure you want to delete this document? This will remove all its nodes and relationships from the database. This action cannot be undone.
331
  </p>
332
+ <div className="flex justify-end gap-3">
333
+ <button
334
+ type="button"
335
+ onClick={() => setDeleteConfirmId(null)}
336
+ className="rounded-md border border-border px-4 py-2 text-xs font-semibold text-text-secondary hover:bg-bg-elevated hover:text-text-primary transition-colors"
337
+ >
338
+ Cancel
339
+ </button>
340
+ <button
341
+ type="button"
342
+ onClick={() => {
343
+ deleteDocument(deleteConfirmId);
344
+ setDeleteConfirmId(null);
345
+ }}
346
+ className="rounded-md bg-error px-4 py-2 text-xs font-semibold text-white hover:bg-error/90 transition-colors"
347
+ >
348
+ Delete
349
+ </button>
350
+ </div>
351
+ </div>
352
  </div>
353
+ )}
354
  </div>
355
  );
356
  }
frontend/src/app/(dashboard)/layout.tsx CHANGED
@@ -1,6 +1,6 @@
1
  'use client';
2
 
3
- import { useEffect } from 'react';
4
  import { useRouter } from 'next/navigation';
5
  import { useAuthStore } from '@/store/auth';
6
  import { DashboardLayout } from '@/components/layout/DashboardLayout';
@@ -8,12 +8,21 @@ import { DashboardLayout } from '@/components/layout/DashboardLayout';
8
  export default function DashboardGroupLayout({ children }: { children: React.ReactNode }) {
9
  const isAuthed = useAuthStore((s) => s.isAuthenticated);
10
  const router = useRouter();
 
11
 
12
  useEffect(() => {
13
- if (!isAuthed) router.replace('/login');
14
- }, [isAuthed, router]);
 
 
15
 
16
- if (!isAuthed) return null;
 
 
 
 
 
 
17
 
18
  return <DashboardLayout>{children}</DashboardLayout>;
19
  }
 
1
  'use client';
2
 
3
+ import { useEffect, useState } from 'react';
4
  import { useRouter } from 'next/navigation';
5
  import { useAuthStore } from '@/store/auth';
6
  import { DashboardLayout } from '@/components/layout/DashboardLayout';
 
8
  export default function DashboardGroupLayout({ children }: { children: React.ReactNode }) {
9
  const isAuthed = useAuthStore((s) => s.isAuthenticated);
10
  const router = useRouter();
11
+ const [isReady, setIsReady] = useState(false);
12
 
13
  useEffect(() => {
14
+ // Wait for Zustand persisted state to rehydrate (50ms)
15
+ const timer = setTimeout(() => setIsReady(true), 50);
16
+ return () => clearTimeout(timer);
17
+ }, []);
18
 
19
+ useEffect(() => {
20
+ if (isReady && !isAuthed) {
21
+ router.replace('/login');
22
+ }
23
+ }, [isReady, isAuthed, router]);
24
+
25
+ if (!isReady || !isAuthed) return null;
26
 
27
  return <DashboardLayout>{children}</DashboardLayout>;
28
  }
frontend/src/components/dashboard/MainQueryView.tsx CHANGED
@@ -13,6 +13,7 @@ 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
 
17
  interface QueryResult {
18
  answer: string;
@@ -30,6 +31,8 @@ export function MainQueryView() {
30
  const selectEntity = useGraphStore((s) => s.selectEntity);
31
  const data = useGraphStore((s) => s.data);
32
 
 
 
33
  const [query, setQuery] = useState('');
34
  const [mode, setMode] = useState<QueryMode>('hybrid');
35
  const [loading, setLoading] = useState(false);
@@ -52,7 +55,11 @@ export function MainQueryView() {
52
  setLoading(true);
53
  setResult(null);
54
  try {
55
- const { data: res } = await api.post('/query/', { query, mode });
 
 
 
 
56
  const mapped: QueryResult = {
57
  answer: res.answer,
58
  confidence: res.confidence,
 
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;
 
31
  const selectEntity = useGraphStore((s) => s.selectEntity);
32
  const data = useGraphStore((s) => s.data);
33
 
34
+ const selectedIds = useDocumentsStore((s) => s.selectedDocumentIds);
35
+
36
  const [query, setQuery] = useState('');
37
  const [mode, setMode] = useState<QueryMode>('hybrid');
38
  const [loading, setLoading] = useState(false);
 
55
  setLoading(true);
56
  setResult(null);
57
  try {
58
+ const { data: res } = await api.post('/query/', {
59
+ query,
60
+ mode,
61
+ document_ids: selectedIds.length > 0 ? selectedIds : undefined
62
+ });
63
  const mapped: QueryResult = {
64
  answer: res.answer,
65
  confidence: res.confidence,
frontend/src/components/documents/DocumentRow.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { FileText, AlertCircle, RotateCcw } from 'lucide-react';
2
  import { DocumentItem } from '@/store/documents';
3
  import { StatusBadge } from './StatusBadge';
4
  import { ProcessingSteps } from './ProcessingSteps';
@@ -19,35 +19,65 @@ function formatDate(iso: string) {
19
 
20
  export function DocumentRow({
21
  doc,
22
- onFilter,
 
 
 
23
  onRetry,
24
  }: {
25
  doc: DocumentItem;
26
- onFilter?: (name: string) => void;
 
 
 
27
  onRetry?: (id: string) => void;
28
  }) {
29
  return (
30
  <div
31
  className={cn(
32
- 'rounded-md border border-border bg-bg-surface p-3',
33
- doc.status === 'COMPLETED' && 'cursor-pointer hover:border-accent-violet/40'
34
  )}
35
- onClick={() => doc.status === 'COMPLETED' && onFilter?.(doc.name)}
36
  >
37
  <div className="flex items-center justify-between gap-3">
38
- <div className="flex min-w-0 items-center gap-2">
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  <FileText className="h-4 w-4 shrink-0 text-text-muted" />
40
  <span className="truncate text-sm font-medium text-text-primary">{doc.name}</span>
41
  </div>
42
- <StatusBadge status={doc.status} />
 
 
 
 
 
 
 
 
 
 
 
43
  </div>
44
 
45
  {doc.status === 'PROCESSING' ? (
46
- <div className="mt-2">
47
- <ProcessingSteps currentStep={doc.processingStep} />
48
  </div>
49
  ) : (
50
- <div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-text-muted">
51
  {doc.entities !== undefined && <span>{doc.entities} entities</span>}
52
  {doc.relationships !== undefined && <span>{doc.relationships} relations</span>}
53
  <span>{formatDate(doc.uploadedAt)}</span>
@@ -55,7 +85,7 @@ export function DocumentRow({
55
  )}
56
 
57
  {doc.status === 'FAILED' && (
58
- <div className="mt-2 flex items-center justify-between gap-2 rounded-md bg-error/10 px-2 py-1.5 text-xs text-error">
59
  <span className="flex items-center gap-1">
60
  <AlertCircle className="h-3.5 w-3.5" /> {doc.error || 'Processing failed'}
61
  </span>
@@ -65,7 +95,7 @@ export function DocumentRow({
65
  e.stopPropagation();
66
  onRetry?.(doc.id);
67
  }}
68
- className="inline-flex items-center gap-1 font-medium hover:underline"
69
  >
70
  <RotateCcw className="h-3 w-3" /> Retry
71
  </button>
 
1
+ import { FileText, AlertCircle, RotateCcw, Trash2 } from 'lucide-react';
2
  import { DocumentItem } from '@/store/documents';
3
  import { StatusBadge } from './StatusBadge';
4
  import { ProcessingSteps } from './ProcessingSteps';
 
19
 
20
  export function DocumentRow({
21
  doc,
22
+ isSelected,
23
+ onToggleSelect,
24
+ onViewDetails,
25
+ onDelete,
26
  onRetry,
27
  }: {
28
  doc: DocumentItem;
29
+ isSelected?: boolean;
30
+ onToggleSelect?: (id: string) => void;
31
+ onViewDetails?: (doc: DocumentItem) => void;
32
+ onDelete?: (id: string) => void;
33
  onRetry?: (id: string) => void;
34
  }) {
35
  return (
36
  <div
37
  className={cn(
38
+ 'rounded-md border border-border bg-bg-surface p-3 transition-colors',
39
+ doc.status === 'COMPLETED' && 'hover:border-accent-violet/30 cursor-pointer'
40
  )}
41
+ onClick={() => doc.status === 'COMPLETED' && onViewDetails?.(doc)}
42
  >
43
  <div className="flex items-center justify-between gap-3">
44
+ <div className="flex min-w-0 flex-1 items-center gap-2">
45
+ {doc.status === 'COMPLETED' ? (
46
+ <input
47
+ type="checkbox"
48
+ checked={isSelected}
49
+ onChange={(e) => {
50
+ e.stopPropagation();
51
+ onToggleSelect?.(doc.id);
52
+ }}
53
+ className="h-4 w-4 rounded border-border bg-bg-surface text-accent-violet focus:ring-accent-violet cursor-pointer"
54
+ />
55
+ ) : (
56
+ <div className="w-4 h-4 shrink-0" /> // Spacer to preserve alignment
57
+ )}
58
  <FileText className="h-4 w-4 shrink-0 text-text-muted" />
59
  <span className="truncate text-sm font-medium text-text-primary">{doc.name}</span>
60
  </div>
61
+
62
+ <div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
63
+ <StatusBadge status={doc.status} />
64
+ <button
65
+ type="button"
66
+ onClick={() => onDelete?.(doc.id)}
67
+ className="rounded p-1 text-text-muted hover:bg-bg-elevated hover:text-error transition-colors"
68
+ title="Delete document"
69
+ >
70
+ <Trash2 className="h-4 w-4" />
71
+ </button>
72
+ </div>
73
  </div>
74
 
75
  {doc.status === 'PROCESSING' ? (
76
+ <div className="mt-2 pl-6">
77
+ <ProcessingSteps currentStep={doc.processingStep} status={doc.status} />
78
  </div>
79
  ) : (
80
+ <div className="mt-2 pl-6 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-text-muted">
81
  {doc.entities !== undefined && <span>{doc.entities} entities</span>}
82
  {doc.relationships !== undefined && <span>{doc.relationships} relations</span>}
83
  <span>{formatDate(doc.uploadedAt)}</span>
 
85
  )}
86
 
87
  {doc.status === 'FAILED' && (
88
+ <div className="mt-2 ml-6 flex items-center justify-between gap-2 rounded-md bg-error/10 px-2 py-1.5 text-xs text-error">
89
  <span className="flex items-center gap-1">
90
  <AlertCircle className="h-3.5 w-3.5" /> {doc.error || 'Processing failed'}
91
  </span>
 
95
  e.stopPropagation();
96
  onRetry?.(doc.id);
97
  }}
98
+ className="inline-flex items-center gap-1 font-medium hover:underline text-error"
99
  >
100
  <RotateCcw className="h-3 w-3" /> Retry
101
  </button>
frontend/src/components/documents/DocumentUpload.tsx CHANGED
@@ -18,9 +18,7 @@ export function DocumentUpload() {
18
 
19
  // Track real processing progress from the document store
20
  const liveDoc = uploadedDocId ? documents.find((d) => d.id === uploadedDocId) : null;
21
- const liveProgress = liveDoc?.status === 'COMPLETED' ? 100
22
- : liveDoc?.status === 'FAILED' ? 0
23
- : 50;
24
  const liveStep = liveDoc?.processingStep || null;
25
 
26
  // Clear upload state when document completes or fails
@@ -79,6 +77,7 @@ export function DocumentUpload() {
79
  status: doc.status,
80
  uploadedAt: doc.uploaded_at ?? new Date().toISOString(),
81
  processingStep: doc.processing_step ?? null,
 
82
  });
83
  setUploadedDocId(doc.id);
84
  } catch (err: any) {
 
18
 
19
  // Track real processing progress from the document store
20
  const liveDoc = uploadedDocId ? documents.find((d) => d.id === uploadedDocId) : null;
21
+ const liveProgress = liveDoc?.processingProgress ?? 0;
 
 
22
  const liveStep = liveDoc?.processingStep || null;
23
 
24
  // Clear upload state when document completes or fails
 
77
  status: doc.status,
78
  uploadedAt: doc.uploaded_at ?? new Date().toISOString(),
79
  processingStep: doc.processing_step ?? null,
80
+ processingProgress: doc.processing_progress ?? 0,
81
  });
82
  setUploadedDocId(doc.id);
83
  } catch (err: any) {
frontend/src/components/documents/ProcessingSteps.tsx CHANGED
@@ -2,11 +2,11 @@ import { CheckCircle2, Circle, Loader2 } from 'lucide-react';
2
 
3
  export const PROCESSING_STEPS = [
4
  'Loading document',
 
5
  'Extracting entities',
6
- 'Extracting relationships',
7
  'Resolving duplicates',
8
  'Building graph',
9
- 'Indexing vectors',
10
  ];
11
 
12
  interface StepCounts {
@@ -17,12 +17,29 @@ interface StepCounts {
17
 
18
  export function ProcessingSteps({
19
  currentStep,
 
20
  counts,
21
  }: {
22
  currentStep?: string | null;
 
23
  counts?: StepCounts;
24
  }) {
25
- const active = currentStep ? PROCESSING_STEPS.indexOf(currentStep) : -1;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
 
27
  function getCountLabel(step: string): string | null {
28
  if (!counts) return null;
 
2
 
3
  export const PROCESSING_STEPS = [
4
  'Loading document',
5
+ 'Indexing vectors',
6
  'Extracting entities',
 
7
  'Resolving duplicates',
8
  'Building graph',
9
+ 'Extracting relationships',
10
  ];
11
 
12
  interface StepCounts {
 
17
 
18
  export function ProcessingSteps({
19
  currentStep,
20
+ status,
21
  counts,
22
  }: {
23
  currentStep?: string | null;
24
+ status?: string;
25
  counts?: StepCounts;
26
  }) {
27
+ const isCompleted = status === 'COMPLETED' || currentStep === 'Complete';
28
+ const active = isCompleted
29
+ ? PROCESSING_STEPS.length
30
+ : currentStep
31
+ ? PROCESSING_STEPS.findIndex((s) => {
32
+ const lowerStep = s.toLowerCase();
33
+ const lowerCurrent = currentStep.toLowerCase();
34
+ return (
35
+ lowerCurrent.includes(lowerStep) ||
36
+ (lowerStep === 'loading document' && lowerCurrent.includes('parsing')) ||
37
+ (lowerStep === 'indexing vectors' && lowerCurrent.includes('indexing')) ||
38
+ (lowerStep === 'building graph' && lowerCurrent.includes('building')) ||
39
+ (lowerStep === 'extracting relationships' && (lowerCurrent.includes('writing relationships') || lowerCurrent.includes('extracting relationships')))
40
+ );
41
+ })
42
+ : -1;
43
 
44
  function getCountLabel(step: string): string | null {
45
  if (!counts) return null;
frontend/src/components/graph/ForceGraphWrapper.tsx ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import React, { forwardRef } from 'react';
4
+ import ForceGraph2D from 'react-force-graph-2d';
5
+
6
+ const ForceGraphWrapper = forwardRef((props: any, ref: any) => {
7
+ return <ForceGraph2D {...props} ref={ref} />;
8
+ });
9
+
10
+ ForceGraphWrapper.displayName = 'ForceGraphWrapper';
11
+ export default ForceGraphWrapper;
frontend/src/components/graph/GraphVisualization.tsx CHANGED
@@ -10,7 +10,7 @@ import { GraphControls } from './GraphControls';
10
  import { GraphLegend } from './GraphLegend';
11
  import { GraphSearch } from './GraphSearch';
12
 
13
- const ForceGraph2D = dynamic(() => import('react-force-graph-2d'), { ssr: false }) as any;
14
 
15
  interface FGNode extends GraphNode {
16
  x?: number;
 
10
  import { GraphLegend } from './GraphLegend';
11
  import { GraphSearch } from './GraphSearch';
12
 
13
+ const ForceGraph2D = dynamic(() => import('./ForceGraphWrapper'), { ssr: false }) as any;
14
 
15
  interface FGNode extends GraphNode {
16
  x?: number;
frontend/src/components/query/DocumentFilterDropdown.tsx ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { useState, useRef, useEffect } from 'react';
4
+ import { Filter, ChevronDown, Search, Square, CheckSquare } from 'lucide-react';
5
+ import { useDocumentsStore } from '@/store/documents';
6
+
7
+ export function DocumentFilterDropdown() {
8
+ const [isOpen, setIsOpen] = useState(false);
9
+ const [searchQuery, setSearchQuery] = useState('');
10
+ const dropdownRef = useRef<HTMLDivElement>(null);
11
+
12
+ const documents = useDocumentsStore((s) => s.documents);
13
+ const selectedIds = useDocumentsStore((s) => s.selectedDocumentIds);
14
+ const toggleSelect = useDocumentsStore((s) => s.toggleSelectDocument);
15
+ const selectAll = useDocumentsStore((s) => s.selectAllDocuments);
16
+ const deselectAll = useDocumentsStore((s) => s.deselectAllDocuments);
17
+
18
+ const completedDocs = documents.filter((d) => d.status === 'COMPLETED');
19
+ const filteredDocs = completedDocs.filter((d) =>
20
+ d.name.toLowerCase().includes(searchQuery.toLowerCase())
21
+ );
22
+
23
+ // Close dropdown on click outside
24
+ useEffect(() => {
25
+ function handleClickOutside(event: MouseEvent) {
26
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
27
+ setIsOpen(false);
28
+ }
29
+ }
30
+ document.addEventListener('mousedown', handleClickOutside);
31
+ return () => document.removeEventListener('mousedown', handleClickOutside);
32
+ }, []);
33
+
34
+ let label = 'All Documents';
35
+ if (selectedIds.length === 0) {
36
+ label = 'No Documents selected';
37
+ } else if (selectedIds.length < completedDocs.length) {
38
+ label = `${selectedIds.length}/${completedDocs.length} selected`;
39
+ }
40
+
41
+ return (
42
+ <div className="relative inline-block text-left" ref={dropdownRef}>
43
+ <button
44
+ type="button"
45
+ onClick={() => setIsOpen((prev) => !prev)}
46
+ className="inline-flex items-center gap-1.5 rounded-md border border-border bg-bg-surface px-3 py-1.5 text-xs font-semibold text-text-primary hover:border-accent-violet/50 hover:bg-bg-elevated transition-all"
47
+ >
48
+ <Filter className="h-3.5 w-3.5 text-text-muted" />
49
+ <span>{label}</span>
50
+ <ChevronDown className="h-3 w-3 text-text-muted" />
51
+ </button>
52
+
53
+ {isOpen && (
54
+ <div className="absolute right-0 z-50 mt-1.5 w-64 origin-top-right rounded-md border border-border bg-bg-surface shadow-lg focus:outline-none flex flex-col max-h-80 overflow-hidden">
55
+ {/* Quick Actions Header */}
56
+ <div className="flex items-center justify-between border-b border-border p-2 bg-bg-elevated/40">
57
+ <span className="text-[10px] font-bold text-text-muted uppercase tracking-wider">
58
+ Filter context
59
+ </span>
60
+ <div className="flex items-center gap-2">
61
+ <button
62
+ type="button"
63
+ onClick={selectAll}
64
+ className="text-[10px] font-semibold text-accent-violet hover:underline"
65
+ >
66
+ Select All
67
+ </button>
68
+ <span className="text-[10px] text-text-muted">|</span>
69
+ <button
70
+ type="button"
71
+ onClick={deselectAll}
72
+ className="text-[10px] font-semibold text-text-muted hover:underline"
73
+ >
74
+ Clear
75
+ </button>
76
+ </div>
77
+ </div>
78
+
79
+ {/* Search Input */}
80
+ <div className="relative p-2 border-b border-border">
81
+ <Search className="absolute left-4 top-3.5 h-3.5 w-3.5 text-text-muted" />
82
+ <input
83
+ type="text"
84
+ placeholder="Search documents..."
85
+ value={searchQuery}
86
+ onChange={(e) => setSearchQuery(e.target.value)}
87
+ className="w-full pl-8 pr-3 py-1.5 text-xs rounded border border-border bg-bg-elevated focus:outline-none focus:ring-1 focus:ring-accent-violet text-text-primary placeholder:text-text-muted/60"
88
+ />
89
+ </div>
90
+
91
+ {/* Scrollable list */}
92
+ <div className="flex-1 overflow-y-auto p-1.5 space-y-0.5 scrollbar-thin max-h-48">
93
+ {completedDocs.length === 0 ? (
94
+ <p className="text-center text-xs text-text-muted py-4">No completed documents.</p>
95
+ ) : filteredDocs.length === 0 ? (
96
+ <p className="text-center text-xs text-text-muted py-4">No matching documents.</p>
97
+ ) : (
98
+ filteredDocs.map((doc) => {
99
+ const checked = selectedIds.includes(doc.id);
100
+ return (
101
+ <div
102
+ key={doc.id}
103
+ onClick={() => toggleSelect(doc.id)}
104
+ className="flex items-center gap-2 rounded px-2 py-1.5 hover:bg-bg-elevated cursor-pointer transition-colors"
105
+ >
106
+ {checked ? (
107
+ <CheckSquare className="h-4 w-4 shrink-0 text-accent-violet" />
108
+ ) : (
109
+ <Square className="h-4 w-4 shrink-0 text-text-muted" />
110
+ )}
111
+ <span className="truncate text-xs font-medium text-text-primary" title={doc.name}>
112
+ {doc.name}
113
+ </span>
114
+ </div>
115
+ );
116
+ })
117
+ )}
118
+ </div>
119
+ </div>
120
+ )}
121
+ </div>
122
+ );
123
+ }
frontend/src/components/query/QueryPanel.tsx CHANGED
@@ -5,6 +5,8 @@ import { Button } from '@/components/ui/button';
5
  import { Input } from '@/components/ui/input';
6
  import { cn } from '@/lib/utils';
7
 
 
 
8
  export type QueryMode = 'graph' | 'vector' | 'hybrid';
9
 
10
  const MODES: { value: QueryMode; label: string }[] = [
@@ -32,26 +34,30 @@ export function QueryPanel({
32
  }: QueryPanelProps) {
33
  return (
34
  <div className="space-y-3">
35
- <div className="flex items-center gap-2">
36
- <span className="text-xs font-semibold uppercase tracking-wide text-text-muted">
37
- Mode
38
- </span>
39
- <div className="inline-flex rounded-md border border-border p-0.5">
40
- {MODES.map((m) => (
41
- <button
42
- key={m.value}
43
- onClick={() => onModeChange(m.value)}
44
- className={cn(
45
- 'rounded px-2.5 py-1 text-xs font-medium transition-colors',
46
- mode === m.value
47
- ? 'bg-accent-violet text-white'
48
- : 'text-text-secondary hover:text-text-primary'
49
- )}
50
- >
51
- {m.label}
52
- </button>
53
- ))}
 
 
54
  </div>
 
 
55
  </div>
56
 
57
  <div className="flex gap-2">
 
5
  import { Input } from '@/components/ui/input';
6
  import { cn } from '@/lib/utils';
7
 
8
+ import { DocumentFilterDropdown } from './DocumentFilterDropdown';
9
+
10
  export type QueryMode = 'graph' | 'vector' | 'hybrid';
11
 
12
  const MODES: { value: QueryMode; label: string }[] = [
 
34
  }: QueryPanelProps) {
35
  return (
36
  <div className="space-y-3">
37
+ <div className="flex items-center justify-between gap-4">
38
+ <div className="flex items-center gap-2">
39
+ <span className="text-xs font-semibold uppercase tracking-wide text-text-muted">
40
+ Mode
41
+ </span>
42
+ <div className="inline-flex rounded-md border border-border p-0.5">
43
+ {MODES.map((m) => (
44
+ <button
45
+ key={m.value}
46
+ onClick={() => onModeChange(m.value)}
47
+ className={cn(
48
+ 'rounded px-2.5 py-1 text-xs font-medium transition-colors',
49
+ mode === m.value
50
+ ? 'bg-accent-violet text-white'
51
+ : 'text-text-secondary hover:text-text-primary'
52
+ )}
53
+ >
54
+ {m.label}
55
+ </button>
56
+ ))}
57
+ </div>
58
  </div>
59
+
60
+ <DocumentFilterDropdown />
61
  </div>
62
 
63
  <div className="flex gap-2">
frontend/src/hooks/useDocumentPolling.ts CHANGED
@@ -27,6 +27,7 @@ export function useDocumentPolling(enabled = true) {
27
  error: d.error_message,
28
  source: d.source || '',
29
  processingStep: d.processing_step ?? d.processingStep ?? null,
 
30
  }));
31
  if (docs.length > 0) {
32
  setDocuments(docs);
@@ -57,6 +58,7 @@ export function useDocumentPolling(enabled = true) {
57
  error: d.error_message,
58
  source: d.source || '',
59
  processingStep: d.processing_step ?? d.processingStep ?? null,
 
60
  }));
61
  const active = docs.filter(
62
  (d) => d.status === 'PENDING' || d.status === 'PROCESSING'
 
27
  error: d.error_message,
28
  source: d.source || '',
29
  processingStep: d.processing_step ?? d.processingStep ?? null,
30
+ processingProgress: d.processing_progress ?? d.processingProgress ?? 0,
31
  }));
32
  if (docs.length > 0) {
33
  setDocuments(docs);
 
58
  error: d.error_message,
59
  source: d.source || '',
60
  processingStep: d.processing_step ?? d.processingStep ?? null,
61
+ processingProgress: d.processing_progress ?? d.processingProgress ?? 0,
62
  }));
63
  const active = docs.filter(
64
  (d) => d.status === 'PENDING' || d.status === 'PROCESSING'
frontend/src/hooks/useGraphData.ts CHANGED
@@ -2,13 +2,7 @@ import { useEffect, useState } from 'react';
2
  import api from '@/lib/axios';
3
  import { useGraphStore, GraphData } from '@/store/graph';
4
  import { mockGraph } from '@/lib/mockData';
5
-
6
- // Module-level dedupe so multiple components calling useGraphData
7
- // (HeroSection, MainQueryView, ExplorePage, CommunityView) don't race
8
- // or fire redundant requests.
9
- let inFlight: Promise<void> | null = null;
10
- let lastFetchedAt = 0;
11
- const CACHE_TTL_MS = 30_000;
12
 
13
  function mapResponse(data: any): GraphData {
14
  return {
@@ -32,48 +26,40 @@ function mapResponse(data: any): GraphData {
32
  };
33
  }
34
 
35
- function fetchGraph(setData: (d: GraphData) => void, setError: (e: string | null) => void) {
36
- if (inFlight) return inFlight;
37
- inFlight = (async () => {
38
- try {
39
- const { data } = await api.get('/graph/');
40
- setData(mapResponse(data));
41
- setError(null);
42
- lastFetchedAt = Date.now();
43
- } catch {
44
- setError('Using demo graph data (backend unavailable).');
45
- setData(mockGraph);
46
- lastFetchedAt = Date.now();
47
- } finally {
48
- inFlight = null;
49
- }
50
- })();
51
- return inFlight;
52
- }
53
-
54
  export function useGraphData() {
55
  const [loading, setLoading] = useState(false);
56
  const [error, setError] = useState<string | null>(null);
57
  const setData = useGraphStore((s) => s.setData);
58
- const existing = useGraphStore((s) => s.data);
 
59
 
60
  useEffect(() => {
61
- // Skip if we already have fresh data within the TTL.
62
- if (existing.nodes.length > 0 && Date.now() - lastFetchedAt < CACHE_TTL_MS) {
63
- return;
64
- }
65
  let cancelled = false;
66
  setLoading(true);
67
- fetchGraph(
68
- (d) => !cancelled && setData(d),
69
- (e) => !cancelled && setError(e)
70
- ).finally(() => {
71
- if (!cancelled) setLoading(false);
72
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  return () => {
74
  cancelled = true;
75
  };
76
- }, [setData, existing.nodes.length]);
77
 
78
  return { loading, error };
79
  }
 
2
  import api from '@/lib/axios';
3
  import { useGraphStore, GraphData } from '@/store/graph';
4
  import { mockGraph } from '@/lib/mockData';
5
+ import { useDocumentsStore } from '@/store/documents';
 
 
 
 
 
 
6
 
7
  function mapResponse(data: any): GraphData {
8
  return {
 
26
  };
27
  }
28
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  export function useGraphData() {
30
  const [loading, setLoading] = useState(false);
31
  const [error, setError] = useState<string | null>(null);
32
  const setData = useGraphStore((s) => s.setData);
33
+ const selectedIds = useDocumentsStore((s) => s.selectedDocumentIds);
34
+ const selectedIdsStr = selectedIds.join(',');
35
 
36
  useEffect(() => {
 
 
 
 
37
  let cancelled = false;
38
  setLoading(true);
39
+
40
+ const url = selectedIdsStr ? `/graph/?document_ids=${selectedIdsStr}` : '/graph/';
41
+
42
+ api.get(url)
43
+ .then(({ data }) => {
44
+ if (!cancelled) {
45
+ setData(mapResponse(data));
46
+ setError(null);
47
+ }
48
+ })
49
+ .catch(() => {
50
+ if (!cancelled) {
51
+ setError('Using demo graph data (backend unavailable).');
52
+ setData(mockGraph);
53
+ }
54
+ })
55
+ .finally(() => {
56
+ if (!cancelled) setLoading(false);
57
+ });
58
+
59
  return () => {
60
  cancelled = true;
61
  };
62
+ }, [setData, selectedIdsStr]);
63
 
64
  return { loading, error };
65
  }
frontend/src/store/documents.ts CHANGED
@@ -1,4 +1,5 @@
1
  import { create } from 'zustand';
 
2
 
3
  export type DocStatus = 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED';
4
 
@@ -12,29 +13,62 @@ export interface DocumentItem {
12
  error?: string;
13
  source?: string;
14
  processingStep?: string | null;
 
15
  }
16
 
17
  interface DocumentsState {
18
  documents: DocumentItem[];
19
  activeJobs: number;
 
20
  setDocuments: (docs: DocumentItem[]) => void;
21
  upsertDocument: (doc: DocumentItem) => void;
22
  setActiveJobs: (n: number) => void;
 
 
 
 
23
  }
24
 
25
- export const useDocumentsStore = create<DocumentsState>((set) => ({
26
- documents: [],
27
- activeJobs: 0,
28
- setDocuments: (documents) => set({ documents }),
29
- upsertDocument: (doc) =>
30
- set((state) => {
31
- const idx = state.documents.findIndex((d) => d.id === doc.id);
32
- if (idx >= 0) {
33
- const next = [...state.documents];
34
- next[idx] = doc;
35
- return { documents: next };
36
- }
37
- return { documents: [doc, ...state.documents] };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  }),
39
- setActiveJobs: (activeJobs) => set({ activeJobs }),
40
- }));
 
 
 
 
 
 
1
  import { create } from 'zustand';
2
+ import { persist } from 'zustand/middleware';
3
 
4
  export type DocStatus = 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED';
5
 
 
13
  error?: string;
14
  source?: string;
15
  processingStep?: string | null;
16
+ processingProgress?: number;
17
  }
18
 
19
  interface DocumentsState {
20
  documents: DocumentItem[];
21
  activeJobs: number;
22
+ selectedDocumentIds: string[];
23
  setDocuments: (docs: DocumentItem[]) => void;
24
  upsertDocument: (doc: DocumentItem) => void;
25
  setActiveJobs: (n: number) => void;
26
+ toggleSelectDocument: (id: string) => void;
27
+ setSelectedDocumentIds: (ids: string[]) => void;
28
+ selectAllDocuments: () => void;
29
+ deselectAllDocuments: () => void;
30
  }
31
 
32
+ export const useDocumentsStore = create<DocumentsState>()(
33
+ persist(
34
+ (set) => ({
35
+ documents: [],
36
+ activeJobs: 0,
37
+ selectedDocumentIds: [],
38
+ setDocuments: (documents) => set({ documents }),
39
+ upsertDocument: (doc) =>
40
+ set((state) => {
41
+ const idx = state.documents.findIndex((d) => d.id === doc.id);
42
+ if (idx >= 0) {
43
+ const next = [...state.documents];
44
+ next[idx] = doc;
45
+ return { documents: next };
46
+ }
47
+ return { documents: [doc, ...state.documents] };
48
+ }),
49
+ setActiveJobs: (activeJobs) => set({ activeJobs }),
50
+ toggleSelectDocument: (id) =>
51
+ set((state) => {
52
+ const isSel = state.selectedDocumentIds.includes(id);
53
+ const next = isSel
54
+ ? state.selectedDocumentIds.filter((x) => x !== id)
55
+ : [...state.selectedDocumentIds, id];
56
+ return { selectedDocumentIds: next };
57
+ }),
58
+ setSelectedDocumentIds: (selectedDocumentIds) => set({ selectedDocumentIds }),
59
+ selectAllDocuments: () =>
60
+ set((state) => {
61
+ const completedIds = state.documents
62
+ .filter((d) => d.status === 'COMPLETED')
63
+ .map((d) => d.id);
64
+ return { selectedDocumentIds: completedIds };
65
+ }),
66
+ deselectAllDocuments: () => set({ selectedDocumentIds: [] }),
67
  }),
68
+ {
69
+ name: 'graphrag-selected-documents',
70
+ partialize: (state) => ({ selectedDocumentIds: state.selectedDocumentIds }),
71
+ }
72
+ )
73
+ );
74
+
frontend/tsconfig.tsbuildinfo CHANGED
The diff for this file is too large to render. See raw diff