Spaces:
Sleeping
Sleeping
feat: redesign multi-hop reasoning, add sticky layout, query logs history tracking, and hybrid fallback search
0527a95 | import os | |
| import time | |
| import logging | |
| import threading | |
| import json | |
| from concurrent.futures import ThreadPoolExecutor, as_completed | |
| from rest_framework import status, viewsets | |
| from rest_framework.views import APIView | |
| from rest_framework.response import Response | |
| from rest_framework.permissions import AllowAny, IsAuthenticated | |
| from rest_framework.throttling import UserRateThrottle | |
| from rest_framework.pagination import PageNumberPagination | |
| from rest_framework_simplejwt.views import TokenObtainPairView | |
| from django.contrib.auth import get_user_model | |
| from .models import Document, QueryLog, EvaluationPair | |
| from .serializers import ( | |
| RegisterSerializer, | |
| UserSerializer, | |
| DocumentSerializer, | |
| QueryLogSerializer, | |
| EvaluationPairSerializer, | |
| ) | |
| from django.conf import settings | |
| from .services.rag_chain import RAGChain | |
| from .services.nl_to_cypher import NLToCypher | |
| from .services.multihop_reasoner import MultiHopReasoner | |
| from .services.community_detector import CommunityDetector | |
| from .services.neo4j_client import Neo4jClient | |
| from .services.graph_retriever import GraphRetriever | |
| from .services.graph_builder import GraphBuilder | |
| # Concurrency control for background ingestion — from settings | |
| _ingestion_semaphore = threading.Semaphore(settings.MAX_INGESTION_WORKERS) | |
| logger = logging.getLogger(__name__) | |
| from django.contrib.auth.backends import ModelBackend | |
| from django.db.models import Q | |
| User = get_user_model() | |
| class EmailOrUsernameModelBackend(ModelBackend): | |
| """ | |
| Custom authentication backend that allows authenticating with either | |
| a username or an email address. | |
| """ | |
| def authenticate(self, request, username=None, password=None, **kwargs): | |
| if username is None: | |
| username = kwargs.get('username') | |
| # Check if input matches username OR email (case-insensitive) | |
| user = User.objects.filter( | |
| Q(username__iexact=username) | Q(email__iexact=username) | |
| ).first() | |
| if user and user.check_password(password): | |
| return user | |
| return None | |
| # ============================================================ | |
| # Custom Throttle Classes | |
| # ============================================================ | |
| class LLMLoadThrottle(UserRateThrottle): | |
| """Stricter throttle for LLM-heavy endpoints.""" | |
| rate = '20/minute' | |
| # ============================================================ | |
| # Helper — background ingestion thread | |
| # ============================================================ | |
| def trigger_ingestion_background(document_id, user_id): | |
| """ | |
| Isolated target runner to execute ingestion processing inside a background thread. | |
| Uses a semaphore to limit concurrent ingestion jobs to 3. | |
| Handles LLM API key errors and unexpected failures gracefully. | |
| """ | |
| acquired = _ingestion_semaphore.acquire(blocking=False) | |
| if not acquired: | |
| logger.warning("Ingestion concurrency limit reached. Rejecting document ID: %s", document_id) | |
| try: | |
| doc = Document.objects.get(id=document_id) | |
| doc.status = Document.Status.FAILED | |
| doc.error_message = "Too many documents processing concurrently. Please try again later." | |
| doc.save() | |
| except Exception: | |
| pass | |
| return | |
| logger.info("Background thread spawned for ingestion of document ID: %s", document_id) | |
| try: | |
| builder = GraphBuilder() | |
| builder.process_document(document_id, user_id) | |
| logger.info("Background ingestion completed successfully for document ID: %s", document_id) | |
| except ValueError as e: | |
| logger.error("LLM API key error for document ID: %s. Error: %s", document_id, str(e)) | |
| try: | |
| doc = Document.objects.get(id=document_id) | |
| doc.status = Document.Status.FAILED | |
| doc.error_message = str(e) | |
| doc.save() | |
| except Exception: | |
| pass | |
| except Exception as e: | |
| logger.error("Critical error in background ingestion thread for document ID: %s. Error: %s", | |
| document_id, str(e), exc_info=True) | |
| try: | |
| doc = Document.objects.get(id=document_id) | |
| doc.status = Document.Status.FAILED | |
| doc.error_message = f"Unexpected error: {str(e)}" | |
| doc.save() | |
| except Exception: | |
| pass | |
| finally: | |
| _ingestion_semaphore.release() | |
| # ============================================================ | |
| # Endpoint #20: GET /api/health/ | |
| # ============================================================ | |
| class HealthCheckView(APIView): | |
| """ | |
| Health check endpoint — verifies Django + Neo4j connectivity. | |
| Used by Docker / Kubernetes probes. | |
| """ | |
| permission_classes = [AllowAny] | |
| def get(self, request): | |
| health = { | |
| "django": "healthy", | |
| "neo4j": "unknown", | |
| "timestamp": time.time() | |
| } | |
| # Check Neo4j connectivity | |
| try: | |
| neo4j_client = Neo4jClient() | |
| neo4j_client.execute_query("RETURN 1 AS test") | |
| health["neo4j"] = "healthy" | |
| except Exception as e: | |
| logger.error("Neo4j health check failed: %s", str(e)) | |
| health["neo4j"] = "unhealthy" | |
| overall = "healthy" if health["neo4j"] == "healthy" else "degraded" | |
| return Response({ | |
| "status": overall, | |
| "services": health | |
| }, status=status.HTTP_200_OK) | |
| # ============================================================ | |
| # Endpoint #1: POST /api/auth/register/ | |
| # ============================================================ | |
| class RegisterView(APIView): | |
| """ | |
| Endpoint for new user registration. | |
| """ | |
| permission_classes = [AllowAny] | |
| def post(self, request): | |
| logger.info("Received account registration request.") | |
| serializer = RegisterSerializer(data=request.data) | |
| if serializer.is_valid(): | |
| user = serializer.save() | |
| logger.info("Successfully registered user account: %s", user.username) | |
| return Response( | |
| { | |
| "message": "User registered successfully.", | |
| "user": UserSerializer(user).data | |
| }, | |
| status=status.HTTP_201_CREATED | |
| ) | |
| logger.warning("Registration request failed validation check: %s", serializer.errors) | |
| return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) | |
| # ============================================================ | |
| # Endpoint #2: POST /api/auth/login/ | |
| # ============================================================ | |
| class CustomTokenObtainPairView(TokenObtainPairView): | |
| """ | |
| Custom JWT Token Obtain View to add custom execution logs. | |
| """ | |
| permission_classes = [AllowAny] | |
| def post(self, request, *args, **kwargs): | |
| username = request.data.get('username') | |
| logger.info("Authentication attempt received for user: %s", username) | |
| try: | |
| response = super().post(request, *args, **kwargs) | |
| logger.info("Authentication successful for user: %s", username) | |
| return response | |
| except Exception as e: | |
| logger.warning("Authentication failed for user: %s. Error: %s", username, str(e)) | |
| return Response( | |
| {"error": str(e)}, | |
| status=status.HTTP_401_UNAUTHORIZED | |
| ) | |
| # ============================================================ | |
| # Endpoint #4: POST /api/documents/upload/ | |
| # ============================================================ | |
| class DocumentUploadView(APIView): | |
| """ | |
| Endpoint for uploading documents. Runs the parsing and extraction pipeline | |
| in a non-blocking background thread. | |
| """ | |
| permission_classes = [IsAuthenticated] | |
| def post(self, request): | |
| logger.info("Received document upload request from user: %s", request.user.username) | |
| if 'file' not in request.FILES: | |
| logger.warning("Document upload request rejected: No file attachment found.") | |
| return Response( | |
| {"error": "No file was uploaded."}, | |
| status=status.HTTP_400_BAD_REQUEST | |
| ) | |
| file_obj = request.FILES['file'] | |
| # --- File Validation --- | |
| ext = os.path.splitext(file_obj.name)[1].lower() | |
| if ext not in settings.ALLOWED_EXTENSIONS: | |
| return Response( | |
| {"error": f"File type '{ext}' is not allowed. Supported: {', '.join(sorted(settings.ALLOWED_EXTENSIONS))}"}, | |
| status=status.HTTP_400_BAD_REQUEST | |
| ) | |
| if file_obj.size > settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024: | |
| return Response( | |
| {"error": f"File size exceeds {settings.MAX_UPLOAD_SIZE_MB}MB limit."}, | |
| status=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE | |
| ) | |
| if file_obj.size == 0: | |
| return Response( | |
| {"error": "Empty files are not allowed."}, | |
| status=status.HTTP_400_BAD_REQUEST | |
| ) | |
| # --- Duplicate Check --- | |
| if Document.objects.filter(user=request.user, name=file_obj.name).exists(): | |
| logger.warning("Document upload request rejected: Duplicate filename '%s' found for user: %s", file_obj.name, request.user.username) | |
| return Response( | |
| {"error": "A document with this name has already been uploaded."}, | |
| status=status.HTTP_409_CONFLICT | |
| ) | |
| # Save document record with initial PENDING status | |
| doc = Document.objects.create( | |
| user=request.user, | |
| name=file_obj.name, | |
| file=file_obj, | |
| status=Document.Status.PENDING, | |
| source=request.data.get('source', '') | |
| ) | |
| logger.info("Saved initial document metadata row. ID: %s | Name: %s", doc.id, doc.name) | |
| # Launch background pipeline thread | |
| thread = threading.Thread( | |
| target=trigger_ingestion_background, | |
| args=(doc.id, request.user.id) | |
| ) | |
| thread.daemon = True | |
| thread.start() | |
| # Return 202 Accepted immediately so client is non-blocking | |
| return Response( | |
| { | |
| "message": "File upload accepted. Ingestion running in background.", | |
| "document": DocumentSerializer(doc, context={'request': request}).data | |
| }, | |
| status=status.HTTP_202_ACCEPTED | |
| ) | |
| # ============================================================ | |
| # Endpoints #5, #6: /api/documents/ (list, retrieve, delete) | |
| # ============================================================ | |
| class DocumentViewSet(viewsets.ModelViewSet): | |
| """ | |
| ViewSet for listing, retrieving details, and deleting user documents. | |
| """ | |
| permission_classes = [IsAuthenticated] | |
| serializer_class = DocumentSerializer | |
| http_method_names = ['get', 'delete'] | |
| pagination_class = PageNumberPagination | |
| page_size = 20 | |
| def get_queryset(self): | |
| # Enforce multi-tenancy: users can only see their own documents | |
| return Document.objects.filter(user=self.request.user) | |
| def destroy(self, request, *args, **kwargs): | |
| doc = self.get_object() | |
| logger.info("Received request to delete document: %s (ID: %s) for user: %s", | |
| doc.name, doc.id, request.user.username) | |
| try: | |
| # Trigger custom graph/vector cleanup using GraphBuilder | |
| builder = GraphBuilder() | |
| builder.delete_document_data(doc.id, request.user.id) | |
| # Delete physical file and SQL DB record | |
| doc.file.delete(save=False) | |
| doc.delete() | |
| logger.info("Successfully deleted document %s and cleaned associated graph/vector database records.", doc.name) | |
| return Response( | |
| {"message": "Document and all extracted nodes/vectors deleted successfully."}, | |
| status=status.HTTP_200_OK | |
| ) | |
| except Exception as e: | |
| logger.error("Failed to cleanly delete document ID: %s. Error: %s", doc.id, str(e), exc_info=True) | |
| return Response( | |
| {"error": "Failed to delete document."}, | |
| status=status.HTTP_500_INTERNAL_SERVER_ERROR | |
| ) | |
| # ============================================================ | |
| # Endpoint #7: POST /api/query/ | |
| # ============================================================ | |
| class QueryView(APIView): | |
| """ | |
| Endpoint for executing GraphRAG queries. | |
| Supports 'hybrid', 'vector', and 'graph' retrieval modes. | |
| """ | |
| permission_classes = [IsAuthenticated] | |
| throttle_classes = [LLMLoadThrottle] | |
| def post(self, request): | |
| query = request.data.get("query") | |
| mode = request.data.get("mode", "hybrid") | |
| document_ids = request.data.get("document_ids", None) | |
| if not query or not query.strip(): | |
| return Response( | |
| {"error": "The 'query' field is required and cannot be empty."}, | |
| status=status.HTTP_400_BAD_REQUEST | |
| ) | |
| logger.info("Executing RAG Query for user: %s | Mode: %s | Docs: %s", request.user.username, mode, document_ids) | |
| start_time = time.time() | |
| try: | |
| rag_chain = RAGChain() | |
| result = rag_chain.generate_answer(query, request.user.id, mode, doc_ids=document_ids) | |
| elapsed = time.time() - start_time | |
| # Log query | |
| QueryLog.objects.create( | |
| user=request.user, | |
| query_text=query, | |
| retrieval_mode=mode.upper(), | |
| answer_text=result.get("answer", ""), | |
| response_time=round(elapsed, 3) | |
| ) | |
| if result.get("success", False): | |
| return Response(result, status=status.HTTP_200_OK) | |
| else: | |
| return Response( | |
| {"error": "Failed to generate RAG response."}, | |
| status=status.HTTP_500_INTERNAL_SERVER_ERROR | |
| ) | |
| except Exception as e: | |
| elapsed = time.time() - start_time | |
| logger.error("Error in QueryView: %s", str(e), exc_info=True) | |
| QueryLog.objects.create( | |
| user=request.user, | |
| query_text=query, | |
| retrieval_mode=mode.upper(), | |
| answer_text="ERROR", | |
| response_time=round(elapsed, 3) | |
| ) | |
| return Response( | |
| {"error": "An internal error occurred while processing your query."}, | |
| status=status.HTTP_500_INTERNAL_SERVER_ERROR | |
| ) | |
| # ============================================================ | |
| # Endpoint #8: POST /api/query/graph-only/ | |
| # ============================================================ | |
| class GraphOnlyQueryView(APIView): | |
| """Dedicated endpoint for graph-only retrieval.""" | |
| permission_classes = [IsAuthenticated] | |
| throttle_classes = [LLMLoadThrottle] | |
| def post(self, request): | |
| query = request.data.get("query") | |
| if not query or not query.strip(): | |
| return Response( | |
| {"error": "The 'query' field is required and cannot be empty."}, | |
| status=status.HTTP_400_BAD_REQUEST | |
| ) | |
| start_time = time.time() | |
| try: | |
| rag_chain = RAGChain() | |
| result = rag_chain.generate_answer(query, request.user.id, mode="graph") | |
| elapsed = time.time() - start_time | |
| QueryLog.objects.create( | |
| user=request.user, query_text=query, | |
| retrieval_mode='GRAPH', | |
| answer_text=result.get("answer", ""), | |
| response_time=round(elapsed, 3) | |
| ) | |
| if result.get("success", False): | |
| return Response(result, status=status.HTTP_200_OK) | |
| return Response( | |
| {"error": "Failed to generate graph retrieval response."}, | |
| status=status.HTTP_500_INTERNAL_SERVER_ERROR | |
| ) | |
| except Exception as e: | |
| logger.error("Error in GraphOnlyQueryView: %s", str(e), exc_info=True) | |
| return Response( | |
| {"error": "An internal error occurred during graph retrieval."}, | |
| status=status.HTTP_500_INTERNAL_SERVER_ERROR | |
| ) | |
| # ============================================================ | |
| # Endpoint #9: POST /api/query/vector-only/ | |
| # ============================================================ | |
| class VectorOnlyQueryView(APIView): | |
| """Dedicated endpoint for vector-only retrieval.""" | |
| permission_classes = [IsAuthenticated] | |
| throttle_classes = [LLMLoadThrottle] | |
| def post(self, request): | |
| query = request.data.get("query") | |
| if not query or not query.strip(): | |
| return Response( | |
| {"error": "The 'query' field is required and cannot be empty."}, | |
| status=status.HTTP_400_BAD_REQUEST | |
| ) | |
| start_time = time.time() | |
| try: | |
| rag_chain = RAGChain() | |
| result = rag_chain.generate_answer(query, request.user.id, mode="vector") | |
| elapsed = time.time() - start_time | |
| QueryLog.objects.create( | |
| user=request.user, query_text=query, | |
| retrieval_mode='VECTOR', | |
| answer_text=result.get("answer", ""), | |
| response_time=round(elapsed, 3) | |
| ) | |
| if result.get("success", False): | |
| return Response(result, status=status.HTTP_200_OK) | |
| return Response( | |
| {"error": "Failed to generate vector retrieval response."}, | |
| status=status.HTTP_500_INTERNAL_SERVER_ERROR | |
| ) | |
| except Exception as e: | |
| logger.error("Error in VectorOnlyQueryView: %s", str(e), exc_info=True) | |
| return Response( | |
| {"error": "An internal error occurred during vector retrieval."}, | |
| status=status.HTTP_500_INTERNAL_SERVER_ERROR | |
| ) | |
| # ============================================================ | |
| # Endpoint #10: POST /api/query/compare/ | |
| # ============================================================ | |
| class QueryCompareView(APIView): | |
| """Runs all 3 retrieval modes and returns side-by-side comparison.""" | |
| permission_classes = [IsAuthenticated] | |
| throttle_classes = [UserRateThrottle] | |
| def post(self, request): | |
| query = request.data.get("query") | |
| if not query or not query.strip(): | |
| return Response( | |
| {"error": "The 'query' field is required and cannot be empty."}, | |
| status=status.HTTP_400_BAD_REQUEST | |
| ) | |
| try: | |
| rag_chain = RAGChain() | |
| results = {} | |
| results_lock = threading.Lock() | |
| def run_mode(mode: str) -> tuple: | |
| start = time.time() | |
| result = rag_chain.generate_answer(query, request.user.id, mode) | |
| elapsed = time.time() - start | |
| return mode, { | |
| "answer": result.get("answer", ""), | |
| "context": result.get("context", ""), | |
| "sources": result.get("sources", []), | |
| "strategy": result.get("strategy", mode.upper()), | |
| "response_time": round(elapsed, 3), | |
| "success": result.get("success", False), | |
| "confidence": result.get("confidence", 0.0), | |
| "highlighted_entities": result.get("highlighted_entities", []), | |
| } | |
| with ThreadPoolExecutor(max_workers=3) as executor: | |
| futures = [executor.submit(run_mode, m) for m in ["graph", "vector", "hybrid"]] | |
| for future in as_completed(futures): | |
| mode, data = future.result() | |
| with results_lock: | |
| results[mode] = data | |
| return Response({ | |
| "query": query, | |
| "comparisons": results, | |
| "verdict": self._generate_verdict(results), | |
| "success": True | |
| }, status=status.HTTP_200_OK) | |
| except Exception as e: | |
| logger.error("Error in QueryCompareView: %s", str(e), exc_info=True) | |
| return Response( | |
| {"error": "An internal error occurred during comparison."}, | |
| status=status.HTTP_500_INTERNAL_SERVER_ERROR | |
| ) | |
| def _generate_verdict(self, comparisons: dict) -> str: | |
| """Generate a verdict comparing the three retrieval modes.""" | |
| times = {} | |
| has_answer = {} | |
| confidences = {} | |
| for mode in ["graph", "vector", "hybrid"]: | |
| data = comparisons.get(mode, {}) | |
| times[mode] = data.get("response_time", 999) | |
| has_answer[mode] = bool(data.get("answer") and len(data.get("answer", "")) > 10) | |
| confidences[mode] = data.get("confidence", 0) | |
| fastest = min(times, key=times.get) | |
| answered = [m for m, v in has_answer.items() if v] | |
| if not answered: | |
| return "No retrieval mode produced a valid answer." | |
| best = max(answered, key=lambda m: confidences.get(m, 0)) | |
| parts = [f"**{best.title()}** produced the best result (confidence: {round(confidences.get(best, 0) * 100)}%)."] | |
| if fastest != best: | |
| parts.append(f"**{fastest.title()}** was fastest ({round(times[fastest] * 1000)}ms).") | |
| else: | |
| parts.append(f"It was also the fastest ({round(times[fastest] * 1000)}ms).") | |
| return " ".join(parts) | |
| # ============================================================ | |
| # Endpoint #11: GET /api/graph/ | |
| # ============================================================ | |
| class GraphDataView(APIView): | |
| """Returns full graph data (nodes + edges) for frontend visualization.""" | |
| permission_classes = [IsAuthenticated] | |
| def get(self, request): | |
| try: | |
| document_ids_str = request.GET.get("document_ids", None) | |
| doc_ids = None | |
| if document_ids_str: | |
| doc_ids = [d.strip() for d in document_ids_str.split(",") if d.strip()] | |
| graph_retriever = GraphRetriever() | |
| graph_json = graph_retriever.get_graph_as_json(request.user.id, doc_ids=doc_ids) | |
| return Response(graph_json, status=status.HTTP_200_OK) | |
| except Exception as e: | |
| logger.error("Error in GraphDataView: %s", str(e), exc_info=True) | |
| return Response( | |
| {"error": "Failed to retrieve graph data."}, | |
| status=status.HTTP_500_INTERNAL_SERVER_ERROR | |
| ) | |
| # ============================================================ | |
| # Endpoint #12: GET /api/graph/entity/{name}/ | |
| # ============================================================ | |
| class GraphEntityDetailView(APIView): | |
| """Returns entity details and its direct subgraph.""" | |
| permission_classes = [IsAuthenticated] | |
| def get(self, request, name): | |
| if not name: | |
| return Response( | |
| {"error": "Entity name is required."}, | |
| status=status.HTTP_400_BAD_REQUEST | |
| ) | |
| try: | |
| neo4j_client = Neo4jClient() | |
| entity_data = neo4j_client.get_entity_details(name, request.user.id) | |
| if not entity_data: | |
| return Response( | |
| {"error": f"Entity '{name}' not found."}, | |
| status=status.HTTP_404_NOT_FOUND | |
| ) | |
| return Response(entity_data, status=status.HTTP_200_OK) | |
| except Exception as e: | |
| logger.error("Error in GraphEntityDetailView: %s", str(e), exc_info=True) | |
| return Response( | |
| {"error": "Failed to retrieve entity details."}, | |
| status=status.HTTP_500_INTERNAL_SERVER_ERROR | |
| ) | |
| # ============================================================ | |
| # Endpoint #13: GET /api/graph/path/ | |
| # ============================================================ | |
| class GraphPathView(APIView): | |
| """Finds paths between two entities. Moved from /query/shortest-path/.""" | |
| permission_classes = [IsAuthenticated] | |
| def get(self, request): | |
| entity_a = request.query_params.get("entity_a") | |
| entity_b = request.query_params.get("entity_b") | |
| if not entity_a or not entity_b: | |
| return Response( | |
| {"error": "Both 'entity_a' and 'entity_b' query parameters are required."}, | |
| status=status.HTTP_400_BAD_REQUEST | |
| ) | |
| try: | |
| reasoner = MultiHopReasoner() | |
| result = reasoner.explain_connection(entity_a, entity_b, request.user.id) | |
| return Response(result, status=status.HTTP_200_OK) | |
| except Exception as e: | |
| logger.error("Error in GraphPathView: %s", str(e), exc_info=True) | |
| return Response( | |
| {"error": "Failed to find path between entities."}, | |
| status=status.HTTP_500_INTERNAL_SERVER_ERROR | |
| ) | |
| # ============================================================ | |
| # Endpoint #14: POST /api/graph/cypher/ | |
| # ============================================================ | |
| class GraphCypherView(APIView): | |
| """Executes raw Cypher query. Moved from /query/cypher/.""" | |
| permission_classes = [IsAuthenticated] | |
| throttle_classes = [LLMLoadThrottle] | |
| def post(self, request): | |
| query = request.data.get("query") | |
| if not query or not query.strip(): | |
| return Response( | |
| {"error": "The 'query' field is required and cannot be empty."}, | |
| status=status.HTTP_400_BAD_REQUEST | |
| ) | |
| try: | |
| nl_to_cypher = NLToCypher() | |
| result = nl_to_cypher.execute_nl_query(query, request.user.id) | |
| if result.get("success", False): | |
| return Response(result, status=status.HTTP_200_OK) | |
| return Response( | |
| {"error": "Failed to translate and execute Cypher query."}, | |
| status=status.HTTP_500_INTERNAL_SERVER_ERROR | |
| ) | |
| except Exception as e: | |
| logger.error("Error in GraphCypherView: %s", str(e), exc_info=True) | |
| return Response( | |
| {"error": "An internal error occurred while executing Cypher."}, | |
| status=status.HTTP_500_INTERNAL_SERVER_ERROR | |
| ) | |
| # ============================================================ | |
| # Endpoint #15: GET /api/graph/stats/ | |
| # ============================================================ | |
| class GraphStatsView(APIView): | |
| """Returns graph statistics: total nodes, edges, type distribution.""" | |
| permission_classes = [IsAuthenticated] | |
| def get(self, request): | |
| try: | |
| neo4j_client = Neo4jClient() | |
| stats = neo4j_client.get_graph_statistics(request.user.id) | |
| return Response(stats, status=status.HTTP_200_OK) | |
| except Exception as e: | |
| logger.error("Error in GraphStatsView: %s", str(e), exc_info=True) | |
| return Response( | |
| {"error": "Failed to retrieve graph statistics."}, | |
| status=status.HTTP_500_INTERNAL_SERVER_ERROR | |
| ) | |
| # ============================================================ | |
| # Endpoint #16: GET /api/graph/communities/ | |
| # ============================================================ | |
| class CommunityListView(APIView): | |
| """Lists all detected communities with summaries.""" | |
| permission_classes = [IsAuthenticated] | |
| def get(self, request): | |
| try: | |
| from django.core.cache import cache | |
| user_id = str(request.user.id) | |
| cache_key = f"communities_{user_id}" | |
| # Return cached communities immediately | |
| cached = cache.get(cache_key, []) | |
| if not cached: | |
| # Cache is empty — trigger background detection and return loading state | |
| def _detect_in_background(): | |
| try: | |
| detector = CommunityDetector() | |
| detector.detect_communities(user_id) | |
| except Exception as bg_err: | |
| logger.error("Background community detection failed: %s", str(bg_err)) | |
| bg_thread = threading.Thread(target=_detect_in_background, daemon=True) | |
| bg_thread.start() | |
| return Response({ | |
| "communities": [], | |
| "count": 0, | |
| "document_summary": "", | |
| "loading": True, | |
| "message": "Communities are being generated. Please refresh in 30 seconds." | |
| }, status=status.HTTP_200_OK) | |
| # Build response from cache | |
| summary_list = [] | |
| for comm in cached: | |
| summary_list.append({ | |
| "id": comm.get("id", 0), | |
| "label": comm.get("label", f"Community {comm.get('id', '?')}"), | |
| "summary": comm.get("summary", ""), | |
| "member_count": comm.get("member_count", 0), | |
| "relationship_count": comm.get("relationship_count", 0), | |
| "members": comm.get("members", []), | |
| }) | |
| # Get document summary from cache (fast — no LLM call) | |
| doc_summary = cache.get(f"doc_summary_{user_id}", "") | |
| return Response({ | |
| "communities": summary_list, | |
| "count": len(summary_list), | |
| "document_summary": doc_summary, | |
| "loading": False | |
| }, status=status.HTTP_200_OK) | |
| except Exception as e: | |
| logger.error("Error in CommunityListView: %s", str(e), exc_info=True) | |
| return Response( | |
| {"error": "Failed to retrieve communities."}, | |
| status=status.HTTP_500_INTERNAL_SERVER_ERROR | |
| ) | |
| # ============================================================ | |
| # Endpoint #17: GET /api/graph/communities/{id}/ | |
| # ============================================================ | |
| class CommunityDetailView(APIView): | |
| """Returns a single community's full details and members.""" | |
| permission_classes = [IsAuthenticated] | |
| def get(self, request, community_id): | |
| try: | |
| detector = CommunityDetector() | |
| community = detector.get_community_by_id(int(community_id), request.user.id) | |
| if not community: | |
| return Response( | |
| {"error": f"Community with ID {community_id} not found."}, | |
| status=status.HTTP_404_NOT_FOUND | |
| ) | |
| return Response(community, status=status.HTTP_200_OK) | |
| except Exception as e: | |
| logger.error("Error in CommunityDetailView: %s", str(e), exc_info=True) | |
| return Response( | |
| {"error": "Failed to retrieve community details."}, | |
| status=status.HTTP_500_INTERNAL_SERVER_ERROR | |
| ) | |
| # ============================================================ | |
| # Endpoint #18: POST /api/graph/search/ | |
| # ============================================================ | |
| class GraphSearchView(APIView): | |
| """Search entities by name or description with fuzzy matching.""" | |
| permission_classes = [IsAuthenticated] | |
| def get(self, request): | |
| search_term = request.query_params.get("q", "").strip() | |
| if not search_term: | |
| return Response( | |
| {"error": "The 'q' query parameter is required."}, | |
| status=status.HTTP_400_BAD_REQUEST | |
| ) | |
| return self._search(search_term, request) | |
| def post(self, request): | |
| search_term = request.data.get("query", "").strip() | |
| if not search_term: | |
| return Response( | |
| {"error": "The 'query' field is required and cannot be empty."}, | |
| status=status.HTTP_400_BAD_REQUEST | |
| ) | |
| return self._search(search_term, request) | |
| def _search(self, search_term, request): | |
| try: | |
| neo4j_client = Neo4jClient() | |
| results = neo4j_client.search_entities(search_term, request.user.id) | |
| return Response({ | |
| "query": search_term, | |
| "results": results, | |
| "count": len(results) | |
| }, status=status.HTTP_200_OK) | |
| except Exception as e: | |
| logger.error("Error in GraphSearchView: %s", str(e), exc_info=True) | |
| return Response( | |
| {"error": "Failed to search entities."}, | |
| status=status.HTTP_500_INTERNAL_SERVER_ERROR | |
| ) | |
| # ============================================================ | |
| # Endpoint #21: GET /api/query/history/ | |
| # ============================================================ | |
| class QueryHistoryView(APIView): | |
| """Returns the authenticated user's recent query history.""" | |
| permission_classes = [IsAuthenticated] | |
| def get(self, request): | |
| try: | |
| logs = QueryLog.objects.filter(user=request.user)[:50] | |
| serializer = QueryLogSerializer(logs, many=True) | |
| return Response({"results": serializer.data, "count": len(logs)}) | |
| except Exception as e: | |
| logger.error("Error in QueryHistoryView: %s", str(e), exc_info=True) | |
| return Response( | |
| {"error": "Failed to retrieve query history."}, | |
| status=status.HTTP_500_INTERNAL_SERVER_ERROR | |
| ) | |
| # ============================================================ | |
| # Endpoint #19: GET /api/evaluation/ | |
| # ============================================================ | |
| class EvaluationView(APIView): | |
| """Returns evaluation results comparing retrieval modes.""" | |
| permission_classes = [IsAuthenticated] | |
| throttle_classes = [UserRateThrottle] | |
| MAX_EVAL_PAIRS = 5 | |
| MAX_LLM_CALLS = 10 | |
| def get(self, request): | |
| try: | |
| # Get evaluation pairs for this user (capped to prevent timeout) | |
| pairs = EvaluationPair.objects.filter( | |
| user=request.user, is_active=True | |
| )[:self.MAX_EVAL_PAIRS] | |
| if not pairs.exists(): | |
| return Response({ | |
| "evaluations": [], | |
| "message": "No evaluation pairs found. Create evaluation pairs first.", | |
| "summary": None | |
| }, status=status.HTTP_200_OK) | |
| rag_chain = RAGChain() | |
| eval_results = [] | |
| llm_calls = 0 | |
| for pair in pairs: | |
| if llm_calls >= self.MAX_LLM_CALLS: | |
| break | |
| modes_results = {} | |
| for mode in ["graph", "vector", "hybrid"]: | |
| if llm_calls >= self.MAX_LLM_CALLS: | |
| break | |
| start = time.time() | |
| result = rag_chain.generate_answer( | |
| pair.question, request.user.id, mode | |
| ) | |
| elapsed = time.time() - start | |
| llm_calls += 1 | |
| modes_results[mode] = { | |
| "answer": result.get("answer", ""), | |
| "response_time": round(elapsed, 3), | |
| "success": result.get("success", False) | |
| } | |
| eval_results.append({ | |
| "question": pair.question, | |
| "expected_answer": pair.expected_answer, | |
| "results": modes_results | |
| }) | |
| # Summary stats | |
| summary = { | |
| "total_pairs": len(eval_results), | |
| "llm_calls_used": llm_calls, | |
| "avg_response_times": {} | |
| } | |
| for mode in ["graph", "vector", "hybrid"]: | |
| times = [e["results"][mode]["response_time"] for e in eval_results if mode in e["results"]] | |
| summary["avg_response_times"][mode] = round(sum(times) / len(times), 3) if times else 0 | |
| return Response({ | |
| "evaluations": eval_results, | |
| "summary": summary | |
| }, status=status.HTTP_200_OK) | |
| except Exception as e: | |
| logger.error("Error in EvaluationView: %s", str(e), exc_info=True) | |
| return Response( | |
| {"error": "Failed to run evaluation."}, | |
| status=status.HTTP_500_INTERNAL_SERVER_ERROR | |
| ) | |
| # ============================================================ | |
| # Legacy endpoints (kept for backwards compatibility) | |
| # ============================================================ | |
| class CypherQueryView(APIView): | |
| """ | |
| Legacy endpoint for converting natural language queries directly into Cypher. | |
| Kept at /api/query/cypher/ for backwards compatibility. | |
| """ | |
| permission_classes = [IsAuthenticated] | |
| def post(self, request): | |
| query = request.data.get("query") | |
| if not query or not query.strip(): | |
| return Response( | |
| {"error": "The 'query' field is required and cannot be empty."}, | |
| status=status.HTTP_400_BAD_REQUEST | |
| ) | |
| logger.info("Translating NL Query to Cypher for user: %s", request.user.username) | |
| try: | |
| nl_to_cypher = NLToCypher() | |
| result = nl_to_cypher.execute_nl_query(query, request.user.id) | |
| if result.get("success", False): | |
| return Response(result, status=status.HTTP_200_OK) | |
| else: | |
| return Response( | |
| {"error": "Failed to translate and execute Cypher query."}, | |
| status=status.HTTP_500_INTERNAL_SERVER_ERROR | |
| ) | |
| except Exception as e: | |
| logger.error("Error in CypherQueryView: %s", str(e), exc_info=True) | |
| return Response( | |
| {"error": "An internal error occurred while translating your query."}, | |
| status=status.HTTP_500_INTERNAL_SERVER_ERROR | |
| ) | |
| class ShortestPathView(APIView): | |
| """ | |
| Legacy endpoint for finding and explaining the connection path between two entities. | |
| Kept at /api/query/shortest-path/ for backwards compatibility. | |
| """ | |
| permission_classes = [IsAuthenticated] | |
| def post(self, request): | |
| entity_a = request.data.get("entity_a") | |
| entity_b = request.data.get("entity_b") | |
| if not entity_a or not entity_b: | |
| return Response( | |
| {"error": "Both 'entity_a' and 'entity_b' fields are required."}, | |
| status=status.HTTP_400_BAD_REQUEST | |
| ) | |
| logger.info("Executing Shortest Path reasoning: '%s' to '%s' for user: %s", | |
| entity_a, entity_b, request.user.username) | |
| try: | |
| reasoner = MultiHopReasoner() | |
| result = reasoner.explain_connection(entity_a, entity_b, request.user.id) | |
| return Response(result, status=status.HTTP_200_OK) | |
| except Exception as e: | |
| logger.error("Error in ShortestPathView: %s", str(e), exc_info=True) | |
| return Response( | |
| {"error": "An internal error occurred while finding the path."}, | |
| status=status.HTTP_500_INTERNAL_SERVER_ERROR | |
| ) | |
| # ============================================================ | |
| # Endpoint: POST /api/query/multihop/ | |
| # ============================================================ | |
| class MultiHopQueryView(APIView): | |
| """ | |
| Dedicated endpoint for multi-hop reasoning queries. | |
| Accepts a natural language query or explicit entity pair. | |
| Returns path, alternative paths, and explanation. | |
| """ | |
| permission_classes = [IsAuthenticated] | |
| throttle_classes = [LLMLoadThrottle] | |
| def post(self, request): | |
| start_time = time.time() | |
| query = request.data.get("query", "").strip() | |
| entity_a = request.data.get("entity_a", "").strip() | |
| entity_b = request.data.get("entity_b", "").strip() | |
| if not query and not (entity_a and entity_b): | |
| return Response( | |
| {"error": "Provide a 'query' or both 'entity_a' and 'entity_b'."}, | |
| status=status.HTTP_400_BAD_REQUEST | |
| ) | |
| logger.info("Multi-hop query from user %s: '%s' (entities: %s -> %s)", | |
| request.user.username, query, entity_a, entity_b) | |
| try: | |
| reasoner = MultiHopReasoner() | |
| # If explicit entities provided, skip extraction | |
| if entity_a and entity_b: | |
| path_result = reasoner.find_all_reasoning_paths(None, entity_a, entity_b, request.user.id) | |
| else: | |
| entity_pair = reasoner.extract_entities_from_query(query) | |
| if not entity_pair: | |
| explanation_not_found = "Could not identify two entities to connect from your query. Try specifying entity names directly." | |
| # Log empty search to history | |
| response_time = round(time.time() - start_time, 4) | |
| QueryLog.objects.create( | |
| user=request.user, | |
| query_text=query, | |
| retrieval_mode=QueryLog.RetrievalMode.MULTIHOP, | |
| answer_text=explanation_not_found, | |
| response_time=response_time | |
| ) | |
| return Response({ | |
| "found": False, | |
| "explanation": explanation_not_found, | |
| "path": [], | |
| "alternative_paths": [], | |
| "hop_count": 0, | |
| "entity_a": "", | |
| "entity_b": "" | |
| }, status=status.HTTP_200_OK) | |
| entity_a = entity_pair["entity_a"] | |
| entity_b = entity_pair["entity_b"] | |
| path_result = reasoner.find_all_reasoning_paths(query, entity_a, entity_b, request.user.id) | |
| # Build hops format for frontend PathView | |
| hops = [] | |
| for step in path_result.get("path", []): | |
| hops.append({ | |
| "from": step["source"], | |
| "rel": step["type"], | |
| "to": step["target"], | |
| "doc": step.get("source_doc", ""), | |
| "chunk_text": step.get("chunk_text", "") | |
| }) | |
| # Format alternative paths | |
| alt_paths = [] | |
| for alt_obj in path_result.get("alternative_paths", []): | |
| alt_hops = [] | |
| for step in alt_obj.get("hops", []): | |
| alt_hops.append({ | |
| "from": step.get("source", "") or step.get("from", ""), | |
| "rel": step.get("type", "") or step.get("rel", ""), | |
| "to": step.get("target", "") or step.get("to", ""), | |
| "doc": step.get("source_doc", "") or step.get("doc", ""), | |
| "chunk_text": step.get("chunk_text", "") | |
| }) | |
| alt_paths.append({ | |
| "hops": alt_hops, | |
| "explanation": alt_obj.get("explanation", "") | |
| }) | |
| # Collect all entity names for graph highlighting | |
| highlighted_entities = [] | |
| for step in path_result.get("path", []): | |
| if step["source"] not in highlighted_entities: | |
| highlighted_entities.append(step["source"]) | |
| if step["target"] not in highlighted_entities: | |
| highlighted_entities.append(step["target"]) | |
| # Save query log to database for user history (only accessible by request.user) | |
| response_time = round(time.time() - start_time, 4) | |
| QueryLog.objects.create( | |
| user=request.user, | |
| query_text=query or f"Find path from {entity_a} to {entity_b}", | |
| retrieval_mode=QueryLog.RetrievalMode.MULTIHOP, | |
| answer_text=path_result.get("explanation", "") or "No connection found.", | |
| response_time=response_time | |
| ) | |
| return Response({ | |
| "found": path_result.get("found", False), | |
| "explanation": path_result.get("explanation", ""), | |
| "path": path_result.get("path", []), | |
| "hops": hops, | |
| "alternative_paths": alt_paths, | |
| "hop_count": path_result.get("hop_count", 0), | |
| "entity_a": entity_a, | |
| "entity_b": entity_b, | |
| "highlighted_entities": highlighted_entities, | |
| "success": True | |
| }, status=status.HTTP_200_OK) | |
| except Exception as e: | |
| logger.error("Error in MultiHopQueryView: %s", str(e), exc_info=True) | |
| return Response( | |
| {"error": "An internal error occurred during multi-hop reasoning."}, | |
| status=status.HTTP_500_INTERNAL_SERVER_ERROR | |
| ) | |
| # ============================================================ | |
| # Endpoint: POST /api/query/multihop/explain/ | |
| # ============================================================ | |
| class MultiHopExplainPathView(APIView): | |
| """ | |
| On-demand endpoint to generate an LLM explanation for a selected reasoning path. | |
| Accepts entity_a, entity_b, and the path hops. | |
| """ | |
| permission_classes = [IsAuthenticated] | |
| throttle_classes = [LLMLoadThrottle] | |
| def post(self, request): | |
| start_time = time.time() | |
| entity_a = request.data.get("entity_a", "").strip() | |
| entity_b = request.data.get("entity_b", "").strip() | |
| hops = request.data.get("hops", []) | |
| if not entity_a or not entity_b or not hops: | |
| return Response( | |
| {"error": "Provide 'entity_a', 'entity_b', and 'hops'."}, | |
| status=status.HTTP_400_BAD_REQUEST | |
| ) | |
| try: | |
| reasoner = MultiHopReasoner() | |
| # Format the connection steps | |
| alt_steps = [] | |
| for step in hops: | |
| alt_steps.append(f"({step.get('from', '')}) --[{step.get('rel', '')}]--> ({step.get('to', '')})") | |
| # Extract chunks | |
| chunk_texts = [step.get("chunk_text", "") for step in hops if step.get("chunk_text", "")] | |
| chunk_context = "\n\n".join([f"Source Document Chunk:\n{text}" for text in chunk_texts if text]) | |
| path_details = "Connection steps:\n" + "\n".join(alt_steps) | |
| if chunk_context: | |
| path_details += f"\n\nRetrieved Relevant Document Text:\n{chunk_context}" | |
| response = reasoner.chain.invoke({ | |
| "entity_a": entity_a, | |
| "entity_b": entity_b, | |
| "path_details": path_details | |
| }) | |
| explanation = response.content.strip() | |
| # Save query log to database for user history (only accessible by request.user) | |
| response_time = round(time.time() - start_time, 4) | |
| QueryLog.objects.create( | |
| user=request.user, | |
| query_text=f"Explain Path: {entity_a} -> {entity_b}", | |
| retrieval_mode=QueryLog.RetrievalMode.MULTIHOP, | |
| answer_text=explanation, | |
| response_time=response_time | |
| ) | |
| return Response({ | |
| "explanation": explanation, | |
| "success": True | |
| }, status=status.HTTP_200_OK) | |
| except Exception as e: | |
| logger.error("Error in MultiHopExplainPathView: %s", str(e), exc_info=True) | |
| return Response( | |
| {"error": "Failed to generate explanation for the selected reasoning path."}, | |
| status=status.HTTP_500_INTERNAL_SERVER_ERROR | |
| ) | |