Spaces:
Sleeping
Sleeping
Base 1.0
Browse files- .dockerignore +24 -0
- .gitattributes +1 -0
- .gitignore +16 -0
- AGENTS.md +18 -0
- Dockerfile +58 -0
- README.md +4 -4
- STARTUP.md +83 -0
- app.log +15 -0
- app.py +597 -0
- data/NPS_Rules.txt +21 -0
- data/Secrets.txt +1 -0
- frontend/admin.html +186 -0
- frontend/index.html +763 -0
- mistral.py +517 -0
- nps_agent.py +473 -0
- 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
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 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
|