Spaces:
Sleeping
Sleeping
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 +3 -0
- backend/Dockerfile +3 -0
- backend/clear_all_data.py +68 -0
- backend/config/settings.py +6 -1
- backend/graphrag/services/graph_builder.py +21 -11
- backend/graphrag/services/graph_retriever.py +5 -5
- backend/graphrag/services/hybrid_retriever.py +4 -4
- backend/graphrag/services/llm_client.py +116 -20
- backend/graphrag/services/neo4j_client.py +66 -37
- backend/graphrag/services/rag_chain.py +15 -5
- backend/graphrag/services/vector_retriever.py +11 -7
- backend/graphrag/tests.py +11 -0
- backend/graphrag/views.py +38 -3
- backend/requirements.txt +1 -0
- docker-compose.yml +4 -1
- eval_dataset/eval_questions.json +0 -182
- frontend/package-lock.json +120 -0
- frontend/src/app/(dashboard)/documents/page.tsx +312 -58
- frontend/src/app/(dashboard)/layout.tsx +13 -4
- frontend/src/components/dashboard/MainQueryView.tsx +8 -1
- frontend/src/components/documents/DocumentRow.tsx +43 -13
- frontend/src/components/documents/DocumentUpload.tsx +2 -3
- frontend/src/components/documents/ProcessingSteps.tsx +20 -3
- frontend/src/components/graph/ForceGraphWrapper.tsx +11 -0
- frontend/src/components/graph/GraphVisualization.tsx +1 -1
- frontend/src/components/query/DocumentFilterDropdown.tsx +123 -0
- frontend/src/components/query/QueryPanel.tsx +25 -19
- frontend/src/hooks/useDocumentPolling.ts +2 -0
- frontend/src/hooks/useGraphData.ts +24 -38
- frontend/src/store/documents.ts +49 -15
- frontend/tsconfig.tsbuildinfo +0 -0
.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'
|
| 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.
|
| 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
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
if groq_api_key:
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
else:
|
| 44 |
-
|
| 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 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 254 |
"""
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
try:
|
| 269 |
-
nodes = self.execute_query(nodes_query,
|
| 270 |
-
edges = self.execute_query(edges_query,
|
| 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,
|
| 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",
|
| 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.
|
| 398 |
"DELETE r"
|
| 399 |
)
|
| 400 |
-
# 2. Delete nodes created
|
| 401 |
delete_nodes_query = (
|
| 402 |
"MATCH (e:Entity {user_id: $user_id}) "
|
| 403 |
-
"WHERE e.
|
| 404 |
-
"
|
| 405 |
)
|
| 406 |
|
| 407 |
-
params = {"
|
| 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(
|
| 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 |
-
|
| 94 |
-
query_embeddings
|
| 95 |
-
n_results
|
| 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
|
|
|
|
| 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
|
| 24 |
-
|
| 25 |
-
.
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
<
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
<
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
<
|
| 63 |
-
<
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
<
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
|
|
|
|
|
|
|
|
|
| 70 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
</div>
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
</div>
|
| 78 |
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
<
|
| 82 |
-
{
|
| 83 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
)}
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
<
|
| 95 |
-
|
|
|
|
| 96 |
</p>
|
| 97 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
</div>
|
| 99 |
-
|
| 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 |
-
|
| 14 |
-
|
|
|
|
|
|
|
| 15 |
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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/', {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 23 |
onRetry,
|
| 24 |
}: {
|
| 25 |
doc: DocumentItem;
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
| 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' && '
|
| 34 |
)}
|
| 35 |
-
onClick={() => doc.status === 'COMPLETED' &&
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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?.
|
| 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 |
-
'
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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('
|
| 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-
|
| 36 |
-
<
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 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
|
|
|
|
| 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 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
return () => {
|
| 74 |
cancelled = true;
|
| 75 |
};
|
| 76 |
-
}, [setData,
|
| 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>(
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
}),
|
| 39 |
-
|
| 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
|
|
|