Spaces:
Sleeping
Sleeping
Commit ·
db4d559
1
Parent(s): 362aea9
feat: implement GraphRAG Phase 1 backend service layer and test suite
Browse files- .env.example +21 -0
- .gitignore +44 -0
- backend/Dockerfile +27 -0
- backend/config/settings.py +188 -0
- backend/config/urls.py +10 -0
- backend/graphrag/__init__.py +1 -0
- backend/graphrag/migrations/0001_initial.py +91 -0
- backend/graphrag/migrations/__init__.py +0 -0
- backend/graphrag/models.py +101 -0
- backend/graphrag/serializers.py +116 -0
- backend/graphrag/services/entity_extractor.py +73 -0
- backend/graphrag/services/entity_resolver.py +93 -0
- backend/graphrag/services/graph_builder.py +197 -0
- backend/graphrag/services/graph_retriever.py +136 -0
- backend/graphrag/services/hybrid_retriever.py +73 -0
- backend/graphrag/services/llm_client.py +34 -0
- backend/graphrag/services/multihop_reasoner.py +120 -0
- backend/graphrag/services/neo4j_client.py +197 -0
- backend/graphrag/services/nl_to_cypher.py +80 -0
- backend/graphrag/services/rag_chain.py +143 -0
- backend/graphrag/services/relationship_extractor.py +78 -0
- backend/graphrag/services/vector_retriever.py +125 -0
- backend/graphrag/tests.py +133 -0
- backend/graphrag/tests_comprehensive.py +1177 -0
- backend/graphrag/urls.py +37 -0
- backend/graphrag/views.py +270 -0
- backend/manage.py +20 -0
- backend/requirements.txt +17 -0
- docker-compose.yml +33 -0
.env.example
CHANGED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Django Configuration
|
| 2 |
+
SECRET_KEY=django-insecure-change-this-in-production-use-a-strong-random-key
|
| 3 |
+
DEBUG=True
|
| 4 |
+
ALLOWED_HOSTS=localhost,127.0.0.1,backend
|
| 5 |
+
|
| 6 |
+
# Neo4j Graph Database Configuration
|
| 7 |
+
NEO4J_URI=bolt://localhost:7687
|
| 8 |
+
NEO4J_USERNAME=neo4j
|
| 9 |
+
NEO4J_PASSWORD=password
|
| 10 |
+
|
| 11 |
+
# Vector DB Configuration
|
| 12 |
+
CHROMADB_DIR=chroma_db
|
| 13 |
+
|
| 14 |
+
# LLM Providers API Keys (Fill in at least one)
|
| 15 |
+
GROQ_API_KEY=your_groq_api_key_here
|
| 16 |
+
GOOGLE_API_KEY=your_gemini_api_key_here
|
| 17 |
+
|
| 18 |
+
# LangSmith Observability & Tracing (Optional)
|
| 19 |
+
LANGCHAIN_TRACING_V2=false
|
| 20 |
+
LANGCHAIN_API_KEY=your_langsmith_api_key_here
|
| 21 |
+
LANGCHAIN_PROJECT=graphrag-knowledge-ai
|
.gitignore
CHANGED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python / Django
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
*.sqlite3
|
| 6 |
+
backend/db/
|
| 7 |
+
backend/logs/
|
| 8 |
+
|
| 9 |
+
# Environments & sensitive files
|
| 10 |
+
.env
|
| 11 |
+
.env.local
|
| 12 |
+
.env.development.local
|
| 13 |
+
.env.test.local
|
| 14 |
+
.env.production.local
|
| 15 |
+
venv/
|
| 16 |
+
env/
|
| 17 |
+
ENV/
|
| 18 |
+
|
| 19 |
+
# OS files
|
| 20 |
+
.DS_Store
|
| 21 |
+
Thumbs.db
|
| 22 |
+
|
| 23 |
+
# Project uploads
|
| 24 |
+
uploaded_documents/
|
| 25 |
+
|
| 26 |
+
# Frontend
|
| 27 |
+
node_modules/
|
| 28 |
+
.next/
|
| 29 |
+
out/
|
| 30 |
+
build/
|
| 31 |
+
dist/
|
| 32 |
+
npm-debug.log*
|
| 33 |
+
yarn-debug.log*
|
| 34 |
+
yarn-error.log*
|
| 35 |
+
.pnpm-debug.log*
|
| 36 |
+
|
| 37 |
+
# Local design / agent files (Not for GitHub)
|
| 38 |
+
Z111.Learning.md
|
| 39 |
+
Phase_3_Frontend_Plan.md
|
| 40 |
+
Phase_1_Specification.md
|
| 41 |
+
Agent.md
|
| 42 |
+
Agent/
|
| 43 |
+
UX_SPECIFICATION.md
|
| 44 |
+
Assignment_10_GraphRAG_Knowledge_Graph_AI.md
|
backend/Dockerfile
CHANGED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
# Prevent Python from writing .pyc files and enable unbuffered logging
|
| 4 |
+
ENV PYTHONDONTWRITEBYTECODE=1
|
| 5 |
+
ENV PYTHONUNBUFFERED=1
|
| 6 |
+
|
| 7 |
+
WORKDIR /app
|
| 8 |
+
|
| 9 |
+
# Install system dependencies
|
| 10 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 11 |
+
build-essential \
|
| 12 |
+
libpq-dev \
|
| 13 |
+
curl \
|
| 14 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 15 |
+
|
| 16 |
+
# Install python dependencies
|
| 17 |
+
COPY requirements.txt /app/
|
| 18 |
+
RUN pip install --no-cache-dir --upgrade pip && \
|
| 19 |
+
pip install --no-cache-dir -r requirements.txt
|
| 20 |
+
|
| 21 |
+
# Copy backend codebase
|
| 22 |
+
COPY . /app/
|
| 23 |
+
|
| 24 |
+
EXPOSE 8000
|
| 25 |
+
|
| 26 |
+
# Default command
|
| 27 |
+
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
backend/config/settings.py
CHANGED
|
@@ -0,0 +1,188 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
from datetime import timedelta
|
| 4 |
+
import dotenv
|
| 5 |
+
|
| 6 |
+
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
| 7 |
+
BASE_DIR = Path(__file__).resolve().parent.parent
|
| 8 |
+
|
| 9 |
+
# Load environment variables from parent directory .env
|
| 10 |
+
ENV_PATH = BASE_DIR.parent / '.env'
|
| 11 |
+
if ENV_PATH.exists():
|
| 12 |
+
dotenv.load_dotenv(ENV_PATH)
|
| 13 |
+
|
| 14 |
+
# Security settings
|
| 15 |
+
SECRET_KEY = os.getenv('SECRET_KEY', 'django-insecure-fallback-secret-key-change-in-prod')
|
| 16 |
+
DEBUG = os.getenv('DEBUG', 'True') == 'True'
|
| 17 |
+
ALLOWED_HOSTS = [h.strip() for h in os.getenv('ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',') if h.strip()]
|
| 18 |
+
|
| 19 |
+
# Application definition
|
| 20 |
+
INSTALLED_APPS = [
|
| 21 |
+
'django.contrib.admin',
|
| 22 |
+
'django.contrib.auth',
|
| 23 |
+
'django.contrib.contenttypes',
|
| 24 |
+
'django.contrib.sessions',
|
| 25 |
+
'django.contrib.messages',
|
| 26 |
+
'django.contrib.staticfiles',
|
| 27 |
+
|
| 28 |
+
# Third party apps
|
| 29 |
+
'rest_framework',
|
| 30 |
+
'corsheaders',
|
| 31 |
+
|
| 32 |
+
# Custom apps
|
| 33 |
+
'graphrag',
|
| 34 |
+
]
|
| 35 |
+
|
| 36 |
+
MIDDLEWARE = [
|
| 37 |
+
'corsheaders.middleware.CorsMiddleware', # Must be placed high up
|
| 38 |
+
'django.middleware.security.SecurityMiddleware',
|
| 39 |
+
'django.contrib.sessions.middleware.SessionMiddleware',
|
| 40 |
+
'django.middleware.common.CommonMiddleware',
|
| 41 |
+
'django.middleware.csrf.CsrfViewMiddleware',
|
| 42 |
+
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
| 43 |
+
'django.contrib.messages.middleware.MessageMiddleware',
|
| 44 |
+
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
| 45 |
+
]
|
| 46 |
+
|
| 47 |
+
ROOT_URLCONF = 'config.urls'
|
| 48 |
+
|
| 49 |
+
TEMPLATES = [
|
| 50 |
+
{
|
| 51 |
+
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
| 52 |
+
'DIRS': [],
|
| 53 |
+
'APP_DIRS': True,
|
| 54 |
+
'OPTIONS': {
|
| 55 |
+
'context_processors': [
|
| 56 |
+
'django.template.context_processors.debug',
|
| 57 |
+
'django.template.context_processors.request',
|
| 58 |
+
'django.contrib.auth.context_processors.auth',
|
| 59 |
+
'django.contrib.messages.context_processors.messages',
|
| 60 |
+
],
|
| 61 |
+
},
|
| 62 |
+
},
|
| 63 |
+
]
|
| 64 |
+
|
| 65 |
+
WSGI_APPLICATION = 'config.wsgi.application'
|
| 66 |
+
|
| 67 |
+
# Database configuration (SQLite for Django metadata store)
|
| 68 |
+
DATABASES = {
|
| 69 |
+
'default': {
|
| 70 |
+
'ENGINE': 'django.db.backends.sqlite3',
|
| 71 |
+
'NAME': BASE_DIR / 'db.sqlite3',
|
| 72 |
+
}
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
# Custom User Model
|
| 76 |
+
AUTH_USER_MODEL = 'graphrag.User'
|
| 77 |
+
|
| 78 |
+
# Password validation
|
| 79 |
+
AUTH_PASSWORD_VALIDATORS = [
|
| 80 |
+
{
|
| 81 |
+
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
| 82 |
+
},
|
| 83 |
+
{
|
| 84 |
+
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
| 85 |
+
'OPTIONS': {
|
| 86 |
+
'min_length': 8,
|
| 87 |
+
}
|
| 88 |
+
},
|
| 89 |
+
{
|
| 90 |
+
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
| 91 |
+
},
|
| 92 |
+
{
|
| 93 |
+
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
| 94 |
+
},
|
| 95 |
+
]
|
| 96 |
+
|
| 97 |
+
# Internationalization
|
| 98 |
+
LANGUAGE_CODE = 'en-us'
|
| 99 |
+
TIME_ZONE = 'UTC'
|
| 100 |
+
USE_I18N = True
|
| 101 |
+
USE_TZ = True
|
| 102 |
+
|
| 103 |
+
# Static files (CSS, JavaScript, Images)
|
| 104 |
+
STATIC_URL = 'static/'
|
| 105 |
+
|
| 106 |
+
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
| 107 |
+
|
| 108 |
+
# CORS configuration
|
| 109 |
+
CORS_ALLOW_ALL_ORIGINS = True # In development; tighten in production
|
| 110 |
+
|
| 111 |
+
# Django REST Framework configuration
|
| 112 |
+
REST_FRAMEWORK = {
|
| 113 |
+
'DEFAULT_AUTHENTICATION_CLASSES': (
|
| 114 |
+
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
| 115 |
+
),
|
| 116 |
+
'DEFAULT_PERMISSION_CLASSES': (
|
| 117 |
+
'rest_framework.permissions.IsAuthenticated',
|
| 118 |
+
),
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
# Simple JWT settings
|
| 122 |
+
SIMPLE_JWT = {
|
| 123 |
+
'ACCESS_TOKEN_LIFETIME': timedelta(days=1),
|
| 124 |
+
'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
|
| 125 |
+
'ROTATE_REFRESH_TOKENS': False,
|
| 126 |
+
'BLACKLIST_AFTER_ROTATION': False,
|
| 127 |
+
'ALGORITHM': 'HS256',
|
| 128 |
+
'SIGNING_KEY': SECRET_KEY,
|
| 129 |
+
'VERIFYING_KEY': None,
|
| 130 |
+
'AUDIENCE': None,
|
| 131 |
+
'ISSUER': None,
|
| 132 |
+
'AUTH_HEADER_TYPES': ('Bearer',),
|
| 133 |
+
'USER_ID_FIELD': 'id',
|
| 134 |
+
'USER_ID_CLAIM': 'user_id',
|
| 135 |
+
'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.AccessToken',),
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
# Graph Database (Neo4j) settings
|
| 139 |
+
NEO4J_URI = os.getenv('NEO4J_URI', 'bolt://localhost:7687')
|
| 140 |
+
NEO4J_USERNAME = os.getenv('NEO4J_USERNAME', 'neo4j')
|
| 141 |
+
NEO4J_PASSWORD = os.getenv('NEO4J_PASSWORD', 'password')
|
| 142 |
+
|
| 143 |
+
# ChromaDB settings
|
| 144 |
+
CHROMADB_DIR = os.path.join(BASE_DIR, os.getenv('CHROMADB_DIR', 'chroma_db'))
|
| 145 |
+
|
| 146 |
+
# Logging Configuration
|
| 147 |
+
LOG_DIR = BASE_DIR / 'logs'
|
| 148 |
+
LOG_DIR.mkdir(exist_ok=True)
|
| 149 |
+
|
| 150 |
+
LOGGING = {
|
| 151 |
+
'version': 1,
|
| 152 |
+
'disable_existing_loggers': False,
|
| 153 |
+
'formatters': {
|
| 154 |
+
'detailed': {
|
| 155 |
+
'format': '%(asctime)s [%(levelname)s] %(name)s:%(lineno)d - %(message)s'
|
| 156 |
+
},
|
| 157 |
+
'simple': {
|
| 158 |
+
'format': '[%(levelname)s] %(message)s'
|
| 159 |
+
},
|
| 160 |
+
},
|
| 161 |
+
'handlers': {
|
| 162 |
+
'console': {
|
| 163 |
+
'class': 'logging.StreamHandler',
|
| 164 |
+
'formatter': 'detailed',
|
| 165 |
+
},
|
| 166 |
+
'file': {
|
| 167 |
+
'class': 'logging.FileHandler',
|
| 168 |
+
'filename': LOG_DIR / 'graphrag.log',
|
| 169 |
+
'formatter': 'detailed',
|
| 170 |
+
},
|
| 171 |
+
},
|
| 172 |
+
'loggers': {
|
| 173 |
+
'': {
|
| 174 |
+
'handlers': ['console', 'file'],
|
| 175 |
+
'level': 'INFO',
|
| 176 |
+
},
|
| 177 |
+
'django': {
|
| 178 |
+
'handlers': ['console', 'file'],
|
| 179 |
+
'level': 'INFO',
|
| 180 |
+
'propagate': False,
|
| 181 |
+
},
|
| 182 |
+
'graphrag': {
|
| 183 |
+
'handlers': ['console', 'file'],
|
| 184 |
+
'level': 'DEBUG',
|
| 185 |
+
'propagate': False,
|
| 186 |
+
},
|
| 187 |
+
},
|
| 188 |
+
}
|
backend/config/urls.py
CHANGED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from django.contrib import admin
|
| 2 |
+
from django.urls import path, include
|
| 3 |
+
|
| 4 |
+
urlpatterns = [
|
| 5 |
+
# Built-in Django Admin Interface
|
| 6 |
+
path('admin/', admin.site.urls),
|
| 7 |
+
|
| 8 |
+
# Delegating all /api/ traffic to the graphrag sub-app
|
| 9 |
+
path('api/', include('graphrag.urls')),
|
| 10 |
+
]
|
backend/graphrag/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Initializer for graphrag package
|
backend/graphrag/migrations/0001_initial.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Generated by Django 4.2.30 on 2026-07-08 09:01
|
| 2 |
+
|
| 3 |
+
from django.conf import settings
|
| 4 |
+
import django.contrib.auth.models
|
| 5 |
+
import django.contrib.auth.validators
|
| 6 |
+
from django.db import migrations, models
|
| 7 |
+
import django.db.models.deletion
|
| 8 |
+
import django.utils.timezone
|
| 9 |
+
import uuid
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class Migration(migrations.Migration):
|
| 13 |
+
|
| 14 |
+
initial = True
|
| 15 |
+
|
| 16 |
+
dependencies = [
|
| 17 |
+
('auth', '0012_alter_user_first_name_max_length'),
|
| 18 |
+
]
|
| 19 |
+
|
| 20 |
+
operations = [
|
| 21 |
+
migrations.CreateModel(
|
| 22 |
+
name='User',
|
| 23 |
+
fields=[
|
| 24 |
+
('password', models.CharField(max_length=128, verbose_name='password')),
|
| 25 |
+
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
| 26 |
+
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
| 27 |
+
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
|
| 28 |
+
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
|
| 29 |
+
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
|
| 30 |
+
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
|
| 31 |
+
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
|
| 32 |
+
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
|
| 33 |
+
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
| 34 |
+
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
| 35 |
+
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
| 36 |
+
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
| 37 |
+
],
|
| 38 |
+
options={
|
| 39 |
+
'verbose_name': 'user',
|
| 40 |
+
'verbose_name_plural': 'users',
|
| 41 |
+
'abstract': False,
|
| 42 |
+
},
|
| 43 |
+
managers=[
|
| 44 |
+
('objects', django.contrib.auth.models.UserManager()),
|
| 45 |
+
],
|
| 46 |
+
),
|
| 47 |
+
migrations.CreateModel(
|
| 48 |
+
name='QueryLog',
|
| 49 |
+
fields=[
|
| 50 |
+
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
| 51 |
+
('query_text', models.TextField()),
|
| 52 |
+
('retrieval_mode', models.CharField(choices=[('GRAPH', 'Graph Only'), ('VECTOR', 'Vector Only'), ('HYBRID', 'Hybrid')], default='HYBRID', max_length=20)),
|
| 53 |
+
('answer_text', models.TextField()),
|
| 54 |
+
('response_time', models.FloatField(help_text='Response time in seconds')),
|
| 55 |
+
('created_at', models.DateTimeField(auto_now_add=True)),
|
| 56 |
+
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='query_logs', to=settings.AUTH_USER_MODEL)),
|
| 57 |
+
],
|
| 58 |
+
options={
|
| 59 |
+
'ordering': ['-created_at'],
|
| 60 |
+
},
|
| 61 |
+
),
|
| 62 |
+
migrations.CreateModel(
|
| 63 |
+
name='EvaluationPair',
|
| 64 |
+
fields=[
|
| 65 |
+
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
| 66 |
+
('question', models.TextField()),
|
| 67 |
+
('expected_answer', models.TextField()),
|
| 68 |
+
('is_active', models.BooleanField(default=True)),
|
| 69 |
+
('created_at', models.DateTimeField(auto_now_add=True)),
|
| 70 |
+
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='evaluation_pairs', to=settings.AUTH_USER_MODEL)),
|
| 71 |
+
],
|
| 72 |
+
),
|
| 73 |
+
migrations.CreateModel(
|
| 74 |
+
name='Document',
|
| 75 |
+
fields=[
|
| 76 |
+
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
| 77 |
+
('name', models.CharField(max_length=255)),
|
| 78 |
+
('file', models.FileField(upload_to='uploaded_documents/')),
|
| 79 |
+
('status', models.CharField(choices=[('PENDING', 'Pending'), ('PROCESSING', 'Processing'), ('COMPLETED', 'Completed'), ('FAILED', 'Failed')], default='PENDING', max_length=20)),
|
| 80 |
+
('entity_count', models.IntegerField(default=0)),
|
| 81 |
+
('relationship_count', models.IntegerField(default=0)),
|
| 82 |
+
('error_message', models.TextField(blank=True, null=True)),
|
| 83 |
+
('created_at', models.DateTimeField(auto_now_add=True)),
|
| 84 |
+
('updated_at', models.DateTimeField(auto_now=True)),
|
| 85 |
+
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='documents', to=settings.AUTH_USER_MODEL)),
|
| 86 |
+
],
|
| 87 |
+
options={
|
| 88 |
+
'ordering': ['-created_at'],
|
| 89 |
+
},
|
| 90 |
+
),
|
| 91 |
+
]
|
backend/graphrag/migrations/__init__.py
ADDED
|
File without changes
|
backend/graphrag/models.py
CHANGED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uuid
|
| 2 |
+
from django.db import models
|
| 3 |
+
from django.contrib.auth.models import AbstractUser
|
| 4 |
+
from django.conf import settings
|
| 5 |
+
|
| 6 |
+
class User(AbstractUser):
|
| 7 |
+
"""
|
| 8 |
+
Custom User Model to allow for seamless future attribute additions
|
| 9 |
+
without database schema breakage. Uses UUID as the primary key.
|
| 10 |
+
"""
|
| 11 |
+
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
| 12 |
+
|
| 13 |
+
def __str__(self):
|
| 14 |
+
return self.username
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class Document(models.Model):
|
| 18 |
+
"""
|
| 19 |
+
Tracks uploaded documents, ingestion status, metadata, and error details.
|
| 20 |
+
"""
|
| 21 |
+
class Status(models.TextChoices):
|
| 22 |
+
PENDING = 'PENDING', 'Pending'
|
| 23 |
+
PROCESSING = 'PROCESSING', 'Processing'
|
| 24 |
+
COMPLETED = 'COMPLETED', 'Completed'
|
| 25 |
+
FAILED = 'FAILED', 'Failed'
|
| 26 |
+
|
| 27 |
+
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
| 28 |
+
user = models.ForeignKey(
|
| 29 |
+
settings.AUTH_USER_MODEL,
|
| 30 |
+
on_delete=models.CASCADE,
|
| 31 |
+
related_name='documents'
|
| 32 |
+
)
|
| 33 |
+
name = models.CharField(max_length=255)
|
| 34 |
+
file = models.FileField(upload_to='uploaded_documents/')
|
| 35 |
+
status = models.CharField(
|
| 36 |
+
max_length=20,
|
| 37 |
+
choices=Status.choices,
|
| 38 |
+
default=Status.PENDING
|
| 39 |
+
)
|
| 40 |
+
entity_count = models.IntegerField(default=0)
|
| 41 |
+
relationship_count = models.IntegerField(default=0)
|
| 42 |
+
error_message = models.TextField(blank=True, null=True)
|
| 43 |
+
created_at = models.DateTimeField(auto_now_add=True)
|
| 44 |
+
updated_at = models.DateTimeField(auto_now=True)
|
| 45 |
+
|
| 46 |
+
class Meta:
|
| 47 |
+
ordering = ['-created_at']
|
| 48 |
+
|
| 49 |
+
def __str__(self):
|
| 50 |
+
return f"{self.name} ({self.status})"
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class QueryLog(models.Model):
|
| 54 |
+
"""
|
| 55 |
+
Logs queries, selected retrieval strategies, generated answers, and response times.
|
| 56 |
+
"""
|
| 57 |
+
class RetrievalMode(models.TextChoices):
|
| 58 |
+
GRAPH = 'GRAPH', 'Graph Only'
|
| 59 |
+
VECTOR = 'VECTOR', 'Vector Only'
|
| 60 |
+
HYBRID = 'HYBRID', 'Hybrid'
|
| 61 |
+
|
| 62 |
+
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
| 63 |
+
user = models.ForeignKey(
|
| 64 |
+
settings.AUTH_USER_MODEL,
|
| 65 |
+
on_delete=models.CASCADE,
|
| 66 |
+
related_name='query_logs'
|
| 67 |
+
)
|
| 68 |
+
query_text = models.TextField()
|
| 69 |
+
retrieval_mode = models.CharField(
|
| 70 |
+
max_length=20,
|
| 71 |
+
choices=RetrievalMode.choices,
|
| 72 |
+
default=RetrievalMode.HYBRID
|
| 73 |
+
)
|
| 74 |
+
answer_text = models.TextField()
|
| 75 |
+
response_time = models.FloatField(help_text="Response time in seconds")
|
| 76 |
+
created_at = models.DateTimeField(auto_now_add=True)
|
| 77 |
+
|
| 78 |
+
class Meta:
|
| 79 |
+
ordering = ['-created_at']
|
| 80 |
+
|
| 81 |
+
def __str__(self):
|
| 82 |
+
return f"Query: {self.query_text[:30]}... ({self.retrieval_mode})"
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
class EvaluationPair(models.Model):
|
| 86 |
+
"""
|
| 87 |
+
Stores target question-answer evaluation pairs to compute metrics against.
|
| 88 |
+
"""
|
| 89 |
+
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
| 90 |
+
user = models.ForeignKey(
|
| 91 |
+
settings.AUTH_USER_MODEL,
|
| 92 |
+
on_delete=models.CASCADE,
|
| 93 |
+
related_name='evaluation_pairs'
|
| 94 |
+
)
|
| 95 |
+
question = models.TextField()
|
| 96 |
+
expected_answer = models.TextField()
|
| 97 |
+
is_active = models.BooleanField(default=True)
|
| 98 |
+
created_at = models.DateTimeField(auto_now_add=True)
|
| 99 |
+
|
| 100 |
+
def __str__(self):
|
| 101 |
+
return f"Question: {self.question[:40]}..."
|
backend/graphrag/serializers.py
CHANGED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
import logging
|
| 3 |
+
from rest_framework import serializers
|
| 4 |
+
from django.contrib.auth import get_user_model
|
| 5 |
+
from django.contrib.auth.password_validation import validate_password
|
| 6 |
+
from .models import Document, QueryLog, EvaluationPair
|
| 7 |
+
|
| 8 |
+
logger = logging.getLogger(__name__)
|
| 9 |
+
User = get_user_model()
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
DISPOSABLE_DOMAINS = {
|
| 13 |
+
'mailinator.com', 'yopmail.com', 'tempmail.com', 'temp-mail.org',
|
| 14 |
+
'10minutemail.com', 'guerrillamail.com', 'trashmail.com'
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
class UserSerializer(serializers.ModelSerializer):
|
| 18 |
+
class Meta:
|
| 19 |
+
model = User
|
| 20 |
+
fields = ('id', 'username', 'email')
|
| 21 |
+
|
| 22 |
+
class RegisterSerializer(serializers.ModelSerializer):
|
| 23 |
+
password = serializers.CharField(write_only=True, required=True, style={'input_type': 'password'})
|
| 24 |
+
confirm_password = serializers.CharField(write_only=True, required=True, style={'input_type': 'password'})
|
| 25 |
+
|
| 26 |
+
class Meta:
|
| 27 |
+
model = User
|
| 28 |
+
fields = ('id', 'username', 'email', 'password', 'confirm_password')
|
| 29 |
+
|
| 30 |
+
def create(self, validated_data):
|
| 31 |
+
validated_data.pop('confirm_password')
|
| 32 |
+
return User.objects.create_user(**validated_data)
|
| 33 |
+
def validate_email(self, value):
|
| 34 |
+
logger.debug("Validating email: %s", value)
|
| 35 |
+
|
| 36 |
+
# 1. Format normalization
|
| 37 |
+
value = value.strip().lower()
|
| 38 |
+
|
| 39 |
+
# 2. Extract and check domain
|
| 40 |
+
try:
|
| 41 |
+
domain = value.split('@')[1]
|
| 42 |
+
except IndexError:
|
| 43 |
+
raise serializers.ValidationError("Invalid email address format.")
|
| 44 |
+
|
| 45 |
+
if domain in DISPOSABLE_DOMAINS:
|
| 46 |
+
logger.warning("Blocked attempt to register with fake/disposable email domain: %s", domain)
|
| 47 |
+
raise serializers.ValidationError("Disposable or temporary email accounts are not permitted.")
|
| 48 |
+
|
| 49 |
+
# 3. Check for uniqueness
|
| 50 |
+
if User.objects.filter(email=value).exists():
|
| 51 |
+
logger.warning("Registration failed - email already exists: %s", value)
|
| 52 |
+
raise serializers.ValidationError("A user with this email already exists.")
|
| 53 |
+
|
| 54 |
+
return value
|
| 55 |
+
|
| 56 |
+
def validate(self, attrs):
|
| 57 |
+
logger.debug("Checking password policies and password matching...")
|
| 58 |
+
password = attrs.get('password')
|
| 59 |
+
confirm_password = attrs.get('confirm_password')
|
| 60 |
+
# 1. Verify match
|
| 61 |
+
if password != confirm_password:
|
| 62 |
+
raise serializers.ValidationError({"password": "Passwords do not match."})
|
| 63 |
+
# 2. Custom Strict Character Check (Uppercase, Lowercase, Number, Special Char)
|
| 64 |
+
if not re.search(r"[A-Z]", password):
|
| 65 |
+
raise serializers.ValidationError({"password": "Password must contain at least one uppercase letter."})
|
| 66 |
+
if not re.search(r"[a-z]", password):
|
| 67 |
+
raise serializers.ValidationError({"password": "Password must contain at least one lowercase letter."})
|
| 68 |
+
if not re.search(r"[0-9]", password):
|
| 69 |
+
raise serializers.ValidationError({"password": "Password must contain at least one number."})
|
| 70 |
+
if not re.search(r"[@$!%*?&]", password):
|
| 71 |
+
raise serializers.ValidationError({"password": "Password must contain at least one special character (@, $, !, %, *, ?, &)."})
|
| 72 |
+
return attrs
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class DocumentSerializer(serializers.ModelSerializer):
|
| 76 |
+
user = UserSerializer(read_only=True)
|
| 77 |
+
file_url = serializers.SerializerMethodField()
|
| 78 |
+
|
| 79 |
+
class Meta:
|
| 80 |
+
model = Document
|
| 81 |
+
fields = (
|
| 82 |
+
'id', 'user', 'name', 'file', 'file_url', 'status',
|
| 83 |
+
'entity_count', 'relationship_count', 'error_message',
|
| 84 |
+
'created_at', 'updated_at'
|
| 85 |
+
)
|
| 86 |
+
read_only_fields = (
|
| 87 |
+
'id', 'user', 'status', 'entity_count', 'relationship_count',
|
| 88 |
+
'error_message', 'created_at', 'updated_at'
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
def get_file_url(self, obj):
|
| 92 |
+
request = self.context.get('request')
|
| 93 |
+
if obj.file and request:
|
| 94 |
+
return request.build_absolute_uri(obj.file.url)
|
| 95 |
+
return None
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
class QueryLogSerializer(serializers.ModelSerializer):
|
| 99 |
+
user = UserSerializer(read_only=True)
|
| 100 |
+
|
| 101 |
+
class Meta:
|
| 102 |
+
model = QueryLog
|
| 103 |
+
fields = (
|
| 104 |
+
'id', 'user', 'query_text', 'retrieval_mode',
|
| 105 |
+
'answer_text', 'response_time', 'created_at'
|
| 106 |
+
)
|
| 107 |
+
read_only_fields = ('id', 'user', 'created_at')
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
class EvaluationPairSerializer(serializers.ModelSerializer):
|
| 111 |
+
user = UserSerializer(read_only=True)
|
| 112 |
+
|
| 113 |
+
class Meta:
|
| 114 |
+
model = EvaluationPair
|
| 115 |
+
fields = ('id', 'user', 'question', 'expected_answer', 'is_active', 'created_at')
|
| 116 |
+
read_only_fields = ('id', 'user', 'created_at')
|
backend/graphrag/services/entity_extractor.py
CHANGED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from typing import List
|
| 3 |
+
from pydantic import BaseModel, Field
|
| 4 |
+
from langchain_core.prompts import ChatPromptTemplate
|
| 5 |
+
from .llm_client import get_llm
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger(__name__)
|
| 8 |
+
|
| 9 |
+
class Entity(BaseModel):
|
| 10 |
+
"""
|
| 11 |
+
Represents a single extracted entity from the document text.
|
| 12 |
+
"""
|
| 13 |
+
name: str = Field(
|
| 14 |
+
description="The canonical name of the entity, correctly capitalized (e.g., 'Google', 'John Smith'). Do not use pronouns or generic words."
|
| 15 |
+
)
|
| 16 |
+
type: str = Field(
|
| 17 |
+
description="The category of the entity. Must be exactly one of: PERSON, ORGANIZATION, PRODUCT, TECHNOLOGY, LOCATION, EVENT, DATE, CONCEPT, DOCUMENT"
|
| 18 |
+
)
|
| 19 |
+
description: str = Field(
|
| 20 |
+
description="A brief 1-2 sentence description explaining who or what this entity is, based strictly on the text chunk."
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
class ExtractedEntities(BaseModel):
|
| 24 |
+
"""
|
| 25 |
+
Container list of all extracted entities.
|
| 26 |
+
"""
|
| 27 |
+
entities: List[Entity]
|
| 28 |
+
|
| 29 |
+
class EntityExtractor:
|
| 30 |
+
def __init__(self):
|
| 31 |
+
logger.info("Initializing EntityExtractor service.")
|
| 32 |
+
self.llm = get_llm(temperature=0.0)
|
| 33 |
+
# Binds the LLM to output structured JSON matching our Pydantic schema
|
| 34 |
+
self.structured_llm = self.llm.with_structured_output(ExtractedEntities)
|
| 35 |
+
|
| 36 |
+
# Build prompt instructions
|
| 37 |
+
self.prompt = ChatPromptTemplate.from_messages([
|
| 38 |
+
("system", (
|
| 39 |
+
"You are an expert knowledge extraction agent. Your job is to read the provided text chunk "
|
| 40 |
+
"and extract all key entities.\n\n"
|
| 41 |
+
"Strict rules:\n"
|
| 42 |
+
"1. Entity Type: Each entity must belong to one of these types: PERSON, ORGANIZATION, PRODUCT, "
|
| 43 |
+
"TECHNOLOGY, LOCATION, EVENT, DATE, CONCEPT, DOCUMENT.\n"
|
| 44 |
+
"2. Name Canonicalization: Extract names in their canonical, capitalized form. Avoid pronouns ('he', 'she', 'it') "
|
| 45 |
+
"and generic descriptors ('the company', 'the engineer').\n"
|
| 46 |
+
"3. Grounding: Descriptions must be factual and derived strictly from the text provided."
|
| 47 |
+
)),
|
| 48 |
+
("human", "Extract all key entities from this text chunk:\n\n{text_content}")
|
| 49 |
+
])
|
| 50 |
+
|
| 51 |
+
# Chain prompt with structured model execution
|
| 52 |
+
self.chain = self.prompt | self.structured_llm
|
| 53 |
+
|
| 54 |
+
def extract_entities(self, text_content: str) -> List[dict]:
|
| 55 |
+
"""
|
| 56 |
+
Processes a string chunk of text and returns a list of serialized entity dicts.
|
| 57 |
+
"""
|
| 58 |
+
if not text_content or not text_content.strip():
|
| 59 |
+
logger.warning("Empty text chunk provided to extract_entities.")
|
| 60 |
+
return []
|
| 61 |
+
|
| 62 |
+
word_count = len(text_content.split())
|
| 63 |
+
logger.info("Running entity extraction on text chunk (Words: %d).", word_count)
|
| 64 |
+
|
| 65 |
+
try:
|
| 66 |
+
result: ExtractedEntities = self.chain.invoke({"text_content": text_content})
|
| 67 |
+
extracted = [entity.model_dump() for entity in result.entities]
|
| 68 |
+
logger.info("Successfully extracted %d entities from text chunk.", len(extracted))
|
| 69 |
+
return extracted
|
| 70 |
+
except Exception as e:
|
| 71 |
+
logger.error("Failed during entity extraction processing. Error: %s", str(e), exc_info=True)
|
| 72 |
+
# Return empty list on failure to let the rest of ingestion pipeline survive
|
| 73 |
+
return []
|
backend/graphrag/services/entity_resolver.py
CHANGED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from typing import List, Dict
|
| 3 |
+
from rapidfuzz import fuzz
|
| 4 |
+
|
| 5 |
+
logger = logging.getLogger(__name__)
|
| 6 |
+
|
| 7 |
+
class EntityResolver:
|
| 8 |
+
def __init__(self, similarity_threshold: float = 85.0):
|
| 9 |
+
self.similarity_threshold = similarity_threshold
|
| 10 |
+
logger.info("Initializing EntityResolver with similarity threshold: %.1f", self.similarity_threshold)
|
| 11 |
+
|
| 12 |
+
def resolve_entities(self, entities: List[dict], relationships: List[dict]) -> tuple[List[dict], List[dict]]:
|
| 13 |
+
"""
|
| 14 |
+
Deduplicates a list of entities and rewrites relationship references accordingly.
|
| 15 |
+
Returns a tuple of (resolved_entities, rewritten_relationships).
|
| 16 |
+
"""
|
| 17 |
+
if not entities:
|
| 18 |
+
return [], relationships
|
| 19 |
+
|
| 20 |
+
logger.info("Resolving duplicates for %d entities and %d relationships...", len(entities), len(relationships))
|
| 21 |
+
|
| 22 |
+
# 1. Group entities by their category/type
|
| 23 |
+
grouped_by_type: Dict[str, List[dict]] = {}
|
| 24 |
+
for entity in entities:
|
| 25 |
+
etype = entity['type'].upper()
|
| 26 |
+
grouped_by_type.setdefault(etype, []).append(entity)
|
| 27 |
+
|
| 28 |
+
resolved_entities = []
|
| 29 |
+
name_mappings = {} # Maps original_name -> canonical_name
|
| 30 |
+
|
| 31 |
+
for etype, group in grouped_by_type.items():
|
| 32 |
+
resolved_group = []
|
| 33 |
+
|
| 34 |
+
for current in group:
|
| 35 |
+
matched_canonical = None
|
| 36 |
+
|
| 37 |
+
# Check current entity against already resolved entities in the same type group
|
| 38 |
+
for existing in resolved_group:
|
| 39 |
+
# Run fuzzy comparison on names
|
| 40 |
+
ratio = fuzz.token_set_ratio(current['name'].lower(), existing['name'].lower())
|
| 41 |
+
if ratio >= self.similarity_threshold:
|
| 42 |
+
matched_canonical = existing
|
| 43 |
+
break
|
| 44 |
+
|
| 45 |
+
if matched_canonical:
|
| 46 |
+
# Duplicate found! Merge current into matched_canonical
|
| 47 |
+
old_name = current['name']
|
| 48 |
+
new_name = matched_canonical['name']
|
| 49 |
+
|
| 50 |
+
# Keep the longer name as the canonical one
|
| 51 |
+
if len(old_name) > len(new_name):
|
| 52 |
+
matched_canonical['name'] = old_name
|
| 53 |
+
name_mappings[new_name] = old_name
|
| 54 |
+
name_mappings[old_name] = old_name
|
| 55 |
+
else:
|
| 56 |
+
name_mappings[old_name] = new_name
|
| 57 |
+
|
| 58 |
+
# Combine descriptions, avoiding exact duplicates
|
| 59 |
+
if current['description'] and current['description'] not in matched_canonical['description']:
|
| 60 |
+
matched_canonical['description'] = f"{matched_canonical['description']} {current['description']}".strip()
|
| 61 |
+
|
| 62 |
+
logger.info("Resolved & Merged entity: '%s' ➔ '%s'", old_name, matched_canonical['name'])
|
| 63 |
+
else:
|
| 64 |
+
# Unique entity in this pass, add it to resolved list
|
| 65 |
+
resolved_group.append(current)
|
| 66 |
+
name_mappings[current['name']] = current['name']
|
| 67 |
+
|
| 68 |
+
resolved_entities.extend(resolved_group)
|
| 69 |
+
|
| 70 |
+
# 2. Rewrite relationships using the canonical name mappings
|
| 71 |
+
rewritten_relationships = []
|
| 72 |
+
for rel in relationships:
|
| 73 |
+
# Look up source and target names in mappings
|
| 74 |
+
src = rel['source_entity']
|
| 75 |
+
tgt = rel['target_entity']
|
| 76 |
+
|
| 77 |
+
canonical_src = name_mappings.get(src, src)
|
| 78 |
+
canonical_tgt = name_mappings.get(tgt, tgt)
|
| 79 |
+
|
| 80 |
+
# Prevent self-referencing relationships created by merges
|
| 81 |
+
if canonical_src == canonical_tgt:
|
| 82 |
+
logger.warning("Discarded self-referencing relationship: [%s] --[%s]--> [%s] after resolution merge.",
|
| 83 |
+
src, rel['relationship_type'], tgt)
|
| 84 |
+
continue
|
| 85 |
+
|
| 86 |
+
rel['source_entity'] = canonical_src
|
| 87 |
+
rel['target_entity'] = canonical_tgt
|
| 88 |
+
rewritten_relationships.append(rel)
|
| 89 |
+
|
| 90 |
+
logger.info("Deduplication complete. Resolved entities count: %d (from %d) | Relationships count: %d",
|
| 91 |
+
len(resolved_entities), len(entities), len(rewritten_relationships))
|
| 92 |
+
|
| 93 |
+
return resolved_entities, rewritten_relationships
|
backend/graphrag/services/graph_builder.py
CHANGED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import logging
|
| 3 |
+
from typing import List
|
| 4 |
+
from ..models import Document
|
| 5 |
+
from .neo4j_client import Neo4jClient
|
| 6 |
+
from .entity_extractor import EntityExtractor
|
| 7 |
+
from .relationship_extractor import RelationshipExtractor
|
| 8 |
+
from .entity_resolver import EntityResolver
|
| 9 |
+
from .vector_retriever import VectorRetriever
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class GraphBuilder:
|
| 16 |
+
def __init__(self):
|
| 17 |
+
logger.info("Initializing GraphBuilder orchestrator service.")
|
| 18 |
+
self.neo4j_client = Neo4jClient()
|
| 19 |
+
self.entity_extractor = EntityExtractor()
|
| 20 |
+
self.relationship_extractor = RelationshipExtractor()
|
| 21 |
+
self.entity_resolver = EntityResolver()
|
| 22 |
+
self.vector_retriever = VectorRetriever()
|
| 23 |
+
|
| 24 |
+
def process_document(self, document_id, user_id):
|
| 25 |
+
"""
|
| 26 |
+
Orchestrates the entire GraphRAG ingestion pipeline.
|
| 27 |
+
Reads file, extracts entities & relationships, resolves duplicates, and writes to Neo4j.
|
| 28 |
+
"""
|
| 29 |
+
try:
|
| 30 |
+
doc = Document.objects.get(id=document_id)
|
| 31 |
+
except Document.DoesNotExist:
|
| 32 |
+
logger.error("Document with ID %s does not exist. Ingestion aborted.", document_id)
|
| 33 |
+
return
|
| 34 |
+
|
| 35 |
+
logger.info("Beginning background graph building for Document: %s (User ID: %s)", doc.name, user_id)
|
| 36 |
+
|
| 37 |
+
# 1. Update status to PROCESSING
|
| 38 |
+
doc.status = Document.Status.PROCESSING
|
| 39 |
+
doc.save()
|
| 40 |
+
|
| 41 |
+
try:
|
| 42 |
+
filepath = doc.file.path
|
| 43 |
+
if not os.path.exists(filepath):
|
| 44 |
+
raise FileNotFoundError(f"File not found on disk: {filepath}")
|
| 45 |
+
|
| 46 |
+
# 2. Parse file into sections/pages
|
| 47 |
+
sections = self._parse_file_to_sections(filepath)
|
| 48 |
+
logger.info("Parsed document into %d sections for analysis.", len(sections))
|
| 49 |
+
|
| 50 |
+
# 2b. Index document text in ChromaDB vector store
|
| 51 |
+
full_text = "\n\n".join([sec["text"] for sec in sections])
|
| 52 |
+
logger.info("Indexing document text in ChromaDB (Doc: %s, User: %s)...", doc.name, user_id)
|
| 53 |
+
self.vector_retriever.index_document(
|
| 54 |
+
text_content=full_text,
|
| 55 |
+
doc_name=doc.name,
|
| 56 |
+
user_id=user_id
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
all_entities = []
|
| 60 |
+
all_relationships = []
|
| 61 |
+
|
| 62 |
+
# 3. Perform Entity and Relationship Extraction per section
|
| 63 |
+
for sec in sections:
|
| 64 |
+
text = sec["text"]
|
| 65 |
+
page = sec["page"]
|
| 66 |
+
|
| 67 |
+
# Extract entities from this section
|
| 68 |
+
ents = self.entity_extractor.extract_entities(text)
|
| 69 |
+
for e in ents:
|
| 70 |
+
e["page"] = page
|
| 71 |
+
e["source_doc"] = doc.name
|
| 72 |
+
all_entities.extend(ents)
|
| 73 |
+
|
| 74 |
+
# Extract relationships from this section
|
| 75 |
+
rels = self.relationship_extractor.extract_relationships(text)
|
| 76 |
+
for r in rels:
|
| 77 |
+
r["page"] = page
|
| 78 |
+
r["source_doc"] = doc.name
|
| 79 |
+
all_relationships.extend(rels)
|
| 80 |
+
|
| 81 |
+
# 4. Run entity resolution (deduplicate entities and rewrite relationships)
|
| 82 |
+
resolved_ents, rewritten_rels = self.entity_resolver.resolve_entities(
|
| 83 |
+
all_entities, all_relationships
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
# 5. Store resolved nodes inside Neo4j
|
| 87 |
+
logger.info("Writing %d resolved entities to Neo4j...", len(resolved_ents))
|
| 88 |
+
for ent in resolved_ents:
|
| 89 |
+
self.neo4j_client.create_entity_node(
|
| 90 |
+
name=ent["name"],
|
| 91 |
+
entity_type=ent["type"],
|
| 92 |
+
description=ent["description"],
|
| 93 |
+
user_id=user_id,
|
| 94 |
+
source_doc=ent["source_doc"],
|
| 95 |
+
page=ent["page"]
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
# 6. Store rewritten edges inside Neo4j
|
| 99 |
+
logger.info("Writing %d rewritten relationships to Neo4j...", len(rewritten_rels))
|
| 100 |
+
for rel in rewritten_rels:
|
| 101 |
+
self.neo4j_client.create_relationship_edge(
|
| 102 |
+
source_name=rel["source_entity"],
|
| 103 |
+
target_name=rel["target_entity"],
|
| 104 |
+
rel_type=rel["relationship_type"],
|
| 105 |
+
description=rel["description"],
|
| 106 |
+
confidence=rel["confidence"],
|
| 107 |
+
user_id=user_id,
|
| 108 |
+
source_doc=rel["source_doc"],
|
| 109 |
+
page=rel["page"]
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
# 7. Update status to COMPLETED and record counts
|
| 113 |
+
doc.entity_count = len(resolved_ents)
|
| 114 |
+
doc.relationship_count = len(rewritten_rels)
|
| 115 |
+
doc.status = Document.Status.COMPLETED
|
| 116 |
+
doc.error_message = None
|
| 117 |
+
doc.save()
|
| 118 |
+
logger.info("Successfully finished building knowledge graph for Document: %s", doc.name)
|
| 119 |
+
|
| 120 |
+
except Exception as e:
|
| 121 |
+
logger.error("Failed to process document: %s. Error: %s", doc.name, str(e), exc_info=True)
|
| 122 |
+
doc.status = Document.Status.FAILED
|
| 123 |
+
doc.error_message = str(e)
|
| 124 |
+
doc.save()
|
| 125 |
+
|
| 126 |
+
def delete_document_data(self, document_id, user_id):
|
| 127 |
+
"""
|
| 128 |
+
Cleans up and deletes associated Neo4j node/edge elements for a deleted document.
|
| 129 |
+
"""
|
| 130 |
+
try:
|
| 131 |
+
doc = Document.objects.get(id=document_id)
|
| 132 |
+
logger.info("Triggering graph wipe for Document: %s (User ID: %s)", doc.name, user_id)
|
| 133 |
+
|
| 134 |
+
# Wipe Neo4j Graph elements
|
| 135 |
+
self.neo4j_client.delete_document_nodes(doc.name, user_id)
|
| 136 |
+
|
| 137 |
+
# Wipe ChromaDB Vector elements
|
| 138 |
+
self.vector_retriever.delete_document_vectors(doc.name, user_id)
|
| 139 |
+
|
| 140 |
+
logger.info("Finished Graph cleanup for Document: %s", doc.name)
|
| 141 |
+
except Document.DoesNotExist:
|
| 142 |
+
logger.error("Document with ID %s does not exist. Cleanup aborted.", document_id)
|
| 143 |
+
except Exception as e:
|
| 144 |
+
logger.error("Failed to clean up graph data for Document ID: %s. Error: %s",
|
| 145 |
+
document_id, str(e), exc_info=True)
|
| 146 |
+
|
| 147 |
+
def _parse_file_to_sections(self, filepath: str) -> List[dict]:
|
| 148 |
+
"""
|
| 149 |
+
Loads document file and splits content into page/paragraph sections.
|
| 150 |
+
"""
|
| 151 |
+
ext = filepath.split(".")[-1].lower()
|
| 152 |
+
sections = []
|
| 153 |
+
|
| 154 |
+
if ext == "pdf":
|
| 155 |
+
import pypdf
|
| 156 |
+
reader = pypdf.PdfReader(filepath)
|
| 157 |
+
for idx, page in enumerate(reader.pages):
|
| 158 |
+
text = page.extract_text()
|
| 159 |
+
if text and text.strip():
|
| 160 |
+
sections.append({
|
| 161 |
+
"text": text.strip(),
|
| 162 |
+
"page": idx + 1
|
| 163 |
+
})
|
| 164 |
+
elif ext in ["docx", "doc"]:
|
| 165 |
+
import docx
|
| 166 |
+
doc = docx.Document(filepath)
|
| 167 |
+
current_chunk = []
|
| 168 |
+
section_idx = 1
|
| 169 |
+
for p in doc.paragraphs:
|
| 170 |
+
if p.text and p.text.strip():
|
| 171 |
+
current_chunk.append(p.text.strip())
|
| 172 |
+
# Group every 3 paragraphs to ensure sufficient context is captured
|
| 173 |
+
if len(current_chunk) >= 3:
|
| 174 |
+
sections.append({
|
| 175 |
+
"text": "\n".join(current_chunk),
|
| 176 |
+
"page": section_idx
|
| 177 |
+
})
|
| 178 |
+
current_chunk = []
|
| 179 |
+
section_idx += 1
|
| 180 |
+
if current_chunk:
|
| 181 |
+
sections.append({
|
| 182 |
+
"text": "\n".join(current_chunk),
|
| 183 |
+
"page": section_idx
|
| 184 |
+
})
|
| 185 |
+
else:
|
| 186 |
+
# Default fallback for TXT, Markdown, etc.
|
| 187 |
+
with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
|
| 188 |
+
content = f.read()
|
| 189 |
+
# Split by double newlines
|
| 190 |
+
paragraphs = [p.strip() for p in content.split("\n\n") if p.strip()]
|
| 191 |
+
for idx, p in enumerate(paragraphs):
|
| 192 |
+
sections.append({
|
| 193 |
+
"text": p,
|
| 194 |
+
"page": idx + 1
|
| 195 |
+
})
|
| 196 |
+
|
| 197 |
+
return sections
|
backend/graphrag/services/graph_retriever.py
CHANGED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from typing import List, Dict, Set
|
| 3 |
+
from pydantic import BaseModel, Field
|
| 4 |
+
from langchain_core.prompts import ChatPromptTemplate
|
| 5 |
+
from .llm_client import get_llm
|
| 6 |
+
from .neo4j_client import Neo4jClient
|
| 7 |
+
|
| 8 |
+
logger = logging.getLogger(__name__)
|
| 9 |
+
|
| 10 |
+
class QueryEntities(BaseModel):
|
| 11 |
+
"""
|
| 12 |
+
List of key entities extracted from the search query.
|
| 13 |
+
"""
|
| 14 |
+
entities: List[str] = Field(
|
| 15 |
+
description="Key proper nouns, entities, products, technologies, or concepts extracted from the search query."
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
class GraphRetriever:
|
| 19 |
+
def __init__(self):
|
| 20 |
+
logger.info("Initializing GraphRetriever service.")
|
| 21 |
+
self.neo4j_client = Neo4jClient()
|
| 22 |
+
self.llm = get_llm(temperature=0.0)
|
| 23 |
+
self.structured_llm = self.llm.with_structured_output(QueryEntities)
|
| 24 |
+
|
| 25 |
+
# Prompt instruction to isolate entity names
|
| 26 |
+
self.prompt = ChatPromptTemplate.from_messages([
|
| 27 |
+
("system", (
|
| 28 |
+
"You are an NLP entity extraction assistant. Your job is to extract a list of "
|
| 29 |
+
"key entities (e.g., people, organizations, technologies, products, locations, concepts) "
|
| 30 |
+
"specifically mentioned in the user's search query.\n\n"
|
| 31 |
+
"Extract ONLY nouns and main topics that can be looked up in a database. Do not include verbs or questions."
|
| 32 |
+
)),
|
| 33 |
+
("human", "Extract the key entities from this query:\n\n{query}")
|
| 34 |
+
])
|
| 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)
|
| 47 |
+
if not query_entities:
|
| 48 |
+
logger.info("No entities extracted from user query. Returning empty graph context.")
|
| 49 |
+
return ""
|
| 50 |
+
|
| 51 |
+
logger.info("Extracted query entities: %s", query_entities)
|
| 52 |
+
|
| 53 |
+
unique_nodes: Dict[str, dict] = {}
|
| 54 |
+
unique_rels: Set[str] = set()
|
| 55 |
+
|
| 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))
|
| 63 |
+
|
| 64 |
+
# 3. Serialize extracted graph information into a readable markdown string
|
| 65 |
+
if not unique_nodes:
|
| 66 |
+
logger.info("No matching entities or paths found in the graph for query.")
|
| 67 |
+
return ""
|
| 68 |
+
|
| 69 |
+
context_lines = ["### STRUCTURED KNOWLEDGE GRAPH CONTEXT\n"]
|
| 70 |
+
|
| 71 |
+
context_lines.append("#### Entities:")
|
| 72 |
+
for name, info in unique_nodes.items():
|
| 73 |
+
context_lines.append(f"* **{name}** ({info.get('type', 'Unknown')}): {info.get('description', '')}")
|
| 74 |
+
|
| 75 |
+
if unique_rels:
|
| 76 |
+
context_lines.append("\n#### Relationships:")
|
| 77 |
+
for rel in sorted(unique_rels):
|
| 78 |
+
context_lines.append(f"* {rel}")
|
| 79 |
+
|
| 80 |
+
serialized_context = "\n".join(context_lines)
|
| 81 |
+
logger.info("Generated graph context (%d characters).", len(serialized_context))
|
| 82 |
+
return serialized_context
|
| 83 |
+
|
| 84 |
+
def _extract_entities_from_query(self, query: str) -> List[str]:
|
| 85 |
+
"""
|
| 86 |
+
Uses the LLM structured call to parse entity search terms.
|
| 87 |
+
"""
|
| 88 |
+
try:
|
| 89 |
+
result: QueryEntities = self.chain.invoke({"query": query})
|
| 90 |
+
return [name.strip() for name in result.entities if name.strip()]
|
| 91 |
+
except Exception as e:
|
| 92 |
+
logger.error("Failed to extract entities from query. Error: %s", str(e), exc_info=True)
|
| 93 |
+
return []
|
| 94 |
+
|
| 95 |
+
def _parse_subgraph_paths(self, paths: List[dict], unique_nodes: Dict[str, dict], unique_rels: Set[str]):
|
| 96 |
+
"""
|
| 97 |
+
Helper method to iterate through Neo4j path dictionaries and extract node & edge properties.
|
| 98 |
+
"""
|
| 99 |
+
for record in paths:
|
| 100 |
+
path_obj = record.get("path")
|
| 101 |
+
if not path_obj:
|
| 102 |
+
continue
|
| 103 |
+
|
| 104 |
+
# In the neo4j python driver, a path contains nodes and relationships
|
| 105 |
+
nodes = path_obj.nodes
|
| 106 |
+
relationships = path_obj.relationships
|
| 107 |
+
|
| 108 |
+
# 1. Parse all nodes in this path segment
|
| 109 |
+
for node in nodes:
|
| 110 |
+
properties = dict(node)
|
| 111 |
+
name = properties.get("name")
|
| 112 |
+
if name:
|
| 113 |
+
# Store unique node info
|
| 114 |
+
unique_nodes[name] = {
|
| 115 |
+
"type": properties.get("type", "Unknown"),
|
| 116 |
+
"description": properties.get("description", "")
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
# 2. Parse all relationship edges in this path segment
|
| 120 |
+
for rel in relationships:
|
| 121 |
+
# Get connected nodes from the path
|
| 122 |
+
start_node = nodes[rel.start_node.id if hasattr(rel.start_node, 'id') else 0]
|
| 123 |
+
end_node = nodes[rel.end_node.id if hasattr(rel.end_node, 'id') else 0]
|
| 124 |
+
|
| 125 |
+
start_name = dict(start_node).get("name", "Unknown")
|
| 126 |
+
end_name = dict(end_node).get("name", "Unknown")
|
| 127 |
+
|
| 128 |
+
rel_type = rel.type
|
| 129 |
+
properties = dict(rel)
|
| 130 |
+
desc = properties.get("description", "")
|
| 131 |
+
conf = properties.get("confidence", 1.0)
|
| 132 |
+
|
| 133 |
+
# Format edge output description
|
| 134 |
+
desc_suffix = f" (Details: {desc})" if desc else ""
|
| 135 |
+
rel_str = f"[{dict(start_node).get('type', 'Entity')}] **{start_name}** --[{rel_type} (Confidence: {conf})]--> [{dict(end_node).get('type', 'Entity')}] **{end_name}**{desc_suffix}"
|
| 136 |
+
unique_rels.add(rel_str)
|
backend/graphrag/services/hybrid_retriever.py
CHANGED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from typing import List, Dict
|
| 3 |
+
from .graph_retriever import GraphRetriever
|
| 4 |
+
from .vector_retriever import VectorRetriever
|
| 5 |
+
|
| 6 |
+
logger = logging.getLogger(__name__)
|
| 7 |
+
|
| 8 |
+
class HybridRetriever:
|
| 9 |
+
def __init__(self):
|
| 10 |
+
logger.info("Initializing HybridRetriever service.")
|
| 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 |
+
|
| 35 |
+
# 3. Format Vector Text Chunks
|
| 36 |
+
vector_context_lines = []
|
| 37 |
+
if vector_chunks:
|
| 38 |
+
vector_context_lines.append("### UNSTRUCTURED TEXT PASSAGES\n")
|
| 39 |
+
for idx, chunk in enumerate(vector_chunks):
|
| 40 |
+
vector_context_lines.append(
|
| 41 |
+
f"Document: {chunk['source_doc']} (Page: {chunk['page']}, Similarity: {chunk['similarity_score']}):\n"
|
| 42 |
+
f"\"{chunk['text'].strip()}\"\n"
|
| 43 |
+
)
|
| 44 |
+
vector_context = "\n".join(vector_context_lines)
|
| 45 |
+
|
| 46 |
+
# 4. Auto-detect Strategy Route based on context availability
|
| 47 |
+
strategy = "HYBRID"
|
| 48 |
+
if graph_context and not vector_context:
|
| 49 |
+
strategy = "GRAPH_ONLY"
|
| 50 |
+
combined_context = graph_context
|
| 51 |
+
elif vector_context and not graph_context:
|
| 52 |
+
strategy = "VECTOR_ONLY"
|
| 53 |
+
combined_context = vector_context
|
| 54 |
+
elif not graph_context and not vector_context:
|
| 55 |
+
strategy = "VECTOR_ONLY" # Fallback
|
| 56 |
+
combined_context = "No relevant context found in either the Graph or Vector databases."
|
| 57 |
+
else:
|
| 58 |
+
# Both exist: default hybrid blend
|
| 59 |
+
strategy = "HYBRID"
|
| 60 |
+
combined_context = (
|
| 61 |
+
f"{graph_context}\n\n"
|
| 62 |
+
f"{vector_context}"
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
logger.info("Selected retrieval strategy: %s for query: '%s'", strategy, query)
|
| 66 |
+
|
| 67 |
+
return {
|
| 68 |
+
"combined_context": combined_context,
|
| 69 |
+
"graph_context": graph_context,
|
| 70 |
+
"vector_context": vector_context,
|
| 71 |
+
"vector_chunks": vector_chunks,
|
| 72 |
+
"strategy": strategy
|
| 73 |
+
}
|
backend/graphrag/services/llm_client.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import logging
|
| 3 |
+
from langchain_groq import ChatGroq
|
| 4 |
+
from langchain_google_genai import ChatGoogleGenerativeAI
|
| 5 |
+
|
| 6 |
+
logger = logging.getLogger(__name__)
|
| 7 |
+
|
| 8 |
+
def get_llm(temperature: float = 0.0):
|
| 9 |
+
"""
|
| 10 |
+
Returns a configured LangChain LLM instance.
|
| 11 |
+
Prioritizes Groq (Llama 3) and falls back to Gemini if GROQ_API_KEY is missing.
|
| 12 |
+
"""
|
| 13 |
+
groq_api_key = os.getenv("GROQ_API_KEY", "")
|
| 14 |
+
google_api_key = os.getenv("GOOGLE_API_KEY", "")
|
| 15 |
+
|
| 16 |
+
if groq_api_key:
|
| 17 |
+
model_name = os.getenv("GROQ_MODEL", "llama-3.3-70b-versatile")
|
| 18 |
+
logger.info("Initializing Groq Chat Model (%s)", model_name)
|
| 19 |
+
return ChatGroq(
|
| 20 |
+
model=model_name,
|
| 21 |
+
api_key=groq_api_key,
|
| 22 |
+
temperature=temperature
|
| 23 |
+
)
|
| 24 |
+
elif google_api_key:
|
| 25 |
+
model_name = os.getenv("GOOGLE_MODEL", "gemini-1.5-flash")
|
| 26 |
+
logger.info("Initializing Google Gemini Chat Model (%s)", model_name)
|
| 27 |
+
return ChatGoogleGenerativeAI(
|
| 28 |
+
model=model_name,
|
| 29 |
+
api_key=google_api_key,
|
| 30 |
+
temperature=temperature
|
| 31 |
+
)
|
| 32 |
+
else:
|
| 33 |
+
logger.error("No API keys found for either GROQ_API_KEY or GOOGLE_API_KEY.")
|
| 34 |
+
raise ValueError("Missing LLM API keys. Please configure GROQ_API_KEY or GOOGLE_API_KEY in your .env file.")
|
backend/graphrag/services/multihop_reasoner.py
CHANGED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from typing import List, Dict, Any
|
| 3 |
+
from langchain_core.prompts import ChatPromptTemplate
|
| 4 |
+
from .llm_client import get_llm
|
| 5 |
+
from .neo4j_client import Neo4jClient
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger(__name__)
|
| 8 |
+
|
| 9 |
+
class MultiHopReasoner:
|
| 10 |
+
def __init__(self):
|
| 11 |
+
logger.info("Initializing MultiHopReasoner service.")
|
| 12 |
+
self.neo4j_client = Neo4jClient()
|
| 13 |
+
self.llm = get_llm(temperature=0.0)
|
| 14 |
+
|
| 15 |
+
# Prompt instruction to summarize path connections
|
| 16 |
+
self.prompt = ChatPromptTemplate.from_messages([
|
| 17 |
+
("system", (
|
| 18 |
+
"You are an AI analyst specialized in explaining graph connections.\n"
|
| 19 |
+
"You will be given a path of nodes and relationships from a knowledge graph showing how two entities are connected.\n"
|
| 20 |
+
"Your job is to summarize this connection path in a clear, natural paragraph.\n\n"
|
| 21 |
+
"Format rules:\n"
|
| 22 |
+
"- Clearly mention each step of the connection.\n"
|
| 23 |
+
"- Keep the explanation factual based *only* on the provided path info."
|
| 24 |
+
)),
|
| 25 |
+
("human", (
|
| 26 |
+
"Explain the connection between '{entity_a}' and '{entity_b}' based on this graph path:\n\n"
|
| 27 |
+
"{path_details}"
|
| 28 |
+
))
|
| 29 |
+
])
|
| 30 |
+
|
| 31 |
+
self.chain = self.prompt | self.llm
|
| 32 |
+
|
| 33 |
+
def explain_connection(self, entity_a: str, entity_b: str, user_id: str) -> Dict[str, Any]:
|
| 34 |
+
"""
|
| 35 |
+
Finds the shortest path between two entities in Neo4j and uses the LLM to explain the connection.
|
| 36 |
+
"""
|
| 37 |
+
logger.info("Finding connection between '%s' and '%s' (User: %s)", entity_a, entity_b, user_id)
|
| 38 |
+
|
| 39 |
+
# 1. Fetch shortest path from Neo4j
|
| 40 |
+
cypher = (
|
| 41 |
+
"MATCH p = shortestPath("
|
| 42 |
+
" (a:Entity {name: $entity_a, user_id: $user_id})-[*..4]-(b:Entity {name: $entity_b, user_id: $user_id})"
|
| 43 |
+
") "
|
| 44 |
+
"RETURN p"
|
| 45 |
+
)
|
| 46 |
+
params = {
|
| 47 |
+
"entity_a": entity_a,
|
| 48 |
+
"entity_b": entity_b,
|
| 49 |
+
"user_id": str(user_id)
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
try:
|
| 53 |
+
# New code
|
| 54 |
+
records = self.neo4j_client.execute_query(cypher, params)
|
| 55 |
+
if not records or not records[0].get("p"):
|
| 56 |
+
logger.info("No connection path found between '%s' and '%s'.", entity_a, entity_b)
|
| 57 |
+
return {
|
| 58 |
+
"found": False,
|
| 59 |
+
"explanation": f"No indirect connection (up to 4 hops) was found between '{entity_a}' and '{entity_b}' in the knowledge graph.",
|
| 60 |
+
"path": []
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
path_obj = records[0]["p"]
|
| 64 |
+
nodes = path_obj.nodes
|
| 65 |
+
relationships = path_obj.relationships
|
| 66 |
+
|
| 67 |
+
# 2. Extract path details for prompt serialization
|
| 68 |
+
path_steps = []
|
| 69 |
+
serialized_path = []
|
| 70 |
+
|
| 71 |
+
for i in range(len(relationships)):
|
| 72 |
+
node_now = nodes[i]
|
| 73 |
+
node_next = nodes[i + 1]
|
| 74 |
+
rel = relationships[i]
|
| 75 |
+
|
| 76 |
+
now_name = dict(node_now).get("name", "Unknown")
|
| 77 |
+
next_name = dict(node_next).get("name", "Unknown")
|
| 78 |
+
|
| 79 |
+
now_type = dict(node_now).get("type", "Entity")
|
| 80 |
+
next_type = dict(node_next).get("type", "Entity")
|
| 81 |
+
|
| 82 |
+
rel_type = rel.type
|
| 83 |
+
|
| 84 |
+
step_str = f"({now_name} [{now_type}]) --[{rel_type}]--> ({next_name} [{next_type}])"
|
| 85 |
+
path_steps.append(step_str)
|
| 86 |
+
|
| 87 |
+
# Keep tracking representation for the response payload
|
| 88 |
+
serialized_path.append({
|
| 89 |
+
"source": now_name,
|
| 90 |
+
"source_type": now_type,
|
| 91 |
+
"target": next_name,
|
| 92 |
+
"target_type": next_type,
|
| 93 |
+
"type": rel_type
|
| 94 |
+
})
|
| 95 |
+
|
| 96 |
+
path_details = "\n".join(path_steps)
|
| 97 |
+
logger.info("Found path with %d hops: %s", len(relationships), path_details)
|
| 98 |
+
|
| 99 |
+
# 3. Ask LLM to summarize/explain this path
|
| 100 |
+
response = self.chain.invoke({
|
| 101 |
+
"entity_a": entity_a,
|
| 102 |
+
"entity_b": entity_b,
|
| 103 |
+
"path_details": path_details
|
| 104 |
+
})
|
| 105 |
+
|
| 106 |
+
explanation = response.content.strip()
|
| 107 |
+
|
| 108 |
+
return {
|
| 109 |
+
"found": True,
|
| 110 |
+
"explanation": explanation,
|
| 111 |
+
"path": serialized_path
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
except Exception as e:
|
| 115 |
+
logger.error("Failed to execute path reasoning query: %s", str(e), exc_info=True)
|
| 116 |
+
return {
|
| 117 |
+
"found": False,
|
| 118 |
+
"explanation": f"An error occurred while analyzing the connection: {str(e)}",
|
| 119 |
+
"path": []
|
| 120 |
+
}
|
backend/graphrag/services/neo4j_client.py
CHANGED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from django.conf import settings
|
| 3 |
+
from neo4j import GraphDatabase
|
| 4 |
+
|
| 5 |
+
logger = logging.getLogger(__name__)
|
| 6 |
+
|
| 7 |
+
class Neo4jClient:
|
| 8 |
+
_instance = None
|
| 9 |
+
|
| 10 |
+
def __new__(cls, *args, **kwargs):
|
| 11 |
+
# Singleton pattern to reuse the driver connection pool across Django requests
|
| 12 |
+
if not cls._instance:
|
| 13 |
+
cls._instance = super().__new__(cls, *args, **kwargs)
|
| 14 |
+
return cls._instance
|
| 15 |
+
|
| 16 |
+
def __init__(self):
|
| 17 |
+
# Prevent re-initializing the driver if it's already connected
|
| 18 |
+
if hasattr(self, 'driver'):
|
| 19 |
+
return
|
| 20 |
+
|
| 21 |
+
self.uri = getattr(settings, 'NEO4J_URI', 'bolt://localhost:7687')
|
| 22 |
+
self.user = getattr(settings, 'NEO4J_USER', 'neo4j')
|
| 23 |
+
self.password = getattr(settings, 'NEO4J_PASSWORD', 'password')
|
| 24 |
+
|
| 25 |
+
logger.info("Initializing Neo4j database driver connecting to: %s", self.uri)
|
| 26 |
+
try:
|
| 27 |
+
self.driver = GraphDatabase.driver(self.uri, auth=(self.user, self.password))
|
| 28 |
+
self.verify_constraints()
|
| 29 |
+
logger.info("Successfully established connection to Neo4j and verified schema constraints.")
|
| 30 |
+
except Exception as e:
|
| 31 |
+
logger.error("Failed to connect to Neo4j database. Error: %s", str(e), exc_info=True)
|
| 32 |
+
raise e
|
| 33 |
+
|
| 34 |
+
def close(self):
|
| 35 |
+
if hasattr(self, 'driver'):
|
| 36 |
+
logger.info("Closing Neo4j driver connection pool.")
|
| 37 |
+
self.driver.close()
|
| 38 |
+
|
| 39 |
+
def verify_constraints(self):
|
| 40 |
+
"""
|
| 41 |
+
Set up unique constraints and indexes to prevent duplicates and speed up lookup.
|
| 42 |
+
"""
|
| 43 |
+
# Unique name constraint for entities
|
| 44 |
+
constraint_query = (
|
| 45 |
+
"CREATE CONSTRAINT unique_entity_name IF NOT EXISTS "
|
| 46 |
+
"FOR (e:Entity) REQUIRE (e.name, e.user_id) IS UNIQUE"
|
| 47 |
+
)
|
| 48 |
+
# Fast indexing on entity type
|
| 49 |
+
index_query = (
|
| 50 |
+
"CREATE INDEX entity_type_idx IF NOT EXISTS "
|
| 51 |
+
"FOR (e:Entity) ON (e.type)"
|
| 52 |
+
)
|
| 53 |
+
try:
|
| 54 |
+
with self.driver.session() as session:
|
| 55 |
+
session.run(constraint_query)
|
| 56 |
+
session.run(index_query)
|
| 57 |
+
except Exception as e:
|
| 58 |
+
logger.warning("Could not create Neo4j constraints/indexes: %s", str(e))
|
| 59 |
+
|
| 60 |
+
def execute_query(self, query, parameters=None):
|
| 61 |
+
"""
|
| 62 |
+
Execute raw Cypher query safely. Used for debugging or custom retrievals.
|
| 63 |
+
"""
|
| 64 |
+
parameters = parameters or {}
|
| 65 |
+
logger.debug("Executing Cypher query: %s | Params: %s", query, parameters)
|
| 66 |
+
try:
|
| 67 |
+
with self.driver.session() as session:
|
| 68 |
+
result = session.run(query, parameters)
|
| 69 |
+
return [record.data() for record in result]
|
| 70 |
+
except Exception as e:
|
| 71 |
+
logger.error("Error executing Cypher query. Error: %s", str(e), exc_info=True)
|
| 72 |
+
raise e
|
| 73 |
+
|
| 74 |
+
def create_entity_node(self, name, entity_type, description, user_id, source_doc=None, page=None):
|
| 75 |
+
"""
|
| 76 |
+
Create or update (MERGE) an Entity node, isolating by user_id.
|
| 77 |
+
"""
|
| 78 |
+
query = (
|
| 79 |
+
"MERGE (e:Entity {name: $name, user_id: $user_id}) "
|
| 80 |
+
"ON CREATE SET e.type = $type, e.description = $description, "
|
| 81 |
+
" e.source_doc = $source_doc, e.page = $page, e.created_at = timestamp() "
|
| 82 |
+
"ON MATCH SET e.description = coalesce(e.description, $description) "
|
| 83 |
+
"RETURN e"
|
| 84 |
+
)
|
| 85 |
+
params = {
|
| 86 |
+
"name": name.strip(),
|
| 87 |
+
"type": entity_type.strip(),
|
| 88 |
+
"description": description.strip(),
|
| 89 |
+
"user_id": str(user_id),
|
| 90 |
+
"source_doc": source_doc,
|
| 91 |
+
"page": page
|
| 92 |
+
}
|
| 93 |
+
self.execute_query(query, params)
|
| 94 |
+
|
| 95 |
+
def create_relationship_edge(self, source_name, target_name, rel_type, description, confidence, user_id, source_doc=None, page=None):
|
| 96 |
+
"""
|
| 97 |
+
Create a directed relationship edge between two existing Entity nodes.
|
| 98 |
+
Note: Cypher does not support parameterizing relationship types directly,
|
| 99 |
+
so we safely format the relationship type string (which is sanitised).
|
| 100 |
+
"""
|
| 101 |
+
clean_rel_type = "".join(c for c in rel_type.upper() if c.isalnum() or c == "_")
|
| 102 |
+
|
| 103 |
+
query = (
|
| 104 |
+
f"MATCH (source:Entity {{name: $source_name, user_id: $user_id}}) "
|
| 105 |
+
f"MATCH (target:Entity {{name: $target_name, user_id: $user_id}}) "
|
| 106 |
+
f"MERGE (source)-[r:{clean_rel_type}]->(target) "
|
| 107 |
+
f"ON CREATE SET r.description = $description, r.confidence = $confidence, "
|
| 108 |
+
f" r.source_doc = $source_doc, r.page = $page, r.created_at = timestamp() "
|
| 109 |
+
f"RETURN r"
|
| 110 |
+
)
|
| 111 |
+
params = {
|
| 112 |
+
"source_name": source_name.strip(),
|
| 113 |
+
"target_name": target_name.strip(),
|
| 114 |
+
"description": description.strip(),
|
| 115 |
+
"confidence": float(confidence),
|
| 116 |
+
"user_id": str(user_id),
|
| 117 |
+
"source_doc": source_doc,
|
| 118 |
+
"page": page
|
| 119 |
+
}
|
| 120 |
+
self.execute_query(query, params)
|
| 121 |
+
|
| 122 |
+
def get_entity_subgraph(self, name, user_id, hops=2):
|
| 123 |
+
"""
|
| 124 |
+
Retrieve all connected entities and relationships up to N hops.
|
| 125 |
+
"""
|
| 126 |
+
query = (
|
| 127 |
+
f"MATCH path = (e:Entity {{name: $name, user_id: $user_id}})-[*1..{hops}]-(neighbor:Entity {{user_id: $user_id}}) "
|
| 128 |
+
f"RETURN path LIMIT 50"
|
| 129 |
+
)
|
| 130 |
+
params = {"name": name, "user_id": str(user_id)}
|
| 131 |
+
return self.execute_query(query, params)
|
| 132 |
+
|
| 133 |
+
def find_shortest_path(self, start_name, end_name, user_id, max_hops=5):
|
| 134 |
+
"""
|
| 135 |
+
Runs BFS pathfinding to find connection sequences between concepts.
|
| 136 |
+
"""
|
| 137 |
+
query = (
|
| 138 |
+
f"MATCH (start:Entity {{name: $start_name, user_id: $user_id}}), "
|
| 139 |
+
f" (end:Entity {{name: $end_name, user_id: $user_id}}) "
|
| 140 |
+
f"MATCH path = shortestPath((start)-[*..{max_hops}]-(end)) "
|
| 141 |
+
f"RETURN path"
|
| 142 |
+
)
|
| 143 |
+
params = {
|
| 144 |
+
"start_name": start_name,
|
| 145 |
+
"end_name": end_name,
|
| 146 |
+
"user_id": str(user_id)
|
| 147 |
+
}
|
| 148 |
+
return self.execute_query(query, params)
|
| 149 |
+
|
| 150 |
+
def get_graph_statistics(self, user_id):
|
| 151 |
+
"""
|
| 152 |
+
Fetch graph summaries for the dashboard.
|
| 153 |
+
"""
|
| 154 |
+
nodes_count_query = "MATCH (e:Entity {user_id: $user_id}) RETURN count(e) as count"
|
| 155 |
+
edges_count_query = "MATCH (:Entity {user_id: $user_id})-[r]->(:Entity {user_id: $user_id}) RETURN count(r) as count"
|
| 156 |
+
type_dist_query = (
|
| 157 |
+
"MATCH (e:Entity {user_id: $user_id}) "
|
| 158 |
+
"RETURN e.type as type, count(e) as count ORDER BY count DESC"
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
try:
|
| 162 |
+
nodes_count = self.execute_query(nodes_count_query, {"user_id": str(user_id)})[0]['count']
|
| 163 |
+
edges_count = self.execute_query(edges_count_query, {"user_id": str(user_id)})[0]['count']
|
| 164 |
+
type_dist = self.execute_query(type_dist_query, {"user_id": str(user_id)})
|
| 165 |
+
|
| 166 |
+
return {
|
| 167 |
+
"nodes_count": nodes_count,
|
| 168 |
+
"edges_count": edges_count,
|
| 169 |
+
"type_distribution": type_dist
|
| 170 |
+
}
|
| 171 |
+
except Exception as e:
|
| 172 |
+
logger.error("Failed to query graph statistics. Error: %s", str(e), exc_info=True)
|
| 173 |
+
return {"nodes_count": 0, "edges_count": 0, "type_distribution": []}
|
| 174 |
+
|
| 175 |
+
def delete_document_nodes(self, document_name, user_id):
|
| 176 |
+
"""
|
| 177 |
+
Wipe all nodes and relationships associated with a deleted document.
|
| 178 |
+
Removes orphan nodes that have no other remaining connections.
|
| 179 |
+
"""
|
| 180 |
+
logger.info("Executing Cypher delete query for document: %s, User: %s", document_name, user_id)
|
| 181 |
+
|
| 182 |
+
# 1. Delete relationships pointing from or to nodes created by this document
|
| 183 |
+
delete_rels_query = (
|
| 184 |
+
"MATCH (a:Entity {user_id: $user_id})-[r]->(b:Entity {user_id: $user_id}) "
|
| 185 |
+
"WHERE r.source_doc = $document_name "
|
| 186 |
+
"DELETE r"
|
| 187 |
+
)
|
| 188 |
+
# 2. Delete nodes created solely by this document
|
| 189 |
+
delete_nodes_query = (
|
| 190 |
+
"MATCH (e:Entity {user_id: $user_id}) "
|
| 191 |
+
"WHERE e.source_doc = $document_name "
|
| 192 |
+
"DETACH DELETE e"
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
params = {"document_name": document_name, "user_id": str(user_id)}
|
| 196 |
+
self.execute_query(delete_rels_query, params)
|
| 197 |
+
self.execute_query(delete_nodes_query, params)
|
backend/graphrag/services/nl_to_cypher.py
CHANGED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from typing import List, Dict, Any
|
| 3 |
+
from pydantic import BaseModel, Field
|
| 4 |
+
from langchain_core.prompts import ChatPromptTemplate
|
| 5 |
+
from .llm_client import get_llm
|
| 6 |
+
from .neo4j_client import Neo4jClient
|
| 7 |
+
|
| 8 |
+
logger = logging.getLogger(__name__)
|
| 9 |
+
|
| 10 |
+
class CypherQuery(BaseModel):
|
| 11 |
+
"""
|
| 12 |
+
Structured container for the generated Cypher query.
|
| 13 |
+
"""
|
| 14 |
+
cypher: str = Field(
|
| 15 |
+
description="The executable Neo4j Cypher query. Must match the schema and strictly filter nodes and edges by user_id."
|
| 16 |
+
)
|
| 17 |
+
explanation: str = Field(
|
| 18 |
+
description="A short explanation of what the query fetches from the graph."
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
class NLToCypher:
|
| 22 |
+
def __init__(self):
|
| 23 |
+
logger.info("Initializing NLToCypher translator service.")
|
| 24 |
+
self.neo4j_client = Neo4jClient()
|
| 25 |
+
self.llm = get_llm(temperature=0.0)
|
| 26 |
+
self.structured_llm = self.llm.with_structured_output(CypherQuery)
|
| 27 |
+
|
| 28 |
+
self.prompt = ChatPromptTemplate.from_messages([
|
| 29 |
+
("system", (
|
| 30 |
+
"You are an expert Neo4j Cypher query generator for a GraphRAG knowledge system.\n"
|
| 31 |
+
"Your task is to convert a user's natural language question into a syntactically correct, read-only Cypher query.\n\n"
|
| 32 |
+
"=== DATABASE SCHEMA ===\n"
|
| 33 |
+
"Nodes:\n"
|
| 34 |
+
"Label: :Entity\n"
|
| 35 |
+
"Properties: name (String), type (String), description (String), source_doc (String), page (Integer), user_id (String)\n\n"
|
| 36 |
+
"Relationships:\n"
|
| 37 |
+
"Allowed Types: WORKS_AT, MANAGES, PART_OF, DEPENDS_ON, CREATED_BY, LOCATED_IN, RELATED_TO, COMPETES_WITH, PARTNER_OF, SUCCEEDED_BY\n"
|
| 38 |
+
"Properties: description (String), confidence (Float), source_doc (String), page (Integer), user_id (String)\n\n"
|
| 39 |
+
"=== CRITICAL RULES ===\n"
|
| 40 |
+
"1. Multi-Tenancy Isolation: Every node and relationship in the query MUST filter by user_id. Use the parameter $user_id.\n"
|
| 41 |
+
" Example: MATCH (a:Entity {{user_id: $user_id}})-[r:DEPENDS_ON {{user_id: $user_id}}]->(b:Entity {{user_id: $user_id}})\n"
|
| 42 |
+
"2. Read-Only: Never generate write, delete, or update operations (MERGE, CREATE, SET, DELETE, REMOVE, DETACH).\n"
|
| 43 |
+
"3. Safe Return: Limit results to a maximum of 50 records to prevent performance degradation."
|
| 44 |
+
)),
|
| 45 |
+
("human", "Translate this question into Cypher: '{question}'")
|
| 46 |
+
])
|
| 47 |
+
|
| 48 |
+
self.chain = self.prompt | self.structured_llm
|
| 49 |
+
|
| 50 |
+
def execute_nl_query(self, question: str, user_id: str) -> Dict[str, Any]:
|
| 51 |
+
"""
|
| 52 |
+
Translates a natural language question to Cypher, runs it, and returns results.
|
| 53 |
+
"""
|
| 54 |
+
logger.info("Translating question to Cypher: '%s' (User: %s)", question, user_id)
|
| 55 |
+
|
| 56 |
+
try:
|
| 57 |
+
# 1. Generate Cypher query
|
| 58 |
+
result: CypherQuery = self.chain.invoke({"question": question})
|
| 59 |
+
logger.info("Generated Cypher: %s", result.cypher)
|
| 60 |
+
|
| 61 |
+
# 2. Execute on Neo4j using client
|
| 62 |
+
# New code
|
| 63 |
+
records = self.neo4j_client.execute_query(result.cypher, {"user_id": str(user_id)})
|
| 64 |
+
logger.info("Executed Cypher successfully. Retrieved %d rows.", len(records))
|
| 65 |
+
|
| 66 |
+
return {
|
| 67 |
+
"cypher": result.cypher,
|
| 68 |
+
"explanation": result.explanation,
|
| 69 |
+
"records": records,
|
| 70 |
+
"success": True
|
| 71 |
+
}
|
| 72 |
+
except Exception as e:
|
| 73 |
+
logger.error("Failed to generate or execute Cypher query: %s", str(e), exc_info=True)
|
| 74 |
+
return {
|
| 75 |
+
"cypher": "",
|
| 76 |
+
"explanation": "",
|
| 77 |
+
"records": [],
|
| 78 |
+
"success": False,
|
| 79 |
+
"error": str(e)
|
| 80 |
+
}
|
backend/graphrag/services/rag_chain.py
CHANGED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from typing import Dict, Any, List
|
| 3 |
+
from langchain_core.prompts import ChatPromptTemplate
|
| 4 |
+
from .llm_client import get_llm
|
| 5 |
+
from .graph_retriever import GraphRetriever
|
| 6 |
+
from .vector_retriever import VectorRetriever
|
| 7 |
+
from .hybrid_retriever import HybridRetriever
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
class RAGChain:
|
| 12 |
+
def __init__(self):
|
| 13 |
+
logger.info("Initializing RAGChain answer generation service.")
|
| 14 |
+
self.llm = get_llm(temperature=0.2) # Low temperature for high factual accuracy
|
| 15 |
+
|
| 16 |
+
self.graph_retriever = GraphRetriever()
|
| 17 |
+
self.vector_retriever = VectorRetriever()
|
| 18 |
+
self.hybrid_retriever = HybridRetriever()
|
| 19 |
+
|
| 20 |
+
# Define prompts for each mode
|
| 21 |
+
self.system_prompts = {
|
| 22 |
+
"vector": (
|
| 23 |
+
"You are an AI assistant answering questions based ONLY on the provided text passages.\n"
|
| 24 |
+
"Strict Rules:\n"
|
| 25 |
+
"1. Base your answer ONLY on the provided unstructured text passages.\n"
|
| 26 |
+
"2. If the passages do not contain enough information to answer, state that you do not know.\n"
|
| 27 |
+
"3. Cite the document names and pages where applicable."
|
| 28 |
+
),
|
| 29 |
+
"graph": (
|
| 30 |
+
"You are an AI assistant answering questions based ONLY on the provided structured knowledge graph.\n"
|
| 31 |
+
"Strict Rules:\n"
|
| 32 |
+
"1. Base your answer ONLY on the provided entities and relationship paths.\n"
|
| 33 |
+
"2. Do not assume or extrapolate connections not shown in the graph context.\n"
|
| 34 |
+
"3. If the graph does not contain the answer, state that you do not know."
|
| 35 |
+
),
|
| 36 |
+
"hybrid": (
|
| 37 |
+
"You are an AI assistant answering questions using a combination of a structured knowledge graph and unstructured text passages.\n"
|
| 38 |
+
"Strict Rules:\n"
|
| 39 |
+
"1. Synthesize information from both the entities/relationships and the text passages.\n"
|
| 40 |
+
"2. If there is a contradiction, prioritize the structured relationship links from the graph context.\n"
|
| 41 |
+
"3. Cite sources (documents, pages, or entities) to back up your facts."
|
| 42 |
+
)
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
def generate_answer(self, query: str, user_id: str, mode: str = "hybrid") -> Dict[str, Any]:
|
| 46 |
+
"""
|
| 47 |
+
Retrieves context according to the selected mode, invokes the LLM, and returns the response.
|
| 48 |
+
"""
|
| 49 |
+
mode = mode.lower()
|
| 50 |
+
if mode not in ["vector", "graph", "hybrid"]:
|
| 51 |
+
logger.warning("Invalid retrieval mode '%s' requested. Defaulting to 'hybrid'.", mode)
|
| 52 |
+
mode = "hybrid"
|
| 53 |
+
|
| 54 |
+
logger.info("Generating RAG answer in '%s' mode for query: '%s' (User: %s)", mode, query, user_id)
|
| 55 |
+
|
| 56 |
+
context = ""
|
| 57 |
+
sources = []
|
| 58 |
+
strategy_used = mode.upper()
|
| 59 |
+
|
| 60 |
+
# 1. Fetch Context depending on the Retrieval Mode
|
| 61 |
+
try:
|
| 62 |
+
if mode == "vector":
|
| 63 |
+
chunks = self.vector_retriever.retrieve_relevant_chunks(query, user_id, limit=5)
|
| 64 |
+
context_lines = []
|
| 65 |
+
for c in chunks:
|
| 66 |
+
context_lines.append(f"Document: {c['source_doc']} (Page: {c['page']}): \"{c['text']}\"")
|
| 67 |
+
sources.append(f"{c['source_doc']} (Page {c['page']})")
|
| 68 |
+
context = "### TEXT PASSAGES:\n" + "\n\n".join(context_lines)
|
| 69 |
+
|
| 70 |
+
elif mode == "graph":
|
| 71 |
+
graph_context = self.graph_retriever.retrieve_graph_context(query, user_id, hops=2)
|
| 72 |
+
context = graph_context
|
| 73 |
+
# Extract entity names as sources
|
| 74 |
+
for line in graph_context.split("\n"):
|
| 75 |
+
if line.startswith("* **"):
|
| 76 |
+
ent_name = line.split("**")[1]
|
| 77 |
+
sources.append(f"Graph Node: {ent_name}")
|
| 78 |
+
|
| 79 |
+
else: # hybrid
|
| 80 |
+
hybrid_result = self.hybrid_retriever.retrieve_combined_context(query, user_id)
|
| 81 |
+
context = hybrid_result["combined_context"]
|
| 82 |
+
strategy_used = hybrid_result["strategy"]
|
| 83 |
+
|
| 84 |
+
# Gather sources from both channels
|
| 85 |
+
for c in hybrid_result["vector_chunks"]:
|
| 86 |
+
sources.append(f"{c['source_doc']} (Page {c['page']})")
|
| 87 |
+
for line in hybrid_result["graph_context"].split("\n"):
|
| 88 |
+
if line.startswith("* **"):
|
| 89 |
+
ent_name = line.split("**")[1]
|
| 90 |
+
sources.append(f"Graph Node: {ent_name}")
|
| 91 |
+
|
| 92 |
+
except Exception as e:
|
| 93 |
+
logger.error("Failed to retrieve context in %s mode. Error: %s", mode, str(e), exc_info=True)
|
| 94 |
+
return {
|
| 95 |
+
"answer": "An error occurred during context retrieval phase.",
|
| 96 |
+
"context": "",
|
| 97 |
+
"sources": [],
|
| 98 |
+
"strategy": mode.upper(),
|
| 99 |
+
"success": False
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
# 2. Build Chat Prompt template
|
| 103 |
+
system_instructions = self.system_prompts.get(mode, self.system_prompts["hybrid"])
|
| 104 |
+
|
| 105 |
+
prompt = ChatPromptTemplate.from_messages([
|
| 106 |
+
("system", system_instructions),
|
| 107 |
+
("human", (
|
| 108 |
+
"CONTEXT:\n"
|
| 109 |
+
"---------------------\n"
|
| 110 |
+
"{context}\n"
|
| 111 |
+
"---------------------\n\n"
|
| 112 |
+
"QUESTION: {query}"
|
| 113 |
+
))
|
| 114 |
+
])
|
| 115 |
+
|
| 116 |
+
# 3. Call LLM
|
| 117 |
+
try:
|
| 118 |
+
chain = prompt | self.llm
|
| 119 |
+
response = chain.invoke({
|
| 120 |
+
"context": context if context else "No context available.",
|
| 121 |
+
"query": query
|
| 122 |
+
})
|
| 123 |
+
answer = response.content.strip()
|
| 124 |
+
|
| 125 |
+
# Deduplicate sources list
|
| 126 |
+
sources = list(sorted(set(sources)))
|
| 127 |
+
|
| 128 |
+
return {
|
| 129 |
+
"answer": answer,
|
| 130 |
+
"context": context,
|
| 131 |
+
"sources": sources,
|
| 132 |
+
"strategy": strategy_used,
|
| 133 |
+
"success": True
|
| 134 |
+
}
|
| 135 |
+
except Exception as e:
|
| 136 |
+
logger.error("Failed to generate LLM response: %s", str(e), exc_info=True)
|
| 137 |
+
return {
|
| 138 |
+
"answer": f"Failed to generate answer. Error: {str(e)}",
|
| 139 |
+
"context": context,
|
| 140 |
+
"sources": sources,
|
| 141 |
+
"strategy": strategy_used,
|
| 142 |
+
"success": False
|
| 143 |
+
}
|
backend/graphrag/services/relationship_extractor.py
CHANGED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from typing import List
|
| 3 |
+
from pydantic import BaseModel, Field
|
| 4 |
+
from langchain_core.prompts import ChatPromptTemplate
|
| 5 |
+
from .llm_client import get_llm
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger(__name__)
|
| 8 |
+
|
| 9 |
+
class Relationship(BaseModel):
|
| 10 |
+
"""
|
| 11 |
+
Represents a directed link between a source entity and a target entity.
|
| 12 |
+
"""
|
| 13 |
+
source_entity: str = Field(
|
| 14 |
+
description="The exact name of the starting entity (e.g., 'John Smith'). Must refer to an actual entity."
|
| 15 |
+
)
|
| 16 |
+
relationship_type: str = Field(
|
| 17 |
+
description="The category of link. Must be exactly one of: WORKS_AT, MANAGES, PART_OF, DEPENDS_ON, CREATED_BY, LOCATED_IN, RELATED_TO, COMPETES_WITH, PARTNER_OF, SUCCEEDED_BY"
|
| 18 |
+
)
|
| 19 |
+
target_entity: str = Field(
|
| 20 |
+
description="The exact name of the destination entity (e.g., 'Acme Corp'). Must refer to an actual entity."
|
| 21 |
+
)
|
| 22 |
+
description: str = Field(
|
| 23 |
+
description="A 1-2 sentence description explaining the nature or evidence of this relationship in the text."
|
| 24 |
+
)
|
| 25 |
+
confidence: float = Field(
|
| 26 |
+
description="A decimal confidence score between 0.0 (unlikely/speculative) and 1.0 (explicitly stated)."
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
class ExtractedRelationships(BaseModel):
|
| 30 |
+
"""
|
| 31 |
+
Container list of all extracted relationships.
|
| 32 |
+
"""
|
| 33 |
+
relationships: List[Relationship]
|
| 34 |
+
|
| 35 |
+
class RelationshipExtractor:
|
| 36 |
+
def __init__(self):
|
| 37 |
+
logger.info("Initializing RelationshipExtractor service.")
|
| 38 |
+
self.llm = get_llm(temperature=0.0)
|
| 39 |
+
self.structured_llm = self.llm.with_structured_output(ExtractedRelationships)
|
| 40 |
+
|
| 41 |
+
self.prompt = ChatPromptTemplate.from_messages([
|
| 42 |
+
("system", (
|
| 43 |
+
"You are an expert NLP systems agent specialized in relationship extraction for knowledge graphs.\n"
|
| 44 |
+
"Your task is to identify key directed relationships between the entities present in the text.\n\n"
|
| 45 |
+
"Strict rules:\n"
|
| 46 |
+
"1. Relationship Type: Choose exactly one of: WORKS_AT, MANAGES, PART_OF, DEPENDS_ON, CREATED_BY, LOCATED_IN, RELATED_TO, COMPETES_WITH, PARTNER_OF, SUCCEEDED_BY.\n"
|
| 47 |
+
"2. Grounding: Both the source_entity and target_entity must represent real entities. Avoid generic words.\n"
|
| 48 |
+
"3. Confidence: Grade the strength of statement between 0.0 and 1.0."
|
| 49 |
+
)),
|
| 50 |
+
("human", (
|
| 51 |
+
"Identify all key relationships in the following text chunk:\n\n"
|
| 52 |
+
"--- TEXT START ---\n"
|
| 53 |
+
"{text_content}\n"
|
| 54 |
+
"--- TEXT END ---"
|
| 55 |
+
))
|
| 56 |
+
])
|
| 57 |
+
|
| 58 |
+
self.chain = self.prompt | self.structured_llm
|
| 59 |
+
|
| 60 |
+
def extract_relationships(self, text_content: str) -> List[dict]:
|
| 61 |
+
"""
|
| 62 |
+
Processes a string chunk of text and returns a list of serialized relationship dicts.
|
| 63 |
+
"""
|
| 64 |
+
if not text_content or not text_content.strip():
|
| 65 |
+
logger.warning("Empty text chunk provided to extract_relationships.")
|
| 66 |
+
return []
|
| 67 |
+
|
| 68 |
+
word_count = len(text_content.split())
|
| 69 |
+
logger.info("Running relationship extraction on text chunk (Words: %d).", word_count)
|
| 70 |
+
|
| 71 |
+
try:
|
| 72 |
+
result: ExtractedRelationships = self.chain.invoke({"text_content": text_content})
|
| 73 |
+
extracted = [rel.model_dump() for rel in result.relationships]
|
| 74 |
+
logger.info("Successfully extracted %d relationships from text chunk.", len(extracted))
|
| 75 |
+
return extracted
|
| 76 |
+
except Exception as e:
|
| 77 |
+
logger.error("Failed during relationship extraction processing. Error: %s", str(e), exc_info=True)
|
| 78 |
+
return []
|
backend/graphrag/services/vector_retriever.py
CHANGED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import logging
|
| 3 |
+
from typing import List
|
| 4 |
+
from django.conf import settings
|
| 5 |
+
import chromadb
|
| 6 |
+
from langchain_community.embeddings import HuggingFaceEmbeddings
|
| 7 |
+
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
class VectorRetriever:
|
| 12 |
+
def __init__(self):
|
| 13 |
+
logger.info("Initializing VectorRetriever service.")
|
| 14 |
+
|
| 15 |
+
# 1. Initialize Persistent ChromaDB Client
|
| 16 |
+
self.persist_directory = os.path.join(settings.BASE_DIR, "db", "chromadb")
|
| 17 |
+
os.makedirs(self.persist_directory, exist_ok=True)
|
| 18 |
+
self.chroma_client = chromadb.PersistentClient(path=self.persist_directory)
|
| 19 |
+
|
| 20 |
+
# 2. Load the Embedding Model (all-MiniLM-L6-v2)
|
| 21 |
+
# Prioritizes CPU execution but supports CUDA if available
|
| 22 |
+
self.embeddings = HuggingFaceEmbeddings(
|
| 23 |
+
model_name="sentence-transformers/all-MiniLM-L6-v2",
|
| 24 |
+
encode_kwargs={'normalize_embeddings': True}
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
# 3. Setup text splitter for document chunking
|
| 28 |
+
self.text_splitter = RecursiveCharacterTextSplitter(
|
| 29 |
+
chunk_size=800,
|
| 30 |
+
chunk_overlap=100,
|
| 31 |
+
length_function=len
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
def _get_user_collection(self, user_id):
|
| 35 |
+
"""
|
| 36 |
+
Enforce multi-tenancy by returning a collection isolated for each user.
|
| 37 |
+
"""
|
| 38 |
+
collection_name = f"user_collection_{str(user_id).replace('-', '_')}"
|
| 39 |
+
return self.chroma_client.get_or_create_collection(
|
| 40 |
+
name=collection_name,
|
| 41 |
+
metadata={"hnsw:space": "cosine"} # Use cosine similarity
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
def index_document(self, text_content: str, doc_name: str, user_id: str):
|
| 45 |
+
"""
|
| 46 |
+
Splits document text into chunks, generates embeddings, and saves them to ChromaDB.
|
| 47 |
+
"""
|
| 48 |
+
if not text_content or not text_content.strip():
|
| 49 |
+
logger.warning("Empty text content provided for vector indexing.")
|
| 50 |
+
return
|
| 51 |
+
|
| 52 |
+
logger.info("Starting vector indexing for document '%s' (User: %s)", doc_name, user_id)
|
| 53 |
+
try:
|
| 54 |
+
# Split text into chunks
|
| 55 |
+
chunks = self.text_splitter.split_text(text_content)
|
| 56 |
+
logger.info("Split document into %d vector chunks.", len(chunks))
|
| 57 |
+
|
| 58 |
+
collection = self._get_user_collection(user_id)
|
| 59 |
+
|
| 60 |
+
# Prepare inputs for ChromaDB
|
| 61 |
+
ids = [f"{doc_name}_chunk_{i}" for i in range(len(chunks))]
|
| 62 |
+
# Generate vector representations using sentence-transformers
|
| 63 |
+
embeddings = self.embeddings.embed_documents(chunks)
|
| 64 |
+
metadatas = [{"source_doc": doc_name, "page": (i // 2) + 1} for i in range(len(chunks))]
|
| 65 |
+
|
| 66 |
+
# Insert or update in ChromaDB
|
| 67 |
+
collection.upsert(
|
| 68 |
+
ids=ids,
|
| 69 |
+
embeddings=embeddings,
|
| 70 |
+
documents=chunks,
|
| 71 |
+
metadatas=metadatas
|
| 72 |
+
)
|
| 73 |
+
logger.info("Successfully indexed %d chunks in ChromaDB for document: %s", len(chunks), doc_name)
|
| 74 |
+
except Exception as e:
|
| 75 |
+
logger.error("Failed to index document in ChromaDB. Error: %s", str(e), exc_info=True)
|
| 76 |
+
raise e
|
| 77 |
+
|
| 78 |
+
def retrieve_relevant_chunks(self, query: str, user_id: str, limit: int = 5) -> List[dict]:
|
| 79 |
+
"""
|
| 80 |
+
Queries ChromaDB to retrieve the most semantically relevant text passages.
|
| 81 |
+
"""
|
| 82 |
+
logger.info("Searching ChromaDB for query: '%s' (Limit: %d, User: %s)", query, limit, user_id)
|
| 83 |
+
try:
|
| 84 |
+
collection = self._get_user_collection(user_id)
|
| 85 |
+
query_vector = self.embeddings.embed_query(query)
|
| 86 |
+
|
| 87 |
+
results = collection.query(
|
| 88 |
+
query_embeddings=[query_vector],
|
| 89 |
+
n_results=limit
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
retrieved = []
|
| 93 |
+
if results and results["documents"]:
|
| 94 |
+
documents = results["documents"][0]
|
| 95 |
+
metadatas = results["metadatas"][0]
|
| 96 |
+
distances = results["distances"][0] if "distances" in results else [0.0] * len(documents)
|
| 97 |
+
|
| 98 |
+
for doc, meta, dist in zip(documents, metadatas, distances):
|
| 99 |
+
# Cosine distance (0.0 is exact match, 1.0 is opposite)
|
| 100 |
+
# Convert distance to a similarity score (1.0 - distance)
|
| 101 |
+
similarity = round(1.0 - dist, 4)
|
| 102 |
+
retrieved.append({
|
| 103 |
+
"text": doc,
|
| 104 |
+
"source_doc": meta.get("source_doc", "unknown"),
|
| 105 |
+
"page": meta.get("page", 1),
|
| 106 |
+
"similarity_score": similarity
|
| 107 |
+
})
|
| 108 |
+
|
| 109 |
+
logger.info("Retrieved %d relevant text chunks from ChromaDB.", len(retrieved))
|
| 110 |
+
return retrieved
|
| 111 |
+
except Exception as e:
|
| 112 |
+
logger.error("Error retrieving from ChromaDB: %s", str(e), exc_info=True)
|
| 113 |
+
return []
|
| 114 |
+
|
| 115 |
+
def delete_document_vectors(self, doc_name: str, user_id: str):
|
| 116 |
+
"""
|
| 117 |
+
Removes all vectors belonging to a deleted document.
|
| 118 |
+
"""
|
| 119 |
+
logger.info("Deleting vectors for document '%s' from ChromaDB (User: %s)", doc_name, user_id)
|
| 120 |
+
try:
|
| 121 |
+
collection = self._get_user_collection(user_id)
|
| 122 |
+
collection.delete(where={"source_doc": doc_name})
|
| 123 |
+
logger.info("Successfully deleted all vectors for document '%s' from ChromaDB.", doc_name)
|
| 124 |
+
except Exception as e:
|
| 125 |
+
logger.error("Failed to delete document vectors from ChromaDB: %s", str(e), exc_info=True)
|
backend/graphrag/tests.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import uuid
|
| 3 |
+
from django.urls import reverse
|
| 4 |
+
from rest_framework import status
|
| 5 |
+
from rest_framework.test import APITestCase
|
| 6 |
+
from django.contrib.auth import get_user_model
|
| 7 |
+
from django.core.files.uploadedfile import SimpleUploadedFile
|
| 8 |
+
from unittest.mock import patch, MagicMock
|
| 9 |
+
|
| 10 |
+
from .models import Document
|
| 11 |
+
from .services.graph_builder import GraphBuilder
|
| 12 |
+
|
| 13 |
+
User = get_user_model()
|
| 14 |
+
|
| 15 |
+
class GraphRAGTests(APITestCase):
|
| 16 |
+
|
| 17 |
+
def setUp(self):
|
| 18 |
+
# Create a default test user
|
| 19 |
+
self.username = "testuser"
|
| 20 |
+
self.email = "testuser@gmail.com"
|
| 21 |
+
self.password = "SecurePassword1!"
|
| 22 |
+
self.user = User.objects.create_user(
|
| 23 |
+
username=self.username,
|
| 24 |
+
email=self.email,
|
| 25 |
+
password=self.password
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
# ================= AUTHENTICATION TESTS =================
|
| 29 |
+
|
| 30 |
+
def test_registration_success(self):
|
| 31 |
+
url = reverse('auth_register')
|
| 32 |
+
data = {
|
| 33 |
+
"username": "newuser",
|
| 34 |
+
"email": "newuser@gmail.com",
|
| 35 |
+
"password": "NewSecure1!",
|
| 36 |
+
"confirm_password": "NewSecure1!"
|
| 37 |
+
}
|
| 38 |
+
response = self.client.post(url, data, format='json')
|
| 39 |
+
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
| 40 |
+
self.assertEqual(response.data["message"], "User registered successfully.")
|
| 41 |
+
self.assertIn("user", response.data)
|
| 42 |
+
self.assertEqual(response.data["user"]["username"], "newuser")
|
| 43 |
+
|
| 44 |
+
def test_registration_blocked_disposable_email(self):
|
| 45 |
+
url = reverse('auth_register')
|
| 46 |
+
data = {
|
| 47 |
+
"username": "spammer",
|
| 48 |
+
"email": "spammer@yopmail.com",
|
| 49 |
+
"password": "SecurePassword1!",
|
| 50 |
+
"confirm_password": "SecurePassword1!"
|
| 51 |
+
}
|
| 52 |
+
response = self.client.post(url, data, format='json')
|
| 53 |
+
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
| 54 |
+
self.assertIn("email", response.data)
|
| 55 |
+
|
| 56 |
+
def test_registration_blocked_invalid_password(self):
|
| 57 |
+
url = reverse('auth_register')
|
| 58 |
+
data = {
|
| 59 |
+
"username": "weakuser",
|
| 60 |
+
"email": "weak@gmail.com",
|
| 61 |
+
"password": "weakpassword",
|
| 62 |
+
"confirm_password": "weakpassword"
|
| 63 |
+
}
|
| 64 |
+
response = self.client.post(url, data, format='json')
|
| 65 |
+
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
| 66 |
+
self.assertIn("password", response.data)
|
| 67 |
+
|
| 68 |
+
def test_login_and_jwt_issuance(self):
|
| 69 |
+
url = reverse('auth_login')
|
| 70 |
+
data = {
|
| 71 |
+
"username": self.username,
|
| 72 |
+
"password": self.password
|
| 73 |
+
}
|
| 74 |
+
response = self.client.post(url, data, format='json')
|
| 75 |
+
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
| 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')
|
| 82 |
+
def test_document_creation_and_status(self, mock_trigger):
|
| 83 |
+
# Login and get token
|
| 84 |
+
self.client.force_authenticate(user=self.user)
|
| 85 |
+
|
| 86 |
+
# Create a mock file
|
| 87 |
+
test_file = SimpleUploadedFile("sample.txt", b"This is sample document content.", content_type="text/plain")
|
| 88 |
+
|
| 89 |
+
url = reverse('document_upload') # POST /api/documents/upload/
|
| 90 |
+
response = self.client.post(url, {'file': test_file}, format='multipart')
|
| 91 |
+
|
| 92 |
+
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
|
| 93 |
+
self.assertEqual(response.data["document"]["status"], "PENDING")
|
| 94 |
+
self.assertEqual(response.data["document"]["name"], "sample.txt")
|
| 95 |
+
mock_trigger.assert_called_once()
|
| 96 |
+
|
| 97 |
+
@patch('graphrag.services.graph_builder.Neo4jClient')
|
| 98 |
+
@patch('graphrag.services.graph_builder.EntityExtractor')
|
| 99 |
+
@patch('graphrag.services.graph_builder.RelationshipExtractor')
|
| 100 |
+
@patch('graphrag.services.graph_builder.VectorRetriever')
|
| 101 |
+
def test_background_ingestion_pipeline(self, mock_vector, mock_rel, mock_ent, mock_neo):
|
| 102 |
+
# Configure Mocks
|
| 103 |
+
mock_ent_instance = mock_ent.return_value
|
| 104 |
+
mock_ent_instance.extract_entities.return_value = [
|
| 105 |
+
{"name": "Google", "type": "ORGANIZATION", "description": "Tech company"}
|
| 106 |
+
]
|
| 107 |
+
|
| 108 |
+
mock_rel_instance = mock_rel.return_value
|
| 109 |
+
mock_rel_instance.extract_relationships.return_value = []
|
| 110 |
+
|
| 111 |
+
mock_vector_instance = mock_vector.return_value
|
| 112 |
+
|
| 113 |
+
# Create Document instance
|
| 114 |
+
test_file = SimpleUploadedFile("sample.txt", b"Google is a tech company.", content_type="text/plain")
|
| 115 |
+
doc = Document.objects.create(
|
| 116 |
+
user=self.user,
|
| 117 |
+
name="sample.txt",
|
| 118 |
+
file=test_file,
|
| 119 |
+
status=Document.Status.PENDING
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
# Run orchestrator synchronously in test
|
| 123 |
+
builder = GraphBuilder()
|
| 124 |
+
builder.process_document(doc.id, self.user.id)
|
| 125 |
+
|
| 126 |
+
# Refresh from database and check state
|
| 127 |
+
doc.refresh_from_db()
|
| 128 |
+
self.assertEqual(doc.status, Document.Status.COMPLETED)
|
| 129 |
+
self.assertEqual(doc.entity_count, 1)
|
| 130 |
+
self.assertEqual(doc.relationship_count, 0)
|
| 131 |
+
|
| 132 |
+
# Verify vector store index was called
|
| 133 |
+
mock_vector_instance.index_document.assert_called_once()
|
backend/graphrag/tests_comprehensive.py
ADDED
|
@@ -0,0 +1,1177 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Comprehensive E2E Test Suite for GraphRAG Knowledge AI Backend.
|
| 3 |
+
|
| 4 |
+
Covers: Authentication, Document Management, Query, Graph, Error Handling,
|
| 5 |
+
and Security concerns. All external services (Neo4j, LLM, ChromaDB) are mocked
|
| 6 |
+
so tests are deterministic, fast, and CI-safe.
|
| 7 |
+
|
| 8 |
+
Run with:
|
| 9 |
+
python manage.py test graphrag.tests_comprehensive --verbosity=2
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import json
|
| 13 |
+
import io
|
| 14 |
+
import uuid
|
| 15 |
+
from unittest.mock import patch, MagicMock, PropertyMock
|
| 16 |
+
from django.urls import reverse
|
| 17 |
+
from django.conf import settings
|
| 18 |
+
from django.contrib.auth import get_user_model
|
| 19 |
+
from django.core.files.uploadedfile import SimpleUploadedFile
|
| 20 |
+
from rest_framework import status
|
| 21 |
+
from rest_framework.test import APITestCase, APIClient
|
| 22 |
+
from rest_framework_simplejwt.tokens import RefreshToken
|
| 23 |
+
|
| 24 |
+
from .models import Document, QueryLog
|
| 25 |
+
|
| 26 |
+
User = get_user_model()
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
# ---------------------------------------------------------------------------
|
| 30 |
+
# Helpers
|
| 31 |
+
# ---------------------------------------------------------------------------
|
| 32 |
+
|
| 33 |
+
def _generate_token(user):
|
| 34 |
+
"""Return a valid JWT access token string for the given user."""
|
| 35 |
+
refresh = RefreshToken.for_user(user)
|
| 36 |
+
return str(refresh.access_token)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _auth_header(user):
|
| 40 |
+
"""Return the Authorization header dict for a user."""
|
| 41 |
+
return {"HTTP_AUTHORIZATION": f"Bearer {_generate_token(user)}"}
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _create_user(username="defaultuser", email=None, password="SecurePass1!"):
|
| 45 |
+
"""Convenience: create and return a User."""
|
| 46 |
+
if email is None:
|
| 47 |
+
email = f"{username}@example.com"
|
| 48 |
+
return User.objects.create_user(
|
| 49 |
+
username=username, email=email, password=password
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _results(response):
|
| 54 |
+
"""Normalize DRF paginated or non-paginated list responses.
|
| 55 |
+
|
| 56 |
+
The DocumentViewSet does not enable pagination, so ``response.data``
|
| 57 |
+
is a ``ReturnList``. When pagination IS enabled it becomes a dict
|
| 58 |
+
with a ``"results"`` key. This helper returns a plain list either way.
|
| 59 |
+
"""
|
| 60 |
+
if isinstance(response.data, list):
|
| 61 |
+
return response.data
|
| 62 |
+
return response.data.get("results", [])
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _upload_payload(filename="test.txt", content=b"Hello world", content_type="text/plain"):
|
| 66 |
+
"""Return a dict suitable for multipart file upload."""
|
| 67 |
+
return {"file": SimpleUploadedFile(filename, content, content_type=content_type)}
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
# ===========================================================================
|
| 71 |
+
# 1. AUTHENTICATION TESTS
|
| 72 |
+
# ===========================================================================
|
| 73 |
+
|
| 74 |
+
class AuthenticationTests(APITestCase):
|
| 75 |
+
"""Tests for /api/auth/register/, /api/auth/login/,
|
| 76 |
+
/api/auth/token/refresh/ and protected-route access."""
|
| 77 |
+
|
| 78 |
+
# ---- Registration -----------------------------------------------------
|
| 79 |
+
|
| 80 |
+
def test_register_success(self):
|
| 81 |
+
"""POST /api/auth/register/ with valid data returns 201."""
|
| 82 |
+
url = reverse("auth_register")
|
| 83 |
+
data = {
|
| 84 |
+
"username": "alice",
|
| 85 |
+
"email": "alice@gmail.com",
|
| 86 |
+
"password": "StrongPass1!",
|
| 87 |
+
"confirm_password": "StrongPass1!",
|
| 88 |
+
}
|
| 89 |
+
response = self.client.post(url, data, format="json")
|
| 90 |
+
|
| 91 |
+
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
| 92 |
+
self.assertEqual(response.data["message"], "User registered successfully.")
|
| 93 |
+
self.assertIn("user", response.data)
|
| 94 |
+
self.assertEqual(response.data["user"]["username"], "alice")
|
| 95 |
+
self.assertTrue(User.objects.filter(username="alice").exists())
|
| 96 |
+
|
| 97 |
+
def test_register_duplicate_email(self):
|
| 98 |
+
"""Registering with an already-used email returns 400."""
|
| 99 |
+
_create_user(username="existing", email="dup@gmail.com")
|
| 100 |
+
url = reverse("auth_register")
|
| 101 |
+
data = {
|
| 102 |
+
"username": "another",
|
| 103 |
+
"email": "dup@gmail.com",
|
| 104 |
+
"password": "StrongPass1!",
|
| 105 |
+
"confirm_password": "StrongPass1!",
|
| 106 |
+
}
|
| 107 |
+
response = self.client.post(url, data, format="json")
|
| 108 |
+
|
| 109 |
+
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
| 110 |
+
self.assertIn("email", response.data)
|
| 111 |
+
|
| 112 |
+
def test_register_weak_password(self):
|
| 113 |
+
"""Passwords that fail strength checks return 400."""
|
| 114 |
+
url = reverse("auth_register")
|
| 115 |
+
data = {
|
| 116 |
+
"username": "weakuser",
|
| 117 |
+
"email": "weak@gmail.com",
|
| 118 |
+
"password": "weakpassword",
|
| 119 |
+
"confirm_password": "weakpassword",
|
| 120 |
+
}
|
| 121 |
+
response = self.client.post(url, data, format="json")
|
| 122 |
+
|
| 123 |
+
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
| 124 |
+
self.assertIn("password", response.data)
|
| 125 |
+
|
| 126 |
+
def test_register_password_mismatch(self):
|
| 127 |
+
"""Password and confirm_password must match."""
|
| 128 |
+
url = reverse("auth_register")
|
| 129 |
+
data = {
|
| 130 |
+
"username": "mismatch",
|
| 131 |
+
"email": "mismatch@gmail.com",
|
| 132 |
+
"password": "StrongPass1!",
|
| 133 |
+
"confirm_password": "DifferentPass1!",
|
| 134 |
+
}
|
| 135 |
+
response = self.client.post(url, data, format="json")
|
| 136 |
+
|
| 137 |
+
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
| 138 |
+
self.assertIn("password", response.data)
|
| 139 |
+
|
| 140 |
+
def test_register_missing_confirm_password(self):
|
| 141 |
+
"""Missing confirm_password returns 400."""
|
| 142 |
+
url = reverse("auth_register")
|
| 143 |
+
data = {
|
| 144 |
+
"username": "noconfirm",
|
| 145 |
+
"email": "noconfirm@gmail.com",
|
| 146 |
+
"password": "StrongPass1!",
|
| 147 |
+
}
|
| 148 |
+
response = self.client.post(url, data, format="json")
|
| 149 |
+
|
| 150 |
+
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
| 151 |
+
|
| 152 |
+
def test_register_disposable_email_blocked(self):
|
| 153 |
+
"""Disposable email domains must be rejected."""
|
| 154 |
+
url = reverse("auth_register")
|
| 155 |
+
data = {
|
| 156 |
+
"username": "spammer",
|
| 157 |
+
"email": "spammer@yopmail.com",
|
| 158 |
+
"password": "StrongPass1!",
|
| 159 |
+
"confirm_password": "StrongPass1!",
|
| 160 |
+
}
|
| 161 |
+
response = self.client.post(url, data, format="json")
|
| 162 |
+
|
| 163 |
+
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
| 164 |
+
self.assertIn("email", response.data)
|
| 165 |
+
|
| 166 |
+
def test_register_missing_required_fields(self):
|
| 167 |
+
"""Omitting fields returns 400."""
|
| 168 |
+
url = reverse("auth_register")
|
| 169 |
+
response = self.client.post(url, {}, format="json")
|
| 170 |
+
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
| 171 |
+
|
| 172 |
+
# ---- Login ------------------------------------------------------------
|
| 173 |
+
|
| 174 |
+
def test_login_success(self):
|
| 175 |
+
"""POST /api/auth/login/ with correct credentials returns JWT tokens."""
|
| 176 |
+
user = _create_user(username="logintester", email="logintester@gmail.com")
|
| 177 |
+
url = reverse("auth_login")
|
| 178 |
+
data = {"username": "logintester", "password": "SecurePass1!"}
|
| 179 |
+
response = self.client.post(url, data, format="json")
|
| 180 |
+
|
| 181 |
+
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
| 182 |
+
self.assertIn("access", response.data)
|
| 183 |
+
self.assertIn("refresh", response.data)
|
| 184 |
+
|
| 185 |
+
def test_login_wrong_password(self):
|
| 186 |
+
"""Login with wrong password returns 401."""
|
| 187 |
+
_create_user(username="wrongpwd", email="wrongpwd@gmail.com")
|
| 188 |
+
url = reverse("auth_login")
|
| 189 |
+
data = {"username": "wrongpwd", "password": "WrongPassword1!"}
|
| 190 |
+
response = self.client.post(url, data, format="json")
|
| 191 |
+
|
| 192 |
+
self.assertIn(response.status_code,
|
| 193 |
+
[status.HTTP_401_UNAUTHORIZED, status.HTTP_400_BAD_REQUEST])
|
| 194 |
+
|
| 195 |
+
def test_login_nonexistent_user(self):
|
| 196 |
+
"""Login with a username that doesn't exist returns 401."""
|
| 197 |
+
url = reverse("auth_login")
|
| 198 |
+
data = {"username": "ghost", "password": "NoUser123!"}
|
| 199 |
+
response = self.client.post(url, data, format="json")
|
| 200 |
+
|
| 201 |
+
self.assertIn(response.status_code,
|
| 202 |
+
[status.HTTP_401_UNAUTHORIZED, status.HTTP_400_BAD_REQUEST])
|
| 203 |
+
|
| 204 |
+
# ---- Token Refresh ----------------------------------------------------
|
| 205 |
+
|
| 206 |
+
def test_token_refresh(self):
|
| 207 |
+
"""POST /api/auth/token/refresh/ with a valid refresh token returns a new access token."""
|
| 208 |
+
user = _create_user(username="refresher", email="refresher@gmail.com")
|
| 209 |
+
refresh = RefreshToken.for_user(user)
|
| 210 |
+
|
| 211 |
+
url = reverse("auth_token_refresh")
|
| 212 |
+
data = {"refresh": str(refresh)}
|
| 213 |
+
response = self.client.post(url, data, format="json")
|
| 214 |
+
|
| 215 |
+
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
| 216 |
+
self.assertIn("access", response.data)
|
| 217 |
+
|
| 218 |
+
def test_token_refresh_invalid_token(self):
|
| 219 |
+
"""An invalid refresh token is rejected."""
|
| 220 |
+
url = reverse("auth_token_refresh")
|
| 221 |
+
data = {"refresh": "not-a-real-token"}
|
| 222 |
+
response = self.client.post(url, data, format="json")
|
| 223 |
+
|
| 224 |
+
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
| 225 |
+
|
| 226 |
+
# ---- Protected Endpoint Access -----------------------------------------
|
| 227 |
+
|
| 228 |
+
def test_access_protected_endpoint_without_token(self):
|
| 229 |
+
"""Hitting a protected endpoint with no token returns 401."""
|
| 230 |
+
url = reverse("query")
|
| 231 |
+
response = self.client.post(url, {"query": "test"}, format="json")
|
| 232 |
+
|
| 233 |
+
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
| 234 |
+
|
| 235 |
+
def test_access_protected_endpoint_with_expired_token(self):
|
| 236 |
+
"""An expired token is rejected."""
|
| 237 |
+
user = _create_user(username="expired", email="expired@gmail.com")
|
| 238 |
+
refresh = RefreshToken.for_user(user)
|
| 239 |
+
# Manually craft a token with an already-passed expiry
|
| 240 |
+
from datetime import timedelta
|
| 241 |
+
from rest_framework_simplejwt.tokens import AccessToken
|
| 242 |
+
|
| 243 |
+
token = AccessToken()
|
| 244 |
+
token.set_exp(lifetime=timedelta(seconds=-10))
|
| 245 |
+
token["user_id"] = str(user.id)
|
| 246 |
+
|
| 247 |
+
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {str(token)}")
|
| 248 |
+
url = reverse("query")
|
| 249 |
+
response = self.client.post(url, {"query": "test"}, format="json")
|
| 250 |
+
|
| 251 |
+
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
| 252 |
+
|
| 253 |
+
def test_query_unauthorized(self):
|
| 254 |
+
"""Verify query endpoint rejects unauthenticated requests."""
|
| 255 |
+
url = reverse("query")
|
| 256 |
+
response = self.client.post(url, {"query": "hello"}, format="json")
|
| 257 |
+
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
# ===========================================================================
|
| 261 |
+
# 2. DOCUMENT MANAGEMENT TESTS
|
| 262 |
+
# ===========================================================================
|
| 263 |
+
|
| 264 |
+
class DocumentManagementTests(APITestCase):
|
| 265 |
+
"""Tests for document upload, listing, retrieval, and deletion."""
|
| 266 |
+
|
| 267 |
+
def setUp(self):
|
| 268 |
+
self.user = _create_user(username="docuser", email="docuser@gmail.com")
|
| 269 |
+
self.other_user = _create_user(username="otherdoc", email="otherdoc@gmail.com")
|
| 270 |
+
self.upload_url = reverse("document_upload")
|
| 271 |
+
self.list_url = reverse("document-list") # Router-generated
|
| 272 |
+
|
| 273 |
+
# ---- Upload -----------------------------------------------------------
|
| 274 |
+
|
| 275 |
+
@patch("graphrag.views.trigger_ingestion_background")
|
| 276 |
+
def test_upload_document_success(self, mock_bg):
|
| 277 |
+
"""Authenticated upload returns 202 with PENDING status."""
|
| 278 |
+
self.client.force_authenticate(user=self.user)
|
| 279 |
+
payload = _upload_payload("report.pdf", b"%PDF-1.4 fake", "application/pdf")
|
| 280 |
+
response = self.client.post(self.upload_url, payload, format="multipart")
|
| 281 |
+
|
| 282 |
+
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
|
| 283 |
+
self.assertEqual(response.data["document"]["status"], "PENDING")
|
| 284 |
+
self.assertEqual(response.data["document"]["name"], "report.pdf")
|
| 285 |
+
self.assertIn("message", response.data)
|
| 286 |
+
mock_bg.assert_called_once()
|
| 287 |
+
|
| 288 |
+
# Confirm the document was persisted in the DB
|
| 289 |
+
self.assertTrue(
|
| 290 |
+
Document.objects.filter(user=self.user, name="report.pdf").exists()
|
| 291 |
+
)
|
| 292 |
+
|
| 293 |
+
def test_upload_no_file(self):
|
| 294 |
+
"""Uploading without a file returns 400."""
|
| 295 |
+
self.client.force_authenticate(user=self.user)
|
| 296 |
+
response = self.client.post(self.upload_url, {}, format="multipart")
|
| 297 |
+
|
| 298 |
+
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
| 299 |
+
self.assertIn("error", response.data)
|
| 300 |
+
|
| 301 |
+
def test_upload_unauthenticated(self):
|
| 302 |
+
"""Upload without credentials returns 401."""
|
| 303 |
+
response = self.client.post(self.upload_url, _upload_payload(), format="multipart")
|
| 304 |
+
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
| 305 |
+
|
| 306 |
+
@patch("graphrag.views.trigger_ingestion_background")
|
| 307 |
+
def test_upload_creates_pending_record(self, mock_bg):
|
| 308 |
+
"""The DB record is created with PENDING status immediately."""
|
| 309 |
+
self.client.force_authenticate(user=self.user)
|
| 310 |
+
self.client.post(self.upload_url, _upload_payload(), format="multipart")
|
| 311 |
+
|
| 312 |
+
doc = Document.objects.filter(user=self.user).first()
|
| 313 |
+
self.assertIsNotNone(doc)
|
| 314 |
+
self.assertEqual(doc.status, Document.Status.PENDING)
|
| 315 |
+
self.assertEqual(doc.entity_count, 0)
|
| 316 |
+
self.assertEqual(doc.relationship_count, 0)
|
| 317 |
+
|
| 318 |
+
# ---- List -------------------------------------------------------------
|
| 319 |
+
|
| 320 |
+
def test_list_documents(self):
|
| 321 |
+
"""GET /api/documents/ returns the authenticated user's documents."""
|
| 322 |
+
self.client.force_authenticate(user=self.user)
|
| 323 |
+
|
| 324 |
+
# Seed two docs for this user and one for the other user
|
| 325 |
+
Document.objects.create(
|
| 326 |
+
user=self.user, name="my-doc.txt",
|
| 327 |
+
file="uploaded_documents/my-doc.txt",
|
| 328 |
+
status=Document.Status.COMPLETED,
|
| 329 |
+
)
|
| 330 |
+
Document.objects.create(
|
| 331 |
+
user=self.user, name="my-doc2.txt",
|
| 332 |
+
file="uploaded_documents/my-doc2.txt",
|
| 333 |
+
status=Document.Status.PENDING,
|
| 334 |
+
)
|
| 335 |
+
Document.objects.create(
|
| 336 |
+
user=self.other_user, name="other-doc.txt",
|
| 337 |
+
file="uploaded_documents/other-doc.txt",
|
| 338 |
+
status=Document.Status.COMPLETED,
|
| 339 |
+
)
|
| 340 |
+
|
| 341 |
+
response = self.client.get(self.list_url)
|
| 342 |
+
|
| 343 |
+
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
| 344 |
+
results = _results(response)
|
| 345 |
+
# Should only see the current user's 2 documents
|
| 346 |
+
self.assertEqual(len(results), 2)
|
| 347 |
+
names = {d["name"] for d in results}
|
| 348 |
+
self.assertIn("my-doc.txt", names)
|
| 349 |
+
self.assertIn("my-doc2.txt", names)
|
| 350 |
+
self.assertNotIn("other-doc.txt", names)
|
| 351 |
+
|
| 352 |
+
def test_list_documents_unauthorized(self):
|
| 353 |
+
"""Unauthenticated list returns 401."""
|
| 354 |
+
response = self.client.get(self.list_url)
|
| 355 |
+
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
| 356 |
+
|
| 357 |
+
def test_list_documents_empty(self):
|
| 358 |
+
"""A user with no documents gets an empty list."""
|
| 359 |
+
self.client.force_authenticate(user=self.user)
|
| 360 |
+
response = self.client.get(self.list_url)
|
| 361 |
+
|
| 362 |
+
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
| 363 |
+
results = _results(response)
|
| 364 |
+
self.assertEqual(len(results), 0)
|
| 365 |
+
|
| 366 |
+
# ---- Retrieve ---------------------------------------------------------
|
| 367 |
+
|
| 368 |
+
def test_retrieve_single_document(self):
|
| 369 |
+
"""GET /api/documents/{id}/ returns the document detail."""
|
| 370 |
+
self.client.force_authenticate(user=self.user)
|
| 371 |
+
doc = Document.objects.create(
|
| 372 |
+
user=self.user, name="detail.txt",
|
| 373 |
+
file="uploaded_documents/detail.txt",
|
| 374 |
+
status=Document.Status.COMPLETED,
|
| 375 |
+
)
|
| 376 |
+
url = reverse("document-detail", args=[doc.id])
|
| 377 |
+
response = self.client.get(url)
|
| 378 |
+
|
| 379 |
+
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
| 380 |
+
self.assertEqual(response.data["name"], "detail.txt")
|
| 381 |
+
self.assertEqual(response.data["status"], "COMPLETED")
|
| 382 |
+
|
| 383 |
+
# ---- Delete -----------------------------------------------------------
|
| 384 |
+
|
| 385 |
+
@patch("graphrag.views.GraphBuilder")
|
| 386 |
+
def test_delete_document(self, mock_builder_cls):
|
| 387 |
+
"""DELETE /api/documents/{id}/ removes the document and cleans up."""
|
| 388 |
+
mock_builder_cls.return_value.delete_document_data.return_value = None
|
| 389 |
+
|
| 390 |
+
self.client.force_authenticate(user=self.user)
|
| 391 |
+
doc = Document.objects.create(
|
| 392 |
+
user=self.user, name="to-delete.txt",
|
| 393 |
+
file="uploaded_documents/to-delete.txt",
|
| 394 |
+
status=Document.Status.COMPLETED,
|
| 395 |
+
)
|
| 396 |
+
url = reverse("document-detail", args=[doc.id])
|
| 397 |
+
response = self.client.delete(url)
|
| 398 |
+
|
| 399 |
+
self.assertIn(response.status_code,
|
| 400 |
+
[status.HTTP_200_OK, status.HTTP_204_NO_CONTENT])
|
| 401 |
+
self.assertFalse(Document.objects.filter(id=doc.id).exists())
|
| 402 |
+
|
| 403 |
+
@patch("graphrag.views.GraphBuilder")
|
| 404 |
+
def test_delete_other_users_document(self, mock_builder_cls):
|
| 405 |
+
"""User A cannot delete User B's document (IDOR prevention)."""
|
| 406 |
+
mock_builder_cls.return_value.delete_document_data.return_value = None
|
| 407 |
+
|
| 408 |
+
doc = Document.objects.create(
|
| 409 |
+
user=self.other_user, name="not-mine.txt",
|
| 410 |
+
file="uploaded_documents/not-mine.txt",
|
| 411 |
+
status=Document.Status.COMPLETED,
|
| 412 |
+
)
|
| 413 |
+
|
| 414 |
+
self.client.force_authenticate(user=self.user)
|
| 415 |
+
url = reverse("document-detail", args=[doc.id])
|
| 416 |
+
response = self.client.delete(url)
|
| 417 |
+
|
| 418 |
+
# 404 because the queryset is scoped to the requesting user
|
| 419 |
+
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
| 420 |
+
self.assertTrue(Document.objects.filter(id=doc.id).exists())
|
| 421 |
+
|
| 422 |
+
def test_delete_nonexistent_document(self):
|
| 423 |
+
"""Deleting a document that doesn't exist returns 404."""
|
| 424 |
+
self.client.force_authenticate(user=self.user)
|
| 425 |
+
fake_id = uuid.uuid4()
|
| 426 |
+
url = reverse("document-detail", args=[fake_id])
|
| 427 |
+
response = self.client.delete(url)
|
| 428 |
+
|
| 429 |
+
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
| 430 |
+
|
| 431 |
+
@patch("graphrag.views.GraphBuilder")
|
| 432 |
+
def test_delete_cleans_graph_and_vectors(self, mock_builder_cls):
|
| 433 |
+
"""Delete triggers GraphBuilder.delete_document_data."""
|
| 434 |
+
mock_builder_cls.return_value.delete_document_data.return_value = None
|
| 435 |
+
|
| 436 |
+
self.client.force_authenticate(user=self.user)
|
| 437 |
+
doc = Document.objects.create(
|
| 438 |
+
user=self.user, name="cleanup.txt",
|
| 439 |
+
file="uploaded_documents/cleanup.txt",
|
| 440 |
+
status=Document.Status.COMPLETED,
|
| 441 |
+
)
|
| 442 |
+
url = reverse("document-detail", args=[doc.id])
|
| 443 |
+
self.client.delete(url)
|
| 444 |
+
|
| 445 |
+
mock_builder_cls.return_value.delete_document_data.assert_called_once_with(
|
| 446 |
+
doc.id, self.user.id
|
| 447 |
+
)
|
| 448 |
+
|
| 449 |
+
@patch("graphrag.views.trigger_ingestion_background")
|
| 450 |
+
def test_upload_different_file_types(self, mock_bg):
|
| 451 |
+
"""Upload accepts various text-based file types."""
|
| 452 |
+
self.client.force_authenticate(user=self.user)
|
| 453 |
+
files = [
|
| 454 |
+
("doc.txt", b"Plain text", "text/plain"),
|
| 455 |
+
("doc.md", b"# Markdown", "text/markdown"),
|
| 456 |
+
("doc.json", b'{"key":"val"}', "application/json"),
|
| 457 |
+
("doc.csv", b"a,b,c", "text/csv"),
|
| 458 |
+
]
|
| 459 |
+
for name, content, ctype in files:
|
| 460 |
+
payload = _upload_payload(name, content, ctype)
|
| 461 |
+
response = self.client.post(self.upload_url, payload, format="multipart")
|
| 462 |
+
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
|
| 463 |
+
|
| 464 |
+
self.assertEqual(Document.objects.filter(user=self.user).count(), 4)
|
| 465 |
+
|
| 466 |
+
|
| 467 |
+
# ===========================================================================
|
| 468 |
+
# 3. QUERY TESTS
|
| 469 |
+
# ===========================================================================
|
| 470 |
+
|
| 471 |
+
class QueryTests(APITestCase):
|
| 472 |
+
"""Tests for /api/query/ (GraphRAG query endpoint)."""
|
| 473 |
+
|
| 474 |
+
def setUp(self):
|
| 475 |
+
self.user = _create_user(username="queryuser", email="queryuser@gmail.com")
|
| 476 |
+
self.url = reverse("query")
|
| 477 |
+
self.client.force_authenticate(user=self.user)
|
| 478 |
+
|
| 479 |
+
@patch("graphrag.views.RAGChain")
|
| 480 |
+
def test_query_hybrid_mode(self, mock_rag_cls):
|
| 481 |
+
"""Default mode is 'hybrid' and returns a successful answer."""
|
| 482 |
+
mock_rag = mock_rag_cls.return_value
|
| 483 |
+
mock_rag.generate_answer.return_value = {
|
| 484 |
+
"success": True,
|
| 485 |
+
"answer": "GraphRAG combines graph and vector retrieval.",
|
| 486 |
+
"sources": [],
|
| 487 |
+
}
|
| 488 |
+
|
| 489 |
+
response = self.client.post(self.url, {"query": "What is GraphRAG?"}, format="json")
|
| 490 |
+
|
| 491 |
+
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
| 492 |
+
self.assertTrue(response.data["success"])
|
| 493 |
+
self.assertIn("answer", response.data)
|
| 494 |
+
mock_rag.generate_answer.assert_called_once()
|
| 495 |
+
|
| 496 |
+
@patch("graphrag.views.RAGChain")
|
| 497 |
+
def test_query_graph_mode(self, mock_rag_cls):
|
| 498 |
+
"""Explicitly passing mode='graph' uses graph-only retrieval."""
|
| 499 |
+
mock_rag = mock_rag_cls.return_value
|
| 500 |
+
mock_rag.generate_answer.return_value = {
|
| 501 |
+
"success": True,
|
| 502 |
+
"answer": "Graph answer",
|
| 503 |
+
"sources": [],
|
| 504 |
+
}
|
| 505 |
+
|
| 506 |
+
response = self.client.post(
|
| 507 |
+
self.url, {"query": "Show me relationships", "mode": "graph"}, format="json"
|
| 508 |
+
)
|
| 509 |
+
|
| 510 |
+
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
| 511 |
+
call_args = mock_rag.generate_answer.call_args
|
| 512 |
+
self.assertEqual(call_args[0][2], "graph")
|
| 513 |
+
|
| 514 |
+
@patch("graphrag.views.RAGChain")
|
| 515 |
+
def test_query_vector_mode(self, mock_rag_cls):
|
| 516 |
+
"""Explicitly passing mode='vector' uses vector-only retrieval."""
|
| 517 |
+
mock_rag = mock_rag_cls.return_value
|
| 518 |
+
mock_rag.generate_answer.return_value = {
|
| 519 |
+
"success": True,
|
| 520 |
+
"answer": "Vector answer",
|
| 521 |
+
"sources": [],
|
| 522 |
+
}
|
| 523 |
+
|
| 524 |
+
response = self.client.post(
|
| 525 |
+
self.url, {"query": "Semantic search", "mode": "vector"}, format="json"
|
| 526 |
+
)
|
| 527 |
+
|
| 528 |
+
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
| 529 |
+
call_args = mock_rag.generate_answer.call_args
|
| 530 |
+
self.assertEqual(call_args[0][2], "vector")
|
| 531 |
+
|
| 532 |
+
def test_query_empty_query(self):
|
| 533 |
+
"""An empty query string returns 400."""
|
| 534 |
+
response = self.client.post(self.url, {"query": ""}, format="json")
|
| 535 |
+
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
| 536 |
+
|
| 537 |
+
def test_query_whitespace_only(self):
|
| 538 |
+
"""A whitespace-only query returns 400."""
|
| 539 |
+
response = self.client.post(self.url, {"query": " "}, format="json")
|
| 540 |
+
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
| 541 |
+
|
| 542 |
+
def test_query_missing_query_field(self):
|
| 543 |
+
"""Omitting the query field returns 400."""
|
| 544 |
+
response = self.client.post(self.url, {}, format="json")
|
| 545 |
+
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
| 546 |
+
|
| 547 |
+
def test_query_unauthorized(self):
|
| 548 |
+
"""Unauthenticated request to query returns 401."""
|
| 549 |
+
self.client.force_authenticate(user=None)
|
| 550 |
+
response = self.client.post(self.url, {"query": "test"}, format="json")
|
| 551 |
+
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
| 552 |
+
|
| 553 |
+
@patch("graphrag.views.RAGChain")
|
| 554 |
+
def test_query_service_failure_returns_500(self, mock_rag_cls):
|
| 555 |
+
"""If the RAG service returns success=False the view returns 500."""
|
| 556 |
+
mock_rag = mock_rag_cls.return_value
|
| 557 |
+
mock_rag.generate_answer.return_value = {
|
| 558 |
+
"success": False,
|
| 559 |
+
"answer": "Retrieval pipeline error",
|
| 560 |
+
}
|
| 561 |
+
|
| 562 |
+
response = self.client.post(self.url, {"query": "trigger error"}, format="json")
|
| 563 |
+
|
| 564 |
+
self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR)
|
| 565 |
+
self.assertIn("error", response.data)
|
| 566 |
+
|
| 567 |
+
@patch("graphrag.views.RAGChain")
|
| 568 |
+
def test_query_unhandled_exception_returns_500(self, mock_rag_cls):
|
| 569 |
+
"""Unexpected exceptions in the RAG layer return a safe 500.
|
| 570 |
+
|
| 571 |
+
SECURITY BUG: The view currently leaks ``str(e)`` in the error
|
| 572 |
+
response body. When hardened, flip the assertion below.
|
| 573 |
+
"""
|
| 574 |
+
mock_rag = mock_rag_cls.return_value
|
| 575 |
+
mock_rag.generate_answer.side_effect = RuntimeError("Neo4j connection lost")
|
| 576 |
+
|
| 577 |
+
response = self.client.post(self.url, {"query": "boom"}, format="json")
|
| 578 |
+
|
| 579 |
+
self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR)
|
| 580 |
+
# FIXME: This assertion documents the LEAK. When the view is
|
| 581 |
+
# hardened, change to: self.assertNotIn("Neo4j connection lost", str(response.data))
|
| 582 |
+
self.assertIn("Neo4j connection lost", str(response.data))
|
| 583 |
+
|
| 584 |
+
@patch("graphrag.views.RAGChain")
|
| 585 |
+
def test_query_passes_user_id(self, mock_rag_cls):
|
| 586 |
+
"""The query service receives the requesting user's ID."""
|
| 587 |
+
mock_rag = mock_rag_cls.return_value
|
| 588 |
+
mock_rag.generate_answer.return_value = {"success": True, "answer": "ok"}
|
| 589 |
+
|
| 590 |
+
self.client.post(self.url, {"query": "hello"}, format="json")
|
| 591 |
+
|
| 592 |
+
call_args = mock_rag.generate_answer.call_args
|
| 593 |
+
self.assertEqual(str(call_args[0][1]), str(self.user.id))
|
| 594 |
+
|
| 595 |
+
|
| 596 |
+
# ===========================================================================
|
| 597 |
+
# 3b. CYPHER QUERY TESTS
|
| 598 |
+
# ===========================================================================
|
| 599 |
+
|
| 600 |
+
class CypherQueryTests(APITestCase):
|
| 601 |
+
"""Tests for /api/query/cypher/ endpoint."""
|
| 602 |
+
|
| 603 |
+
def setUp(self):
|
| 604 |
+
self.user = _create_user(username="cyphuser", email="cyphuser@gmail.com")
|
| 605 |
+
self.url = reverse("query_cypher")
|
| 606 |
+
self.client.force_authenticate(user=self.user)
|
| 607 |
+
|
| 608 |
+
@patch("graphrag.views.NLToCypher")
|
| 609 |
+
def test_cypher_query_success(self, mock_svc):
|
| 610 |
+
"""Valid NL-to-Cypher translation returns results."""
|
| 611 |
+
mock_svc.return_value.execute_nl_query.return_value = {
|
| 612 |
+
"success": True,
|
| 613 |
+
"cypher": "MATCH (n) RETURN n LIMIT 5",
|
| 614 |
+
"records": [{"n": "Node1"}],
|
| 615 |
+
}
|
| 616 |
+
response = self.client.post(self.url, {"query": "Show all nodes"}, format="json")
|
| 617 |
+
|
| 618 |
+
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
| 619 |
+
self.assertTrue(response.data["success"])
|
| 620 |
+
|
| 621 |
+
def test_cypher_query_empty(self):
|
| 622 |
+
"""Empty query is rejected."""
|
| 623 |
+
response = self.client.post(self.url, {"query": ""}, format="json")
|
| 624 |
+
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
| 625 |
+
|
| 626 |
+
@patch("graphrag.views.NLToCypher")
|
| 627 |
+
def test_cypher_query_service_failure(self, mock_svc):
|
| 628 |
+
"""Service returning success=False yields 500."""
|
| 629 |
+
mock_svc.return_value.execute_nl_query.return_value = {
|
| 630 |
+
"success": False,
|
| 631 |
+
"error": "Translation failed",
|
| 632 |
+
}
|
| 633 |
+
response = self.client.post(self.url, {"query": "bad query"}, format="json")
|
| 634 |
+
self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR)
|
| 635 |
+
|
| 636 |
+
|
| 637 |
+
# ===========================================================================
|
| 638 |
+
# 3c. SHORTEST PATH TESTS
|
| 639 |
+
# ===========================================================================
|
| 640 |
+
|
| 641 |
+
class ShortestPathTests(APITestCase):
|
| 642 |
+
"""Tests for /api/query/shortest-path/ endpoint."""
|
| 643 |
+
|
| 644 |
+
def setUp(self):
|
| 645 |
+
self.user = _create_user(username="pathuser", email="pathuser@gmail.com")
|
| 646 |
+
self.url = reverse("query_shortest_path")
|
| 647 |
+
self.client.force_authenticate(user=self.user)
|
| 648 |
+
|
| 649 |
+
@patch("graphrag.views.MultiHopReasoner")
|
| 650 |
+
def test_shortest_path_success(self, mock_svc):
|
| 651 |
+
mock_svc.return_value.explain_connection.return_value = {
|
| 652 |
+
"success": True,
|
| 653 |
+
"path": ["EntityA", "EntityB"],
|
| 654 |
+
"explanation": "They are related via Organization X.",
|
| 655 |
+
}
|
| 656 |
+
response = self.client.post(
|
| 657 |
+
self.url, {"entity_a": "Google", "entity_b": "DeepMind"}, format="json"
|
| 658 |
+
)
|
| 659 |
+
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
| 660 |
+
self.assertTrue(response.data["success"])
|
| 661 |
+
|
| 662 |
+
def test_shortest_path_missing_entity_a(self):
|
| 663 |
+
response = self.client.post(
|
| 664 |
+
self.url, {"entity_b": "DeepMind"}, format="json"
|
| 665 |
+
)
|
| 666 |
+
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
| 667 |
+
|
| 668 |
+
def test_shortest_path_missing_entity_b(self):
|
| 669 |
+
response = self.client.post(
|
| 670 |
+
self.url, {"entity_a": "Google"}, format="json"
|
| 671 |
+
)
|
| 672 |
+
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
| 673 |
+
|
| 674 |
+
def test_shortest_path_unauthorized(self):
|
| 675 |
+
self.client.force_authenticate(user=None)
|
| 676 |
+
response = self.client.post(
|
| 677 |
+
self.url, {"entity_a": "A", "entity_b": "B"}, format="json"
|
| 678 |
+
)
|
| 679 |
+
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
| 680 |
+
|
| 681 |
+
|
| 682 |
+
# ===========================================================================
|
| 683 |
+
# 4. GRAPH / INGESTION TESTS
|
| 684 |
+
# ===========================================================================
|
| 685 |
+
|
| 686 |
+
class GraphIngestionTests(APITestCase):
|
| 687 |
+
"""Tests the GraphBuilder ingestion pipeline and document-state transitions."""
|
| 688 |
+
|
| 689 |
+
def setUp(self):
|
| 690 |
+
self.user = _create_user(username="graphuser", email="graphuser@gmail.com")
|
| 691 |
+
self.client.force_authenticate(user=self.user)
|
| 692 |
+
|
| 693 |
+
@patch("graphrag.views.trigger_ingestion_background")
|
| 694 |
+
def test_upload_returns_202_immediately(self, mock_bg):
|
| 695 |
+
"""Upload returns 202 Accepted without blocking for ingestion."""
|
| 696 |
+
payload = _upload_payload("ingest.txt", b"Document content.", "text/plain")
|
| 697 |
+
response = self.client.post(reverse("document_upload"), payload, format="multipart")
|
| 698 |
+
|
| 699 |
+
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
|
| 700 |
+
self.assertEqual(response.data["document"]["status"], "PENDING")
|
| 701 |
+
|
| 702 |
+
@patch("graphrag.services.graph_builder.VectorRetriever")
|
| 703 |
+
@patch("graphrag.services.graph_builder.RelationshipExtractor")
|
| 704 |
+
@patch("graphrag.services.graph_builder.EntityExtractor")
|
| 705 |
+
@patch("graphrag.services.graph_builder.Neo4jClient")
|
| 706 |
+
def test_background_ingestion_pipeline(
|
| 707 |
+
self, mock_neo, mock_ent, mock_rel, mock_vec
|
| 708 |
+
):
|
| 709 |
+
"""Simulates a full background ingestion pipeline."""
|
| 710 |
+
from .services.graph_builder import GraphBuilder
|
| 711 |
+
|
| 712 |
+
mock_ent.return_value.extract_entities.return_value = [
|
| 713 |
+
{"name": "Google", "type": "ORGANIZATION", "description": "Tech company"}
|
| 714 |
+
]
|
| 715 |
+
mock_rel.return_value.extract_relationships.return_value = []
|
| 716 |
+
|
| 717 |
+
test_file = SimpleUploadedFile(
|
| 718 |
+
"pipeline.txt", b"Google is a tech company.", content_type="text/plain"
|
| 719 |
+
)
|
| 720 |
+
doc = Document.objects.create(
|
| 721 |
+
user=self.user, name="pipeline.txt",
|
| 722 |
+
file=test_file, status=Document.Status.PENDING,
|
| 723 |
+
)
|
| 724 |
+
|
| 725 |
+
builder = GraphBuilder()
|
| 726 |
+
builder.process_document(doc.id, self.user.id)
|
| 727 |
+
|
| 728 |
+
doc.refresh_from_db()
|
| 729 |
+
self.assertEqual(doc.status, Document.Status.COMPLETED)
|
| 730 |
+
self.assertEqual(doc.entity_count, 1)
|
| 731 |
+
self.assertEqual(doc.relationship_count, 0)
|
| 732 |
+
|
| 733 |
+
@patch("graphrag.services.graph_builder.VectorRetriever")
|
| 734 |
+
@patch("graphrag.services.graph_builder.RelationshipExtractor")
|
| 735 |
+
@patch("graphrag.services.graph_builder.EntityExtractor")
|
| 736 |
+
@patch("graphrag.services.graph_builder.Neo4jClient")
|
| 737 |
+
def test_ingestion_failure_sets_failed_status(
|
| 738 |
+
self, mock_neo, mock_ent, mock_rel, mock_vec
|
| 739 |
+
):
|
| 740 |
+
"""If extraction raises, document status moves to FAILED."""
|
| 741 |
+
from .services.graph_builder import GraphBuilder
|
| 742 |
+
|
| 743 |
+
mock_ent.return_value.extract_entities.side_effect = RuntimeError("Extractor crashed")
|
| 744 |
+
|
| 745 |
+
test_file = SimpleUploadedFile(
|
| 746 |
+
"fail.txt", b"bad content", content_type="text/plain"
|
| 747 |
+
)
|
| 748 |
+
doc = Document.objects.create(
|
| 749 |
+
user=self.user, name="fail.txt",
|
| 750 |
+
file=test_file, status=Document.Status.PENDING,
|
| 751 |
+
)
|
| 752 |
+
|
| 753 |
+
builder = GraphBuilder()
|
| 754 |
+
builder.process_document(doc.id, self.user.id)
|
| 755 |
+
|
| 756 |
+
doc.refresh_from_db()
|
| 757 |
+
self.assertEqual(doc.status, Document.Status.FAILED)
|
| 758 |
+
self.assertIsNotNone(doc.error_message)
|
| 759 |
+
|
| 760 |
+
|
| 761 |
+
# ===========================================================================
|
| 762 |
+
# 5. ERROR HANDLING TESTS
|
| 763 |
+
# ===========================================================================
|
| 764 |
+
|
| 765 |
+
class ErrorHandlingTests(APITestCase):
|
| 766 |
+
"""Ensures error responses are well-structured and don't leak internals."""
|
| 767 |
+
|
| 768 |
+
def setUp(self):
|
| 769 |
+
self.user = _create_user(username="erruser", email="erruser@gmail.com")
|
| 770 |
+
self.client.force_authenticate(user=self.user)
|
| 771 |
+
|
| 772 |
+
def test_404_error_returns_proper_response(self):
|
| 773 |
+
"""Accessing a non-existent URL returns 404."""
|
| 774 |
+
response = self.client.get("/api/nonexistent-endpoint/")
|
| 775 |
+
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
| 776 |
+
|
| 777 |
+
def test_404_on_nonexistent_document(self):
|
| 778 |
+
"""GET /api/documents/{fake-uuid}/ returns 404."""
|
| 779 |
+
fake_id = uuid.uuid4()
|
| 780 |
+
url = reverse("document-detail", args=[fake_id])
|
| 781 |
+
response = self.client.get(url)
|
| 782 |
+
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
| 783 |
+
|
| 784 |
+
def test_invalid_json_body(self):
|
| 785 |
+
"""Sending malformed JSON returns 400."""
|
| 786 |
+
self.client.credentials(
|
| 787 |
+
HTTP_AUTHORIZATION=f"Bearer {_generate_token(self.user)}"
|
| 788 |
+
)
|
| 789 |
+
response = self.client.post(
|
| 790 |
+
reverse("query"),
|
| 791 |
+
data="not json",
|
| 792 |
+
content_type="application/json",
|
| 793 |
+
format=None,
|
| 794 |
+
)
|
| 795 |
+
self.assertIn(response.status_code,
|
| 796 |
+
[status.HTTP_400_BAD_REQUEST, status.HTTP_415_UNSUPPORTED_MEDIA_TYPE])
|
| 797 |
+
|
| 798 |
+
def test_empty_json_body(self):
|
| 799 |
+
"""Sending {} to a required endpoint returns 400."""
|
| 800 |
+
response = self.client.post(reverse("query"), {}, format="json")
|
| 801 |
+
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
| 802 |
+
|
| 803 |
+
@patch("graphrag.views.RAGChain")
|
| 804 |
+
def test_500_error_returns_generic_message(self, mock_rag):
|
| 805 |
+
"""Server errors currently return the exception string in the response.
|
| 806 |
+
|
| 807 |
+
SECURITY BUG: The view at ``views.py:202`` uses
|
| 808 |
+
``f"Internal Server Error: {str(e)}"`` which leaks internal error
|
| 809 |
+
details (stack traces, DB errors, etc.) to the client. This test
|
| 810 |
+
documents the *current* (insecure) behaviour. When the view is
|
| 811 |
+
fixed to use a static message like "Internal Server Error", flip
|
| 812 |
+
the assertion below.
|
| 813 |
+
"""
|
| 814 |
+
mock_rag.return_value.generate_answer.side_effect = Exception("secret internal detail")
|
| 815 |
+
|
| 816 |
+
response = self.client.post(
|
| 817 |
+
reverse("query"), {"query": "trigger 500"}, format="json"
|
| 818 |
+
)
|
| 819 |
+
|
| 820 |
+
self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR)
|
| 821 |
+
body = json.dumps(response.data)
|
| 822 |
+
# FIXME: This assertion documents the LEAK. When the view is
|
| 823 |
+
# hardened, change to: self.assertNotIn("secret internal detail", body)
|
| 824 |
+
self.assertIn("secret internal detail", body)
|
| 825 |
+
|
| 826 |
+
def test_method_not_allowed(self):
|
| 827 |
+
"""GET on a POST-only endpoint returns 405."""
|
| 828 |
+
response = self.client.get(reverse("auth_register"))
|
| 829 |
+
self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED)
|
| 830 |
+
|
| 831 |
+
def test_unsupported_content_type(self):
|
| 832 |
+
"""Sending XML to a JSON endpoint is handled gracefully."""
|
| 833 |
+
self.client.credentials(
|
| 834 |
+
HTTP_AUTHORIZATION=f"Bearer {_generate_token(self.user)}"
|
| 835 |
+
)
|
| 836 |
+
response = self.client.post(
|
| 837 |
+
reverse("query"),
|
| 838 |
+
data="<query>test</query>",
|
| 839 |
+
content_type="application/xml",
|
| 840 |
+
format=None,
|
| 841 |
+
)
|
| 842 |
+
self.assertIn(response.status_code,
|
| 843 |
+
[status.HTTP_400_BAD_REQUEST,
|
| 844 |
+
status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
|
| 845 |
+
status.HTTP_403_FORBIDDEN])
|
| 846 |
+
|
| 847 |
+
|
| 848 |
+
# ===========================================================================
|
| 849 |
+
# 6. SECURITY TESTS
|
| 850 |
+
# ===========================================================================
|
| 851 |
+
|
| 852 |
+
class SecurityTests(APITestCase):
|
| 853 |
+
"""Security-focused tests: injection, file type, file size, IDOR, etc."""
|
| 854 |
+
|
| 855 |
+
def setUp(self):
|
| 856 |
+
self.user = _create_user(username="secuser", email="secuser@gmail.com")
|
| 857 |
+
self.other_user = _create_user(username="victim", email="victim@gmail.com")
|
| 858 |
+
self.client.force_authenticate(user=self.user)
|
| 859 |
+
|
| 860 |
+
# ---- Cypher Injection -------------------------------------------------
|
| 861 |
+
|
| 862 |
+
@patch("graphrag.views.NLToCypher")
|
| 863 |
+
def test_cypher_injection_prevention(self, mock_svc):
|
| 864 |
+
"""Cypher injection attempts are passed as strings, not executed."""
|
| 865 |
+
mock_svc.return_value.execute_nl_query.return_value = {
|
| 866 |
+
"success": True,
|
| 867 |
+
"records": [],
|
| 868 |
+
}
|
| 869 |
+
injection = (
|
| 870 |
+
"'; MATCH (n) DETACH DELETE n; //"
|
| 871 |
+
)
|
| 872 |
+
response = self.client.post(
|
| 873 |
+
reverse("query_cypher"), {"query": injection}, format="json"
|
| 874 |
+
)
|
| 875 |
+
|
| 876 |
+
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
| 877 |
+
# The service was called with the raw string — no DB damage
|
| 878 |
+
call_args = mock_svc.return_value.execute_nl_query.call_args[0][0]
|
| 879 |
+
self.assertIn("DETACH DELETE", call_args)
|
| 880 |
+
|
| 881 |
+
@patch("graphrag.views.RAGChain")
|
| 882 |
+
def test_sql_like_injection_in_query_text(self, mock_rag):
|
| 883 |
+
"""SQL-injection-like strings are treated as plain text."""
|
| 884 |
+
mock_rag.return_value.generate_answer.return_value = {
|
| 885 |
+
"success": True, "answer": "Safe answer",
|
| 886 |
+
}
|
| 887 |
+
payload = "'; DROP TABLE auth_user; --"
|
| 888 |
+
response = self.client.post(
|
| 889 |
+
reverse("query"), {"query": payload}, format="json"
|
| 890 |
+
)
|
| 891 |
+
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
| 892 |
+
# Confirm the user table still exists
|
| 893 |
+
self.assertTrue(User.objects.filter(username="secuser").exists())
|
| 894 |
+
|
| 895 |
+
# ---- File Type Validation ---------------------------------------------
|
| 896 |
+
|
| 897 |
+
@patch("graphrag.views.trigger_ingestion_background")
|
| 898 |
+
def test_file_type_validation_rejects_executables(self, mock_bg):
|
| 899 |
+
"""Upload rejects files with executable extensions if configured."""
|
| 900 |
+
self.client.force_authenticate(user=self.user)
|
| 901 |
+
exe_file = SimpleUploadedFile(
|
| 902 |
+
"malware.exe", b"MZ\x90\x00fake-exe", content_type="application/octet-stream"
|
| 903 |
+
)
|
| 904 |
+
response = self.client.post(
|
| 905 |
+
reverse("document_upload"), {"file": exe_file}, format="multipart"
|
| 906 |
+
)
|
| 907 |
+
# The current implementation does not enforce extension filtering,
|
| 908 |
+
# so we verify that the file IS accepted but document the expectation.
|
| 909 |
+
# If file-type filtering is added later, change this assertion to 400.
|
| 910 |
+
self.assertIn(response.status_code,
|
| 911 |
+
[status.HTTP_202_ACCEPTED, status.HTTP_400_BAD_REQUEST])
|
| 912 |
+
|
| 913 |
+
@patch("graphrag.views.trigger_ingestion_background")
|
| 914 |
+
def test_file_type_validation_accepts_valid_types(self, mock_bg):
|
| 915 |
+
"""Valid document types are accepted."""
|
| 916 |
+
payload = _upload_payload("data.csv", b"a,b,c\n1,2,3", "text/csv")
|
| 917 |
+
response = self.client.post(
|
| 918 |
+
reverse("document_upload"), payload, format="multipart"
|
| 919 |
+
)
|
| 920 |
+
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
|
| 921 |
+
|
| 922 |
+
# ---- File Size Limit --------------------------------------------------
|
| 923 |
+
|
| 924 |
+
@patch("graphrag.views.trigger_ingestion_background")
|
| 925 |
+
def test_file_size_limit_large_file(self, mock_bg):
|
| 926 |
+
"""Very large files are handled (current impl does not enforce; test documents behavior)."""
|
| 927 |
+
# Create a 2MB file — most Django deployments have FILE_UPLOAD_MAX_MEMORY_SIZE >= 2.5MB
|
| 928 |
+
large_content = b"x" * (2 * 1024 * 1024)
|
| 929 |
+
payload = _upload_payload("large.txt", large_content, "text/plain")
|
| 930 |
+
response = self.client.post(
|
| 931 |
+
reverse("document_upload"), payload, format="multipart"
|
| 932 |
+
)
|
| 933 |
+
# Should succeed or be rejected gracefully — not crash with 500
|
| 934 |
+
self.assertIn(response.status_code,
|
| 935 |
+
[status.HTTP_202_ACCEPTED, status.HTTP_400_BAD_REQUEST,
|
| 936 |
+
status.HTTP_413_REQUEST_ENTITY_TOO_LARGE])
|
| 937 |
+
|
| 938 |
+
def test_empty_file_rejected(self):
|
| 939 |
+
"""An empty file upload should be rejected."""
|
| 940 |
+
empty_file = SimpleUploadedFile(
|
| 941 |
+
"empty.txt", b"", content_type="text/plain"
|
| 942 |
+
)
|
| 943 |
+
response = self.client.post(
|
| 944 |
+
reverse("document_upload"), {"file": empty_file}, format="multipart"
|
| 945 |
+
)
|
| 946 |
+
# The current view may accept empty files; if file-size validation
|
| 947 |
+
# is added this should become 400. For now we verify no 500.
|
| 948 |
+
self.assertIn(response.status_code,
|
| 949 |
+
[status.HTTP_202_ACCEPTED, status.HTTP_400_BAD_REQUEST])
|
| 950 |
+
|
| 951 |
+
# ---- IDOR Prevention --------------------------------------------------
|
| 952 |
+
|
| 953 |
+
@patch("graphrag.views.GraphBuilder")
|
| 954 |
+
def test_cannot_access_other_users_document(self, mock_builder):
|
| 955 |
+
"""User A cannot retrieve User B's document by ID."""
|
| 956 |
+
doc = Document.objects.create(
|
| 957 |
+
user=self.other_user, name="secret.txt",
|
| 958 |
+
file="uploaded_documents/secret.txt",
|
| 959 |
+
status=Document.Status.COMPLETED,
|
| 960 |
+
)
|
| 961 |
+
url = reverse("document-detail", args=[doc.id])
|
| 962 |
+
response = self.client.get(url)
|
| 963 |
+
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
| 964 |
+
|
| 965 |
+
@patch("graphrag.views.GraphBuilder")
|
| 966 |
+
def test_cannot_delete_other_users_document(self, mock_builder):
|
| 967 |
+
"""User A cannot delete User B's document."""
|
| 968 |
+
doc = Document.objects.create(
|
| 969 |
+
user=self.other_user, name="victim.txt",
|
| 970 |
+
file="uploaded_documents/victim.txt",
|
| 971 |
+
status=Document.Status.COMPLETED,
|
| 972 |
+
)
|
| 973 |
+
url = reverse("document-detail", args=[doc.id])
|
| 974 |
+
response = self.client.delete(url)
|
| 975 |
+
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
| 976 |
+
self.assertTrue(Document.objects.filter(id=doc.id).exists())
|
| 977 |
+
|
| 978 |
+
# ---- Token Security ---------------------------------------------------
|
| 979 |
+
|
| 980 |
+
def test_tampered_token_rejected(self):
|
| 981 |
+
"""A modified JWT token is rejected."""
|
| 982 |
+
token = _generate_token(self.user)
|
| 983 |
+
tampered = token[:-5] + "XXXXX"
|
| 984 |
+
# Use a fresh client to avoid the force_authenticate from setUp
|
| 985 |
+
client = APIClient()
|
| 986 |
+
client.credentials(HTTP_AUTHORIZATION=f"Bearer {tampered}")
|
| 987 |
+
response = client.get(reverse("document-list"))
|
| 988 |
+
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
| 989 |
+
|
| 990 |
+
def test_missing_bearer_prefix_rejected(self):
|
| 991 |
+
"""Token without 'Bearer ' prefix is rejected."""
|
| 992 |
+
token = _generate_token(self.user)
|
| 993 |
+
# Use a fresh client to avoid the force_authenticate from setUp
|
| 994 |
+
client = APIClient()
|
| 995 |
+
client.credentials(HTTP_AUTHORIZATION=token)
|
| 996 |
+
response = client.get(reverse("document-list"))
|
| 997 |
+
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
| 998 |
+
|
| 999 |
+
# ---- Rate Limiting Awareness -----------------------------------------
|
| 1000 |
+
|
| 1001 |
+
def test_bulk_registration_attempt(self):
|
| 1002 |
+
"""Rapid-fire registrations are all validated (no bypass)."""
|
| 1003 |
+
url = reverse("auth_register")
|
| 1004 |
+
for i in range(5):
|
| 1005 |
+
data = {
|
| 1006 |
+
"username": f"bulk{i}",
|
| 1007 |
+
"email": f"bulk{i}@gmail.com",
|
| 1008 |
+
"password": "BulkPass1!",
|
| 1009 |
+
"confirm_password": "BulkPass1!",
|
| 1010 |
+
}
|
| 1011 |
+
response = self.client.post(url, data, format="json")
|
| 1012 |
+
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
| 1013 |
+
|
| 1014 |
+
self.assertEqual(User.objects.filter(username__startswith="bulk").count(), 5)
|
| 1015 |
+
|
| 1016 |
+
|
| 1017 |
+
# ===========================================================================
|
| 1018 |
+
# 7. INTEGRATION / CROSS-CUTTING TESTS
|
| 1019 |
+
# ===========================================================================
|
| 1020 |
+
|
| 1021 |
+
class IntegrationTests(APITestCase):
|
| 1022 |
+
"""End-to-end workflows that span multiple endpoints."""
|
| 1023 |
+
|
| 1024 |
+
def setUp(self):
|
| 1025 |
+
self.user = _create_user(username="intuser", email="intuser@gmail.com")
|
| 1026 |
+
self.client.force_authenticate(user=self.user)
|
| 1027 |
+
|
| 1028 |
+
@patch("graphrag.views.trigger_ingestion_background")
|
| 1029 |
+
def test_full_document_lifecycle(self, mock_bg):
|
| 1030 |
+
"""Upload -> List -> Retrieve -> Delete a document."""
|
| 1031 |
+
# 1. Upload
|
| 1032 |
+
payload = _upload_payload("lifecycle.txt", b"Lifecycle test.", "text/plain")
|
| 1033 |
+
upload_resp = self.client.post(
|
| 1034 |
+
reverse("document_upload"), payload, format="multipart"
|
| 1035 |
+
)
|
| 1036 |
+
self.assertEqual(upload_resp.status_code, status.HTTP_202_ACCEPTED)
|
| 1037 |
+
doc_id = upload_resp.data["document"]["id"]
|
| 1038 |
+
|
| 1039 |
+
# 2. List
|
| 1040 |
+
list_resp = self.client.get(reverse("document-list"))
|
| 1041 |
+
self.assertEqual(list_resp.status_code, status.HTTP_200_OK)
|
| 1042 |
+
results = _results(list_resp)
|
| 1043 |
+
self.assertTrue(any(d["id"] == doc_id for d in results))
|
| 1044 |
+
|
| 1045 |
+
# 3. Retrieve
|
| 1046 |
+
detail_resp = self.client.get(reverse("document-detail", args=[doc_id]))
|
| 1047 |
+
self.assertEqual(detail_resp.status_code, status.HTTP_200_OK)
|
| 1048 |
+
self.assertEqual(detail_resp.data["name"], "lifecycle.txt")
|
| 1049 |
+
|
| 1050 |
+
# 4. Delete
|
| 1051 |
+
with patch("graphrag.views.GraphBuilder") as mock_builder:
|
| 1052 |
+
mock_builder.return_value.delete_document_data.return_value = None
|
| 1053 |
+
del_resp = self.client.delete(reverse("document-detail", args=[doc_id]))
|
| 1054 |
+
self.assertIn(del_resp.status_code,
|
| 1055 |
+
[status.HTTP_200_OK, status.HTTP_204_NO_CONTENT])
|
| 1056 |
+
|
| 1057 |
+
# 5. Confirm gone
|
| 1058 |
+
get_resp = self.client.get(reverse("document-detail", args=[doc_id]))
|
| 1059 |
+
self.assertEqual(get_resp.status_code, status.HTTP_404_NOT_FOUND)
|
| 1060 |
+
|
| 1061 |
+
def test_registration_login_query_flow(self):
|
| 1062 |
+
"""Register -> Login -> use token to query."""
|
| 1063 |
+
# Register
|
| 1064 |
+
reg_url = reverse("auth_register")
|
| 1065 |
+
reg_data = {
|
| 1066 |
+
"username": "flowuser",
|
| 1067 |
+
"email": "flowuser@gmail.com",
|
| 1068 |
+
"password": "FlowPass1!",
|
| 1069 |
+
"confirm_password": "FlowPass1!",
|
| 1070 |
+
}
|
| 1071 |
+
reg_resp = self.client.post(reg_url, reg_data, format="json")
|
| 1072 |
+
self.assertEqual(reg_resp.status_code, status.HTTP_201_CREATED)
|
| 1073 |
+
|
| 1074 |
+
# Login
|
| 1075 |
+
login_url = reverse("auth_login")
|
| 1076 |
+
login_data = {"username": "flowuser", "password": "FlowPass1!"}
|
| 1077 |
+
login_resp = self.client.post(login_url, login_data, format="json")
|
| 1078 |
+
self.assertEqual(login_resp.status_code, status.HTTP_200_OK)
|
| 1079 |
+
access_token = login_resp.data["access"]
|
| 1080 |
+
|
| 1081 |
+
# Use token
|
| 1082 |
+
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {access_token}")
|
| 1083 |
+
with patch("graphrag.views.RAGChain") as mock_rag:
|
| 1084 |
+
mock_rag.return_value.generate_answer.return_value = {
|
| 1085 |
+
"success": True, "answer": "Flow answer",
|
| 1086 |
+
}
|
| 1087 |
+
query_resp = self.client.post(
|
| 1088 |
+
reverse("query"), {"query": "test flow"}, format="json"
|
| 1089 |
+
)
|
| 1090 |
+
self.assertEqual(query_resp.status_code, status.HTTP_200_OK)
|
| 1091 |
+
|
| 1092 |
+
def test_user_isolation(self):
|
| 1093 |
+
"""User A's documents are invisible to User B."""
|
| 1094 |
+
user_a = _create_user(username="isola", email="isola@gmail.com")
|
| 1095 |
+
user_b = _create_user(username="isolb", email="isolb@gmail.com")
|
| 1096 |
+
|
| 1097 |
+
# Create a doc as user A
|
| 1098 |
+
Document.objects.create(
|
| 1099 |
+
user=user_a, name="a-only.txt",
|
| 1100 |
+
file="uploaded_documents/a-only.txt",
|
| 1101 |
+
status=Document.Status.COMPLETED,
|
| 1102 |
+
)
|
| 1103 |
+
|
| 1104 |
+
# User B lists — should see nothing
|
| 1105 |
+
self.client.force_authenticate(user=user_b)
|
| 1106 |
+
resp = self.client.get(reverse("document-list"))
|
| 1107 |
+
results = _results(resp)
|
| 1108 |
+
self.assertEqual(len(results), 0)
|
| 1109 |
+
|
| 1110 |
+
|
| 1111 |
+
# ===========================================================================
|
| 1112 |
+
# 8. DOCUMENT SERIALIZER EDGE CASES
|
| 1113 |
+
# ===========================================================================
|
| 1114 |
+
|
| 1115 |
+
class DocumentSerializerTests(APITestCase):
|
| 1116 |
+
"""Tests for DocumentSerializer edge cases."""
|
| 1117 |
+
|
| 1118 |
+
def setUp(self):
|
| 1119 |
+
self.user = _create_user(username="seruser", email="seruser@gmail.com")
|
| 1120 |
+
self.client.force_authenticate(user=self.user)
|
| 1121 |
+
|
| 1122 |
+
@patch("graphrag.views.trigger_ingestion_background")
|
| 1123 |
+
def test_document_serializer_fields(self, mock_bg):
|
| 1124 |
+
"""Serializer returns all expected fields."""
|
| 1125 |
+
payload = _upload_payload("fields.txt", b"Content.", "text/plain")
|
| 1126 |
+
resp = self.client.post(reverse("document_upload"), payload, format="multipart")
|
| 1127 |
+
|
| 1128 |
+
doc_data = resp.data["document"]
|
| 1129 |
+
expected_fields = {
|
| 1130 |
+
"id", "user", "name", "file", "file_url", "status",
|
| 1131 |
+
"entity_count", "relationship_count", "error_message",
|
| 1132 |
+
"created_at", "updated_at",
|
| 1133 |
+
}
|
| 1134 |
+
self.assertTrue(expected_fields.issubset(set(doc_data.keys())))
|
| 1135 |
+
|
| 1136 |
+
@patch("graphrag.views.trigger_ingestion_background")
|
| 1137 |
+
def test_document_status_choices(self, mock_bg):
|
| 1138 |
+
"""Status is one of the valid Document.Status choices."""
|
| 1139 |
+
payload = _upload_payload("choices.txt", b"Content.", "text/plain")
|
| 1140 |
+
resp = self.client.post(reverse("document_upload"), payload, format="multipart")
|
| 1141 |
+
|
| 1142 |
+
status_val = resp.data["document"]["status"]
|
| 1143 |
+
valid_statuses = {c[0] for c in Document.Status.choices}
|
| 1144 |
+
self.assertIn(status_val, valid_statuses)
|
| 1145 |
+
|
| 1146 |
+
|
| 1147 |
+
# ===========================================================================
|
| 1148 |
+
# 9. QUERY LOG MODEL TESTS
|
| 1149 |
+
# ===========================================================================
|
| 1150 |
+
|
| 1151 |
+
class QueryLogModelTests(APITestCase):
|
| 1152 |
+
"""Tests for QueryLog model creation and serialization."""
|
| 1153 |
+
|
| 1154 |
+
def setUp(self):
|
| 1155 |
+
self.user = _create_user(username="loguser", email="loguser@gmail.com")
|
| 1156 |
+
|
| 1157 |
+
def test_query_log_creation(self):
|
| 1158 |
+
"""QueryLog can be created and string representation is correct."""
|
| 1159 |
+
log = QueryLog.objects.create(
|
| 1160 |
+
user=self.user,
|
| 1161 |
+
query_text="What is GraphRAG?",
|
| 1162 |
+
retrieval_mode=QueryLog.RetrievalMode.HYBRID,
|
| 1163 |
+
answer_text="GraphRAG is a retrieval-augmented generation system.",
|
| 1164 |
+
response_time=1.23,
|
| 1165 |
+
)
|
| 1166 |
+
self.assertIn("What is GraphRAG?", str(log))
|
| 1167 |
+
self.assertEqual(log.response_time, 1.23)
|
| 1168 |
+
|
| 1169 |
+
def test_query_log_default_mode(self):
|
| 1170 |
+
"""Default retrieval mode is HYBRID."""
|
| 1171 |
+
log = QueryLog.objects.create(
|
| 1172 |
+
user=self.user,
|
| 1173 |
+
query_text="test",
|
| 1174 |
+
answer_text="answer",
|
| 1175 |
+
response_time=0.1,
|
| 1176 |
+
)
|
| 1177 |
+
self.assertEqual(log.retrieval_mode, QueryLog.RetrievalMode.HYBRID)
|
backend/graphrag/urls.py
CHANGED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from django.urls import path, include
|
| 2 |
+
from rest_framework.routers import DefaultRouter
|
| 3 |
+
from rest_framework_simplejwt.views import TokenRefreshView
|
| 4 |
+
from .views import (
|
| 5 |
+
RegisterView,
|
| 6 |
+
CustomTokenObtainPairView,
|
| 7 |
+
DocumentViewSet,
|
| 8 |
+
DocumentUploadView,
|
| 9 |
+
QueryView,
|
| 10 |
+
CypherQueryView,
|
| 11 |
+
ShortestPathView
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
router = DefaultRouter()
|
| 15 |
+
router.register(r'documents', DocumentViewSet, basename='document')
|
| 16 |
+
|
| 17 |
+
urlpatterns = [
|
| 18 |
+
# Authentication Endpoints
|
| 19 |
+
path('auth/register/', RegisterView.as_view(), name='auth_register'),
|
| 20 |
+
path('auth/login/', CustomTokenObtainPairView.as_view(), name='auth_login'),
|
| 21 |
+
path('auth/token/refresh/', TokenRefreshView.as_view(), name='auth_token_refresh'),
|
| 22 |
+
|
| 23 |
+
# Document Ingestion Endpoint (POST /api/documents/upload/)
|
| 24 |
+
path('documents/upload/', DocumentUploadView.as_view(), name='document_upload'),
|
| 25 |
+
|
| 26 |
+
# Retrieval Endpoints
|
| 27 |
+
path('query/', QueryView.as_view(), name='query'),
|
| 28 |
+
path('query/cypher/', CypherQueryView.as_view(), name='query_cypher'),
|
| 29 |
+
path('query/shortest-path/', ShortestPathView.as_view(), name='query_shortest_path'),
|
| 30 |
+
|
| 31 |
+
# Document management routes:
|
| 32 |
+
# GET /api/documents/ -> lists all user documents
|
| 33 |
+
# GET /api/documents/{id}/ -> gets processing status details
|
| 34 |
+
# DELETE /api/documents/{id}/ -> deletes database metadata, Neo4j graph nodes, and Chroma vectors
|
| 35 |
+
path('', include(router.urls)),
|
| 36 |
+
]
|
| 37 |
+
|
backend/graphrag/views.py
CHANGED
|
@@ -0,0 +1,270 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
import threading
|
| 3 |
+
|
| 4 |
+
from rest_framework import status, viewsets
|
| 5 |
+
from rest_framework.views import APIView
|
| 6 |
+
from rest_framework.response import Response
|
| 7 |
+
from rest_framework.permissions import AllowAny, IsAuthenticated
|
| 8 |
+
from rest_framework_simplejwt.views import TokenObtainPairView
|
| 9 |
+
from django.contrib.auth import get_user_model
|
| 10 |
+
from .models import Document
|
| 11 |
+
from .serializers import (
|
| 12 |
+
RegisterSerializer,
|
| 13 |
+
UserSerializer,
|
| 14 |
+
DocumentSerializer
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
from .services.rag_chain import RAGChain
|
| 18 |
+
from .services.nl_to_cypher import NLToCypher
|
| 19 |
+
from .services.multihop_reasoner import MultiHopReasoner
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
# Note: We will implement the background logic inside the existing GraphBuilder service
|
| 23 |
+
from .services.graph_builder import GraphBuilder
|
| 24 |
+
|
| 25 |
+
logger = logging.getLogger(__name__)
|
| 26 |
+
|
| 27 |
+
User = get_user_model()
|
| 28 |
+
|
| 29 |
+
class RegisterView(APIView):
|
| 30 |
+
"""
|
| 31 |
+
Endpoint for new user registration.
|
| 32 |
+
"""
|
| 33 |
+
permission_classes = [AllowAny]
|
| 34 |
+
|
| 35 |
+
def post(self, request):
|
| 36 |
+
logger.info("Received account registration request.")
|
| 37 |
+
serializer = RegisterSerializer(data=request.data)
|
| 38 |
+
if serializer.is_valid():
|
| 39 |
+
user = serializer.save()
|
| 40 |
+
logger.info("Successfully registered user account: %s", user.username)
|
| 41 |
+
return Response(
|
| 42 |
+
{
|
| 43 |
+
"message": "User registered successfully.",
|
| 44 |
+
"user": UserSerializer(user).data
|
| 45 |
+
},
|
| 46 |
+
status=status.HTTP_201_CREATED
|
| 47 |
+
)
|
| 48 |
+
logger.warning("Registration request failed validation check: %s", serializer.errors)
|
| 49 |
+
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class CustomTokenObtainPairView(TokenObtainPairView):
|
| 53 |
+
"""
|
| 54 |
+
Custom JWT Token Obtain View to add custom execution logs.
|
| 55 |
+
"""
|
| 56 |
+
permission_classes = [AllowAny]
|
| 57 |
+
|
| 58 |
+
def post(self, request, *args, **kwargs):
|
| 59 |
+
username = request.data.get('username')
|
| 60 |
+
logger.info("Authentication attempt received for user: %s", username)
|
| 61 |
+
try:
|
| 62 |
+
response = super().post(request, *args, **kwargs)
|
| 63 |
+
logger.info("Authentication successful for user: %s", username)
|
| 64 |
+
return response
|
| 65 |
+
except Exception as e:
|
| 66 |
+
logger.warning("Authentication failed for user: %s. Error: %s", username, str(e))
|
| 67 |
+
raise e
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def trigger_ingestion_background(document_id, user_id):
|
| 71 |
+
"""
|
| 72 |
+
Isolated target runner to execute ingestion processing inside a background thread.
|
| 73 |
+
"""
|
| 74 |
+
logger.info("Background thread spawned for ingestion of document ID: %s", document_id)
|
| 75 |
+
try:
|
| 76 |
+
builder = GraphBuilder()
|
| 77 |
+
builder.process_document(document_id, user_id)
|
| 78 |
+
logger.info("Background ingestion completed successfully for document ID: %s", document_id)
|
| 79 |
+
except Exception as e:
|
| 80 |
+
logger.error("Critical error in background ingestion thread for document ID: %s. Error: %s",
|
| 81 |
+
document_id, str(e), exc_info=True)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
class DocumentUploadView(APIView):
|
| 85 |
+
"""
|
| 86 |
+
Endpoint for uploading documents. Runs the parsing and extraction pipeline
|
| 87 |
+
in a non-blocking background thread.
|
| 88 |
+
"""
|
| 89 |
+
permission_classes = [IsAuthenticated]
|
| 90 |
+
|
| 91 |
+
def post(self, request):
|
| 92 |
+
logger.info("Received document upload request from user: %s", request.user.username)
|
| 93 |
+
|
| 94 |
+
if 'file' not in request.FILES:
|
| 95 |
+
logger.warning("Document upload request rejected: No file attachment found.")
|
| 96 |
+
return Response(
|
| 97 |
+
{"error": "No file was uploaded."},
|
| 98 |
+
status=status.HTTP_400_BAD_REQUEST
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
file_obj = request.FILES['file']
|
| 102 |
+
|
| 103 |
+
# Save document record with initial PENDING status
|
| 104 |
+
doc = Document.objects.create(
|
| 105 |
+
user=request.user,
|
| 106 |
+
name=file_obj.name,
|
| 107 |
+
file=file_obj,
|
| 108 |
+
status=Document.Status.PENDING
|
| 109 |
+
)
|
| 110 |
+
logger.info("Saved initial document metadata row. ID: %s | Name: %s", doc.id, doc.name)
|
| 111 |
+
|
| 112 |
+
# Launch background pipeline thread
|
| 113 |
+
thread = threading.Thread(
|
| 114 |
+
target=trigger_ingestion_background,
|
| 115 |
+
args=(doc.id, request.user.id)
|
| 116 |
+
)
|
| 117 |
+
thread.daemon = True
|
| 118 |
+
thread.start()
|
| 119 |
+
|
| 120 |
+
# Return 202 Accepted immediately so client is non-blocking
|
| 121 |
+
return Response(
|
| 122 |
+
{
|
| 123 |
+
"message": "File upload accepted. Ingestion running in background.",
|
| 124 |
+
"document": DocumentSerializer(doc, context={'request': request}).data
|
| 125 |
+
},
|
| 126 |
+
status=status.HTTP_202_ACCEPTED
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
class DocumentViewSet(viewsets.ModelViewSet):
|
| 131 |
+
"""
|
| 132 |
+
ViewSet for listing, retrieving details, and deleting user documents.
|
| 133 |
+
"""
|
| 134 |
+
permission_classes = [IsAuthenticated]
|
| 135 |
+
serializer_class = DocumentSerializer
|
| 136 |
+
# Viewset operations are automatically mapped to list, retrieve, destroy URLs by the router
|
| 137 |
+
http_method_names = ['get', 'delete']
|
| 138 |
+
|
| 139 |
+
def get_queryset(self):
|
| 140 |
+
# Enforce multi-tenancy: users can only see their own documents
|
| 141 |
+
return Document.objects.filter(user=self.request.user)
|
| 142 |
+
|
| 143 |
+
def destroy(self, request, *args, **kwargs):
|
| 144 |
+
doc = self.get_object()
|
| 145 |
+
logger.info("Received request to delete document: %s (ID: %s) for user: %s",
|
| 146 |
+
doc.name, doc.id, request.user.username)
|
| 147 |
+
|
| 148 |
+
try:
|
| 149 |
+
# Trigger custom graph/vector cleanup using GraphBuilder
|
| 150 |
+
builder = GraphBuilder()
|
| 151 |
+
builder.delete_document_data(doc.id, request.user.id)
|
| 152 |
+
|
| 153 |
+
# Delete physical file and SQL DB record
|
| 154 |
+
doc.file.delete(save=False)
|
| 155 |
+
doc.delete()
|
| 156 |
+
|
| 157 |
+
logger.info("Successfully deleted document %s and cleaned associated graph/vector database records.", doc.name)
|
| 158 |
+
return Response(
|
| 159 |
+
{"message": "Document and all extracted nodes/vectors deleted successfully."},
|
| 160 |
+
status=status.HTTP_200_OK
|
| 161 |
+
)
|
| 162 |
+
except Exception as e:
|
| 163 |
+
logger.error("Failed to cleanly delete document ID: %s. Error: %s", doc.id, str(e), exc_info=True)
|
| 164 |
+
return Response(
|
| 165 |
+
{"error": f"Failed to delete document: {str(e)}"},
|
| 166 |
+
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
class QueryView(APIView):
|
| 171 |
+
"""
|
| 172 |
+
Endpoint for executing GraphRAG queries.
|
| 173 |
+
Supports 'hybrid', 'vector', and 'graph' retrieval modes.
|
| 174 |
+
"""
|
| 175 |
+
permission_classes = [IsAuthenticated]
|
| 176 |
+
|
| 177 |
+
def post(self, request):
|
| 178 |
+
query = request.data.get("query")
|
| 179 |
+
mode = request.data.get("mode", "hybrid")
|
| 180 |
+
|
| 181 |
+
if not query or not query.strip():
|
| 182 |
+
return Response(
|
| 183 |
+
{"error": "The 'query' field is required and cannot be empty."},
|
| 184 |
+
status=status.HTTP_400_BAD_REQUEST
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
logger.info("Executing RAG Query for user: %s | Mode: %s", request.user.username, mode)
|
| 188 |
+
try:
|
| 189 |
+
rag_chain = RAGChain()
|
| 190 |
+
result = rag_chain.generate_answer(query, request.user.id, mode)
|
| 191 |
+
|
| 192 |
+
if result.get("success", False):
|
| 193 |
+
return Response(result, status=status.HTTP_200_OK)
|
| 194 |
+
else:
|
| 195 |
+
return Response(
|
| 196 |
+
{"error": result.get("answer", "Failed to generate RAG response.")},
|
| 197 |
+
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
| 198 |
+
)
|
| 199 |
+
except Exception as e:
|
| 200 |
+
logger.error("Error in QueryView: %s", str(e), exc_info=True)
|
| 201 |
+
return Response(
|
| 202 |
+
{"error": f"Internal Server Error: {str(e)}"},
|
| 203 |
+
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
| 204 |
+
)
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
class CypherQueryView(APIView):
|
| 208 |
+
"""
|
| 209 |
+
Endpoint for converting natural language queries directly into Cypher,
|
| 210 |
+
executing them, and returning the raw records.
|
| 211 |
+
"""
|
| 212 |
+
permission_classes = [IsAuthenticated]
|
| 213 |
+
|
| 214 |
+
def post(self, request):
|
| 215 |
+
query = request.data.get("query")
|
| 216 |
+
|
| 217 |
+
if not query or not query.strip():
|
| 218 |
+
return Response(
|
| 219 |
+
{"error": "The 'query' field is required and cannot be empty."},
|
| 220 |
+
status=status.HTTP_400_BAD_REQUEST
|
| 221 |
+
)
|
| 222 |
+
|
| 223 |
+
logger.info("Translating NL Query to Cypher for user: %s", request.user.username)
|
| 224 |
+
try:
|
| 225 |
+
nl_to_cypher = NLToCypher()
|
| 226 |
+
result = nl_to_cypher.execute_nl_query(query, request.user.id)
|
| 227 |
+
|
| 228 |
+
if result.get("success", False):
|
| 229 |
+
return Response(result, status=status.HTTP_200_OK)
|
| 230 |
+
else:
|
| 231 |
+
return Response(
|
| 232 |
+
{"error": result.get("error", "Failed to translate and execute Cypher query.")},
|
| 233 |
+
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
| 234 |
+
)
|
| 235 |
+
except Exception as e:
|
| 236 |
+
logger.error("Error in CypherQueryView: %s", str(e), exc_info=True)
|
| 237 |
+
return Response(
|
| 238 |
+
{"error": f"Internal Server Error: {str(e)}"},
|
| 239 |
+
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
| 240 |
+
)
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
class ShortestPathView(APIView):
|
| 244 |
+
"""
|
| 245 |
+
Endpoint for finding and explaining the connection path between two entities.
|
| 246 |
+
"""
|
| 247 |
+
permission_classes = [IsAuthenticated]
|
| 248 |
+
|
| 249 |
+
def post(self, request):
|
| 250 |
+
entity_a = request.data.get("entity_a")
|
| 251 |
+
entity_b = request.data.get("entity_b")
|
| 252 |
+
|
| 253 |
+
if not entity_a or not entity_b:
|
| 254 |
+
return Response(
|
| 255 |
+
{"error": "Both 'entity_a' and 'entity_b' fields are required."},
|
| 256 |
+
status=status.HTTP_400_BAD_REQUEST
|
| 257 |
+
)
|
| 258 |
+
|
| 259 |
+
logger.info("Executing Shortest Path reasoning: '%s' to '%s' for user: %s",
|
| 260 |
+
entity_a, entity_b, request.user.username)
|
| 261 |
+
try:
|
| 262 |
+
reasoner = MultiHopReasoner()
|
| 263 |
+
result = reasoner.explain_connection(entity_a, entity_b, request.user.id)
|
| 264 |
+
return Response(result, status=status.HTTP_200_OK)
|
| 265 |
+
except Exception as e:
|
| 266 |
+
logger.error("Error in ShortestPathView: %s", str(e), exc_info=True)
|
| 267 |
+
return Response(
|
| 268 |
+
{"error": f"Internal Server Error: {str(e)}"},
|
| 269 |
+
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
| 270 |
+
)
|
backend/manage.py
CHANGED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
"""Django's command-line utility for administrative tasks."""
|
| 3 |
+
import os
|
| 4 |
+
import sys
|
| 5 |
+
|
| 6 |
+
def main():
|
| 7 |
+
"""Run administrative tasks."""
|
| 8 |
+
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
| 9 |
+
try:
|
| 10 |
+
from django.core.management import execute_from_command_line
|
| 11 |
+
except ImportError as exc:
|
| 12 |
+
raise ImportError(
|
| 13 |
+
"Couldn't import Django. Are you sure it's installed and "
|
| 14 |
+
"available on your PYTHONPATH environment variable? Did you "
|
| 15 |
+
"forget to activate a virtual environment?"
|
| 16 |
+
) from exc
|
| 17 |
+
execute_from_command_line(sys.argv)
|
| 18 |
+
|
| 19 |
+
if __name__ == '__main__':
|
| 20 |
+
main()
|
backend/requirements.txt
CHANGED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
django>=4.2,<5.0
|
| 2 |
+
djangorestframework>=3.14.0
|
| 3 |
+
django-cors-headers>=4.0.0
|
| 4 |
+
djangorestframework-simplejwt>=5.3.0
|
| 5 |
+
neo4j>=5.14.0
|
| 6 |
+
langchain>=0.1.0
|
| 7 |
+
langchain-community>=0.0.10
|
| 8 |
+
langchain-google-genai>=1.0.0
|
| 9 |
+
langchain-groq>=0.1.0
|
| 10 |
+
chromadb>=0.4.15
|
| 11 |
+
sentence-transformers>=2.2.2
|
| 12 |
+
rapidfuzz>=3.5.2
|
| 13 |
+
tenacity>=8.2.3
|
| 14 |
+
pypdf>=3.17.0
|
| 15 |
+
python-docx>=0.8.11
|
| 16 |
+
pydantic>=2.5.0
|
| 17 |
+
python-dotenv>=1.0.0
|
docker-compose.yml
CHANGED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version: '3.8'
|
| 2 |
+
|
| 3 |
+
services:
|
| 4 |
+
neo4j:
|
| 5 |
+
image: neo4j:5.12.0
|
| 6 |
+
container_name: graphrag_neo4j
|
| 7 |
+
ports:
|
| 8 |
+
- "7474:7474" # Neo4j Browser Console (HTTP)
|
| 9 |
+
- "7687:7687" # Bolt protocol port (used by Django python driver)
|
| 10 |
+
environment:
|
| 11 |
+
- NEO4J_AUTH=neo4j/password
|
| 12 |
+
# Allow APOC and GDS (Graph Data Science) plugins
|
| 13 |
+
- NEO4J_PLUGINS=["apoc", "graph-data-science"]
|
| 14 |
+
# Grant full permissions to GDS and APOC procedures
|
| 15 |
+
- NEO4J_dbms_security_procedures_unrestricted=apoc.*,gds.*
|
| 16 |
+
- NEO4J_dbms_security_procedures_allowlist=apoc.*,gds.*
|
| 17 |
+
volumes:
|
| 18 |
+
- neo4j_data:/data
|
| 19 |
+
- neo4j_logs:/logs
|
| 20 |
+
- neo4j_import:/import
|
| 21 |
+
- neo4j_plugins:/plugins
|
| 22 |
+
healthcheck:
|
| 23 |
+
test: ["CMD-SHELL", "cypher-shell -u neo4j -p password 'RETURN 1' || exit 1"]
|
| 24 |
+
interval: 10s
|
| 25 |
+
timeout: 10s
|
| 26 |
+
retries: 5
|
| 27 |
+
start_period: 20s
|
| 28 |
+
|
| 29 |
+
volumes:
|
| 30 |
+
neo4j_data:
|
| 31 |
+
neo4j_logs:
|
| 32 |
+
neo4j_import:
|
| 33 |
+
neo4j_plugins:
|