agent-brain-rag 1.2.0__py3-none-any.whl → 3.0.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 (51) hide show
  1. {agent_brain_rag-1.2.0.dist-info → agent_brain_rag-3.0.0.dist-info}/METADATA +55 -18
  2. agent_brain_rag-3.0.0.dist-info/RECORD +56 -0
  3. {agent_brain_rag-1.2.0.dist-info → agent_brain_rag-3.0.0.dist-info}/WHEEL +1 -1
  4. {agent_brain_rag-1.2.0.dist-info → agent_brain_rag-3.0.0.dist-info}/entry_points.txt +0 -1
  5. agent_brain_server/__init__.py +1 -1
  6. agent_brain_server/api/main.py +146 -45
  7. agent_brain_server/api/routers/__init__.py +2 -0
  8. agent_brain_server/api/routers/health.py +85 -21
  9. agent_brain_server/api/routers/index.py +108 -36
  10. agent_brain_server/api/routers/jobs.py +111 -0
  11. agent_brain_server/config/provider_config.py +352 -0
  12. agent_brain_server/config/settings.py +22 -5
  13. agent_brain_server/indexing/__init__.py +21 -0
  14. agent_brain_server/indexing/bm25_index.py +15 -2
  15. agent_brain_server/indexing/document_loader.py +45 -4
  16. agent_brain_server/indexing/embedding.py +86 -135
  17. agent_brain_server/indexing/graph_extractors.py +582 -0
  18. agent_brain_server/indexing/graph_index.py +536 -0
  19. agent_brain_server/job_queue/__init__.py +11 -0
  20. agent_brain_server/job_queue/job_service.py +317 -0
  21. agent_brain_server/job_queue/job_store.py +427 -0
  22. agent_brain_server/job_queue/job_worker.py +434 -0
  23. agent_brain_server/locking.py +101 -8
  24. agent_brain_server/models/__init__.py +28 -0
  25. agent_brain_server/models/graph.py +253 -0
  26. agent_brain_server/models/health.py +30 -3
  27. agent_brain_server/models/job.py +289 -0
  28. agent_brain_server/models/query.py +16 -3
  29. agent_brain_server/project_root.py +1 -1
  30. agent_brain_server/providers/__init__.py +64 -0
  31. agent_brain_server/providers/base.py +251 -0
  32. agent_brain_server/providers/embedding/__init__.py +23 -0
  33. agent_brain_server/providers/embedding/cohere.py +163 -0
  34. agent_brain_server/providers/embedding/ollama.py +150 -0
  35. agent_brain_server/providers/embedding/openai.py +118 -0
  36. agent_brain_server/providers/exceptions.py +95 -0
  37. agent_brain_server/providers/factory.py +157 -0
  38. agent_brain_server/providers/summarization/__init__.py +41 -0
  39. agent_brain_server/providers/summarization/anthropic.py +87 -0
  40. agent_brain_server/providers/summarization/gemini.py +96 -0
  41. agent_brain_server/providers/summarization/grok.py +95 -0
  42. agent_brain_server/providers/summarization/ollama.py +114 -0
  43. agent_brain_server/providers/summarization/openai.py +87 -0
  44. agent_brain_server/runtime.py +2 -2
  45. agent_brain_server/services/indexing_service.py +39 -0
  46. agent_brain_server/services/query_service.py +203 -0
  47. agent_brain_server/storage/__init__.py +18 -2
  48. agent_brain_server/storage/graph_store.py +519 -0
  49. agent_brain_server/storage/vector_store.py +35 -0
  50. agent_brain_server/storage_paths.py +5 -3
  51. agent_brain_rag-1.2.0.dist-info/RECORD +0 -31
@@ -1,4 +1,4 @@
1
- """Health check endpoints."""
1
+ """Health check endpoints with non-blocking queue status."""
2
2
 
3
3
  from datetime import datetime, timezone
4
4
  from typing import Literal
@@ -20,6 +20,8 @@ router = APIRouter()
20
20
  async def health_check(request: Request) -> HealthStatus:
21
21
  """Check server health status.
22
22
 
23
+ This endpoint never blocks and always returns quickly.
24
+
23
25
  Returns:
24
26
  HealthStatus with current status:
25
27
  - healthy: Server is running and ready for queries
@@ -27,20 +29,35 @@ async def health_check(request: Request) -> HealthStatus:
27
29
  - degraded: Server is up but some services are unavailable
28
30
  - unhealthy: Server is not operational
29
31
  """
30
- indexing_service = request.app.state.indexing_service
31
32
  vector_store = request.app.state.vector_store
33
+ job_service = getattr(request.app.state, "job_service", None)
32
34
 
33
- # Determine status
35
+ # Determine status using queue service (non-blocking)
34
36
  status: Literal["healthy", "indexing", "degraded", "unhealthy"]
35
- if indexing_service.is_indexing:
37
+ message: str
38
+
39
+ # Check queue status (non-blocking)
40
+ is_indexing = False
41
+ current_folder = None
42
+ if job_service:
43
+ try:
44
+ queue_stats = await job_service.get_queue_stats()
45
+ is_indexing = queue_stats.running > 0
46
+ if is_indexing and queue_stats.current_job_id:
47
+ # Get current job details for message
48
+ current_job = await job_service.get_job(queue_stats.current_job_id)
49
+ if current_job:
50
+ current_folder = current_job.folder_path
51
+ except Exception:
52
+ # Non-blocking: don't fail health check if queue service errors
53
+ pass
54
+
55
+ if is_indexing:
36
56
  status = "indexing"
37
- message = f"Indexing in progress: {indexing_service.state.folder_path}"
57
+ message = f"Indexing in progress: {current_folder or 'unknown'}"
38
58
  elif not vector_store.is_initialized:
39
59
  status = "degraded"
40
60
  message = "Vector store not initialized"
41
- elif indexing_service.state.error:
42
- status = "degraded"
43
- message = f"Last indexing failed: {indexing_service.state.error}"
44
61
  else:
45
62
  status = "healthy"
46
63
  message = "Server is running and ready for queries"
@@ -67,35 +84,82 @@ async def health_check(request: Request) -> HealthStatus:
67
84
  "/status",
68
85
  response_model=IndexingStatus,
69
86
  summary="Indexing Status",
70
- description="Returns detailed indexing status information.",
87
+ description="Returns detailed indexing status information. Never blocks.",
71
88
  )
72
89
  async def indexing_status(request: Request) -> IndexingStatus:
73
90
  """Get detailed indexing status.
74
91
 
92
+ This endpoint never blocks and always returns quickly, even during indexing.
93
+
75
94
  Returns:
76
95
  IndexingStatus with:
77
96
  - total_documents: Number of documents indexed
78
97
  - total_chunks: Number of chunks in vector store
79
98
  - indexing_in_progress: Boolean indicating active indexing
99
+ - queue_pending: Number of pending jobs
100
+ - queue_running: Number of running jobs (0 or 1)
101
+ - current_job_running_time_ms: How long current job has been running
80
102
  - last_indexed_at: Timestamp of last indexing operation
81
103
  - indexed_folders: List of folders that have been indexed
82
104
  """
83
105
  indexing_service = request.app.state.indexing_service
84
- status = await indexing_service.get_status()
106
+ vector_store = request.app.state.vector_store
107
+ job_service = getattr(request.app.state, "job_service", None)
108
+
109
+ # Get vector store count (non-blocking read)
110
+ try:
111
+ total_chunks = (
112
+ await vector_store.get_count() if vector_store.is_initialized else 0
113
+ )
114
+ except Exception:
115
+ total_chunks = 0
116
+
117
+ # Get queue status (non-blocking)
118
+ queue_pending = 0
119
+ queue_running = 0
120
+ current_job_id = None
121
+ current_job_running_time_ms = None
122
+ progress_percent = 0.0
123
+
124
+ if job_service:
125
+ try:
126
+ queue_stats = await job_service.get_queue_stats()
127
+ queue_pending = queue_stats.pending
128
+ queue_running = queue_stats.running
129
+ current_job_id = queue_stats.current_job_id
130
+ current_job_running_time_ms = queue_stats.current_job_running_time_ms
131
+
132
+ # Get progress from current job
133
+ if current_job_id:
134
+ current_job = await job_service.get_job(current_job_id)
135
+ if current_job and current_job.progress:
136
+ progress_percent = current_job.progress.percent_complete
137
+ except Exception:
138
+ # Non-blocking: don't fail status if queue service errors
139
+ pass
140
+
141
+ # Get indexing service status for historical data
142
+ # This is read-only and non-blocking
143
+ service_status = await indexing_service.get_status()
85
144
 
86
145
  return IndexingStatus(
87
- total_documents=status["total_documents"],
88
- total_chunks=status["total_chunks"],
89
- total_doc_chunks=status.get("total_doc_chunks", 0),
90
- total_code_chunks=status.get("total_code_chunks", 0),
91
- indexing_in_progress=status["is_indexing"],
92
- current_job_id=status["current_job_id"],
93
- progress_percent=status["progress_percent"],
146
+ total_documents=service_status.get("total_documents", 0),
147
+ total_chunks=total_chunks,
148
+ total_doc_chunks=service_status.get("total_doc_chunks", 0),
149
+ total_code_chunks=service_status.get("total_code_chunks", 0),
150
+ indexing_in_progress=queue_running > 0,
151
+ current_job_id=current_job_id,
152
+ progress_percent=progress_percent,
94
153
  last_indexed_at=(
95
- datetime.fromisoformat(status["completed_at"])
96
- if status["completed_at"]
154
+ datetime.fromisoformat(service_status["completed_at"])
155
+ if service_status.get("completed_at")
97
156
  else None
98
157
  ),
99
- indexed_folders=status["indexed_folders"],
100
- supported_languages=status.get("supported_languages", []),
158
+ indexed_folders=service_status.get("indexed_folders", []),
159
+ supported_languages=service_status.get("supported_languages", []),
160
+ graph_index=service_status.get("graph_index"),
161
+ # Queue status (Feature 115)
162
+ queue_pending=queue_pending,
163
+ queue_running=queue_running,
164
+ current_job_running_time_ms=current_job_running_time_ms,
101
165
  )
@@ -1,40 +1,52 @@
1
- """Indexing endpoints for document processing."""
1
+ """Indexing endpoints for document processing with job queue support."""
2
2
 
3
3
  import os
4
4
  from pathlib import Path
5
5
 
6
- from fastapi import APIRouter, HTTPException, Request, status
6
+ from fastapi import APIRouter, HTTPException, Query, Request, status
7
7
 
8
+ from agent_brain_server.config import settings
8
9
  from agent_brain_server.models import IndexRequest, IndexResponse
9
10
 
10
11
  router = APIRouter()
11
12
 
13
+ # Maximum queue length for backpressure
14
+ MAX_QUEUE_LENGTH = settings.AGENT_BRAIN_MAX_QUEUE
15
+
12
16
 
13
17
  @router.post(
14
18
  "/",
15
19
  response_model=IndexResponse,
16
20
  status_code=status.HTTP_202_ACCEPTED,
17
21
  summary="Index Documents",
18
- description="Start indexing documents from a folder.",
22
+ description="Enqueue a job to index documents from a folder.",
19
23
  )
20
24
  async def index_documents(
21
- request_body: IndexRequest, request: Request
25
+ request_body: IndexRequest,
26
+ request: Request,
27
+ force: bool = Query(False, description="Bypass deduplication and force a new job"),
28
+ allow_external: bool = Query(
29
+ False, description="Allow paths outside the project directory"
30
+ ),
22
31
  ) -> IndexResponse:
23
- """Start indexing documents from the specified folder.
32
+ """Enqueue an indexing job for documents from the specified folder.
24
33
 
25
- This endpoint initiates a background indexing job and returns immediately.
26
- Use the /health/status endpoint to monitor progress.
34
+ This endpoint accepts the request and returns immediately with a job ID.
35
+ The job is processed asynchronously by a background worker.
36
+ Use the /index/jobs/{job_id} endpoint to monitor progress.
27
37
 
28
38
  Args:
29
39
  request_body: IndexRequest with folder_path and optional configuration.
30
40
  request: FastAPI request for accessing app state.
41
+ force: If True, bypass deduplication and create a new job.
42
+ allow_external: If True, allow indexing paths outside the project.
31
43
 
32
44
  Returns:
33
45
  IndexResponse with job_id and status.
34
46
 
35
47
  Raises:
36
- 400: Invalid folder path
37
- 409: Indexing already in progress
48
+ 400: Invalid folder path or path outside project (without allow_external)
49
+ 429: Queue is full (backpressure)
38
50
  """
39
51
  # Validate folder path
40
52
  folder_path = Path(request_body.folder_path).expanduser().resolve()
@@ -57,17 +69,20 @@ async def index_documents(
57
69
  detail=f"Cannot read folder: {request_body.folder_path}",
58
70
  )
59
71
 
60
- # Get indexing service from app state
61
- indexing_service = request.app.state.indexing_service
72
+ # Get job service from app state
73
+ job_service = request.app.state.job_service
62
74
 
63
- # Check if already indexing
64
- if indexing_service.is_indexing:
75
+ # Backpressure check (pending + running to prevent overflow)
76
+ stats = await job_service.get_queue_stats()
77
+ active_jobs = stats.pending + stats.running
78
+ if active_jobs >= MAX_QUEUE_LENGTH:
65
79
  raise HTTPException(
66
- status_code=status.HTTP_409_CONFLICT,
67
- detail="Indexing already in progress. Please wait for completion.",
80
+ status_code=status.HTTP_429_TOO_MANY_REQUESTS,
81
+ detail=f"Queue full ({stats.pending} pending, {stats.running} running). "
82
+ "Try again later.",
68
83
  )
69
84
 
70
- # Start indexing
85
+ # Enqueue the job
71
86
  try:
72
87
  # Update request with resolved path
73
88
  resolved_request = IndexRequest(
@@ -82,17 +97,37 @@ async def index_documents(
82
97
  exclude_patterns=request_body.exclude_patterns,
83
98
  generate_summaries=request_body.generate_summaries,
84
99
  )
85
- job_id = await indexing_service.start_indexing(resolved_request)
100
+
101
+ result = await job_service.enqueue_job(
102
+ request=resolved_request,
103
+ operation="index",
104
+ force=force,
105
+ allow_external=allow_external,
106
+ )
107
+ except ValueError as e:
108
+ # Path validation error (outside project)
109
+ raise HTTPException(
110
+ status_code=status.HTTP_400_BAD_REQUEST,
111
+ detail=str(e),
112
+ ) from e
86
113
  except Exception as e:
87
114
  raise HTTPException(
88
115
  status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
89
- detail=f"Failed to start indexing: {str(e)}",
116
+ detail=f"Failed to enqueue indexing job: {str(e)}",
90
117
  ) from e
91
118
 
119
+ # Build response message
120
+ if result.dedupe_hit:
121
+ message = (
122
+ f"Duplicate detected - existing job {result.job_id} is {result.status}"
123
+ )
124
+ else:
125
+ message = f"Job queued for {request_body.folder_path}"
126
+
92
127
  return IndexResponse(
93
- job_id=job_id,
94
- status="started",
95
- message=f"Indexing started for {request_body.folder_path}",
128
+ job_id=result.job_id,
129
+ status=result.status,
130
+ message=message,
96
131
  )
97
132
 
98
133
 
@@ -101,10 +136,17 @@ async def index_documents(
101
136
  response_model=IndexResponse,
102
137
  status_code=status.HTTP_202_ACCEPTED,
103
138
  summary="Add Documents",
104
- description="Add documents from another folder to the existing index.",
139
+ description="Enqueue a job to add documents from another folder.",
105
140
  )
106
- async def add_documents(request_body: IndexRequest, request: Request) -> IndexResponse:
107
- """Add documents from a new folder to the existing index.
141
+ async def add_documents(
142
+ request_body: IndexRequest,
143
+ request: Request,
144
+ force: bool = Query(False, description="Bypass deduplication and force a new job"),
145
+ allow_external: bool = Query(
146
+ False, description="Allow paths outside the project directory"
147
+ ),
148
+ ) -> IndexResponse:
149
+ """Enqueue a job to add documents from a new folder to the existing index.
108
150
 
109
151
  This is similar to the index endpoint but adds to the existing
110
152
  vector store instead of replacing it.
@@ -112,6 +154,8 @@ async def add_documents(request_body: IndexRequest, request: Request) -> IndexRe
112
154
  Args:
113
155
  request_body: IndexRequest with folder_path and optional configuration.
114
156
  request: FastAPI request for accessing app state.
157
+ force: If True, bypass deduplication and create a new job.
158
+ allow_external: If True, allow indexing paths outside the project.
115
159
 
116
160
  Returns:
117
161
  IndexResponse with job_id and status.
@@ -131,12 +175,17 @@ async def add_documents(request_body: IndexRequest, request: Request) -> IndexRe
131
175
  detail=f"Path is not a directory: {request_body.folder_path}",
132
176
  )
133
177
 
134
- indexing_service = request.app.state.indexing_service
178
+ # Get job service from app state
179
+ job_service = request.app.state.job_service
135
180
 
136
- if indexing_service.is_indexing:
181
+ # Backpressure check (pending + running to prevent overflow)
182
+ stats = await job_service.get_queue_stats()
183
+ active_jobs = stats.pending + stats.running
184
+ if active_jobs >= MAX_QUEUE_LENGTH:
137
185
  raise HTTPException(
138
- status_code=status.HTTP_409_CONFLICT,
139
- detail="Indexing already in progress. Please wait for completion.",
186
+ status_code=status.HTTP_429_TOO_MANY_REQUESTS,
187
+ detail=f"Queue full ({stats.pending} pending, {stats.running} running). "
188
+ "Try again later.",
140
189
  )
141
190
 
142
191
  try:
@@ -151,17 +200,36 @@ async def add_documents(request_body: IndexRequest, request: Request) -> IndexRe
151
200
  include_patterns=request_body.include_patterns,
152
201
  exclude_patterns=request_body.exclude_patterns,
153
202
  )
154
- job_id = await indexing_service.start_indexing(resolved_request)
203
+
204
+ result = await job_service.enqueue_job(
205
+ request=resolved_request,
206
+ operation="add",
207
+ force=force,
208
+ allow_external=allow_external,
209
+ )
210
+ except ValueError as e:
211
+ raise HTTPException(
212
+ status_code=status.HTTP_400_BAD_REQUEST,
213
+ detail=str(e),
214
+ ) from e
155
215
  except Exception as e:
156
216
  raise HTTPException(
157
217
  status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
158
- detail=f"Failed to add documents: {str(e)}",
218
+ detail=f"Failed to enqueue add job: {str(e)}",
159
219
  ) from e
160
220
 
221
+ # Build response message
222
+ if result.dedupe_hit:
223
+ message = (
224
+ f"Duplicate detected - existing job {result.job_id} is {result.status}"
225
+ )
226
+ else:
227
+ message = f"Job queued to add documents from {request_body.folder_path}"
228
+
161
229
  return IndexResponse(
162
- job_id=job_id,
163
- status="started",
164
- message=f"Adding documents from {request_body.folder_path}",
230
+ job_id=result.job_id,
231
+ status=result.status,
232
+ message=message,
165
233
  )
166
234
 
167
235
 
@@ -175,6 +243,7 @@ async def reset_index(request: Request) -> IndexResponse:
175
243
  """Reset the index by deleting all stored documents.
176
244
 
177
245
  Warning: This permanently removes all indexed content.
246
+ Cannot be performed while jobs are running.
178
247
 
179
248
  Args:
180
249
  request: FastAPI request for accessing app state.
@@ -183,14 +252,17 @@ async def reset_index(request: Request) -> IndexResponse:
183
252
  IndexResponse confirming the reset.
184
253
 
185
254
  Raises:
186
- 409: Indexing in progress
255
+ 409: Jobs in progress
187
256
  """
257
+ job_service = request.app.state.job_service
188
258
  indexing_service = request.app.state.indexing_service
189
259
 
190
- if indexing_service.is_indexing:
260
+ # Check if any jobs are running
261
+ stats = await job_service.get_queue_stats()
262
+ if stats.running > 0:
191
263
  raise HTTPException(
192
264
  status_code=status.HTTP_409_CONFLICT,
193
- detail="Cannot reset while indexing is in progress.",
265
+ detail="Cannot reset while indexing jobs are in progress.",
194
266
  )
195
267
 
196
268
  try:
@@ -0,0 +1,111 @@
1
+ """Job management endpoints for indexing job queue."""
2
+
3
+ from typing import Any
4
+
5
+ from fastapi import APIRouter, HTTPException, Query, Request, status
6
+
7
+ from agent_brain_server.job_queue.job_service import JobQueueService
8
+ from agent_brain_server.models.job import JobDetailResponse, JobListResponse
9
+
10
+ router = APIRouter()
11
+
12
+
13
+ @router.get(
14
+ "/",
15
+ response_model=JobListResponse,
16
+ summary="List Jobs",
17
+ description="List all indexing jobs with pagination.",
18
+ )
19
+ async def list_jobs(
20
+ request: Request,
21
+ limit: int = Query(
22
+ 50, ge=1, le=100, description="Maximum number of jobs to return"
23
+ ),
24
+ offset: int = Query(0, ge=0, description="Number of jobs to skip"),
25
+ ) -> JobListResponse:
26
+ """List all jobs with pagination.
27
+
28
+ Returns a paginated list of jobs with summary information and queue statistics.
29
+
30
+ Args:
31
+ request: FastAPI request for accessing app state.
32
+ limit: Maximum number of jobs to return (1-100, default 50).
33
+ offset: Number of jobs to skip for pagination (default 0).
34
+
35
+ Returns:
36
+ JobListResponse with list of job summaries and queue statistics.
37
+ """
38
+ job_service: JobQueueService = request.app.state.job_service
39
+ return await job_service.list_jobs(limit=limit, offset=offset)
40
+
41
+
42
+ @router.get(
43
+ "/{job_id}",
44
+ response_model=JobDetailResponse,
45
+ summary="Get Job Details",
46
+ description="Get detailed information about a specific job.",
47
+ )
48
+ async def get_job(job_id: str, request: Request) -> JobDetailResponse:
49
+ """Get details for a specific job.
50
+
51
+ Returns full job information including progress, timestamps, and results.
52
+
53
+ Args:
54
+ job_id: The unique job identifier.
55
+ request: FastAPI request for accessing app state.
56
+
57
+ Returns:
58
+ JobDetailResponse with full job details.
59
+
60
+ Raises:
61
+ 404: Job not found.
62
+ """
63
+ job_service: JobQueueService = request.app.state.job_service
64
+ job = await job_service.get_job(job_id)
65
+ if not job:
66
+ raise HTTPException(
67
+ status_code=status.HTTP_404_NOT_FOUND,
68
+ detail=f"Job {job_id} not found",
69
+ )
70
+ return job
71
+
72
+
73
+ @router.delete(
74
+ "/{job_id}",
75
+ summary="Cancel Job",
76
+ description="Cancel a pending or running job.",
77
+ )
78
+ async def cancel_job(job_id: str, request: Request) -> dict[str, Any]:
79
+ """Cancel a job.
80
+
81
+ Cancellation behavior depends on job status:
82
+ - PENDING jobs are cancelled immediately
83
+ - RUNNING jobs have cancel_requested flag set; worker will stop at next checkpoint
84
+ - Completed/Failed/Cancelled jobs return 409 Conflict
85
+
86
+ Args:
87
+ job_id: The unique job identifier.
88
+ request: FastAPI request for accessing app state.
89
+
90
+ Returns:
91
+ Dictionary with cancellation status and message.
92
+
93
+ Raises:
94
+ 404: Job not found.
95
+ 409: Job cannot be cancelled (already completed, failed, or cancelled).
96
+ """
97
+ job_service: JobQueueService = request.app.state.job_service
98
+
99
+ try:
100
+ result = await job_service.cancel_job(job_id)
101
+ return result
102
+ except KeyError as e:
103
+ raise HTTPException(
104
+ status_code=status.HTTP_404_NOT_FOUND,
105
+ detail=str(e),
106
+ ) from e
107
+ except ValueError as e:
108
+ raise HTTPException(
109
+ status_code=status.HTTP_409_CONFLICT,
110
+ detail=str(e),
111
+ ) from e