Ashutosh1975270 commited on
Commit
db4d559
·
1 Parent(s): 362aea9

feat: implement GraphRAG Phase 1 backend service layer and test suite

Browse files
.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: