Ashutosh1975270's picture
feat: redesign multi-hop reasoning, add sticky layout, query logs history tracking, and hybrid fallback search
0527a95
Raw
History Blame Contribute Delete
47.6 kB
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
)