Spaces:
Running
Running
Commit Β·
33332ff
0
Parent(s):
Initial SourceLink AI demo
Browse files- .env.example +12 -0
- .gitignore +16 -0
- .streamlit/config.toml +5 -0
- MIGRATION.md +118 -0
- README.md +93 -0
- app/config/settings.py +129 -0
- app/ingestion/chunker.py +292 -0
- app/ingestion/embedder.py +139 -0
- app/ingestion/ingest.py +307 -0
- app/ingestion/loader.py +218 -0
- app/retrieval/aggregator.py +181 -0
- app/retrieval/chat.py +158 -0
- app/retrieval/search.py +205 -0
- app/sources/__init__.py +1 -0
- app/sources/connectors.py +226 -0
- app/ui/main.py +715 -0
- app/vectordb/base.py +28 -0
- app/vectordb/chroma_client.py +143 -0
- app/vectordb/factory.py +26 -0
- app/vectordb/zilliz_client.py +182 -0
- pyproject.toml +0 -0
- requirements.txt +11 -0
- run.py +15 -0
- scripts/check_vector_store.py +88 -0
- test_embeder.py +95 -0
- uv.lock +3 -0
.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"
|