YADUUG commited on
Commit
4e06012
·
1 Parent(s): 9d02d13
Files changed (16) hide show
  1. .dockerignore +24 -0
  2. .gitattributes +1 -0
  3. .gitignore +16 -0
  4. AGENTS.md +18 -0
  5. Dockerfile +58 -0
  6. README.md +4 -4
  7. STARTUP.md +83 -0
  8. app.log +15 -0
  9. app.py +597 -0
  10. data/NPS_Rules.txt +21 -0
  11. data/Secrets.txt +1 -0
  12. frontend/admin.html +186 -0
  13. frontend/index.html +763 -0
  14. mistral.py +517 -0
  15. nps_agent.py +473 -0
  16. requirements.txt +20 -0
.dockerignore ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Git files
2
+ .git
3
+ .gitignore
4
+
5
+ # Python virtual environment
6
+ venv/
7
+ env/
8
+ .venv/
9
+
10
+ # Python cache
11
+ __pycache__/
12
+ *.pyc
13
+
14
+ # IDE and OS files
15
+ .vscode/
16
+ .idea/
17
+ *.DS_Store
18
+
19
+ # Local secrets and environment variables
20
+ .env
21
+
22
+ # Application cache (if you don't want it in the image)
23
+ cache/
24
+
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ *.pdf filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ *.pyd
5
+ env/
6
+ .venv/
7
+ .env
8
+ *.egg-info/
9
+ dist/
10
+ build/
11
+
12
+ /cache/
13
+ *.faiss
14
+ default.sqlite
15
+
16
+ sessions
AGENTS.md ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Agent Instructions
2
+
3
+ You are a proactive web browsing agent.
4
+
5
+ ## Browsing Guidelines
6
+
7
+ - **Search is just the start**: When you search for something, don't just stop at the search results.
8
+ - **Navigate**: You MUST click on relevant results to find the actual information.
9
+ - **Use `get_links`**: On a search results page or any page, use the `get_links` action to see clickable items.
10
+ - **Click Links**: Use the `click` action with the text of the link (or selector) to navigate.
11
+ - **Deep Research**: Go 2-3 levels deep if necessary. Don't be lazy.
12
+
13
+ ## Example Flow
14
+ 1. `browser(action="navigate", url="google.com")`
15
+ 2. `browser(action="type", selector="search box", text="query")` + `press("Enter")`
16
+ 3. `browser(action="get_links")` -> Returns list of results
17
+ 4. `browser(action="click", selector="Link Text of Result 1")`
18
+ 5. `browser(action="get_text")` -> Read page content
Dockerfile ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Use the official Python image with Playwright pre-installed
2
+ # This base image includes Ubuntu 22.04 (Jammy) and necessary system deps for browsers
3
+ FROM mcr.microsoft.com/playwright/python:v1.41.0-jammy
4
+
5
+ # Set non-interactive mode for apt
6
+ ENV DEBIAN_FRONTEND=noninteractive
7
+
8
+ # Create a user to run the application (HF Spaces uses user 1000)
9
+ RUN useradd -m -u 1000 user
10
+
11
+ # Set working directory
12
+ WORKDIR /app
13
+
14
+ # Switch to root to install dependencies and system packages if needed
15
+ USER root
16
+
17
+ # Install system dependencies if any additional ones are needed (e.g. for some python packages)
18
+ RUN apt-get update && apt-get install -y \
19
+ build-essential \
20
+ software-properties-common \
21
+ git \
22
+ && rm -rf /var/lib/apt/lists/*
23
+
24
+ # Copy requirements first to leverage Docker cache
25
+ COPY requirements.txt .
26
+
27
+ # Install Python dependencies
28
+ RUN pip install --no-cache-dir -r requirements.txt
29
+
30
+ # Install Playwright browsers (already in base image, but ensure they are available for the user)
31
+ # The base image puts them in specific locations.
32
+ # We run `playwright install chromium` just to be safe and ensure links are set up if needed by the python lib
33
+ RUN playwright install chromium
34
+
35
+ # Copy application code
36
+ COPY . .
37
+
38
+ # Adjust permissions for the user
39
+ RUN chown -R user:user /app
40
+
41
+ # Create necessary directories for the app (data, cache) and ensure permission
42
+ # HF Spaces likes /data for persistent storage if configured, but we map it in code.
43
+ # Here we just make sure our app directories are writable
44
+ RUN mkdir -p /app/data /app/memory /tmp/cache /tmp/hf && \
45
+ chown -R user:user /app/data /app/memory /tmp/cache /tmp/hf
46
+
47
+ # Switch to the non-root user
48
+ USER user
49
+
50
+ # Expose the port
51
+ EXPOSE 7860
52
+
53
+ # Define environment variables
54
+ ENV PYTHONUNBUFFERED=1
55
+ ENV HOME=/home/user
56
+
57
+ # Start the application
58
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,8 +1,8 @@
1
  ---
2
- title: NPS ChatAgentBot
3
- emoji: 📊
4
- colorFrom: purple
5
- colorTo: blue
6
  sdk: docker
7
  pinned: false
8
  ---
 
1
  ---
2
+ title: NPS-AI
3
+ emoji: 👀
4
+ colorFrom: indigo
5
+ colorTo: yellow
6
  sdk: docker
7
  pinned: false
8
  ---
STARTUP.md ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Startup Instructions for NPS-AI
2
+
3
+ This guide explains how to set up and run the NPS-AI project, including the FastAPI chatbot and the autonomous browser agent.
4
+
5
+ ## Prerequisites
6
+
7
+ - **Python 3.10+**
8
+ - **Ollama** (for local agent inference)
9
+ - **Mistral API Key** (for chatbot RAG)
10
+
11
+ ## 1. Environment Setup
12
+
13
+ 1. **Clone the repository** (if not already done).
14
+ 2. **Create a `.env` file** in the project root:
15
+ ```bash
16
+ cp .env.example .env # If .env.example exists
17
+ # OR create manually:
18
+ touch .env
19
+ ```
20
+ 3. **Configure Environment Variables** in `.env`:
21
+ ```ini
22
+ MISTRAL_API_KEY=your_mistral_api_key_here
23
+ # Optional Admin Credentials for file management
24
+ ADMIN_USERNAME=admin
25
+ ADMIN_PASSWORD=admin
26
+ ```
27
+
28
+ ## 2. Install Dependencies
29
+
30
+ Install the required Python packages:
31
+
32
+ ```bash
33
+ pip install -r requirements.txt
34
+ ```
35
+
36
+ Verify that `playwright` browsers are installed (required for the agent):
37
+
38
+ ```bash
39
+ playwright install
40
+ ```
41
+
42
+ ## 3. Ollama Setup (For Agent)
43
+
44
+ The agent uses a local Ollama model (`kimi-k2.5:cloud` by default in `nps_agent.py`). Ensure Ollama is running and the model is pulled:
45
+
46
+ 1. **Start Ollama**:
47
+ ```bash
48
+ ollama serve
49
+ ```
50
+ 2. **Pull the Model**:
51
+ ```bash
52
+ ollama pull kimi-k2.5:cloud
53
+ ```
54
+ *(Note: If you use a different model, update `model_name` in `nps_agent.py`)*
55
+
56
+ ## 4. Running the Application
57
+
58
+ ### Option A: Run the Full Application (Chatbot + Agent API)
59
+
60
+ Start the FastAPI server:
61
+
62
+ ```bash
63
+ python app.py
64
+ ```
65
+
66
+ - **URL**: `http://localhost:7860`
67
+ - **Chatbot Interface**: Accessible at the root URL (configured via static files in `frontend`).
68
+ - **Agent Endpoint**: `POST /agent/run`
69
+
70
+ ### Option B: Run the Agent Standalone
71
+
72
+ To test the agent directly specifically:
73
+
74
+ ```bash
75
+ python nps_agent.py
76
+ ```
77
+ *(This will prompt for a task input in the terminal)*
78
+
79
+ ## Troubleshooting
80
+
81
+ - **Import Errors**: Ensure you are in the virtual environment (`source .venv/bin/activate` or similar).
82
+ - **Browser Issues**: Run `playwright install` again.
83
+ - **LLM Connection**: Check if Ollama is running on port 11434 (`http://localhost:11434`).
app.log ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 2026-02-19 21:53:16,697 - __main__ - INFO - No persistent storage '/data' found. Using local 'data' folder and '/tmp/cache'.
2
+ /home/yaduug/Desktop/Dev/NPS-AI/app.py:136: PydanticDeprecatedSince20: Support for class-based `config` is deprecated, use ConfigDict instead. Deprecated in Pydantic V2.0 to be removed in V3.0. See Pydantic V2 Migration Guide at https://errors.pydantic.dev/2.12/migration/
3
+ class ScikitLearnTfidfRetriever(BaseRetriever):
4
+ INFO: Started server process [1629794]
5
+ INFO: Waiting for application startup.
6
+ 2026-02-19 21:53:16,719 - __main__ - INFO - 🚀 Initializing RAG system...
7
+ 2026-02-19 21:53:16,719 - sentence_transformers.SentenceTransformer - INFO - Load pretrained SentenceTransformer: BAAI/bge-base-en-v1.5
8
+ 2026-02-19 21:53:22,747 - __main__ - INFO - ✅ Loading vector store from cache: /tmp/cache/vectorstore_91a2d81fdd09516b836d2bb886c10c28.faiss
9
+ 2026-02-19 21:53:22,754 - faiss.loader - INFO - Loading faiss with AVX2 support.
10
+ 2026-02-19 21:53:22,770 - faiss.loader - INFO - Successfully loaded faiss with AVX2 support.
11
+ 2026-02-19 21:53:22,811 - __main__ - INFO - ✅ RAG system initialized successfully.
12
+ INFO: Application startup complete.
13
+ INFO: Uvicorn running on http://0.0.0.0:7860 (Press CTRL+C to quit)
14
+ 2026-02-19 21:53:45,327 - __main__ - INFO - Translated 'GNDU kahan hai?' (Hindi) -> 'Where is GNDU?' (English)
15
+ INFO: 127.0.0.1:33394 - "POST /ask HTTP/1.1" 200 OK
app.py ADDED
@@ -0,0 +1,597 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os, logging
2
+ import glob
3
+ import logging
4
+ import re
5
+ import hashlib
6
+ import numpy as np
7
+ import asyncio
8
+ from typing import List, Optional, Any
9
+ import shutil
10
+ import secrets
11
+ from contextlib import asynccontextmanager
12
+ from nps_agent import run_nps_task
13
+
14
+ # --- Pydantic & FastAPI ---
15
+ from pydantic import BaseModel, Field
16
+ import uvicorn
17
+ from fastapi import FastAPI, HTTPException, APIRouter, UploadFile, File, Depends
18
+ from fastapi.middleware.cors import CORSMiddleware
19
+ from fastapi.staticfiles import StaticFiles
20
+ from fastapi.security import HTTPBasic, HTTPBasicCredentials
21
+
22
+ # --- LangChain & RAG Core ---
23
+ from PyPDF2 import PdfReader
24
+
25
+ try:
26
+ from docx import Document as DocxDocument
27
+ except ImportError:
28
+ DocxDocument = None
29
+ from langchain_text_splitters import RecursiveCharacterTextSplitter
30
+ from langchain_community.vectorstores import FAISS
31
+ from langchain_classic.retrievers import EnsembleRetriever
32
+ from langchain_classic.chains.combine_documents import (
33
+ create_stuff_documents_chain,
34
+ )
35
+ from langchain_classic.chains import create_retrieval_chain
36
+ from langchain_huggingface import HuggingFaceEmbeddings
37
+ from langchain_core.language_models import LLM
38
+ from langchain_core.callbacks.manager import CallbackManagerForLLMRun
39
+ from langchain_core.documents import Document
40
+ from langchain_core.retrievers import BaseRetriever
41
+ from langchain_core.prompts import PromptTemplate
42
+
43
+ # --- Scikit-Learn for TF-IDF ---
44
+ from sklearn.feature_extraction.text import TfidfVectorizer
45
+ from sklearn.metrics.pairwise import cosine_similarity
46
+
47
+ # --- Mistral AI ---
48
+ from mistralai import Mistral as MistralClient
49
+ from dotenv import load_dotenv
50
+
51
+ load_dotenv()
52
+
53
+ # --- Global Variables & Configuration ---
54
+ logging.basicConfig(
55
+ level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
56
+ )
57
+ logger = logging.getLogger(__name__)
58
+
59
+ # Define the original, read-only data folder from the repo
60
+ REPO_DATA_FOLDER = "data"
61
+
62
+ # Use persistent storage if available (on Hugging Face Spaces)
63
+ if os.path.exists("/data"):
64
+ logger.info("Persistent storage '/data' found. Using it for PDFs and cache.")
65
+ DATA_FOLDER = "/data/pdf_storage"
66
+ CACHE_DIR = "/data/cache"
67
+ else:
68
+ logger.info(
69
+ "No persistent storage '/data' found. Using local 'data' folder and '/tmp/cache'."
70
+ )
71
+ DATA_FOLDER = REPO_DATA_FOLDER
72
+ CACHE_DIR = "/tmp/cache"
73
+
74
+ os.makedirs(DATA_FOLDER, exist_ok=True)
75
+ os.makedirs(CACHE_DIR, exist_ok=True)
76
+ retrieval_chain = None
77
+
78
+ HF_CACHE = "/tmp/hf"
79
+ os.environ["HF_HOME"] = HF_CACHE
80
+ os.environ["HF_DATASETS_CACHE"] = HF_CACHE
81
+ os.environ["HF_MODULES_CACHE"] = HF_CACHE
82
+
83
+ retrieval_chain = None
84
+
85
+ db_path = "/data/default.sqlite" if os.path.exists("/data") else "default.sqlite"
86
+
87
+
88
+ # --- Pydantic Models ---
89
+
90
+ # --- Pydantic Models ---
91
+ class QuestionRequest(BaseModel):
92
+ question: str = Field(..., min_length=1)
93
+ mode: str = Field("chat", description="Mode of operation: 'chat' or 'agent'")
94
+ language: str = Field("English", description="Language of the question and expected answer")
95
+
96
+
97
+ class AgentRequest(BaseModel):
98
+ task: str = Field(..., min_length=5, description="The task for the browser agent to perform")
99
+
100
+
101
+ class AnswerResponse(BaseModel):
102
+ answer: str
103
+ sources: List[str]
104
+
105
+
106
+ # --- Greeting Handler ---
107
+ GREETING_RESPONSES = {
108
+ "hi": "Hello! How can I assist you?",
109
+ "hello": "Hello! How can I help you today?",
110
+ "bye": "Bye-bye! See you soon.",
111
+ "ty": "Your Welcome!",
112
+ }
113
+
114
+ # --- Enhanced Prompt ---
115
+ UNIVERSITY_ASSISTANT_PROMPT = PromptTemplate.from_template(
116
+ """
117
+ You are a meticulous Guru Nanak Dev University Academic Assistant.
118
+ Your task is to provide accurate, specific, and concise answers based ONLY on the provided Context.
119
+ **Instructions:**
120
+ 1. **Analyze the Context:** Carefully read the user's question and the provided context to synthesize an answer.
121
+ 2. **Extract Specifics:** If asked for a fee, number, or date, provide the EXACT information.
122
+ 3. **Cite Sources:** After your answer, cite the source document and page number: `(Source: Prospectus.pdf, Page: 15)`.
123
+ 4. **Handle Missing Information:** If the info is not in the context, state: "Based on the provided documents, I could not find specific information about this topic."
124
+ ---
125
+ **Context:**
126
+ {context}
127
+ ---
128
+ **Question:**
129
+ {input}
130
+ **Answer (with citations):**
131
+ """
132
+ )
133
+
134
+
135
+ # --- Custom Scikit-Learn TF-IDF Retriever ---
136
+ class ScikitLearnTfidfRetriever(BaseRetriever):
137
+ docs: List[Document]
138
+ tfidf_vectorizer: TfidfVectorizer
139
+ tfidf_matrix: Any
140
+ k: int = 15
141
+
142
+ class Config:
143
+ arbitrary_types_allowed = True
144
+
145
+ def _get_relevant_documents(self, query: str) -> List[Document]:
146
+ query_vector = self.tfidf_vectorizer.transform([query])
147
+ similarity_scores = cosine_similarity(query_vector, self.tfidf_matrix).flatten()
148
+ top_k_indices = np.argsort(similarity_scores)[-self.k :][::-1]
149
+ return [self.docs[i] for i in top_k_indices]
150
+
151
+ async def _aget_relevant_documents(
152
+ self, query: str, *, run_manager=None
153
+ ) -> List[Document]:
154
+ return self._get_relevant_documents(query)
155
+
156
+
157
+ # --- MistralLLM Class ---
158
+ class MistralLLM(LLM):
159
+ client: Any = None
160
+ model: str = "mistral-small-latest"
161
+ temperature: float = 0.0
162
+ api_key: str = ""
163
+
164
+ def __init__(
165
+ self,
166
+ api_key: str,
167
+ model: str = "mistral-small-latest",
168
+ temperature: float = 0.0,
169
+ ):
170
+ super().__init__()
171
+ self.api_key = api_key
172
+ self.model = model
173
+ self.temperature = temperature
174
+ self.client = MistralClient(api_key=self.api_key)
175
+
176
+ @property
177
+ def _llm_type(self) -> str:
178
+ return "mistral"
179
+
180
+ def _call(
181
+ self,
182
+ prompt: str,
183
+ stop: Optional[List[str]] = None,
184
+ run_manager: Optional[CallbackManagerForLLMRun] = None,
185
+ **kwargs: Any,
186
+ ) -> str:
187
+ messages = [{"role": "user", "content": prompt}]
188
+ response = self.client.chat.complete(
189
+ model=self.model, messages=messages, temperature=self.temperature
190
+ )
191
+ return response.choices[0].message.content.strip()
192
+
193
+
194
+ # --- Caching and Data Processing Functions ---
195
+ def get_data_hash(folder_path=DATA_FOLDER) -> str:
196
+ """Generates a hash based on the filenames and modification times of supported files."""
197
+ hasher = hashlib.md5()
198
+ if not os.path.exists(folder_path):
199
+ return "no_data"
200
+
201
+ supported_extensions = [".pdf", ".txt", ".md", ".docx"]
202
+ files_to_hash = []
203
+ for ext in supported_extensions:
204
+ files_to_hash.extend(glob.glob(os.path.join(folder_path, f"*{ext}")))
205
+
206
+ for file_path in sorted(files_to_hash):
207
+ try:
208
+ stat = os.stat(file_path)
209
+ file_info = f"{os.path.basename(file_path)}-{stat.st_mtime}-{stat.st_size}"
210
+ hasher.update(file_info.encode("utf-8"))
211
+ except FileNotFoundError:
212
+ continue # File might have been deleted during the process
213
+
214
+ return hasher.hexdigest()
215
+
216
+
217
+ def get_documents_from_files(folder_path=DATA_FOLDER) -> List[Document]:
218
+ docs = []
219
+ supported_extensions = [".pdf", ".txt", ".md", ".docx"]
220
+
221
+ for file_path in glob.glob(os.path.join(folder_path, "*")):
222
+ file_name = os.path.basename(file_path)
223
+ file_ext = os.path.splitext(file_name)[1].lower()
224
+
225
+ if file_ext not in supported_extensions:
226
+ continue
227
+
228
+ if file_ext == ".pdf":
229
+ pdf_reader = PdfReader(file_path)
230
+ for i, page in enumerate(pdf_reader.pages):
231
+ text = page.extract_text() or ""
232
+ docs.append(
233
+ Document(
234
+ page_content=text, metadata={"source": file_name, "page": i + 1}
235
+ )
236
+ )
237
+ elif file_ext in [".txt", ".md"]:
238
+ with open(file_path, "r", encoding="utf-8") as f:
239
+ text = f.read()
240
+ docs.append(
241
+ Document(
242
+ page_content=text, metadata={"source": file_name, "page": 1}
243
+ )
244
+ )
245
+ elif file_ext == ".docx":
246
+ if DocxDocument is None:
247
+ logger.warning(
248
+ "Skipping .docx file because 'python-docx' is not installed. Please install it with 'pip install python-docx'."
249
+ )
250
+ continue
251
+ try:
252
+ doc = DocxDocument(file_path)
253
+ text = "\n".join([para.text for para in doc.paragraphs])
254
+ docs.append(
255
+ Document(
256
+ page_content=text, metadata={"source": file_name, "page": 1}
257
+ )
258
+ )
259
+ except Exception as e:
260
+ logger.error(f"Error processing .docx file {file_name}: {e}")
261
+
262
+ return docs
263
+
264
+
265
+ async def initialize_rag_system():
266
+ global retrieval_chain
267
+ logger.info("🚀 Initializing RAG system...")
268
+ os.makedirs(CACHE_DIR, exist_ok=True)
269
+
270
+ data_hash = get_data_hash()
271
+ cache_path = os.path.join(CACHE_DIR, f"vectorstore_{data_hash}.faiss")
272
+
273
+ embeddings = HuggingFaceEmbeddings(
274
+ model_name="BAAI/bge-base-en-v1.5",
275
+ model_kwargs={"device": "cpu"},
276
+ encode_kwargs={"normalize_embeddings": True},
277
+ )
278
+
279
+ if os.path.exists(cache_path):
280
+ logger.info(f"✅ Loading vector store from cache: {cache_path}")
281
+ vectorstore = FAISS.load_local(
282
+ cache_path, embeddings, allow_dangerous_deserialization=True
283
+ )
284
+ documents = get_documents_from_files()
285
+ text_chunks = RecursiveCharacterTextSplitter(
286
+ chunk_size=1000, chunk_overlap=150
287
+ ).split_documents(documents)
288
+ else:
289
+ logger.warning(
290
+ "No cache found or data has changed. Building new vector store..."
291
+ )
292
+ documents = get_documents_from_files()
293
+ if not documents:
294
+ logger.warning(
295
+ "No documents found in /data folder. RAG system will be inactive."
296
+ )
297
+ retrieval_chain = None # Ensure chain is None
298
+ return
299
+
300
+ text_chunks = RecursiveCharacterTextSplitter(
301
+ chunk_size=1000, chunk_overlap=150
302
+ ).split_documents(documents)
303
+
304
+ logger.info(
305
+ f"Embedding {len(text_chunks)} chunks... (This will be slow on the first run)"
306
+ )
307
+ vectorstore = FAISS.from_documents(documents=text_chunks, embedding=embeddings)
308
+
309
+ logger.info(f"💾 Saving new vector store to cache: {cache_path}")
310
+ vectorstore.save_local(cache_path)
311
+
312
+ # --- Chain Setup ---
313
+ mistral_api_key = os.getenv("MISTRAL_API_KEY")
314
+ if not mistral_api_key:
315
+ raise ValueError("MISTRAL_API_KEY not found")
316
+
317
+ llm = MistralLLM(api_key=mistral_api_key)
318
+
319
+ question_answer_chain = create_stuff_documents_chain(
320
+ llm, UNIVERSITY_ASSISTANT_PROMPT
321
+ )
322
+
323
+ faiss_retriever = vectorstore.as_retriever(
324
+ search_type="similarity", search_kwargs={"k": 15}
325
+ )
326
+
327
+ tfidf_vectorizer = TfidfVectorizer(stop_words="english")
328
+
329
+ if not text_chunks:
330
+ logger.warning("No text chunks to process for TF-IDF.")
331
+ tfidf_retriever = ScikitLearnTfidfRetriever(
332
+ docs=[],
333
+ tfidf_vectorizer=tfidf_vectorizer,
334
+ tfidf_matrix=tfidf_vectorizer.fit_transform([]),
335
+ k=15,
336
+ )
337
+ else:
338
+ tfidf_matrix = tfidf_vectorizer.fit_transform(
339
+ [doc.page_content for doc in text_chunks]
340
+ )
341
+ tfidf_retriever = ScikitLearnTfidfRetriever(
342
+ docs=text_chunks,
343
+ tfidf_vectorizer=tfidf_vectorizer,
344
+ tfidf_matrix=tfidf_matrix,
345
+ k=15,
346
+ )
347
+
348
+ ensemble_retriever = EnsembleRetriever(
349
+ retrievers=[tfidf_retriever, faiss_retriever], weights=[0.5, 0.5]
350
+ )
351
+
352
+ retrieval_chain = create_retrieval_chain(ensemble_retriever, question_answer_chain)
353
+
354
+ logger.info("✅ RAG system initialized successfully.")
355
+
356
+
357
+ def extract_sources_from_docs(docs: List[Document]) -> List[str]:
358
+ """Extracts unique source and page numbers from a list of documents."""
359
+ return sorted(
360
+ list(
361
+ set(
362
+ f"({doc.metadata.get('source', 'N/A')}, Page: {doc.metadata.get('page', 'N/A')})"
363
+ for doc in docs
364
+ )
365
+ )
366
+ )
367
+
368
+ @asynccontextmanager
369
+ async def lifespan(app: FastAPI):
370
+ # Startup event
371
+ # On Hugging Face, copy initial files from the read-only repo folder to persistent storage
372
+ if DATA_FOLDER != REPO_DATA_FOLDER and os.path.exists(REPO_DATA_FOLDER):
373
+ supported_extensions = [".pdf", ".txt", ".md", ".docx"]
374
+ if not any(
375
+ any(f.endswith(ext) for ext in supported_extensions)
376
+ for f in os.listdir(DATA_FOLDER)
377
+ ):
378
+ logger.info(
379
+ f"Copying initial files from '{REPO_DATA_FOLDER}' to '{DATA_FOLDER}'..."
380
+ )
381
+ for filename in os.listdir(REPO_DATA_FOLDER):
382
+ if any(filename.endswith(ext) for ext in supported_extensions):
383
+ src_path = os.path.join(REPO_DATA_FOLDER, filename)
384
+ dest_path = os.path.join(DATA_FOLDER, filename)
385
+ if not os.path.exists(dest_path):
386
+ shutil.copy(src_path, dest_path)
387
+
388
+ await initialize_rag_system()
389
+ yield
390
+ # Shutdown event (if needed in future)
391
+
392
+
393
+ # --- FastAPI Application ---
394
+ app = FastAPI(title="GNDU Assistant (Cached)", version="4.0.0", lifespan=lifespan)
395
+ app.add_middleware(
396
+ CORSMiddleware,
397
+ allow_origins=["*"],
398
+ allow_credentials=True,
399
+ allow_methods=["*"],
400
+ allow_headers=["*"],
401
+ )
402
+
403
+ security = HTTPBasic()
404
+
405
+
406
+ def get_current_username(credentials: HTTPBasicCredentials = Depends(security)):
407
+ correct_username = secrets.compare_digest(
408
+ credentials.username, os.environ.get("ADMIN_USERNAME", "admin")
409
+ )
410
+ correct_password = secrets.compare_digest(
411
+ credentials.password, os.environ.get("ADMIN_PASSWORD", "admin")
412
+ )
413
+ if not (correct_username and correct_password):
414
+ raise HTTPException(
415
+ status_code=401,
416
+ detail="Incorrect email or password",
417
+ headers={"WWW-Authenticate": "Basic"},
418
+ )
419
+ return credentials.username
420
+
421
+
422
+ admin_router = APIRouter()
423
+
424
+
425
+ @admin_router.get("/files")
426
+ async def get_files(_: str = Depends(get_current_username)):
427
+ """Lists all supported files in the data directory."""
428
+ if not os.path.exists(DATA_FOLDER):
429
+ return []
430
+ supported_extensions = [".pdf", ".txt", ".md", ".docx"]
431
+ files = sorted(
432
+ [
433
+ f
434
+ for f in os.listdir(DATA_FOLDER)
435
+ if any(f.endswith(ext) for ext in supported_extensions)
436
+ ]
437
+ )
438
+ return files
439
+
440
+
441
+ @admin_router.post("/files")
442
+ async def upload_file(
443
+ file: UploadFile = File(...), _: str = Depends(get_current_username)
444
+ ):
445
+ """Uploads a new PDF file."""
446
+ file_path = os.path.join(DATA_FOLDER, file.filename)
447
+ if os.path.exists(file_path):
448
+ raise HTTPException(
449
+ status_code=409, detail="File with this name already exists."
450
+ )
451
+
452
+ try:
453
+ with open(file_path, "wb") as buffer:
454
+ shutil.copyfileobj(file.file, buffer)
455
+ finally:
456
+ file.file.close()
457
+
458
+ await initialize_rag_system()
459
+
460
+ return {"filename": file.filename}
461
+
462
+
463
+ @admin_router.delete("/files/{filename}")
464
+ async def delete_file(filename: str, _: str = Depends(get_current_username)):
465
+ """Deletes a PDF file."""
466
+ if ".." in filename:
467
+ raise HTTPException(status_code=400, detail="Invalid filename.")
468
+
469
+ file_path = os.path.join(DATA_FOLDER, filename)
470
+
471
+ if not os.path.exists(file_path):
472
+ raise HTTPException(status_code=404, detail="File not found.")
473
+
474
+ os.remove(file_path)
475
+
476
+ await initialize_rag_system()
477
+
478
+ return {"message": f"File '{filename}' deleted successfully."}
479
+
480
+
481
+ app.include_router(admin_router, prefix="/admin")
482
+
483
+
484
+ async def translate_text(text: str, target_language: str, api_key: str) -> str:
485
+ """Translates text to the target language using Mistral."""
486
+ if target_language.lower() == "english":
487
+ return text
488
+
489
+ client = MistralClient(api_key=api_key)
490
+ prompt = f"Translate the following text to {target_language}. Return ONLY the translated text, no unique prefix/suffix.\n\nText: {text}"
491
+
492
+ try:
493
+ messages = [{"role": "user", "content": prompt}]
494
+ response = await client.chat.complete_async(
495
+ model="mistral-small-latest", messages=messages, temperature=0.0
496
+ )
497
+ return response.choices[0].message.content.strip()
498
+ except Exception as e:
499
+ logger.error(f"Translation failed: {e}")
500
+ return text # Fallback to original text
501
+
502
+ @app.post("/ask", response_model=AnswerResponse)
503
+ async def ask_question(request: QuestionRequest):
504
+ normalized_question = request.question.strip().lower()
505
+ mistral_api_key = os.getenv("MISTRAL_API_KEY")
506
+
507
+ # 1. Translate Query to English if needed
508
+ english_question = request.question
509
+ if request.language.lower() != "english":
510
+ try:
511
+ # Use a simple synchronous call for now if async is tricky with the client version,
512
+ # but let's try to stick to the pattern.
513
+ # IMPORTANT check: MistralClient in this code might be sync or async.
514
+ # The existing MistralLLM uses sync `client.chat.complete`.
515
+ # We will use sync here for safety to match MistralLLM usage, or wrap in run_in_executor if needed.
516
+ # For simplicity in this step, let's use the same sync client pattern as MistralLLM.
517
+
518
+ client = MistralClient(api_key=mistral_api_key)
519
+ trans_prompt = f"Translate the following text to English. Return ONLY the translated text.\n\nText: {request.question}"
520
+ trans_resp = client.chat.complete(
521
+ model="mistral-small-latest", messages=[{"role": "user", "content": trans_prompt}], temperature=0.0
522
+ )
523
+ english_question = trans_resp.choices[0].message.content.strip()
524
+ logger.info(f"Translated '{request.question}' ({request.language}) -> '{english_question}' (English)")
525
+ except Exception as e:
526
+ logger.error(f"Query translation failed: {e}")
527
+
528
+ # 2. Process Request
529
+ answer_text = ""
530
+ sources_list = []
531
+
532
+ if request.mode == "agent":
533
+ try:
534
+ logger.info(f"🚀 Executing Agent Task: {english_question}")
535
+ answer_text = await run_nps_task(english_question)
536
+ sources_list = ["Autonomus Agent"]
537
+ except Exception as e:
538
+ logger.error(f"Error running agent task: {e}")
539
+ raise HTTPException(status_code=500, detail=f"Agent Error: {str(e)}")
540
+
541
+ elif normalized_question in GREETING_RESPONSES:
542
+ answer_text = GREETING_RESPONSES[normalized_question]
543
+ sources_list = ["Conversational Greeting"]
544
+
545
+ else:
546
+ # RAG Logic
547
+ if not retrieval_chain:
548
+ raise HTTPException(
549
+ status_code=503,
550
+ detail="RAG system is not initialized. Please add PDF files to the /data folder and restart.",
551
+ )
552
+
553
+ try:
554
+ # Modify the input to specificy the output language in the prompt if it's RAG
555
+ # Actually, RAG prompt expects 'input' and 'context'.
556
+ # We will rely on post-translation for consistency across Agent/Chat
557
+ result = await retrieval_chain.ainvoke({"input": english_question})
558
+ answer_text = result.get("answer", "No answer could be generated.")
559
+ sources_list = extract_sources_from_docs(result.get("context", []))
560
+
561
+ except Exception as e:
562
+ logger.error(f"Error during RAG chain invocation: {e}")
563
+ raise HTTPException(status_code=500, detail=str(e))
564
+
565
+ # 3. Translate Answer back to Target Language
566
+ if request.language.lower() != "english":
567
+ try:
568
+ client = MistralClient(api_key=mistral_api_key)
569
+ back_prompt = f"Translate the following text to {request.language}. Return ONLY the translated text.\n\nText: {answer_text}"
570
+ back_resp = client.chat.complete(
571
+ model="mistral-small-latest", messages=[{"role": "user", "content": back_prompt}], temperature=0.0
572
+ )
573
+ answer_text = back_resp.choices[0].message.content.strip()
574
+ except Exception as e:
575
+ logger.error(f"Answer translation failed: {e}")
576
+
577
+ return AnswerResponse(answer=answer_text, sources=sources_list)
578
+
579
+
580
+ @app.post("/agent/run")
581
+ async def run_agent(request: AgentRequest):
582
+ """
583
+ Executes a task using the NPS Nanobot Agent.
584
+ """
585
+ try:
586
+ logger.info(f"🚀 Received agent task: {request.task}")
587
+ result = await run_nps_task(request.task)
588
+ return {"result": result}
589
+ except Exception as e:
590
+ logger.error(f"Error running agent task: {e}")
591
+ raise HTTPException(status_code=500, detail=str(e))
592
+
593
+
594
+ app.mount("/", StaticFiles(directory="frontend", html=True), name="static")
595
+
596
+ if __name__ == "__main__":
597
+ uvicorn.run(app, host="0.0.0.0", port=7860)
data/NPS_Rules.txt ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ National Pension System (NPS) Information
3
+
4
+ 1. What is NPS?
5
+ The National Pension System (NPS) is a voluntary, long-term retirement savings scheme designed to enable systematic savings during your working life. It is regulated by the Pension Fund Regulatory and Development Authority (PFRDA).
6
+
7
+ 2. Eligibility
8
+ - Any citizen of India, whether resident or non-resident (NRI).
9
+ - Individuals between 18 – 70 years of age can join.
10
+
11
+ 3. Types of Accounts
12
+ - Tier I Account: This is the non-withdrawable permanent retirement account into which the accumulations are deposited and invested as per the option of the subscriber.
13
+ - Tier II Account: This is a voluntary withdrawable account which is allowed only when there is an active Tier I account in the name of the subscriber. The withdrawals are permitted from this account as per the needs of the subscriber as and when required.
14
+
15
+ 4. Tax Benefits
16
+ - Exclusive Tax Benefit: Investment up to ₹50,000 under Section 80CCD(1B) is available over and above the limit of ₹1.5 Lakhs under Section 80C.
17
+ - Tax benefits are also available on employer contributions (for corporate sector).
18
+
19
+ 5. Exit and Withdrawal
20
+ - Minimum 40% of the corpus must be utilized to purchase an annuity plan at the time of exit (superannuation/60 years).
21
+ - Up to 60% of the corpus can be withdrawn as a tax-free lump sum.
data/Secrets.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ GITHUB TOKEN FOR Victus: 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
frontend/admin.html ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>NPS Admin Panel</title>
8
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
9
+ <style>
10
+ body {
11
+ background-color: #f8f9fa;
12
+ }
13
+
14
+ .container {
15
+ margin-top: 50px;
16
+ }
17
+
18
+ #status {
19
+ margin-top: 15px;
20
+ }
21
+
22
+ .home-link {
23
+ margin-top: 20px;
24
+ }
25
+ </style>
26
+ </head>
27
+
28
+ <body>
29
+ <div class="container">
30
+ <div class="card">
31
+ <div class="card-header">
32
+ <h1>🏛️ NPS Admin Panel</h1>
33
+ </div>
34
+ <div class="card-body">
35
+ <h2>Existing Files</h2>
36
+ <ul id="file-list" class="list-group mb-3"></ul>
37
+ <div id="status"></div>
38
+
39
+ <h2 class="mt-4">Upload New File</h2>
40
+ <form id="upload-form" enctype="multipart/form-data">
41
+ <div class="input-group mb-3">
42
+ <input type="file" class="form-control" id="file-input" name="file" accept=".pdf,.txt,.md,.docx"
43
+ required>
44
+ <button class="btn btn-primary" type="submit" id="upload-button">Upload</button>
45
+ </div>
46
+ </form>
47
+
48
+ <a href="/" class="btn btn-secondary home-link">Back to Chat</a>
49
+ </div>
50
+ </div>
51
+ </div>
52
+
53
+ <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
54
+ <script>
55
+ document.addEventListener('DOMContentLoaded', () => {
56
+ const fileList = document.getElementById('file-list');
57
+ const uploadForm = document.getElementById('upload-form');
58
+ const fileInput = document.getElementById('file-input');
59
+ const status = document.getElementById('status');
60
+ let credentials = null;
61
+
62
+ const getCredentials = () => {
63
+ const username = prompt("Enter username:");
64
+ const password = prompt("Enter password:");
65
+ if (username && password) {
66
+ credentials = btoa(`${username}:${password}`);
67
+ return true;
68
+ }
69
+ return false;
70
+ };
71
+
72
+ const fetchWithAuth = async (url, options = {}) => {
73
+ if (!credentials) {
74
+ if (!getCredentials()) {
75
+ status.innerHTML = '<div class="alert alert-danger">Authentication required.</div>';
76
+ return null;
77
+ }
78
+ }
79
+
80
+ const headers = { ...options.headers, 'Authorization': `Basic ${credentials}` };
81
+ let response = await fetch(url, { ...options, headers });
82
+
83
+ if (response.status === 401) {
84
+ status.innerHTML = '<div class="alert alert-danger">Authentication failed. Please try again.</div>';
85
+ credentials = null; // Reset credentials
86
+ if (getCredentials()) {
87
+ const newHeaders = { ...options.headers, 'Authorization': `Basic ${credentials}` };
88
+ response = await fetch(url, { ...options, headers: newHeaders });
89
+ } else {
90
+ status.innerHTML = '<div class="alert alert-danger">Authentication required.</div>';
91
+ return null;
92
+ }
93
+ }
94
+ return response;
95
+ };
96
+
97
+ const fetchFiles = async () => {
98
+ status.innerHTML = '<div class="alert alert-info">Loading files...</div>';
99
+ try {
100
+ const response = await fetchWithAuth('/admin/files');
101
+ if (!response) return;
102
+
103
+ if (response.ok) {
104
+ const files = await response.json();
105
+ fileList.innerHTML = '';
106
+ if (files.length === 0) {
107
+ fileList.innerHTML = '<li class="list-group-item">No files found.</li>';
108
+ } else {
109
+ files.forEach(file => {
110
+ const li = document.createElement('li');
111
+ li.className = 'list-group-item d-flex justify-content-between align-items-center';
112
+ li.textContent = file;
113
+ const deleteBtn = document.createElement('button');
114
+ deleteBtn.textContent = 'Delete';
115
+ deleteBtn.className = 'btn btn-danger btn-sm';
116
+ deleteBtn.onclick = () => deleteFile(file);
117
+ li.appendChild(deleteBtn);
118
+ fileList.appendChild(li);
119
+ });
120
+ }
121
+ status.innerHTML = '';
122
+ } else {
123
+ status.innerHTML = '<div class="alert alert-danger">Error loading files.</div>';
124
+ }
125
+ } catch (error) {
126
+ status.innerHTML = '<div class="alert alert-danger">Error loading files.</div>';
127
+ console.error('Error fetching files:', error);
128
+ }
129
+ };
130
+
131
+ const deleteFile = async (filename) => {
132
+ if (!confirm(`Are you sure you want to delete ${filename}?`)) return;
133
+ status.innerHTML = `<div class="alert alert-info">Deleting ${filename}...</div>`;
134
+ try {
135
+ const response = await fetchWithAuth(`/admin/files/${filename}`, { method: 'DELETE' });
136
+ if (!response) return;
137
+
138
+ if (response.ok) {
139
+ status.innerHTML = `<div class="alert alert-success">File '${filename}' deleted.</div>`;
140
+ fetchFiles();
141
+ } else {
142
+ const error = await response.json();
143
+ throw new Error(error.detail || 'Failed to delete file.');
144
+ }
145
+ } catch (error) {
146
+ status.innerHTML = `<div class="alert alert-danger">Error deleting file: ${error.message}</div>`;
147
+ console.error('Error deleting file:', error);
148
+ }
149
+ };
150
+
151
+ uploadForm.addEventListener('submit', async (event) => {
152
+ event.preventDefault();
153
+ const file = fileInput.files[0];
154
+ if (!file) return;
155
+
156
+ const formData = new FormData();
157
+ formData.append('file', file);
158
+ status.innerHTML = `<div class="alert alert-info">Uploading ${file.name}...</div>`;
159
+
160
+ try {
161
+ const response = await fetchWithAuth('/admin/files', {
162
+ method: 'POST',
163
+ body: formData,
164
+ });
165
+ if (!response) return;
166
+
167
+ if (response.ok) {
168
+ status.innerHTML = `<div class="alert alert-success">File '${file.name}' uploaded successfully.</div>`;
169
+ fileInput.value = '';
170
+ fetchFiles();
171
+ } else {
172
+ const error = await response.json();
173
+ throw new Error(error.detail || 'Failed to upload file.');
174
+ }
175
+ } catch (error) {
176
+ status.innerHTML = `<div class="alert alert-danger">Error uploading file: ${error.message}</div>`;
177
+ console.error('Error uploading file:', error);
178
+ }
179
+ });
180
+
181
+ fetchFiles();
182
+ });
183
+ </script>
184
+ </body>
185
+
186
+ </html>
frontend/index.html ADDED
@@ -0,0 +1,763 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>NPS Informational Assistant</title>
8
+ <link rel="icon"
9
+ href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🏛️</text></svg>">
10
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js"></script>
11
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"></script>
12
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css">
13
+ <style>
14
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
15
+
16
+ :root {
17
+ --primary: linear-gradient(135deg, #1e3c72, #2a5298);
18
+ --secondary: #e67e22;
19
+ --background: #0f172a;
20
+ --surface: #1e293b;
21
+ --surface-light: #334155;
22
+ --text: #f8fafc;
23
+ --text-muted: #94a3b8;
24
+ --assistant-bg: linear-gradient(135deg, #334155, #475569);
25
+ --user-bg: linear-gradient(135deg, #1e3c72, #2a5298);
26
+ --border: #333344;
27
+ --accent: #60a5fa;
28
+ --success: #10b981;
29
+ --error: #ef4444;
30
+ --glass-bg: rgba(26, 26, 36, 0.7);
31
+ --glass-border: rgba(255, 255, 255, 0.1);
32
+ }
33
+
34
+ * {
35
+ box-sizing: border-box;
36
+ margin: 0;
37
+ padding: 0;
38
+ }
39
+
40
+ html {
41
+ scroll-behavior: smooth;
42
+ }
43
+
44
+ body {
45
+ font-family: 'Inter', sans-serif;
46
+ background: var(--background);
47
+ background-image:
48
+ radial-gradient(circle at 15% 25%, rgba(30, 60, 114, 0.15) 0%, transparent 40%),
49
+ radial-gradient(circle at 85% 75%, rgba(230, 126, 34, 0.08) 0%, transparent 40%);
50
+ color: var(--text);
51
+ min-height: 100vh;
52
+ display: flex;
53
+ justify-content: center;
54
+ align-items: center;
55
+ padding: 10px;
56
+ }
57
+
58
+ .container {
59
+ width: 100%;
60
+ max-width: 900px;
61
+ height: calc(100vh - 40px);
62
+ max-height: 850px;
63
+ background: var(--glass-bg);
64
+ backdrop-filter: blur(25px);
65
+ border: 1px solid var(--glass-border);
66
+ border-radius: 24px;
67
+ box-shadow: 0 32px 64px rgba(0, 0, 0, 0.4);
68
+ display: flex;
69
+ flex-direction: column;
70
+ overflow: hidden;
71
+ position: relative;
72
+ animation: fadeIn 0.5s ease-out;
73
+ }
74
+
75
+ @keyframes fadeIn {
76
+ from {
77
+ opacity: 0;
78
+ transform: scale(0.95);
79
+ }
80
+
81
+ to {
82
+ opacity: 1;
83
+ transform: scale(1);
84
+ }
85
+ }
86
+
87
+ header {
88
+ background: var(--primary);
89
+ padding: 20px 24px;
90
+ text-align: center;
91
+ position: relative;
92
+ overflow: hidden;
93
+ flex-shrink: 0;
94
+ }
95
+
96
+ header h1 {
97
+ font-size: 1.6rem;
98
+ font-weight: 700;
99
+ display: flex;
100
+ align-items: center;
101
+ justify-content: center;
102
+ gap: 12px;
103
+ }
104
+
105
+ .status-indicator {
106
+ display: flex;
107
+ align-items: center;
108
+ justify-content: center;
109
+ gap: 8px;
110
+ margin-top: 8px;
111
+ font-size: 0.85rem;
112
+ opacity: 0.9;
113
+ }
114
+
115
+ .status-dot {
116
+ width: 8px;
117
+ height: 8px;
118
+ border-radius: 50%;
119
+ background: var(--success);
120
+ animation: pulse 2s infinite cubic-bezier(0.4, 0, 0.6, 1);
121
+ }
122
+
123
+ @keyframes pulse {
124
+ 50% {
125
+ opacity: 0.6;
126
+ transform: scale(0.9);
127
+ }
128
+ }
129
+
130
+ /* Toggle Switch */
131
+ .mode-toggle-container {
132
+ display: flex;
133
+ align-items: center;
134
+ justify-content: center;
135
+ gap: 12px;
136
+ margin-top: 16px;
137
+ }
138
+
139
+ .mode-label {
140
+ font-size: 0.9rem;
141
+ color: rgba(255, 255, 255, 0.8);
142
+ font-weight: 500;
143
+ }
144
+
145
+ .language-select {
146
+ background: rgba(255, 255, 255, 0.1);
147
+ border: 1px solid rgba(255, 255, 255, 0.2);
148
+ color: white;
149
+ padding: 5px 10px;
150
+ border-radius: 15px;
151
+ outline: none;
152
+ font-family: 'Inter', sans-serif;
153
+ font-size: 0.9rem;
154
+ cursor: pointer;
155
+ transition: all 0.3s ease;
156
+ }
157
+
158
+ .language-select:hover {
159
+ background: rgba(255, 255, 255, 0.2);
160
+ }
161
+
162
+ .language-select option {
163
+ background: var(--surface);
164
+ color: var(--text);
165
+ }
166
+
167
+ .switch {
168
+ position: relative;
169
+ display: inline-block;
170
+ width: 50px;
171
+ height: 26px;
172
+ }
173
+
174
+ .switch input {
175
+ opacity: 0;
176
+ width: 0;
177
+ height: 0;
178
+ }
179
+
180
+ .slider {
181
+ position: absolute;
182
+ cursor: pointer;
183
+ top: 0;
184
+ left: 0;
185
+ right: 0;
186
+ bottom: 0;
187
+ background-color: rgba(255, 255, 255, 0.2);
188
+ -webkit-transition: .4s;
189
+ transition: .4s;
190
+ border-radius: 34px;
191
+ }
192
+
193
+ .slider:before {
194
+ position: absolute;
195
+ content: "";
196
+ height: 20px;
197
+ width: 20px;
198
+ left: 3px;
199
+ bottom: 3px;
200
+ background-color: white;
201
+ -webkit-transition: .4s;
202
+ transition: .4s;
203
+ border-radius: 50%;
204
+ }
205
+
206
+ input:checked+.slider {
207
+ background-color: var(--accent);
208
+ }
209
+
210
+ input:focus+.slider {
211
+ box-shadow: 0 0 1px var(--accent);
212
+ }
213
+
214
+ input:checked+.slider:before {
215
+ -webkit-transform: translateX(24px);
216
+ -ms-transform: translateX(24px);
217
+ transform: translateX(24px);
218
+ }
219
+
220
+ .chat-container {
221
+ flex: 1;
222
+ display: flex;
223
+ flex-direction: column;
224
+ background: var(--surface);
225
+ position: relative;
226
+ min-height: 0;
227
+ }
228
+
229
+ #chat-box {
230
+ flex: 1;
231
+ overflow-y: auto;
232
+ display: flex;
233
+ flex-direction: column;
234
+ gap: 10px;
235
+ padding: 24px;
236
+ padding-bottom: 120px;
237
+ }
238
+
239
+ #chat-box::-webkit-scrollbar {
240
+ width: 6px;
241
+ }
242
+
243
+ #chat-box::-webkit-scrollbar-track {
244
+ background: transparent;
245
+ }
246
+
247
+ #chat-box::-webkit-scrollbar-thumb {
248
+ background: var(--border);
249
+ border-radius: 3px;
250
+ }
251
+
252
+ #chat-box::-webkit-scrollbar-thumb:hover {
253
+ background: var(--accent);
254
+ }
255
+
256
+ .message-wrapper {
257
+ display: flex;
258
+ gap: 12px;
259
+ max-width: 85%;
260
+ animation: slideIn 0.4s ease-out;
261
+ }
262
+
263
+ @keyframes slideIn {
264
+ from {
265
+ opacity: 0;
266
+ transform: translateY(15px);
267
+ }
268
+
269
+ to {
270
+ opacity: 1;
271
+ transform: translateY(0);
272
+ }
273
+ }
274
+
275
+ .user-message-wrapper {
276
+ align-self: flex-end;
277
+ flex-direction: row-reverse;
278
+ }
279
+
280
+ .assistant-message-wrapper {
281
+ align-self: flex-start;
282
+ }
283
+
284
+ .assistant-avatar {
285
+ width: 40px;
286
+ height: 40px;
287
+ border-radius: 50%;
288
+ background: var(--accent);
289
+ display: flex;
290
+ align-items: center;
291
+ justify-content: center;
292
+ flex-shrink: 0;
293
+ font-size: 1.5rem;
294
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
295
+ }
296
+
297
+ .message {
298
+ padding: 14px 20px;
299
+ border-radius: 20px;
300
+ line-height: 1.6;
301
+ position: relative;
302
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
303
+ }
304
+
305
+ .assistant-message {
306
+ background: var(--assistant-bg);
307
+ border-bottom-left-radius: 8px;
308
+ }
309
+
310
+ .user-message {
311
+ background: var(--user-bg);
312
+ border-bottom-right-radius: 8px;
313
+ color: white;
314
+ }
315
+
316
+ .message-content :is(h1, h2, h3) {
317
+ margin-bottom: 12px;
318
+ color: var(--accent);
319
+ border-bottom: 1px solid var(--border);
320
+ padding-bottom: 8px;
321
+ }
322
+
323
+ .message-content :is(ul, ol) {
324
+ margin-left: 20px;
325
+ margin-bottom: 12px;
326
+ }
327
+
328
+ .message-content li {
329
+ margin-bottom: 6px;
330
+ }
331
+
332
+ .message-content p {
333
+ margin-bottom: 12px;
334
+ }
335
+
336
+ .message-content p:last-child {
337
+ margin-bottom: 0;
338
+ }
339
+
340
+ .message-content code {
341
+ background: rgba(0, 0, 0, 0.3);
342
+ padding: 3px 6px;
343
+ border-radius: 4px;
344
+ font-size: 0.9em;
345
+ font-family: 'Courier New', Courier, monospace;
346
+ }
347
+
348
+ .message-content pre {
349
+ background: rgba(0, 0, 0, 0.4);
350
+ padding: 16px;
351
+ border-radius: 8px;
352
+ overflow-x: auto;
353
+ margin: 12px 0;
354
+ border-left: 4px solid var(--accent);
355
+ }
356
+
357
+ .message-content blockquote {
358
+ border-left: 4px solid var(--accent);
359
+ padding-left: 16px;
360
+ margin: 12px 0;
361
+ font-style: italic;
362
+ opacity: 0.9;
363
+ }
364
+
365
+ .loading-message .message-content {
366
+ display: flex;
367
+ align-items: center;
368
+ gap: 12px;
369
+ }
370
+
371
+ .typing-indicator {
372
+ display: flex;
373
+ gap: 5px;
374
+ }
375
+
376
+ .typing-dot {
377
+ width: 8px;
378
+ height: 8px;
379
+ border-radius: 50%;
380
+ background: var(--accent);
381
+ animation: typing 1.4s infinite ease-in-out;
382
+ }
383
+
384
+ .typing-dot:nth-child(2) {
385
+ animation-delay: 0.2s;
386
+ }
387
+
388
+ .typing-dot:nth-child(3) {
389
+ animation-delay: 0.4s;
390
+ }
391
+
392
+ @keyframes typing {
393
+
394
+ 0%,
395
+ 80%,
396
+ 100% {
397
+ transform: scale(0);
398
+ }
399
+
400
+ 40% {
401
+ transform: scale(1.0);
402
+ }
403
+ }
404
+
405
+ .input-section {
406
+ position: absolute;
407
+ bottom: 0;
408
+ left: 0;
409
+ right: 0;
410
+ background: linear-gradient(to top, rgba(26, 26, 36, 1), rgba(26, 26, 36, 0.8));
411
+ padding: 24px;
412
+ padding-top: 16px;
413
+ border-top: 1px solid var(--border);
414
+ backdrop-filter: blur(10px);
415
+ }
416
+
417
+ .input-container {
418
+ background: var(--surface-light);
419
+ border-radius: 24px;
420
+ padding: 6px;
421
+ border: 1px solid var(--border);
422
+ transition: all 0.3s ease;
423
+ }
424
+
425
+ .input-container:focus-within {
426
+ border-color: var(--accent);
427
+ box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.25);
428
+ }
429
+
430
+ .input-form {
431
+ display: flex;
432
+ align-items: center;
433
+ gap: 10px;
434
+ }
435
+
436
+ #user-input {
437
+ flex: 1;
438
+ background: transparent;
439
+ border: none;
440
+ padding: 12px 16px;
441
+ color: var(--text);
442
+ font-size: 1rem;
443
+ outline: none;
444
+ resize: none;
445
+ min-height: 24px;
446
+ max-height: 120px;
447
+ }
448
+
449
+ #user-input::placeholder {
450
+ color: var(--text-muted);
451
+ }
452
+
453
+ .send-button {
454
+ background: var(--primary);
455
+ border: none;
456
+ border-radius: 18px;
457
+ width: 48px;
458
+ height: 48px;
459
+ color: white;
460
+ cursor: pointer;
461
+ transition: all 0.3s ease;
462
+ display: flex;
463
+ align-items: center;
464
+ justify-content: center;
465
+ flex-shrink: 0;
466
+ }
467
+
468
+ .send-button:hover:not(:disabled) {
469
+ transform: scale(1.05);
470
+ box-shadow: 0 8px 25px rgba(0, 90, 156, 0.4);
471
+ }
472
+
473
+ .send-button:disabled {
474
+ opacity: 0.6;
475
+ cursor: not-allowed;
476
+ }
477
+
478
+ .send-icon {
479
+ width: 20px;
480
+ height: 20px;
481
+ transition: transform 0.3s ease;
482
+ }
483
+
484
+ .send-button:hover:not(:disabled) .send-icon {
485
+ transform: rotate(45deg) scale(1.1);
486
+ }
487
+
488
+ footer {
489
+ padding: 12px 24px;
490
+ text-align: center;
491
+ font-size: 0.8rem;
492
+ color: var(--text-muted);
493
+ border-top: 1px solid var(--border);
494
+ background: var(--surface);
495
+ flex-shrink: 0;
496
+ }
497
+
498
+ .error-message {
499
+ background: linear-gradient(135deg, #b91c1c, #ef4444);
500
+ border-color: #fca5a5;
501
+ }
502
+
503
+ @media (max-width: 768px) {
504
+ body {
505
+ padding: 0;
506
+ }
507
+
508
+ .container {
509
+ height: 100vh;
510
+ max-height: 100vh;
511
+ border-radius: 0;
512
+ box-shadow: none;
513
+ border: none;
514
+ }
515
+
516
+ header h1 {
517
+ font-size: 1.3rem;
518
+ }
519
+
520
+ #chat-box {
521
+ padding: 16px;
522
+ padding-bottom: 120px;
523
+ }
524
+
525
+ .message-wrapper {
526
+ max-width: 90%;
527
+ }
528
+
529
+ .assistant-avatar {
530
+ width: 32px;
531
+ height: 32px;
532
+ font-size: 1.2rem;
533
+ }
534
+
535
+ .message {
536
+ padding: 12px 16px;
537
+ }
538
+
539
+ .input-section {
540
+ padding: 12px;
541
+ }
542
+
543
+ .input-container {
544
+ border-radius: 20px;
545
+ }
546
+
547
+ .send-button {
548
+ width: 44px;
549
+ height: 44px;
550
+ }
551
+ }
552
+ </style>
553
+ </head>
554
+
555
+ <body>
556
+ <div class="container">
557
+ <header>
558
+ <h1>🏛️ NPS Informational Assistant</h1>
559
+ <div class="status-indicator">
560
+ <div class="status-dot"></div>
561
+ <span>Online and ready to help</span>
562
+ </div>
563
+ <div class="mode-toggle-container">
564
+ <select id="language-select" class="language-select">
565
+ <option value="English">English</option>
566
+ <option value="Hindi">Hindi (हिंदी)</option>
567
+ <option value="Bengali">Bengali (বাংলা)</option>
568
+ <option value="Telugu">Telugu (తెలుగు)</option>
569
+ <option value="Marathi">Marathi (मराठी)</option>
570
+ <option value="Tamil">Tamil (தமிழ்)</option>
571
+ <option value="Urdu">Urdu (اردو)</option>
572
+ <option value="Gujarati">Gujarati (ગુજરાતી)</option>
573
+ <option value="Kannada">Kannada (ಕನ್ನಡ)</option>
574
+ <option value="Malayalam">Malayalam (മലയാളം)</option>
575
+ <option value="Odia">Odia (ଓଡ଼ିଆ)</option>
576
+ <option value="Punjabi">Punjabi (ਪੰਜਾਬੀ)</option>
577
+ <option value="Assamese">Assamese (অসমীয়া)</option>
578
+ </select>
579
+ <span class="mode-label">Chat</span>
580
+ <label class="switch">
581
+ <input type="checkbox" id="mode-toggle">
582
+ <span class="slider"></span>
583
+ </label>
584
+ <span class="mode-label">Agent</span>
585
+ </div>
586
+ </header>
587
+
588
+ <main class="chat-container">
589
+ <div id="chat-box">
590
+ </div>
591
+
592
+ <div class="input-section">
593
+ <div class="input-container">
594
+ <form class="input-form" id="question-form">
595
+ <textarea id="user-input" placeholder="Ask anything about NPS..." rows="1" autocomplete="off"
596
+ required></textarea>
597
+ <button type="submit" class="send-button" id="send-btn" aria-label="Send message">
598
+ <svg class="send-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
599
+ stroke-linecap="round" stroke-linejoin="round">
600
+ <path d="M22 2L11 13" />
601
+ <path d="M22 2L15 22L11 13L2 9L22 2Z" />
602
+ </svg>
603
+ </button>
604
+ </form>
605
+ </div>
606
+ </div>
607
+ </main>
608
+
609
+ <footer>
610
+ <p>Powered by AI | <a href="/admin.html" style="color: var(--text-muted);">Admin</a></p>
611
+ </footer>
612
+ </div>
613
+
614
+ <script>
615
+
616
+
617
+ document.addEventListener('DOMContentLoaded', () => {
618
+ // Configuration for marked library
619
+ if (typeof marked !== 'undefined') {
620
+ marked.setOptions({
621
+ breaks: true,
622
+ gfm: true,
623
+ highlight: function (code, lang) {
624
+ if (typeof Prism !== 'undefined' && Prism.languages[lang]) {
625
+ return Prism.highlight(code, Prism.languages[lang], lang);
626
+ }
627
+ return code;
628
+ }
629
+ });
630
+ }
631
+
632
+ const API_BASE_URL = ''; // Keep empty to use the same origin
633
+ const chatBox = document.getElementById('chat-box');
634
+ const questionForm = document.getElementById('question-form');
635
+ const userInput = document.getElementById('user-input');
636
+ const sendBtn = document.getElementById('send-btn');
637
+ const modeToggle = document.getElementById('mode-toggle');
638
+ const languageSelect = document.getElementById('language-select');
639
+
640
+ userInput.addEventListener('input', function () {
641
+ this.style.height = 'auto';
642
+ this.style.height = Math.min(this.scrollHeight, 120) + 'px';
643
+ });
644
+
645
+ userInput.addEventListener('keydown', function (e) {
646
+ if (e.key === 'Enter' && !e.shiftKey) {
647
+ e.preventDefault();
648
+ sendBtn.click();
649
+ }
650
+ });
651
+
652
+ const addMessage = (content, sender, isError = false) => {
653
+ const wrapper = document.createElement('div');
654
+ wrapper.classList.add('message-wrapper', `${sender}-message-wrapper`);
655
+
656
+ let messageHTML = '';
657
+
658
+ if (sender === 'assistant') {
659
+ messageHTML += `<div class="assistant-avatar">🏛️</div>`;
660
+ }
661
+
662
+ const messageDiv = document.createElement('div');
663
+ messageDiv.classList.add('message', `${sender}-message`);
664
+ if (isError) {
665
+ messageDiv.classList.add('error-message');
666
+ }
667
+
668
+ const contentDiv = document.createElement('div');
669
+ contentDiv.classList.add('message-content');
670
+ // Safely handle marked parsing
671
+ if (sender === 'assistant' && typeof marked !== 'undefined') {
672
+ contentDiv.innerHTML = marked.parse(content);
673
+ } else {
674
+ contentDiv.textContent = content;
675
+ }
676
+
677
+ messageDiv.appendChild(contentDiv);
678
+
679
+ wrapper.innerHTML = messageHTML;
680
+ wrapper.appendChild(messageDiv);
681
+
682
+ chatBox.appendChild(wrapper);
683
+ chatBox.scrollTop = chatBox.scrollHeight;
684
+ };
685
+
686
+ const showLoadingIndicator = () => {
687
+ const wrapper = document.createElement('div');
688
+ wrapper.classList.add('message-wrapper', 'assistant-message-wrapper');
689
+ wrapper.id = 'loading-indicator';
690
+
691
+ wrapper.innerHTML = `
692
+ <div class="assistant-avatar">🏛️</div>
693
+ <div class="message assistant-message loading-message">
694
+ <div class="message-content">
695
+ <div class="typing-indicator">
696
+ <div class="typing-dot"></div>
697
+ <div class="typing-dot"></div>
698
+ <div class="typing-dot"></div>
699
+ </div>
700
+ </div>
701
+ </div>
702
+ `;
703
+ chatBox.appendChild(wrapper);
704
+ chatBox.scrollTop = chatBox.scrollHeight;
705
+ };
706
+
707
+ const handleQuestionSubmit = async (event) => {
708
+ event.preventDefault();
709
+ const question = userInput.value.trim();
710
+ if (!question) return;
711
+
712
+ const mode = modeToggle.checked ? 'agent' : 'chat';
713
+ const language = languageSelect.value;
714
+
715
+ addMessage(question, 'user');
716
+ userInput.value = '';
717
+ userInput.style.height = 'auto';
718
+ sendBtn.disabled = true;
719
+
720
+ showLoadingIndicator();
721
+ const loadingIndicator = document.getElementById('loading-indicator');
722
+
723
+ try {
724
+ const response = await fetch(`${API_BASE_URL}/ask`, {
725
+ method: 'POST',
726
+ headers: { 'Content-Type': 'application/json' },
727
+ body: JSON.stringify({ question: question, mode: mode, language: language }),
728
+ });
729
+
730
+ if (!response.ok) {
731
+ const errorData = await response.json();
732
+ throw new Error(errorData.detail || 'An unknown server error occurred.');
733
+ }
734
+
735
+ const data = await response.json();
736
+ addMessage(data.answer, 'assistant');
737
+
738
+ } catch (error) {
739
+ console.error('Error:', error);
740
+ const errorMessage = `**Connection Error**\n\nI'm having trouble connecting to my knowledge base. Please try again in a moment.\n\n*(Error: ${error.message})*`;
741
+ addMessage(errorMessage, 'assistant', true);
742
+ } finally {
743
+ if (loadingIndicator) {
744
+ loadingIndicator.remove();
745
+ }
746
+ sendBtn.disabled = false;
747
+ userInput.focus();
748
+ }
749
+ };
750
+
751
+ const displayWelcomeMessage = () => {
752
+ const welcomeText = `👋 Hello! I'm your NPS Assistant. I can help with questions about:\n\n- **Registration & Account Opening**\n- **Contributions & Tax Benefits**\n- **Investment Choices & Asset Classes**\n- **Exit, Withdrawal & Annuity**\n\n## 📋 Chat Guidelines\n\n✅ **Do's:**\n- Ask specifically about NPS rules and regulations\n- Inquire about processes for different sectors (Govt, Corporate, All Citizens)\n\n❌ **Don'ts:**\n- Share personal financial details (PRAN, Passwords)\n\nHow can I assist you with your National Pension System queries today?`;
753
+ addMessage(welcomeText, 'assistant');
754
+ };
755
+
756
+ questionForm.addEventListener('submit', handleQuestionSubmit);
757
+ displayWelcomeMessage();
758
+ userInput.focus();
759
+ });
760
+ </script>
761
+ </body>
762
+
763
+ </html>
mistral.py ADDED
@@ -0,0 +1,517 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os, logging
2
+ import glob
3
+ import logging
4
+ import re
5
+ import hashlib
6
+ import numpy as np
7
+ import asyncio
8
+ from typing import List, Optional, Any
9
+ import shutil
10
+ import secrets
11
+
12
+ # --- Pydantic & FastAPI ---
13
+ from pydantic import BaseModel, Field
14
+ import uvicorn
15
+ from fastapi import FastAPI, HTTPException, APIRouter, UploadFile, File, Depends
16
+ from fastapi.middleware.cors import CORSMiddleware
17
+ from fastapi.staticfiles import StaticFiles
18
+ from fastapi.security import HTTPBasic, HTTPBasicCredentials
19
+
20
+ # --- LangChain & RAG Core ---
21
+ from PyPDF2 import PdfReader
22
+
23
+ try:
24
+ from docx import Document as DocxDocument
25
+ except ImportError:
26
+ DocxDocument = None
27
+ from langchain.text_splitter import RecursiveCharacterTextSplitter
28
+ from langchain_community.vectorstores import FAISS
29
+ from langchain.retrievers import EnsembleRetriever
30
+ from langchain.chains.combine_documents import (
31
+ create_stuff_documents_chain,
32
+ ) # <-- DEPRECATION FIX
33
+ from langchain.chains import create_retrieval_chain
34
+ from langchain_huggingface import HuggingFaceEmbeddings
35
+ from langchain.llms.base import LLM
36
+ from langchain_core.callbacks.manager import CallbackManagerForLLMRun
37
+ from langchain.docstore.document import Document
38
+ from langchain_core.retrievers import BaseRetriever
39
+ from langchain.prompts import PromptTemplate
40
+
41
+ # --- Scikit-Learn for TF-IDF ---
42
+ from sklearn.feature_extraction.text import TfidfVectorizer
43
+ from sklearn.metrics.pairwise import cosine_similarity
44
+
45
+ # --- TruLens for Evaluation ---
46
+ # from trulens_eval import Feedback, Tru, TruChain
47
+ # from trulens_eval.feedback.provider import LiteLLM
48
+
49
+ # tru = Tru()
50
+
51
+ # --- Mistral AI ---
52
+ from mistralai import Mistral as MistralClient
53
+ from dotenv import load_dotenv
54
+
55
+ load_dotenv()
56
+
57
+ # --- Global Variables & Configuration ---
58
+ logging.basicConfig(
59
+ level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
60
+ )
61
+ logger = logging.getLogger(__name__)
62
+
63
+ # Define the original, read-only data folder from the repo
64
+ REPO_DATA_FOLDER = "data"
65
+
66
+ # Use persistent storage if available (on Hugging Face Spaces)
67
+ if os.path.exists("/data"):
68
+ logger.info("Persistent storage '/data' found. Using it for PDFs and cache.")
69
+ DATA_FOLDER = "/data/pdf_storage"
70
+ CACHE_DIR = "/data/cache"
71
+ else:
72
+ logger.info(
73
+ "No persistent storage '/data' found. Using local 'data' folder and '/tmp/cache'."
74
+ )
75
+ DATA_FOLDER = REPO_DATA_FOLDER
76
+ CACHE_DIR = "/tmp/cache"
77
+
78
+ os.makedirs(DATA_FOLDER, exist_ok=True)
79
+ os.makedirs(CACHE_DIR, exist_ok=True)
80
+ retrieval_chain = None
81
+
82
+ HF_CACHE = "/tmp/hf"
83
+ os.environ["HF_HOME"] = HF_CACHE
84
+ os.environ["HF_HOME"] = HF_CACHE
85
+ os.environ["HF_DATASETS_CACHE"] = HF_CACHE
86
+ os.environ["HF_MODULES_CACHE"] = HF_CACHE
87
+
88
+ retrieval_chain = None
89
+
90
+ db_path = "/data/default.sqlite" if os.path.exists("/data") else "default.sqlite"
91
+
92
+ # ✅ Only run the dashboard locally (not in Hugging Face Spaces)
93
+ if os.environ.get("SPACE_ID") is None:
94
+ # tru.run_dashboard()
95
+ pass
96
+
97
+
98
+ # --- Pydantic Models ---
99
+ class QuestionRequest(BaseModel):
100
+ question: str = Field(..., min_length=1)
101
+
102
+
103
+ class AnswerResponse(BaseModel):
104
+ answer: str
105
+ sources: List[str]
106
+
107
+
108
+ # --- Greeting Handler ---
109
+ GREETING_RESPONSES = {
110
+ "hi": "Hello! How can I assist you?",
111
+ "hello": "Hello! How can I help you today?",
112
+ "bye": "Bye-bye! See you soon.",
113
+ "ty": "Your Welcome!",
114
+ }
115
+
116
+ # --- Enhanced Prompt ---
117
+ UNIVERSITY_ASSISTANT_PROMPT = PromptTemplate.from_template(
118
+ """
119
+ You are a meticulous Guru Nanak Dev University Academic Assistant.
120
+ Your task is to provide accurate, specific, and concise answers based ONLY on the provided Context.
121
+ **Instructions:**
122
+ 1. **Analyze the Context:** Carefully read the user's question and the provided context to synthesize an answer.
123
+ 2. **Extract Specifics:** If asked for a fee, number, or date, provide the EXACT information.
124
+ 3. **Cite Sources:** After your answer, cite the source document and page number: `(Source: Prospectus.pdf, Page: 15)`.
125
+ 4. **Handle Missing Information:** If the info is not in the context, state: "Based on the provided documents, I could not find specific information about this topic."
126
+ ---
127
+ **Context:**
128
+ {context}
129
+ ---
130
+ **Question:**
131
+ {input}
132
+ **Answer (with citations):**
133
+ """
134
+ )
135
+
136
+
137
+ # --- Custom Scikit-Learn TF-IDF Retriever ---
138
+ class ScikitLearnTfidfRetriever(BaseRetriever):
139
+ docs: List[Document]
140
+ tfidf_vectorizer: TfidfVectorizer
141
+ tfidf_matrix: Any
142
+ k: int = 15
143
+
144
+ class Config:
145
+ arbitrary_types_allowed = True
146
+
147
+ def _get_relevant_documents(self, query: str) -> List[Document]:
148
+ query_vector = self.tfidf_vectorizer.transform([query])
149
+ similarity_scores = cosine_similarity(query_vector, self.tfidf_matrix).flatten()
150
+ top_k_indices = np.argsort(similarity_scores)[-self.k :][::-1]
151
+ return [self.docs[i] for i in top_k_indices]
152
+
153
+
154
+ # --- MistralLLM Class ---
155
+ class MistralLLM(LLM):
156
+ client: Any = None
157
+ model: str = "mistral-small-latest"
158
+ temperature: float = 0.0
159
+ api_key: str = ""
160
+
161
+ def __init__(
162
+ self,
163
+ api_key: str,
164
+ model: str = "mistral-small-latest",
165
+ temperature: float = 0.0,
166
+ ):
167
+ super().__init__()
168
+ self.api_key = api_key
169
+ self.model = model
170
+ self.temperature = temperature
171
+ self.client = MistralClient(api_key=self.api_key)
172
+
173
+ @property
174
+ def _llm_type(self) -> str:
175
+ return "mistral"
176
+
177
+ def _call(
178
+ self,
179
+ prompt: str,
180
+ stop: Optional[List[str]] = None,
181
+ run_manager: Optional[CallbackManagerForLLMRun] = None,
182
+ **kwargs: Any,
183
+ ) -> str:
184
+ messages = [{"role": "user", "content": prompt}]
185
+ response = self.client.chat.complete(
186
+ model=self.model, messages=messages, temperature=self.temperature
187
+ )
188
+ return response.choices[0].message.content.strip()
189
+
190
+
191
+ # --- Caching and Data Processing Functions ---
192
+ def get_data_hash(folder_path=DATA_FOLDER) -> str:
193
+ """Generates a hash based on the filenames and modification times of supported files."""
194
+ hasher = hashlib.md5()
195
+ if not os.path.exists(folder_path):
196
+ return "no_data"
197
+
198
+ supported_extensions = [".pdf", ".txt", ".md", ".docx"]
199
+ files_to_hash = []
200
+ for ext in supported_extensions:
201
+ files_to_hash.extend(glob.glob(os.path.join(folder_path, f"*{ext}")))
202
+
203
+ for file_path in sorted(files_to_hash):
204
+ try:
205
+ stat = os.stat(file_path)
206
+ file_info = f"{os.path.basename(file_path)}-{stat.st_mtime}-{stat.st_size}"
207
+ hasher.update(file_info.encode("utf-8"))
208
+ except FileNotFoundError:
209
+ continue # File might have been deleted during the process
210
+
211
+ return hasher.hexdigest()
212
+
213
+
214
+ def get_documents_from_files(folder_path=DATA_FOLDER) -> List[Document]:
215
+ docs = []
216
+ supported_extensions = [".pdf", ".txt", ".md", ".docx"]
217
+
218
+ for file_path in glob.glob(os.path.join(folder_path, "*")):
219
+ file_name = os.path.basename(file_path)
220
+ file_ext = os.path.splitext(file_name)[1].lower()
221
+
222
+ if file_ext not in supported_extensions:
223
+ continue
224
+
225
+ if file_ext == ".pdf":
226
+ pdf_reader = PdfReader(file_path)
227
+ for i, page in enumerate(pdf_reader.pages):
228
+ text = page.extract_text() or ""
229
+ docs.append(
230
+ Document(
231
+ page_content=text, metadata={"source": file_name, "page": i + 1}
232
+ )
233
+ )
234
+ elif file_ext in [".txt", ".md"]:
235
+ with open(file_path, "r", encoding="utf-8") as f:
236
+ text = f.read()
237
+ docs.append(
238
+ Document(
239
+ page_content=text, metadata={"source": file_name, "page": 1}
240
+ )
241
+ )
242
+ elif file_ext == ".docx":
243
+ if DocxDocument is None:
244
+ logger.warning(
245
+ "Skipping .docx file because 'python-docx' is not installed. Please install it with 'pip install python-docx'."
246
+ )
247
+ continue
248
+ try:
249
+ doc = DocxDocument(file_path)
250
+ text = "\n".join([para.text for para in doc.paragraphs])
251
+ docs.append(
252
+ Document(
253
+ page_content=text, metadata={"source": file_name, "page": 1}
254
+ )
255
+ )
256
+ except Exception as e:
257
+ logger.error(f"Error processing .docx file {file_name}: {e}")
258
+
259
+ return docs
260
+
261
+
262
+ async def initialize_rag_system():
263
+ global retrieval_chain
264
+ logger.info("🚀 Initializing RAG system...")
265
+ os.makedirs(CACHE_DIR, exist_ok=True)
266
+
267
+ data_hash = get_data_hash()
268
+ cache_path = os.path.join(CACHE_DIR, f"vectorstore_{data_hash}.faiss")
269
+
270
+ embeddings = HuggingFaceEmbeddings(
271
+ model_name="BAAI/bge-base-en-v1.5",
272
+ model_kwargs={"device": "cpu"},
273
+ encode_kwargs={"normalize_embeddings": True},
274
+ )
275
+
276
+ if os.path.exists(cache_path):
277
+ logger.info(f"✅ Loading vector store from cache: {cache_path}")
278
+ vectorstore = FAISS.load_local(
279
+ cache_path, embeddings, allow_dangerous_deserialization=True
280
+ )
281
+ documents = get_documents_from_files()
282
+ text_chunks = RecursiveCharacterTextSplitter(
283
+ chunk_size=1000, chunk_overlap=150
284
+ ).split_documents(documents)
285
+ else:
286
+ logger.warning(
287
+ "No cache found or data has changed. Building new vector store..."
288
+ )
289
+ documents = get_documents_from_files()
290
+ if not documents:
291
+ logger.warning(
292
+ "No documents found in /data folder. RAG system will be inactive."
293
+ )
294
+ retrieval_chain = None # Ensure chain is None
295
+ return
296
+
297
+ text_chunks = RecursiveCharacterTextSplitter(
298
+ chunk_size=1000, chunk_overlap=150
299
+ ).split_documents(documents)
300
+
301
+ logger.info(
302
+ f"Embedding {len(text_chunks)} chunks... (This will be slow on the first run)"
303
+ )
304
+ vectorstore = FAISS.from_documents(documents=text_chunks, embedding=embeddings)
305
+
306
+ logger.info(f"💾 Saving new vector store to cache: {cache_path}")
307
+ vectorstore.save_local(cache_path)
308
+
309
+ # --- Chain Setup ---
310
+ mistral_api_key = os.getenv("MISTRAL_API_KEY")
311
+ if not mistral_api_key:
312
+ raise ValueError("MISTRAL_API_KEY not found")
313
+
314
+ llm = MistralLLM(api_key=mistral_api_key)
315
+
316
+ # --- DEPRECATION FIX: Use create_stuff_documents_chain ---
317
+ question_answer_chain = create_stuff_documents_chain(
318
+ llm, UNIVERSITY_ASSISTANT_PROMPT
319
+ )
320
+
321
+ faiss_retriever = vectorstore.as_retriever(
322
+ search_type="similarity", search_kwargs={"k": 15}
323
+ )
324
+
325
+ tfidf_vectorizer = TfidfVectorizer(stop_words="english")
326
+
327
+ # Ensure text_chunks is not empty before fitting
328
+ if not text_chunks:
329
+ logger.warning("No text chunks to process for TF-IDF.")
330
+ # Handle case with no documents gracefully
331
+ tfidf_retriever = ScikitLearnTfidfRetriever(
332
+ docs=[],
333
+ tfidf_vectorizer=tfidf_vectorizer, # an empty vectorizer
334
+ tfidf_matrix=tfidf_vectorizer.fit_transform([]), # an empty matrix
335
+ k=15,
336
+ )
337
+ else:
338
+ tfidf_matrix = tfidf_vectorizer.fit_transform(
339
+ [doc.page_content for doc in text_chunks]
340
+ )
341
+ tfidf_retriever = ScikitLearnTfidfRetriever(
342
+ docs=text_chunks,
343
+ tfidf_vectorizer=tfidf_vectorizer,
344
+ tfidf_matrix=tfidf_matrix,
345
+ k=15,
346
+ )
347
+
348
+ ensemble_retriever = EnsembleRetriever(
349
+ retrievers=[tfidf_retriever, faiss_retriever], weights=[0.5, 0.5]
350
+ )
351
+
352
+ # Create the final retrieval chain that combines the retriever and the QA chain
353
+ retrieval_chain = create_retrieval_chain(ensemble_retriever, question_answer_chain)
354
+
355
+ logger.info("✅ RAG system initialized successfully.")
356
+
357
+
358
+ def extract_sources_from_docs(docs: List[Document]) -> List[str]:
359
+ """Extracts unique source and page numbers from a list of documents."""
360
+ return sorted(
361
+ list(
362
+ set(
363
+ f"({doc.metadata.get('source', 'N/A')}, Page: {doc.metadata.get('page', 'N/A')})"
364
+ for doc in docs
365
+ )
366
+ )
367
+ )
368
+
369
+
370
+ # --- FastAPI Application ---
371
+ app = FastAPI(title="GNDU Assistant (Cached)", version="4.0.0")
372
+ app.add_middleware(
373
+ CORSMiddleware,
374
+ allow_origins=["*"],
375
+ allow_credentials=True,
376
+ allow_methods=["*"],
377
+ allow_headers=["*"],
378
+ )
379
+
380
+ security = HTTPBasic()
381
+
382
+
383
+ def get_current_username(credentials: HTTPBasicCredentials = Depends(security)):
384
+ correct_username = secrets.compare_digest(
385
+ credentials.username, os.environ.get("ADMIN_USERNAME", "admin")
386
+ )
387
+ correct_password = secrets.compare_digest(
388
+ credentials.password, os.environ.get("ADMIN_PASSWORD", "password")
389
+ )
390
+ if not (correct_username and correct_password):
391
+ raise HTTPException(
392
+ status_code=401,
393
+ detail="Incorrect email or password",
394
+ headers={"WWW-Authenticate": "Basic"},
395
+ )
396
+ return credentials.username
397
+
398
+
399
+ @app.on_event("startup")
400
+ async def startup_event():
401
+ # On Hugging Face, copy initial files from the read-only repo folder to persistent storage
402
+ if DATA_FOLDER != REPO_DATA_FOLDER and os.path.exists(REPO_DATA_FOLDER):
403
+ supported_extensions = [".pdf", ".txt", ".md", ".docx"]
404
+ # Check if the destination is empty of supported files before copying
405
+ if not any(
406
+ any(f.endswith(ext) for ext in supported_extensions)
407
+ for f in os.listdir(DATA_FOLDER)
408
+ ):
409
+ logger.info(
410
+ f"Copying initial files from '{REPO_DATA_FOLDER}' to '{DATA_FOLDER}'..."
411
+ )
412
+ for filename in os.listdir(REPO_DATA_FOLDER):
413
+ if any(filename.endswith(ext) for ext in supported_extensions):
414
+ src_path = os.path.join(REPO_DATA_FOLDER, filename)
415
+ dest_path = os.path.join(DATA_FOLDER, filename)
416
+ if not os.path.exists(dest_path):
417
+ shutil.copy(src_path, dest_path)
418
+
419
+ await initialize_rag_system()
420
+
421
+
422
+ admin_router = APIRouter()
423
+
424
+
425
+ @admin_router.get("/files")
426
+ async def get_files(_: str = Depends(get_current_username)):
427
+ """Lists all supported files in the data directory."""
428
+ if not os.path.exists(DATA_FOLDER):
429
+ return []
430
+ supported_extensions = [".pdf", ".txt", ".md", ".docx"]
431
+ files = sorted(
432
+ [
433
+ f
434
+ for f in os.listdir(DATA_FOLDER)
435
+ if any(f.endswith(ext) for ext in supported_extensions)
436
+ ]
437
+ )
438
+ return files
439
+
440
+
441
+ @admin_router.post("/files")
442
+ async def upload_file(
443
+ file: UploadFile = File(...), _: str = Depends(get_current_username)
444
+ ):
445
+ """Uploads a new PDF file."""
446
+ file_path = os.path.join(DATA_FOLDER, file.filename)
447
+ if os.path.exists(file_path):
448
+ raise HTTPException(
449
+ status_code=409, detail="File with this name already exists."
450
+ )
451
+
452
+ try:
453
+ with open(file_path, "wb") as buffer:
454
+ shutil.copyfileobj(file.file, buffer)
455
+ finally:
456
+ file.file.close()
457
+
458
+ await initialize_rag_system()
459
+
460
+ return {"filename": file.filename}
461
+
462
+
463
+ @admin_router.delete("/files/{filename}")
464
+ async def delete_file(filename: str, _: str = Depends(get_current_username)):
465
+ """Deletes a PDF file."""
466
+ # Basic security check
467
+ if ".." in filename:
468
+ raise HTTPException(status_code=400, detail="Invalid filename.")
469
+
470
+ file_path = os.path.join(DATA_FOLDER, filename)
471
+
472
+ if not os.path.exists(file_path):
473
+ raise HTTPException(status_code=404, detail="File not found.")
474
+
475
+ os.remove(file_path)
476
+
477
+ await initialize_rag_system()
478
+
479
+ return {"message": f"File '{filename}' deleted successfully."}
480
+
481
+
482
+ app.include_router(admin_router, prefix="/admin")
483
+
484
+
485
+ @app.post("/ask", response_model=AnswerResponse)
486
+ async def ask_question(request: QuestionRequest):
487
+ normalized_question = request.question.strip().lower()
488
+ if normalized_question in GREETING_RESPONSES:
489
+ return AnswerResponse(
490
+ answer=GREETING_RESPONSES[normalized_question],
491
+ sources=["Conversational Greeting"],
492
+ )
493
+
494
+ if not retrieval_chain:
495
+ raise HTTPException(
496
+ status_code=503,
497
+ detail="RAG system is not initialized. Please add PDF files to the /data folder and restart.",
498
+ )
499
+
500
+ try:
501
+ # The new chain structure returns a dictionary with 'answer' and 'context'
502
+ result = await retrieval_chain.ainvoke({"input": request.question})
503
+
504
+ answer = result.get("answer", "No answer could be generated.")
505
+ sources = extract_sources_from_docs(result.get("context", []))
506
+
507
+ return AnswerResponse(answer=answer, sources=sources)
508
+
509
+ except Exception as e:
510
+ logger.error(f"Error during RAG chain invocation: {e}")
511
+ raise HTTPException(status_code=500, detail=str(e))
512
+
513
+
514
+ app.mount("/", StaticFiles(directory="frontend", html=True), name="static")
515
+
516
+ if __name__ == "__main__":
517
+ uvicorn.run(app, host="0.0.0.0", port=7860)
nps_agent.py ADDED
@@ -0,0 +1,473 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import os
3
+ import json
4
+ import logging
5
+ import sys
6
+ from typing import Any, Optional
7
+ from dotenv import load_dotenv
8
+ from pathlib import Path
9
+
10
+ # Ensure we can import nanobot if installed in venv
11
+ from playwright.async_api import async_playwright, BrowserContext, Page, Browser
12
+
13
+ try:
14
+ from nanobot.agent.loop import AgentLoop
15
+ from nanobot.agent.tools.base import Tool
16
+ from nanobot.bus.queue import MessageBus
17
+ from nanobot.providers.litellm_provider import LiteLLMProvider
18
+ from nanobot.config.schema import ExecToolConfig
19
+ except ImportError:
20
+ print("Error: 'nanobot' not found. Please install 'nanobot-ai'.")
21
+ # We don't exit here so it can be imported safely, but it will likely fail on run
22
+ pass
23
+
24
+ # Load environment variables
25
+ load_dotenv()
26
+
27
+ # Configure logging
28
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
29
+ logger = logging.getLogger("nps_nanobot")
30
+
31
+ class PlaywrightTool(Tool):
32
+ """
33
+ A tool to control a web browser using Playwright.
34
+ Supports navigation, clicking, typing, and extracting text.
35
+ """
36
+ name = "browser"
37
+ description = (
38
+ "Control a web browser to navigate, interact, and extract information. "
39
+ "Use this tool to visit websites, click elements, fill forms, and read content."
40
+ )
41
+ parameters = {
42
+ "type": "object",
43
+ "properties": {
44
+ "action": {
45
+ "type": "string",
46
+ "enum": ["navigate", "click", "type", "press", "scroll", "get_text", "get_links", "get_dom", "screenshot"],
47
+ "description": "The action to perform."
48
+ },
49
+ "url": {"type": "string", "description": "URL to navigate to (required for 'navigate')"},
50
+ "selector": {"type": "string", "description": "CSS selector or text to interact with (required for 'click', 'type', 'get_text')"},
51
+ "text": {"type": "string", "description": "Text to type (required for 'type')"},
52
+ "key": {"type": "string", "description": "Key to press (required for 'press', e.g., 'Enter')"},
53
+ "direction": {"type": "string", "enum": ["up", "down"], "description": "Scroll direction (required for 'scroll')"},
54
+ "amount": {"type": "integer", "description": "Scroll amount in pixels (optional for 'scroll')"},
55
+ },
56
+ "required": ["action"]
57
+ }
58
+
59
+ def __init__(self):
60
+ self._playwright = None
61
+ self._browser = None
62
+ self._context: Optional[BrowserContext] = None
63
+ self._page: Optional[Page] = None
64
+ self._initialized = False
65
+
66
+ async def _ensure_initialized(self):
67
+ if not self._initialized:
68
+ self._playwright = await async_playwright().start()
69
+ # Launch with anti-detection args
70
+ self._browser = await self._playwright.chromium.launch(
71
+ headless=False,
72
+ args=[
73
+ "--no-sandbox",
74
+ "--disable-blink-features=AutomationControlled",
75
+ "--disable-infobars"
76
+ ],
77
+ ignore_default_args=["--enable-automation"]
78
+ )
79
+ self._context = await self._browser.new_context(
80
+ viewport={"width": 1280, "height": 720},
81
+ user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
82
+ )
83
+ self._page = await self._context.new_page()
84
+ self._page.set_default_timeout(15000) # 15s timeout
85
+
86
+ # Auto-handle native dialogs
87
+ self._page.on("dialog", lambda dialog: dialog.accept())
88
+
89
+ self._initialized = True
90
+ logger.info("Playwright browser initialized")
91
+
92
+ async def _handle_popups(self):
93
+ """Try to dismiss common cookie banners and popups."""
94
+ try:
95
+ # Common patterns for dismissal
96
+ patterns = [
97
+ "text=Accept", "text=Agree", "text=Allow",
98
+ "text=Reject", "text=Close", "text=No thanks",
99
+ "text=Not now", "text=OK", "text=I understand",
100
+ "button[aria-label='Close']", ".close-button",
101
+ "#dismiss-button", "div[aria-label='Close ad']",
102
+ ".ri-close-line", ".close-icon"
103
+ ]
104
+ for selector in patterns:
105
+ try:
106
+ # Check if visible and click. fast timeout.
107
+ elem = await self._page.query_selector(selector)
108
+ if elem and await elem.is_visible():
109
+ await elem.click(timeout=500)
110
+ logger.info(f"Dismissed popup using {selector}")
111
+ await asyncio.sleep(0.5) # Wait for animation
112
+ except Exception:
113
+ pass
114
+ except Exception as e:
115
+ logger.warning(f"Popup dismissal error: {e}")
116
+
117
+ async def execute(self, action: str, **kwargs: Any) -> str:
118
+ try:
119
+ await self._ensure_initialized()
120
+ page = self._page
121
+
122
+ if action == "navigate":
123
+ url = kwargs.get("url")
124
+ if not url: return "Error: 'url' is required for navigate"
125
+ if not url.startswith("http"): url = "https://" + url
126
+ try:
127
+ await page.goto(url, timeout=20000)
128
+ await page.wait_for_load_state("domcontentloaded", timeout=10000)
129
+ # Attempt to clean up view
130
+ await self._handle_popups()
131
+ except Exception as e:
132
+ # Ignore timeout and assume page loaded partially
133
+ logger.warning(f"Navigation to {url} timed out: {e}")
134
+ await self._handle_popups() # Try finding popups even if timed out
135
+ return f"Navigated to {url}, but timed out waiting for full load. Content might be available."
136
+
137
+ title = await page.title()
138
+ return f"Navigated to {url}. Page title: {title}"
139
+
140
+ elif action == "click":
141
+ selector = kwargs.get("selector")
142
+ if not selector: return "Error: 'selector' is required for click"
143
+ # specialized selector handling: try text if loose selector
144
+ try:
145
+ # If it looks like "link: <text>", extract
146
+ if selector.startswith("link: "):
147
+ selector = f"text={selector[6:]}"
148
+ await page.click(selector, timeout=5000)
149
+ except Exception:
150
+ # Fallback to text match
151
+ await page.click(f"text={selector}")
152
+ return f"Clicked element: {selector}"
153
+
154
+ elif action == "type":
155
+ selector = kwargs.get("selector")
156
+ text = kwargs.get("text")
157
+ if not selector or text is None: return "Error: 'selector' and 'text' are required for type"
158
+ await page.fill(selector, text)
159
+ return f"Typed '{text}' into {selector}"
160
+
161
+ elif action == "press":
162
+ key = kwargs.get("key")
163
+ if not key: return "Error: 'key' is required for press"
164
+ await page.keyboard.press(key)
165
+ return f"Pressed key: {key}"
166
+
167
+ elif action == "scroll":
168
+ direction = kwargs.get("direction", "down")
169
+ amount = kwargs.get("amount", 500)
170
+ if direction == "down":
171
+ await page.evaluate(f"window.scrollBy(0, {amount})")
172
+ else:
173
+ await page.evaluate(f"window.scrollBy(0, -{amount})")
174
+ return f"Scrolled {direction} by {amount} pixels"
175
+
176
+ elif action == "get_text":
177
+ selector = kwargs.get("selector", "body")
178
+ if selector == "body":
179
+ # Smart extraction: get text but also hints of links
180
+ # We'll stick to text for this action to keep it simple,
181
+ # but maybe use Readability if we had it. For now, innerText is fine.
182
+ content = await page.evaluate("document.body.innerText")
183
+ return content[:4000] + "... (truncated)"
184
+ else:
185
+ element = await page.query_selector(selector)
186
+ if element:
187
+ text = await element.inner_text()
188
+ return text
189
+ return f"Element not found: {selector}"
190
+
191
+ elif action == "get_links":
192
+ # new action to find clickable links
193
+ links = await page.evaluate("""() => {
194
+ const params = [];
195
+ document.querySelectorAll('a').forEach(a => {
196
+ if (a.innerText.trim().length > 3 && a.offsetParent !== null) {
197
+ params.push({text: a.innerText.trim().slice(0, 50), href: a.href});
198
+ }
199
+ });
200
+ return params.slice(0, 20); // Top 20 relevant links
201
+ }""")
202
+ return json.dumps(links, indent=2)
203
+
204
+ elif action == "get_dom":
205
+ # Simplified interactive DOM tree for buttons and inputs
206
+ dom = await page.evaluate("""() => {
207
+ const params = [];
208
+ const elements = document.querySelectorAll('button, input, select, textarea, [role="button"]');
209
+ elements.forEach(el => {
210
+ if (el.offsetParent !== null) { // visible
211
+ let label = el.innerText || el.placeholder || el.getAttribute('aria-label') || el.name || el.id || el.value;
212
+ label = label ? label.trim().slice(0, 50) : '[No Label]';
213
+ const tag = el.tagName.toLowerCase();
214
+ params.push(`${tag}: ${label}`);
215
+ }
216
+ });
217
+ return params.slice(0, 50).join('\\n'); // Top 50 interactive elements
218
+ }""")
219
+ return dom if dom else "No interactive elements found."
220
+
221
+ elif action == "screenshot":
222
+ path = "screenshot.png"
223
+ await page.screenshot(path=path)
224
+ return f"Screenshot saved to {path}"
225
+
226
+ else:
227
+ return f"Unknown action: {action}"
228
+
229
+ except Exception as e:
230
+ logger.error(f"Playwright error: {e}")
231
+ return f"Error executing {action}: {str(e)}"
232
+
233
+ async def close(self):
234
+ if self._browser:
235
+ await self._browser.close()
236
+ if self._playwright:
237
+ await self._playwright.stop()
238
+
239
+
240
+ async def run_nps_task(task: str) -> str:
241
+ """
242
+ Executes a task using the NPS Nanobot Agent.
243
+
244
+ Args:
245
+ task: The natural language task description.
246
+
247
+ Returns:
248
+ The result of the task execution.
249
+ """
250
+ print("\n" + "="*60)
251
+ print("🐈 NPS OPERATIONAL AGENT (Nanobot + Playwright + Ollama)")
252
+ print("="*60)
253
+
254
+ # Initialize bus
255
+ bus = MessageBus()
256
+
257
+ # Configure LiteLLM for Hugging Face Inference API
258
+ # Using 'huggingface/model_name' tells LiteLLM to use the HF provider
259
+
260
+ model_name = "huggingface/Qwen/Qwen2.5-72B-Instruct"
261
+
262
+ hf_token = os.getenv("HF_TOKEN")
263
+ if not hf_token:
264
+ logger.warning("HF_TOKEN not found in environment variables. Agent may fail if not authenticated.")
265
+
266
+ # Initialize standard LiteLLM provider for Hugging Face
267
+ class HFProvider(LiteLLMProvider):
268
+ async def chat(self, messages, tools=None, model=None, max_tokens=4096, temperature=0.7):
269
+ model = model or self.default_model
270
+
271
+ kwargs = {
272
+ "model": model,
273
+ "messages": messages,
274
+ "max_tokens": max_tokens,
275
+ "temperature": temperature,
276
+ # "api_base": "https://api-inference.huggingface.co/models/", # LiteLLM handles this default
277
+ }
278
+ if tools:
279
+ kwargs["tools"] = tools
280
+ kwargs["tool_choice"] = "auto"
281
+
282
+ # Retry loop for empty responses
283
+ max_retries = 5
284
+ for attempt in range(max_retries):
285
+ try:
286
+ # Import litellm here to ensure it's available
287
+ from litellm import acompletion
288
+ from nanobot.providers.base import LLMResponse, ToolCallRequest
289
+ import json
290
+
291
+ # Ensure HF_TOKEN is used
292
+ os.environ["HUGGINGFACE_API_KEY"] = os.getenv("HF_TOKEN", "")
293
+
294
+ response = await acompletion(**kwargs)
295
+
296
+ # Standard parsing
297
+ llm_response = self._parse_response(response)
298
+
299
+ # Fallback: if no tool calls but content looks like JSON tool call, parse it
300
+ if not llm_response.tool_calls and llm_response.content:
301
+ import re
302
+ content = llm_response.content.strip()
303
+ # Regex to find JSON array or object
304
+ json_match = re.search(r'(\[.*\]|\{.*\})', content, re.DOTALL)
305
+
306
+ if json_match:
307
+ try:
308
+ json_str = json_match.group(1)
309
+ try:
310
+ data = json.loads(json_str)
311
+ except json.JSONDecodeError:
312
+ # Try parsing as NDJSON (multiple objects, one per line)
313
+ data = []
314
+ for line in json_str.splitlines():
315
+ line = line.strip()
316
+ if line:
317
+ try:
318
+ obj = json.loads(line)
319
+ data.append(obj)
320
+ except json.JSONDecodeError:
321
+ pass
322
+ if not data: raise
323
+
324
+ tool_calls = []
325
+ # Handle list of calls
326
+ if isinstance(data, list):
327
+ for item in data:
328
+ if isinstance(item, dict) and "name" in item and "arguments" in item: # Check simplified structure
329
+ args = item["arguments"]
330
+ if isinstance(args, str):
331
+ try:
332
+ args = json.loads(args)
333
+ except:
334
+ pass
335
+
336
+ import uuid
337
+ tool_calls.append(ToolCallRequest(
338
+ id=f"call_{uuid.uuid4().hex[:8]}",
339
+ name=item["name"],
340
+ arguments=args
341
+ ))
342
+ elif isinstance(item, dict) and "type" in item and item["type"] == "function": # Standard format
343
+ func = item["function"]
344
+ args = func["arguments"]
345
+ if isinstance(args, str):
346
+ try:
347
+ args = json.loads(args)
348
+ except:
349
+ pass
350
+
351
+ import uuid
352
+ tool_calls.append(ToolCallRequest(
353
+ id=item.get("id", f"call_{uuid.uuid4().hex[:8]}"),
354
+ name=func["name"],
355
+ arguments=args
356
+ ))
357
+
358
+ # Handle single object call
359
+ elif isinstance(data, dict):
360
+ if "name" in data and "arguments" in data:
361
+ args = data["arguments"]
362
+ if isinstance(args, str):
363
+ try:
364
+ args = json.loads(args)
365
+ except:
366
+ pass
367
+ import uuid
368
+ tool_calls.append(ToolCallRequest(
369
+ id=f"call_{uuid.uuid4().hex[:8]}",
370
+ name=data["name"],
371
+ arguments=args
372
+ ))
373
+
374
+ if tool_calls:
375
+ llm_response.tool_calls = tool_calls
376
+ llm_response.content = None # specific for tool use
377
+ llm_response.finish_reason = "tool_calls"
378
+
379
+ except Exception as e:
380
+ logger.warning(f"Failed to parse JSON tool call fallback: {e}")
381
+
382
+ return llm_response
383
+
384
+ except Exception as e:
385
+ if attempt == max_retries - 1:
386
+ from nanobot.providers.base import LLMResponse
387
+ return LLMResponse(content=f"Error calling LLM after {max_retries} attempts: {str(e)}", finish_reason="error")
388
+ logger.warning(f"LLM call failed (attempt {attempt+1}/{max_retries}): {e}")
389
+
390
+ return LLMResponse(content="Error: Max retries exceeded", finish_reason="error")
391
+
392
+ provider = HFProvider(
393
+ default_model=model_name,
394
+ api_key=hf_token
395
+ )
396
+
397
+ # Initialize Agent Loop
398
+ # We use current directory as workspace
399
+ workspace_path = Path(os.getcwd())
400
+
401
+ # Clear memory to ensure fresh start
402
+ memory_path = workspace_path / "memory"
403
+ if memory_path.exists():
404
+ import shutil
405
+ try:
406
+ shutil.rmtree(memory_path)
407
+ print("INFO: Cleared previous agent memory.")
408
+ except Exception as e:
409
+ print(f"WARN: Could not clear memory: {e}")
410
+
411
+ agent = AgentLoop(
412
+ bus=bus,
413
+ provider=provider,
414
+ workspace=workspace_path,
415
+ model=model_name,
416
+ max_iterations=15,
417
+ )
418
+
419
+ # Register our custom Playwright tool
420
+ playwright_tool = PlaywrightTool()
421
+ agent.tools.register(playwright_tool)
422
+
423
+ print(f"\nINFO: Agent initialized with model: {model_name}")
424
+ print("INFO: Browser tool (Playwright) registered")
425
+
426
+ try:
427
+ if not task:
428
+ return "❌ No task provided."
429
+
430
+ print(f"\n🚀 Starting task: {task}")
431
+
432
+ # Append strong instruction to the task
433
+ system_instruction = (
434
+ "\n\nIMPORTANT INSTRUCTIONS:"
435
+ "\n1. You are an autonomous browser agent. You MUST USE TOOLS to perform actions."
436
+ "\n2. DO NOT narrate what you are going to do (e.g., 'I will click...'). JUST CALL THE TOOL."
437
+ "\n3. If the task is not complete, your response MUST contain a tool call."
438
+ "\n4. Only output text when you have fully completed the user's request and have the answer."
439
+ "\n5. Start by navigating: browser(action='navigate', ...)."
440
+ "\n6. Use 'get_links' to find clickable items."
441
+ "\n7. Use 'get_dom' to find buttons and inputs on interactive pages."
442
+ "\n8. If you see popups, 'OK' dialogs, or cookie banners that block you, click them to dismiss."
443
+ )
444
+ full_task = task + system_instruction
445
+
446
+ # Use a unique session ID for each task execution to prevent history leakage
447
+ import uuid
448
+ session_id = f"task_{uuid.uuid4().hex[:8]}"
449
+
450
+ # Run the agent with a unique session key
451
+ response = await agent.process_direct(
452
+ full_task,
453
+ session_key=f"cli:{session_id}",
454
+ channel="cli",
455
+ chat_id=session_id
456
+ )
457
+
458
+ print("\n" + "="*60)
459
+ print("✅ TASK COMPLETED")
460
+ print("="*60)
461
+ return response
462
+
463
+ except KeyboardInterrupt:
464
+ return "Task interrupted by user."
465
+ finally:
466
+ # Cleanup
467
+ await playwright_tool.close()
468
+ agent.stop()
469
+
470
+ if __name__ == "__main__":
471
+ # Test block
472
+ task = input("Enter test task: ")
473
+ asyncio.run(run_nps_task(task))
requirements.txt ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi>=0.100.0
2
+ uvicorn>=0.23.0
3
+ python-multipart>=0.0.6
4
+ jinja2>=3.1.2
5
+ pydantic>=2.0.0
6
+ python-dotenv>=1.0.0
7
+ mistralai>=0.0.12
8
+ scikit-learn>=1.3.0
9
+ pypdf2>=3.0.0
10
+ langchain>=0.1.0
11
+ langchain-community>=0.0.10
12
+ langchain-huggingface>=0.0.1
13
+ sentence-transformers>=2.2.2
14
+ faiss-cpu>=1.7.4
15
+ python-docx>=0.8.11
16
+ langchain-text-splitters
17
+ nanobot-ai
18
+ playwright
19
+ huggingface_hub
20
+ litellm