agent-brain-rag 1.1.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.
@@ -0,0 +1,208 @@
1
+ """Indexing endpoints for document processing."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ from fastapi import APIRouter, HTTPException, Request, status
7
+
8
+ from doc_serve_server.models import IndexRequest, IndexResponse
9
+
10
+ router = APIRouter()
11
+
12
+
13
+ @router.post(
14
+ "/",
15
+ response_model=IndexResponse,
16
+ status_code=status.HTTP_202_ACCEPTED,
17
+ summary="Index Documents",
18
+ description="Start indexing documents from a folder.",
19
+ )
20
+ async def index_documents(
21
+ request_body: IndexRequest, request: Request
22
+ ) -> IndexResponse:
23
+ """Start indexing documents from the specified folder.
24
+
25
+ This endpoint initiates a background indexing job and returns immediately.
26
+ Use the /health/status endpoint to monitor progress.
27
+
28
+ Args:
29
+ request_body: IndexRequest with folder_path and optional configuration.
30
+ request: FastAPI request for accessing app state.
31
+
32
+ Returns:
33
+ IndexResponse with job_id and status.
34
+
35
+ Raises:
36
+ 400: Invalid folder path
37
+ 409: Indexing already in progress
38
+ """
39
+ # Validate folder path
40
+ folder_path = Path(request_body.folder_path).expanduser().resolve()
41
+
42
+ if not folder_path.exists():
43
+ raise HTTPException(
44
+ status_code=status.HTTP_400_BAD_REQUEST,
45
+ detail=f"Folder not found: {request_body.folder_path}",
46
+ )
47
+
48
+ if not folder_path.is_dir():
49
+ raise HTTPException(
50
+ status_code=status.HTTP_400_BAD_REQUEST,
51
+ detail=f"Path is not a directory: {request_body.folder_path}",
52
+ )
53
+
54
+ if not os.access(folder_path, os.R_OK):
55
+ raise HTTPException(
56
+ status_code=status.HTTP_400_BAD_REQUEST,
57
+ detail=f"Cannot read folder: {request_body.folder_path}",
58
+ )
59
+
60
+ # Get indexing service from app state
61
+ indexing_service = request.app.state.indexing_service
62
+
63
+ # Check if already indexing
64
+ if indexing_service.is_indexing:
65
+ raise HTTPException(
66
+ status_code=status.HTTP_409_CONFLICT,
67
+ detail="Indexing already in progress. Please wait for completion.",
68
+ )
69
+
70
+ # Start indexing
71
+ try:
72
+ # Update request with resolved path
73
+ resolved_request = IndexRequest(
74
+ folder_path=str(folder_path),
75
+ chunk_size=request_body.chunk_size,
76
+ chunk_overlap=request_body.chunk_overlap,
77
+ recursive=request_body.recursive,
78
+ include_code=request_body.include_code,
79
+ supported_languages=request_body.supported_languages,
80
+ code_chunk_strategy=request_body.code_chunk_strategy,
81
+ include_patterns=request_body.include_patterns,
82
+ exclude_patterns=request_body.exclude_patterns,
83
+ generate_summaries=request_body.generate_summaries,
84
+ )
85
+ job_id = await indexing_service.start_indexing(resolved_request)
86
+ except Exception as e:
87
+ raise HTTPException(
88
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
89
+ detail=f"Failed to start indexing: {str(e)}",
90
+ ) from e
91
+
92
+ return IndexResponse(
93
+ job_id=job_id,
94
+ status="started",
95
+ message=f"Indexing started for {request_body.folder_path}",
96
+ )
97
+
98
+
99
+ @router.post(
100
+ "/add",
101
+ response_model=IndexResponse,
102
+ status_code=status.HTTP_202_ACCEPTED,
103
+ summary="Add Documents",
104
+ description="Add documents from another folder to the existing index.",
105
+ )
106
+ async def add_documents(request_body: IndexRequest, request: Request) -> IndexResponse:
107
+ """Add documents from a new folder to the existing index.
108
+
109
+ This is similar to the index endpoint but adds to the existing
110
+ vector store instead of replacing it.
111
+
112
+ Args:
113
+ request_body: IndexRequest with folder_path and optional configuration.
114
+ request: FastAPI request for accessing app state.
115
+
116
+ Returns:
117
+ IndexResponse with job_id and status.
118
+ """
119
+ # Same validation as index_documents
120
+ folder_path = Path(request_body.folder_path).expanduser().resolve()
121
+
122
+ if not folder_path.exists():
123
+ raise HTTPException(
124
+ status_code=status.HTTP_400_BAD_REQUEST,
125
+ detail=f"Folder not found: {request_body.folder_path}",
126
+ )
127
+
128
+ if not folder_path.is_dir():
129
+ raise HTTPException(
130
+ status_code=status.HTTP_400_BAD_REQUEST,
131
+ detail=f"Path is not a directory: {request_body.folder_path}",
132
+ )
133
+
134
+ indexing_service = request.app.state.indexing_service
135
+
136
+ if indexing_service.is_indexing:
137
+ raise HTTPException(
138
+ status_code=status.HTTP_409_CONFLICT,
139
+ detail="Indexing already in progress. Please wait for completion.",
140
+ )
141
+
142
+ try:
143
+ resolved_request = IndexRequest(
144
+ folder_path=str(folder_path),
145
+ chunk_size=request_body.chunk_size,
146
+ chunk_overlap=request_body.chunk_overlap,
147
+ recursive=request_body.recursive,
148
+ include_code=request_body.include_code,
149
+ supported_languages=request_body.supported_languages,
150
+ code_chunk_strategy=request_body.code_chunk_strategy,
151
+ include_patterns=request_body.include_patterns,
152
+ exclude_patterns=request_body.exclude_patterns,
153
+ )
154
+ job_id = await indexing_service.start_indexing(resolved_request)
155
+ except Exception as e:
156
+ raise HTTPException(
157
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
158
+ detail=f"Failed to add documents: {str(e)}",
159
+ ) from e
160
+
161
+ return IndexResponse(
162
+ job_id=job_id,
163
+ status="started",
164
+ message=f"Adding documents from {request_body.folder_path}",
165
+ )
166
+
167
+
168
+ @router.delete(
169
+ "/",
170
+ response_model=IndexResponse,
171
+ summary="Reset Index",
172
+ description="Delete all indexed documents and reset the vector store.",
173
+ )
174
+ async def reset_index(request: Request) -> IndexResponse:
175
+ """Reset the index by deleting all stored documents.
176
+
177
+ Warning: This permanently removes all indexed content.
178
+
179
+ Args:
180
+ request: FastAPI request for accessing app state.
181
+
182
+ Returns:
183
+ IndexResponse confirming the reset.
184
+
185
+ Raises:
186
+ 409: Indexing in progress
187
+ """
188
+ indexing_service = request.app.state.indexing_service
189
+
190
+ if indexing_service.is_indexing:
191
+ raise HTTPException(
192
+ status_code=status.HTTP_409_CONFLICT,
193
+ detail="Cannot reset while indexing is in progress.",
194
+ )
195
+
196
+ try:
197
+ await indexing_service.reset()
198
+ except Exception as e:
199
+ raise HTTPException(
200
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
201
+ detail=f"Failed to reset index: {str(e)}",
202
+ ) from e
203
+
204
+ return IndexResponse(
205
+ job_id="reset",
206
+ status="completed",
207
+ message="Index has been reset successfully",
208
+ )
@@ -0,0 +1,96 @@
1
+ """Query endpoints for semantic search."""
2
+
3
+ import logging
4
+
5
+ from fastapi import APIRouter, HTTPException, Request, status
6
+
7
+ from doc_serve_server.models import QueryRequest, QueryResponse
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ router = APIRouter()
12
+
13
+
14
+ @router.post(
15
+ "/",
16
+ response_model=QueryResponse,
17
+ summary="Query Documents",
18
+ description="Perform semantic, keyword, or hybrid search on indexed documents.",
19
+ )
20
+ async def query_documents(
21
+ request_body: QueryRequest, request: Request
22
+ ) -> QueryResponse:
23
+ """Execute a search query on indexed documents.
24
+
25
+ Args:
26
+ request_body: QueryRequest containing query parameters.
27
+ request: FastAPI request for accessing app state.
28
+
29
+ Returns:
30
+ QueryResponse with ranked results and timing.
31
+
32
+ Raises:
33
+ 400: Invalid query (empty or too long)
34
+ 503: Index not ready (indexing in progress or not initialized)
35
+ """
36
+ from doc_serve_server.services import QueryService
37
+ from doc_serve_server.services.indexing_service import IndexingService
38
+
39
+ query_service: QueryService = request.app.state.query_service
40
+ indexing_service: IndexingService = request.app.state.indexing_service
41
+
42
+ # Validate query
43
+ query = request_body.query.strip()
44
+ if not query:
45
+ raise HTTPException(
46
+ status_code=status.HTTP_400_BAD_REQUEST,
47
+ detail="Query cannot be empty",
48
+ )
49
+
50
+ # Check if service is ready
51
+ if not query_service.is_ready():
52
+ if indexing_service.is_indexing:
53
+ raise HTTPException(
54
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
55
+ detail="Index not ready. Indexing is in progress.",
56
+ )
57
+ else:
58
+ raise HTTPException(
59
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
60
+ detail="Index not ready. Please index documents first.",
61
+ )
62
+
63
+ # Execute query
64
+ try:
65
+ response = await query_service.execute_query(request_body)
66
+ except Exception as e:
67
+ raise HTTPException(
68
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
69
+ detail=f"Query failed: {str(e)}",
70
+ ) from e
71
+
72
+ return response
73
+
74
+
75
+ @router.get(
76
+ "/count",
77
+ summary="Document Count",
78
+ description="Get the total number of indexed document chunks.",
79
+ )
80
+ async def get_document_count(request: Request) -> dict[str, int | bool]:
81
+ """Get the total number of indexed document chunks.
82
+
83
+ Args:
84
+ request: FastAPI request for accessing app state.
85
+
86
+ Returns:
87
+ Dictionary with count of indexed chunks.
88
+ """
89
+ query_service = request.app.state.query_service
90
+
91
+ count = await query_service.get_document_count()
92
+
93
+ return {
94
+ "total_chunks": count,
95
+ "ready": query_service.is_ready(),
96
+ }
@@ -0,0 +1,5 @@
1
+ """Configuration module."""
2
+
3
+ from .settings import settings
4
+
5
+ __all__ = ["settings"]
@@ -0,0 +1,92 @@
1
+ """Application configuration using Pydantic settings."""
2
+
3
+ import json
4
+ import logging
5
+ from functools import lru_cache
6
+ from pathlib import Path
7
+ from typing import Any, Optional
8
+
9
+ from pydantic_settings import BaseSettings, SettingsConfigDict
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class Settings(BaseSettings):
15
+ """Application settings loaded from environment variables."""
16
+
17
+ # API Configuration
18
+ API_HOST: str = "127.0.0.1"
19
+ API_PORT: int = 8000
20
+ DEBUG: bool = False
21
+
22
+ # OpenAI Configuration
23
+ OPENAI_API_KEY: str = ""
24
+ EMBEDDING_MODEL: str = "text-embedding-3-large"
25
+ EMBEDDING_DIMENSIONS: int = 3072
26
+
27
+ # Anthropic Configuration
28
+ ANTHROPIC_API_KEY: str = ""
29
+ CLAUDE_MODEL: str = "claude-3-5-haiku-20241022" # Claude 3.5 Haiku (latest)
30
+
31
+ # Chroma Configuration
32
+ CHROMA_PERSIST_DIR: str = "./chroma_db"
33
+ BM25_INDEX_PATH: str = "./bm25_index"
34
+ COLLECTION_NAME: str = "doc_serve_collection"
35
+
36
+ # Chunking Configuration
37
+ DEFAULT_CHUNK_SIZE: int = 512
38
+ DEFAULT_CHUNK_OVERLAP: int = 50
39
+ MAX_CHUNK_SIZE: int = 2048
40
+ MIN_CHUNK_SIZE: int = 128
41
+
42
+ # Query Configuration
43
+ DEFAULT_TOP_K: int = 5
44
+ MAX_TOP_K: int = 50
45
+ DEFAULT_SIMILARITY_THRESHOLD: float = 0.7
46
+
47
+ # Rate Limiting
48
+ EMBEDDING_BATCH_SIZE: int = 100
49
+
50
+ # Multi-instance Configuration
51
+ DOC_SERVE_STATE_DIR: Optional[str] = None # Override state directory
52
+ DOC_SERVE_MODE: str = "project" # "project" or "shared"
53
+
54
+ model_config = SettingsConfigDict(
55
+ env_file=[
56
+ ".env", # Current directory
57
+ Path(__file__).parent.parent.parent / ".env", # Project root
58
+ Path(__file__).parent.parent / ".env", # doc-serve-server directory
59
+ ],
60
+ env_file_encoding="utf-8",
61
+ case_sensitive=True,
62
+ )
63
+
64
+
65
+ @lru_cache
66
+ def get_settings() -> Settings:
67
+ """Get cached settings instance."""
68
+ return Settings()
69
+
70
+
71
+ settings = get_settings()
72
+
73
+
74
+ def load_project_config(state_dir: Path) -> dict[str, Any]:
75
+ """Load project configuration from state directory.
76
+
77
+ Precedence: CLI flags > env vars > project config > defaults
78
+
79
+ Args:
80
+ state_dir: Path to the state directory containing config.json.
81
+
82
+ Returns:
83
+ Dictionary of configuration values from config.json, or empty dict.
84
+ """
85
+ config_path = state_dir / "config.json"
86
+ if config_path.exists():
87
+ try:
88
+ with open(config_path) as f:
89
+ return json.load(f) # type: ignore[no-any-return]
90
+ except (json.JSONDecodeError, OSError) as e:
91
+ logger.warning(f"Failed to load project config from {config_path}: {e}")
92
+ return {}
@@ -0,0 +1,19 @@
1
+ """Indexing pipeline components for document processing."""
2
+
3
+ from doc_serve_server.indexing.bm25_index import BM25IndexManager, get_bm25_manager
4
+ from doc_serve_server.indexing.chunking import CodeChunker, ContextAwareChunker
5
+ from doc_serve_server.indexing.document_loader import DocumentLoader
6
+ from doc_serve_server.indexing.embedding import (
7
+ EmbeddingGenerator,
8
+ get_embedding_generator,
9
+ )
10
+
11
+ __all__ = [
12
+ "DocumentLoader",
13
+ "ContextAwareChunker",
14
+ "CodeChunker",
15
+ "EmbeddingGenerator",
16
+ "get_embedding_generator",
17
+ "BM25IndexManager",
18
+ "get_bm25_manager",
19
+ ]
@@ -0,0 +1,166 @@
1
+ """BM25 index manager for persistence and retrieval."""
2
+
3
+ import logging
4
+ from collections.abc import Sequence
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ from llama_index.core.schema import BaseNode, NodeWithScore
9
+ from llama_index.retrievers.bm25 import BM25Retriever
10
+
11
+ from doc_serve_server.config import settings
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class BM25IndexManager:
17
+ """
18
+ Manages the lifecycle of the BM25 index.
19
+
20
+ Handles building the index from nodes, persisting it to disk,
21
+ and loading it for retrieval.
22
+ """
23
+
24
+ def __init__(self, persist_dir: Optional[str] = None):
25
+ """
26
+ Initialize the BM25 index manager.
27
+
28
+ Args:
29
+ persist_dir: Directory for index persistence.
30
+ """
31
+ self.persist_dir = persist_dir or settings.BM25_INDEX_PATH
32
+ self._retriever: Optional[BM25Retriever] = None
33
+
34
+ @property
35
+ def is_initialized(self) -> bool:
36
+ """Check if the index is initialized."""
37
+ return self._retriever is not None
38
+
39
+ def initialize(self) -> None:
40
+ """
41
+ Load the index from disk if it exists.
42
+ """
43
+ persist_path = Path(self.persist_dir)
44
+ if (persist_path / "retriever.json").exists():
45
+ try:
46
+ self._retriever = BM25Retriever.from_persist_dir(str(persist_path))
47
+ logger.info(f"BM25 index loaded from {self.persist_dir}")
48
+ except Exception as e:
49
+ logger.error(f"Failed to load BM25 index: {e}")
50
+ self._retriever = None
51
+ else:
52
+ logger.info("No existing BM25 index found")
53
+
54
+ def build_index(self, nodes: Sequence[BaseNode]) -> None:
55
+ """
56
+ Build a new BM25 index from nodes and persist it.
57
+
58
+ Args:
59
+ nodes: List of LlamaIndex nodes.
60
+ """
61
+ logger.info(f"Building BM25 index with {len(nodes)} nodes")
62
+ self._retriever = BM25Retriever.from_defaults(nodes=nodes)
63
+ self.persist()
64
+
65
+ def persist(self) -> None:
66
+ """Persist the current index to disk."""
67
+ if not self._retriever:
68
+ logger.warning("No BM25 index to persist")
69
+ return
70
+
71
+ persist_path = Path(self.persist_dir)
72
+ persist_path.mkdir(parents=True, exist_ok=True)
73
+ self._retriever.persist(str(persist_path))
74
+ logger.info(f"BM25 index persisted to {self.persist_dir}")
75
+
76
+ def get_retriever(self, top_k: int = 5) -> BM25Retriever:
77
+ """
78
+ Get the BM25 retriever instance.
79
+
80
+ Args:
81
+ top_k: Number of results to return.
82
+
83
+ Returns:
84
+ The BM25Retriever instance.
85
+
86
+ Raises:
87
+ RuntimeError: If the index is not initialized.
88
+ """
89
+ if not self._retriever:
90
+ raise RuntimeError("BM25 index not initialized")
91
+
92
+ # BM25Retriever similarity_top_k is usually set during initialization.
93
+ self._retriever.similarity_top_k = top_k
94
+ return self._retriever
95
+
96
+ async def search_with_filters(
97
+ self,
98
+ query: str,
99
+ top_k: int = 5,
100
+ source_types: Optional[list[str]] = None,
101
+ languages: Optional[list[str]] = None,
102
+ max_results: Optional[int] = None,
103
+ ) -> list[NodeWithScore]:
104
+ """
105
+ Search the BM25 index with metadata filtering.
106
+
107
+ Args:
108
+ query: Search query string.
109
+ top_k: Number of results to return.
110
+ source_types: Filter by source types (doc, code, test).
111
+ languages: Filter by programming languages.
112
+
113
+ Returns:
114
+ List of NodeWithScore objects, filtered by metadata.
115
+ """
116
+ if not self._retriever:
117
+ raise RuntimeError("BM25 index not initialized")
118
+
119
+ # Get results for filtering
120
+ retriever_top_k = max_results if max_results is not None else (top_k * 3)
121
+ retriever = self.get_retriever(top_k=retriever_top_k)
122
+ nodes = await retriever.aretrieve(query)
123
+
124
+ # Apply metadata filtering
125
+ filtered_nodes = []
126
+ for node in nodes:
127
+ metadata = node.node.metadata
128
+
129
+ # Check source type filter
130
+ if source_types:
131
+ source_type = metadata.get("source_type", "doc")
132
+ if source_type not in source_types:
133
+ continue
134
+
135
+ # Check language filter
136
+ if languages:
137
+ language = metadata.get("language")
138
+ if not language or language not in languages:
139
+ continue
140
+
141
+ filtered_nodes.append(node)
142
+
143
+ # Return top_k results after filtering
144
+ return filtered_nodes[:top_k]
145
+
146
+ def reset(self) -> None:
147
+ """Reset the BM25 index by deleting persistent files."""
148
+ self._retriever = None
149
+ persist_path = Path(self.persist_dir)
150
+ if persist_path.exists():
151
+ for file in persist_path.glob("*"):
152
+ file.unlink()
153
+ persist_path.rmdir()
154
+ logger.info("BM25 index reset")
155
+
156
+
157
+ # Global singleton instance
158
+ _bm25_manager: Optional[BM25IndexManager] = None
159
+
160
+
161
+ def get_bm25_manager() -> BM25IndexManager:
162
+ """Get the global BM25 manager instance."""
163
+ global _bm25_manager
164
+ if _bm25_manager is None:
165
+ _bm25_manager = BM25IndexManager()
166
+ return _bm25_manager