flexible-graphrag 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- backend.py +1858 -0
- check_elasticsearch.py +76 -0
- cleanup.py +655 -0
- cmis_util.py +69 -0
- config.py +574 -0
- document_processor.py +817 -0
- factories.py +1463 -0
- flexible_graphrag-0.4.0.data/data/Dockerfile +89 -0
- flexible_graphrag-0.4.0.data/data/env-sample.txt +595 -0
- flexible_graphrag-0.4.0.data/data/requirements.txt +135 -0
- flexible_graphrag-0.4.0.data/data/uv.toml +53 -0
- flexible_graphrag-0.4.0.dist-info/METADATA +135 -0
- flexible_graphrag-0.4.0.dist-info/RECORD +71 -0
- flexible_graphrag-0.4.0.dist-info/WHEEL +5 -0
- flexible_graphrag-0.4.0.dist-info/entry_points.txt +2 -0
- flexible_graphrag-0.4.0.dist-info/top_level.txt +18 -0
- hybrid_system.py +2764 -0
- incremental_system.py +237 -0
- incremental_updates/__init__.py +17 -0
- incremental_updates/config_manager.py +260 -0
- incremental_updates/datasource-queries.sql +303 -0
- incremental_updates/detectors/README.md +532 -0
- incremental_updates/detectors/__init__.py +45 -0
- incremental_updates/detectors/alfresco_broadcaster.py +274 -0
- incremental_updates/detectors/alfresco_detector.py +1551 -0
- incremental_updates/detectors/azure_blob_detector.py +780 -0
- incremental_updates/detectors/base.py +111 -0
- incremental_updates/detectors/box_detector.py +634 -0
- incremental_updates/detectors/factory.py +62 -0
- incremental_updates/detectors/filesystem_detector.py +535 -0
- incremental_updates/detectors/gcs_detector.py +830 -0
- incremental_updates/detectors/google_drive_detector.py +794 -0
- incremental_updates/detectors/msgraph_detector.py +911 -0
- incremental_updates/detectors/s3_detector.py +700 -0
- incremental_updates/diagnostic_queries.sql +177 -0
- incremental_updates/engine.py +997 -0
- incremental_updates/logging_config.py +178 -0
- incremental_updates/orchestrator.py +418 -0
- incremental_updates/path_utils.py +23 -0
- incremental_updates/s3_helpers.py +452 -0
- incremental_updates/schema.sql +81 -0
- incremental_updates/state_manager.py +353 -0
- ingest/__init__.py +13 -0
- ingest/factory.py +82 -0
- ingest/manager.py +229 -0
- install.py +52 -0
- main.py +1468 -0
- neptune_database_wrapper.py +131 -0
- observability/__init__.py +25 -0
- observability/custom_hooks.py +252 -0
- observability/metrics.py +172 -0
- observability/telemetry_openlit.py +163 -0
- observability/telemetry_setup.py +222 -0
- post_ingestion_state.py +586 -0
- sources/__init__.py +38 -0
- sources/alfresco.py +856 -0
- sources/azure_blob.py +267 -0
- sources/base.py +89 -0
- sources/box.py +244 -0
- sources/cmis.py +356 -0
- sources/filesystem.py +217 -0
- sources/gcs.py +274 -0
- sources/google_drive.py +278 -0
- sources/onedrive.py +313 -0
- sources/passthrough_extractor.py +257 -0
- sources/s3.py +342 -0
- sources/sharepoint.py +556 -0
- sources/web.py +105 -0
- sources/wikipedia.py +389 -0
- sources/youtube.py +219 -0
- start.py +24 -0
backend.py
ADDED
|
@@ -0,0 +1,1858 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared backend core for Flexible GraphRAG
|
|
3
|
+
This module contains the business logic that can be called by both FastAPI and FastMCP servers
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
import uuid
|
|
8
|
+
import asyncio
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
# Fix for async event loop issues with containers and LlamaIndex
|
|
12
|
+
if sys.platform == 'win32':
|
|
13
|
+
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
|
14
|
+
else:
|
|
15
|
+
# Docker/Linux environments - use default policy but ensure proper loop handling
|
|
16
|
+
asyncio.set_event_loop_policy(asyncio.DefaultEventLoopPolicy())
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
import nest_asyncio
|
|
20
|
+
nest_asyncio.apply()
|
|
21
|
+
|
|
22
|
+
# Ensure we have a proper event loop for Docker containers
|
|
23
|
+
try:
|
|
24
|
+
loop = asyncio.get_running_loop()
|
|
25
|
+
except RuntimeError:
|
|
26
|
+
loop = asyncio.new_event_loop()
|
|
27
|
+
asyncio.set_event_loop(loop)
|
|
28
|
+
except ImportError:
|
|
29
|
+
pass
|
|
30
|
+
from datetime import datetime
|
|
31
|
+
from typing import List, Dict, Any, Union, Optional
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
from config import Settings
|
|
35
|
+
from hybrid_system import HybridSearchSystem
|
|
36
|
+
from ingest import IngestionManager
|
|
37
|
+
from sources.filesystem import FileSystemSource
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
# Global processing status storage
|
|
42
|
+
PROCESSING_STATUS = {}
|
|
43
|
+
|
|
44
|
+
# File processing phases for dynamic time estimation
|
|
45
|
+
PROCESSING_PHASES = {
|
|
46
|
+
"docling": {"weight": 0.2, "name": "Converting document"},
|
|
47
|
+
"chunking": {"weight": 0.1, "name": "Splitting into chunks"},
|
|
48
|
+
"kg_extraction": {"weight": 0.6, "name": "Extracting knowledge graph"},
|
|
49
|
+
"indexing": {"weight": 0.1, "name": "Building indexes"}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
class FlexibleGraphRAGBackend:
|
|
53
|
+
"""Shared backend core for both REST API and MCP server"""
|
|
54
|
+
|
|
55
|
+
def __init__(self, settings: Settings = None):
|
|
56
|
+
self.settings = settings or Settings()
|
|
57
|
+
self._system = None
|
|
58
|
+
self.ingestion_manager = IngestionManager()
|
|
59
|
+
logger.info("FlexibleGraphRAGBackend initialized")
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def system(self) -> HybridSearchSystem:
|
|
63
|
+
"""Lazy-load the hybrid search system"""
|
|
64
|
+
if self._system is None:
|
|
65
|
+
self._system = HybridSearchSystem.from_settings(self.settings)
|
|
66
|
+
logger.info("HybridSearchSystem initialized")
|
|
67
|
+
return self._system
|
|
68
|
+
|
|
69
|
+
# Processing status management
|
|
70
|
+
|
|
71
|
+
def _create_processing_id(self) -> str:
|
|
72
|
+
"""Create a unique processing ID"""
|
|
73
|
+
return str(uuid.uuid4())[:8]
|
|
74
|
+
|
|
75
|
+
def _estimate_processing_time(self, data_source: str = None, paths: List[str] = None, content: str = None) -> str:
|
|
76
|
+
"""Estimate processing time based on input size and type"""
|
|
77
|
+
try:
|
|
78
|
+
if content:
|
|
79
|
+
# Text content - quick processing
|
|
80
|
+
char_count = len(content)
|
|
81
|
+
if char_count < 1000:
|
|
82
|
+
return "30-60 seconds"
|
|
83
|
+
elif char_count < 5000:
|
|
84
|
+
return "1-2 minutes"
|
|
85
|
+
else:
|
|
86
|
+
return "2-3 minutes"
|
|
87
|
+
|
|
88
|
+
elif paths:
|
|
89
|
+
import os
|
|
90
|
+
total_size = 0
|
|
91
|
+
file_count = 0
|
|
92
|
+
has_complex_files = False
|
|
93
|
+
|
|
94
|
+
for path in paths:
|
|
95
|
+
if os.path.isfile(path):
|
|
96
|
+
file_count += 1
|
|
97
|
+
size = os.path.getsize(path)
|
|
98
|
+
total_size += size
|
|
99
|
+
|
|
100
|
+
# Check for complex file types
|
|
101
|
+
ext = os.path.splitext(path)[1].lower()
|
|
102
|
+
if ext in ['.pdf', '.docx', '.pptx', '.xlsx']:
|
|
103
|
+
has_complex_files = True
|
|
104
|
+
elif os.path.isdir(path):
|
|
105
|
+
# Estimate directory contents
|
|
106
|
+
for root, dirs, files in os.walk(path):
|
|
107
|
+
for file in files:
|
|
108
|
+
file_path = os.path.join(root, file)
|
|
109
|
+
try:
|
|
110
|
+
file_count += 1
|
|
111
|
+
size = os.path.getsize(file_path)
|
|
112
|
+
total_size += size
|
|
113
|
+
ext = os.path.splitext(file)[1].lower()
|
|
114
|
+
if ext in ['.pdf', '.docx', '.pptx', '.xlsx']:
|
|
115
|
+
has_complex_files = True
|
|
116
|
+
except:
|
|
117
|
+
continue
|
|
118
|
+
|
|
119
|
+
# Size-based estimation
|
|
120
|
+
size_mb = total_size / (1024 * 1024)
|
|
121
|
+
|
|
122
|
+
if file_count == 0:
|
|
123
|
+
return "30 seconds"
|
|
124
|
+
elif file_count == 1 and size_mb < 1:
|
|
125
|
+
return "30-60 seconds" # Single small file
|
|
126
|
+
elif file_count == 1 and size_mb < 5:
|
|
127
|
+
return "1-2 minutes" # Single medium file
|
|
128
|
+
elif file_count == 1:
|
|
129
|
+
return "2-4 minutes" # Single large file
|
|
130
|
+
elif file_count <= 5 and not has_complex_files:
|
|
131
|
+
return "1-3 minutes" # Few simple files
|
|
132
|
+
elif file_count <= 10:
|
|
133
|
+
return "2-5 minutes" # Several files
|
|
134
|
+
else:
|
|
135
|
+
return "3-8 minutes" # Many files
|
|
136
|
+
|
|
137
|
+
return "2-4 minutes" # Default fallback
|
|
138
|
+
|
|
139
|
+
except Exception as e:
|
|
140
|
+
logger.warning(f"Error estimating processing time: {e}")
|
|
141
|
+
return "2-4 minutes" # Safe fallback
|
|
142
|
+
|
|
143
|
+
def _update_processing_status(self, processing_id: str, status: str, message: str, progress: int = 0,
|
|
144
|
+
current_file: str = None, current_phase: str = None,
|
|
145
|
+
files_completed: int = 0, total_files: int = 0,
|
|
146
|
+
estimated_time_remaining: str = None, file_progress: List[Dict] = None):
|
|
147
|
+
"""Update processing status with dynamic timing information"""
|
|
148
|
+
current_time = datetime.now()
|
|
149
|
+
existing_status = PROCESSING_STATUS.get(processing_id, {})
|
|
150
|
+
started_at = existing_status.get("started_at", current_time.isoformat())
|
|
151
|
+
|
|
152
|
+
# Calculate dynamic time estimates if we have timing info
|
|
153
|
+
if isinstance(started_at, str):
|
|
154
|
+
start_time = datetime.fromisoformat(started_at.replace('Z', '+00:00'))
|
|
155
|
+
else:
|
|
156
|
+
start_time = started_at
|
|
157
|
+
|
|
158
|
+
elapsed_seconds = (current_time - start_time).total_seconds()
|
|
159
|
+
|
|
160
|
+
# Build enhanced status
|
|
161
|
+
status_update = {
|
|
162
|
+
"processing_id": processing_id,
|
|
163
|
+
"status": status,
|
|
164
|
+
"message": message,
|
|
165
|
+
"progress": progress,
|
|
166
|
+
"updated_at": current_time.isoformat(),
|
|
167
|
+
"started_at": started_at if isinstance(started_at, str) else started_at.isoformat()
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
# Add file-level progress information
|
|
171
|
+
if current_file:
|
|
172
|
+
status_update["current_file"] = current_file
|
|
173
|
+
if current_phase:
|
|
174
|
+
status_update["current_phase"] = current_phase
|
|
175
|
+
if total_files > 0:
|
|
176
|
+
status_update["files_completed"] = files_completed
|
|
177
|
+
status_update["total_files"] = total_files
|
|
178
|
+
# Handle both in-progress (0-based) and completion (actual count) scenarios
|
|
179
|
+
if status == "completed" or files_completed >= total_files:
|
|
180
|
+
status_update["file_progress"] = f"File {files_completed} of {total_files}"
|
|
181
|
+
else:
|
|
182
|
+
status_update["file_progress"] = f"File {files_completed + 1} of {total_files}"
|
|
183
|
+
|
|
184
|
+
# Add dynamic time estimation
|
|
185
|
+
if estimated_time_remaining:
|
|
186
|
+
status_update["estimated_time_remaining"] = estimated_time_remaining
|
|
187
|
+
elif total_files > 0 and files_completed > 0 and elapsed_seconds > 0:
|
|
188
|
+
# Calculate based on files completed so far
|
|
189
|
+
avg_time_per_file = elapsed_seconds / files_completed
|
|
190
|
+
remaining_files = total_files - files_completed
|
|
191
|
+
estimated_remaining = avg_time_per_file * remaining_files
|
|
192
|
+
|
|
193
|
+
if estimated_remaining < 60:
|
|
194
|
+
status_update["estimated_time_remaining"] = f"{int(estimated_remaining)} seconds"
|
|
195
|
+
elif estimated_remaining < 3600:
|
|
196
|
+
status_update["estimated_time_remaining"] = f"{int(estimated_remaining / 60)} minutes"
|
|
197
|
+
else:
|
|
198
|
+
status_update["estimated_time_remaining"] = f"{estimated_remaining / 3600:.1f} hours"
|
|
199
|
+
|
|
200
|
+
# Add individual file progress tracking
|
|
201
|
+
if file_progress:
|
|
202
|
+
status_update["individual_files"] = file_progress
|
|
203
|
+
|
|
204
|
+
# Update existing status dict instead of replacing it (preserves documents field)
|
|
205
|
+
existing = PROCESSING_STATUS.get(processing_id, {})
|
|
206
|
+
existing.update(status_update)
|
|
207
|
+
PROCESSING_STATUS[processing_id] = existing
|
|
208
|
+
if total_files > 0:
|
|
209
|
+
# Handle both in-progress (0-based) and completion (actual count) scenarios
|
|
210
|
+
if status == "completed" or files_completed >= total_files:
|
|
211
|
+
logger.info(f"Processing {processing_id}: {status} - {message} ({files_completed}/{total_files} files)")
|
|
212
|
+
else:
|
|
213
|
+
logger.info(f"Processing {processing_id}: {status} - {message} ({files_completed + 1}/{total_files} files)")
|
|
214
|
+
else:
|
|
215
|
+
logger.info(f"Processing {processing_id}: {status} - {message}")
|
|
216
|
+
|
|
217
|
+
def get_processing_status(self, processing_id: str) -> Dict[str, Any]:
|
|
218
|
+
"""Get processing status by ID"""
|
|
219
|
+
if processing_id not in PROCESSING_STATUS:
|
|
220
|
+
return {"success": False, "error": f"Processing ID {processing_id} not found"}
|
|
221
|
+
|
|
222
|
+
return {"success": True, "processing": PROCESSING_STATUS[processing_id]}
|
|
223
|
+
|
|
224
|
+
def cancel_processing(self, processing_id: str) -> Dict[str, Any]:
|
|
225
|
+
"""Cancel a processing operation"""
|
|
226
|
+
if processing_id not in PROCESSING_STATUS:
|
|
227
|
+
return {"success": False, "error": f"Processing ID {processing_id} not found"}
|
|
228
|
+
|
|
229
|
+
status = PROCESSING_STATUS[processing_id]
|
|
230
|
+
if status["status"] in ["started", "processing"]:
|
|
231
|
+
self._update_processing_status(
|
|
232
|
+
processing_id,
|
|
233
|
+
"cancelled",
|
|
234
|
+
"Processing cancelled by user",
|
|
235
|
+
status.get("progress", 0)
|
|
236
|
+
)
|
|
237
|
+
return {"success": True, "message": "Processing cancelled successfully"}
|
|
238
|
+
else:
|
|
239
|
+
return {"success": False, "error": f"Cannot cancel processing in status: {status['status']}"}
|
|
240
|
+
|
|
241
|
+
def _is_processing_cancelled(self, processing_id: str) -> bool:
|
|
242
|
+
"""Check if processing has been cancelled"""
|
|
243
|
+
return (processing_id in PROCESSING_STATUS and
|
|
244
|
+
PROCESSING_STATUS[processing_id]["status"] == "cancelled")
|
|
245
|
+
|
|
246
|
+
def _initialize_file_progress(self, processing_id: str, file_paths: List[str]) -> List[Dict]:
|
|
247
|
+
"""Initialize per-file progress tracking"""
|
|
248
|
+
file_progress = []
|
|
249
|
+
for i, file_path in enumerate(file_paths):
|
|
250
|
+
filename = Path(file_path).name
|
|
251
|
+
file_progress.append({
|
|
252
|
+
"index": i,
|
|
253
|
+
"filename": filename,
|
|
254
|
+
"filepath": file_path,
|
|
255
|
+
"status": "pending", # pending, processing, completed, failed
|
|
256
|
+
"progress": 0,
|
|
257
|
+
"phase": "waiting", # waiting, docling, chunking, kg_extraction, indexing
|
|
258
|
+
"message": "Waiting to process...",
|
|
259
|
+
"started_at": None,
|
|
260
|
+
"completed_at": None,
|
|
261
|
+
"error": None
|
|
262
|
+
})
|
|
263
|
+
return file_progress
|
|
264
|
+
|
|
265
|
+
def _initialize_data_source_progress(self, processing_id: str, data_source: str, source_description: str = None) -> List[Dict]:
|
|
266
|
+
"""Initialize progress tracking for new modular data sources"""
|
|
267
|
+
# Create a single "file" entry representing the data source
|
|
268
|
+
source_name = source_description or f"{data_source.title()} Source"
|
|
269
|
+
file_progress = [{
|
|
270
|
+
"index": 0,
|
|
271
|
+
"filename": source_name,
|
|
272
|
+
"filepath": source_name,
|
|
273
|
+
"status": "pending",
|
|
274
|
+
"progress": 0,
|
|
275
|
+
"phase": "connecting",
|
|
276
|
+
"message": f"Connecting to {data_source}...",
|
|
277
|
+
"started_at": None,
|
|
278
|
+
"completed_at": None,
|
|
279
|
+
"error": None
|
|
280
|
+
}]
|
|
281
|
+
return file_progress
|
|
282
|
+
|
|
283
|
+
def _update_data_source_progress(self, processing_id: str, status: str = None,
|
|
284
|
+
progress: int = None, phase: str = None, message: str = None):
|
|
285
|
+
"""Update progress for modular data sources (single source entry)"""
|
|
286
|
+
current_status = PROCESSING_STATUS.get(processing_id, {})
|
|
287
|
+
file_progress = current_status.get("individual_files", [])
|
|
288
|
+
|
|
289
|
+
if file_progress:
|
|
290
|
+
# Update the single data source entry
|
|
291
|
+
if status:
|
|
292
|
+
file_progress[0]["status"] = status
|
|
293
|
+
if progress is not None:
|
|
294
|
+
file_progress[0]["progress"] = progress
|
|
295
|
+
if phase:
|
|
296
|
+
file_progress[0]["phase"] = phase
|
|
297
|
+
if message:
|
|
298
|
+
file_progress[0]["message"] = message
|
|
299
|
+
|
|
300
|
+
# Update completion time
|
|
301
|
+
if status == "completed":
|
|
302
|
+
from datetime import datetime
|
|
303
|
+
file_progress[0]["completed_at"] = datetime.now().isoformat()
|
|
304
|
+
elif status == "processing" and not file_progress[0]["started_at"]:
|
|
305
|
+
from datetime import datetime
|
|
306
|
+
file_progress[0]["started_at"] = datetime.now().isoformat()
|
|
307
|
+
|
|
308
|
+
# CRITICAL FIX: Update the main processing status to reflect the individual file progress
|
|
309
|
+
# This ensures the UI's top area progress bar gets updated
|
|
310
|
+
files_completed = 1 if status == "completed" else 0
|
|
311
|
+
self._update_processing_status(
|
|
312
|
+
processing_id=processing_id,
|
|
313
|
+
status=status or current_status.get("status", "processing"),
|
|
314
|
+
message=message or current_status.get("message", "Processing..."),
|
|
315
|
+
progress=progress if progress is not None else current_status.get("progress", 0),
|
|
316
|
+
total_files=1,
|
|
317
|
+
files_completed=files_completed,
|
|
318
|
+
file_progress=file_progress
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
async def _process_modular_data_source(self, processing_id: str, data_source: str, config_key: str,
|
|
322
|
+
display_name: str, connect_message: str, process_message: str,
|
|
323
|
+
config_id: str = None, skip_graph: bool = False, **kwargs):
|
|
324
|
+
"""Generic method to process modular data sources with proper progress tracking"""
|
|
325
|
+
# Get configuration
|
|
326
|
+
config = kwargs.get(config_key)
|
|
327
|
+
if not config:
|
|
328
|
+
raise ValueError(f"{data_source.title()} configuration is required for {data_source} data source")
|
|
329
|
+
|
|
330
|
+
# DEBUG: Log skip_graph extraction
|
|
331
|
+
logger.info(f"=== _process_modular_data_source DEBUG ===")
|
|
332
|
+
logger.info(f" data_source: {data_source}")
|
|
333
|
+
logger.info(f" skip_graph parameter (explicit): {skip_graph} (type: {type(skip_graph)})")
|
|
334
|
+
logger.info(f" kwargs keys: {list(kwargs.keys())}")
|
|
335
|
+
logger.info(f" 'skip_graph' in kwargs: {'skip_graph' in kwargs}")
|
|
336
|
+
logger.info(f" config_id: {config_id}")
|
|
337
|
+
logger.info(f"=== END _process_modular_data_source DEBUG ===")
|
|
338
|
+
|
|
339
|
+
# Log the config for debugging
|
|
340
|
+
logger.info(f"Processing {data_source} with config: {config}")
|
|
341
|
+
|
|
342
|
+
# Initialize progress tracking
|
|
343
|
+
file_progress = self._initialize_data_source_progress(processing_id, data_source, display_name)
|
|
344
|
+
|
|
345
|
+
# Initial connection status
|
|
346
|
+
self._update_processing_status(
|
|
347
|
+
processing_id,
|
|
348
|
+
"processing",
|
|
349
|
+
connect_message,
|
|
350
|
+
20,
|
|
351
|
+
total_files=1,
|
|
352
|
+
files_completed=0,
|
|
353
|
+
file_progress=file_progress
|
|
354
|
+
)
|
|
355
|
+
self._update_data_source_progress(processing_id, "processing", 20, "connecting", connect_message)
|
|
356
|
+
|
|
357
|
+
# Check for cancellation
|
|
358
|
+
if self._is_processing_cancelled(processing_id):
|
|
359
|
+
return
|
|
360
|
+
|
|
361
|
+
# Processing status
|
|
362
|
+
self._update_processing_status(
|
|
363
|
+
processing_id,
|
|
364
|
+
"processing",
|
|
365
|
+
process_message,
|
|
366
|
+
60,
|
|
367
|
+
total_files=1,
|
|
368
|
+
files_completed=0,
|
|
369
|
+
file_progress=file_progress
|
|
370
|
+
)
|
|
371
|
+
self._update_data_source_progress(processing_id, "processing", 60, "loading", process_message)
|
|
372
|
+
|
|
373
|
+
# Create status callback
|
|
374
|
+
def status_callback(**cb_kwargs):
|
|
375
|
+
status = cb_kwargs.get("status", "processing")
|
|
376
|
+
progress = cb_kwargs.get("progress", 0)
|
|
377
|
+
message = cb_kwargs.get("message", "")
|
|
378
|
+
|
|
379
|
+
# Update data source progress (this internally calls _update_processing_status)
|
|
380
|
+
self._update_data_source_progress(processing_id, status, progress, "processing", message)
|
|
381
|
+
|
|
382
|
+
# Process documents
|
|
383
|
+
documents = await self.ingestion_manager.ingest_from_source(
|
|
384
|
+
source_type=data_source,
|
|
385
|
+
config=config,
|
|
386
|
+
processing_id=processing_id,
|
|
387
|
+
status_callback=status_callback
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
# Store documents in PROCESSING_STATUS for document_state creation
|
|
391
|
+
PROCESSING_STATUS[processing_id]["documents"] = documents
|
|
392
|
+
logger.info(f"Stored {len(documents)} documents in PROCESSING_STATUS for incremental sync (data_source={data_source})")
|
|
393
|
+
|
|
394
|
+
# Store data source type for completion message
|
|
395
|
+
PROCESSING_STATUS[processing_id]["data_source"] = data_source
|
|
396
|
+
|
|
397
|
+
await self.system._process_documents_direct(documents, processing_id=processing_id, status_callback=status_callback, skip_graph=skip_graph, config_id=config_id)
|
|
398
|
+
|
|
399
|
+
def _update_file_progress(self, processing_id: str, file_index: int, status: str = None,
|
|
400
|
+
progress: int = None, phase: str = None, message: str = None, error: str = None):
|
|
401
|
+
"""Update progress for a specific file"""
|
|
402
|
+
current_status = PROCESSING_STATUS.get(processing_id, {})
|
|
403
|
+
file_progress = current_status.get("individual_files", [])
|
|
404
|
+
|
|
405
|
+
if file_index < len(file_progress):
|
|
406
|
+
file_info = file_progress[file_index]
|
|
407
|
+
current_time = datetime.now().isoformat()
|
|
408
|
+
|
|
409
|
+
if status:
|
|
410
|
+
file_info["status"] = status
|
|
411
|
+
if status == "processing" and not file_info["started_at"]:
|
|
412
|
+
file_info["started_at"] = current_time
|
|
413
|
+
elif status in ["completed", "failed"]:
|
|
414
|
+
file_info["completed_at"] = current_time
|
|
415
|
+
|
|
416
|
+
if progress is not None:
|
|
417
|
+
file_info["progress"] = progress
|
|
418
|
+
if phase:
|
|
419
|
+
file_info["phase"] = phase
|
|
420
|
+
if message:
|
|
421
|
+
file_info["message"] = message
|
|
422
|
+
if error:
|
|
423
|
+
file_info["error"] = error
|
|
424
|
+
|
|
425
|
+
# Update the main status with the new file progress
|
|
426
|
+
completed_count = sum(1 for f in file_progress if f["status"] == "completed")
|
|
427
|
+
logger.info(f"File progress update: {file_info['filename']} -> {status} ({progress}%) - {completed_count}/{len(file_progress)} completed")
|
|
428
|
+
|
|
429
|
+
self._update_processing_status(
|
|
430
|
+
processing_id,
|
|
431
|
+
current_status.get("status", "processing"),
|
|
432
|
+
current_status.get("message", "Processing files..."),
|
|
433
|
+
current_status.get("progress", 0),
|
|
434
|
+
current_file=file_info["filename"],
|
|
435
|
+
current_phase=phase,
|
|
436
|
+
files_completed=completed_count,
|
|
437
|
+
total_files=len(file_progress),
|
|
438
|
+
file_progress=file_progress
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
async def _process_files_batch_with_progress(self, processing_id: str, file_paths: List[str]):
|
|
442
|
+
"""Process files in batch with per-file progress simulation"""
|
|
443
|
+
try:
|
|
444
|
+
logger.info(f"Starting batch processing with per-file progress for {len(file_paths)} files")
|
|
445
|
+
|
|
446
|
+
# Get current status to preserve file_progress
|
|
447
|
+
current_status = PROCESSING_STATUS.get(processing_id, {})
|
|
448
|
+
existing_file_progress = current_status.get("individual_files", [])
|
|
449
|
+
|
|
450
|
+
# If no existing file progress, initialize it
|
|
451
|
+
if not existing_file_progress:
|
|
452
|
+
logger.warning(f"No existing file progress found for {processing_id}, initializing now")
|
|
453
|
+
existing_file_progress = self._initialize_file_progress(processing_id, file_paths)
|
|
454
|
+
|
|
455
|
+
logger.info(f"Found {len(existing_file_progress)} files in progress tracking")
|
|
456
|
+
|
|
457
|
+
# Mark all files as processing
|
|
458
|
+
for file_index in range(len(file_paths)):
|
|
459
|
+
self._update_file_progress(
|
|
460
|
+
processing_id, file_index,
|
|
461
|
+
status="processing",
|
|
462
|
+
progress=0,
|
|
463
|
+
phase="docling",
|
|
464
|
+
message="Starting batch processing..."
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
# Simulate progress updates during batch processing
|
|
468
|
+
async def progress_updater():
|
|
469
|
+
"""Background task to simulate per-file progress during batch processing"""
|
|
470
|
+
phases = [
|
|
471
|
+
("docling", "Converting documents...", 20),
|
|
472
|
+
("chunking", "Splitting into chunks...", 40),
|
|
473
|
+
("kg_extraction", "Extracting knowledge graph...", 70),
|
|
474
|
+
("indexing", "Building indexes...", 90)
|
|
475
|
+
]
|
|
476
|
+
|
|
477
|
+
for phase_name, message, progress in phases:
|
|
478
|
+
await asyncio.sleep(0.5) # Wait between phases
|
|
479
|
+
for file_index in range(len(file_paths)):
|
|
480
|
+
if not self._is_processing_cancelled(processing_id):
|
|
481
|
+
self._update_file_progress(
|
|
482
|
+
processing_id, file_index,
|
|
483
|
+
progress=progress,
|
|
484
|
+
phase=phase_name,
|
|
485
|
+
message=message
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
# Check for cancellation
|
|
489
|
+
if self._is_processing_cancelled(processing_id):
|
|
490
|
+
return
|
|
491
|
+
|
|
492
|
+
# Start progress updater in background
|
|
493
|
+
progress_task = asyncio.create_task(progress_updater())
|
|
494
|
+
|
|
495
|
+
try:
|
|
496
|
+
# Create a completion callback that will be called when processing truly finishes
|
|
497
|
+
def completion_callback(callback_processing_id=None, status=None, message=None, progress=None, **kwargs):
|
|
498
|
+
if status == "completed" or (progress and progress >= 100):
|
|
499
|
+
# This is called from hybrid_system.py AFTER the completion logs
|
|
500
|
+
logger.info(f"Real processing completed - now sending completion status to UI")
|
|
501
|
+
|
|
502
|
+
# Use the processing_id from the outer scope
|
|
503
|
+
current_status = PROCESSING_STATUS.get(processing_id, {})
|
|
504
|
+
existing_file_progress = current_status.get("individual_files", [])
|
|
505
|
+
|
|
506
|
+
# Optional: Clean up uploaded files after successful processing
|
|
507
|
+
# Check if files are from uploads directory
|
|
508
|
+
from pathlib import Path
|
|
509
|
+
upload_files = [f for f in file_paths if Path(f).parent.name == "uploads"]
|
|
510
|
+
if upload_files:
|
|
511
|
+
logger.info(f"Processing completed successfully - uploaded files can be cleaned up if needed")
|
|
512
|
+
# Note: Cleanup is available via /api/cleanup-uploads endpoint
|
|
513
|
+
|
|
514
|
+
completion_message = self._generate_completion_message(len(file_paths))
|
|
515
|
+
self._update_processing_status(
|
|
516
|
+
processing_id, # Use the processing_id from outer scope
|
|
517
|
+
"completed",
|
|
518
|
+
completion_message,
|
|
519
|
+
100,
|
|
520
|
+
total_files=len(file_paths),
|
|
521
|
+
files_completed=len(file_paths),
|
|
522
|
+
file_progress=existing_file_progress
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
# Actual batch processing - use completion callback for proper timing
|
|
526
|
+
await self.system.ingest_documents(
|
|
527
|
+
file_paths,
|
|
528
|
+
processing_id=processing_id,
|
|
529
|
+
status_callback=completion_callback,
|
|
530
|
+
skip_graph=skip_graph
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
# Cancel progress updater since real processing is done
|
|
534
|
+
progress_task.cancel()
|
|
535
|
+
|
|
536
|
+
# Mark all files as completed with a small delay to show 90% → 100% transition
|
|
537
|
+
for file_index in range(len(file_paths)):
|
|
538
|
+
self._update_file_progress(
|
|
539
|
+
processing_id, file_index,
|
|
540
|
+
status="completed",
|
|
541
|
+
progress=100,
|
|
542
|
+
phase="completed",
|
|
543
|
+
message="Processing completed successfully"
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
# No delay here - let the main method handle timing
|
|
547
|
+
|
|
548
|
+
except Exception as e:
|
|
549
|
+
# Cancel progress updater on error
|
|
550
|
+
progress_task.cancel()
|
|
551
|
+
|
|
552
|
+
# Mark all files as failed
|
|
553
|
+
for file_index in range(len(file_paths)):
|
|
554
|
+
self._update_file_progress(
|
|
555
|
+
processing_id, file_index,
|
|
556
|
+
status="failed",
|
|
557
|
+
progress=0,
|
|
558
|
+
phase="error",
|
|
559
|
+
message=f"Processing failed: {str(e)}",
|
|
560
|
+
error=str(e)
|
|
561
|
+
)
|
|
562
|
+
raise e
|
|
563
|
+
|
|
564
|
+
# Don't send completed status here - let the main method handle it
|
|
565
|
+
# This avoids duplicate "completed" messages and ensures proper timing
|
|
566
|
+
logger.info(f"Batch processing completed for {len(file_paths)} files")
|
|
567
|
+
|
|
568
|
+
except Exception as e:
|
|
569
|
+
import traceback
|
|
570
|
+
error_details = f"{type(e).__name__}: {str(e)}"
|
|
571
|
+
if not str(e): # If error message is empty, get more details
|
|
572
|
+
error_details = f"{type(e).__name__} (no message) - Traceback: {traceback.format_exc()}"
|
|
573
|
+
logger.error(f"Error in batch file processing: {error_details}")
|
|
574
|
+
self._update_processing_status(
|
|
575
|
+
processing_id,
|
|
576
|
+
"failed",
|
|
577
|
+
f"File processing failed: {error_details}",
|
|
578
|
+
0
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
async def _process_files_with_progress(self, processing_id: str, file_paths: List[str]):
|
|
582
|
+
"""Process files sequentially with detailed per-file progress tracking"""
|
|
583
|
+
try:
|
|
584
|
+
for file_index, file_path in enumerate(file_paths):
|
|
585
|
+
# Check for cancellation before each file
|
|
586
|
+
if self._is_processing_cancelled(processing_id):
|
|
587
|
+
return
|
|
588
|
+
|
|
589
|
+
filename = Path(file_path).name
|
|
590
|
+
logger.info(f"Starting processing of file {file_index + 1}/{len(file_paths)}: {filename}")
|
|
591
|
+
|
|
592
|
+
# Update file status to processing
|
|
593
|
+
self._update_file_progress(
|
|
594
|
+
processing_id, file_index,
|
|
595
|
+
status="processing",
|
|
596
|
+
progress=0,
|
|
597
|
+
phase="docling",
|
|
598
|
+
message="Converting document..."
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
try:
|
|
602
|
+
# Process individual file with progress updates
|
|
603
|
+
await self._process_single_file_with_progress(processing_id, file_index, file_path)
|
|
604
|
+
|
|
605
|
+
# Mark file as completed
|
|
606
|
+
self._update_file_progress(
|
|
607
|
+
processing_id, file_index,
|
|
608
|
+
status="completed",
|
|
609
|
+
progress=100,
|
|
610
|
+
phase="completed",
|
|
611
|
+
message="Processing completed successfully"
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
except Exception as e:
|
|
615
|
+
logger.error(f"Error processing file {filename}: {str(e)}")
|
|
616
|
+
self._update_file_progress(
|
|
617
|
+
processing_id, file_index,
|
|
618
|
+
status="failed",
|
|
619
|
+
progress=0,
|
|
620
|
+
phase="error",
|
|
621
|
+
message=f"Processing failed: {str(e)}",
|
|
622
|
+
error=str(e)
|
|
623
|
+
)
|
|
624
|
+
# Continue with next file instead of stopping entire process
|
|
625
|
+
continue
|
|
626
|
+
|
|
627
|
+
# Update overall progress to completed
|
|
628
|
+
completed_files = sum(1 for i in range(len(file_paths))
|
|
629
|
+
if PROCESSING_STATUS.get(processing_id, {}).get("individual_files", [{}])[i].get("status") == "completed")
|
|
630
|
+
|
|
631
|
+
completion_message = self._generate_completion_message(completed_files)
|
|
632
|
+
if completed_files < len(file_paths):
|
|
633
|
+
failed_count = len(file_paths) - completed_files
|
|
634
|
+
completion_message += f" ({failed_count} files failed)"
|
|
635
|
+
|
|
636
|
+
self._update_processing_status(
|
|
637
|
+
processing_id,
|
|
638
|
+
"completed",
|
|
639
|
+
completion_message,
|
|
640
|
+
100
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
except Exception as e:
|
|
644
|
+
logger.error(f"Error in file processing: {str(e)}")
|
|
645
|
+
self._update_processing_status(
|
|
646
|
+
processing_id,
|
|
647
|
+
"failed",
|
|
648
|
+
f"File processing failed: {str(e)}",
|
|
649
|
+
0
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
async def _process_single_file_with_progress(self, processing_id: str, file_index: int, file_path: str):
|
|
653
|
+
"""Process a single file with detailed progress updates"""
|
|
654
|
+
try:
|
|
655
|
+
filename = Path(file_path).name
|
|
656
|
+
logger.info(f"Processing file {file_index + 1}: {filename}")
|
|
657
|
+
|
|
658
|
+
# Phase 1: Document conversion (Docling)
|
|
659
|
+
self._update_file_progress(
|
|
660
|
+
processing_id, file_index,
|
|
661
|
+
progress=10,
|
|
662
|
+
phase="docling",
|
|
663
|
+
message="Converting document format..."
|
|
664
|
+
)
|
|
665
|
+
logger.info(f"File {filename}: Starting document conversion")
|
|
666
|
+
await asyncio.sleep(0.5) # Small delay to make progress visible
|
|
667
|
+
|
|
668
|
+
# Phase 2: Text chunking
|
|
669
|
+
self._update_file_progress(
|
|
670
|
+
processing_id, file_index,
|
|
671
|
+
progress=30,
|
|
672
|
+
phase="chunking",
|
|
673
|
+
message="Splitting into chunks..."
|
|
674
|
+
)
|
|
675
|
+
logger.info(f"File {filename}: Starting text chunking")
|
|
676
|
+
await asyncio.sleep(0.5) # Small delay to make progress visible
|
|
677
|
+
|
|
678
|
+
# Phase 3: Knowledge graph extraction
|
|
679
|
+
self._update_file_progress(
|
|
680
|
+
processing_id, file_index,
|
|
681
|
+
progress=50,
|
|
682
|
+
phase="kg_extraction",
|
|
683
|
+
message="Extracting knowledge graph..."
|
|
684
|
+
)
|
|
685
|
+
logger.info(f"File {filename}: Starting knowledge graph extraction")
|
|
686
|
+
|
|
687
|
+
# Actual processing - call the system with single file
|
|
688
|
+
# Note: This processes the single file through the full pipeline
|
|
689
|
+
await self.system.ingest_documents(
|
|
690
|
+
[file_path],
|
|
691
|
+
processing_id=processing_id,
|
|
692
|
+
status_callback=lambda pid, status, msg, prog, **kwargs: self._update_file_progress(
|
|
693
|
+
processing_id, file_index, progress=min(50 + int(prog * 0.4), 90)
|
|
694
|
+
),
|
|
695
|
+
skip_graph=skip_graph
|
|
696
|
+
)
|
|
697
|
+
|
|
698
|
+
# Phase 4: Indexing
|
|
699
|
+
self._update_file_progress(
|
|
700
|
+
processing_id, file_index,
|
|
701
|
+
progress=90,
|
|
702
|
+
phase="indexing",
|
|
703
|
+
message="Building indexes..."
|
|
704
|
+
)
|
|
705
|
+
logger.info(f"File {filename}: Completed processing")
|
|
706
|
+
await asyncio.sleep(0.5) # Small delay to make progress visible
|
|
707
|
+
|
|
708
|
+
except Exception as e:
|
|
709
|
+
logger.error(f"Error in single file processing: {str(e)}")
|
|
710
|
+
raise e
|
|
711
|
+
|
|
712
|
+
async def _cleanup_partial_processing(self, processing_id: str):
|
|
713
|
+
"""Clean up partial processing artifacts when cancelled"""
|
|
714
|
+
try:
|
|
715
|
+
logger.info(f"Cleaning up partial processing for {processing_id}")
|
|
716
|
+
|
|
717
|
+
# Check if we have a fully functional system (completed previous ingestion)
|
|
718
|
+
has_complete_system = (
|
|
719
|
+
hasattr(self.system, 'vector_index') and self.system.vector_index is not None and
|
|
720
|
+
hasattr(self.system, 'graph_index') and self.system.graph_index is not None and
|
|
721
|
+
hasattr(self.system, 'hybrid_retriever') and self.system.hybrid_retriever is not None
|
|
722
|
+
)
|
|
723
|
+
|
|
724
|
+
if has_complete_system:
|
|
725
|
+
# System was fully functional from previous ingestion - preserve it
|
|
726
|
+
logger.info(f"Preserving existing functional system state after cancellation of {processing_id}")
|
|
727
|
+
# Only clean up processing-specific state, not the core indexes
|
|
728
|
+
if processing_id in PROCESSING_STATUS:
|
|
729
|
+
PROCESSING_STATUS[processing_id]["status"] = "cancelled"
|
|
730
|
+
PROCESSING_STATUS[processing_id]["message"] = "Processing cancelled - existing data preserved"
|
|
731
|
+
else:
|
|
732
|
+
# System was in partial state, safe to clear everything
|
|
733
|
+
logger.info(f"Clearing partial system state after cancellation of {processing_id}")
|
|
734
|
+
if hasattr(self.system, 'vector_index'):
|
|
735
|
+
self.system.vector_index = None
|
|
736
|
+
if hasattr(self.system, 'graph_index'):
|
|
737
|
+
self.system.graph_index = None
|
|
738
|
+
if hasattr(self.system, 'hybrid_retriever'):
|
|
739
|
+
self.system.hybrid_retriever = None
|
|
740
|
+
|
|
741
|
+
# Also call the system's clear method if it exists
|
|
742
|
+
if hasattr(self.system, '_clear_partial_state'):
|
|
743
|
+
self.system._clear_partial_state()
|
|
744
|
+
|
|
745
|
+
logger.info(f"Cleanup completed for {processing_id}")
|
|
746
|
+
except Exception as e:
|
|
747
|
+
logger.error(f"Error during cleanup for {processing_id}: {str(e)}")
|
|
748
|
+
|
|
749
|
+
# Core business logic methods
|
|
750
|
+
|
|
751
|
+
async def ingest_documents(self, data_source: str = None, paths: List[str] = None, skip_graph: bool = False, config_id: str = None, **kwargs) -> Dict[str, Any]:
|
|
752
|
+
"""Start async document ingestion and return processing ID
|
|
753
|
+
|
|
754
|
+
Args:
|
|
755
|
+
skip_graph: If True, skip knowledge graph extraction for this ingest (temporary, doesn't persist)
|
|
756
|
+
config_id: Optional stable config_id for incremental sync (generates stable doc_id format)
|
|
757
|
+
"""
|
|
758
|
+
processing_id = self._create_processing_id()
|
|
759
|
+
|
|
760
|
+
# Start processing immediately in background
|
|
761
|
+
self._update_processing_status(
|
|
762
|
+
processing_id,
|
|
763
|
+
"started",
|
|
764
|
+
"Complex document processing has started, please wait...",
|
|
765
|
+
0
|
|
766
|
+
)
|
|
767
|
+
|
|
768
|
+
# Start background task
|
|
769
|
+
asyncio.create_task(self._process_documents_async(processing_id, data_source, paths, skip_graph, config_id, **kwargs))
|
|
770
|
+
|
|
771
|
+
estimated_time = self._estimate_processing_time(data_source, paths)
|
|
772
|
+
|
|
773
|
+
return {
|
|
774
|
+
"processing_id": processing_id,
|
|
775
|
+
"status": "started",
|
|
776
|
+
"message": "Document processing has started, please wait...",
|
|
777
|
+
"estimated_time": estimated_time
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
async def _process_documents_async(self, processing_id: str, data_source: str = None, paths: List[str] = None, skip_graph: bool = False, config_id: str = None, **kwargs):
|
|
781
|
+
"""Background task for document processing"""
|
|
782
|
+
try:
|
|
783
|
+
data_source = data_source or self.settings.data_source
|
|
784
|
+
|
|
785
|
+
# Log skip_graph flag if set
|
|
786
|
+
if skip_graph:
|
|
787
|
+
logger.info(f"skip_graph=True for processing_id={processing_id} - Knowledge graph extraction will be skipped for this ingest")
|
|
788
|
+
|
|
789
|
+
# Log config_id if set (for stable doc_id)
|
|
790
|
+
if config_id:
|
|
791
|
+
logger.info(f"config_id={config_id} for processing_id={processing_id} - Using stable doc_id format for incremental sync")
|
|
792
|
+
|
|
793
|
+
# Check for cancellation before starting
|
|
794
|
+
if self._is_processing_cancelled(processing_id):
|
|
795
|
+
return
|
|
796
|
+
|
|
797
|
+
self._update_processing_status(
|
|
798
|
+
processing_id,
|
|
799
|
+
"processing",
|
|
800
|
+
f"Initializing {data_source} document ingestion...",
|
|
801
|
+
10
|
|
802
|
+
)
|
|
803
|
+
|
|
804
|
+
if data_source == "filesystem":
|
|
805
|
+
# Extract filesystem_config from kwargs if provided (used by detectors)
|
|
806
|
+
filesystem_config = kwargs.get('filesystem_config', {})
|
|
807
|
+
file_paths = paths or filesystem_config.get('paths') or self.settings.source_paths
|
|
808
|
+
if not file_paths:
|
|
809
|
+
self._update_processing_status(
|
|
810
|
+
processing_id,
|
|
811
|
+
"failed",
|
|
812
|
+
"No file paths provided for filesystem source",
|
|
813
|
+
0
|
|
814
|
+
)
|
|
815
|
+
return
|
|
816
|
+
|
|
817
|
+
# Clean paths - remove extra quotes that might come from frontend
|
|
818
|
+
cleaned_paths = []
|
|
819
|
+
for path in file_paths:
|
|
820
|
+
if isinstance(path, str):
|
|
821
|
+
# Remove surrounding quotes if present
|
|
822
|
+
cleaned_path = path.strip('"').strip("'")
|
|
823
|
+
cleaned_paths.append(cleaned_path)
|
|
824
|
+
logger.info(f"Cleaned path: {path} -> {cleaned_path}")
|
|
825
|
+
else:
|
|
826
|
+
cleaned_paths.append(path)
|
|
827
|
+
|
|
828
|
+
# Initialize per-file progress tracking for UI
|
|
829
|
+
file_progress = self._initialize_file_progress(processing_id, cleaned_paths)
|
|
830
|
+
logger.info(f"Initialized per-file progress for {len(file_progress)} files")
|
|
831
|
+
|
|
832
|
+
self._update_processing_status(
|
|
833
|
+
processing_id,
|
|
834
|
+
"processing",
|
|
835
|
+
"Initializing filesystem document ingestion...",
|
|
836
|
+
20,
|
|
837
|
+
total_files=len(cleaned_paths),
|
|
838
|
+
files_completed=0,
|
|
839
|
+
file_progress=file_progress
|
|
840
|
+
)
|
|
841
|
+
if self._is_processing_cancelled(processing_id):
|
|
842
|
+
return
|
|
843
|
+
self._update_processing_status(
|
|
844
|
+
processing_id,
|
|
845
|
+
"processing",
|
|
846
|
+
"Scanning filesystem paths...",
|
|
847
|
+
40,
|
|
848
|
+
total_files=len(cleaned_paths),
|
|
849
|
+
files_completed=0,
|
|
850
|
+
file_progress=file_progress
|
|
851
|
+
)
|
|
852
|
+
if self._is_processing_cancelled(processing_id):
|
|
853
|
+
return
|
|
854
|
+
self._update_processing_status(
|
|
855
|
+
processing_id,
|
|
856
|
+
"processing",
|
|
857
|
+
"Processing filesystem documents...",
|
|
858
|
+
60,
|
|
859
|
+
total_files=len(cleaned_paths),
|
|
860
|
+
files_completed=0,
|
|
861
|
+
file_progress=file_progress
|
|
862
|
+
)
|
|
863
|
+
|
|
864
|
+
config = {"paths": cleaned_paths}
|
|
865
|
+
|
|
866
|
+
# Use the same pattern as CMIS and Alfresco - go through IngestionManager
|
|
867
|
+
# But create a custom status callback that provides individual_files data for UI
|
|
868
|
+
def filesystem_status_callback(**cb_kwargs):
|
|
869
|
+
status = cb_kwargs.get("status", "processing")
|
|
870
|
+
progress = cb_kwargs.get("progress", 0)
|
|
871
|
+
current_file = cb_kwargs.get("current_file", "")
|
|
872
|
+
files_completed = cb_kwargs.get("files_completed", 0)
|
|
873
|
+
total_files = cb_kwargs.get("total_files", 0)
|
|
874
|
+
|
|
875
|
+
# Handle completion status - mark all individual files as completed
|
|
876
|
+
if status == "completed" and progress == 100:
|
|
877
|
+
for i in range(len(file_progress)):
|
|
878
|
+
self._update_file_progress(
|
|
879
|
+
processing_id,
|
|
880
|
+
i,
|
|
881
|
+
status="completed",
|
|
882
|
+
progress=100,
|
|
883
|
+
phase="completed",
|
|
884
|
+
message="Processing completed"
|
|
885
|
+
)
|
|
886
|
+
# Handle loading progress - update individual file progress
|
|
887
|
+
elif files_completed > 0 and files_completed <= len(file_progress):
|
|
888
|
+
file_index = files_completed - 1 # Convert to 0-based index
|
|
889
|
+
self._update_file_progress(
|
|
890
|
+
processing_id,
|
|
891
|
+
file_index,
|
|
892
|
+
status="processing",
|
|
893
|
+
progress=min(progress, 90), # Don't complete during loading
|
|
894
|
+
phase="loading",
|
|
895
|
+
message=f"Loading {current_file}" if current_file else "Loading..."
|
|
896
|
+
)
|
|
897
|
+
|
|
898
|
+
# Add the individual_files data to the callback
|
|
899
|
+
cb_kwargs["file_progress"] = file_progress
|
|
900
|
+
self._update_processing_status(**cb_kwargs)
|
|
901
|
+
|
|
902
|
+
documents = await self.ingestion_manager.ingest_from_source(
|
|
903
|
+
source_type="filesystem",
|
|
904
|
+
config=config,
|
|
905
|
+
processing_id=processing_id,
|
|
906
|
+
status_callback=filesystem_status_callback
|
|
907
|
+
)
|
|
908
|
+
|
|
909
|
+
# Mark all files as loaded (not completed) after IngestionManager finishes
|
|
910
|
+
for i in range(len(file_progress)):
|
|
911
|
+
self._update_file_progress(
|
|
912
|
+
processing_id,
|
|
913
|
+
i,
|
|
914
|
+
status="processing",
|
|
915
|
+
progress=90, # Loaded but not processed
|
|
916
|
+
phase="loaded",
|
|
917
|
+
message="Documents loaded, starting pipeline processing..."
|
|
918
|
+
)
|
|
919
|
+
|
|
920
|
+
# Store documents in PROCESSING_STATUS for document_state creation
|
|
921
|
+
PROCESSING_STATUS[processing_id]["documents"] = documents
|
|
922
|
+
if config_id:
|
|
923
|
+
logger.info(f"Stored {len(documents)} documents in PROCESSING_STATUS for incremental sync (data_source=filesystem, config_id={config_id})")
|
|
924
|
+
else:
|
|
925
|
+
logger.info(f"Stored {len(documents)} documents in PROCESSING_STATUS for one-time ingestion (data_source=filesystem)")
|
|
926
|
+
|
|
927
|
+
await self.system._process_documents_direct(documents, processing_id=processing_id, status_callback=filesystem_status_callback, skip_graph=skip_graph, config_id=config_id)
|
|
928
|
+
|
|
929
|
+
elif data_source == "cmis":
|
|
930
|
+
self._update_processing_status(
|
|
931
|
+
processing_id,
|
|
932
|
+
"processing",
|
|
933
|
+
"Connecting to CMIS repository...",
|
|
934
|
+
20
|
|
935
|
+
)
|
|
936
|
+
|
|
937
|
+
# Check for cancellation before connecting
|
|
938
|
+
if self._is_processing_cancelled(processing_id):
|
|
939
|
+
return
|
|
940
|
+
|
|
941
|
+
self._update_processing_status(
|
|
942
|
+
processing_id,
|
|
943
|
+
"processing",
|
|
944
|
+
"Processing CMIS documents...",
|
|
945
|
+
60
|
|
946
|
+
)
|
|
947
|
+
|
|
948
|
+
# Use new modular approach with IngestionManager
|
|
949
|
+
cmis_config = kwargs.get('cmis_config')
|
|
950
|
+
if cmis_config:
|
|
951
|
+
# Use provided config
|
|
952
|
+
config = cmis_config
|
|
953
|
+
else:
|
|
954
|
+
# Use environment variables
|
|
955
|
+
import os
|
|
956
|
+
config = {
|
|
957
|
+
"url": os.getenv("CMIS_URL", "http://localhost:8080/alfresco/api/-default-/public/cmis/versions/1.1/atom"),
|
|
958
|
+
"username": os.getenv("CMIS_USERNAME", "admin"),
|
|
959
|
+
"password": os.getenv("CMIS_PASSWORD", "admin"),
|
|
960
|
+
"folder_path": os.getenv("CMIS_FOLDER_PATH", "/")
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
documents = await self.ingestion_manager.ingest_from_source(
|
|
964
|
+
source_type="cmis",
|
|
965
|
+
config=config,
|
|
966
|
+
processing_id=processing_id,
|
|
967
|
+
status_callback=lambda **cb_kwargs: self._update_processing_status(**cb_kwargs)
|
|
968
|
+
)
|
|
969
|
+
|
|
970
|
+
# Store documents in PROCESSING_STATUS for document_state creation
|
|
971
|
+
PROCESSING_STATUS[processing_id]["documents"] = documents
|
|
972
|
+
|
|
973
|
+
await self.system._process_documents_direct(documents, processing_id=processing_id, status_callback=lambda **cb_kwargs: self._update_processing_status(**cb_kwargs), skip_graph=skip_graph, config_id=config_id)
|
|
974
|
+
|
|
975
|
+
elif data_source == "alfresco":
|
|
976
|
+
self._update_processing_status(
|
|
977
|
+
processing_id,
|
|
978
|
+
"processing",
|
|
979
|
+
"Connecting to Alfresco repository...",
|
|
980
|
+
20
|
|
981
|
+
)
|
|
982
|
+
|
|
983
|
+
# Check for cancellation before connecting
|
|
984
|
+
if self._is_processing_cancelled(processing_id):
|
|
985
|
+
return
|
|
986
|
+
|
|
987
|
+
self._update_processing_status(
|
|
988
|
+
processing_id,
|
|
989
|
+
"processing",
|
|
990
|
+
"Processing Alfresco documents...",
|
|
991
|
+
60
|
|
992
|
+
)
|
|
993
|
+
|
|
994
|
+
# Use new modular approach with IngestionManager
|
|
995
|
+
alfresco_config = kwargs.get('alfresco_config')
|
|
996
|
+
if alfresco_config:
|
|
997
|
+
# Use provided config
|
|
998
|
+
config = alfresco_config
|
|
999
|
+
else:
|
|
1000
|
+
# Use environment variables
|
|
1001
|
+
import os
|
|
1002
|
+
config = {
|
|
1003
|
+
"url": os.getenv("ALFRESCO_URL", "http://localhost:8080/alfresco"),
|
|
1004
|
+
"username": os.getenv("ALFRESCO_USERNAME", "admin"),
|
|
1005
|
+
"password": os.getenv("ALFRESCO_PASSWORD", "admin"),
|
|
1006
|
+
"path": os.getenv("ALFRESCO_PATH", "/")
|
|
1007
|
+
}
|
|
1008
|
+
# Add STOMP port if configured
|
|
1009
|
+
stomp_port = os.getenv("ALFRESCO_STOMP_PORT")
|
|
1010
|
+
if stomp_port:
|
|
1011
|
+
config["stomp_port"] = int(stomp_port)
|
|
1012
|
+
|
|
1013
|
+
documents = await self.ingestion_manager.ingest_from_source(
|
|
1014
|
+
source_type="alfresco",
|
|
1015
|
+
config=config,
|
|
1016
|
+
processing_id=processing_id,
|
|
1017
|
+
status_callback=lambda **cb_kwargs: self._update_processing_status(**cb_kwargs)
|
|
1018
|
+
)
|
|
1019
|
+
|
|
1020
|
+
# Store documents in PROCESSING_STATUS for document_state creation
|
|
1021
|
+
PROCESSING_STATUS[processing_id]["documents"] = documents
|
|
1022
|
+
|
|
1023
|
+
await self.system._process_documents_direct(documents, processing_id=processing_id, status_callback=lambda **cb_kwargs: self._update_processing_status(**cb_kwargs), skip_graph=skip_graph, config_id=config_id)
|
|
1024
|
+
|
|
1025
|
+
elif data_source == "web":
|
|
1026
|
+
# Initialize progress tracking for web source
|
|
1027
|
+
web_config = kwargs.get('web_config')
|
|
1028
|
+
if not web_config:
|
|
1029
|
+
raise ValueError("Web configuration is required for web data source")
|
|
1030
|
+
|
|
1031
|
+
# Get URL for display
|
|
1032
|
+
url = web_config.get('url', 'Web Page')
|
|
1033
|
+
file_progress = self._initialize_data_source_progress(processing_id, "web", url)
|
|
1034
|
+
|
|
1035
|
+
self._update_processing_status(
|
|
1036
|
+
processing_id,
|
|
1037
|
+
"processing",
|
|
1038
|
+
"Connecting to web page...",
|
|
1039
|
+
20,
|
|
1040
|
+
total_files=1,
|
|
1041
|
+
files_completed=0,
|
|
1042
|
+
file_progress=file_progress
|
|
1043
|
+
)
|
|
1044
|
+
|
|
1045
|
+
# Update data source progress
|
|
1046
|
+
self._update_data_source_progress(processing_id, "processing", 20, "connecting", "Connecting to web page...")
|
|
1047
|
+
|
|
1048
|
+
# Check for cancellation before connecting
|
|
1049
|
+
if self._is_processing_cancelled(processing_id):
|
|
1050
|
+
return
|
|
1051
|
+
|
|
1052
|
+
self._update_processing_status(
|
|
1053
|
+
processing_id,
|
|
1054
|
+
"processing",
|
|
1055
|
+
"Processing web page content...",
|
|
1056
|
+
60,
|
|
1057
|
+
total_files=1,
|
|
1058
|
+
files_completed=0,
|
|
1059
|
+
file_progress=file_progress
|
|
1060
|
+
)
|
|
1061
|
+
|
|
1062
|
+
# Update data source progress
|
|
1063
|
+
self._update_data_source_progress(processing_id, "processing", 60, "loading", "Processing web page content...")
|
|
1064
|
+
|
|
1065
|
+
# Create status callback that updates both overall and individual progress
|
|
1066
|
+
def web_status_callback(**cb_kwargs):
|
|
1067
|
+
status = cb_kwargs.get("status", "processing")
|
|
1068
|
+
progress = cb_kwargs.get("progress", 0)
|
|
1069
|
+
message = cb_kwargs.get("message", "")
|
|
1070
|
+
|
|
1071
|
+
# Update data source progress
|
|
1072
|
+
self._update_data_source_progress(processing_id, status, progress, "processing", message)
|
|
1073
|
+
|
|
1074
|
+
documents = await self.ingestion_manager.ingest_from_source(
|
|
1075
|
+
source_type="web",
|
|
1076
|
+
config=web_config,
|
|
1077
|
+
processing_id=processing_id,
|
|
1078
|
+
status_callback=web_status_callback
|
|
1079
|
+
)
|
|
1080
|
+
|
|
1081
|
+
# Store documents in PROCESSING_STATUS for document_state creation
|
|
1082
|
+
PROCESSING_STATUS[processing_id]["documents"] = documents
|
|
1083
|
+
|
|
1084
|
+
await self.system._process_documents_direct(documents, processing_id=processing_id, status_callback=web_status_callback, skip_graph=skip_graph, config_id=config_id)
|
|
1085
|
+
|
|
1086
|
+
elif data_source == "youtube":
|
|
1087
|
+
# Initialize progress tracking for YouTube source
|
|
1088
|
+
youtube_config = kwargs.get('youtube_config')
|
|
1089
|
+
if not youtube_config:
|
|
1090
|
+
raise ValueError("YouTube configuration is required for YouTube data source")
|
|
1091
|
+
|
|
1092
|
+
# Get video URL for display
|
|
1093
|
+
video_url = youtube_config.get('url', 'YouTube Video')
|
|
1094
|
+
file_progress = self._initialize_data_source_progress(processing_id, "youtube", video_url)
|
|
1095
|
+
|
|
1096
|
+
self._update_processing_status(
|
|
1097
|
+
processing_id,
|
|
1098
|
+
"processing",
|
|
1099
|
+
"Connecting to YouTube...",
|
|
1100
|
+
20,
|
|
1101
|
+
total_files=1,
|
|
1102
|
+
files_completed=0,
|
|
1103
|
+
file_progress=file_progress
|
|
1104
|
+
)
|
|
1105
|
+
|
|
1106
|
+
# Update data source progress
|
|
1107
|
+
self._update_data_source_progress(processing_id, "processing", 20, "connecting", "Connecting to YouTube...")
|
|
1108
|
+
|
|
1109
|
+
# Check for cancellation before connecting
|
|
1110
|
+
if self._is_processing_cancelled(processing_id):
|
|
1111
|
+
return
|
|
1112
|
+
|
|
1113
|
+
self._update_processing_status(
|
|
1114
|
+
processing_id,
|
|
1115
|
+
"processing",
|
|
1116
|
+
"Processing YouTube transcript...",
|
|
1117
|
+
60,
|
|
1118
|
+
total_files=1,
|
|
1119
|
+
files_completed=0,
|
|
1120
|
+
file_progress=file_progress
|
|
1121
|
+
)
|
|
1122
|
+
|
|
1123
|
+
# Update data source progress
|
|
1124
|
+
self._update_data_source_progress(processing_id, "processing", 60, "loading", "Processing YouTube transcript...")
|
|
1125
|
+
|
|
1126
|
+
# Create status callback that updates both overall and individual progress
|
|
1127
|
+
def youtube_status_callback(**cb_kwargs):
|
|
1128
|
+
status = cb_kwargs.get("status", "processing")
|
|
1129
|
+
progress = cb_kwargs.get("progress", 0)
|
|
1130
|
+
message = cb_kwargs.get("message", "")
|
|
1131
|
+
|
|
1132
|
+
# Update data source progress
|
|
1133
|
+
self._update_data_source_progress(processing_id, status, progress, "processing", message)
|
|
1134
|
+
|
|
1135
|
+
# Update overall status
|
|
1136
|
+
current_status = PROCESSING_STATUS.get(processing_id, {})
|
|
1137
|
+
current_file_progress = current_status.get("individual_files", file_progress)
|
|
1138
|
+
|
|
1139
|
+
self._update_processing_status(
|
|
1140
|
+
processing_id=processing_id,
|
|
1141
|
+
status=status,
|
|
1142
|
+
message=message,
|
|
1143
|
+
progress=progress,
|
|
1144
|
+
total_files=1,
|
|
1145
|
+
files_completed=1 if status == "completed" else 0,
|
|
1146
|
+
file_progress=current_file_progress
|
|
1147
|
+
)
|
|
1148
|
+
|
|
1149
|
+
documents = await self.ingestion_manager.ingest_from_source(
|
|
1150
|
+
source_type="youtube",
|
|
1151
|
+
config=youtube_config,
|
|
1152
|
+
processing_id=processing_id,
|
|
1153
|
+
status_callback=youtube_status_callback
|
|
1154
|
+
)
|
|
1155
|
+
|
|
1156
|
+
# Store documents in PROCESSING_STATUS for document_state creation
|
|
1157
|
+
PROCESSING_STATUS[processing_id]["documents"] = documents
|
|
1158
|
+
|
|
1159
|
+
await self.system._process_documents_direct(documents, processing_id=processing_id, status_callback=youtube_status_callback, skip_graph=skip_graph, config_id=config_id)
|
|
1160
|
+
|
|
1161
|
+
elif data_source == "wikipedia":
|
|
1162
|
+
# Initialize progress tracking for Wikipedia source
|
|
1163
|
+
wikipedia_config = kwargs.get('wikipedia_config')
|
|
1164
|
+
if not wikipedia_config:
|
|
1165
|
+
raise ValueError("Wikipedia configuration is required for Wikipedia data source")
|
|
1166
|
+
|
|
1167
|
+
# Get query for display
|
|
1168
|
+
query = wikipedia_config.get('query', 'Wikipedia Article')
|
|
1169
|
+
file_progress = self._initialize_data_source_progress(processing_id, "wikipedia", query)
|
|
1170
|
+
|
|
1171
|
+
self._update_processing_status(
|
|
1172
|
+
processing_id,
|
|
1173
|
+
"processing",
|
|
1174
|
+
"Connecting to Wikipedia...",
|
|
1175
|
+
20,
|
|
1176
|
+
total_files=1,
|
|
1177
|
+
files_completed=0,
|
|
1178
|
+
file_progress=file_progress
|
|
1179
|
+
)
|
|
1180
|
+
|
|
1181
|
+
# Update data source progress
|
|
1182
|
+
self._update_data_source_progress(processing_id, "processing", 20, "connecting", "Connecting to Wikipedia...")
|
|
1183
|
+
|
|
1184
|
+
# Check for cancellation before connecting
|
|
1185
|
+
if self._is_processing_cancelled(processing_id):
|
|
1186
|
+
return
|
|
1187
|
+
|
|
1188
|
+
self._update_processing_status(
|
|
1189
|
+
processing_id,
|
|
1190
|
+
"processing",
|
|
1191
|
+
"Processing Wikipedia content...",
|
|
1192
|
+
60,
|
|
1193
|
+
total_files=1,
|
|
1194
|
+
files_completed=0,
|
|
1195
|
+
file_progress=file_progress
|
|
1196
|
+
)
|
|
1197
|
+
|
|
1198
|
+
# Update data source progress
|
|
1199
|
+
self._update_data_source_progress(processing_id, "processing", 60, "loading", "Processing Wikipedia content...")
|
|
1200
|
+
|
|
1201
|
+
# Create status callback that updates both overall and individual progress
|
|
1202
|
+
def wikipedia_status_callback(**cb_kwargs):
|
|
1203
|
+
status = cb_kwargs.get("status", "processing")
|
|
1204
|
+
progress = cb_kwargs.get("progress", 0)
|
|
1205
|
+
message = cb_kwargs.get("message", "")
|
|
1206
|
+
|
|
1207
|
+
# Update data source progress
|
|
1208
|
+
self._update_data_source_progress(processing_id, status, progress, "processing", message)
|
|
1209
|
+
|
|
1210
|
+
# Update overall status
|
|
1211
|
+
current_status = PROCESSING_STATUS.get(processing_id, {})
|
|
1212
|
+
current_file_progress = current_status.get("individual_files", file_progress)
|
|
1213
|
+
|
|
1214
|
+
self._update_processing_status(
|
|
1215
|
+
processing_id=processing_id,
|
|
1216
|
+
status=status,
|
|
1217
|
+
message=message,
|
|
1218
|
+
progress=progress,
|
|
1219
|
+
total_files=1,
|
|
1220
|
+
files_completed=1 if status == "completed" else 0,
|
|
1221
|
+
file_progress=current_file_progress
|
|
1222
|
+
)
|
|
1223
|
+
|
|
1224
|
+
documents = await self.ingestion_manager.ingest_from_source(
|
|
1225
|
+
source_type="wikipedia",
|
|
1226
|
+
config=wikipedia_config,
|
|
1227
|
+
processing_id=processing_id,
|
|
1228
|
+
status_callback=wikipedia_status_callback
|
|
1229
|
+
)
|
|
1230
|
+
|
|
1231
|
+
# Store documents in PROCESSING_STATUS for document_state creation
|
|
1232
|
+
PROCESSING_STATUS[processing_id]["documents"] = documents
|
|
1233
|
+
|
|
1234
|
+
await self.system._process_documents_direct(documents, processing_id=processing_id, status_callback=wikipedia_status_callback, skip_graph=skip_graph, config_id=config_id)
|
|
1235
|
+
|
|
1236
|
+
elif data_source == "s3":
|
|
1237
|
+
# Initialize progress tracking for S3 source
|
|
1238
|
+
s3_config = kwargs.get('s3_config')
|
|
1239
|
+
if not s3_config:
|
|
1240
|
+
raise ValueError("S3 configuration is required for S3 data source")
|
|
1241
|
+
|
|
1242
|
+
# Get bucket and prefix for display
|
|
1243
|
+
bucket_name = s3_config.get('bucket_name', 'S3 Bucket')
|
|
1244
|
+
prefix = s3_config.get('prefix', '')
|
|
1245
|
+
|
|
1246
|
+
# Create display name with bucket and prefix
|
|
1247
|
+
if prefix:
|
|
1248
|
+
display_name = f's3://{bucket_name}/{prefix}'
|
|
1249
|
+
else:
|
|
1250
|
+
display_name = f's3://{bucket_name}'
|
|
1251
|
+
|
|
1252
|
+
file_progress = self._initialize_data_source_progress(processing_id, "s3", display_name)
|
|
1253
|
+
|
|
1254
|
+
self._update_processing_status(
|
|
1255
|
+
processing_id,
|
|
1256
|
+
"processing",
|
|
1257
|
+
"Connecting to Amazon S3...",
|
|
1258
|
+
20,
|
|
1259
|
+
total_files=1,
|
|
1260
|
+
files_completed=0,
|
|
1261
|
+
file_progress=file_progress
|
|
1262
|
+
)
|
|
1263
|
+
|
|
1264
|
+
# Update data source progress
|
|
1265
|
+
self._update_data_source_progress(processing_id, "processing", 20, "connecting", "Connecting to Amazon S3...")
|
|
1266
|
+
|
|
1267
|
+
# Check for cancellation before connecting
|
|
1268
|
+
if self._is_processing_cancelled(processing_id):
|
|
1269
|
+
return
|
|
1270
|
+
|
|
1271
|
+
self._update_processing_status(
|
|
1272
|
+
processing_id,
|
|
1273
|
+
"processing",
|
|
1274
|
+
"Processing S3 documents...",
|
|
1275
|
+
60,
|
|
1276
|
+
total_files=1,
|
|
1277
|
+
files_completed=0,
|
|
1278
|
+
file_progress=file_progress
|
|
1279
|
+
)
|
|
1280
|
+
|
|
1281
|
+
# Update data source progress
|
|
1282
|
+
self._update_data_source_progress(processing_id, "processing", 60, "loading", "Processing S3 documents...")
|
|
1283
|
+
|
|
1284
|
+
# Create status callback that updates both overall and individual progress
|
|
1285
|
+
def s3_status_callback(**cb_kwargs):
|
|
1286
|
+
status = cb_kwargs.get("status", "processing")
|
|
1287
|
+
progress = cb_kwargs.get("progress", 0)
|
|
1288
|
+
message = cb_kwargs.get("message", "")
|
|
1289
|
+
|
|
1290
|
+
# Update data source progress (this internally calls _update_processing_status)
|
|
1291
|
+
self._update_data_source_progress(processing_id, status, progress, "processing", message)
|
|
1292
|
+
|
|
1293
|
+
documents = await self.ingestion_manager.ingest_from_source(
|
|
1294
|
+
source_type="s3",
|
|
1295
|
+
config=s3_config,
|
|
1296
|
+
processing_id=processing_id,
|
|
1297
|
+
status_callback=s3_status_callback
|
|
1298
|
+
)
|
|
1299
|
+
|
|
1300
|
+
# Store documents in PROCESSING_STATUS for document_state creation
|
|
1301
|
+
PROCESSING_STATUS[processing_id]["documents"] = documents
|
|
1302
|
+
|
|
1303
|
+
await self.system._process_documents_direct(documents, processing_id=processing_id, status_callback=s3_status_callback, skip_graph=skip_graph, config_id=config_id)
|
|
1304
|
+
|
|
1305
|
+
elif data_source == "gcs":
|
|
1306
|
+
gcs_config = kwargs.get('gcs_config', {})
|
|
1307
|
+
bucket_name = gcs_config.get('bucket_name', 'GCS Bucket')
|
|
1308
|
+
await self._process_modular_data_source(
|
|
1309
|
+
processing_id=processing_id,
|
|
1310
|
+
data_source="gcs",
|
|
1311
|
+
config_key="gcs_config",
|
|
1312
|
+
display_name=bucket_name,
|
|
1313
|
+
connect_message="Connecting to Google Cloud Storage...",
|
|
1314
|
+
process_message="Processing GCS documents...",
|
|
1315
|
+
config_id=config_id,
|
|
1316
|
+
skip_graph=skip_graph, # Pass explicitly as named parameter
|
|
1317
|
+
**kwargs
|
|
1318
|
+
)
|
|
1319
|
+
|
|
1320
|
+
elif data_source == "azure_blob":
|
|
1321
|
+
azure_blob_config = kwargs.get('azure_blob_config', {})
|
|
1322
|
+
container_name = azure_blob_config.get('container_name', 'Container')
|
|
1323
|
+
account_url = azure_blob_config.get('account_url', '')
|
|
1324
|
+
# Extract account name from URL for display
|
|
1325
|
+
if account_url:
|
|
1326
|
+
try:
|
|
1327
|
+
from urllib.parse import urlparse
|
|
1328
|
+
parsed = urlparse(account_url)
|
|
1329
|
+
account_name = parsed.hostname.split('.')[0] if parsed.hostname else 'Azure'
|
|
1330
|
+
display_name = f'Azure: {account_name}/{container_name}'
|
|
1331
|
+
except:
|
|
1332
|
+
display_name = f'Azure: {container_name}'
|
|
1333
|
+
else:
|
|
1334
|
+
display_name = f'Azure: {container_name}'
|
|
1335
|
+
await self._process_modular_data_source(
|
|
1336
|
+
processing_id=processing_id,
|
|
1337
|
+
data_source="azure_blob",
|
|
1338
|
+
config_key="azure_blob_config",
|
|
1339
|
+
display_name=display_name,
|
|
1340
|
+
connect_message="Connecting to Azure Blob Storage...",
|
|
1341
|
+
process_message="Processing Azure Blob Storage documents...",
|
|
1342
|
+
config_id=config_id,
|
|
1343
|
+
skip_graph=skip_graph, # Pass explicitly as named parameter
|
|
1344
|
+
**kwargs
|
|
1345
|
+
)
|
|
1346
|
+
|
|
1347
|
+
elif data_source == "onedrive":
|
|
1348
|
+
onedrive_config = kwargs.get('onedrive_config', {})
|
|
1349
|
+
user_principal_name = onedrive_config.get('user_principal_name', '')
|
|
1350
|
+
folder_path = onedrive_config.get('folder_path', '')
|
|
1351
|
+
folder_id = onedrive_config.get('folder_id', '')
|
|
1352
|
+
|
|
1353
|
+
# Create display name using user principal name and folder info
|
|
1354
|
+
if user_principal_name:
|
|
1355
|
+
if folder_path:
|
|
1356
|
+
display_name = f'OneDrive: {user_principal_name}{folder_path}'
|
|
1357
|
+
elif folder_id:
|
|
1358
|
+
display_name = f'OneDrive: {user_principal_name} (Folder ID: {folder_id})'
|
|
1359
|
+
else:
|
|
1360
|
+
display_name = f'OneDrive: {user_principal_name}'
|
|
1361
|
+
else:
|
|
1362
|
+
display_name = 'OneDrive'
|
|
1363
|
+
|
|
1364
|
+
await self._process_modular_data_source(
|
|
1365
|
+
processing_id=processing_id,
|
|
1366
|
+
data_source="onedrive",
|
|
1367
|
+
config_key="onedrive_config",
|
|
1368
|
+
display_name=display_name,
|
|
1369
|
+
connect_message="Connecting to Microsoft OneDrive...",
|
|
1370
|
+
process_message="Processing OneDrive documents...",
|
|
1371
|
+
config_id=config_id,
|
|
1372
|
+
skip_graph=skip_graph, # Pass explicitly as named parameter
|
|
1373
|
+
**kwargs
|
|
1374
|
+
)
|
|
1375
|
+
|
|
1376
|
+
elif data_source == "sharepoint":
|
|
1377
|
+
sharepoint_config = kwargs.get('sharepoint_config', {})
|
|
1378
|
+
site_name = sharepoint_config.get('site_name', '')
|
|
1379
|
+
site_id = sharepoint_config.get('site_id', '')
|
|
1380
|
+
folder_path = sharepoint_config.get('folder_path', '')
|
|
1381
|
+
folder_id = sharepoint_config.get('folder_id', '')
|
|
1382
|
+
|
|
1383
|
+
# Create display name using site name and folder info
|
|
1384
|
+
if site_name:
|
|
1385
|
+
if folder_path:
|
|
1386
|
+
display_name = f'SharePoint: {site_name}{folder_path}'
|
|
1387
|
+
elif folder_id:
|
|
1388
|
+
display_name = f'SharePoint: {site_name} (Folder ID: {folder_id})'
|
|
1389
|
+
else:
|
|
1390
|
+
display_name = f'SharePoint: {site_name}'
|
|
1391
|
+
elif site_id:
|
|
1392
|
+
display_name = f'SharePoint: Site ID {site_id}'
|
|
1393
|
+
else:
|
|
1394
|
+
display_name = 'SharePoint'
|
|
1395
|
+
|
|
1396
|
+
await self._process_modular_data_source(
|
|
1397
|
+
processing_id=processing_id,
|
|
1398
|
+
data_source="sharepoint",
|
|
1399
|
+
config_key="sharepoint_config",
|
|
1400
|
+
display_name=display_name,
|
|
1401
|
+
connect_message="Connecting to Microsoft SharePoint...",
|
|
1402
|
+
process_message="Processing SharePoint documents...",
|
|
1403
|
+
config_id=config_id,
|
|
1404
|
+
skip_graph=skip_graph, # Pass explicitly as named parameter
|
|
1405
|
+
**kwargs
|
|
1406
|
+
)
|
|
1407
|
+
|
|
1408
|
+
elif data_source == "box":
|
|
1409
|
+
box_config = kwargs.get('box_config', {})
|
|
1410
|
+
folder_id = box_config.get('folder_id', 'Box Folder')
|
|
1411
|
+
await self._process_modular_data_source(
|
|
1412
|
+
processing_id=processing_id,
|
|
1413
|
+
data_source="box",
|
|
1414
|
+
config_key="box_config",
|
|
1415
|
+
display_name=folder_id,
|
|
1416
|
+
connect_message="Connecting to Box...",
|
|
1417
|
+
process_message="Processing Box documents...",
|
|
1418
|
+
config_id=config_id,
|
|
1419
|
+
skip_graph=skip_graph, # Pass explicitly as named parameter
|
|
1420
|
+
**kwargs
|
|
1421
|
+
)
|
|
1422
|
+
|
|
1423
|
+
elif data_source == "google_drive":
|
|
1424
|
+
google_drive_config = kwargs.get('google_drive_config', {})
|
|
1425
|
+
# Use folder_id if provided, otherwise generic name
|
|
1426
|
+
folder_id = google_drive_config.get('folder_id')
|
|
1427
|
+
if folder_id:
|
|
1428
|
+
display_name = f'Google Drive: {folder_id}'
|
|
1429
|
+
else:
|
|
1430
|
+
display_name = 'Google Drive'
|
|
1431
|
+
await self._process_modular_data_source(
|
|
1432
|
+
processing_id=processing_id,
|
|
1433
|
+
data_source="google_drive",
|
|
1434
|
+
config_key="google_drive_config",
|
|
1435
|
+
display_name=display_name,
|
|
1436
|
+
connect_message="Connecting to Google Drive...",
|
|
1437
|
+
process_message="Processing Google Drive documents...",
|
|
1438
|
+
config_id=config_id,
|
|
1439
|
+
skip_graph=skip_graph, # Pass explicitly as named parameter
|
|
1440
|
+
**kwargs
|
|
1441
|
+
)
|
|
1442
|
+
|
|
1443
|
+
else:
|
|
1444
|
+
self._update_processing_status(
|
|
1445
|
+
processing_id,
|
|
1446
|
+
"failed",
|
|
1447
|
+
f"Unsupported data source: {data_source}",
|
|
1448
|
+
0
|
|
1449
|
+
)
|
|
1450
|
+
|
|
1451
|
+
except RuntimeError as e:
|
|
1452
|
+
if "cancelled by user" in str(e):
|
|
1453
|
+
logger.info(f"Processing {processing_id} was cancelled by user")
|
|
1454
|
+
# Clean up any partial indexes that might have been created
|
|
1455
|
+
await self._cleanup_partial_processing(processing_id)
|
|
1456
|
+
else:
|
|
1457
|
+
import traceback
|
|
1458
|
+
error_msg = str(e) if str(e) else repr(e)
|
|
1459
|
+
logger.error(f"Runtime error in processing {processing_id}: {error_msg}")
|
|
1460
|
+
logger.error(f"Full traceback:\n{traceback.format_exc()}")
|
|
1461
|
+
self._update_processing_status(
|
|
1462
|
+
processing_id,
|
|
1463
|
+
"failed",
|
|
1464
|
+
f"Document processing failed: {error_msg}",
|
|
1465
|
+
0
|
|
1466
|
+
)
|
|
1467
|
+
except Exception as e:
|
|
1468
|
+
import traceback
|
|
1469
|
+
# Handle LLM self-cancellation and timeout errors gracefully
|
|
1470
|
+
error_str = str(e).lower() if str(e) else ""
|
|
1471
|
+
error_msg = str(e) if str(e) else repr(e)
|
|
1472
|
+
|
|
1473
|
+
if any(keyword in error_str for keyword in ['timeout', 'timed out', 'request timeout', 'connection timeout']):
|
|
1474
|
+
logger.warning(f"LLM timeout in processing {processing_id}: {error_msg}")
|
|
1475
|
+
logger.error(f"Full traceback:\n{traceback.format_exc()}")
|
|
1476
|
+
self._update_processing_status(
|
|
1477
|
+
processing_id,
|
|
1478
|
+
"failed",
|
|
1479
|
+
f"Processing timeout - LLM took too long to respond. Try increasing timeout or using smaller documents: {error_msg}",
|
|
1480
|
+
0
|
|
1481
|
+
)
|
|
1482
|
+
elif any(keyword in error_str for keyword in ['cancelled', 'aborted', 'interrupted']):
|
|
1483
|
+
logger.warning(f"LLM self-cancelled in processing {processing_id}: {error_msg}")
|
|
1484
|
+
logger.error(f"Full traceback:\n{traceback.format_exc()}")
|
|
1485
|
+
self._update_processing_status(
|
|
1486
|
+
processing_id,
|
|
1487
|
+
"failed",
|
|
1488
|
+
f"LLM processing was interrupted. This can happen with complex documents: {error_msg}",
|
|
1489
|
+
0
|
|
1490
|
+
)
|
|
1491
|
+
else:
|
|
1492
|
+
logger.error(f"Error ingesting documents {processing_id}: {error_msg}")
|
|
1493
|
+
logger.error(f"Error type: {type(e).__name__}")
|
|
1494
|
+
logger.error(f"Full traceback:\n{traceback.format_exc()}")
|
|
1495
|
+
self._update_processing_status(
|
|
1496
|
+
processing_id,
|
|
1497
|
+
"failed",
|
|
1498
|
+
f"Document processing failed: {error_msg}",
|
|
1499
|
+
0
|
|
1500
|
+
)
|
|
1501
|
+
|
|
1502
|
+
async def search_documents(self, query: str, top_k: int = 10) -> Dict[str, Any]:
|
|
1503
|
+
"""Search documents using hybrid search"""
|
|
1504
|
+
start_time = datetime.now()
|
|
1505
|
+
logger.info(f"Search query started at {start_time.strftime('%H:%M:%S.%f')[:-3]} - Query: '{query}' (top_k={top_k})")
|
|
1506
|
+
|
|
1507
|
+
try:
|
|
1508
|
+
results = await self.system.search(query, top_k=top_k)
|
|
1509
|
+
|
|
1510
|
+
end_time = datetime.now()
|
|
1511
|
+
duration = (end_time - start_time).total_seconds()
|
|
1512
|
+
logger.info(f"Search query completed in {duration:.3f}s - Returning {len(results)} final results (post-deduplication)")
|
|
1513
|
+
|
|
1514
|
+
return {"success": True, "results": results, "query_time": f"{duration:.3f}s"}
|
|
1515
|
+
except Exception as e:
|
|
1516
|
+
end_time = datetime.now()
|
|
1517
|
+
duration = (end_time - start_time).total_seconds()
|
|
1518
|
+
logger.error(f"Search query failed after {duration:.3f}s: {str(e)}")
|
|
1519
|
+
return {"success": False, "error": str(e), "query_time": f"{duration:.3f}s"}
|
|
1520
|
+
|
|
1521
|
+
async def qa_query(self, query: str) -> Dict[str, Any]:
|
|
1522
|
+
"""Answer a question using the Q&A system"""
|
|
1523
|
+
start_time = datetime.now()
|
|
1524
|
+
logger.info(f"Q&A query started at {start_time.strftime('%H:%M:%S.%f')[:-3]} - Query: '{query}'")
|
|
1525
|
+
|
|
1526
|
+
try:
|
|
1527
|
+
# Ensure Weaviate async client is connected before Q&A query
|
|
1528
|
+
if self.system.vector_store and type(self.system.vector_store).__name__ == "WeaviateVectorStore":
|
|
1529
|
+
if hasattr(self.system.vector_store, '_aclient') and self.system.vector_store._aclient is not None:
|
|
1530
|
+
if not self.system.vector_store._aclient.is_connected():
|
|
1531
|
+
await self.system.vector_store._aclient.connect()
|
|
1532
|
+
logger.info("Connected Weaviate async client for Q&A query")
|
|
1533
|
+
|
|
1534
|
+
query_engine = self.system.get_query_engine()
|
|
1535
|
+
|
|
1536
|
+
# Use async method directly (nest_asyncio.apply() called at module level)
|
|
1537
|
+
try:
|
|
1538
|
+
response = await query_engine.aquery(query)
|
|
1539
|
+
except Exception as e:
|
|
1540
|
+
error_msg = str(e)
|
|
1541
|
+
# Handle index/collection not found errors gracefully
|
|
1542
|
+
if ('index_not_found_exception' in error_msg or
|
|
1543
|
+
'no such index' in error_msg or
|
|
1544
|
+
"doesn't exist" in error_msg or
|
|
1545
|
+
'Not found' in error_msg or
|
|
1546
|
+
'could not find class' in error_msg or # Weaviate collection not found
|
|
1547
|
+
'NotFoundError' in str(type(e))):
|
|
1548
|
+
logger.warning(f"Collection/Index not found during Q&A query: {error_msg}")
|
|
1549
|
+
end_time = datetime.now()
|
|
1550
|
+
duration = (end_time - start_time).total_seconds()
|
|
1551
|
+
return {
|
|
1552
|
+
"success": True, # Don't show as error in UI
|
|
1553
|
+
"answer": "No documents have been indexed yet.",
|
|
1554
|
+
"query_time": f"{duration:.3f}s",
|
|
1555
|
+
"sources": []
|
|
1556
|
+
}
|
|
1557
|
+
else:
|
|
1558
|
+
# Re-raise other exceptions
|
|
1559
|
+
raise
|
|
1560
|
+
|
|
1561
|
+
end_time = datetime.now()
|
|
1562
|
+
duration = (end_time - start_time).total_seconds()
|
|
1563
|
+
answer = str(response)
|
|
1564
|
+
logger.info(f"Q&A query completed in {duration:.3f}s - Answer length: {len(answer)} characters")
|
|
1565
|
+
|
|
1566
|
+
# Record LLM generation metrics for observability
|
|
1567
|
+
if hasattr(self.system, '_observability_enabled') and self.system._observability_enabled:
|
|
1568
|
+
try:
|
|
1569
|
+
from observability.metrics import get_rag_metrics
|
|
1570
|
+
metrics = get_rag_metrics()
|
|
1571
|
+
generation_latency_ms = duration * 1000
|
|
1572
|
+
|
|
1573
|
+
# Extract token counts from response metadata if available
|
|
1574
|
+
prompt_tokens = 0
|
|
1575
|
+
completion_tokens = 0
|
|
1576
|
+
if hasattr(response, 'metadata') and response.metadata:
|
|
1577
|
+
prompt_tokens = response.metadata.get('prompt_tokens', 0)
|
|
1578
|
+
completion_tokens = response.metadata.get('completion_tokens', 0)
|
|
1579
|
+
elif hasattr(response, 'source_nodes'):
|
|
1580
|
+
# Try to get from source nodes metadata
|
|
1581
|
+
for node in response.source_nodes:
|
|
1582
|
+
if hasattr(node, 'metadata') and node.metadata:
|
|
1583
|
+
prompt_tokens += node.metadata.get('prompt_tokens', 0)
|
|
1584
|
+
completion_tokens += node.metadata.get('completion_tokens', 0)
|
|
1585
|
+
|
|
1586
|
+
metrics.record_llm_call(
|
|
1587
|
+
latency_ms=generation_latency_ms,
|
|
1588
|
+
prompt_tokens=prompt_tokens,
|
|
1589
|
+
completion_tokens=completion_tokens,
|
|
1590
|
+
attributes={"operation": "qa_query", "query_length": len(query)}
|
|
1591
|
+
)
|
|
1592
|
+
logger.info(f"Recorded LLM generation metrics: {generation_latency_ms:.2f}ms, {prompt_tokens} prompt tokens, {completion_tokens} completion tokens")
|
|
1593
|
+
except Exception as e:
|
|
1594
|
+
logger.warning(f"Failed to record LLM metrics: {e}")
|
|
1595
|
+
|
|
1596
|
+
return {"success": True, "answer": answer, "query_time": f"{duration:.3f}s"}
|
|
1597
|
+
except Exception as e:
|
|
1598
|
+
end_time = datetime.now()
|
|
1599
|
+
duration = (end_time - start_time).total_seconds()
|
|
1600
|
+
logger.error(f"Q&A query failed after {duration:.3f}s: {str(e)}")
|
|
1601
|
+
return {"success": False, "error": str(e), "query_time": f"{duration:.3f}s"}
|
|
1602
|
+
|
|
1603
|
+
async def query_documents(self, query: str, top_k: int = 10) -> Dict[str, Any]:
|
|
1604
|
+
"""Query documents with AI-generated answers"""
|
|
1605
|
+
start_time = datetime.now()
|
|
1606
|
+
logger.info(f"Document query started at {start_time.strftime('%H:%M:%S.%f')[:-3]} - Query: '{query}'")
|
|
1607
|
+
|
|
1608
|
+
try:
|
|
1609
|
+
query_engine = self.system.get_query_engine()
|
|
1610
|
+
|
|
1611
|
+
# Use async method directly (nest_asyncio.apply() called at module level)
|
|
1612
|
+
try:
|
|
1613
|
+
response = await query_engine.aquery(query)
|
|
1614
|
+
except Exception as e:
|
|
1615
|
+
error_msg = str(e)
|
|
1616
|
+
# Handle index/collection not found errors gracefully
|
|
1617
|
+
if ('index_not_found_exception' in error_msg or
|
|
1618
|
+
'no such index' in error_msg or
|
|
1619
|
+
"doesn't exist" in error_msg or
|
|
1620
|
+
'Not found' in error_msg or
|
|
1621
|
+
'NotFoundError' in str(type(e))):
|
|
1622
|
+
logger.warning(f"Collection/Index not found during document query: {error_msg}")
|
|
1623
|
+
end_time = datetime.now()
|
|
1624
|
+
duration = (end_time - start_time).total_seconds()
|
|
1625
|
+
return {
|
|
1626
|
+
"success": True, # Don't show as error in UI
|
|
1627
|
+
"answer": "No documents have been indexed yet.",
|
|
1628
|
+
"query_time": f"{duration:.3f}s",
|
|
1629
|
+
"sources": []
|
|
1630
|
+
}
|
|
1631
|
+
else:
|
|
1632
|
+
# Re-raise other exceptions
|
|
1633
|
+
raise
|
|
1634
|
+
|
|
1635
|
+
end_time = datetime.now()
|
|
1636
|
+
duration = (end_time - start_time).total_seconds()
|
|
1637
|
+
answer = str(response)
|
|
1638
|
+
logger.info(f"Document query completed in {duration:.3f}s - Answer length: {len(answer)} characters")
|
|
1639
|
+
|
|
1640
|
+
# Record LLM generation metrics for observability
|
|
1641
|
+
if hasattr(self.system, '_observability_enabled') and self.system._observability_enabled:
|
|
1642
|
+
try:
|
|
1643
|
+
from observability.metrics import get_rag_metrics
|
|
1644
|
+
metrics = get_rag_metrics()
|
|
1645
|
+
generation_latency_ms = duration * 1000
|
|
1646
|
+
|
|
1647
|
+
# Extract token counts from response metadata if available
|
|
1648
|
+
prompt_tokens = 0
|
|
1649
|
+
completion_tokens = 0
|
|
1650
|
+
if hasattr(response, 'metadata') and response.metadata:
|
|
1651
|
+
prompt_tokens = response.metadata.get('prompt_tokens', 0)
|
|
1652
|
+
completion_tokens = response.metadata.get('completion_tokens', 0)
|
|
1653
|
+
elif hasattr(response, 'source_nodes'):
|
|
1654
|
+
# Try to get from source nodes metadata
|
|
1655
|
+
for node in response.source_nodes:
|
|
1656
|
+
if hasattr(node, 'metadata') and node.metadata:
|
|
1657
|
+
prompt_tokens += node.metadata.get('prompt_tokens', 0)
|
|
1658
|
+
completion_tokens += node.metadata.get('completion_tokens', 0)
|
|
1659
|
+
|
|
1660
|
+
metrics.record_llm_call(
|
|
1661
|
+
latency_ms=generation_latency_ms,
|
|
1662
|
+
prompt_tokens=prompt_tokens,
|
|
1663
|
+
completion_tokens=completion_tokens,
|
|
1664
|
+
attributes={"operation": "query_documents", "query_length": len(query)}
|
|
1665
|
+
)
|
|
1666
|
+
logger.info(f"Recorded LLM generation metrics: {generation_latency_ms:.2f}ms, {prompt_tokens} prompt tokens, {completion_tokens} completion tokens")
|
|
1667
|
+
except Exception as e:
|
|
1668
|
+
logger.warning(f"Failed to record LLM metrics: {e}")
|
|
1669
|
+
|
|
1670
|
+
return {"success": True, "answer": answer, "query_time": f"{duration:.3f}s"}
|
|
1671
|
+
except Exception as e:
|
|
1672
|
+
end_time = datetime.now()
|
|
1673
|
+
duration = (end_time - start_time).total_seconds()
|
|
1674
|
+
logger.error(f"Document query failed after {duration:.3f}s: {str(e)}")
|
|
1675
|
+
return {"success": False, "error": str(e), "query_time": f"{duration:.3f}s"}
|
|
1676
|
+
|
|
1677
|
+
async def ingest_text(self, content: str, source_name: str = "text_input") -> Dict[str, Any]:
|
|
1678
|
+
"""Start async text ingestion and return processing ID"""
|
|
1679
|
+
processing_id = self._create_processing_id()
|
|
1680
|
+
|
|
1681
|
+
# Start processing immediately in background
|
|
1682
|
+
self._update_processing_status(
|
|
1683
|
+
processing_id,
|
|
1684
|
+
"started",
|
|
1685
|
+
"Complex document processing has started, please wait...",
|
|
1686
|
+
0
|
|
1687
|
+
)
|
|
1688
|
+
|
|
1689
|
+
# Start background task
|
|
1690
|
+
asyncio.create_task(self._process_text_async(processing_id, content, source_name))
|
|
1691
|
+
|
|
1692
|
+
estimated_time = self._estimate_processing_time(content=content)
|
|
1693
|
+
|
|
1694
|
+
return {
|
|
1695
|
+
"processing_id": processing_id,
|
|
1696
|
+
"status": "started",
|
|
1697
|
+
"message": "Text processing has started, please wait...",
|
|
1698
|
+
"estimated_time": estimated_time
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
async def _process_text_async(self, processing_id: str, content: str, source_name: str):
|
|
1702
|
+
"""Background task for text processing"""
|
|
1703
|
+
try:
|
|
1704
|
+
self._update_processing_status(
|
|
1705
|
+
processing_id,
|
|
1706
|
+
"processing",
|
|
1707
|
+
"Creating document and initializing pipeline...",
|
|
1708
|
+
10
|
|
1709
|
+
)
|
|
1710
|
+
|
|
1711
|
+
self._update_processing_status(
|
|
1712
|
+
processing_id,
|
|
1713
|
+
"processing",
|
|
1714
|
+
"Processing text and generating embeddings...",
|
|
1715
|
+
30
|
|
1716
|
+
)
|
|
1717
|
+
|
|
1718
|
+
self._update_processing_status(
|
|
1719
|
+
processing_id,
|
|
1720
|
+
"processing",
|
|
1721
|
+
"Building vector index...",
|
|
1722
|
+
50
|
|
1723
|
+
)
|
|
1724
|
+
|
|
1725
|
+
self._update_processing_status(
|
|
1726
|
+
processing_id,
|
|
1727
|
+
"processing",
|
|
1728
|
+
"Extracting knowledge graph...",
|
|
1729
|
+
70
|
|
1730
|
+
)
|
|
1731
|
+
|
|
1732
|
+
self._update_processing_status(
|
|
1733
|
+
processing_id,
|
|
1734
|
+
"processing",
|
|
1735
|
+
"Creating graph index and relationships...",
|
|
1736
|
+
85
|
|
1737
|
+
)
|
|
1738
|
+
|
|
1739
|
+
# Actual processing with cancellation support
|
|
1740
|
+
await self.system.ingest_text(content=content, source_name=source_name, processing_id=processing_id)
|
|
1741
|
+
|
|
1742
|
+
self._update_processing_status(
|
|
1743
|
+
processing_id,
|
|
1744
|
+
"completed",
|
|
1745
|
+
"Text content ingested successfully! Knowledge graph and vector index ready.",
|
|
1746
|
+
100
|
|
1747
|
+
)
|
|
1748
|
+
|
|
1749
|
+
except RuntimeError as e:
|
|
1750
|
+
if "cancelled by user" in str(e):
|
|
1751
|
+
logger.info(f"Text processing {processing_id} was cancelled by user")
|
|
1752
|
+
# Clean up any partial indexes that might have been created
|
|
1753
|
+
await self._cleanup_partial_processing(processing_id)
|
|
1754
|
+
else:
|
|
1755
|
+
logger.error(f"Runtime error in text processing {processing_id}: {str(e)}")
|
|
1756
|
+
self._update_processing_status(
|
|
1757
|
+
processing_id,
|
|
1758
|
+
"failed",
|
|
1759
|
+
f"Processing failed: {str(e)}",
|
|
1760
|
+
0
|
|
1761
|
+
)
|
|
1762
|
+
except Exception as e:
|
|
1763
|
+
logger.error(f"Error ingesting text {processing_id}: {str(e)}")
|
|
1764
|
+
self._update_processing_status(
|
|
1765
|
+
processing_id,
|
|
1766
|
+
"failed",
|
|
1767
|
+
f"Processing failed: {str(e)}",
|
|
1768
|
+
0
|
|
1769
|
+
)
|
|
1770
|
+
|
|
1771
|
+
def get_system_status(self) -> Dict[str, Any]:
|
|
1772
|
+
"""Get current system status without triggering database initialization"""
|
|
1773
|
+
try:
|
|
1774
|
+
# Return status without initializing databases to avoid APOC calls
|
|
1775
|
+
return {
|
|
1776
|
+
"success": True,
|
|
1777
|
+
"status": {
|
|
1778
|
+
"has_vector_index": self._system is not None and self._system.vector_index is not None,
|
|
1779
|
+
"has_graph_index": self._system is not None and self._system.graph_index is not None,
|
|
1780
|
+
"has_hybrid_retriever": self._system is not None and self._system.hybrid_retriever is not None,
|
|
1781
|
+
"config": {
|
|
1782
|
+
"data_source": self.settings.data_source,
|
|
1783
|
+
"vector_db": self.settings.vector_db,
|
|
1784
|
+
"graph_db": self.settings.graph_db,
|
|
1785
|
+
"search_db": self.settings.search_db,
|
|
1786
|
+
"llm_provider": self.settings.llm_provider
|
|
1787
|
+
},
|
|
1788
|
+
"system_initialized": self._system is not None
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
except Exception as e:
|
|
1792
|
+
logger.error(f"Error getting status: {str(e)}")
|
|
1793
|
+
return {"success": False, "error": str(e)}
|
|
1794
|
+
|
|
1795
|
+
def get_config(self) -> Dict[str, Any]:
|
|
1796
|
+
"""Get current configuration"""
|
|
1797
|
+
return {
|
|
1798
|
+
"success": True,
|
|
1799
|
+
"config": {
|
|
1800
|
+
"data_source": self.settings.data_source,
|
|
1801
|
+
"vector_db": self.settings.vector_db,
|
|
1802
|
+
"graph_db": self.settings.graph_db,
|
|
1803
|
+
"llm_provider": self.settings.llm_provider
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
def health_check(self) -> Dict[str, Any]:
|
|
1808
|
+
"""Health check"""
|
|
1809
|
+
return {"success": True, "status": "ok"}
|
|
1810
|
+
|
|
1811
|
+
def _generate_completion_message(self, doc_count: int) -> str:
|
|
1812
|
+
"""Generate dynamic completion message based on enabled features"""
|
|
1813
|
+
# Check what's actually enabled
|
|
1814
|
+
has_vector = str(self.settings.vector_db) != "none"
|
|
1815
|
+
has_graph = str(self.settings.graph_db) != "none" and self.settings.enable_knowledge_graph
|
|
1816
|
+
has_search = str(self.settings.search_db) != "none"
|
|
1817
|
+
|
|
1818
|
+
# Map database names to proper capitalization
|
|
1819
|
+
db_name_map = {
|
|
1820
|
+
"opensearch": "OpenSearch",
|
|
1821
|
+
"elasticsearch": "Elasticsearch",
|
|
1822
|
+
"qdrant": "Qdrant",
|
|
1823
|
+
"chroma": "Chroma",
|
|
1824
|
+
"neo4j": "Neo4j",
|
|
1825
|
+
"kuzu": "Kuzu",
|
|
1826
|
+
"falkordb": "FalkorDB",
|
|
1827
|
+
"nebula": "NebulaGraph",
|
|
1828
|
+
"bm25": "BM25"
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
# Build feature list
|
|
1832
|
+
features = []
|
|
1833
|
+
if has_vector:
|
|
1834
|
+
features.append("vector index")
|
|
1835
|
+
if has_graph:
|
|
1836
|
+
features.append("knowledge graph")
|
|
1837
|
+
if has_search:
|
|
1838
|
+
search_db = str(self.settings.search_db).lower()
|
|
1839
|
+
search_name = db_name_map.get(search_db, search_db.title())
|
|
1840
|
+
features.append(f"{search_name} search")
|
|
1841
|
+
|
|
1842
|
+
# Create appropriate message
|
|
1843
|
+
if features:
|
|
1844
|
+
feature_text = " and ".join(features)
|
|
1845
|
+
return f"Successfully ingested {doc_count} document(s)! {feature_text.title()} ready."
|
|
1846
|
+
else:
|
|
1847
|
+
# Fallback (shouldn't happen due to validation)
|
|
1848
|
+
return f"Successfully ingested {doc_count} document(s)!"
|
|
1849
|
+
|
|
1850
|
+
# Global backend instance
|
|
1851
|
+
_backend_instance = None
|
|
1852
|
+
|
|
1853
|
+
def get_backend() -> FlexibleGraphRAGBackend:
|
|
1854
|
+
"""Get the global backend instance"""
|
|
1855
|
+
global _backend_instance
|
|
1856
|
+
if _backend_instance is None:
|
|
1857
|
+
_backend_instance = FlexibleGraphRAGBackend()
|
|
1858
|
+
return _backend_instance
|