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.
Files changed (71) hide show
  1. backend.py +1858 -0
  2. check_elasticsearch.py +76 -0
  3. cleanup.py +655 -0
  4. cmis_util.py +69 -0
  5. config.py +574 -0
  6. document_processor.py +817 -0
  7. factories.py +1463 -0
  8. flexible_graphrag-0.4.0.data/data/Dockerfile +89 -0
  9. flexible_graphrag-0.4.0.data/data/env-sample.txt +595 -0
  10. flexible_graphrag-0.4.0.data/data/requirements.txt +135 -0
  11. flexible_graphrag-0.4.0.data/data/uv.toml +53 -0
  12. flexible_graphrag-0.4.0.dist-info/METADATA +135 -0
  13. flexible_graphrag-0.4.0.dist-info/RECORD +71 -0
  14. flexible_graphrag-0.4.0.dist-info/WHEEL +5 -0
  15. flexible_graphrag-0.4.0.dist-info/entry_points.txt +2 -0
  16. flexible_graphrag-0.4.0.dist-info/top_level.txt +18 -0
  17. hybrid_system.py +2764 -0
  18. incremental_system.py +237 -0
  19. incremental_updates/__init__.py +17 -0
  20. incremental_updates/config_manager.py +260 -0
  21. incremental_updates/datasource-queries.sql +303 -0
  22. incremental_updates/detectors/README.md +532 -0
  23. incremental_updates/detectors/__init__.py +45 -0
  24. incremental_updates/detectors/alfresco_broadcaster.py +274 -0
  25. incremental_updates/detectors/alfresco_detector.py +1551 -0
  26. incremental_updates/detectors/azure_blob_detector.py +780 -0
  27. incremental_updates/detectors/base.py +111 -0
  28. incremental_updates/detectors/box_detector.py +634 -0
  29. incremental_updates/detectors/factory.py +62 -0
  30. incremental_updates/detectors/filesystem_detector.py +535 -0
  31. incremental_updates/detectors/gcs_detector.py +830 -0
  32. incremental_updates/detectors/google_drive_detector.py +794 -0
  33. incremental_updates/detectors/msgraph_detector.py +911 -0
  34. incremental_updates/detectors/s3_detector.py +700 -0
  35. incremental_updates/diagnostic_queries.sql +177 -0
  36. incremental_updates/engine.py +997 -0
  37. incremental_updates/logging_config.py +178 -0
  38. incremental_updates/orchestrator.py +418 -0
  39. incremental_updates/path_utils.py +23 -0
  40. incremental_updates/s3_helpers.py +452 -0
  41. incremental_updates/schema.sql +81 -0
  42. incremental_updates/state_manager.py +353 -0
  43. ingest/__init__.py +13 -0
  44. ingest/factory.py +82 -0
  45. ingest/manager.py +229 -0
  46. install.py +52 -0
  47. main.py +1468 -0
  48. neptune_database_wrapper.py +131 -0
  49. observability/__init__.py +25 -0
  50. observability/custom_hooks.py +252 -0
  51. observability/metrics.py +172 -0
  52. observability/telemetry_openlit.py +163 -0
  53. observability/telemetry_setup.py +222 -0
  54. post_ingestion_state.py +586 -0
  55. sources/__init__.py +38 -0
  56. sources/alfresco.py +856 -0
  57. sources/azure_blob.py +267 -0
  58. sources/base.py +89 -0
  59. sources/box.py +244 -0
  60. sources/cmis.py +356 -0
  61. sources/filesystem.py +217 -0
  62. sources/gcs.py +274 -0
  63. sources/google_drive.py +278 -0
  64. sources/onedrive.py +313 -0
  65. sources/passthrough_extractor.py +257 -0
  66. sources/s3.py +342 -0
  67. sources/sharepoint.py +556 -0
  68. sources/web.py +105 -0
  69. sources/wikipedia.py +389 -0
  70. sources/youtube.py +219 -0
  71. 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