Spaces:
Runtime error
Runtime error
Upload folder using huggingface_hub
Browse files- .dockerignore +9 -0
- .env.example +22 -0
- .gitignore +7 -0
- Dockerfile +26 -0
- app/__init__.py +0 -0
- app/config.py +36 -0
- app/db.py +99 -0
- app/embeddings.py +26 -0
- app/ingestion.py +144 -0
- app/main.py +144 -0
- app/models.py +39 -0
- app/rag.py +91 -0
- app/vector_store.py +101 -0
- render.yaml +37 -0
- requirements.txt +26 -0
- run.sh +30 -0
- static/index.html +267 -0
.dockerignore
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.venv/
|
| 2 |
+
venv/
|
| 3 |
+
__pycache__/
|
| 4 |
+
*.pyc
|
| 5 |
+
.git/
|
| 6 |
+
.env
|
| 7 |
+
scripts/
|
| 8 |
+
uploads_tmp/
|
| 9 |
+
.DS_Store
|
.env.example
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ===== Qdrant (vector DB) =====
|
| 2 |
+
QDRANT_URL=https://your-cluster-id.region-0.aws.cloud.qdrant.io
|
| 3 |
+
QDRANT_API_KEY=your-qdrant-api-key
|
| 4 |
+
QDRANT_COLLECTION=documents
|
| 5 |
+
|
| 6 |
+
# ===== MongoDB (metadata store) =====
|
| 7 |
+
MONGODB_URI=mongodb+srv://user:password@cluster0.xxxxx.mongodb.net/?appName=Cluster0
|
| 8 |
+
MONGODB_DB=hemantsatishjadhav06_db_user
|
| 9 |
+
|
| 10 |
+
# ===== LLM via OpenRouter (OpenAI-compatible) =====
|
| 11 |
+
OPENROUTER_API_KEY=sk-or-v1-xxxxxxxx
|
| 12 |
+
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
| 13 |
+
OPENROUTER_MODEL=anthropic/claude-sonnet-4.6
|
| 14 |
+
|
| 15 |
+
# ===== Embeddings (local ONNX via fastembed — no torch) =====
|
| 16 |
+
EMBEDDING_MODEL=BAAI/bge-small-en-v1.5
|
| 17 |
+
EMBEDDING_DIM=384
|
| 18 |
+
|
| 19 |
+
# ===== Retrieval / chunking =====
|
| 20 |
+
CHUNK_SIZE=900
|
| 21 |
+
CHUNK_OVERLAP=150
|
| 22 |
+
TOP_K=5
|
.gitignore
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.env
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.pyc
|
| 4 |
+
.venv/
|
| 5 |
+
venv/
|
| 6 |
+
uploads_tmp/
|
| 7 |
+
.DS_Store
|
Dockerfile
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
# System deps: Tesseract (OCR) + Poppler (PDF rasterization for scanned PDFs)
|
| 4 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 5 |
+
tesseract-ocr \
|
| 6 |
+
poppler-utils \
|
| 7 |
+
libgl1 \
|
| 8 |
+
libglib2.0-0 \
|
| 9 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 10 |
+
|
| 11 |
+
WORKDIR /app
|
| 12 |
+
|
| 13 |
+
COPY requirements.txt .
|
| 14 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 15 |
+
|
| 16 |
+
# Pre-download the embedding model at build time so the first request is fast.
|
| 17 |
+
RUN python -c "from fastembed import TextEmbedding; TextEmbedding(model_name='BAAI/bge-small-en-v1.5')"
|
| 18 |
+
|
| 19 |
+
COPY app ./app
|
| 20 |
+
COPY static ./static
|
| 21 |
+
|
| 22 |
+
# Most PaaS platforms inject $PORT; default to 8000 locally.
|
| 23 |
+
ENV PORT=8000
|
| 24 |
+
EXPOSE 8000
|
| 25 |
+
|
| 26 |
+
CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${PORT}"]
|
app/__init__.py
ADDED
|
File without changes
|
app/config.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Application configuration, loaded from environment / .env file."""
|
| 2 |
+
from functools import lru_cache
|
| 3 |
+
|
| 4 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class Settings(BaseSettings):
|
| 8 |
+
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
| 9 |
+
|
| 10 |
+
# Qdrant
|
| 11 |
+
qdrant_url: str
|
| 12 |
+
qdrant_api_key: str
|
| 13 |
+
qdrant_collection: str = "documents"
|
| 14 |
+
|
| 15 |
+
# MongoDB
|
| 16 |
+
mongodb_uri: str
|
| 17 |
+
mongodb_db: str = "doc_intelligence_rag"
|
| 18 |
+
|
| 19 |
+
# LLM via OpenRouter (OpenAI-compatible API)
|
| 20 |
+
openrouter_api_key: str = ""
|
| 21 |
+
openrouter_base_url: str = "https://openrouter.ai/api/v1"
|
| 22 |
+
openrouter_model: str = "anthropic/claude-sonnet-4.6"
|
| 23 |
+
|
| 24 |
+
# Embeddings (local, ONNX via fastembed — no torch needed)
|
| 25 |
+
embedding_model: str = "BAAI/bge-small-en-v1.5"
|
| 26 |
+
embedding_dim: int = 384
|
| 27 |
+
|
| 28 |
+
# Retrieval / chunking
|
| 29 |
+
chunk_size: int = 900
|
| 30 |
+
chunk_overlap: int = 150
|
| 31 |
+
top_k: int = 5
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@lru_cache
|
| 35 |
+
def get_settings() -> Settings:
|
| 36 |
+
return Settings()
|
app/db.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""MongoDB metadata store for uploaded documents.
|
| 2 |
+
|
| 3 |
+
If MongoDB is unreachable (e.g. Atlas IP allow-list not configured yet), the
|
| 4 |
+
store gracefully falls back to an in-process dictionary so the rest of the app
|
| 5 |
+
keeps working. Fallback data is not persisted across restarts.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from datetime import datetime, timezone
|
| 10 |
+
from functools import lru_cache
|
| 11 |
+
from typing import List, Optional
|
| 12 |
+
|
| 13 |
+
from pymongo import MongoClient, DESCENDING
|
| 14 |
+
|
| 15 |
+
from .config import get_settings
|
| 16 |
+
|
| 17 |
+
# In-memory fallback store (used only when Mongo can't be reached).
|
| 18 |
+
_MEM: dict[str, dict] = {}
|
| 19 |
+
_USING_FALLBACK = False
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@lru_cache
|
| 23 |
+
def _client() -> MongoClient:
|
| 24 |
+
settings = get_settings()
|
| 25 |
+
return MongoClient(
|
| 26 |
+
settings.mongodb_uri,
|
| 27 |
+
appname="doc-intelligence-rag",
|
| 28 |
+
serverSelectionTimeoutMS=4000,
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _collection():
|
| 33 |
+
settings = get_settings()
|
| 34 |
+
return _client()[settings.mongodb_db]["documents"]
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _fallback(reason: str = "") -> None:
|
| 38 |
+
global _USING_FALLBACK
|
| 39 |
+
if not _USING_FALLBACK:
|
| 40 |
+
_USING_FALLBACK = True
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def using_fallback() -> bool:
|
| 44 |
+
return _USING_FALLBACK
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def save_document(
|
| 48 |
+
document_id: str,
|
| 49 |
+
filename: str,
|
| 50 |
+
content_type: str,
|
| 51 |
+
num_chunks: int,
|
| 52 |
+
num_chars: int,
|
| 53 |
+
ocr_used: bool,
|
| 54 |
+
) -> dict:
|
| 55 |
+
doc = {
|
| 56 |
+
"_id": document_id,
|
| 57 |
+
"filename": filename,
|
| 58 |
+
"content_type": content_type or "application/octet-stream",
|
| 59 |
+
"num_chunks": num_chunks,
|
| 60 |
+
"num_chars": num_chars,
|
| 61 |
+
"ocr_used": ocr_used,
|
| 62 |
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
| 63 |
+
}
|
| 64 |
+
try:
|
| 65 |
+
_collection().insert_one(dict(doc))
|
| 66 |
+
except Exception as exc: # noqa: BLE001
|
| 67 |
+
_fallback(str(exc))
|
| 68 |
+
_MEM[document_id] = doc
|
| 69 |
+
return doc
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def list_documents() -> List[dict]:
|
| 73 |
+
try:
|
| 74 |
+
return list(_collection().find().sort("created_at", DESCENDING))
|
| 75 |
+
except Exception as exc: # noqa: BLE001
|
| 76 |
+
_fallback(str(exc))
|
| 77 |
+
return sorted(_MEM.values(), key=lambda d: d["created_at"], reverse=True)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def get_document(document_id: str) -> Optional[dict]:
|
| 81 |
+
try:
|
| 82 |
+
return _collection().find_one({"_id": document_id})
|
| 83 |
+
except Exception as exc: # noqa: BLE001
|
| 84 |
+
_fallback(str(exc))
|
| 85 |
+
return _MEM.get(document_id)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def delete_document(document_id: str) -> int:
|
| 89 |
+
try:
|
| 90 |
+
result = _collection().delete_one({"_id": document_id})
|
| 91 |
+
return result.deleted_count
|
| 92 |
+
except Exception as exc: # noqa: BLE001
|
| 93 |
+
_fallback(str(exc))
|
| 94 |
+
return 1 if _MEM.pop(document_id, None) else 0
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def ping() -> bool:
|
| 98 |
+
_client().admin.command("ping")
|
| 99 |
+
return True
|
app/embeddings.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Local embedding model wrapper (fastembed / ONNX — no torch dependency)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from functools import lru_cache
|
| 5 |
+
from typing import List
|
| 6 |
+
|
| 7 |
+
from .config import get_settings
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@lru_cache
|
| 11 |
+
def _model():
|
| 12 |
+
from fastembed import TextEmbedding
|
| 13 |
+
|
| 14 |
+
settings = get_settings()
|
| 15 |
+
return TextEmbedding(model_name=settings.embedding_model)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def embed_texts(texts: List[str]) -> List[List[float]]:
|
| 19 |
+
if not texts:
|
| 20 |
+
return []
|
| 21 |
+
# fastembed returns numpy arrays (already L2-normalized for bge models).
|
| 22 |
+
return [v.tolist() for v in _model().embed(texts)]
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def embed_query(text: str) -> List[float]:
|
| 26 |
+
return embed_texts([text])[0]
|
app/ingestion.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Document text extraction (with OCR fallback) and chunking."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import io
|
| 5 |
+
import re
|
| 6 |
+
from dataclasses import dataclass
|
| 7 |
+
from typing import List, Tuple
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@dataclass
|
| 11 |
+
class ExtractResult:
|
| 12 |
+
text: str
|
| 13 |
+
ocr_used: bool
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _ocr_image_bytes(data: bytes) -> str:
|
| 17 |
+
"""OCR a single image using pytesseract."""
|
| 18 |
+
from PIL import Image
|
| 19 |
+
import pytesseract
|
| 20 |
+
|
| 21 |
+
image = Image.open(io.BytesIO(data))
|
| 22 |
+
return pytesseract.image_to_string(image)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _extract_pdf(data: bytes) -> ExtractResult:
|
| 26 |
+
"""Extract text from a PDF. Falls back to OCR for pages with no text layer."""
|
| 27 |
+
from pypdf import PdfReader
|
| 28 |
+
|
| 29 |
+
reader = PdfReader(io.BytesIO(data))
|
| 30 |
+
pages_text: List[str] = []
|
| 31 |
+
ocr_used = False
|
| 32 |
+
|
| 33 |
+
for page in reader.pages:
|
| 34 |
+
text = (page.extract_text() or "").strip()
|
| 35 |
+
pages_text.append(text)
|
| 36 |
+
|
| 37 |
+
# If the PDF has little/no extractable text, it is likely scanned -> OCR.
|
| 38 |
+
total_chars = sum(len(t) for t in pages_text)
|
| 39 |
+
if total_chars < 40:
|
| 40 |
+
try:
|
| 41 |
+
from pdf2image import convert_from_bytes
|
| 42 |
+
import pytesseract
|
| 43 |
+
|
| 44 |
+
images = convert_from_bytes(data)
|
| 45 |
+
ocr_pages = [pytesseract.image_to_string(img) for img in images]
|
| 46 |
+
ocr_used = True
|
| 47 |
+
return ExtractResult("\n\n".join(ocr_pages), ocr_used)
|
| 48 |
+
except Exception:
|
| 49 |
+
# pdf2image needs poppler; if unavailable, return whatever text we had.
|
| 50 |
+
pass
|
| 51 |
+
|
| 52 |
+
return ExtractResult("\n\n".join(pages_text), ocr_used)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _extract_docx(data: bytes) -> ExtractResult:
|
| 56 |
+
from docx import Document
|
| 57 |
+
|
| 58 |
+
doc = Document(io.BytesIO(data))
|
| 59 |
+
parts = [p.text for p in doc.paragraphs]
|
| 60 |
+
for table in doc.tables:
|
| 61 |
+
for row in table.rows:
|
| 62 |
+
parts.append(" | ".join(cell.text for cell in row.cells))
|
| 63 |
+
return ExtractResult("\n".join(parts), ocr_used=False)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def extract_text(filename: str, content_type: str, data: bytes) -> ExtractResult:
|
| 67 |
+
"""Dispatch extraction based on file type."""
|
| 68 |
+
name = filename.lower()
|
| 69 |
+
|
| 70 |
+
if name.endswith(".pdf") or content_type == "application/pdf":
|
| 71 |
+
return _extract_pdf(data)
|
| 72 |
+
|
| 73 |
+
if name.endswith(".docx") or content_type in (
|
| 74 |
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
| 75 |
+
):
|
| 76 |
+
return _extract_docx(data)
|
| 77 |
+
|
| 78 |
+
if name.endswith((".png", ".jpg", ".jpeg", ".tiff", ".bmp", ".gif")) or (
|
| 79 |
+
content_type or ""
|
| 80 |
+
).startswith("image/"):
|
| 81 |
+
return ExtractResult(_ocr_image_bytes(data), ocr_used=True)
|
| 82 |
+
|
| 83 |
+
if name.endswith((".txt", ".md", ".csv")) or (content_type or "").startswith("text/"):
|
| 84 |
+
return ExtractResult(data.decode("utf-8", errors="ignore"), ocr_used=False)
|
| 85 |
+
|
| 86 |
+
# Last resort: try to decode as text.
|
| 87 |
+
return ExtractResult(data.decode("utf-8", errors="ignore"), ocr_used=False)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _normalize(text: str) -> str:
|
| 91 |
+
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
| 92 |
+
text = re.sub(r"[ \t]+", " ", text)
|
| 93 |
+
text = re.sub(r"\n{3,}", "\n\n", text)
|
| 94 |
+
return text.strip()
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def chunk_text(text: str, chunk_size: int, overlap: int) -> List[str]:
|
| 98 |
+
"""Split text into overlapping word-based chunks.
|
| 99 |
+
|
| 100 |
+
Word-based windows keep chunks coherent and avoid cutting mid-word.
|
| 101 |
+
chunk_size / overlap are measured in characters (approximate).
|
| 102 |
+
"""
|
| 103 |
+
text = _normalize(text)
|
| 104 |
+
if not text:
|
| 105 |
+
return []
|
| 106 |
+
|
| 107 |
+
words = text.split(" ")
|
| 108 |
+
chunks: List[str] = []
|
| 109 |
+
current: List[str] = []
|
| 110 |
+
current_len = 0
|
| 111 |
+
|
| 112 |
+
for word in words:
|
| 113 |
+
current.append(word)
|
| 114 |
+
current_len += len(word) + 1
|
| 115 |
+
if current_len >= chunk_size:
|
| 116 |
+
chunks.append(" ".join(current).strip())
|
| 117 |
+
# Build overlap tail by characters.
|
| 118 |
+
tail: List[str] = []
|
| 119 |
+
tail_len = 0
|
| 120 |
+
for w in reversed(current):
|
| 121 |
+
tail_len += len(w) + 1
|
| 122 |
+
tail.insert(0, w)
|
| 123 |
+
if tail_len >= overlap:
|
| 124 |
+
break
|
| 125 |
+
current = tail
|
| 126 |
+
current_len = sum(len(w) + 1 for w in current)
|
| 127 |
+
|
| 128 |
+
if current and " ".join(current).strip():
|
| 129 |
+
chunks.append(" ".join(current).strip())
|
| 130 |
+
|
| 131 |
+
return [c for c in chunks if c]
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def process_document(
|
| 135 |
+
filename: str,
|
| 136 |
+
content_type: str,
|
| 137 |
+
data: bytes,
|
| 138 |
+
chunk_size: int,
|
| 139 |
+
overlap: int,
|
| 140 |
+
) -> Tuple[List[str], bool, int]:
|
| 141 |
+
"""Return (chunks, ocr_used, num_chars)."""
|
| 142 |
+
result = extract_text(filename, content_type, data)
|
| 143 |
+
chunks = chunk_text(result.text, chunk_size, overlap)
|
| 144 |
+
return chunks, result.ocr_used, len(result.text)
|
app/main.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI application: upload documents, list/manage them, and chat over them."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import uuid
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
from fastapi import FastAPI, File, HTTPException, UploadFile
|
| 8 |
+
from fastapi.responses import FileResponse
|
| 9 |
+
from fastapi.staticfiles import StaticFiles
|
| 10 |
+
|
| 11 |
+
from . import db, vector_store
|
| 12 |
+
from .config import get_settings
|
| 13 |
+
from .embeddings import embed_texts
|
| 14 |
+
from .ingestion import process_document
|
| 15 |
+
from .models import ChatRequest, ChatResponse, DocumentSummary, IngestResponse
|
| 16 |
+
|
| 17 |
+
app = FastAPI(
|
| 18 |
+
title="Document Intelligence & Chat Assistant",
|
| 19 |
+
description="RAG over your uploaded documents: OCR + MongoDB + Qdrant + Claude.",
|
| 20 |
+
version="1.0.0",
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
STATIC_DIR = Path(__file__).resolve().parent.parent / "static"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@app.on_event("startup")
|
| 27 |
+
def _startup() -> None:
|
| 28 |
+
# Best-effort: create the Qdrant collection up front. Never block startup —
|
| 29 |
+
# if Qdrant is briefly unreachable the collection is created lazily on the
|
| 30 |
+
# first upload instead, so the server still binds and the UI loads.
|
| 31 |
+
try:
|
| 32 |
+
vector_store.ensure_collection()
|
| 33 |
+
except Exception as exc: # noqa: BLE001
|
| 34 |
+
print(f"[startup] Qdrant not ready yet, will retry on first upload: {exc}")
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _to_summary(doc: dict) -> DocumentSummary:
|
| 38 |
+
return DocumentSummary(
|
| 39 |
+
id=doc["_id"],
|
| 40 |
+
filename=doc["filename"],
|
| 41 |
+
content_type=doc["content_type"],
|
| 42 |
+
num_chunks=doc["num_chunks"],
|
| 43 |
+
num_chars=doc["num_chars"],
|
| 44 |
+
ocr_used=doc["ocr_used"],
|
| 45 |
+
created_at=doc["created_at"],
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@app.get("/health")
|
| 50 |
+
def health() -> dict:
|
| 51 |
+
status = {"status": "ok"}
|
| 52 |
+
try:
|
| 53 |
+
db.ping()
|
| 54 |
+
status["mongodb"] = "ok"
|
| 55 |
+
except Exception as exc: # noqa: BLE001
|
| 56 |
+
status["mongodb"] = f"error: {exc}"
|
| 57 |
+
try:
|
| 58 |
+
vector_store.get_client().get_collections()
|
| 59 |
+
status["qdrant"] = "ok"
|
| 60 |
+
except Exception as exc: # noqa: BLE001
|
| 61 |
+
status["qdrant"] = f"error: {exc}"
|
| 62 |
+
status["llm_key_configured"] = bool(get_settings().openrouter_api_key)
|
| 63 |
+
return status
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
@app.post("/documents/upload", response_model=IngestResponse)
|
| 67 |
+
async def upload_document(file: UploadFile = File(...)) -> IngestResponse:
|
| 68 |
+
settings = get_settings()
|
| 69 |
+
data = await file.read()
|
| 70 |
+
if not data:
|
| 71 |
+
raise HTTPException(status_code=400, detail="Uploaded file is empty.")
|
| 72 |
+
|
| 73 |
+
chunks, ocr_used, num_chars = process_document(
|
| 74 |
+
file.filename, file.content_type or "", data,
|
| 75 |
+
settings.chunk_size, settings.chunk_overlap,
|
| 76 |
+
)
|
| 77 |
+
if not chunks:
|
| 78 |
+
raise HTTPException(
|
| 79 |
+
status_code=422,
|
| 80 |
+
detail="No text could be extracted from this document.",
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
document_id = str(uuid.uuid4())
|
| 84 |
+
vectors = embed_texts(chunks)
|
| 85 |
+
vector_store.ensure_collection() # lazy create if startup couldn't reach Qdrant
|
| 86 |
+
vector_store.upsert_chunks(document_id, file.filename, chunks, vectors)
|
| 87 |
+
doc = db.save_document(
|
| 88 |
+
document_id=document_id,
|
| 89 |
+
filename=file.filename,
|
| 90 |
+
content_type=file.content_type or "",
|
| 91 |
+
num_chunks=len(chunks),
|
| 92 |
+
num_chars=num_chars,
|
| 93 |
+
ocr_used=ocr_used,
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
return IngestResponse(
|
| 97 |
+
document=_to_summary(doc),
|
| 98 |
+
message=f"Indexed {len(chunks)} chunks" + (" (OCR used)" if ocr_used else ""),
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
@app.get("/documents", response_model=list[DocumentSummary])
|
| 103 |
+
def list_documents() -> list[DocumentSummary]:
|
| 104 |
+
return [_to_summary(d) for d in db.list_documents()]
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
@app.get("/documents/{document_id}", response_model=DocumentSummary)
|
| 108 |
+
def get_document(document_id: str) -> DocumentSummary:
|
| 109 |
+
doc = db.get_document(document_id)
|
| 110 |
+
if not doc:
|
| 111 |
+
raise HTTPException(status_code=404, detail="Document not found.")
|
| 112 |
+
return _to_summary(doc)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
@app.delete("/documents/{document_id}")
|
| 116 |
+
def delete_document(document_id: str) -> dict:
|
| 117 |
+
if not db.get_document(document_id):
|
| 118 |
+
raise HTTPException(status_code=404, detail="Document not found.")
|
| 119 |
+
vector_store.delete_document(document_id)
|
| 120 |
+
db.delete_document(document_id)
|
| 121 |
+
return {"deleted": document_id}
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
@app.post("/chat", response_model=ChatResponse)
|
| 125 |
+
def chat(request: ChatRequest) -> ChatResponse:
|
| 126 |
+
from . import rag
|
| 127 |
+
|
| 128 |
+
try:
|
| 129 |
+
return rag.answer_question(
|
| 130 |
+
question=request.question,
|
| 131 |
+
document_ids=request.document_ids,
|
| 132 |
+
top_k=request.top_k,
|
| 133 |
+
)
|
| 134 |
+
except RuntimeError as exc:
|
| 135 |
+
raise HTTPException(status_code=503, detail=str(exc))
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
# --- Minimal web UI (served at /) ---
|
| 139 |
+
if STATIC_DIR.exists():
|
| 140 |
+
app.mount("/ui", StaticFiles(directory=str(STATIC_DIR), html=True), name="ui")
|
| 141 |
+
|
| 142 |
+
@app.get("/")
|
| 143 |
+
def root() -> FileResponse:
|
| 144 |
+
return FileResponse(str(STATIC_DIR / "index.html"))
|
app/models.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pydantic request/response schemas."""
|
| 2 |
+
from typing import List, Optional
|
| 3 |
+
|
| 4 |
+
from pydantic import BaseModel, Field
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class DocumentSummary(BaseModel):
|
| 8 |
+
id: str
|
| 9 |
+
filename: str
|
| 10 |
+
content_type: str
|
| 11 |
+
num_chunks: int
|
| 12 |
+
num_chars: int
|
| 13 |
+
ocr_used: bool
|
| 14 |
+
created_at: str
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class IngestResponse(BaseModel):
|
| 18 |
+
document: DocumentSummary
|
| 19 |
+
message: str
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class ChatRequest(BaseModel):
|
| 23 |
+
question: str = Field(..., min_length=1)
|
| 24 |
+
# Optionally restrict retrieval to specific uploaded documents.
|
| 25 |
+
document_ids: Optional[List[str]] = None
|
| 26 |
+
top_k: Optional[int] = None
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class Source(BaseModel):
|
| 30 |
+
document_id: str
|
| 31 |
+
filename: str
|
| 32 |
+
chunk_index: int
|
| 33 |
+
score: float
|
| 34 |
+
snippet: str
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class ChatResponse(BaseModel):
|
| 38 |
+
answer: str
|
| 39 |
+
sources: List[Source]
|
app/rag.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""RAG pipeline: retrieve relevant chunks, then generate a grounded answer."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from functools import lru_cache
|
| 5 |
+
from typing import List, Optional
|
| 6 |
+
|
| 7 |
+
from .config import get_settings
|
| 8 |
+
from .embeddings import embed_query
|
| 9 |
+
from .models import ChatResponse, Source
|
| 10 |
+
from . import vector_store
|
| 11 |
+
|
| 12 |
+
SYSTEM_PROMPT = (
|
| 13 |
+
"You are a document assistant. Answer the user's question using ONLY the provided "
|
| 14 |
+
"context excerpts from their uploaded documents. Cite the supporting sources inline "
|
| 15 |
+
"as [1], [2], etc., matching the numbered context blocks. If the answer is not "
|
| 16 |
+
"contained in the context, say you could not find it in the documents. Be concise "
|
| 17 |
+
"and accurate."
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@lru_cache
|
| 22 |
+
def _llm():
|
| 23 |
+
from openai import OpenAI
|
| 24 |
+
|
| 25 |
+
settings = get_settings()
|
| 26 |
+
if not settings.openrouter_api_key:
|
| 27 |
+
raise RuntimeError("OPENROUTER_API_KEY is not set in the environment / .env file.")
|
| 28 |
+
return OpenAI(
|
| 29 |
+
base_url=settings.openrouter_base_url,
|
| 30 |
+
api_key=settings.openrouter_api_key,
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _build_context(hits) -> str:
|
| 35 |
+
blocks = []
|
| 36 |
+
for i, hit in enumerate(hits, start=1):
|
| 37 |
+
payload = hit.payload or {}
|
| 38 |
+
blocks.append(
|
| 39 |
+
f"[{i}] (source: {payload.get('filename', '?')}, "
|
| 40 |
+
f"chunk {payload.get('chunk_index', '?')})\n{payload.get('text', '')}"
|
| 41 |
+
)
|
| 42 |
+
return "\n\n".join(blocks)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def answer_question(
|
| 46 |
+
question: str,
|
| 47 |
+
document_ids: Optional[List[str]] = None,
|
| 48 |
+
top_k: Optional[int] = None,
|
| 49 |
+
) -> ChatResponse:
|
| 50 |
+
settings = get_settings()
|
| 51 |
+
k = top_k or settings.top_k
|
| 52 |
+
|
| 53 |
+
query_vector = embed_query(question)
|
| 54 |
+
hits = vector_store.search(query_vector, top_k=k, document_ids=document_ids)
|
| 55 |
+
|
| 56 |
+
if not hits:
|
| 57 |
+
return ChatResponse(
|
| 58 |
+
answer="I couldn't find anything relevant in your uploaded documents.",
|
| 59 |
+
sources=[],
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
context = _build_context(hits)
|
| 63 |
+
user_message = (
|
| 64 |
+
f"Context excerpts:\n\n{context}\n\n"
|
| 65 |
+
f"Question: {question}\n\n"
|
| 66 |
+
"Answer using only the context above and cite sources as [n]."
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
client = _llm()
|
| 70 |
+
response = client.chat.completions.create(
|
| 71 |
+
model=settings.openrouter_model,
|
| 72 |
+
max_tokens=1024,
|
| 73 |
+
messages=[
|
| 74 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 75 |
+
{"role": "user", "content": user_message},
|
| 76 |
+
],
|
| 77 |
+
)
|
| 78 |
+
answer_text = (response.choices[0].message.content or "").strip()
|
| 79 |
+
|
| 80 |
+
sources = [
|
| 81 |
+
Source(
|
| 82 |
+
document_id=(hit.payload or {}).get("document_id", ""),
|
| 83 |
+
filename=(hit.payload or {}).get("filename", "?"),
|
| 84 |
+
chunk_index=(hit.payload or {}).get("chunk_index", -1),
|
| 85 |
+
score=float(hit.score),
|
| 86 |
+
snippet=((hit.payload or {}).get("text", "")[:280]),
|
| 87 |
+
)
|
| 88 |
+
for hit in hits
|
| 89 |
+
]
|
| 90 |
+
|
| 91 |
+
return ChatResponse(answer=answer_text, sources=sources)
|
app/vector_store.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Qdrant vector store: collection management, upsert, and search."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import uuid
|
| 5 |
+
from functools import lru_cache
|
| 6 |
+
from typing import List, Optional
|
| 7 |
+
|
| 8 |
+
from qdrant_client import QdrantClient
|
| 9 |
+
from qdrant_client.http import models as qm
|
| 10 |
+
|
| 11 |
+
from .config import get_settings
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@lru_cache
|
| 15 |
+
def get_client() -> QdrantClient:
|
| 16 |
+
settings = get_settings()
|
| 17 |
+
return QdrantClient(url=settings.qdrant_url, api_key=settings.qdrant_api_key, timeout=60)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def ensure_collection() -> None:
|
| 21 |
+
"""Create the collection if it does not exist."""
|
| 22 |
+
settings = get_settings()
|
| 23 |
+
client = get_client()
|
| 24 |
+
existing = {c.name for c in client.get_collections().collections}
|
| 25 |
+
if settings.qdrant_collection not in existing:
|
| 26 |
+
client.create_collection(
|
| 27 |
+
collection_name=settings.qdrant_collection,
|
| 28 |
+
vectors_config=qm.VectorParams(
|
| 29 |
+
size=settings.embedding_dim, distance=qm.Distance.COSINE
|
| 30 |
+
),
|
| 31 |
+
)
|
| 32 |
+
# Index on document_id so we can filter / delete by document.
|
| 33 |
+
client.create_payload_index(
|
| 34 |
+
collection_name=settings.qdrant_collection,
|
| 35 |
+
field_name="document_id",
|
| 36 |
+
field_schema=qm.PayloadSchemaType.KEYWORD,
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def upsert_chunks(
|
| 41 |
+
document_id: str,
|
| 42 |
+
filename: str,
|
| 43 |
+
chunks: List[str],
|
| 44 |
+
vectors: List[List[float]],
|
| 45 |
+
) -> None:
|
| 46 |
+
settings = get_settings()
|
| 47 |
+
client = get_client()
|
| 48 |
+
points = [
|
| 49 |
+
qm.PointStruct(
|
| 50 |
+
id=str(uuid.uuid4()),
|
| 51 |
+
vector=vector,
|
| 52 |
+
payload={
|
| 53 |
+
"document_id": document_id,
|
| 54 |
+
"filename": filename,
|
| 55 |
+
"chunk_index": idx,
|
| 56 |
+
"text": chunk,
|
| 57 |
+
},
|
| 58 |
+
)
|
| 59 |
+
for idx, (chunk, vector) in enumerate(zip(chunks, vectors))
|
| 60 |
+
]
|
| 61 |
+
client.upsert(collection_name=settings.qdrant_collection, points=points)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def search(
|
| 65 |
+
query_vector: List[float],
|
| 66 |
+
top_k: int,
|
| 67 |
+
document_ids: Optional[List[str]] = None,
|
| 68 |
+
):
|
| 69 |
+
settings = get_settings()
|
| 70 |
+
client = get_client()
|
| 71 |
+
|
| 72 |
+
query_filter = None
|
| 73 |
+
if document_ids:
|
| 74 |
+
query_filter = qm.Filter(
|
| 75 |
+
must=[qm.FieldCondition(key="document_id", match=qm.MatchAny(any=document_ids))]
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
return client.search(
|
| 79 |
+
collection_name=settings.qdrant_collection,
|
| 80 |
+
query_vector=query_vector,
|
| 81 |
+
limit=top_k,
|
| 82 |
+
query_filter=query_filter,
|
| 83 |
+
with_payload=True,
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def delete_document(document_id: str) -> None:
|
| 88 |
+
settings = get_settings()
|
| 89 |
+
client = get_client()
|
| 90 |
+
client.delete(
|
| 91 |
+
collection_name=settings.qdrant_collection,
|
| 92 |
+
points_selector=qm.FilterSelector(
|
| 93 |
+
filter=qm.Filter(
|
| 94 |
+
must=[
|
| 95 |
+
qm.FieldCondition(
|
| 96 |
+
key="document_id", match=qm.MatchValue(value=document_id)
|
| 97 |
+
)
|
| 98 |
+
]
|
| 99 |
+
)
|
| 100 |
+
),
|
| 101 |
+
)
|
render.yaml
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Render Blueprint — deploys the Dockerfile as a web service.
|
| 2 |
+
# In Render: New + -> Blueprint -> connect this repo. Secrets (sync:false)
|
| 3 |
+
# are entered in the dashboard, never committed.
|
| 4 |
+
services:
|
| 5 |
+
- type: web
|
| 6 |
+
name: doc-intelligence-rag
|
| 7 |
+
runtime: docker
|
| 8 |
+
plan: free # 512MB RAM. Bump to "starter" if you hit out-of-memory.
|
| 9 |
+
healthCheckPath: /health
|
| 10 |
+
autoDeploy: true
|
| 11 |
+
envVars:
|
| 12 |
+
- key: QDRANT_URL
|
| 13 |
+
sync: false
|
| 14 |
+
- key: QDRANT_API_KEY
|
| 15 |
+
sync: false
|
| 16 |
+
- key: QDRANT_COLLECTION
|
| 17 |
+
value: documents
|
| 18 |
+
- key: MONGODB_URI
|
| 19 |
+
sync: false
|
| 20 |
+
- key: MONGODB_DB
|
| 21 |
+
value: doc_intelligence_rag
|
| 22 |
+
- key: OPENROUTER_API_KEY
|
| 23 |
+
sync: false
|
| 24 |
+
- key: OPENROUTER_BASE_URL
|
| 25 |
+
value: https://openrouter.ai/api/v1
|
| 26 |
+
- key: OPENROUTER_MODEL
|
| 27 |
+
value: anthropic/claude-sonnet-4.6
|
| 28 |
+
- key: EMBEDDING_MODEL
|
| 29 |
+
value: BAAI/bge-small-en-v1.5
|
| 30 |
+
- key: EMBEDDING_DIM
|
| 31 |
+
value: "384"
|
| 32 |
+
- key: CHUNK_SIZE
|
| 33 |
+
value: "900"
|
| 34 |
+
- key: CHUNK_OVERLAP
|
| 35 |
+
value: "150"
|
| 36 |
+
- key: TOP_K
|
| 37 |
+
value: "5"
|
requirements.txt
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Pins are lower-bounds so pip selects the newest wheels available for your
|
| 2 |
+
# Python version (important on very new Pythons like 3.14).
|
| 3 |
+
|
| 4 |
+
# --- Web framework ---
|
| 5 |
+
fastapi>=0.115
|
| 6 |
+
uvicorn[standard]>=0.34
|
| 7 |
+
python-multipart>=0.0.18
|
| 8 |
+
pydantic>=2.10
|
| 9 |
+
pydantic-settings>=2.7
|
| 10 |
+
|
| 11 |
+
# --- Databases ---
|
| 12 |
+
pymongo>=4.10
|
| 13 |
+
qdrant-client>=1.12
|
| 14 |
+
|
| 15 |
+
# --- Embeddings (local, ONNX — no torch) ---
|
| 16 |
+
fastembed>=0.5
|
| 17 |
+
|
| 18 |
+
# --- LLM (OpenRouter, OpenAI-compatible) ---
|
| 19 |
+
openai>=1.59
|
| 20 |
+
|
| 21 |
+
# --- Document parsing + OCR ---
|
| 22 |
+
pypdf>=5.1
|
| 23 |
+
python-docx>=1.1
|
| 24 |
+
pytesseract>=0.3.13
|
| 25 |
+
pdf2image>=1.17
|
| 26 |
+
Pillow>=11.0
|
run.sh
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# Local run helper for macOS / Linux. Prints clear errors and the URL.
|
| 3 |
+
set -e
|
| 4 |
+
cd "$(dirname "$0")"
|
| 5 |
+
|
| 6 |
+
# Pick a Python (prefer python3)
|
| 7 |
+
PY="$(command -v python3 || command -v python || true)"
|
| 8 |
+
if [ -z "$PY" ]; then
|
| 9 |
+
echo "Python 3 not found. Install from https://www.python.org/downloads/ and retry."
|
| 10 |
+
exit 1
|
| 11 |
+
fi
|
| 12 |
+
echo "Using $($PY --version)"
|
| 13 |
+
|
| 14 |
+
# Create / reuse venv
|
| 15 |
+
[ -d .venv ] || "$PY" -m venv .venv
|
| 16 |
+
# shellcheck disable=SC1091
|
| 17 |
+
source .venv/bin/activate
|
| 18 |
+
|
| 19 |
+
echo "Installing dependencies (first run downloads ~200MB, please wait)..."
|
| 20 |
+
python -m pip install --upgrade pip >/dev/null
|
| 21 |
+
python -m pip install -r requirements.txt
|
| 22 |
+
|
| 23 |
+
# OCR system deps are optional (only needed for scanned files / images)
|
| 24 |
+
command -v tesseract >/dev/null || echo "Note: tesseract not installed (OCR disabled). Optional: brew install tesseract"
|
| 25 |
+
|
| 26 |
+
echo ""
|
| 27 |
+
echo "Starting server. Open -> http://localhost:8000"
|
| 28 |
+
echo "(the first question downloads the embedding model, ~10s)"
|
| 29 |
+
echo ""
|
| 30 |
+
exec python -m uvicorn app.main:app --host 0.0.0.0 --port 8000
|
static/index.html
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>DocChat · Document Intelligence</title>
|
| 7 |
+
<style>
|
| 8 |
+
:root {
|
| 9 |
+
--bg:#0c0e14; --panel:#151823; --panel2:#1c2030; --border:#2a3040;
|
| 10 |
+
--accent:#7c6cf0; --accent2:#5b8def; --text:#eef0f6; --muted:#98a1b5;
|
| 11 |
+
--green:#3ecf8e; --red:#f06c7c; --amber:#f0b84c; --radius:12px;
|
| 12 |
+
}
|
| 13 |
+
* { box-sizing:border-box; }
|
| 14 |
+
html,body { height:100%; }
|
| 15 |
+
body { margin:0; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
|
| 16 |
+
background:radial-gradient(1200px 600px at 80% -10%, #1a1f3a 0%, var(--bg) 55%); color:var(--text); }
|
| 17 |
+
header { display:flex; align-items:center; gap:12px; padding:14px 22px; border-bottom:1px solid var(--border);
|
| 18 |
+
backdrop-filter:blur(6px); position:sticky; top:0; z-index:5; }
|
| 19 |
+
.logo { width:30px;height:30px;border-radius:8px;
|
| 20 |
+
background:linear-gradient(135deg,var(--accent),var(--accent2)); display:grid;place-items:center;font-size:16px; }
|
| 21 |
+
header h1 { font-size:16px; font-weight:650; margin:0; letter-spacing:.2px; }
|
| 22 |
+
header .sub { color:var(--muted); font-size:12px; margin-left:2px; }
|
| 23 |
+
.health { margin-left:auto; display:flex; gap:14px; font-size:12px; color:var(--muted); align-items:center; }
|
| 24 |
+
.dot { display:inline-block; width:8px;height:8px;border-radius:50%; margin-right:5px; background:var(--muted); vertical-align:middle; }
|
| 25 |
+
.dot.ok{background:var(--green);} .dot.bad{background:var(--red);} .dot.warn{background:var(--amber);}
|
| 26 |
+
|
| 27 |
+
.wrap { display:grid; grid-template-columns:330px 1fr; height:calc(100vh - 59px); }
|
| 28 |
+
.side { border-right:1px solid var(--border); padding:18px; overflow-y:auto; display:flex; flex-direction:column; gap:18px; }
|
| 29 |
+
.card { background:var(--panel); border:1px solid var(--border); border-radius:var(--radius); padding:16px; }
|
| 30 |
+
h3 { margin:0 0 12px; font-size:12px; text-transform:uppercase; letter-spacing:.08em; color:var(--muted); }
|
| 31 |
+
|
| 32 |
+
.drop { border:1.5px dashed var(--border); border-radius:10px; padding:22px 14px; text-align:center; cursor:pointer;
|
| 33 |
+
transition:.15s; color:var(--muted); font-size:13px; }
|
| 34 |
+
.drop:hover,.drop.drag { border-color:var(--accent); color:var(--text); background:var(--panel2); }
|
| 35 |
+
.drop b { color:var(--accent); }
|
| 36 |
+
.btn { background:linear-gradient(135deg,var(--accent),var(--accent2)); color:#fff; border:0; border-radius:9px;
|
| 37 |
+
padding:10px 16px; cursor:pointer; font-weight:600; font-size:13px; width:100%; margin-top:10px; }
|
| 38 |
+
.btn:disabled { opacity:.5; cursor:default; }
|
| 39 |
+
.btn.ghost { background:transparent; border:1px solid var(--border); color:var(--muted); }
|
| 40 |
+
.status { font-size:12px; color:var(--muted); margin-top:8px; min-height:16px; }
|
| 41 |
+
|
| 42 |
+
.doc { background:var(--panel2); border:1px solid var(--border); border-radius:9px; padding:10px 11px; margin-bottom:8px;
|
| 43 |
+
display:flex; gap:9px; align-items:flex-start; }
|
| 44 |
+
.doc input { margin-top:3px; accent-color:var(--accent); }
|
| 45 |
+
.doc .meta { flex:1; min-width:0; }
|
| 46 |
+
.doc .name { font-size:13px; font-weight:550; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
| 47 |
+
.doc small { color:var(--muted); font-size:11px; }
|
| 48 |
+
.doc .x { color:var(--muted); cursor:pointer; font-size:14px; padding:0 3px; }
|
| 49 |
+
.doc .x:hover { color:var(--red); }
|
| 50 |
+
.tag { display:inline-block; font-size:10px; padding:1px 6px; border-radius:6px; background:#2b3350; color:#aab4ff; margin-left:6px; }
|
| 51 |
+
.scope { font-size:11px; color:var(--muted); margin-top:6px; }
|
| 52 |
+
|
| 53 |
+
.main { display:flex; flex-direction:column; min-width:0; }
|
| 54 |
+
.chat { flex:1; overflow-y:auto; padding:26px 28px; display:flex; flex-direction:column; gap:20px; }
|
| 55 |
+
.empty { margin:auto; text-align:center; color:var(--muted); max-width:420px; }
|
| 56 |
+
.empty h2 { color:var(--text); font-weight:600; margin-bottom:8px; }
|
| 57 |
+
.suggest { display:flex; flex-wrap:wrap; gap:8px; justify-content:center; margin-top:16px; }
|
| 58 |
+
.chip { background:var(--panel); border:1px solid var(--border); border-radius:20px; padding:7px 13px; font-size:12px;
|
| 59 |
+
cursor:pointer; color:var(--muted); } .chip:hover{ border-color:var(--accent); color:var(--text); }
|
| 60 |
+
|
| 61 |
+
.msg { display:flex; gap:12px; max-width:860px; }
|
| 62 |
+
.msg.me { flex-direction:row-reverse; align-self:flex-end; }
|
| 63 |
+
.av { width:30px;height:30px;border-radius:8px; flex-shrink:0; display:grid;place-items:center; font-size:14px; }
|
| 64 |
+
.av.ai { background:linear-gradient(135deg,var(--accent),var(--accent2)); }
|
| 65 |
+
.av.me { background:var(--panel2); border:1px solid var(--border); }
|
| 66 |
+
.bubble { background:var(--panel); border:1px solid var(--border); border-radius:12px; padding:13px 15px; line-height:1.6;
|
| 67 |
+
font-size:14.5px; white-space:pre-wrap; word-wrap:break-word; }
|
| 68 |
+
.me .bubble { background:#222a4a; }
|
| 69 |
+
.bubble code { background:#0d1018; padding:1px 5px; border-radius:5px; font-size:13px; }
|
| 70 |
+
.bubble pre { background:#0d1018; padding:10px; border-radius:8px; overflow-x:auto; }
|
| 71 |
+
.sources { margin-top:10px; font-size:12px; }
|
| 72 |
+
.sources summary { cursor:pointer; color:var(--muted); }
|
| 73 |
+
.src { background:var(--panel2); border:1px solid var(--border); border-radius:8px; padding:8px 10px; margin-top:6px; }
|
| 74 |
+
.src .h { color:#aab4ff; font-size:11px; margin-bottom:3px; }
|
| 75 |
+
.src .t { color:var(--muted); font-size:12px; }
|
| 76 |
+
.typing span { display:inline-block; width:6px;height:6px;border-radius:50%; background:var(--muted); margin:0 2px;
|
| 77 |
+
animation:b 1.2s infinite; } .typing span:nth-child(2){animation-delay:.2s;} .typing span:nth-child(3){animation-delay:.4s;}
|
| 78 |
+
@keyframes b { 0%,60%,100%{opacity:.3;transform:translateY(0)} 30%{opacity:1;transform:translateY(-3px)} }
|
| 79 |
+
|
| 80 |
+
.composer { border-top:1px solid var(--border); padding:14px 22px; }
|
| 81 |
+
.composer .row { display:flex; gap:10px; align-items:flex-end; background:var(--panel); border:1px solid var(--border);
|
| 82 |
+
border-radius:12px; padding:8px 10px; }
|
| 83 |
+
textarea#q { flex:1; background:transparent; border:0; color:var(--text); font-size:14.5px; resize:none; outline:none;
|
| 84 |
+
max-height:140px; font-family:inherit; line-height:1.5; padding:6px; }
|
| 85 |
+
.send { background:linear-gradient(135deg,var(--accent),var(--accent2)); border:0; border-radius:9px; width:40px;height:40px;
|
| 86 |
+
cursor:pointer; color:#fff; font-size:17px; flex-shrink:0; } .send:disabled{opacity:.4;cursor:default;}
|
| 87 |
+
.hint { font-size:11px; color:var(--muted); margin-top:6px; text-align:center; }
|
| 88 |
+
@media (max-width:780px){ .wrap{grid-template-columns:1fr;} .side{display:none;} }
|
| 89 |
+
</style>
|
| 90 |
+
</head>
|
| 91 |
+
<body>
|
| 92 |
+
<header>
|
| 93 |
+
<div class="logo">📄</div>
|
| 94 |
+
<div>
|
| 95 |
+
<h1>DocChat <span class="sub">Document Intelligence & Chat</span></h1>
|
| 96 |
+
</div>
|
| 97 |
+
<div class="health" id="health">
|
| 98 |
+
<span><span class="dot" id="d-qdrant"></span>Qdrant</span>
|
| 99 |
+
<span><span class="dot" id="d-mongo"></span>Mongo</span>
|
| 100 |
+
<span><span class="dot" id="d-llm"></span>LLM</span>
|
| 101 |
+
</div>
|
| 102 |
+
</header>
|
| 103 |
+
|
| 104 |
+
<div class="wrap">
|
| 105 |
+
<aside class="side">
|
| 106 |
+
<div class="card">
|
| 107 |
+
<h3>Upload a document</h3>
|
| 108 |
+
<div class="drop" id="drop">
|
| 109 |
+
<div>Drop a file here or <b>browse</b></div>
|
| 110 |
+
<div style="margin-top:6px;font-size:11px;">PDF · DOCX · images · TXT — OCR built in</div>
|
| 111 |
+
</div>
|
| 112 |
+
<input type="file" id="file" hidden />
|
| 113 |
+
<button class="btn" id="uploadBtn" onclick="upload()">Upload & Index</button>
|
| 114 |
+
<div class="status" id="uploadStatus"></div>
|
| 115 |
+
</div>
|
| 116 |
+
|
| 117 |
+
<div class="card" style="flex:1;">
|
| 118 |
+
<h3 style="display:flex;justify-content:space-between;align-items:center;">
|
| 119 |
+
Your documents <span id="count" style="color:var(--accent);">0</span>
|
| 120 |
+
</h3>
|
| 121 |
+
<div id="docs"></div>
|
| 122 |
+
<div class="scope" id="scope">Chatting across all documents.</div>
|
| 123 |
+
</div>
|
| 124 |
+
</aside>
|
| 125 |
+
|
| 126 |
+
<main class="main">
|
| 127 |
+
<div class="chat" id="chat">
|
| 128 |
+
<div class="empty" id="empty">
|
| 129 |
+
<h2>Ask your documents anything</h2>
|
| 130 |
+
<p>Upload a file on the left, then ask a question. Answers are grounded in
|
| 131 |
+
your documents and cite their sources.</p>
|
| 132 |
+
<div class="suggest">
|
| 133 |
+
<div class="chip" onclick="useChip(this)">Summarize the key points</div>
|
| 134 |
+
<div class="chip" onclick="useChip(this)">What are the main risks?</div>
|
| 135 |
+
<div class="chip" onclick="useChip(this)">List the action items</div>
|
| 136 |
+
</div>
|
| 137 |
+
</div>
|
| 138 |
+
</div>
|
| 139 |
+
<div class="composer">
|
| 140 |
+
<div class="row">
|
| 141 |
+
<textarea id="q" rows="1" placeholder="Ask a question about your documents…"
|
| 142 |
+
oninput="autogrow(this)" onkeydown="onKey(event)"></textarea>
|
| 143 |
+
<button class="send" id="send" onclick="ask()">➤</button>
|
| 144 |
+
</div>
|
| 145 |
+
<div class="hint">Enter to send · Shift+Enter for newline</div>
|
| 146 |
+
</div>
|
| 147 |
+
</main>
|
| 148 |
+
</div>
|
| 149 |
+
|
| 150 |
+
<script>
|
| 151 |
+
let DOCS = [];
|
| 152 |
+
const $ = (id) => document.getElementById(id);
|
| 153 |
+
|
| 154 |
+
/* ---------- health ---------- */
|
| 155 |
+
async function health() {
|
| 156 |
+
try {
|
| 157 |
+
const r = await fetch('/health'); const h = await r.json();
|
| 158 |
+
setDot('d-qdrant', h.qdrant === 'ok');
|
| 159 |
+
setDot('d-mongo', h.mongodb === 'ok');
|
| 160 |
+
setDot('d-llm', h.llm_key_configured);
|
| 161 |
+
} catch { ['d-qdrant','d-mongo','d-llm'].forEach(d=>setDot(d,false)); }
|
| 162 |
+
}
|
| 163 |
+
function setDot(id, ok){ const e=$(id); e.className = 'dot ' + (ok?'ok':'bad'); }
|
| 164 |
+
|
| 165 |
+
/* ---------- documents ---------- */
|
| 166 |
+
async function loadDocs() {
|
| 167 |
+
try {
|
| 168 |
+
const r = await fetch('/documents'); DOCS = await r.json();
|
| 169 |
+
} catch { DOCS = []; }
|
| 170 |
+
$('count').textContent = DOCS.length;
|
| 171 |
+
const el = $('docs');
|
| 172 |
+
if (!DOCS.length) { el.innerHTML = '<small>No documents yet — upload one above.</small>'; updateScope(); return; }
|
| 173 |
+
el.innerHTML = DOCS.map(d => `
|
| 174 |
+
<div class="doc">
|
| 175 |
+
<input type="checkbox" class="docsel" value="${d.id}" onchange="updateScope()" checked />
|
| 176 |
+
<div class="meta">
|
| 177 |
+
<div class="name" title="${esc(d.filename)}">${esc(d.filename)}${d.ocr_used?'<span class="tag">OCR</span>':''}</div>
|
| 178 |
+
<small>${d.num_chunks} chunks · ${new Date(d.created_at).toLocaleDateString()}</small>
|
| 179 |
+
</div>
|
| 180 |
+
<span class="x" title="Delete" onclick="del('${d.id}')">✕</span>
|
| 181 |
+
</div>`).join('');
|
| 182 |
+
updateScope();
|
| 183 |
+
}
|
| 184 |
+
function selectedIds(){ return [...document.querySelectorAll('.docsel:checked')].map(c=>c.value); }
|
| 185 |
+
function updateScope(){
|
| 186 |
+
const sel = selectedIds(), total = DOCS.length;
|
| 187 |
+
const s = $('scope');
|
| 188 |
+
if (!total) s.textContent = '';
|
| 189 |
+
else if (sel.length === total) s.textContent = 'Chatting across all documents.';
|
| 190 |
+
else if (sel.length === 0) s.textContent = '⚠ No documents selected — select at least one.';
|
| 191 |
+
else s.textContent = `Chatting across ${sel.length} of ${total} documents.`;
|
| 192 |
+
}
|
| 193 |
+
async function del(id){
|
| 194 |
+
if (!confirm('Delete this document and its vectors?')) return;
|
| 195 |
+
await fetch('/documents/'+id, {method:'DELETE'}); await loadDocs();
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
/* ---------- upload ---------- */
|
| 199 |
+
const drop = $('drop'), fileInput = $('file');
|
| 200 |
+
drop.onclick = () => fileInput.click();
|
| 201 |
+
['dragover','dragenter'].forEach(e=>drop.addEventListener(e,ev=>{ev.preventDefault();drop.classList.add('drag');}));
|
| 202 |
+
['dragleave','drop'].forEach(e=>drop.addEventListener(e,ev=>{ev.preventDefault();drop.classList.remove('drag');}));
|
| 203 |
+
drop.addEventListener('drop', ev=>{ if(ev.dataTransfer.files[0]){ fileInput.files=ev.dataTransfer.files; reflectFile(); }});
|
| 204 |
+
fileInput.onchange = reflectFile;
|
| 205 |
+
function reflectFile(){ const f=fileInput.files[0]; if(f) drop.querySelector('div').innerHTML = '📎 '+esc(f.name); }
|
| 206 |
+
|
| 207 |
+
async function upload(){
|
| 208 |
+
const f = fileInput.files[0]; const st = $('uploadStatus');
|
| 209 |
+
if (!f){ st.textContent='Choose a file first.'; return; }
|
| 210 |
+
const btn = $('uploadBtn'); btn.disabled = true; st.textContent = '⏳ Extracting, embedding & indexing…';
|
| 211 |
+
const fd = new FormData(); fd.append('file', f);
|
| 212 |
+
try {
|
| 213 |
+
const r = await fetch('/documents/upload',{method:'POST',body:fd});
|
| 214 |
+
const d = await r.json();
|
| 215 |
+
st.textContent = r.ok ? ('✓ '+d.message) : ('⚠ '+(d.detail||'Upload failed'));
|
| 216 |
+
if (r.ok){ fileInput.value=''; drop.querySelector('div').innerHTML='Drop a file here or <b>browse</b>'; await loadDocs(); }
|
| 217 |
+
} catch(e){ st.textContent = '⚠ '+e; }
|
| 218 |
+
btn.disabled = false;
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
/* ---------- chat ---------- */
|
| 222 |
+
function autogrow(t){ t.style.height='auto'; t.style.height=Math.min(t.scrollHeight,140)+'px'; }
|
| 223 |
+
function onKey(e){ if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); ask(); } }
|
| 224 |
+
function useChip(el){ $('q').value = el.textContent; $('q').focus(); }
|
| 225 |
+
function esc(s){ return (s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
| 226 |
+
function fmt(s){ // tiny markdown: code fences, inline code, bold
|
| 227 |
+
s = esc(s);
|
| 228 |
+
s = s.replace(/```([\s\S]*?)```/g,(m,c)=>'<pre>'+c+'</pre>');
|
| 229 |
+
s = s.replace(/`([^`]+)`/g,'<code>$1</code>');
|
| 230 |
+
s = s.replace(/\*\*([^*]+)\*\*/g,'<strong>$1</strong>');
|
| 231 |
+
return s;
|
| 232 |
+
}
|
| 233 |
+
function bubble(role, inner){
|
| 234 |
+
$('empty')?.remove();
|
| 235 |
+
const wrap = document.createElement('div'); wrap.className = 'msg '+(role==='me'?'me':'');
|
| 236 |
+
wrap.innerHTML = `<div class="av ${role==='me'?'me':'ai'}">${role==='me'?'🙂':'📄'}</div>
|
| 237 |
+
<div class="bubble">${inner}</div>`;
|
| 238 |
+
$('chat').appendChild(wrap); $('chat').scrollTop = $('chat').scrollHeight;
|
| 239 |
+
return wrap.querySelector('.bubble');
|
| 240 |
+
}
|
| 241 |
+
async function ask(){
|
| 242 |
+
const t = $('q'); const q = t.value.trim(); if(!q) return;
|
| 243 |
+
const ids = selectedIds();
|
| 244 |
+
if (DOCS.length && ids.length===0){ alert('Select at least one document to chat with.'); return; }
|
| 245 |
+
t.value=''; autogrow(t); bubble('me', esc(q));
|
| 246 |
+
const b = bubble('ai', '<div class="typing"><span></span><span></span><span></span></div>');
|
| 247 |
+
$('send').disabled = true;
|
| 248 |
+
try {
|
| 249 |
+
const body = { question:q };
|
| 250 |
+
if (DOCS.length && ids.length < DOCS.length) body.document_ids = ids;
|
| 251 |
+
const r = await fetch('/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
| 252 |
+
const d = await r.json();
|
| 253 |
+
if (!r.ok){ b.innerHTML = '⚠ '+esc(d.detail||'Error'); $('send').disabled=false; return; }
|
| 254 |
+
let html = fmt(d.answer||'');
|
| 255 |
+
if (d.sources?.length){
|
| 256 |
+
const items = d.sources.map((s,i)=>`<div class="src"><div class="h">[${i+1}] ${esc(s.filename)} · chunk ${s.chunk_index} · ${(s.score*100).toFixed(0)}% match</div><div class="t">${esc(s.snippet)}</div></div>`).join('');
|
| 257 |
+
html += `<details class="sources"><summary>${d.sources.length} source${d.sources.length>1?'s':''}</summary>${items}</details>`;
|
| 258 |
+
}
|
| 259 |
+
b.innerHTML = html;
|
| 260 |
+
} catch(e){ b.innerHTML = '⚠ '+esc(''+e); }
|
| 261 |
+
$('send').disabled = false; $('chat').scrollTop = $('chat').scrollHeight;
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
health(); loadDocs(); setInterval(health, 30000);
|
| 265 |
+
</script>
|
| 266 |
+
</body>
|
| 267 |
+
</html>
|