dashhdata commited on
Commit
1e8bb26
·
verified ·
1 Parent(s): 82d4f12

Upload folder using huggingface_hub

Browse files
Files changed (17) hide show
  1. .dockerignore +9 -0
  2. .env.example +22 -0
  3. .gitignore +7 -0
  4. Dockerfile +26 -0
  5. app/__init__.py +0 -0
  6. app/config.py +36 -0
  7. app/db.py +99 -0
  8. app/embeddings.py +26 -0
  9. app/ingestion.py +144 -0
  10. app/main.py +144 -0
  11. app/models.py +39 -0
  12. app/rag.py +91 -0
  13. app/vector_store.py +101 -0
  14. render.yaml +37 -0
  15. requirements.txt +26 -0
  16. run.sh +30 -0
  17. 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 &amp; 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 &amp; 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
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>