kushalkachari commited on
Commit
33332ff
Β·
0 Parent(s):

Initial SourceLink AI demo

Browse files
.env.example ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ VECTOR_DB_BACKEND=zilliz
2
+ ZILLIZ_URI=your-zilliz-public-endpoint
3
+ ZILLIZ_TOKEN=your-zilliz-token
4
+ COLLECTION_NAME=vectorEMBD
5
+
6
+ EMBEDDING_PROVIDER=huggingface
7
+ EMBEDDING_MODEL=BAAI/bge-small-en-v1.5
8
+ EMBEDDING_DIMENSION=384
9
+
10
+ CHAT_PROVIDER=groq
11
+ CHAT_MODEL=llama-3.1-8b-instant
12
+ GROQ_API_KEY=your-groq-api-key
.gitignore ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .env
2
+ .env.*
3
+ !.env.example
4
+ .venv/
5
+ __pycache__/
6
+ *.pyc
7
+ *.pyo
8
+ *.pyd
9
+
10
+ data/raw/
11
+ data/chroma/
12
+ data/processed/
13
+
14
+ .streamlit/secrets.toml
15
+ Untitled
16
+ *.log
.streamlit/config.toml ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ [server]
2
+ fileWatcherType = "none"
3
+
4
+ [client]
5
+ showErrorDetails = true
MIGRATION.md ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Migration Toward The Demo Goal
2
+ ==============================
3
+
4
+ Goal
5
+ ----
6
+
7
+ Build a deployable demo where users connect document sources, index them, search semantically, open the original source, and chat with retrieved document context.
8
+
9
+ The app should avoid storing original files unless the user explicitly uploads them. For linked sources, it stores:
10
+
11
+ - chunk text
12
+ - embedding vectors
13
+ - document metadata
14
+ - source references such as Drive/GitHub URLs
15
+
16
+ Current State
17
+ -------------
18
+
19
+ The app now supports:
20
+
21
+ - public GitHub repository ingestion
22
+ - public Google Drive file ingestion
23
+ - public Google Drive folder ingestion through `gdown`
24
+ - upload-based ingestion
25
+ - local `data/raw` ingestion
26
+ - retrieved-document chat with a local Ollama chat model
27
+ - a vector store interface so Chroma can later be swapped out
28
+
29
+ Available vector backends:
30
+
31
+ ```text
32
+ VECTOR_DB_BACKEND=chroma
33
+ VECTOR_DB_BACKEND=zilliz
34
+ ```
35
+
36
+ Cloud Migration Path
37
+ --------------------
38
+
39
+ Recommended demo stack:
40
+
41
+ ```text
42
+ App hosting: Hugging Face Spaces or Render
43
+ Source files: stay in Google Drive / GitHub
44
+ Metadata: vector DB metadata first, Supabase later if auth is added
45
+ Vector DB: Zilliz Cloud or Qdrant Cloud
46
+ Embeddings: local sentence-transformers on the app server, or Ollama on a VPS
47
+ Chat: local small model on VPS, or API-based model for hosted demos
48
+ ```
49
+
50
+ Zilliz Setup
51
+ ------------
52
+
53
+ Install dependencies:
54
+
55
+ ```powershell
56
+ pip install -r requirements.txt
57
+ ```
58
+
59
+ Create a free Zilliz Cloud cluster, then set:
60
+
61
+ Expected environment variables:
62
+
63
+ ```text
64
+ VECTOR_DB_BACKEND=zilliz
65
+ ZILLIZ_URI=<your-zilliz-endpoint>
66
+ ZILLIZ_TOKEN=<your-zilliz-token>
67
+ COLLECTION_NAME=vectorEMBD
68
+ EMBEDDING_PROVIDER=huggingface
69
+ EMBEDDING_MODEL=BAAI/bge-small-en-v1.5
70
+ EMBEDDING_DIMENSION=384
71
+ CHAT_PROVIDER=groq
72
+ CHAT_MODEL=llama-3.1-8b-instant
73
+ GROQ_API_KEY=<your-groq-api-key>
74
+ ```
75
+
76
+ These can be placed in `.env` at the project root. The app loads `.env` automatically through `python-dotenv`.
77
+
78
+ The Zilliz backend stores:
79
+
80
+ - vector
81
+ - chunk text
82
+ - filename
83
+ - source_url
84
+ - source_type
85
+ - document_id
86
+ - other scalar metadata
87
+
88
+ Keep secrets and OAuth tokens outside Zilliz.
89
+
90
+ Next Code Step
91
+ --------------
92
+
93
+ Add user identity and source ownership metadata:
94
+
95
+ ```text
96
+ user_id
97
+ source_id
98
+ tenant_id
99
+ ```
100
+
101
+ Then filter search results by user/source so one user's indexed chunks cannot appear for another user.
102
+
103
+ Production Notes
104
+ ----------------
105
+
106
+ For a public demo, public Drive/GitHub links are enough.
107
+
108
+ For real users, use OAuth:
109
+
110
+ - Google Drive API for private Drive access
111
+ - GitHub OAuth or GitHub App installation for private repos
112
+ - Supabase Auth for app users
113
+
114
+ For original files:
115
+
116
+ - Keep linked source files in Drive/GitHub.
117
+ - Store only source references in vector metadata.
118
+ - Use Supabase Storage only for manual uploads that need persistence.
README.md ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Document Search System
2
+ ======================
3
+
4
+ Demo app for indexing document sources, searching them semantically, opening the original source file, and chatting with the retrieved document context.
5
+
6
+ Supported demo sources:
7
+
8
+ - Public GitHub repository URLs, such as `https://github.com/owner/repo`
9
+ - Public Google Drive file and folder links
10
+ - Manual uploads through the Streamlit sidebar
11
+ - Local demo files in `data/raw`
12
+
13
+ Current Google Drive note: public folder ingestion uses `gdown`, which is good for demos. Private folders or per-user permissions should use the Google Drive API with OAuth.
14
+
15
+ Setup
16
+ -----
17
+
18
+ Install dependencies:
19
+
20
+ ```powershell
21
+ pip install -r requirements.txt
22
+ ```
23
+
24
+ Run Ollama and pull the models you want to use:
25
+
26
+ ```powershell
27
+ ollama pull bge-m3
28
+ ollama pull llama3.2:3b
29
+ ```
30
+
31
+ Optional environment overrides:
32
+
33
+ ```env
34
+ EMBEDDING_PROVIDER=ollama
35
+ EMBEDDING_MODEL=bge-m3:567m
36
+ EMBEDDING_DIMENSION=1024
37
+ CHAT_MODEL=llama3.2:3b
38
+ VECTOR_DB_BACKEND=chroma
39
+ ```
40
+
41
+ For Zilliz Cloud, put this in `.env`:
42
+
43
+ ```env
44
+ VECTOR_DB_BACKEND=zilliz
45
+ ZILLIZ_URI=your-zilliz-endpoint
46
+ ZILLIZ_TOKEN=your-zilliz-token
47
+ COLLECTION_NAME=vectorEMBD
48
+ EMBEDDING_PROVIDER=huggingface
49
+ EMBEDDING_MODEL=BAAI/bge-small-en-v1.5
50
+ EMBEDDING_DIMENSION=384
51
+ CHAT_PROVIDER=groq
52
+ CHAT_MODEL=llama-3.1-8b-instant
53
+ GROQ_API_KEY=your-groq-api-key
54
+ ```
55
+
56
+ `.env` is ignored by git because it contains secrets.
57
+
58
+ Start the app:
59
+
60
+ ```powershell
61
+ streamlit run app/ui/main.py
62
+ ```
63
+
64
+ Verify Vector Storage
65
+ ---------------------
66
+
67
+ Check which vector store is active and how many chunks are stored:
68
+
69
+ ```powershell
70
+ python scripts/check_vector_store.py
71
+ ```
72
+
73
+ Run a quick search against the active vector store:
74
+
75
+ ```powershell
76
+ python scripts/check_vector_store.py --query "machine learning"
77
+ ```
78
+
79
+ How It Works
80
+ ------------
81
+
82
+ 1. A user provides a source link or uploads files.
83
+ 2. The app extracts supported documents.
84
+ 3. Text is chunked and embedded with Ollama.
85
+ 4. Chunks and metadata are stored in ChromaDB.
86
+ 5. Search returns relevant chunks grouped by original document.
87
+ 6. The UI shows excerpts and an `Open source` or `Download file` action.
88
+ 7. The chat panel answers follow-up questions using the most recent retrieved chunks.
89
+
90
+ Migration
91
+ ---------
92
+
93
+ This project is being moved toward a deployable source-connected demo. See `MIGRATION.md` for the current architecture, cloud backend plan, and the next vector database migration step.
app/config/settings.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from pathlib import Path
3
+ from dataclasses import dataclass
4
+
5
+ from dotenv import load_dotenv
6
+
7
+
8
+ PROJECT_ROOT = Path(__file__).parent.parent.parent
9
+ load_dotenv(PROJECT_ROOT / ".env")
10
+
11
+
12
+ @dataclass
13
+ class Settings:
14
+ """
15
+ Centralized configuration for the document search system.
16
+
17
+ All paths, model names, and system parameters are defined here.
18
+ Can be overridden via environment variables.
19
+ """
20
+
21
+ # ============================================================
22
+ # Project Paths
23
+ # ============================================================
24
+ PROJECT_ROOT: Path = PROJECT_ROOT
25
+ DATA_DIR: Path = PROJECT_ROOT / "data"
26
+ RAW_DATA_DIR: Path = DATA_DIR / "raw"
27
+ PROCESSED_DATA_DIR: Path = DATA_DIR / "processed"
28
+ CHROMA_PERSIST_DIR: Path = DATA_DIR / "chroma"
29
+
30
+ # ============================================================
31
+ # Embedding / Chat Configuration
32
+ # ============================================================
33
+ EMBEDDING_PROVIDER: str = "ollama"
34
+ OLLAMA_BASE_URL: str = "http://localhost:11434"
35
+ EMBEDDING_MODEL: str = "bge-m3:567m"
36
+ EMBEDDING_DIMENSION: int = 1024 # bge-m3 outputs 1024-dim vectors
37
+ CHAT_PROVIDER: str = "ollama"
38
+ CHAT_MODEL: str = "llama3.2:3b"
39
+ GROQ_API_KEY: str = ""
40
+ GROQ_BASE_URL: str = "https://api.groq.com/openai/v1"
41
+
42
+ # ============================================================
43
+ # Vector Database Configuration
44
+ # ============================================================
45
+ VECTOR_DB_BACKEND: str = "chroma"
46
+ COLLECTION_NAME: str = "documents"
47
+ ZILLIZ_URI: str = ""
48
+ ZILLIZ_TOKEN: str = ""
49
+
50
+ # ============================================================
51
+ # Document Processing
52
+ # ============================================================
53
+ CHUNK_SIZE: int = 500 # Characters per chunk
54
+ CHUNK_OVERLAP: int = 50 # Overlap between chunks
55
+
56
+ SUPPORTED_FILE_TYPES: tuple = (".pdf", ".txt", ".docx", ".md")
57
+
58
+ # ============================================================
59
+ # Retrieval Configuration
60
+ # ============================================================
61
+ DEFAULT_TOP_K: int = 5 # Number of chunks to retrieve
62
+ SIMILARITY_THRESHOLD: float = 0.7 # Minimum similarity score (0-1)
63
+
64
+ # ============================================================
65
+ # Streamlit UI
66
+ # ============================================================
67
+ APP_TITLE: str = "πŸ“š Document Search System"
68
+ APP_ICON: str = "πŸ“„"
69
+ MAX_UPLOAD_SIZE_MB: int = 200
70
+
71
+ # ============================================================
72
+ # Logging
73
+ # ============================================================
74
+ LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO")
75
+ LOG_FORMAT: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
76
+
77
+ def __post_init__(self):
78
+ """Create necessary directories on initialization."""
79
+ self.RAW_DATA_DIR.mkdir(parents=True, exist_ok=True)
80
+ self.PROCESSED_DATA_DIR.mkdir(parents=True, exist_ok=True)
81
+ self.CHROMA_PERSIST_DIR.mkdir(parents=True, exist_ok=True)
82
+
83
+ @classmethod
84
+ def from_env(cls) -> "Settings":
85
+ """
86
+ Load settings with environment variable overrides.
87
+
88
+ Example .env file:
89
+ OLLAMA_BASE_URL=http://localhost:11434
90
+ EMBEDDING_PROVIDER=huggingface
91
+ EMBEDDING_MODEL=bge-m3:567m
92
+ EMBEDDING_DIMENSION=1024
93
+ CHAT_PROVIDER=groq
94
+ CHAT_MODEL=llama3.2:3b
95
+ VECTOR_DB_BACKEND=chroma
96
+ CHUNK_SIZE=1000
97
+ DEFAULT_TOP_K=10
98
+ """
99
+ return cls(
100
+ EMBEDDING_PROVIDER=os.getenv("EMBEDDING_PROVIDER", cls.EMBEDDING_PROVIDER),
101
+ OLLAMA_BASE_URL=os.getenv("OLLAMA_BASE_URL", cls.OLLAMA_BASE_URL),
102
+ EMBEDDING_MODEL=os.getenv("EMBEDDING_MODEL", cls.EMBEDDING_MODEL),
103
+ EMBEDDING_DIMENSION=int(os.getenv("EMBEDDING_DIMENSION", cls.EMBEDDING_DIMENSION)),
104
+ CHAT_PROVIDER=os.getenv("CHAT_PROVIDER", cls.CHAT_PROVIDER),
105
+ CHAT_MODEL=os.getenv("CHAT_MODEL", cls.CHAT_MODEL),
106
+ GROQ_API_KEY=os.getenv("GROQ_API_KEY", cls.GROQ_API_KEY),
107
+ GROQ_BASE_URL=os.getenv("GROQ_BASE_URL", cls.GROQ_BASE_URL),
108
+ CHUNK_SIZE=int(os.getenv("CHUNK_SIZE", cls.CHUNK_SIZE)),
109
+ CHUNK_OVERLAP=int(os.getenv("CHUNK_OVERLAP", cls.CHUNK_OVERLAP)),
110
+ DEFAULT_TOP_K=int(os.getenv("DEFAULT_TOP_K", cls.DEFAULT_TOP_K)),
111
+ VECTOR_DB_BACKEND=os.getenv("VECTOR_DB_BACKEND", cls.VECTOR_DB_BACKEND),
112
+ COLLECTION_NAME=os.getenv("COLLECTION_NAME", cls.COLLECTION_NAME),
113
+ ZILLIZ_URI=os.getenv("ZILLIZ_URI", cls.ZILLIZ_URI),
114
+ ZILLIZ_TOKEN=os.getenv("ZILLIZ_TOKEN", cls.ZILLIZ_TOKEN),
115
+ )
116
+
117
+
118
+ # ============================================================
119
+ # Global Settings Instance
120
+ # ============================================================
121
+ settings = Settings.from_env()
122
+
123
+
124
+ # ============================================================
125
+ # Convenience function for other modules
126
+ # ============================================================
127
+ def get_settings() -> Settings:
128
+ """Returns the global settings instance."""
129
+ return settings
app/ingestion/chunker.py ADDED
@@ -0,0 +1,292 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List, Dict, Any
2
+ import logging
3
+ import re
4
+
5
+ from app.config.settings import settings
6
+
7
+ logging.basicConfig(
8
+ level=settings.LOG_LEVEL,
9
+ format=settings.LOG_FORMAT
10
+ )
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class TextChunker:
16
+ """
17
+ Smart text chunking with overlap.
18
+
19
+ Splits documents into manageable chunks for embedding while:
20
+ - Preserving sentence boundaries
21
+ - Adding overlap between chunks for context continuity
22
+ - Maintaining metadata for each chunk
23
+ """
24
+
25
+ def __init__(
26
+ self,
27
+ chunk_size: int = None,
28
+ chunk_overlap: int = None,
29
+ ):
30
+ """
31
+ Args:
32
+ chunk_size: Target characters per chunk (default: from settings)
33
+ chunk_overlap: Overlap between consecutive chunks (default: from settings)
34
+ """
35
+ self.chunk_size = chunk_size or settings.CHUNK_SIZE
36
+ self.chunk_overlap = chunk_overlap or settings.CHUNK_OVERLAP
37
+
38
+ if self.chunk_overlap >= self.chunk_size:
39
+ raise ValueError("chunk_overlap must be less than chunk_size")
40
+
41
+ logger.info(
42
+ f"TextChunker initialized: chunk_size={self.chunk_size}, "
43
+ f"overlap={self.chunk_overlap}"
44
+ )
45
+
46
+ def chunk_text(
47
+ self,
48
+ text: str,
49
+ metadata: Dict[str, Any] = None,
50
+ ) -> List[Dict[str, Any]]:
51
+ """
52
+ Split text into overlapping chunks.
53
+
54
+ Args:
55
+ text: Input text to chunk
56
+ metadata: Optional metadata to attach to each chunk
57
+
58
+ Returns:
59
+ List of chunk dictionaries, each containing:
60
+ - 'text': Chunk text
61
+ - 'metadata': Chunk metadata (includes chunk_index)
62
+ """
63
+ if not text or not text.strip():
64
+ logger.warning("Empty text provided to chunker")
65
+ return []
66
+
67
+ # Split into sentences for better boundary detection
68
+ sentences = self._split_into_sentences(text)
69
+
70
+ chunks = []
71
+ current_chunk = []
72
+ current_length = 0
73
+ chunk_index = 0
74
+
75
+ for sentence in sentences:
76
+ sentence_length = len(sentence)
77
+
78
+ # If adding this sentence exceeds chunk_size, finalize current chunk
79
+ if current_length + sentence_length > self.chunk_size and current_chunk:
80
+ # Create chunk
81
+ chunk_text = " ".join(current_chunk)
82
+ chunks.append(self._create_chunk(chunk_text, chunk_index, metadata))
83
+ chunk_index += 1
84
+
85
+ # Start new chunk with overlap
86
+ overlap_text = chunk_text[-self.chunk_overlap:] if len(chunk_text) > self.chunk_overlap else chunk_text
87
+ current_chunk = [overlap_text]
88
+ current_length = len(overlap_text)
89
+
90
+ # Add sentence to current chunk
91
+ current_chunk.append(sentence)
92
+ current_length += sentence_length + 1 # +1 for space
93
+
94
+ # Add final chunk
95
+ if current_chunk:
96
+ chunk_text = " ".join(current_chunk)
97
+ chunks.append(self._create_chunk(chunk_text, chunk_index, metadata))
98
+
99
+ logger.info(f"βœ“ Created {len(chunks)} chunks from {len(text)} characters")
100
+
101
+ return chunks
102
+
103
+ def _split_into_sentences(self, text: str) -> List[str]:
104
+ """
105
+ Split text into sentences using regex.
106
+
107
+ Handles common sentence boundaries like:
108
+ - Period followed by space and capital letter
109
+ - Question marks and exclamation marks
110
+ - Preserves abbreviations like "Dr." and "U.S."
111
+ """
112
+ # Simple sentence splitting pattern
113
+ # Matches: . ! ? followed by space and capital letter
114
+ sentence_pattern = r'(?<=[.!?])\s+(?=[A-Z])'
115
+
116
+ sentences = re.split(sentence_pattern, text)
117
+
118
+ # Clean up sentences
119
+ sentences = [s.strip() for s in sentences if s.strip()]
120
+
121
+ return sentences
122
+
123
+ def _create_chunk(
124
+ self,
125
+ text: str,
126
+ chunk_index: int,
127
+ base_metadata: Dict[str, Any] = None,
128
+ ) -> Dict[str, Any]:
129
+ """
130
+ Create a chunk dictionary with metadata.
131
+
132
+ Args:
133
+ text: Chunk text
134
+ chunk_index: Index of this chunk in the document
135
+ base_metadata: Base metadata from the document
136
+
137
+ Returns:
138
+ Dictionary with 'text' and 'metadata' keys
139
+ """
140
+ metadata = base_metadata.copy() if base_metadata else {}
141
+
142
+ # Add chunk-specific metadata
143
+ metadata.update({
144
+ "chunk_index": chunk_index,
145
+ "chunk_length": len(text),
146
+ })
147
+
148
+ return {
149
+ "text": text,
150
+ "metadata": metadata,
151
+ }
152
+
153
+ def chunk_document(
154
+ self,
155
+ document: Dict[str, Any],
156
+ ) -> List[Dict[str, Any]]:
157
+ """
158
+ Chunk a loaded document (output from DocumentLoader).
159
+
160
+ Args:
161
+ document: Dictionary with 'text' and 'metadata' keys
162
+
163
+ Returns:
164
+ List of chunks with metadata
165
+ """
166
+ text = document.get("text", "")
167
+ metadata = document.get("metadata", {})
168
+
169
+ return self.chunk_text(text, metadata)
170
+
171
+ def chunk_documents(
172
+ self,
173
+ documents: Dict[str, Dict[str, Any]],
174
+ ) -> List[Dict[str, Any]]:
175
+ """
176
+ Chunk multiple documents.
177
+
178
+ Args:
179
+ documents: Dictionary mapping filename -> document data
180
+
181
+ Returns:
182
+ Flattened list of all chunks from all documents
183
+ """
184
+ all_chunks = []
185
+
186
+ for filename, doc_data in documents.items():
187
+ logger.info(f"Chunking document: {filename}")
188
+ chunks = self.chunk_document(doc_data)
189
+ all_chunks.extend(chunks)
190
+
191
+ logger.info(f"βœ“ Total chunks created: {len(all_chunks)}")
192
+
193
+ return all_chunks
194
+
195
+
196
+ # ============================================================
197
+ # Advanced Chunking Strategies (Optional)
198
+ # ============================================================
199
+
200
+ class SemanticChunker(TextChunker):
201
+ """
202
+ Advanced chunker that tries to preserve semantic boundaries.
203
+
204
+ Uses paragraph breaks and section headers as primary split points.
205
+ Falls back to sentence-based chunking when needed.
206
+ """
207
+
208
+ def chunk_text(
209
+ self,
210
+ text: str,
211
+ metadata: Dict[str, Any] = None,
212
+ ) -> List[Dict[str, Any]]:
213
+ """Override to use paragraph-aware chunking."""
214
+
215
+ if not text or not text.strip():
216
+ return []
217
+
218
+ # Split by paragraphs (double newline)
219
+ paragraphs = re.split(r'\n\s*\n', text)
220
+ paragraphs = [p.strip() for p in paragraphs if p.strip()]
221
+
222
+ chunks = []
223
+ current_chunk = []
224
+ current_length = 0
225
+ chunk_index = 0
226
+
227
+ for para in paragraphs:
228
+ para_length = len(para)
229
+
230
+ # If paragraph itself is too large, split it
231
+ if para_length > self.chunk_size:
232
+ # Finalize current chunk first
233
+ if current_chunk:
234
+ chunk_text = "\n\n".join(current_chunk)
235
+ chunks.append(self._create_chunk(chunk_text, chunk_index, metadata))
236
+ chunk_index += 1
237
+ current_chunk = []
238
+ current_length = 0
239
+
240
+ # Split large paragraph using parent method
241
+ para_chunks = super().chunk_text(para, metadata)
242
+ for pc in para_chunks:
243
+ pc['metadata']['chunk_index'] = chunk_index
244
+ chunks.append(pc)
245
+ chunk_index += 1
246
+
247
+ continue
248
+
249
+ # Check if adding this paragraph exceeds limit
250
+ if current_length + para_length > self.chunk_size and current_chunk:
251
+ chunk_text = "\n\n".join(current_chunk)
252
+ chunks.append(self._create_chunk(chunk_text, chunk_index, metadata))
253
+ chunk_index += 1
254
+ current_chunk = []
255
+ current_length = 0
256
+
257
+ current_chunk.append(para)
258
+ current_length += para_length
259
+
260
+ # Add final chunk
261
+ if current_chunk:
262
+ chunk_text = "\n\n".join(current_chunk)
263
+ chunks.append(self._create_chunk(chunk_text, chunk_index, metadata))
264
+
265
+ logger.info(f"βœ“ Semantic chunking: {len(chunks)} chunks created")
266
+
267
+ return chunks
268
+
269
+
270
+ # ============================================================
271
+ # Global Chunker Instance
272
+ # ============================================================
273
+ _chunker_instance = None
274
+
275
+
276
+ def get_chunker(semantic: bool = False) -> TextChunker:
277
+ """
278
+ Returns a chunker instance.
279
+
280
+ Args:
281
+ semantic: If True, returns SemanticChunker (paragraph-aware)
282
+ If False, returns standard TextChunker
283
+ """
284
+ global _chunker_instance
285
+
286
+ if _chunker_instance is None:
287
+ if semantic:
288
+ _chunker_instance = SemanticChunker()
289
+ else:
290
+ _chunker_instance = TextChunker()
291
+
292
+ return _chunker_instance
app/ingestion/embedder.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List, Union
2
+ import logging
3
+
4
+ import requests
5
+
6
+ from app.config.settings import settings
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class OllamaEmbedder:
12
+ """Wrapper for Ollama's local embedding API."""
13
+
14
+ def __init__(self, base_url: str = None, model: str = None):
15
+ self.base_url = base_url or settings.OLLAMA_BASE_URL
16
+ self.model = model or settings.EMBEDDING_MODEL
17
+ self.embed_endpoint = f"{self.base_url}/api/embeddings"
18
+
19
+ logger.info("Initialized OllamaEmbedder with model: %s", self.model)
20
+ self._check_ollama_connection()
21
+
22
+ def _check_ollama_connection(self) -> None:
23
+ try:
24
+ response = requests.get(f"{self.base_url}/api/tags", timeout=5)
25
+ response.raise_for_status()
26
+ except requests.exceptions.RequestException as exc:
27
+ raise ConnectionError(
28
+ f"Ollama server not reachable at {self.base_url}. "
29
+ f"Make sure Ollama is running. Error: {exc}"
30
+ ) from exc
31
+
32
+ def embed_single(self, text: str) -> List[float]:
33
+ if not text or not text.strip():
34
+ raise ValueError("Cannot embed empty text")
35
+
36
+ response = requests.post(
37
+ self.embed_endpoint,
38
+ json={"model": self.model, "prompt": text},
39
+ timeout=30,
40
+ )
41
+ response.raise_for_status()
42
+
43
+ embedding = response.json().get("embedding")
44
+ if not embedding:
45
+ raise RuntimeError("No embedding returned from Ollama")
46
+
47
+ return embedding
48
+
49
+ def embed_batch(self, texts: List[str]) -> List[List[float]]:
50
+ embeddings = []
51
+ for index, text in enumerate(texts):
52
+ try:
53
+ embeddings.append(self.embed_single(text))
54
+ except Exception as exc:
55
+ logger.error("Failed to embed text %s: %s", index, exc)
56
+ embeddings.append([0.0] * settings.EMBEDDING_DIMENSION)
57
+
58
+ return embeddings
59
+
60
+ def embed(self, text: Union[str, List[str]]) -> Union[List[float], List[List[float]]]:
61
+ if isinstance(text, str):
62
+ return self.embed_single(text)
63
+ if isinstance(text, list):
64
+ return self.embed_batch(text)
65
+ raise TypeError("Input must be str or List[str]")
66
+
67
+ def get_embedding_dimension(self) -> int:
68
+ return settings.EMBEDDING_DIMENSION
69
+
70
+
71
+ class HuggingFaceEmbedder:
72
+ """Local sentence-transformers embedder for deployable Python hosting."""
73
+
74
+ def __init__(self, model: str = None):
75
+ try:
76
+ from sentence_transformers import SentenceTransformer
77
+ except ImportError as exc:
78
+ raise RuntimeError(
79
+ "Hugging Face embeddings require sentence-transformers. "
80
+ "Install requirements.txt again."
81
+ ) from exc
82
+
83
+ self.model_name = model or settings.EMBEDDING_MODEL
84
+ self.model = SentenceTransformer(self.model_name)
85
+ logger.info("Initialized HuggingFaceEmbedder with model: %s", self.model_name)
86
+
87
+ actual_dimension = self.model.get_sentence_embedding_dimension()
88
+ if actual_dimension != settings.EMBEDDING_DIMENSION:
89
+ raise ValueError(
90
+ f"EMBEDDING_DIMENSION={settings.EMBEDDING_DIMENSION} does not match "
91
+ f"{self.model_name} output dimension {actual_dimension}."
92
+ )
93
+
94
+ def embed_single(self, text: str) -> List[float]:
95
+ if not text or not text.strip():
96
+ raise ValueError("Cannot embed empty text")
97
+
98
+ return self.model.encode(text, normalize_embeddings=True).tolist()
99
+
100
+ def embed_batch(self, texts: List[str]) -> List[List[float]]:
101
+ if not texts:
102
+ return []
103
+
104
+ clean_texts = [text if text and text.strip() else " " for text in texts]
105
+ return self.model.encode(clean_texts, normalize_embeddings=True).tolist()
106
+
107
+ def embed(self, text: Union[str, List[str]]) -> Union[List[float], List[List[float]]]:
108
+ if isinstance(text, str):
109
+ return self.embed_single(text)
110
+ if isinstance(text, list):
111
+ return self.embed_batch(text)
112
+ raise TypeError("Input must be str or List[str]")
113
+
114
+ def get_embedding_dimension(self) -> int:
115
+ return settings.EMBEDDING_DIMENSION
116
+
117
+
118
+ _embedder_instance = None
119
+
120
+
121
+ def get_embedder():
122
+ """Return a singleton embedder for the configured provider."""
123
+ global _embedder_instance
124
+
125
+ if _embedder_instance is not None:
126
+ return _embedder_instance
127
+
128
+ provider = settings.EMBEDDING_PROVIDER.lower()
129
+ if provider == "ollama":
130
+ _embedder_instance = OllamaEmbedder()
131
+ elif provider in {"huggingface", "sentence-transformers", "sentence_transformers"}:
132
+ _embedder_instance = HuggingFaceEmbedder()
133
+ else:
134
+ raise ValueError(
135
+ f"Unsupported EMBEDDING_PROVIDER='{settings.EMBEDDING_PROVIDER}'. "
136
+ "Use 'ollama' or 'huggingface'."
137
+ )
138
+
139
+ return _embedder_instance
app/ingestion/ingest.py ADDED
@@ -0,0 +1,307 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List, Dict, Any, Optional
2
+ from pathlib import Path
3
+ import logging
4
+ import uuid
5
+ import tempfile
6
+
7
+ from app.ingestion.loader import get_loader
8
+ from app.ingestion.chunker import get_chunker
9
+ from app.ingestion.embedder import get_embedder
10
+ from app.sources.connectors import get_source_connector
11
+ from app.vectordb.factory import get_vector_store
12
+ from app.config.settings import settings
13
+
14
+ logging.basicConfig(
15
+ level=settings.LOG_LEVEL,
16
+ format=settings.LOG_FORMAT
17
+ )
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class IngestionPipeline:
23
+ """
24
+ End-to-end document ingestion pipeline.
25
+
26
+ Pipeline stages:
27
+ 1. Load documents (PDF, TXT, DOCX, MD)
28
+ 2. Chunk text into manageable pieces
29
+ 3. Generate embeddings using Ollama
30
+ 4. Store in ChromaDB with metadata
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ chroma_persist_dir: str = None,
36
+ collection_name: str = None,
37
+ use_semantic_chunking: bool = False,
38
+ ):
39
+ """
40
+ Args:
41
+ chroma_persist_dir: Where ChromaDB stores data
42
+ collection_name: ChromaDB collection name
43
+ use_semantic_chunking: Use paragraph-aware chunking
44
+ """
45
+ self.loader = get_loader()
46
+ self.chunker = get_chunker(semantic=use_semantic_chunking)
47
+ self.embedder = get_embedder()
48
+
49
+ self.vector_store = get_vector_store()
50
+
51
+ logger.info("βœ“ Ingestion pipeline initialized")
52
+
53
+ def ingest_file(self, file_path: str, extra_metadata: Optional[Dict[str, Any]] = None) -> int:
54
+ """
55
+ Ingest a single document file.
56
+
57
+ Args:
58
+ file_path: Path to document
59
+
60
+ Returns:
61
+ Number of chunks ingested
62
+ """
63
+ logger.info(f"Starting ingestion for: {file_path}")
64
+
65
+ # 1. Load document
66
+ document = self.loader.load(file_path)
67
+ if extra_metadata:
68
+ document["metadata"].update(extra_metadata)
69
+ document["metadata"].setdefault("source_type", "local")
70
+ document["metadata"].setdefault("source_url", "")
71
+ document["metadata"].setdefault("source_root", "")
72
+ document["metadata"].setdefault("source_path", str(file_path))
73
+ document["metadata"].setdefault("document_id", self._document_id(document["metadata"], file_path))
74
+
75
+ # 2. Chunk document
76
+ chunks = self.chunker.chunk_document(document)
77
+
78
+ if not chunks:
79
+ logger.warning(f"No chunks created from {file_path}")
80
+ return 0
81
+
82
+ # 3. Generate embeddings
83
+ chunk_texts = [chunk["text"] for chunk in chunks]
84
+ embeddings = self.embedder.embed_batch(chunk_texts)
85
+
86
+ # 4. Prepare data for ChromaDB
87
+ ids = [self._generate_chunk_id(file_path, i) for i in range(len(chunks))]
88
+ metadatas = [chunk["metadata"] for chunk in chunks]
89
+
90
+ # 5. Store in vector database
91
+ self.vector_store.add_documents(
92
+ ids=ids,
93
+ documents=chunk_texts,
94
+ embeddings=embeddings,
95
+ metadatas=metadatas,
96
+ )
97
+
98
+ logger.info(f"βœ“ Ingested {len(chunks)} chunks from {Path(file_path).name}")
99
+
100
+ return len(chunks)
101
+
102
+ def ingest_directory(self, directory_path: str) -> Dict[str, int]:
103
+ """
104
+ Ingest all supported documents from a directory.
105
+
106
+ Args:
107
+ directory_path: Path to directory containing documents
108
+
109
+ Returns:
110
+ Dictionary mapping filename -> number of chunks ingested
111
+ """
112
+ logger.info(f"Starting batch ingestion from: {directory_path}")
113
+
114
+ dir_path = Path(directory_path)
115
+
116
+ if not dir_path.exists() or not dir_path.is_dir():
117
+ raise ValueError(f"Invalid directory: {directory_path}")
118
+
119
+ results = {}
120
+ total_chunks = 0
121
+
122
+ # Get all supported files
123
+ supported_files = [
124
+ f for f in dir_path.iterdir()
125
+ if f.is_file() and f.suffix.lower() in settings.SUPPORTED_FILE_TYPES
126
+ ]
127
+
128
+ logger.info(f"Found {len(supported_files)} supported documents")
129
+
130
+ for file_path in supported_files:
131
+ try:
132
+ num_chunks = self.ingest_file(
133
+ str(file_path),
134
+ extra_metadata={
135
+ "source_type": "local",
136
+ "source_path": str(file_path),
137
+ "document_id": f"local:{file_path.resolve()}",
138
+ },
139
+ )
140
+ results[file_path.name] = num_chunks
141
+ total_chunks += num_chunks
142
+ except Exception as e:
143
+ logger.error(f"Failed to ingest {file_path.name}: {e}")
144
+ results[file_path.name] = 0
145
+
146
+ logger.info(f"βœ“ Batch ingestion complete: {total_chunks} total chunks from {len(results)} files")
147
+
148
+ return results
149
+
150
+ def ingest_documents(self, documents: Dict[str, Dict[str, Any]]) -> int:
151
+ """
152
+ Ingest pre-loaded documents (from DocumentLoader.load_directory()).
153
+
154
+ Args:
155
+ documents: Dictionary mapping filename -> document data
156
+
157
+ Returns:
158
+ Total number of chunks ingested
159
+ """
160
+ logger.info(f"Ingesting {len(documents)} pre-loaded documents")
161
+
162
+ total_chunks = 0
163
+
164
+ for filename, doc_data in documents.items():
165
+ try:
166
+ # Chunk document
167
+ chunks = self.chunker.chunk_document(doc_data)
168
+
169
+ if not chunks:
170
+ continue
171
+
172
+ # Generate embeddings
173
+ chunk_texts = [chunk["text"] for chunk in chunks]
174
+ embeddings = self.embedder.embed_batch(chunk_texts)
175
+
176
+ # Prepare for ChromaDB
177
+ ids = [self._generate_chunk_id(filename, i) for i in range(len(chunks))]
178
+ metadatas = [chunk["metadata"] for chunk in chunks]
179
+
180
+ # Store
181
+ self.vector_store.add_documents(
182
+ ids=ids,
183
+ documents=chunk_texts,
184
+ embeddings=embeddings,
185
+ metadatas=metadatas,
186
+ )
187
+
188
+ total_chunks += len(chunks)
189
+ logger.info(f"βœ“ Ingested {len(chunks)} chunks from {filename}")
190
+
191
+ except Exception as e:
192
+ logger.error(f"Failed to ingest {filename}: {e}")
193
+
194
+ logger.info(f"βœ“ Total ingestion: {total_chunks} chunks")
195
+
196
+ return total_chunks
197
+
198
+ def ingest_source_url(self, source_url: str) -> Dict[str, int]:
199
+ """
200
+ Ingest supported documents from a public source URL.
201
+
202
+ Demo sources:
203
+ - Public GitHub repository URL
204
+ - Public Google Drive file URL
205
+ """
206
+ connector = get_source_connector()
207
+ results = {}
208
+
209
+ with tempfile.TemporaryDirectory(prefix="document_source_") as temp_dir:
210
+ source_documents = connector.fetch(source_url, Path(temp_dir))
211
+
212
+ if not source_documents:
213
+ return results
214
+
215
+ for source_document in source_documents:
216
+ try:
217
+ num_chunks = self.ingest_file(
218
+ str(source_document.path),
219
+ extra_metadata=source_document.metadata,
220
+ )
221
+ display_name = source_document.metadata.get("source_path", source_document.path.name)
222
+ results[display_name] = num_chunks
223
+ except Exception as e:
224
+ logger.error("Failed to ingest %s: %s", source_document.path.name, e)
225
+ results[source_document.path.name] = 0
226
+
227
+ return results
228
+
229
+ def _generate_chunk_id(self, source: str, chunk_index: int) -> str:
230
+ """
231
+ Generate unique ID for a chunk.
232
+
233
+ Format: {source_name}_{chunk_index}_{uuid}
234
+ """
235
+ source_name = Path(source).stem # filename without extension
236
+ unique_id = str(uuid.uuid4())[:8] # Short UUID
237
+
238
+ return f"{source_name}_chunk{chunk_index}_{unique_id}"
239
+
240
+ def _document_id(self, metadata: Dict[str, Any], file_path: str) -> str:
241
+ source_url = metadata.get("source_url")
242
+ if source_url:
243
+ return source_url
244
+
245
+ return f"local:{Path(file_path).resolve()}"
246
+
247
+ def get_status(self) -> Dict[str, Any]:
248
+ """
249
+ Get current status of the vector database.
250
+
251
+ Returns:
252
+ Dictionary with collection info and document count
253
+ """
254
+ info = self.vector_store.get_collection_info()
255
+
256
+ return {
257
+ "collection_name": info["name"],
258
+ "total_chunks": info["count"],
259
+ "metadata": info["metadata"],
260
+ }
261
+
262
+ def reset_database(self) -> None:
263
+ """
264
+ Delete all data from the vector database.
265
+
266
+ WARNING: This is irreversible!
267
+ """
268
+ logger.warning("Resetting vector database - all data will be deleted!")
269
+ self.vector_store.delete_all()
270
+ logger.info("βœ“ Database reset complete")
271
+
272
+
273
+ # ============================================================
274
+ # Convenience Functions
275
+ # ============================================================
276
+
277
+ def ingest_file(file_path: str) -> int:
278
+ """
279
+ Quick function to ingest a single file.
280
+
281
+ Args:
282
+ file_path: Path to document
283
+
284
+ Returns:
285
+ Number of chunks ingested
286
+ """
287
+ pipeline = IngestionPipeline()
288
+ return pipeline.ingest_file(file_path)
289
+
290
+
291
+ def ingest_directory(directory_path: str) -> Dict[str, int]:
292
+ """
293
+ Quick function to ingest a directory.
294
+
295
+ Args:
296
+ directory_path: Path to directory
297
+
298
+ Returns:
299
+ Dictionary mapping filename -> chunk count
300
+ """
301
+ pipeline = IngestionPipeline()
302
+ return pipeline.ingest_directory(directory_path)
303
+
304
+
305
+ def get_pipeline() -> IngestionPipeline:
306
+ """Returns a configured ingestion pipeline instance."""
307
+ return IngestionPipeline()
app/ingestion/loader.py ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ from typing import Dict, Optional
3
+ import logging
4
+
5
+ # Document parsing libraries
6
+ import PyPDF2
7
+ from docx import Document as DocxDocument
8
+ import markdown
9
+ from bs4 import BeautifulSoup
10
+
11
+ from app.config.settings import settings
12
+
13
+ logging.basicConfig(
14
+ level=settings.LOG_LEVEL,
15
+ format=settings.LOG_FORMAT
16
+ )
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class DocumentLoader:
22
+ """
23
+ Unified document loader for multiple file formats.
24
+
25
+ Supports:
26
+ - PDF (.pdf)
27
+ - Text files (.txt)
28
+ - Word documents (.docx)
29
+ - Markdown files (.md)
30
+ """
31
+
32
+ def __init__(self):
33
+ self.supported_types = settings.SUPPORTED_FILE_TYPES
34
+ logger.info(f"DocumentLoader initialized. Supported types: {self.supported_types}")
35
+
36
+ def load(self, file_path: str) -> Dict[str, any]:
37
+ """
38
+ Load a document and extract its text content.
39
+
40
+ Args:
41
+ file_path: Path to the document file
42
+
43
+ Returns:
44
+ Dictionary containing:
45
+ - 'text': Extracted text content
46
+ - 'metadata': File metadata (name, type, size, etc.)
47
+
48
+ Raises:
49
+ FileNotFoundError: If file doesn't exist
50
+ ValueError: If file type is not supported
51
+ """
52
+ path = Path(file_path)
53
+
54
+ if not path.exists():
55
+ raise FileNotFoundError(f"File not found: {file_path}")
56
+
57
+ file_ext = path.suffix.lower()
58
+
59
+ if file_ext not in self.supported_types:
60
+ raise ValueError(
61
+ f"Unsupported file type: {file_ext}. "
62
+ f"Supported types: {self.supported_types}"
63
+ )
64
+
65
+ logger.info(f"Loading document: {path.name}")
66
+
67
+ # Route to appropriate loader
68
+ if file_ext == ".pdf":
69
+ text = self._load_pdf(path)
70
+ elif file_ext == ".txt":
71
+ text = self._load_txt(path)
72
+ elif file_ext == ".docx":
73
+ text = self._load_docx(path)
74
+ elif file_ext == ".md":
75
+ text = self._load_markdown(path)
76
+ else:
77
+ raise ValueError(f"No loader implemented for {file_ext}")
78
+
79
+ # Build metadata
80
+ metadata = {
81
+ "filename": path.name,
82
+ "file_type": file_ext,
83
+ "file_size_bytes": path.stat().st_size,
84
+ "char_count": len(text),
85
+ }
86
+
87
+ logger.info(f"βœ“ Loaded {path.name}: {len(text)} characters")
88
+
89
+ return {
90
+ "text": text,
91
+ "metadata": metadata,
92
+ }
93
+
94
+ # ------------------------------------------------------------------
95
+ # Format-specific loaders
96
+ # ------------------------------------------------------------------
97
+
98
+ def _load_pdf(self, path: Path) -> str:
99
+ """Extract text from PDF using PyPDF2."""
100
+ text = []
101
+
102
+ try:
103
+ with open(path, 'rb') as file:
104
+ pdf_reader = PyPDF2.PdfReader(file)
105
+ num_pages = len(pdf_reader.pages)
106
+
107
+ for page_num in range(num_pages):
108
+ page = pdf_reader.pages[page_num]
109
+ page_text = page.extract_text()
110
+
111
+ if page_text:
112
+ text.append(page_text)
113
+
114
+ logger.info(f" Extracted {num_pages} pages from PDF")
115
+
116
+ except Exception as e:
117
+ logger.error(f"Failed to parse PDF {path.name}: {e}")
118
+ raise RuntimeError(f"PDF parsing failed: {e}")
119
+
120
+ return "\n\n".join(text)
121
+
122
+ def _load_txt(self, path: Path) -> str:
123
+ """Load plain text file."""
124
+ try:
125
+ with open(path, 'r', encoding='utf-8') as file:
126
+ text = file.read()
127
+ return text
128
+ except UnicodeDecodeError:
129
+ # Fallback to Latin-1 encoding
130
+ logger.warning(f"UTF-8 decode failed for {path.name}, trying latin-1")
131
+ with open(path, 'r', encoding='latin-1') as file:
132
+ text = file.read()
133
+ return text
134
+
135
+ def _load_docx(self, path: Path) -> str:
136
+ """Extract text from Word document."""
137
+ try:
138
+ doc = DocxDocument(path)
139
+ paragraphs = [para.text for para in doc.paragraphs if para.text.strip()]
140
+
141
+ logger.info(f" Extracted {len(paragraphs)} paragraphs from DOCX")
142
+
143
+ return "\n\n".join(paragraphs)
144
+
145
+ except Exception as e:
146
+ logger.error(f"Failed to parse DOCX {path.name}: {e}")
147
+ raise RuntimeError(f"DOCX parsing failed: {e}")
148
+
149
+ def _load_markdown(self, path: Path) -> str:
150
+ """
151
+ Load Markdown file and convert to plain text.
152
+ Strips HTML tags from rendered markdown.
153
+ """
154
+ try:
155
+ with open(path, 'r', encoding='utf-8') as file:
156
+ md_text = file.read()
157
+
158
+ # Convert markdown to HTML
159
+ html = markdown.markdown(md_text)
160
+
161
+ # Strip HTML tags to get plain text
162
+ soup = BeautifulSoup(html, 'html.parser')
163
+ text = soup.get_text(separator='\n\n')
164
+
165
+ return text
166
+
167
+ except Exception as e:
168
+ logger.error(f"Failed to parse Markdown {path.name}: {e}")
169
+ raise RuntimeError(f"Markdown parsing failed: {e}")
170
+
171
+ # ------------------------------------------------------------------
172
+ # Batch loading
173
+ # ------------------------------------------------------------------
174
+
175
+ def load_directory(self, directory_path: str) -> Dict[str, Dict]:
176
+ """
177
+ Load all supported documents from a directory.
178
+
179
+ Args:
180
+ directory_path: Path to directory containing documents
181
+
182
+ Returns:
183
+ Dictionary mapping filename -> loaded document data
184
+ """
185
+ dir_path = Path(directory_path)
186
+
187
+ if not dir_path.exists() or not dir_path.is_dir():
188
+ raise ValueError(f"Invalid directory: {directory_path}")
189
+
190
+ documents = {}
191
+
192
+ for file_path in dir_path.iterdir():
193
+ if file_path.is_file() and file_path.suffix.lower() in self.supported_types:
194
+ try:
195
+ doc_data = self.load(str(file_path))
196
+ documents[file_path.name] = doc_data
197
+ except Exception as e:
198
+ logger.error(f"Failed to load {file_path.name}: {e}")
199
+
200
+ logger.info(f"βœ“ Loaded {len(documents)} documents from {dir_path.name}")
201
+
202
+ return documents
203
+
204
+
205
+ # ============================================================
206
+ # Global Loader Instance
207
+ # ============================================================
208
+ _loader_instance = None
209
+
210
+
211
+ def get_loader() -> DocumentLoader:
212
+ """Returns a singleton instance of DocumentLoader."""
213
+ global _loader_instance
214
+
215
+ if _loader_instance is None:
216
+ _loader_instance = DocumentLoader()
217
+
218
+ return _loader_instance
app/retrieval/aggregator.py ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List, Dict, Any
2
+ from collections import defaultdict
3
+ import logging
4
+
5
+ from app.config.settings import settings
6
+
7
+ logging.basicConfig(
8
+ level=settings.LOG_LEVEL,
9
+ format=settings.LOG_FORMAT
10
+ )
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class ResultAggregator:
16
+ """
17
+ Aggregates and ranks search results by document.
18
+
19
+ Takes raw chunk-level results and groups them by source document,
20
+ computing document-level relevance scores.
21
+ """
22
+
23
+ def __init__(self):
24
+ logger.info("ResultAggregator initialized")
25
+
26
+ def aggregate_by_document(
27
+ self,
28
+ results: List[Dict[str, Any]],
29
+ max_chunks_per_doc: int = 3,
30
+ ) -> List[Dict[str, Any]]:
31
+ """
32
+ Group search results by source document.
33
+
34
+ Args:
35
+ results: List of chunk-level search results
36
+ max_chunks_per_doc: Max chunks to include per document
37
+
38
+ Returns:
39
+ List of document-level results, each containing:
40
+ - 'filename': Source document name
41
+ - 'relevance_score': Aggregated relevance
42
+ - 'chunks': Top matching chunks from this document
43
+ - 'metadata': Document metadata
44
+ """
45
+ if not results:
46
+ return []
47
+
48
+ # Group chunks by stable document identity. Filenames are not enough
49
+ # once the same repo/folder can contain duplicate names.
50
+ doc_groups = defaultdict(list)
51
+
52
+ for result in results:
53
+ document_key = result['metadata'].get('document_id') or result['metadata'].get('source_url') or result['metadata'].get('filename', 'unknown')
54
+ doc_groups[document_key].append(result)
55
+
56
+ # Aggregate and rank documents
57
+ aggregated = []
58
+
59
+ for document_key, chunks in doc_groups.items():
60
+ # Sort chunks by similarity (highest first)
61
+ chunks = sorted(chunks, key=lambda x: x['similarity'], reverse=True)
62
+
63
+ # Take top N chunks
64
+ top_chunks = chunks[:max_chunks_per_doc]
65
+
66
+ # Calculate document-level relevance score
67
+ # Use average of top chunks' similarities
68
+ relevance_score = sum(c['similarity'] for c in top_chunks) / len(top_chunks)
69
+
70
+ # Extract document metadata (from first chunk)
71
+ doc_metadata = self._extract_document_metadata(chunks[0]['metadata'])
72
+ filename = doc_metadata.get('filename', document_key)
73
+
74
+ aggregated.append({
75
+ 'filename': filename,
76
+ 'document_id': document_key,
77
+ 'relevance_score': relevance_score,
78
+ 'num_matching_chunks': len(chunks),
79
+ 'chunks': top_chunks,
80
+ 'metadata': doc_metadata,
81
+ })
82
+
83
+ # Sort documents by relevance
84
+ aggregated = sorted(aggregated, key=lambda x: x['relevance_score'], reverse=True)
85
+
86
+ logger.info(f"βœ“ Aggregated {len(results)} chunks into {len(aggregated)} documents")
87
+
88
+ return aggregated
89
+
90
+ def _extract_document_metadata(self, chunk_metadata: Dict[str, Any]) -> Dict[str, Any]:
91
+ """
92
+ Extract document-level metadata from chunk metadata.
93
+
94
+ Removes chunk-specific fields like chunk_index.
95
+ """
96
+ doc_metadata = chunk_metadata.copy()
97
+
98
+ # Remove chunk-specific fields
99
+ chunk_fields = ['chunk_index', 'chunk_length']
100
+ for field in chunk_fields:
101
+ doc_metadata.pop(field, None)
102
+
103
+ return doc_metadata
104
+
105
+ def format_for_display(
106
+ self,
107
+ aggregated_results: List[Dict[str, Any]],
108
+ include_chunk_text: bool = True,
109
+ ) -> List[Dict[str, Any]]:
110
+ """
111
+ Format aggregated results for UI display.
112
+
113
+ Args:
114
+ aggregated_results: Output from aggregate_by_document()
115
+ include_chunk_text: Whether to include full chunk text
116
+
117
+ Returns:
118
+ Formatted results suitable for display
119
+ """
120
+ formatted = []
121
+
122
+ for doc in aggregated_results:
123
+ formatted_doc = {
124
+ 'filename': doc['filename'],
125
+ 'relevance_score': round(doc['relevance_score'], 3),
126
+ 'num_matches': doc['num_matching_chunks'],
127
+ 'file_type': doc['metadata'].get('file_type', 'unknown'),
128
+ }
129
+
130
+ if include_chunk_text:
131
+ formatted_doc['excerpts'] = []
132
+
133
+ for chunk in doc['chunks']:
134
+ excerpt = {
135
+ 'text': self._create_excerpt(chunk['text']),
136
+ 'similarity': round(chunk['similarity'], 3),
137
+ 'chunk_index': chunk['metadata'].get('chunk_index', 0),
138
+ }
139
+ formatted_doc['excerpts'].append(excerpt)
140
+
141
+ formatted.append(formatted_doc)
142
+
143
+ return formatted
144
+
145
+ def _create_excerpt(self, text: str, max_length: int = 200) -> str:
146
+ """
147
+ Create a display excerpt from full chunk text.
148
+
149
+ Truncates long text and adds ellipsis.
150
+ """
151
+ if len(text) <= max_length:
152
+ return text
153
+
154
+ return text[:max_length].strip() + "..."
155
+
156
+
157
+ # ============================================================
158
+ # Convenience Functions
159
+ # ============================================================
160
+
161
+ def aggregate_results(
162
+ results: List[Dict[str, Any]],
163
+ max_chunks_per_doc: int = 3,
164
+ ) -> List[Dict[str, Any]]:
165
+ """
166
+ Quick function to aggregate search results.
167
+
168
+ Args:
169
+ results: Raw chunk-level search results
170
+ max_chunks_per_doc: Max chunks to show per document
171
+
172
+ Returns:
173
+ Document-level aggregated results
174
+ """
175
+ aggregator = ResultAggregator()
176
+ return aggregator.aggregate_by_document(results, max_chunks_per_doc)
177
+
178
+
179
+ def get_aggregator() -> ResultAggregator:
180
+ """Returns a ResultAggregator instance."""
181
+ return ResultAggregator()
app/retrieval/chat.py ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Dict, List
2
+ import logging
3
+
4
+ import requests
5
+
6
+ from app.config.settings import settings
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class BaseDocumentChat:
12
+ """Common retrieved-context prompt builder."""
13
+
14
+ def answer(self, question: str, chunks: List[Dict], max_context_chars: int = 4000) -> str:
15
+ raise NotImplementedError
16
+
17
+ def healthcheck(self) -> None:
18
+ raise NotImplementedError
19
+
20
+ def _build_prompt(self, question: str, chunks: List[Dict], max_context_chars: int) -> str:
21
+ context = self._build_context(chunks, max_context_chars)
22
+ if not context:
23
+ return ""
24
+
25
+ return (
26
+ "Answer the user's question using only the document excerpts below. "
27
+ "If the excerpts do not contain the answer, say that the indexed documents do not show it.\n\n"
28
+ f"Document excerpts:\n{context}\n\n"
29
+ f"Question: {question}\n"
30
+ "Answer:"
31
+ )
32
+
33
+ def _build_context(self, chunks: List[Dict], max_context_chars: int) -> str:
34
+ parts = []
35
+ used_chars = 0
36
+
37
+ for chunk in chunks:
38
+ metadata = chunk.get("metadata", {})
39
+ filename = metadata.get("filename", "unknown")
40
+ chunk_index = metadata.get("chunk_index", 0)
41
+ text = chunk.get("text", "")
42
+ part = f"[{filename} chunk {chunk_index}]\n{text}"
43
+
44
+ if used_chars + len(part) > max_context_chars:
45
+ break
46
+
47
+ parts.append(part)
48
+ used_chars += len(part)
49
+
50
+ return "\n\n".join(parts)
51
+
52
+
53
+ class OllamaDocumentChat(BaseDocumentChat):
54
+ """Answer questions with a local Ollama chat model."""
55
+
56
+ def __init__(self, base_url: str = None, model: str = None):
57
+ self.base_url = base_url or settings.OLLAMA_BASE_URL
58
+ self.model = model or settings.CHAT_MODEL
59
+ self.generate_endpoint = f"{self.base_url}/api/generate"
60
+
61
+ def answer(self, question: str, chunks: List[Dict], max_context_chars: int = 4000) -> str:
62
+ prompt = self._build_prompt(question, chunks, max_context_chars)
63
+ if not prompt:
64
+ return "I could not find enough retrieved context to answer this."
65
+
66
+ response = requests.post(
67
+ self.generate_endpoint,
68
+ json={"model": self.model, "prompt": prompt, "stream": False},
69
+ timeout=90,
70
+ )
71
+ response.raise_for_status()
72
+ return response.json().get("response", "").strip() or "The model returned an empty answer."
73
+
74
+ def healthcheck(self) -> None:
75
+ response = requests.get(f"{self.base_url}/api/tags", timeout=5)
76
+ response.raise_for_status()
77
+
78
+ models = response.json().get("models", [])
79
+ model_names = {model.get("name") for model in models}
80
+ if self.model not in model_names:
81
+ available = ", ".join(sorted(name for name in model_names if name)) or "none"
82
+ raise RuntimeError(
83
+ f"Chat model '{self.model}' is not installed in Ollama. "
84
+ f"Available models: {available}. Run: ollama pull {self.model}"
85
+ )
86
+
87
+
88
+ class GroqDocumentChat(BaseDocumentChat):
89
+ """Answer questions with Groq's OpenAI-compatible chat completions API."""
90
+
91
+ def __init__(self, api_key: str = None, base_url: str = None, model: str = None):
92
+ self.api_key = api_key or settings.GROQ_API_KEY
93
+ self.base_url = (base_url or settings.GROQ_BASE_URL).rstrip("/")
94
+ self.model = model or settings.CHAT_MODEL
95
+ self.chat_endpoint = f"{self.base_url}/chat/completions"
96
+
97
+ if not self.api_key:
98
+ raise ValueError("GROQ_API_KEY is required when CHAT_PROVIDER=groq.")
99
+
100
+ def answer(self, question: str, chunks: List[Dict], max_context_chars: int = 4000) -> str:
101
+ prompt = self._build_prompt(question, chunks, max_context_chars)
102
+ if not prompt:
103
+ return "I could not find enough retrieved context to answer this."
104
+
105
+ response = requests.post(
106
+ self.chat_endpoint,
107
+ headers={
108
+ "Authorization": f"Bearer {self.api_key}",
109
+ "Content-Type": "application/json",
110
+ },
111
+ json={
112
+ "model": self.model,
113
+ "messages": [
114
+ {
115
+ "role": "system",
116
+ "content": "You answer questions using only the retrieved document context.",
117
+ },
118
+ {"role": "user", "content": prompt},
119
+ ],
120
+ "temperature": 0.2,
121
+ "stream": False,
122
+ },
123
+ timeout=90,
124
+ )
125
+ response.raise_for_status()
126
+ choices = response.json().get("choices", [])
127
+ if not choices:
128
+ return "The chat model returned no answer."
129
+
130
+ return choices[0].get("message", {}).get("content", "").strip() or "The chat model returned an empty answer."
131
+
132
+ def healthcheck(self) -> None:
133
+ response = requests.get(
134
+ f"{self.base_url}/models",
135
+ headers={"Authorization": f"Bearer {self.api_key}"},
136
+ timeout=10,
137
+ )
138
+ response.raise_for_status()
139
+
140
+ models = response.json().get("data", [])
141
+ model_ids = {model.get("id") for model in models}
142
+ if self.model not in model_ids:
143
+ sample = ", ".join(sorted(model for model in model_ids if model)[:10]) or "none"
144
+ raise RuntimeError(
145
+ f"Groq model '{self.model}' was not found for this API key. "
146
+ f"Available examples: {sample}"
147
+ )
148
+
149
+
150
+ def get_document_chat() -> BaseDocumentChat:
151
+ provider = settings.CHAT_PROVIDER.lower()
152
+
153
+ if provider == "ollama":
154
+ return OllamaDocumentChat()
155
+ if provider == "groq":
156
+ return GroqDocumentChat()
157
+
158
+ raise ValueError(f"Unsupported CHAT_PROVIDER='{settings.CHAT_PROVIDER}'. Use 'ollama' or 'groq'.")
app/retrieval/search.py ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List, Dict, Any, Optional
2
+ import logging
3
+
4
+ from app.ingestion.embedder import get_embedder
5
+ from app.vectordb.factory import get_vector_store
6
+ from app.config.settings import settings
7
+
8
+ logging.basicConfig(
9
+ level=settings.LOG_LEVEL,
10
+ format=settings.LOG_FORMAT
11
+ )
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class DocumentSearcher:
17
+ """
18
+ Semantic search over document collection.
19
+
20
+ Workflow:
21
+ 1. Convert query text to embedding
22
+ 2. Search vector database for similar chunks
23
+ 3. Return ranked results with metadata
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ chroma_persist_dir: str = None,
29
+ collection_name: str = None,
30
+ ):
31
+ """
32
+ Args:
33
+ chroma_persist_dir: Where ChromaDB stores data
34
+ collection_name: ChromaDB collection name
35
+ """
36
+ self.embedder = get_embedder()
37
+
38
+ self.vector_store = get_vector_store()
39
+
40
+ logger.info("βœ“ DocumentSearcher initialized")
41
+
42
+ def search(
43
+ self,
44
+ query: str,
45
+ top_k: int = None,
46
+ filter_metadata: Optional[Dict[str, Any]] = None,
47
+ ) -> List[Dict[str, Any]]:
48
+ """
49
+ Search for documents similar to the query.
50
+
51
+ Args:
52
+ query: Search query text
53
+ top_k: Number of results to return
54
+ filter_metadata: Optional metadata filters (e.g., {"file_type": ".pdf"})
55
+
56
+ Returns:
57
+ List of search results, each containing:
58
+ - 'text': Chunk text
59
+ - 'metadata': Chunk metadata
60
+ - 'similarity': Similarity score (lower distance = higher similarity)
61
+ - 'rank': Result rank (1-indexed)
62
+ """
63
+ if not query or not query.strip():
64
+ logger.warning("Empty query provided")
65
+ return []
66
+
67
+ top_k = top_k or settings.DEFAULT_TOP_K
68
+
69
+ logger.info(f"Searching for: '{query[:50]}...' (top_k={top_k})")
70
+
71
+ # 1. Generate query embedding
72
+ try:
73
+ query_embedding = self.embedder.embed(query)
74
+ except Exception as e:
75
+ logger.error(f"Failed to generate query embedding: {e}")
76
+ raise RuntimeError(f"Query embedding failed: {e}")
77
+
78
+ # 2. Search vector database
79
+ try:
80
+ raw_results = self.vector_store.similarity_search(
81
+ query_embedding=query_embedding,
82
+ top_k=top_k,
83
+ where=filter_metadata,
84
+ )
85
+ except Exception as e:
86
+ logger.error(f"Vector search failed: {e}")
87
+ raise RuntimeError(f"Search failed: {e}")
88
+
89
+ # 3. Format results
90
+ results = self._format_results(raw_results)
91
+
92
+ logger.info(f"βœ“ Found {len(results)} results")
93
+
94
+ return results
95
+
96
+ def _format_results(self, raw_results: Dict[str, Any]) -> List[Dict[str, Any]]:
97
+ """
98
+ Convert ChromaDB results to standardized format.
99
+
100
+ ChromaDB returns:
101
+ {
102
+ 'ids': [[...]],
103
+ 'documents': [[...]],
104
+ 'metadatas': [[...]],
105
+ 'distances': [[...]]
106
+ }
107
+ """
108
+ if not raw_results or not raw_results.get('documents'):
109
+ return []
110
+
111
+ # ChromaDB returns nested lists (batch query support)
112
+ # We only send one query, so take first element
113
+ documents = raw_results['documents'][0]
114
+ metadatas = raw_results['metadatas'][0]
115
+ distances = raw_results['distances'][0]
116
+
117
+ results = []
118
+
119
+ for rank, (doc, metadata, distance) in enumerate(zip(documents, metadatas, distances), start=1):
120
+ # Convert cosine distance to similarity
121
+ # Cosine distance: 0 (identical) to 2 (opposite)
122
+ # Cosine similarity: 1 - distance (ranges 0 to 1)
123
+ similarity = 1.0 - distance
124
+
125
+ result = {
126
+ 'text': doc,
127
+ 'metadata': metadata,
128
+ 'distance': distance,
129
+ 'similarity': similarity,
130
+ 'rank': rank,
131
+ }
132
+
133
+ results.append(result)
134
+
135
+ return results
136
+
137
+ def search_with_threshold(
138
+ self,
139
+ query: str,
140
+ top_k: int = None,
141
+ similarity_threshold: float = None,
142
+ ) -> List[Dict[str, Any]]:
143
+ """
144
+ Search and filter by minimum similarity threshold.
145
+
146
+ Args:
147
+ query: Search query
148
+ top_k: Max results to return
149
+ similarity_threshold: Minimum similarity score (0-1)
150
+
151
+ Returns:
152
+ Filtered search results
153
+ """
154
+ threshold = similarity_threshold or settings.SIMILARITY_THRESHOLD
155
+
156
+ results = self.search(query, top_k)
157
+
158
+ # Filter by threshold
159
+ filtered_results = [
160
+ r for r in results
161
+ if r['similarity'] >= threshold
162
+ ]
163
+
164
+ logger.info(
165
+ f"Filtered {len(results)} -> {len(filtered_results)} results "
166
+ f"(threshold={threshold})"
167
+ )
168
+
169
+ return filtered_results
170
+
171
+ def get_collection_stats(self) -> Dict[str, Any]:
172
+ """
173
+ Get statistics about the indexed documents.
174
+
175
+ Returns:
176
+ Dictionary with collection metadata
177
+ """
178
+ return self.vector_store.get_collection_info()
179
+
180
+
181
+ # ============================================================
182
+ # Convenience Function
183
+ # ============================================================
184
+
185
+ def search_documents(
186
+ query: str,
187
+ top_k: int = None,
188
+ ) -> List[Dict[str, Any]]:
189
+ """
190
+ Quick search function.
191
+
192
+ Args:
193
+ query: Search query
194
+ top_k: Number of results
195
+
196
+ Returns:
197
+ Search results
198
+ """
199
+ searcher = DocumentSearcher()
200
+ return searcher.search(query, top_k)
201
+
202
+
203
+ def get_searcher() -> DocumentSearcher:
204
+ """Returns a configured DocumentSearcher instance."""
205
+ return DocumentSearcher()
app/sources/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Source connectors for external document locations."""
app/sources/connectors.py ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import dataclass
2
+ from pathlib import Path
3
+ from typing import Dict, List
4
+ from urllib.parse import urlparse
5
+ import logging
6
+ import re
7
+ import zipfile
8
+
9
+ import requests
10
+
11
+ from app.config.settings import settings
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ @dataclass
17
+ class SourceDocument:
18
+ """A downloaded source file plus metadata about where it came from."""
19
+
20
+ path: Path
21
+ metadata: Dict[str, str]
22
+
23
+
24
+ class SourceConnector:
25
+ """Fetch public source links into temporary local files for ingestion."""
26
+
27
+ def fetch(self, source_url: str, workspace_dir: Path) -> List[SourceDocument]:
28
+ parsed = urlparse(source_url)
29
+ host = parsed.netloc.lower()
30
+
31
+ if "github.com" in host:
32
+ return self._fetch_github_repo(source_url, workspace_dir)
33
+
34
+ if "drive.google.com" in host:
35
+ folder_id = self._parse_google_drive_folder_id(source_url)
36
+ if folder_id:
37
+ return self._fetch_google_drive_folder(source_url, folder_id, workspace_dir)
38
+
39
+ return [self._fetch_google_drive_file(source_url, workspace_dir)]
40
+
41
+ raise ValueError("Supported demo sources are public GitHub repository URLs and public Google Drive file links.")
42
+
43
+ def _fetch_github_repo(self, source_url: str, workspace_dir: Path) -> List[SourceDocument]:
44
+ owner, repo = self._parse_github_repo(source_url)
45
+ repo_api_url = f"https://api.github.com/repos/{owner}/{repo}"
46
+
47
+ repo_response = requests.get(repo_api_url, timeout=20)
48
+ repo_response.raise_for_status()
49
+ default_branch = repo_response.json().get("default_branch", "main")
50
+
51
+ zip_url = f"https://codeload.github.com/{owner}/{repo}/zip/refs/heads/{default_branch}"
52
+ zip_path = workspace_dir / f"{owner}_{repo}.zip"
53
+
54
+ self._download_file(zip_url, zip_path)
55
+
56
+ extract_dir = workspace_dir / f"{owner}_{repo}"
57
+ with zipfile.ZipFile(zip_path) as archive:
58
+ archive.extractall(extract_dir)
59
+
60
+ supported_files = [
61
+ path
62
+ for path in extract_dir.rglob("*")
63
+ if path.is_file() and path.suffix.lower() in settings.SUPPORTED_FILE_TYPES
64
+ ]
65
+
66
+ documents = []
67
+ for path in supported_files:
68
+ relative_path = self._repo_relative_path(path, owner, repo, default_branch)
69
+ source_file_url = f"https://github.com/{owner}/{repo}/blob/{default_branch}/{relative_path}"
70
+ documents.append(
71
+ SourceDocument(
72
+ path=path,
73
+ metadata={
74
+ "source_type": "github",
75
+ "source_url": source_file_url,
76
+ "source_root": source_url,
77
+ "source_path": relative_path,
78
+ "document_id": f"github:{owner}/{repo}:{default_branch}:{relative_path}",
79
+ },
80
+ )
81
+ )
82
+
83
+ logger.info("Fetched %s supported documents from %s/%s", len(documents), owner, repo)
84
+ return documents
85
+
86
+ def _fetch_google_drive_file(self, source_url: str, workspace_dir: Path) -> SourceDocument:
87
+ file_id = self._parse_google_drive_file_id(source_url)
88
+ if not file_id:
89
+ raise ValueError("Google Drive URL must be a public file link or public folder link.")
90
+
91
+ download_url = f"https://drive.google.com/uc?export=download&id={file_id}"
92
+ response = requests.get(download_url, timeout=30, stream=True)
93
+ response.raise_for_status()
94
+
95
+ filename = self._filename_from_response(response) or f"google_drive_{file_id}"
96
+ suffix = Path(filename).suffix.lower()
97
+ if suffix not in settings.SUPPORTED_FILE_TYPES:
98
+ raise ValueError(f"Unsupported Google Drive file type: {suffix or 'unknown'}")
99
+
100
+ destination = workspace_dir / filename
101
+ with open(destination, "wb") as file:
102
+ for chunk in response.iter_content(chunk_size=1024 * 1024):
103
+ if chunk:
104
+ file.write(chunk)
105
+
106
+ return SourceDocument(
107
+ path=destination,
108
+ metadata={
109
+ "source_type": "google_drive",
110
+ "source_url": source_url,
111
+ "source_root": source_url,
112
+ "source_path": filename,
113
+ "document_id": f"google_drive:{file_id}",
114
+ },
115
+ )
116
+
117
+ def _fetch_google_drive_folder(
118
+ self,
119
+ source_url: str,
120
+ folder_id: str,
121
+ workspace_dir: Path,
122
+ ) -> List[SourceDocument]:
123
+ try:
124
+ import gdown
125
+ except ImportError as exc:
126
+ raise RuntimeError("Google Drive folder ingestion requires the gdown package. Install requirements.txt again.") from exc
127
+
128
+ output_dir = workspace_dir / f"google_drive_{folder_id}"
129
+ output_dir.mkdir(parents=True, exist_ok=True)
130
+
131
+ downloaded_paths = gdown.download_folder(
132
+ url=source_url,
133
+ output=str(output_dir),
134
+ quiet=True,
135
+ use_cookies=False,
136
+ )
137
+
138
+ if downloaded_paths is None:
139
+ raise RuntimeError("Could not download the Google Drive folder. Confirm it is public and accessible.")
140
+
141
+ supported_files = [
142
+ Path(path)
143
+ for path in downloaded_paths
144
+ if Path(path).is_file() and Path(path).suffix.lower() in settings.SUPPORTED_FILE_TYPES
145
+ ]
146
+
147
+ documents = []
148
+ for path in supported_files:
149
+ relative_path = str(path.relative_to(output_dir)).replace("\\", "/")
150
+ documents.append(
151
+ SourceDocument(
152
+ path=path,
153
+ metadata={
154
+ "source_type": "google_drive_folder",
155
+ "source_url": source_url,
156
+ "source_root": source_url,
157
+ "source_path": relative_path,
158
+ "document_id": f"google_drive_folder:{folder_id}:{relative_path}",
159
+ },
160
+ )
161
+ )
162
+
163
+ logger.info("Fetched %s supported documents from Google Drive folder %s", len(documents), folder_id)
164
+ return documents
165
+
166
+ def _download_file(self, url: str, destination: Path) -> None:
167
+ response = requests.get(url, timeout=60, stream=True)
168
+ response.raise_for_status()
169
+
170
+ with open(destination, "wb") as file:
171
+ for chunk in response.iter_content(chunk_size=1024 * 1024):
172
+ if chunk:
173
+ file.write(chunk)
174
+
175
+ def _parse_github_repo(self, source_url: str) -> tuple[str, str]:
176
+ parsed = urlparse(source_url)
177
+ parts = [part for part in parsed.path.split("/") if part]
178
+
179
+ if len(parts) < 2:
180
+ raise ValueError("GitHub URL must look like https://github.com/owner/repo")
181
+
182
+ return parts[0], parts[1].removesuffix(".git")
183
+
184
+ def _repo_relative_path(self, path: Path, owner: str, repo: str, branch: str) -> str:
185
+ marker = f"{repo}-{branch}"
186
+ parts = path.parts
187
+
188
+ if marker in parts:
189
+ marker_index = parts.index(marker)
190
+ return str(Path(*parts[marker_index + 1 :])).replace("\\", "/")
191
+
192
+ fallback = path.name
193
+ logger.warning("Could not derive repo-relative path for %s; using %s", path, fallback)
194
+ return fallback
195
+
196
+ def _parse_google_drive_file_id(self, source_url: str) -> str | None:
197
+ patterns = [
198
+ r"/file/d/([^/]+)",
199
+ r"[?&]id=([^&]+)",
200
+ ]
201
+
202
+ for pattern in patterns:
203
+ match = re.search(pattern, source_url)
204
+ if match:
205
+ return match.group(1)
206
+
207
+ return None
208
+
209
+ def _parse_google_drive_folder_id(self, source_url: str) -> str | None:
210
+ match = re.search(r"/drive/folders/([^/?]+)", source_url)
211
+ if match:
212
+ return match.group(1)
213
+
214
+ return None
215
+
216
+ def _filename_from_response(self, response: requests.Response) -> str | None:
217
+ content_disposition = response.headers.get("content-disposition", "")
218
+ match = re.search(r'filename="?([^";]+)"?', content_disposition)
219
+ if match:
220
+ return match.group(1)
221
+
222
+ return None
223
+
224
+
225
+ def get_source_connector() -> SourceConnector:
226
+ return SourceConnector()
app/ui/main.py ADDED
@@ -0,0 +1,715 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import html
2
+ import sys
3
+ import time
4
+ from pathlib import Path
5
+ from textwrap import dedent
6
+
7
+ import streamlit as st
8
+
9
+ PROJECT_ROOT = Path(__file__).resolve().parents[2]
10
+ if str(PROJECT_ROOT) not in sys.path:
11
+ sys.path.insert(0, str(PROJECT_ROOT))
12
+
13
+ from app.config.settings import settings
14
+ from app.ingestion.ingest import get_pipeline
15
+ from app.retrieval.aggregator import get_aggregator
16
+ from app.retrieval.chat import get_document_chat
17
+ from app.retrieval.search import get_searcher
18
+
19
+
20
+ st.set_page_config(
21
+ page_title="SourceLink AI",
22
+ page_icon="SL",
23
+ layout="wide",
24
+ initial_sidebar_state="collapsed",
25
+ )
26
+
27
+
28
+ def inject_styles() -> None:
29
+ st.html(
30
+ dedent(
31
+ """
32
+ <style>
33
+ :root {
34
+ --ink: #101828;
35
+ --muted: #667085;
36
+ --line: #e6e9ef;
37
+ --soft: #f7f8fb;
38
+ --violet: #7047eb;
39
+ --pink: #d946ef;
40
+ --cyan: #06b6d4;
41
+ --green: #10b981;
42
+ }
43
+
44
+ .stApp {
45
+ background: #f8fafc;
46
+ color: var(--ink);
47
+ }
48
+
49
+ .block-container {
50
+ max-width: 1180px;
51
+ padding-top: 1.1rem;
52
+ padding-bottom: 3rem;
53
+ }
54
+
55
+ section[data-testid="stSidebar"] {
56
+ background: #ffffff;
57
+ border-right: 1px solid var(--line);
58
+ }
59
+
60
+ h1, h2, h3, p {
61
+ letter-spacing: 0;
62
+ }
63
+
64
+ div[data-testid="stButton"] > button,
65
+ div[data-testid="stDownloadButton"] > button,
66
+ a[data-testid="stLinkButton"] {
67
+ border-radius: 10px;
68
+ min-height: 42px;
69
+ font-weight: 700;
70
+ border: 1px solid #ded7ff;
71
+ box-shadow: none;
72
+ background: #ffffff;
73
+ color: #111827 !important;
74
+ }
75
+
76
+ div[data-testid="stButton"] > button[kind="primary"] {
77
+ background: linear-gradient(135deg, var(--violet), var(--pink));
78
+ border: 0;
79
+ color: #ffffff !important;
80
+ }
81
+
82
+ div[data-testid="stButton"] > button[kind="primary"] *,
83
+ div[data-testid="stButton"] > button[kind="secondary"] *,
84
+ div[data-testid="stDownloadButton"] > button *,
85
+ a[data-testid="stLinkButton"] * {
86
+ color: inherit !important;
87
+ }
88
+
89
+ div[data-testid="stButton"] > button[kind="secondary"] {
90
+ background: #ffffff !important;
91
+ color: #111827 !important;
92
+ border: 1px solid #ded7ff !important;
93
+ }
94
+
95
+ section[data-testid="stSidebar"] div[data-testid="stButton"] > button {
96
+ background: #ffffff !important;
97
+ color: #111827 !important;
98
+ border: 1px solid #e6e9ef !important;
99
+ }
100
+
101
+ div[data-testid="stFileUploader"] button {
102
+ background: #ffffff !important;
103
+ color: #111827 !important;
104
+ border: 1px solid #ded7ff !important;
105
+ }
106
+
107
+ div[data-testid="stFileUploader"] button * {
108
+ color: #111827 !important;
109
+ }
110
+
111
+ div[data-testid="stTextInput"] input {
112
+ border-radius: 12px;
113
+ min-height: 48px;
114
+ border-color: var(--line);
115
+ background: #ffffff;
116
+ color: #111827;
117
+ }
118
+
119
+ div[data-testid="stTextInput"] input::placeholder {
120
+ color: #8b95a7;
121
+ }
122
+
123
+ div[data-testid="stFileUploader"] section {
124
+ border-radius: 16px;
125
+ border: 1.5px dashed #d8ddec;
126
+ background: #fbfcff;
127
+ min-height: 108px;
128
+ }
129
+
130
+ div[data-testid="stExpander"] {
131
+ border: 1px solid var(--line);
132
+ border-radius: 16px;
133
+ overflow: hidden;
134
+ background: #ffffff;
135
+ box-shadow: 0 14px 40px rgba(16, 24, 40, 0.05);
136
+ }
137
+
138
+ .app-shell {
139
+ width: 100%;
140
+ }
141
+
142
+ .topbar {
143
+ display: flex;
144
+ align-items: center;
145
+ justify-content: space-between;
146
+ margin: 0 auto 4.8rem auto;
147
+ }
148
+
149
+ .brand {
150
+ display: flex;
151
+ align-items: center;
152
+ gap: 12px;
153
+ font-weight: 800;
154
+ color: #111827;
155
+ font-size: 1.05rem;
156
+ }
157
+
158
+ .brand-mark {
159
+ width: 42px;
160
+ height: 42px;
161
+ display: grid;
162
+ place-items: center;
163
+ border-radius: 14px;
164
+ color: #ffffff;
165
+ font-weight: 900;
166
+ background: linear-gradient(135deg, #6d5dfc, #e250d5);
167
+ box-shadow: 0 14px 30px rgba(112, 71, 235, 0.24);
168
+ }
169
+
170
+ .status-pill {
171
+ display: inline-flex;
172
+ align-items: center;
173
+ gap: 9px;
174
+ padding: 8px 16px;
175
+ border-radius: 999px;
176
+ border: 1px solid var(--line);
177
+ background: rgba(255, 255, 255, 0.82);
178
+ color: #6b7280;
179
+ font-size: 0.86rem;
180
+ font-weight: 700;
181
+ }
182
+
183
+ .dot {
184
+ width: 9px;
185
+ height: 9px;
186
+ border-radius: 999px;
187
+ background: linear-gradient(135deg, var(--violet), var(--cyan));
188
+ }
189
+
190
+ .hero {
191
+ text-align: center;
192
+ margin: 0 auto 2.8rem auto;
193
+ }
194
+
195
+ .hero h1 {
196
+ max-width: 860px;
197
+ margin: 1.7rem auto 1.2rem auto;
198
+ font-size: clamp(3.2rem, 6vw, 5.7rem);
199
+ line-height: 0.98;
200
+ font-weight: 900;
201
+ color: #111827;
202
+ }
203
+
204
+ .hero-gradient {
205
+ display: block;
206
+ background: linear-gradient(135deg, #6545f5 0%, #b34df0 48%, #e250d5 100%);
207
+ -webkit-background-clip: text;
208
+ background-clip: text;
209
+ color: transparent;
210
+ }
211
+
212
+ .hero p {
213
+ max-width: 760px;
214
+ margin: 0 auto;
215
+ font-size: 1.18rem;
216
+ line-height: 1.55;
217
+ color: var(--muted);
218
+ }
219
+
220
+ .feature-grid {
221
+ display: grid;
222
+ grid-template-columns: repeat(3, minmax(0, 1fr));
223
+ gap: 18px;
224
+ max-width: 840px;
225
+ margin: 2.8rem auto 3.2rem auto;
226
+ }
227
+
228
+ .feature-card {
229
+ background: #ffffff;
230
+ border: 1px solid var(--line);
231
+ border-radius: 16px;
232
+ padding: 24px;
233
+ min-height: 130px;
234
+ box-shadow: 0 20px 44px rgba(16, 24, 40, 0.06);
235
+ }
236
+
237
+ .feature-icon {
238
+ color: var(--violet);
239
+ font-size: 1.55rem;
240
+ line-height: 1;
241
+ margin-bottom: 20px;
242
+ }
243
+
244
+ .feature-title {
245
+ color: #1f2937;
246
+ font-weight: 800;
247
+ margin-bottom: 4px;
248
+ }
249
+
250
+ .feature-copy {
251
+ color: var(--muted);
252
+ font-size: 0.92rem;
253
+ }
254
+
255
+ .panel {
256
+ background: #ffffff;
257
+ border: 1px solid var(--line);
258
+ border-radius: 18px;
259
+ padding: 28px;
260
+ box-shadow: 0 24px 60px rgba(16, 24, 40, 0.07);
261
+ margin-bottom: 24px;
262
+ }
263
+
264
+ .panel-title {
265
+ display: flex;
266
+ align-items: center;
267
+ justify-content: space-between;
268
+ gap: 18px;
269
+ margin-bottom: 18px;
270
+ }
271
+
272
+ .panel-title h2 {
273
+ font-size: 1.25rem;
274
+ margin: 0;
275
+ color: #111827;
276
+ }
277
+
278
+ .panel-title p {
279
+ margin: 4px 0 0 0;
280
+ color: var(--muted);
281
+ }
282
+
283
+ .metric-row {
284
+ display: grid;
285
+ grid-template-columns: repeat(4, minmax(0, 1fr));
286
+ gap: 14px;
287
+ margin: 10px 0 26px 0;
288
+ }
289
+
290
+ .mini-metric {
291
+ border: 1px solid var(--line);
292
+ border-radius: 14px;
293
+ padding: 15px 16px;
294
+ background: #fbfcff;
295
+ }
296
+
297
+ .mini-metric strong {
298
+ display: block;
299
+ font-size: 1.25rem;
300
+ color: #111827;
301
+ }
302
+
303
+ .mini-metric span {
304
+ color: var(--muted);
305
+ font-size: 0.84rem;
306
+ }
307
+
308
+ .section-kicker {
309
+ color: var(--violet);
310
+ font-weight: 800;
311
+ margin-bottom: 8px;
312
+ font-size: 0.82rem;
313
+ text-transform: uppercase;
314
+ }
315
+
316
+ .excerpt-box {
317
+ border: 1px solid #e9ecf3;
318
+ border-radius: 14px;
319
+ padding: 16px;
320
+ background: #fbfcff;
321
+ margin: 12px 0;
322
+ }
323
+
324
+ .excerpt-meta {
325
+ color: var(--muted);
326
+ font-size: 0.84rem;
327
+ font-weight: 700;
328
+ margin-bottom: 8px;
329
+ }
330
+
331
+ .excerpt-text {
332
+ color: #1f2937;
333
+ line-height: 1.55;
334
+ }
335
+
336
+ @media (max-width: 780px) {
337
+ .topbar {
338
+ margin-bottom: 2.8rem;
339
+ }
340
+ .hero h1 {
341
+ font-size: 3rem;
342
+ }
343
+ .feature-grid,
344
+ .metric-row {
345
+ grid-template-columns: 1fr;
346
+ }
347
+ .panel {
348
+ padding: 20px;
349
+ }
350
+ }
351
+ </style>
352
+ """
353
+ ),
354
+ )
355
+
356
+
357
+ def initialize_state() -> None:
358
+ if "pipeline" not in st.session_state:
359
+ st.session_state.pipeline = get_pipeline()
360
+ st.session_state.searcher = get_searcher()
361
+ st.session_state.aggregator = get_aggregator()
362
+ st.session_state.chat = get_document_chat()
363
+ st.session_state.last_chunks = []
364
+ st.session_state.last_documents = []
365
+
366
+
367
+ def ingest_uploaded_files(uploaded_files) -> None:
368
+ with st.spinner("Indexing uploaded files..."):
369
+ progress_bar = st.progress(0)
370
+ total_chunks = 0
371
+
372
+ for index, uploaded_file in enumerate(uploaded_files):
373
+ temp_path = settings.RAW_DATA_DIR / uploaded_file.name
374
+ with open(temp_path, "wb") as file:
375
+ file.write(uploaded_file.getbuffer())
376
+
377
+ try:
378
+ num_chunks = st.session_state.pipeline.ingest_file(
379
+ str(temp_path),
380
+ extra_metadata={
381
+ "source_type": "upload",
382
+ "source_path": str(temp_path),
383
+ "document_id": f"upload:{uploaded_file.name}",
384
+ },
385
+ )
386
+ total_chunks += num_chunks
387
+ st.success(f"{uploaded_file.name}: {num_chunks} chunks")
388
+ except Exception as exc:
389
+ st.error(f"{uploaded_file.name}: {exc}")
390
+
391
+ progress_bar.progress((index + 1) / len(uploaded_files))
392
+
393
+ st.success(f"Indexed {total_chunks} chunks from {len(uploaded_files)} uploaded files.")
394
+ time.sleep(1)
395
+ st.rerun()
396
+
397
+
398
+ def render_document_actions(doc) -> None:
399
+ source_url = doc["metadata"].get("source_url")
400
+ local_file = settings.RAW_DATA_DIR / doc["filename"]
401
+
402
+ if source_url:
403
+ st.link_button("Open source", source_url, use_container_width=True)
404
+ elif local_file.exists():
405
+ with open(local_file, "rb") as file:
406
+ st.download_button(
407
+ label="Download file",
408
+ data=file.read(),
409
+ file_name=doc["filename"],
410
+ mime="application/octet-stream",
411
+ use_container_width=True,
412
+ )
413
+ else:
414
+ st.caption("Original file is not available locally.")
415
+
416
+
417
+ def render_results(aggregated_results) -> None:
418
+ for doc in aggregated_results:
419
+ safe_filename = html.escape(doc["filename"])
420
+ with st.expander(
421
+ f"{doc['filename']} | relevance {doc['relevance_score']:.3f}",
422
+ expanded=True,
423
+ ):
424
+ meta_col, size_col, match_col, action_col = st.columns(4)
425
+ with meta_col:
426
+ st.caption(f"Source: {doc['metadata'].get('source_type', 'local')}")
427
+ with size_col:
428
+ st.caption(f"Type: {doc['metadata'].get('file_type', 'unknown')}")
429
+ with match_col:
430
+ st.caption(f"Matches: {doc['num_matching_chunks']}")
431
+ with action_col:
432
+ render_document_actions(doc)
433
+
434
+ st.markdown(f"**Relevant excerpts from {safe_filename}**")
435
+ for index, chunk in enumerate(doc["chunks"], start=1):
436
+ text = html.escape(chunk["text"])
437
+ similarity = chunk["similarity"]
438
+ chunk_index = chunk["metadata"].get("chunk_index", 0)
439
+ st.html(
440
+ dedent(
441
+ f"""
442
+ <div class="excerpt-box">
443
+ <div class="excerpt-meta">Excerpt {index} - similarity {similarity:.3f} - chunk #{chunk_index}</div>
444
+ <div class="excerpt-text">{text}</div>
445
+ </div>
446
+ """
447
+ ),
448
+ )
449
+
450
+
451
+ def render_sidebar() -> None:
452
+ with st.sidebar:
453
+ st.header("Controls")
454
+
455
+ status = st.session_state.pipeline.get_status()
456
+ st.metric("Indexed chunks", status["total_chunks"])
457
+ st.caption(f"Vector DB: {settings.VECTOR_DB_BACKEND}")
458
+
459
+ st.divider()
460
+
461
+ st.subheader("Local demo data")
462
+ if st.button("Index data/raw", use_container_width=True):
463
+ with st.spinner("Indexing local demo directory..."):
464
+ try:
465
+ results = st.session_state.pipeline.ingest_directory(str(settings.RAW_DATA_DIR))
466
+ st.success(f"Indexed {len(results)} files.")
467
+ time.sleep(1)
468
+ st.rerun()
469
+ except Exception as exc:
470
+ st.error(str(exc))
471
+
472
+ st.divider()
473
+
474
+ st.subheader("Danger zone")
475
+ if st.button("Clear vector index", use_container_width=True):
476
+ if st.session_state.get("confirm_reset", False):
477
+ st.session_state.pipeline.reset_database()
478
+ st.session_state.confirm_reset = False
479
+ st.session_state.pipeline = get_pipeline()
480
+ st.session_state.searcher = get_searcher()
481
+ st.session_state.aggregator = get_aggregator()
482
+ st.session_state.last_chunks = []
483
+ st.session_state.last_documents = []
484
+ st.success("Vector index cleared.")
485
+ time.sleep(1)
486
+ st.rerun()
487
+ else:
488
+ st.session_state.confirm_reset = True
489
+ st.warning("Click again to confirm deletion.")
490
+
491
+
492
+ def render_hero(status_count: int) -> None:
493
+ st.html(
494
+ dedent(
495
+ f"""
496
+ <div class="app-shell">
497
+ <div class="topbar">
498
+ <div class="brand">
499
+ <div class="brand-mark">SL</div>
500
+ <div>SourceLink AI</div>
501
+ </div>
502
+ <div class="status-pill"><span class="dot"></span>{status_count} indexed chunks</div>
503
+ </div>
504
+
505
+ <section class="hero">
506
+ <div class="status-pill"><span class="dot"></span>Your documents, now an AI assistant</div>
507
+ <h1>
508
+ Connect your sources.
509
+ <span class="hero-gradient">Chat with every document.</span>
510
+ </h1>
511
+ <p>
512
+ Paste a Drive folder, connect a public repository, or upload files.
513
+ SourceLink indexes the knowledge and answers only from retrieved document context.
514
+ </p>
515
+ </section>
516
+
517
+ <div class="feature-grid">
518
+ <div class="feature-card">
519
+ <div class="feature-icon">01</div>
520
+ <div class="feature-title">Connect Sources</div>
521
+ <div class="feature-copy">Google Drive folders, public GitHub repos, and uploads.</div>
522
+ </div>
523
+ <div class="feature-card">
524
+ <div class="feature-icon">02</div>
525
+ <div class="feature-title">Index Knowledge</div>
526
+ <div class="feature-copy">Chunks, embeddings, and source links stay searchable.</div>
527
+ </div>
528
+ <div class="feature-card">
529
+ <div class="feature-icon">03</div>
530
+ <div class="feature-title">Ask Questions</div>
531
+ <div class="feature-copy">Chat with the retrieved files and open originals instantly.</div>
532
+ </div>
533
+ </div>
534
+ </div>
535
+ """
536
+ ),
537
+ )
538
+
539
+
540
+ inject_styles()
541
+ initialize_state()
542
+ render_sidebar()
543
+
544
+ status = st.session_state.pipeline.get_status()
545
+ render_hero(status["total_chunks"])
546
+
547
+ st.html(
548
+ dedent(
549
+ """
550
+ <div class="panel">
551
+ <div class="panel-title">
552
+ <div>
553
+ <div class="section-kicker">Index workspace</div>
554
+ <h2>Add documents to your assistant</h2>
555
+ <p>Use a public source link or upload files directly for this demo.</p>
556
+ </div>
557
+ </div>
558
+ </div>
559
+ """
560
+ ),
561
+ )
562
+
563
+ source_col, upload_col = st.columns([1, 1], gap="large")
564
+
565
+ with source_col:
566
+ st.markdown("#### Source link")
567
+ st.caption(f"Indexing target: {settings.VECTOR_DB_BACKEND} / {settings.COLLECTION_NAME}")
568
+ source_url = st.text_input(
569
+ "Public GitHub repository or Google Drive link",
570
+ placeholder="https://drive.google.com/drive/folders/...",
571
+ help="Supports public GitHub repositories and public Google Drive file/folder links.",
572
+ label_visibility="collapsed",
573
+ )
574
+ if st.button("Index source link", type="primary", use_container_width=True):
575
+ if not source_url.strip():
576
+ st.warning("Paste a public source link first.")
577
+ else:
578
+ with st.spinner("Fetching and indexing source files..."):
579
+ try:
580
+ results = st.session_state.pipeline.ingest_source_url(source_url.strip())
581
+ if results:
582
+ st.success(f"Indexed {len(results)} files.")
583
+ for filename, count in results.items():
584
+ st.write(f"{filename}: {count} chunks")
585
+ else:
586
+ st.warning("No supported files found in that source.")
587
+ time.sleep(1)
588
+ st.rerun()
589
+ except Exception as exc:
590
+ st.error(str(exc))
591
+
592
+ with upload_col:
593
+ st.markdown("#### Upload files")
594
+ st.caption(f"Indexing target: {settings.VECTOR_DB_BACKEND} / {settings.COLLECTION_NAME}")
595
+ uploaded_files = st.file_uploader(
596
+ "Upload PDF, TXT, DOCX, or Markdown",
597
+ type=["pdf", "txt", "docx", "md"],
598
+ accept_multiple_files=True,
599
+ label_visibility="collapsed",
600
+ )
601
+ if st.button("Index uploaded files", use_container_width=True):
602
+ if uploaded_files:
603
+ ingest_uploaded_files(uploaded_files)
604
+ else:
605
+ st.warning("Upload at least one file first.")
606
+
607
+ st.html(
608
+ dedent(
609
+ f"""
610
+ <div class="metric-row">
611
+ <div class="mini-metric"><strong>{status["total_chunks"]}</strong><span>Indexed chunks</span></div>
612
+ <div class="mini-metric"><strong>{html.escape(settings.VECTOR_DB_BACKEND)}</strong><span>Vector backend</span></div>
613
+ <div class="mini-metric"><strong>{html.escape(settings.EMBEDDING_PROVIDER)}</strong><span>Embedding provider</span></div>
614
+ <div class="mini-metric"><strong>{html.escape(settings.CHAT_PROVIDER)}</strong><span>Chat provider</span></div>
615
+ </div>
616
+ """
617
+ ),
618
+ )
619
+
620
+ st.html(
621
+ dedent(
622
+ """
623
+ <div class="panel">
624
+ <div class="panel-title">
625
+ <div>
626
+ <div class="section-kicker">Semantic search</div>
627
+ <h2>Find the most relevant files</h2>
628
+ <p>Search across all indexed chunks, then open the source or ask follow-up questions.</p>
629
+ </div>
630
+ </div>
631
+ </div>
632
+ """
633
+ ),
634
+ )
635
+
636
+ query = st.text_input(
637
+ "Search documents",
638
+ placeholder="Ask about a topic, policy, chapter, API, or concept...",
639
+ label_visibility="collapsed",
640
+ )
641
+
642
+ search_col, top_k_col, threshold_col = st.columns([2, 1, 1])
643
+ with search_col:
644
+ search_requested = st.button("Search documents", type="primary", use_container_width=True)
645
+ with top_k_col:
646
+ top_k = st.slider("Results", min_value=1, max_value=20, value=5)
647
+ with threshold_col:
648
+ similarity_threshold = st.slider("Min similarity", 0.0, 1.0, 0.3, 0.01)
649
+
650
+ if search_requested and query.strip():
651
+ with st.spinner("Searching indexed documents..."):
652
+ try:
653
+ results = st.session_state.searcher.search(query, top_k=top_k * 3)
654
+ results = [result for result in results if result["similarity"] >= similarity_threshold]
655
+
656
+ if not results:
657
+ st.session_state.last_chunks = []
658
+ st.session_state.last_documents = []
659
+ st.warning("No results found. Try a broader query or lower the similarity threshold.")
660
+ else:
661
+ aggregated = st.session_state.aggregator.aggregate_by_document(results, max_chunks_per_doc=3)
662
+ st.session_state.last_chunks = [chunk for doc in aggregated for chunk in doc["chunks"]]
663
+ st.session_state.last_documents = aggregated
664
+ st.success(f"Found {len(aggregated)} relevant documents.")
665
+ render_results(aggregated)
666
+ except Exception as exc:
667
+ st.error(f"Search error: {exc}")
668
+ elif st.session_state.last_documents:
669
+ render_results(st.session_state.last_documents)
670
+
671
+ st.html(
672
+ dedent(
673
+ """
674
+ <div class="panel">
675
+ <div class="panel-title">
676
+ <div>
677
+ <div class="section-kicker">Document chat</div>
678
+ <h2>Ask the retrieved context</h2>
679
+ <p>The answer is grounded in the files returned by your most recent search.</p>
680
+ </div>
681
+ </div>
682
+ </div>
683
+ """
684
+ ),
685
+ )
686
+
687
+ chat_question = st.text_input(
688
+ "Ask a follow-up",
689
+ placeholder="What should I know from the returned documents?",
690
+ label_visibility="collapsed",
691
+ )
692
+
693
+ if st.button("Ask retrieved context", use_container_width=True):
694
+ if not chat_question.strip():
695
+ st.warning("Enter a follow-up question first.")
696
+ elif not st.session_state.last_chunks:
697
+ st.warning("Run a search first so the chat has document context.")
698
+ else:
699
+ with st.spinner("Asking the chat model..."):
700
+ try:
701
+ st.session_state.chat.healthcheck()
702
+ answer = st.session_state.chat.answer(chat_question, st.session_state.last_chunks)
703
+ st.success(answer)
704
+ except Exception as exc:
705
+ st.error(f"Chat model error: {exc}")
706
+ if settings.CHAT_PROVIDER.lower() == "groq":
707
+ st.info("Make sure GROQ_API_KEY is set in .env and CHAT_MODEL is available in your Groq account.")
708
+ else:
709
+ st.info(f"Make sure Ollama is running and the chat model is installed: ollama pull {settings.CHAT_MODEL}")
710
+
711
+ st.caption(
712
+ f"Embeddings: {settings.EMBEDDING_PROVIDER} {settings.EMBEDDING_MODEL} - "
713
+ f"Chat: {settings.CHAT_PROVIDER} {settings.CHAT_MODEL} - "
714
+ f"Vector DB: {settings.VECTOR_DB_BACKEND}"
715
+ )
app/vectordb/base.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any, Dict, List, Optional, Protocol
2
+
3
+
4
+ class VectorStore(Protocol):
5
+ """Interface every vector database backend must implement."""
6
+
7
+ def add_documents(
8
+ self,
9
+ ids: List[str],
10
+ documents: List[str],
11
+ embeddings: List[List[float]],
12
+ metadatas: List[Dict[str, Any]],
13
+ ) -> None:
14
+ ...
15
+
16
+ def similarity_search(
17
+ self,
18
+ query_embedding: List[float],
19
+ top_k: int = 5,
20
+ where: Optional[Dict[str, Any]] = None,
21
+ ) -> Dict[str, Any]:
22
+ ...
23
+
24
+ def delete_all(self) -> None:
25
+ ...
26
+
27
+ def get_collection_info(self) -> Dict[str, Any]:
28
+ ...
app/vectordb/chroma_client.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List, Dict, Any, Optional
2
+ import os
3
+
4
+ import chromadb
5
+ from chromadb.config import Settings
6
+
7
+
8
+ class ChromaClient:
9
+ """
10
+ Centralized ChromaDB client abstraction.
11
+
12
+ Responsibilities:
13
+ - Initialize persistent ChromaDB
14
+ - Create / load collections
15
+ - Add document chunks with embeddings
16
+ - Perform similarity search
17
+
18
+ This class is the SINGLE point of interaction with ChromaDB.
19
+ """
20
+
21
+ def __init__(
22
+ self,
23
+ persist_directory: str,
24
+ collection_name: str = "documents",
25
+ embedding_function=None,
26
+ ):
27
+ """
28
+ Args:
29
+ persist_directory: Path where ChromaDB will persist data
30
+ collection_name: Name of the Chroma collection
31
+ embedding_function: Optional embedding function (NOT required if
32
+ embeddings are precomputed)
33
+ """
34
+
35
+ self.persist_directory = persist_directory
36
+ self.collection_name = collection_name
37
+
38
+ os.makedirs(self.persist_directory, exist_ok=True)
39
+
40
+ self.client = chromadb.PersistentClient(
41
+ path=self.persist_directory,
42
+ settings=Settings(anonymized_telemetry=False),
43
+ )
44
+
45
+
46
+
47
+ self.collection = self.client.get_or_create_collection(
48
+ name=self.collection_name,
49
+ embedding_function=embedding_function,
50
+ metadata={
51
+ "description": "Semantic document retrieval collection",
52
+ "hnsw:space": "cosine" # Use cosine similarity (0-1 scale)
53
+ },
54
+ )
55
+
56
+ # ------------------------------------------------------------------
57
+ # Ingestion API
58
+ # ------------------------------------------------------------------
59
+
60
+ def add_documents(
61
+ self,
62
+ ids: List[str],
63
+ documents: List[str],
64
+ embeddings: List[List[float]],
65
+ metadatas: List[Dict[str, Any]],
66
+ ) -> None:
67
+ """
68
+ Add document chunks to the vector store.
69
+
70
+ Args:
71
+ ids: Unique IDs for each chunk
72
+ documents: Chunk text
73
+ embeddings: Precomputed embedding vectors
74
+ metadatas: Metadata per chunk (doc name, page, chunk index, etc.)
75
+ """
76
+
77
+ if not (len(ids) == len(documents) == len(embeddings) == len(metadatas)):
78
+ raise ValueError("ids, documents, embeddings, and metadatas must be same length")
79
+
80
+ self.collection.add(
81
+ ids=ids,
82
+ documents=documents,
83
+ embeddings=embeddings,
84
+ metadatas=metadatas,
85
+ )
86
+
87
+ # ------------------------------------------------------------------
88
+ # Retrieval API
89
+ # ------------------------------------------------------------------
90
+
91
+ def similarity_search(
92
+ self,
93
+ query_embedding: List[float],
94
+ top_k: int = 5,
95
+ where: Optional[Dict[str, Any]] = None,
96
+ ) -> Dict[str, Any]:
97
+ """
98
+ Perform similarity search using a query embedding.
99
+
100
+ Args:
101
+ query_embedding: Embedded query vector
102
+ top_k: Number of nearest neighbors
103
+ where: Optional metadata filter
104
+
105
+ Returns:
106
+ Raw ChromaDB query result
107
+ """
108
+
109
+ result = self.collection.query(
110
+ query_embeddings=[query_embedding],
111
+ n_results=top_k,
112
+ where=where,
113
+ include=["documents", "metadatas", "distances"],
114
+ )
115
+
116
+ return result
117
+
118
+ # ------------------------------------------------------------------
119
+ # Utility / Maintenance
120
+ # ------------------------------------------------------------------
121
+
122
+ def count(self) -> int:
123
+ """Return number of stored embeddings."""
124
+ return self.collection.count()
125
+
126
+ def delete_all(self) -> None:
127
+ """Delete all data in the collection."""
128
+ self.client.delete_collection(self.collection_name)
129
+ self.collection = self.client.get_or_create_collection(
130
+ name=self.collection_name,
131
+ metadata={
132
+ "description": "Semantic document retrieval collection",
133
+ "hnsw:space": "cosine"
134
+ },
135
+ )
136
+
137
+ def get_collection_info(self) -> Dict[str, Any]:
138
+ """Return basic collection metadata."""
139
+ return {
140
+ "name": self.collection.name,
141
+ "count": self.collection.count(),
142
+ "metadata": self.collection.metadata,
143
+ }
app/vectordb/factory.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from app.config.settings import settings
2
+ from app.vectordb.chroma_client import ChromaClient
3
+ from app.vectordb.zilliz_client import ZillizClient
4
+
5
+
6
+ def get_vector_store():
7
+ backend = settings.VECTOR_DB_BACKEND.lower()
8
+
9
+ if backend == "chroma":
10
+ return ChromaClient(
11
+ persist_directory=str(settings.CHROMA_PERSIST_DIR),
12
+ collection_name=settings.COLLECTION_NAME,
13
+ )
14
+
15
+ if backend == "zilliz":
16
+ return ZillizClient(
17
+ uri=settings.ZILLIZ_URI,
18
+ token=settings.ZILLIZ_TOKEN,
19
+ collection_name=settings.COLLECTION_NAME,
20
+ dimension=settings.EMBEDDING_DIMENSION,
21
+ )
22
+
23
+ raise ValueError(
24
+ f"Unsupported VECTOR_DB_BACKEND='{settings.VECTOR_DB_BACKEND}'. "
25
+ "Available backends: chroma, zilliz."
26
+ )
app/vectordb/zilliz_client.py ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any, Dict, List, Optional
2
+
3
+ from app.config.settings import settings
4
+
5
+
6
+ class ZillizClient:
7
+ """
8
+ Zilliz Cloud / Milvus vector store backend.
9
+
10
+ Uses pymilvus.MilvusClient. The collection uses quick setup with a string
11
+ primary key, a vector field, and dynamic metadata fields.
12
+ """
13
+
14
+ def __init__(
15
+ self,
16
+ uri: str = None,
17
+ token: str = None,
18
+ collection_name: str = None,
19
+ dimension: int = None,
20
+ ):
21
+ try:
22
+ from pymilvus import DataType, MilvusClient
23
+ except ImportError as exc:
24
+ raise RuntimeError("Zilliz backend requires pymilvus. Install requirements.txt again.") from exc
25
+
26
+ self.data_type = DataType
27
+ self.uri = uri or settings.ZILLIZ_URI
28
+ self.token = token or settings.ZILLIZ_TOKEN
29
+ self.collection_name = collection_name or settings.COLLECTION_NAME
30
+ self.dimension = dimension or settings.EMBEDDING_DIMENSION
31
+ self.vector_field = "vector"
32
+ self.text_field = "text"
33
+
34
+ if not self.uri or not self.token:
35
+ raise ValueError("ZILLIZ_URI and ZILLIZ_TOKEN are required when VECTOR_DB_BACKEND=zilliz.")
36
+
37
+ self.client = MilvusClient(uri=self.uri, token=self.token)
38
+
39
+ if not self.client.has_collection(self.collection_name):
40
+ self.client.create_collection(
41
+ collection_name=self.collection_name,
42
+ dimension=self.dimension,
43
+ primary_field_name="id",
44
+ id_type=self.data_type.VARCHAR,
45
+ vector_field_name=self.vector_field,
46
+ metric_type="COSINE",
47
+ auto_id=False,
48
+ max_length=512,
49
+ )
50
+ self._load_collection()
51
+
52
+ def add_documents(
53
+ self,
54
+ ids: List[str],
55
+ documents: List[str],
56
+ embeddings: List[List[float]],
57
+ metadatas: List[Dict[str, Any]],
58
+ ) -> None:
59
+ if not (len(ids) == len(documents) == len(embeddings) == len(metadatas)):
60
+ raise ValueError("ids, documents, embeddings, and metadatas must be same length")
61
+
62
+ rows = []
63
+ for doc_id, document, embedding, metadata in zip(ids, documents, embeddings, metadatas):
64
+ row = {
65
+ "id": doc_id,
66
+ self.vector_field: embedding,
67
+ self.text_field: document,
68
+ }
69
+ row.update(self._sanitize_metadata(metadata))
70
+ rows.append(row)
71
+
72
+ result = self.client.upsert(collection_name=self.collection_name, data=rows)
73
+ self._flush_collection()
74
+ self._load_collection()
75
+ return result
76
+
77
+ def similarity_search(
78
+ self,
79
+ query_embedding: List[float],
80
+ top_k: int = 5,
81
+ where: Optional[Dict[str, Any]] = None,
82
+ ) -> Dict[str, Any]:
83
+ filter_expression = self._where_to_filter(where)
84
+ search_kwargs = {
85
+ "collection_name": self.collection_name,
86
+ "data": [query_embedding],
87
+ "limit": top_k,
88
+ "output_fields": ["*"],
89
+ }
90
+
91
+ if filter_expression:
92
+ search_kwargs["filter"] = filter_expression
93
+
94
+ raw_results = self.client.search(**search_kwargs)
95
+
96
+ documents = []
97
+ metadatas = []
98
+ distances = []
99
+
100
+ for hit in raw_results[0] if raw_results else []:
101
+ entity = hit.get("entity", {})
102
+ documents.append(entity.get(self.text_field, ""))
103
+ metadatas.append(self._extract_metadata(entity))
104
+ distances.append(1.0 - float(hit.get("distance", 0.0)))
105
+
106
+ return {
107
+ "documents": [documents],
108
+ "metadatas": [metadatas],
109
+ "distances": [distances],
110
+ }
111
+
112
+ def delete_all(self) -> None:
113
+ if self.client.has_collection(self.collection_name):
114
+ self.client.drop_collection(self.collection_name)
115
+
116
+ self.client.create_collection(
117
+ collection_name=self.collection_name,
118
+ dimension=self.dimension,
119
+ primary_field_name="id",
120
+ id_type=self.data_type.VARCHAR,
121
+ vector_field_name=self.vector_field,
122
+ metric_type="COSINE",
123
+ auto_id=False,
124
+ max_length=512,
125
+ )
126
+
127
+ def get_collection_info(self) -> Dict[str, Any]:
128
+ count = 0
129
+ if self.client.has_collection(self.collection_name):
130
+ stats = self.client.get_collection_stats(self.collection_name)
131
+ count = int(stats.get("row_count", 0))
132
+
133
+ return {
134
+ "name": self.collection_name,
135
+ "count": count,
136
+ "metadata": {
137
+ "backend": "zilliz",
138
+ "dimension": self.dimension,
139
+ "metric": "COSINE",
140
+ },
141
+ }
142
+
143
+ def _sanitize_metadata(self, metadata: Dict[str, Any]) -> Dict[str, Any]:
144
+ sanitized = {}
145
+ for key, value in metadata.items():
146
+ if value is None:
147
+ continue
148
+ if isinstance(value, (str, int, float, bool)):
149
+ sanitized[key] = value
150
+ else:
151
+ sanitized[key] = str(value)
152
+ return sanitized
153
+
154
+ def _extract_metadata(self, entity: Dict[str, Any]) -> Dict[str, Any]:
155
+ ignored_fields = {"id", self.vector_field, self.text_field}
156
+ return {key: value for key, value in entity.items() if key not in ignored_fields}
157
+
158
+ def _where_to_filter(self, where: Optional[Dict[str, Any]]) -> str:
159
+ if not where:
160
+ return ""
161
+
162
+ filters = []
163
+ for key, value in where.items():
164
+ if isinstance(value, str):
165
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"')
166
+ filters.append(f'{key} == "{escaped}"')
167
+ elif isinstance(value, bool):
168
+ filters.append(f"{key} == {str(value).lower()}")
169
+ elif isinstance(value, (int, float)):
170
+ filters.append(f"{key} == {value}")
171
+
172
+ return " and ".join(filters)
173
+
174
+ def _flush_collection(self) -> None:
175
+ flush = getattr(self.client, "flush", None)
176
+ if callable(flush):
177
+ flush(collection_name=self.collection_name)
178
+
179
+ def _load_collection(self) -> None:
180
+ load_collection = getattr(self.client, "load_collection", None)
181
+ if callable(load_collection):
182
+ load_collection(collection_name=self.collection_name)
pyproject.toml ADDED
File without changes
requirements.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ PyPDF2
2
+ python-docx
3
+ markdown
4
+ beautifulsoup4
5
+ streamlit
6
+ requests
7
+ chromadb
8
+ gdown
9
+ pymilvus
10
+ python-dotenv
11
+ sentence-transformers
run.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # run_app.py
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ # Add project root to path
6
+ project_root = Path(__file__).parent
7
+ sys.path.insert(0, str(project_root))
8
+
9
+ # Now run streamlit
10
+ if __name__ == "__main__":
11
+ import streamlit.web.cli as stcli
12
+ import sys
13
+
14
+ sys.argv = ["streamlit", "run", "app/ui/main.py"]
15
+ sys.exit(stcli.main())
scripts/check_vector_store.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse
2
+ import sys
3
+ from pathlib import Path
4
+
5
+
6
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
7
+ if str(PROJECT_ROOT) not in sys.path:
8
+ sys.path.insert(0, str(PROJECT_ROOT))
9
+
10
+ from app.config.settings import settings
11
+ from app.ingestion.embedder import get_embedder
12
+ from app.vectordb.factory import get_vector_store
13
+
14
+
15
+ def main() -> None:
16
+ parser = argparse.ArgumentParser(description="Check the configured vector store.")
17
+ parser.add_argument("--query", help="Optional test query to run against the vector store.")
18
+ parser.add_argument("--top-k", type=int, default=3, help="Number of search results for --query.")
19
+ parser.add_argument("--list-collections", action="store_true", help="List collections when using Zilliz/Milvus.")
20
+ parser.add_argument("--probe-insert", action="store_true", help="Insert one tiny probe row into the active vector store.")
21
+ args = parser.parse_args()
22
+
23
+ print(f"Vector backend: {settings.VECTOR_DB_BACKEND}")
24
+ print(f"Collection: {settings.COLLECTION_NAME}")
25
+ print(f"Embedding provider: {settings.EMBEDDING_PROVIDER}")
26
+ print(f"Embedding model: {settings.EMBEDDING_MODEL}")
27
+ print(f"Embedding dimension: {settings.EMBEDDING_DIMENSION}")
28
+
29
+ store = get_vector_store()
30
+
31
+ if args.list_collections and hasattr(store, "client"):
32
+ list_collections = getattr(store.client, "list_collections", None)
33
+ if callable(list_collections):
34
+ print(f"Available collections: {list_collections()}")
35
+
36
+ if args.probe_insert:
37
+ probe_id = "debug_probe_row"
38
+ probe_text = "debug probe document for vector store verification"
39
+ probe_embedding = [0.0] * settings.EMBEDDING_DIMENSION
40
+ probe_embedding[0] = 1.0
41
+ store.add_documents(
42
+ ids=[probe_id],
43
+ documents=[probe_text],
44
+ embeddings=[probe_embedding],
45
+ metadatas=[
46
+ {
47
+ "filename": "debug_probe.txt",
48
+ "source_type": "debug",
49
+ "source_path": "debug_probe",
50
+ "document_id": "debug_probe",
51
+ "chunk_index": 0,
52
+ }
53
+ ],
54
+ )
55
+ print("Inserted probe row.")
56
+
57
+ info = store.get_collection_info()
58
+
59
+ print(f"Stored rows/chunks: {info['count']}")
60
+ print(f"Store metadata: {info['metadata']}")
61
+
62
+ if args.query:
63
+ embedder = get_embedder()
64
+ query_embedding = embedder.embed(args.query)
65
+ results = store.similarity_search(query_embedding, top_k=args.top_k)
66
+
67
+ documents = results.get("documents", [[]])[0]
68
+ metadatas = results.get("metadatas", [[]])[0]
69
+ distances = results.get("distances", [[]])[0]
70
+
71
+ print(f"\nSearch results for: {args.query}")
72
+ if not documents:
73
+ print("No matches returned.")
74
+ return
75
+
76
+ for index, (document, metadata, distance) in enumerate(zip(documents, metadatas, distances), start=1):
77
+ filename = metadata.get("filename", "unknown")
78
+ source_type = metadata.get("source_type", "unknown")
79
+ source_path = metadata.get("source_path", "")
80
+ preview = document.replace("\n", " ")[:160]
81
+ print(f"{index}. {filename} | {source_type} | distance={distance:.4f}")
82
+ if source_path:
83
+ print(f" source_path: {source_path}")
84
+ print(f" preview: {preview}")
85
+
86
+
87
+ if __name__ == "__main__":
88
+ main()
test_embeder.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # from app.retrieval.search import get_searcher
2
+ # from app.retrieval.aggregator import get_aggregator
3
+
4
+ # searcher = get_searcher()
5
+
6
+ # # Test different queries
7
+ # queries = [
8
+ # "machine learning",
9
+ # "python programming",
10
+ # "deep learning neural networks",
11
+ # "variance analysis"
12
+ # ]
13
+
14
+ # for query in queries:
15
+ # print(f"\n{'='*60}")
16
+ # print(f"Query: '{query}'")
17
+ # print('='*60)
18
+
19
+ # results = searcher.search(query, top_k=3)
20
+
21
+ # if results:
22
+ # for result in results:
23
+ # print(f"\nRank {result['rank']} - Similarity: {result['similarity']:.3f}")
24
+ # print(f" File: {result['metadata']['filename']}")
25
+ # print(f" Text: {result['text'][:100]}...")
26
+ # else:
27
+ # print("No results found")
28
+
29
+ # print("\n" + "="*60)
30
+ # print("AGGREGATED VIEW")
31
+ # print("="*60)
32
+
33
+ # results = searcher.search("machine learning", top_k=10)
34
+ # aggregator = get_aggregator()
35
+ # aggregated = aggregator.aggregate_by_document(results)
36
+
37
+ # for doc in aggregated:
38
+ # print(f"\nπŸ“„ {doc['filename']}")
39
+ # print(f" Relevance: {doc['relevance_score']:.3f}")
40
+ # print(f" Matches: {doc['num_matching_chunks']}")
41
+
42
+ import shutil
43
+ from pathlib import Path
44
+ from app.config.settings import settings
45
+ from app.ingestion.ingest import get_pipeline
46
+
47
+ # 1. Delete the entire ChromaDB directory
48
+ # force_reset_v2.py
49
+ import shutil
50
+ from pathlib import Path
51
+ from app.config.settings import settings
52
+
53
+ # 1. Delete the entire ChromaDB directory
54
+ chroma_dir = Path(settings.CHROMA_PERSIST_DIR)
55
+ if chroma_dir.exists():
56
+ print(f"πŸ—‘οΈ Deleting old ChromaDB at {chroma_dir}")
57
+ shutil.rmtree(chroma_dir)
58
+ print("βœ“ Deleted")
59
+
60
+ # Wait for imports to use new code
61
+ print("\nπŸ“Š Importing fresh modules...")
62
+ from app.ingestion.ingest import get_pipeline
63
+
64
+ # 2. Create fresh pipeline
65
+ print("Creating fresh ChromaDB with cosine similarity...")
66
+ pipeline = get_pipeline()
67
+ status = pipeline.get_status()
68
+ print(f"βœ“ Collection: {status['collection_name']}")
69
+ print(f"βœ“ Chunks: {status['total_chunks']}")
70
+ print(f"βœ“ Metadata: {status['metadata']}")
71
+
72
+ # 3. Ingest documents
73
+ print("\nπŸ“‚ Ingesting documents...")
74
+ results = pipeline.ingest_directory("data/raw/")
75
+
76
+ print(f"\nβœ… Done! Ingested {len(results)} files:")
77
+ for filename, count in results.items():
78
+ print(f" β€’ {filename}: {count} chunks")
79
+
80
+ final_status = pipeline.get_status()
81
+ print(f"\nπŸ“Š Final count: {final_status['total_chunks']} chunks")
82
+
83
+ # 4. Test search immediately
84
+ print("\nπŸ” Testing search...")
85
+ from app.retrieval.search import get_searcher
86
+
87
+ searcher = get_searcher()
88
+ results = searcher.search("machine learning", top_k=3)
89
+
90
+ if results:
91
+ print(f"βœ“ Search works! Found {len(results)} results")
92
+ for r in results:
93
+ print(f" - {r['metadata']['filename']}: similarity {r['similarity']:.3f}")
94
+ else:
95
+ print("βœ— No results found")
uv.lock ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version = 1
2
+ revision = 3
3
+ requires-python = ">=3.12"