Spaces:
Sleeping
Sleeping
Commit ·
fe2ee65
1
Parent(s): a02fa32
Deploying Python RAG chatbot stack to Hugging Face
Browse files- .dockerignore +6 -0
- Dockerfile +27 -0
- data/faqs.json +20 -0
- frontend.py +162 -0
- requirements.txt +12 -0
- src/__init__.py +0 -0
- src/__pycache__/__init__.cpython-314.pyc +0 -0
- src/__pycache__/database.cpython-314.pyc +0 -0
- src/__pycache__/engine.cpython-314.pyc +0 -0
- src/__pycache__/main.cpython-314.pyc +0 -0
- src/database.py +144 -0
- src/engine.py +47 -0
- src/main.py +167 -0
- start.sh +12 -0
.dockerignore
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
venv/
|
| 2 |
+
.venv/
|
| 3 |
+
__pycache__/
|
| 4 |
+
*.pyc
|
| 5 |
+
.git/
|
| 6 |
+
.env
|
Dockerfile
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.10-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
# Install system dependencies needed for building certain Python packages
|
| 6 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 7 |
+
build-essential \
|
| 8 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 9 |
+
|
| 10 |
+
# Copy and install dependencies
|
| 11 |
+
COPY requirements.txt .
|
| 12 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 13 |
+
|
| 14 |
+
# Copy the rest of your application code
|
| 15 |
+
COPY . .
|
| 16 |
+
|
| 17 |
+
# Force the frontend to look at the local backend inside the same container
|
| 18 |
+
ENV BACKEND_URL=http://127.0.0.1:8000
|
| 19 |
+
|
| 20 |
+
# Give Windows permission to execute our startup script
|
| 21 |
+
RUN chmod +x start.sh
|
| 22 |
+
|
| 23 |
+
# Expose the precise port that Hugging Face expects
|
| 24 |
+
EXPOSE 7860
|
| 25 |
+
|
| 26 |
+
# Run our orchestration script to boot both apps together
|
| 27 |
+
CMD ["./start.sh"]
|
data/faqs.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"id": "faq_001",
|
| 4 |
+
"category": "Refunds",
|
| 5 |
+
"question": "What is the refund window for international transactions?",
|
| 6 |
+
"answer": "International cross-border transactions can be refunded within 14 business days. However, a 2.5% currency conversion processing fee is non-refundable."
|
| 7 |
+
},
|
| 8 |
+
{
|
| 9 |
+
"id": "faq_002",
|
| 10 |
+
"category": "Security",
|
| 11 |
+
"question": "How do I reset my multi-factor authentication (MFA)?",
|
| 12 |
+
"answer": "To reset your MFA, navigate to Settings > Security > Two-Factor Authentication. If you are locked out, contact compliance-ops@fintech.com with your Employee ID."
|
| 13 |
+
},
|
| 14 |
+
{
|
| 15 |
+
"id": "faq_003",
|
| 16 |
+
"category": "Security",
|
| 17 |
+
"question": "How do I reset my account password?",
|
| 18 |
+
"answer": "To reset your account password, click 'Forgot Password' on the login page, enter your corporate email, and follow the secure verification link sent to your inbox."
|
| 19 |
+
}
|
| 20 |
+
]
|
frontend.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
import requests
|
| 3 |
+
import uuid
|
| 4 |
+
import json
|
| 5 |
+
|
| 6 |
+
# 🌐 BACKEND CONFIGURATION
|
| 7 |
+
BACKEND_URL = "http://backend:8000"
|
| 8 |
+
|
| 9 |
+
st.set_page_config(
|
| 10 |
+
page_title="Enterprise Multi-Tenant FAQ Bot",
|
| 11 |
+
page_icon="🤖",
|
| 12 |
+
layout="wide"
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
# 🔑 SESSION STATE INITIALIZATION
|
| 16 |
+
if "session_id" not in st.session_state:
|
| 17 |
+
# Generate a unique session token for this browser tab instance
|
| 18 |
+
st.session_state.session_id = str(uuid.uuid4())
|
| 19 |
+
|
| 20 |
+
if "messages" not in st.session_state:
|
| 21 |
+
st.session_state.messages = []
|
| 22 |
+
|
| 23 |
+
if "active_sources" not in st.session_state:
|
| 24 |
+
st.session_state.active_sources = None
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
# 🗂️ SIDEBAR: Workspace Management & File Uploads
|
| 28 |
+
with st.sidebar:
|
| 29 |
+
st.title("⚙️ Workspace Panel")
|
| 30 |
+
|
| 31 |
+
# Display the current isolated session ID
|
| 32 |
+
st.info(f"**Active Session Partition:**\n`{st.session_state.session_id}`")
|
| 33 |
+
st.caption("All document uploads and chat history are securely sandboxed inside this unique session token.")
|
| 34 |
+
|
| 35 |
+
st.markdown("---")
|
| 36 |
+
|
| 37 |
+
# File Uploader Widget
|
| 38 |
+
st.subheader("📥 Ingest Dynamic Context")
|
| 39 |
+
uploaded_file = st.file_uploader(
|
| 40 |
+
"Upload a .txt or .pdf file to train the bot for this session:",
|
| 41 |
+
type=["txt", "pdf"]
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
if uploaded_file is not None:
|
| 45 |
+
if st.button("🚀 Process & Vectorize Document", use_container_width=True):
|
| 46 |
+
with st.spinner("Streaming chunks to NVIDIA Embedding pipeline..."):
|
| 47 |
+
try:
|
| 48 |
+
# Prepare multipart form file payload
|
| 49 |
+
files = {"file": (uploaded_file.name, uploaded_file.getvalue(), uploaded_file.type)}
|
| 50 |
+
headers = {"X-Session-ID": st.session_state.session_id}
|
| 51 |
+
|
| 52 |
+
# Call FastAPI dynamic ingestion endpoint
|
| 53 |
+
response = requests.post(
|
| 54 |
+
f"{BACKEND_URL}/api/v1/upload",
|
| 55 |
+
files=files,
|
| 56 |
+
headers=headers
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
if response.status_code == 200:
|
| 60 |
+
st.success(f"✅ Context parsed! {uploaded_file.name} is now live in your workspace.")
|
| 61 |
+
else:
|
| 62 |
+
error_detail = response.json().get('detail', 'Unknown error')
|
| 63 |
+
st.error(f"❌ Ingestion Failed: {error_detail}")
|
| 64 |
+
except Exception as e:
|
| 65 |
+
st.error(f"❌ Connection error: {str(e)}")
|
| 66 |
+
|
| 67 |
+
st.markdown("---")
|
| 68 |
+
|
| 69 |
+
# Reset/Clear Options
|
| 70 |
+
st.subheader("🧹 Workspace Cleanup")
|
| 71 |
+
if st.button("Clear Chat Window State", use_container_width=True):
|
| 72 |
+
st.session_state.messages = []
|
| 73 |
+
st.session_state.active_sources = None
|
| 74 |
+
st.rerun()
|
| 75 |
+
|
| 76 |
+
if st.button("🗑️ Wipe Global Vector DB (Admin)", use_container_width=True, type="secondary"):
|
| 77 |
+
with st.spinner("Dropping collection..."):
|
| 78 |
+
try:
|
| 79 |
+
res = requests.post(f"{BACKEND_URL}/api/v1/clear")
|
| 80 |
+
if res.status_code == 200:
|
| 81 |
+
st.warning("💥 Global Vector Store wiped entirely.")
|
| 82 |
+
else:
|
| 83 |
+
st.error("Failed to clear DB.")
|
| 84 |
+
except Exception as e:
|
| 85 |
+
st.error(f"Error: {e}")
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
# 💬 MAIN INTERFACE: Streaming Chat Engine
|
| 89 |
+
st.title("🤖 Enterprise Multi-Tenant FAQ Bot")
|
| 90 |
+
st.markdown("Ask general questions, standard FAQs, or query details out of your uploaded dynamic documents.")
|
| 91 |
+
|
| 92 |
+
# 📜 Render Existing Chat Log
|
| 93 |
+
for message in st.session_state.messages:
|
| 94 |
+
with st.chat_message(message["role"]):
|
| 95 |
+
st.markdown(message["content"])
|
| 96 |
+
|
| 97 |
+
# ⚡ Live User Interaction Loop
|
| 98 |
+
if prompt := st.chat_input("Type your message here..."):
|
| 99 |
+
# Display user input inside the panel
|
| 100 |
+
with st.chat_message("user"):
|
| 101 |
+
st.markdown(prompt)
|
| 102 |
+
|
| 103 |
+
# Store message in state history
|
| 104 |
+
st.session_state.messages.append({"role": "user", "content": prompt})
|
| 105 |
+
|
| 106 |
+
# Build a clean conversion format payload for history matching
|
| 107 |
+
formatted_history = [
|
| 108 |
+
{"role": msg["role"], "role": "assistant" if msg["role"] == "assistant" else "user", "content": msg["content"]}
|
| 109 |
+
for msg in st.session_state.messages[:-1]
|
| 110 |
+
]
|
| 111 |
+
|
| 112 |
+
# Setup the JSON request body
|
| 113 |
+
chat_payload = {
|
| 114 |
+
"question": prompt,
|
| 115 |
+
"history": formatted_history,
|
| 116 |
+
"session_id": st.session_state.session_id
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
# Stream the incoming chunks from FastAPI
|
| 120 |
+
with st.chat_message("assistant"):
|
| 121 |
+
response_placeholder = st.empty()
|
| 122 |
+
full_response = ""
|
| 123 |
+
sources_found = []
|
| 124 |
+
|
| 125 |
+
try:
|
| 126 |
+
# Open persistent stream connection to FastAPI router
|
| 127 |
+
with requests.post(f"{BACKEND_URL}/api/v1/chat", json=chat_payload, stream=True) as response:
|
| 128 |
+
if response.status_code == 500:
|
| 129 |
+
st.error("The streaming backend pipeline returned a fatal exception.")
|
| 130 |
+
|
| 131 |
+
for line in response.iter_lines():
|
| 132 |
+
if line:
|
| 133 |
+
# Decode raw byte lines from incoming NDJSON data stream
|
| 134 |
+
decoded_line = line.decode('utf-8')
|
| 135 |
+
chunk_data = json.loads(decoded_line)
|
| 136 |
+
|
| 137 |
+
# 📦 Handle Retrieved Data Sources
|
| 138 |
+
if chunk_data.get("type") == "sources":
|
| 139 |
+
sources_found = chunk_data.get("content", [])
|
| 140 |
+
|
| 141 |
+
# ⚡ Handle Live Text Tokens
|
| 142 |
+
elif chunk_data.get("type") == "token":
|
| 143 |
+
full_response += chunk_data.get("content", "")
|
| 144 |
+
# Live redraw token progression
|
| 145 |
+
response_placeholder.markdown(full_response + "▌")
|
| 146 |
+
|
| 147 |
+
# Lock final markdown block state rendering without cursor character
|
| 148 |
+
response_placeholder.markdown(full_response)
|
| 149 |
+
|
| 150 |
+
# Render Source Documents in an Accordion if present
|
| 151 |
+
if sources_found:
|
| 152 |
+
with st.expander("📚 View Retrieved Reference Sources"):
|
| 153 |
+
for idx, src in enumerate(sources_found):
|
| 154 |
+
src_name = src.get("metadata", {}).get("source", "Global Base FAQ")
|
| 155 |
+
st.markdown(f"**Source [{idx+1}]:** `{src_name}`")
|
| 156 |
+
st.caption(src.get("content", ""))
|
| 157 |
+
|
| 158 |
+
# Save assistant milestone string state safely
|
| 159 |
+
st.session_state.messages.append({"role": "assistant", "content": full_response})
|
| 160 |
+
|
| 161 |
+
except Exception as conn_err:
|
| 162 |
+
st.error(f"Streaming error or dropped server connection: {str(conn_err)}")
|
requirements.txt
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.115.0
|
| 2 |
+
uvicorn>=0.31.0
|
| 3 |
+
streamlit>=1.35.0
|
| 4 |
+
python-multipart>=0.0.9
|
| 5 |
+
langchain>=0.3.0
|
| 6 |
+
langchain-openai>=0.2.0
|
| 7 |
+
langchain-nvidia-ai-endpoints>=0.1.0
|
| 8 |
+
chromadb>=0.5.10
|
| 9 |
+
pydantic>=2.9.0
|
| 10 |
+
python-dotenv>=1.0.1
|
| 11 |
+
pypdf>=3.20.0
|
| 12 |
+
langchain-community>=0.3.0
|
src/__init__.py
ADDED
|
File without changes
|
src/__pycache__/__init__.cpython-314.pyc
ADDED
|
Binary file (179 Bytes). View file
|
|
|
src/__pycache__/database.cpython-314.pyc
ADDED
|
Binary file (8.9 kB). View file
|
|
|
src/__pycache__/engine.cpython-314.pyc
ADDED
|
Binary file (2.55 kB). View file
|
|
|
src/__pycache__/main.cpython-314.pyc
ADDED
|
Binary file (9.08 kB). View file
|
|
|
src/database.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import os
|
| 3 |
+
from openai import OpenAI
|
| 4 |
+
from langchain_core.documents import Document
|
| 5 |
+
from langchain_core.embeddings import Embeddings
|
| 6 |
+
from langchain_community.vectorstores import Chroma
|
| 7 |
+
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
| 8 |
+
|
| 9 |
+
class NvidiaCompatibleEmbeddings(Embeddings):
|
| 10 |
+
"""
|
| 11 |
+
Custom embedding processor that bypasses LangChain's internal tokenization middleware
|
| 12 |
+
to pass raw text strings and asymmetric type configurations directly to NVIDIA NIM.
|
| 13 |
+
"""
|
| 14 |
+
def __init__(self, model: str, api_key: str, base_url: str):
|
| 15 |
+
self.client = OpenAI(api_key=api_key, base_url=base_url)
|
| 16 |
+
self.model = model
|
| 17 |
+
|
| 18 |
+
def embed_documents(self, texts: list[str]) -> list[list[float]]:
|
| 19 |
+
"""Embeds a list of documentation chunks using the 'passage' type."""
|
| 20 |
+
response = self.client.embeddings.create(
|
| 21 |
+
input=texts,
|
| 22 |
+
model=self.model,
|
| 23 |
+
extra_body={"input_type": "passage"}
|
| 24 |
+
)
|
| 25 |
+
return [item.embedding for item in response.data]
|
| 26 |
+
|
| 27 |
+
def embed_query(self, text: str) -> list[float]:
|
| 28 |
+
"""Embeds a live user search query using the 'query' type."""
|
| 29 |
+
response = self.client.embeddings.create(
|
| 30 |
+
input=[text],
|
| 31 |
+
model=self.model,
|
| 32 |
+
extra_body={"input_type": "query"}
|
| 33 |
+
)
|
| 34 |
+
return response.data[0].embedding
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class VectorDBManager:
|
| 38 |
+
def __init__(self, persist_directory: str = "./chroma_db"):
|
| 39 |
+
self.embeddings = NvidiaCompatibleEmbeddings(
|
| 40 |
+
model=os.getenv("EMBEDDING_MODEL", "nvidia/llama-nemotron-embed-1b-v2"),
|
| 41 |
+
api_key=os.getenv("NVIDIA_API_KEY"),
|
| 42 |
+
base_url=os.getenv("NVIDIA_BASE_URL")
|
| 43 |
+
)
|
| 44 |
+
self.persist_directory = persist_directory
|
| 45 |
+
self.vector_store = None
|
| 46 |
+
|
| 47 |
+
def _ensure_vector_store(self):
|
| 48 |
+
"""🛡️ Internal safeguard to ensure the Chroma instance is actively loaded in memory."""
|
| 49 |
+
if self.vector_store is None:
|
| 50 |
+
self.vector_store = Chroma(
|
| 51 |
+
persist_directory=self.persist_directory,
|
| 52 |
+
embedding_function=self.embeddings
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
def initialize_db(self, faq_filepath: str):
|
| 56 |
+
"""
|
| 57 |
+
Loads incoming JSON FAQs, formats them into searchable LangChain Documents,
|
| 58 |
+
and saves them locally via ChromaDB stamped with a 'global' scope.
|
| 59 |
+
"""
|
| 60 |
+
if not os.path.exists(faq_filepath):
|
| 61 |
+
raise FileNotFoundError(f"Could not find FAQ data resource file at: {faq_filepath}")
|
| 62 |
+
|
| 63 |
+
with open(faq_filepath, 'r') as f:
|
| 64 |
+
faq_data = json.load(f)
|
| 65 |
+
|
| 66 |
+
documents = []
|
| 67 |
+
for item in faq_data:
|
| 68 |
+
page_content = f"Question: {item['question']}\nAnswer: {item['answer']}"
|
| 69 |
+
|
| 70 |
+
# 🔑 Added session_id: "global" so these base FAQs are accessible to all users
|
| 71 |
+
metadata = {
|
| 72 |
+
"category": item["category"],
|
| 73 |
+
"faq_id": item["id"],
|
| 74 |
+
"session_id": "global"
|
| 75 |
+
}
|
| 76 |
+
documents.append(Document(page_content=page_content, metadata=metadata))
|
| 77 |
+
|
| 78 |
+
self.vector_store = Chroma.from_documents(
|
| 79 |
+
documents=documents,
|
| 80 |
+
embedding=self.embeddings,
|
| 81 |
+
persist_directory=self.persist_directory
|
| 82 |
+
)
|
| 83 |
+
print(f"🚀 Vector DB successfully initialized with {len(documents)} FAQs via NVIDIA Embeddings.")
|
| 84 |
+
|
| 85 |
+
def get_retriever(self, session_id: str = "default_session"):
|
| 86 |
+
"""
|
| 87 |
+
Loads the localized database and converts it into a queryable retriever layer
|
| 88 |
+
strictly filtered by the active session identity.
|
| 89 |
+
"""
|
| 90 |
+
self._ensure_vector_store()
|
| 91 |
+
|
| 92 |
+
# 🔑 MULTI-TENANCY FILTER: Look for global baseline data OR this specific user's uploads
|
| 93 |
+
meta_filter = {
|
| 94 |
+
"$or": [
|
| 95 |
+
{"session_id": "global"},
|
| 96 |
+
{"session_id": session_id}
|
| 97 |
+
]
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
return self.vector_store.as_retriever(
|
| 101 |
+
search_kwargs={
|
| 102 |
+
"k": 2,
|
| 103 |
+
"filter": meta_filter
|
| 104 |
+
}
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
def add_text_to_db(self, text: str, filename: str, session_id: str = "default_session"):
|
| 108 |
+
"""
|
| 109 |
+
📥 Chunks raw text from dynamic frontend uploads, computes NVIDIA embeddings,
|
| 110 |
+
and appends them directly into the live database marked with the owner's session ID.
|
| 111 |
+
"""
|
| 112 |
+
self._ensure_vector_store()
|
| 113 |
+
|
| 114 |
+
# 1. Break the document down into semantic pieces
|
| 115 |
+
text_splitter = RecursiveCharacterTextSplitter(chunk_size=600, chunk_overlap=60)
|
| 116 |
+
chunks = text_splitter.split_text(text)
|
| 117 |
+
|
| 118 |
+
# 2. Package raw strings into formal LangChain Document structures
|
| 119 |
+
documents = []
|
| 120 |
+
for idx, chunk in enumerate(chunks):
|
| 121 |
+
# 🔑 Stamping chunk dictionary metadata with the active session tracking ID
|
| 122 |
+
metadata = {
|
| 123 |
+
"source": filename,
|
| 124 |
+
"category": "Dynamic Upload",
|
| 125 |
+
"chunk_index": idx,
|
| 126 |
+
"session_id": session_id
|
| 127 |
+
}
|
| 128 |
+
documents.append(Document(page_content=chunk, metadata=metadata))
|
| 129 |
+
|
| 130 |
+
# 3. Stream the newly generated embeddings straight into the persistent store
|
| 131 |
+
self.vector_store.add_documents(documents)
|
| 132 |
+
print(f"⚡ Successfully indexed {len(documents)} dynamic chunks from raw file: '{filename}' for session '{session_id}'")
|
| 133 |
+
|
| 134 |
+
def clear_db(self):
|
| 135 |
+
"""🗑️ Completely wipes out the existing vector store collection securely on disk and in RAM."""
|
| 136 |
+
try:
|
| 137 |
+
self._ensure_vector_store()
|
| 138 |
+
self.vector_store.delete_collection()
|
| 139 |
+
self.vector_store = None
|
| 140 |
+
print("🗑️ Vector database collection successfully cleared!")
|
| 141 |
+
return True
|
| 142 |
+
except Exception as e:
|
| 143 |
+
print(f"❌ Failed to clear vector database: {e}")
|
| 144 |
+
return False
|
src/engine.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from langchain_openai import ChatOpenAI
|
| 3 |
+
from langchain_classic.chains import create_retrieval_chain
|
| 4 |
+
from langchain_classic.chains.combine_documents import create_stuff_documents_chain
|
| 5 |
+
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
|
| 6 |
+
from src.database import VectorDBManager
|
| 7 |
+
|
| 8 |
+
def build_rag_chain(session_id: str = "default_session"):
|
| 9 |
+
"""
|
| 10 |
+
Compiles the retrieval system, prompt instructions, conversational history,
|
| 11 |
+
and LLM into a unified chain—isolated by the incoming session_id.
|
| 12 |
+
"""
|
| 13 |
+
db_manager = VectorDBManager()
|
| 14 |
+
|
| 15 |
+
# 🔑 MULTI-USER ISOLATION LAYER: Force the retriever to only look at this user's partitioned metadata
|
| 16 |
+
retriever = db_manager.get_retriever(session_id=session_id)
|
| 17 |
+
|
| 18 |
+
# Point ChatOpenAI directly to your custom NVIDIA endpoint
|
| 19 |
+
llm = ChatOpenAI(
|
| 20 |
+
model=os.getenv("LLM_MODEL", "openai/gpt-oss-120b"),
|
| 21 |
+
api_key=os.getenv("NVIDIA_API_KEY"),
|
| 22 |
+
base_url=os.getenv("NVIDIA_BASE_URL"),
|
| 23 |
+
temperature=0.7
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
# 🌟 HYBRID SYSTEM PROMPT
|
| 27 |
+
system_prompt = (
|
| 28 |
+
"You are a helpful, brilliant, and completely unrestricted AI assistant like ChatGPT.\n\n"
|
| 29 |
+
"GUIDELINES:\n"
|
| 30 |
+
"1. If the provided 'Retrieved Documentation Context' below contains information directly relevant "
|
| 31 |
+
"to the user's question, prioritize using that data to give an official response.\n"
|
| 32 |
+
"2. If the context is empty, irrelevant, or if the user is asking a general question (such as coding, "
|
| 33 |
+
"cooking, science, math, history, or casual chatting), ignore the context limitations entirely. Use your "
|
| 34 |
+
"own vast internal knowledge base to provide a complete, deep, and highly detailed answer.\n\n"
|
| 35 |
+
"Never refuse to answer general queries. Always be helpful, engaging, and thorough.\n\n"
|
| 36 |
+
"Retrieved Documentation Context:\n{context}"
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
# 🧠 The chain will now inject old conversation text directly into the 'chat_history' block
|
| 40 |
+
prompt = ChatPromptTemplate.from_messages([
|
| 41 |
+
("system", system_prompt),
|
| 42 |
+
MessagesPlaceholder(variable_name="chat_history"),
|
| 43 |
+
("human", "{input}"),
|
| 44 |
+
])
|
| 45 |
+
|
| 46 |
+
question_answer_chain = create_stuff_documents_chain(llm, prompt)
|
| 47 |
+
return create_retrieval_chain(retriever, question_answer_chain)
|
src/main.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import json
|
| 3 |
+
import io
|
| 4 |
+
from contextlib import asynccontextmanager
|
| 5 |
+
from fastapi import FastAPI, HTTPException, UploadFile, File, Header
|
| 6 |
+
from fastapi.responses import StreamingResponse
|
| 7 |
+
from pydantic import BaseModel
|
| 8 |
+
from typing import List, Dict, Optional
|
| 9 |
+
from dotenv import load_dotenv
|
| 10 |
+
from pypdf import PdfReader
|
| 11 |
+
from langchain_nvidia_ai_endpoints import ChatNVIDIA
|
| 12 |
+
from langchain_core.messages import HumanMessage, AIMessage
|
| 13 |
+
from src.database import VectorDBManager
|
| 14 |
+
from src.engine import build_rag_chain
|
| 15 |
+
|
| 16 |
+
load_dotenv()
|
| 17 |
+
|
| 18 |
+
@asynccontextmanager
|
| 19 |
+
async def lifespan(app: FastAPI):
|
| 20 |
+
if not os.path.exists("./chroma_db"):
|
| 21 |
+
print("Initializing Vector Database...")
|
| 22 |
+
db_manager = VectorDBManager()
|
| 23 |
+
db_manager.initialize_db("data/faqs.json")
|
| 24 |
+
yield
|
| 25 |
+
|
| 26 |
+
app = FastAPI(title="Enterprise FAQ Bot Engine", version="1.0", lifespan=lifespan)
|
| 27 |
+
|
| 28 |
+
class QueryRequest(BaseModel):
|
| 29 |
+
question: str
|
| 30 |
+
history: Optional[List[Dict[str, str]]] = []
|
| 31 |
+
session_id: Optional[str] = "default_session"
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
# 📂 DYNAMIC KNOWLEDGE INGESTION ENDPOINT
|
| 35 |
+
@app.post("/api/v1/upload")
|
| 36 |
+
async def upload_file_endpoint(
|
| 37 |
+
file: UploadFile = File(...),
|
| 38 |
+
x_session_id: Optional[str] = Header(None)
|
| 39 |
+
):
|
| 40 |
+
"""
|
| 41 |
+
Accepts standard multipart form file uploads (.txt or .pdf), extracts raw text
|
| 42 |
+
contents in-memory, and passes them along with a unique session ID.
|
| 43 |
+
"""
|
| 44 |
+
try:
|
| 45 |
+
contents = await file.read()
|
| 46 |
+
filename = file.filename.lower()
|
| 47 |
+
text_data = ""
|
| 48 |
+
|
| 49 |
+
# 📄 Process Plain Text Files
|
| 50 |
+
if filename.endswith(".txt"):
|
| 51 |
+
text_data = contents.decode("utf-8")
|
| 52 |
+
|
| 53 |
+
# 📕 Process PDF Files In-Memory
|
| 54 |
+
elif filename.endswith(".pdf"):
|
| 55 |
+
pdf_stream = io.BytesIO(contents)
|
| 56 |
+
pdf_reader = PdfReader(pdf_stream)
|
| 57 |
+
|
| 58 |
+
extracted_pages = []
|
| 59 |
+
for page in pdf_reader.pages:
|
| 60 |
+
page_text = page.extract_text()
|
| 61 |
+
if page_text:
|
| 62 |
+
extracted_pages.append(page_text)
|
| 63 |
+
|
| 64 |
+
text_data = "\n".join(extracted_pages)
|
| 65 |
+
|
| 66 |
+
if not text_data.strip():
|
| 67 |
+
raise HTTPException(
|
| 68 |
+
status_code=400,
|
| 69 |
+
detail="The uploaded PDF appears to be empty or contains only non-scanned imagery (OCR required)."
|
| 70 |
+
)
|
| 71 |
+
else:
|
| 72 |
+
raise HTTPException(
|
| 73 |
+
status_code=400,
|
| 74 |
+
detail="Unsupported file format. Please upload a valid plain text (.txt) or PDF (.pdf) document."
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
db_manager = VectorDBManager()
|
| 78 |
+
target_session = x_session_id or "default_session"
|
| 79 |
+
db_manager.add_text_to_db(text_data, filename=file.filename, session_id=target_session)
|
| 80 |
+
|
| 81 |
+
return {
|
| 82 |
+
"status": "success",
|
| 83 |
+
"message": f"Successfully vectorized and stored {file.filename} under session context!"
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
except UnicodeDecodeError:
|
| 87 |
+
raise HTTPException(
|
| 88 |
+
status_code=400,
|
| 89 |
+
detail="File encoding error: Please ensure your text file is saved with valid UTF-8 encoding."
|
| 90 |
+
)
|
| 91 |
+
except HTTPException as http_ex:
|
| 92 |
+
raise http_ex
|
| 93 |
+
except Exception as e:
|
| 94 |
+
raise HTTPException(status_code=500, detail=f"Ingestion pipeline failed: {str(e)}")
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
# 🗑️ ADMIN DATABASE RESET ENDPOINT
|
| 98 |
+
@app.post("/api/v1/clear")
|
| 99 |
+
async def clear_database_endpoint():
|
| 100 |
+
"""
|
| 101 |
+
Triggers a collection wipe on the vector database.
|
| 102 |
+
"""
|
| 103 |
+
db_manager = VectorDBManager()
|
| 104 |
+
if db_manager.clear_db():
|
| 105 |
+
return {"status": "success", "message": "Vector database cleared successfully."}
|
| 106 |
+
else:
|
| 107 |
+
raise HTTPException(status_code=500, detail="Failed to drop vector database collection.")
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
# ⚡ LIVE STREAMING CHAT ROUTER
|
| 111 |
+
@app.post("/api/v1/chat")
|
| 112 |
+
async def chat_endpoint(payload: QueryRequest):
|
| 113 |
+
try:
|
| 114 |
+
search_query = payload.question
|
| 115 |
+
chat_history = []
|
| 116 |
+
|
| 117 |
+
# Format the conversational history state if it exists
|
| 118 |
+
if payload.history and len(payload.history) > 0:
|
| 119 |
+
for msg in payload.history:
|
| 120 |
+
if msg["role"] == "user":
|
| 121 |
+
chat_history.append(HumanMessage(content=msg["content"]))
|
| 122 |
+
elif msg["role"] == "assistant":
|
| 123 |
+
chat_history.append(AIMessage(content=msg["content"]))
|
| 124 |
+
|
| 125 |
+
# ✨ RESTORED & ALIGNED MEMORY CONTEXT BLOCK
|
| 126 |
+
try:
|
| 127 |
+
rephrase_llm = ChatNVIDIA(model="meta/llama-3.1-70b-instruct")
|
| 128 |
+
history_context = ""
|
| 129 |
+
for msg in payload.history:
|
| 130 |
+
role = "User" if msg["role"] == "user" else "Assistant"
|
| 131 |
+
history_context += f"{role}: {msg['content']}\n"
|
| 132 |
+
|
| 133 |
+
condense_prompt = (
|
| 134 |
+
f"Given the following chat history and a follow-up question, "
|
| 135 |
+
f"rephrase the follow-up question into a standalone query.\n\n"
|
| 136 |
+
f"Chat History:\n{history_context}\n"
|
| 137 |
+
f"Follow-up Question: {payload.question}\n\n"
|
| 138 |
+
f"Standalone Query:"
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
# Execute standard LangChain invocation
|
| 142 |
+
llm_response = rephrase_llm.invoke(condense_prompt)
|
| 143 |
+
search_query = llm_response.content.strip()
|
| 144 |
+
|
| 145 |
+
except Exception as context_error:
|
| 146 |
+
print(f"⚠️ Memory rephrasing failed: {context_error}")
|
| 147 |
+
search_query = payload.question
|
| 148 |
+
|
| 149 |
+
# Build execution pipeline tied strictly to this user's workspace partitions
|
| 150 |
+
rag_chain = build_rag_chain(session_id=payload.session_id)
|
| 151 |
+
|
| 152 |
+
async def event_generator():
|
| 153 |
+
async for chunk in rag_chain.astream({"input": search_query, "chat_history": chat_history}):
|
| 154 |
+
if "context" in chunk:
|
| 155 |
+
sources_payload = [
|
| 156 |
+
{"content": doc.page_content, "metadata": doc.metadata}
|
| 157 |
+
for doc in chunk["context"]
|
| 158 |
+
]
|
| 159 |
+
yield json.dumps({"type": "sources", "content": sources_payload}) + "\n"
|
| 160 |
+
|
| 161 |
+
if "answer" in chunk:
|
| 162 |
+
yield json.dumps({"type": "token", "content": chunk["answer"]}) + "\n"
|
| 163 |
+
|
| 164 |
+
return StreamingResponse(event_generator(), media_type="application/x-ndjson")
|
| 165 |
+
|
| 166 |
+
except Exception as e:
|
| 167 |
+
raise HTTPException(status_code=500, detail=f"Chat streaming pipeline failed: {str(e)}")
|
start.sh
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
|
| 3 |
+
# 1. Start the FastAPI backend in the background on port 8000
|
| 4 |
+
echo "Starting FastAPI backend..."
|
| 5 |
+
uvicorn src.main:app --host 0.0.0.0 --port 8000 &
|
| 6 |
+
|
| 7 |
+
# 2. Wait a brief moment for the backend to initialize
|
| 8 |
+
sleep 3
|
| 9 |
+
|
| 10 |
+
# 3. Start the Streamlit frontend in the foreground on Hugging Face's required port (7860)
|
| 11 |
+
echo "Starting Streamlit frontend..."
|
| 12 |
+
streamlit run frontend.py --server.port 7860 --server.address 0.0.0.0 --server.enableCORS=false --server.enableXsrfProtection=false
|