Spaces:
Running
Running
Commit
·
1a63864
0
Parent(s):
Deploy VisionExtract to HF Spaces
Browse files- .dockerignore +46 -0
- .env.example +9 -0
- .gitignore +30 -0
- Dockerfile +51 -0
- README.md +36 -0
- app.py +720 -0
- download_nltk.py +44 -0
- gunicorn.conf.py +30 -0
- models.py +612 -0
- rag_core.py +713 -0
- render.yaml +20 -0
- requirements.txt +13 -0
- templates/index.html +1429 -0
.dockerignore
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
*.so
|
| 6 |
+
.Python
|
| 7 |
+
*.egg-info/
|
| 8 |
+
.eggs/
|
| 9 |
+
|
| 10 |
+
# Environment
|
| 11 |
+
.env
|
| 12 |
+
.venv/
|
| 13 |
+
venv/
|
| 14 |
+
ENV/
|
| 15 |
+
|
| 16 |
+
# Git
|
| 17 |
+
.git/
|
| 18 |
+
.gitignore
|
| 19 |
+
|
| 20 |
+
# IDE
|
| 21 |
+
.vscode/
|
| 22 |
+
.idea/
|
| 23 |
+
*.swp
|
| 24 |
+
*.swo
|
| 25 |
+
|
| 26 |
+
# Local files not needed in container
|
| 27 |
+
render.yaml
|
| 28 |
+
gunicorn.conf.py
|
| 29 |
+
ngrok.exe
|
| 30 |
+
*.md
|
| 31 |
+
!README.md
|
| 32 |
+
|
| 33 |
+
# Data directories (will be created fresh in container)
|
| 34 |
+
uploads/*
|
| 35 |
+
user_data/*
|
| 36 |
+
vector_store/*
|
| 37 |
+
instance/*
|
| 38 |
+
*.db
|
| 39 |
+
*.sqlite
|
| 40 |
+
|
| 41 |
+
# Logs
|
| 42 |
+
*.log
|
| 43 |
+
|
| 44 |
+
# OS files
|
| 45 |
+
.DS_Store
|
| 46 |
+
Thumbs.db
|
.env.example
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Chroma Cloud Configuration
|
| 2 |
+
# Get these from https://trychroma.com
|
| 3 |
+
CHROMA_TENANT=your-tenant-id
|
| 4 |
+
CHROMA_DATABASE=your-database-name
|
| 5 |
+
CHROMA_API_KEY=your-api-key-here
|
| 6 |
+
|
| 7 |
+
# OpenRouter API Key (for AI models)
|
| 8 |
+
# Get this from https://openrouter.ai
|
| 9 |
+
OPENROUTER_API_KEY=your-openrouter-api-key-here
|
.gitignore
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Environment variables (NEVER commit this!)
|
| 2 |
+
.env
|
| 3 |
+
|
| 4 |
+
# Python
|
| 5 |
+
__pycache__/
|
| 6 |
+
*.py[cod]
|
| 7 |
+
*$py.class
|
| 8 |
+
*.so
|
| 9 |
+
.Python
|
| 10 |
+
venv/
|
| 11 |
+
env/
|
| 12 |
+
|
| 13 |
+
# Local data (user uploads and database)
|
| 14 |
+
instance/
|
| 15 |
+
uploads/
|
| 16 |
+
user_data/
|
| 17 |
+
vector_store/
|
| 18 |
+
nltk_data/
|
| 19 |
+
|
| 20 |
+
# Large binaries
|
| 21 |
+
ngrok.exe
|
| 22 |
+
*.exe
|
| 23 |
+
|
| 24 |
+
# IDE
|
| 25 |
+
.vscode/
|
| 26 |
+
.idea/
|
| 27 |
+
|
| 28 |
+
# OS
|
| 29 |
+
.DS_Store
|
| 30 |
+
Thumbs.db
|
Dockerfile
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Dockerfile for Hugging Face Spaces
|
| 2 |
+
FROM python:3.11-slim
|
| 3 |
+
|
| 4 |
+
# Set working directory
|
| 5 |
+
WORKDIR /app
|
| 6 |
+
|
| 7 |
+
# Set environment variables
|
| 8 |
+
ENV PYTHONDONTWRITEBYTECODE=1
|
| 9 |
+
ENV PYTHONUNBUFFERED=1
|
| 10 |
+
ENV HF_HOME=/app/.cache/huggingface
|
| 11 |
+
|
| 12 |
+
# Install system dependencies for PyMuPDF and other libs
|
| 13 |
+
RUN apt-get update && apt-get install -y \
|
| 14 |
+
libgl1-mesa-glx \
|
| 15 |
+
libglib2.0-0 \
|
| 16 |
+
libsm6 \
|
| 17 |
+
libxext6 \
|
| 18 |
+
libxrender-dev \
|
| 19 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 20 |
+
|
| 21 |
+
# Copy requirements first (Docker layer caching optimization)
|
| 22 |
+
COPY requirements.txt .
|
| 23 |
+
|
| 24 |
+
# Install Python dependencies
|
| 25 |
+
RUN pip install --no-cache-dir --upgrade pip && \
|
| 26 |
+
pip install --no-cache-dir -r requirements.txt
|
| 27 |
+
|
| 28 |
+
# Download NLTK data during build
|
| 29 |
+
COPY download_nltk.py .
|
| 30 |
+
RUN python download_nltk.py
|
| 31 |
+
|
| 32 |
+
# Copy application code
|
| 33 |
+
COPY . .
|
| 34 |
+
|
| 35 |
+
# Create necessary directories with write permissions
|
| 36 |
+
# HF Spaces only allows writes to certain directories
|
| 37 |
+
RUN mkdir -p /app/uploads /app/user_data /app/vector_store /app/instance /app/.cache
|
| 38 |
+
RUN chmod -R 777 /app/uploads /app/user_data /app/vector_store /app/instance /app/.cache
|
| 39 |
+
|
| 40 |
+
# Expose port 7860 (required by Hugging Face Spaces)
|
| 41 |
+
EXPOSE 7860
|
| 42 |
+
|
| 43 |
+
# Health check
|
| 44 |
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=120s --retries=3 \
|
| 45 |
+
CMD curl -f http://localhost:7860/health || exit 1
|
| 46 |
+
|
| 47 |
+
# Start with gunicorn
|
| 48 |
+
# - Single worker to conserve memory for ML models
|
| 49 |
+
# - 120s timeout to allow model loading on first request
|
| 50 |
+
# - Preload disabled to allow lazy loading
|
| 51 |
+
CMD ["gunicorn", "--bind", "0.0.0.0:7860", "--timeout", "120", "--workers", "1", "--threads", "2", "app:app"]
|
README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: VisionExtract CRM
|
| 3 |
+
emoji: 📄
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# VisionExtract CRM
|
| 12 |
+
|
| 13 |
+
AI-powered document extraction application for business cards and brochures with RAG-based intelligent chat.
|
| 14 |
+
|
| 15 |
+
## Features
|
| 16 |
+
|
| 17 |
+
- 📇 **Business Card OCR** - Extract contact information from business card images
|
| 18 |
+
- 📑 **Brochure Processing** - Parse PDF brochures to extract company and contact details
|
| 19 |
+
- 💬 **RAG Chat** - Ask natural language questions about your uploaded documents
|
| 20 |
+
- 🗄️ **Persistent Storage** - SQLite database for storing extracted data
|
| 21 |
+
|
| 22 |
+
## Tech Stack
|
| 23 |
+
|
| 24 |
+
- **Backend**: Flask + Gunicorn
|
| 25 |
+
- **AI/ML**: OpenRouter API, Sentence-Transformers, ChromaDB
|
| 26 |
+
- **Document Processing**: PyMuPDF, Pillow
|
| 27 |
+
- **Database**: SQLite + ChromaDB Cloud
|
| 28 |
+
|
| 29 |
+
## Environment Variables
|
| 30 |
+
|
| 31 |
+
Set these in your Space secrets:
|
| 32 |
+
- `OPENROUTER_API_KEY` - Your OpenRouter API key
|
| 33 |
+
- `CHROMA_TENANT` - Chroma Cloud tenant
|
| 34 |
+
- `CHROMA_DATABASE` - Chroma Cloud database
|
| 35 |
+
- `CHROMA_API_KEY` - Chroma Cloud API key
|
| 36 |
+
- `SESSION_SECRET` - Random secret for Flask sessions
|
app.py
ADDED
|
@@ -0,0 +1,720 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# app.py
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import io
|
| 5 |
+
import json
|
| 6 |
+
import hashlib
|
| 7 |
+
import requests
|
| 8 |
+
import base64
|
| 9 |
+
from flask import Flask, request, jsonify, send_from_directory, render_template, session
|
| 10 |
+
import webbrowser
|
| 11 |
+
from flask_cors import CORS
|
| 12 |
+
from PIL import Image
|
| 13 |
+
import fitz # PyMuPDF
|
| 14 |
+
import rag_core
|
| 15 |
+
from datetime import timedelta
|
| 16 |
+
import traceback
|
| 17 |
+
import time
|
| 18 |
+
import re
|
| 19 |
+
|
| 20 |
+
from dotenv import load_dotenv
|
| 21 |
+
load_dotenv()
|
| 22 |
+
|
| 23 |
+
# --- MODIFIED: Import db and models from models.py ---
|
| 24 |
+
from models import db, BusinessCard, Brochure, Contact
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
app = Flask(__name__)
|
| 28 |
+
CORS(app)
|
| 29 |
+
|
| 30 |
+
# Disable template caching for development
|
| 31 |
+
app.config['TEMPLATES_AUTO_RELOAD'] = True
|
| 32 |
+
app.jinja_env.auto_reload = True
|
| 33 |
+
|
| 34 |
+
# Session configuration
|
| 35 |
+
app.secret_key = os.environ.get("SESSION_SECRET", "a-very-secret-key-for-sessions")
|
| 36 |
+
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=24)
|
| 37 |
+
|
| 38 |
+
# --- FOLDER CONFIGURATION ---
|
| 39 |
+
UPLOAD_FOLDER = 'uploads'
|
| 40 |
+
DATA_FOLDER = 'user_data'
|
| 41 |
+
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
|
| 42 |
+
|
| 43 |
+
if not os.path.exists(UPLOAD_FOLDER):
|
| 44 |
+
os.makedirs(UPLOAD_FOLDER)
|
| 45 |
+
if not os.path.exists(DATA_FOLDER):
|
| 46 |
+
os.makedirs(DATA_FOLDER)
|
| 47 |
+
|
| 48 |
+
# --- DATABASE CONFIGURATION ---
|
| 49 |
+
app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get(
|
| 50 |
+
'DATABASE_URI',
|
| 51 |
+
'sqlite:///local_crm.db'
|
| 52 |
+
)
|
| 53 |
+
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
| 54 |
+
|
| 55 |
+
# --- MODIFIED: Initialize the app with the database object ---
|
| 56 |
+
db.init_app(app)
|
| 57 |
+
|
| 58 |
+
# --- HARDCODED API KEY (loaded from environment) ---
|
| 59 |
+
OPENROUTER_API_KEY = os.environ.get("OPENROUTER_API_KEY")
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
# --- DATABASE MODEL DEFINITIONS HAVE BEEN MOVED TO models.py ---
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
MODEL_MAP = {
|
| 66 |
+
'gemini': 'google/gemma-3-4b-it:free',
|
| 67 |
+
'deepseek': 'google/gemma-3-27b-it:free',
|
| 68 |
+
|
| 69 |
+
'qwen': 'mistralai/mistral-small-3.1-24b-instruct:free',
|
| 70 |
+
'nvidia': 'nvidia/nemotron-nano-12b-v2-vl:free',
|
| 71 |
+
'amazon': 'amazon/nova-2-lite-v1:free'
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
# Best → fallback order (OCR strength)
|
| 75 |
+
FALLBACK_ORDER = [
|
| 76 |
+
'gemini',
|
| 77 |
+
'deepseek',
|
| 78 |
+
'qwen',
|
| 79 |
+
'nvidia',
|
| 80 |
+
'amazon'
|
| 81 |
+
]
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
# All your other functions (_call_openrouter_api_with_fallback, etc.) remain unchanged below...
|
| 86 |
+
def _call_openrouter_api_with_fallback(api_key, selected_model_key, prompt, images=[]):
|
| 87 |
+
if images:
|
| 88 |
+
vision_models = ['gemini','deepseek','qwen','nvidia','amazon']
|
| 89 |
+
models_to_try = [m for m in vision_models if m == selected_model_key]
|
| 90 |
+
models_to_try.extend([m for m in vision_models if m != selected_model_key])
|
| 91 |
+
models_to_try.extend([m for m in FALLBACK_ORDER if m not in vision_models])
|
| 92 |
+
else:
|
| 93 |
+
models_to_try = [selected_model_key]
|
| 94 |
+
for model in FALLBACK_ORDER:
|
| 95 |
+
if model != selected_model_key:
|
| 96 |
+
models_to_try.append(model)
|
| 97 |
+
|
| 98 |
+
last_error = None
|
| 99 |
+
|
| 100 |
+
for model_key in models_to_try:
|
| 101 |
+
model_name = MODEL_MAP.get(model_key)
|
| 102 |
+
if not model_name: continue
|
| 103 |
+
|
| 104 |
+
print(f"Attempting API call with model: {model_name}...")
|
| 105 |
+
content_parts = [{"type": "text", "text": prompt}]
|
| 106 |
+
|
| 107 |
+
if images and model_key in ['gemini','deepseek','qwen','nvidia','amazon']:
|
| 108 |
+
for img in images:
|
| 109 |
+
buffered = io.BytesIO()
|
| 110 |
+
img_format = img.format or "PNG"
|
| 111 |
+
img.save(buffered, format=img_format)
|
| 112 |
+
img_base64 = base64.b64encode(buffered.getvalue()).decode('utf-8')
|
| 113 |
+
content_parts.append({
|
| 114 |
+
"type": "image_url",
|
| 115 |
+
"image_url": { "url": f"data:image/{img_format.lower()};base64,{img_base64}" }
|
| 116 |
+
})
|
| 117 |
+
elif images and model_key not in ['gemini','deepseek','qwen','nvidia','amazon']:
|
| 118 |
+
print(f"Skipping {model_name} - no image input support")
|
| 119 |
+
continue
|
| 120 |
+
|
| 121 |
+
try:
|
| 122 |
+
response = requests.post(
|
| 123 |
+
url="https://openrouter.ai/api/v1/chat/completions",
|
| 124 |
+
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
| 125 |
+
json={"model": model_name, "messages": [{"role": "user", "content": content_parts}]},
|
| 126 |
+
timeout=30
|
| 127 |
+
)
|
| 128 |
+
response.raise_for_status()
|
| 129 |
+
api_response = response.json()
|
| 130 |
+
|
| 131 |
+
if 'choices' not in api_response or not api_response['choices']:
|
| 132 |
+
print(f"Model {model_name} returned empty response")
|
| 133 |
+
last_error = {"error": f"Model {model_name} returned empty response"}
|
| 134 |
+
continue
|
| 135 |
+
|
| 136 |
+
json_text = api_response['choices'][0]['message']['content']
|
| 137 |
+
|
| 138 |
+
cleaned_json_text = re.search(r'```json\s*([\s\S]+?)\s*```', json_text)
|
| 139 |
+
if cleaned_json_text:
|
| 140 |
+
json_text = cleaned_json_text.group(1)
|
| 141 |
+
else:
|
| 142 |
+
json_text = json_text.strip()
|
| 143 |
+
|
| 144 |
+
result = json.loads(json_text)
|
| 145 |
+
print(f"Successfully processed with model: {model_name}")
|
| 146 |
+
return result
|
| 147 |
+
except requests.exceptions.HTTPError as http_err:
|
| 148 |
+
error_msg = f"HTTP error occurred for model {model_name}: {http_err}"
|
| 149 |
+
if hasattr(response, 'text'): error_msg += f"\nResponse: {response.text}"
|
| 150 |
+
print(error_msg)
|
| 151 |
+
last_error = {"error": f"API request failed for {model_name} with status {response.status_code}."}
|
| 152 |
+
continue
|
| 153 |
+
except requests.exceptions.Timeout:
|
| 154 |
+
print(f"Timeout error for model {model_name}")
|
| 155 |
+
last_error = {"error": f"Request timeout for model {model_name}"}
|
| 156 |
+
continue
|
| 157 |
+
except json.JSONDecodeError as json_err:
|
| 158 |
+
error_msg = f"JSON Decode Error for model {model_name}: {json_err}\nMalformed response: {json_text}"
|
| 159 |
+
print(error_msg)
|
| 160 |
+
last_error = {"error": f"Model {model_name} returned invalid JSON."}
|
| 161 |
+
continue
|
| 162 |
+
except Exception as e:
|
| 163 |
+
print(f"An error occurred with model {model_name}: {e}")
|
| 164 |
+
traceback.print_exc()
|
| 165 |
+
last_error = {"error": f"An unexpected error occurred with model {model_name}."}
|
| 166 |
+
continue
|
| 167 |
+
|
| 168 |
+
return last_error or {"error": "All models failed to process the request."}
|
| 169 |
+
|
| 170 |
+
def _call_openrouter_api_text_only_with_fallback(api_key, selected_model_key, prompt):
|
| 171 |
+
models_to_try = [selected_model_key] + [m for m in FALLBACK_ORDER if m != selected_model_key]
|
| 172 |
+
last_error = None
|
| 173 |
+
for model_key in models_to_try:
|
| 174 |
+
model_name = MODEL_MAP.get(model_key)
|
| 175 |
+
if not model_name: continue
|
| 176 |
+
print(f"Attempting text-only API call with model: {model_name}...")
|
| 177 |
+
try:
|
| 178 |
+
response = requests.post(
|
| 179 |
+
url="https://openrouter.ai/api/v1/chat/completions",
|
| 180 |
+
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
| 181 |
+
json={"model": model_name, "messages": [{"role": "user", "content": prompt}]},
|
| 182 |
+
timeout=30
|
| 183 |
+
)
|
| 184 |
+
response.raise_for_status()
|
| 185 |
+
api_response = response.json()
|
| 186 |
+
if 'choices' not in api_response or not api_response['choices']:
|
| 187 |
+
last_error = {"error": f"Model {model_name} returned unexpected response format"}
|
| 188 |
+
continue
|
| 189 |
+
result = api_response['choices'][0]['message']['content']
|
| 190 |
+
print(f"Successfully processed text with model: {model_name}")
|
| 191 |
+
return result
|
| 192 |
+
except requests.exceptions.HTTPError as http_err:
|
| 193 |
+
error_msg = f"HTTP error occurred for model {model_name}: {http_err}"
|
| 194 |
+
if hasattr(response, 'text'): error_msg += f"\nResponse: {response.text}"
|
| 195 |
+
print(error_msg)
|
| 196 |
+
last_error = {"error": f"API request failed for {model_name} with status {response.status_code}."}
|
| 197 |
+
continue
|
| 198 |
+
except requests.exceptions.Timeout:
|
| 199 |
+
print(f"Timeout error for model {model_name}")
|
| 200 |
+
last_error = {"error": f"Request timeout for model {model_name}"}
|
| 201 |
+
continue
|
| 202 |
+
except Exception as e:
|
| 203 |
+
print(f"An error occurred with model {model_name}: {e}")
|
| 204 |
+
traceback.print_exc()
|
| 205 |
+
last_error = {"error": f"An unexpected error occurred with model {model_name}."}
|
| 206 |
+
continue
|
| 207 |
+
if isinstance(last_error, dict) and "error" in last_error:
|
| 208 |
+
return last_error["error"]
|
| 209 |
+
return "All models failed to process the text request."
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def _extract_contact_info_from_text(text):
|
| 213 |
+
if not text: return "", []
|
| 214 |
+
email_pattern = r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b'
|
| 215 |
+
phone_pattern = r'(?:\+?\d{1,4}[-.\s]?)?(?:\(?\d{1,4}\)?[-.\s]?)?\d{1,4}[-.\s]?\d{1,4}[-.\s]?\d{1,9}'
|
| 216 |
+
emails = re.findall(email_pattern, text, re.IGNORECASE)
|
| 217 |
+
phones = re.findall(phone_pattern, text)
|
| 218 |
+
clean_text = text
|
| 219 |
+
clean_text = re.sub(email_pattern, '', clean_text, flags=re.IGNORECASE)
|
| 220 |
+
for phone in phones:
|
| 221 |
+
if len(phone.replace('-', '').replace('.', '').replace(' ', '').replace('(', '').replace(')', '').replace('+', '')) >= 7:
|
| 222 |
+
clean_text = clean_text.replace(phone, '')
|
| 223 |
+
clean_text = re.sub(r'\s+', ' ', clean_text).strip()
|
| 224 |
+
clean_text = re.sub(r'\n\s*\n', '\n', clean_text)
|
| 225 |
+
return clean_text, emails + phones
|
| 226 |
+
|
| 227 |
+
def _create_clean_info_text(brochure_data):
|
| 228 |
+
company_name = brochure_data.get("company_name", "")
|
| 229 |
+
raw_text = brochure_data.get("raw_text", "")
|
| 230 |
+
info_parts = []
|
| 231 |
+
if company_name and company_name != "Unknown Company":
|
| 232 |
+
info_parts.append(f"Company: {company_name}")
|
| 233 |
+
if raw_text:
|
| 234 |
+
clean_text, _ = _extract_contact_info_from_text(raw_text)
|
| 235 |
+
contact_phrases = [r'contact\s+us\s*:?', r'for\s+more\s+information\s*:?', r'reach\s+out\s+to\s*:?', r'get\s+in\s+touch\s*:?', r'phone\s*:', r'email\s*:', r'tel\s*:', r'mobile\s*:', r'call\s+us\s*:?', r'write\s+to\s+us\s*:?',]
|
| 236 |
+
for phrase in contact_phrases:
|
| 237 |
+
clean_text = re.sub(phrase, '', clean_text, flags=re.IGNORECASE)
|
| 238 |
+
clean_text = re.sub(r'\s+', ' ', clean_text).strip()
|
| 239 |
+
clean_text = re.sub(r'\n\s*\n', '\n', clean_text)
|
| 240 |
+
if clean_text: info_parts.append(clean_text)
|
| 241 |
+
return "\n".join(info_parts) if info_parts else ""
|
| 242 |
+
|
| 243 |
+
def _get_user_data_filepath(user_api_key, mode):
|
| 244 |
+
user_hash = hashlib.sha256(user_api_key.encode()).hexdigest()[:16]
|
| 245 |
+
return os.path.join(DATA_FOLDER, f'{user_hash}_{mode}_data.json')
|
| 246 |
+
|
| 247 |
+
def _load_user_data(user_api_key, mode):
|
| 248 |
+
filepath = _get_user_data_filepath(user_api_key, mode)
|
| 249 |
+
try:
|
| 250 |
+
if os.path.exists(filepath):
|
| 251 |
+
with open(filepath, 'r') as f: return json.load(f)
|
| 252 |
+
except (IOError, json.JSONDecodeError): return []
|
| 253 |
+
return []
|
| 254 |
+
|
| 255 |
+
def _save_user_data(user_api_key, mode, data):
|
| 256 |
+
filepath = _get_user_data_filepath(user_api_key, mode)
|
| 257 |
+
try:
|
| 258 |
+
with open(filepath, 'w') as f: json.dump(data, f, indent=4)
|
| 259 |
+
return True
|
| 260 |
+
except IOError: return False
|
| 261 |
+
|
| 262 |
+
def _clean_and_validate_contacts(data):
|
| 263 |
+
if not data or "contacts" not in data: return data
|
| 264 |
+
cleaned_contacts = []
|
| 265 |
+
def is_placeholder(value):
|
| 266 |
+
if not isinstance(value, str): return True
|
| 267 |
+
test_val = value.strip().lower()
|
| 268 |
+
if not test_val: return True
|
| 269 |
+
placeholders = ["n/a", "na", "none", "null"]
|
| 270 |
+
if test_val in placeholders: return True
|
| 271 |
+
if "not available" in test_val or "not specified" in test_val or "not applicable" in test_val: return True
|
| 272 |
+
return False
|
| 273 |
+
for contact in data.get("contacts", []):
|
| 274 |
+
name = contact.get("Owner Name")
|
| 275 |
+
if is_placeholder(name): continue
|
| 276 |
+
cleaned_contacts.append({
|
| 277 |
+
"Owner Name": name.strip(),
|
| 278 |
+
"Email": None if is_placeholder(contact.get("Email")) else contact.get("Email").strip(),
|
| 279 |
+
"Number": None if is_placeholder(contact.get("Number")) else contact.get("Number").strip()
|
| 280 |
+
})
|
| 281 |
+
data["contacts"] = cleaned_contacts
|
| 282 |
+
return data
|
| 283 |
+
|
| 284 |
+
def extract_card_data(image_bytes, user_api_key, selected_model_key):
|
| 285 |
+
print("Processing business card with OpenRouter API...")
|
| 286 |
+
if not user_api_key: return {"error": "A valid OpenRouter API Key was not provided."}
|
| 287 |
+
try:
|
| 288 |
+
img = Image.open(io.BytesIO(image_bytes))
|
| 289 |
+
prompt = """You are an expert at reading business cards. Analyze the image and extract information into a structured JSON format. The JSON object must use these exact keys: "Owner Name", "Company Name", "Email", "Number", "Address". If a piece of information is not present, its value must be `null`. Your entire response MUST be a single, valid JSON object."""
|
| 290 |
+
parsed_info = _call_openrouter_api_with_fallback(user_api_key, selected_model_key, prompt, images=[img])
|
| 291 |
+
if "error" in parsed_info: return parsed_info
|
| 292 |
+
return {"Owner Name": parsed_info.get("Owner Name"), "Company Name": parsed_info.get("Company Name"), "Email": parsed_info.get("Email"), "Number": parsed_info.get("Number"), "Address": parsed_info.get("Address")}
|
| 293 |
+
except Exception as e:
|
| 294 |
+
print(f"Error during OpenRouter API call for business card: {e}")
|
| 295 |
+
traceback.print_exc()
|
| 296 |
+
return {"error": f"Failed to parse AI response: {e}"}
|
| 297 |
+
|
| 298 |
+
def _extract_brochure_data_with_vision(image_list, user_api_key, selected_model_key):
|
| 299 |
+
print(f"Vision Extraction: Analyzing {len(image_list)} images with OpenRouter...")
|
| 300 |
+
if not user_api_key: return {"error": "A valid OpenRouter API Key was not provided."}
|
| 301 |
+
try:
|
| 302 |
+
prompt = """You are a world-class document analysis expert. Analyze the provided document images with maximum precision. CRITICAL INSTRUCTIONS: 1. Extract the company name. 2. Extract ONLY contact information (names, emails, phone numbers) and put them in the "contacts" array. 3. Extract ALL OTHER content (company description, services, mission, addresses, general information) as "raw_text". 4. DO NOT include contact details like names, emails, or phone numbers in the raw_text. 5. Focus on separating contact information from general company information. OUTPUT FORMAT: Return a SINGLE, valid JSON object with these exact keys: "company_name", "contacts", "raw_text". The "contacts" key must contain a list of objects, each with "Owner Name", "Email", and "Number". If a piece of information is missing for a contact, use `null`. The "raw_text" should contain business information, services, descriptions, but NO contact details."""
|
| 303 |
+
raw_data = _call_openrouter_api_with_fallback(user_api_key, selected_model_key, prompt, images=image_list)
|
| 304 |
+
if "error" in raw_data: return raw_data
|
| 305 |
+
print("AI vision extraction complete. Applying bulletproof cleaning...")
|
| 306 |
+
cleaned_data = _clean_and_validate_contacts(raw_data)
|
| 307 |
+
return cleaned_data
|
| 308 |
+
except Exception as e:
|
| 309 |
+
print(f"Error during unified brochure vision extraction: {e}")
|
| 310 |
+
traceback.print_exc()
|
| 311 |
+
return {"error": f"Failed to parse data from brochure images: {e}"}
|
| 312 |
+
|
| 313 |
+
@app.before_request
|
| 314 |
+
def make_session_permanent():
|
| 315 |
+
session.permanent = True
|
| 316 |
+
|
| 317 |
+
@app.route('/process_card', methods=['POST'])
|
| 318 |
+
def process_card_endpoint():
|
| 319 |
+
if 'file' not in request.files: return jsonify({'error': 'No file part'}), 400
|
| 320 |
+
file, selected_model_key = request.files['file'], request.form.get('selectedModel')
|
| 321 |
+
user_api_key = OPENROUTER_API_KEY # Use hardcoded server-side API key
|
| 322 |
+
if not user_api_key or not selected_model_key: return jsonify({'error': 'Server API key not configured or model not selected'}), 400
|
| 323 |
+
if selected_model_key not in MODEL_MAP: return jsonify({'error': 'Invalid model selected'}), 400
|
| 324 |
+
|
| 325 |
+
try:
|
| 326 |
+
image_bytes = file.read()
|
| 327 |
+
extracted_info = extract_card_data(image_bytes, user_api_key, selected_model_key)
|
| 328 |
+
if "error" in extracted_info: return jsonify(extracted_info), 500
|
| 329 |
+
|
| 330 |
+
file_id = os.urandom(8).hex()
|
| 331 |
+
_, f_ext = os.path.splitext(file.filename)
|
| 332 |
+
safe_ext = f_ext if f_ext.lower() in ['.png', '.jpg', '.jpeg', '.webp'] else '.png'
|
| 333 |
+
image_filename = f"{file_id}{safe_ext}"
|
| 334 |
+
save_path = os.path.join(UPLOAD_FOLDER, image_filename)
|
| 335 |
+
with open(save_path, 'wb') as f: f.write(image_bytes)
|
| 336 |
+
|
| 337 |
+
extracted_info['id'] = file_id
|
| 338 |
+
extracted_info['image_filename'] = image_filename
|
| 339 |
+
|
| 340 |
+
user_contacts = _load_user_data(user_api_key, 'cards')
|
| 341 |
+
user_contacts.insert(0, extracted_info)
|
| 342 |
+
_save_user_data(user_api_key, 'cards', user_contacts)
|
| 343 |
+
|
| 344 |
+
try:
|
| 345 |
+
user_hash = hashlib.sha256(user_api_key.encode()).hexdigest()
|
| 346 |
+
new_card = BusinessCard(
|
| 347 |
+
json_id=file_id,
|
| 348 |
+
owner_name=extracted_info.get("Owner Name"),
|
| 349 |
+
company_name=extracted_info.get("Company Name"),
|
| 350 |
+
email=extracted_info.get("Email"),
|
| 351 |
+
phone_number=extracted_info.get("Number"),
|
| 352 |
+
address=extracted_info.get("Address"),
|
| 353 |
+
source_document=file.filename,
|
| 354 |
+
user_hash=user_hash
|
| 355 |
+
)
|
| 356 |
+
db.session.add(new_card)
|
| 357 |
+
db.session.commit()
|
| 358 |
+
print(f"Successfully saved business card for '{extracted_info.get('Owner Name')}' to the database.")
|
| 359 |
+
except Exception as e:
|
| 360 |
+
db.session.rollback()
|
| 361 |
+
print(f"DATABASE ERROR: Failed to save business card data. Error: {e}")
|
| 362 |
+
traceback.print_exc()
|
| 363 |
+
|
| 364 |
+
raw_text_for_rag = ' '.join(str(v) for k, v in extracted_info.items() if v and k not in ['id', 'image_filename'])
|
| 365 |
+
rag_core.add_document_to_knowledge_base(user_api_key, raw_text_for_rag, file_id, 'cards')
|
| 366 |
+
|
| 367 |
+
return jsonify(extracted_info)
|
| 368 |
+
except Exception as e:
|
| 369 |
+
print(f"An error occurred in process_card endpoint: {e}")
|
| 370 |
+
traceback.print_exc()
|
| 371 |
+
return jsonify({'error': 'Server processing failed'}), 500
|
| 372 |
+
|
| 373 |
+
@app.route('/process_brochure', methods=['POST'])
|
| 374 |
+
def process_brochure_endpoint():
|
| 375 |
+
if 'file' not in request.files: return jsonify({'error': 'No file part'}), 400
|
| 376 |
+
file, selected_model_key = request.files['file'], request.form.get('selectedModel')
|
| 377 |
+
user_api_key = OPENROUTER_API_KEY # Use hardcoded server-side API key
|
| 378 |
+
if not user_api_key or not selected_model_key: return jsonify({'error': 'Server API key not configured or model not selected'}), 400
|
| 379 |
+
if selected_model_key not in MODEL_MAP: return jsonify({'error': 'Invalid model selected'}), 400
|
| 380 |
+
|
| 381 |
+
try:
|
| 382 |
+
pdf_bytes = file.read()
|
| 383 |
+
pdf_doc = fitz.open(stream=pdf_bytes, filetype="pdf")
|
| 384 |
+
|
| 385 |
+
brochure_json_id = os.urandom(8).hex()
|
| 386 |
+
pdf_filename = f"{brochure_json_id}.pdf"
|
| 387 |
+
save_path = os.path.join(UPLOAD_FOLDER, pdf_filename)
|
| 388 |
+
with open(save_path, 'wb') as f: f.write(pdf_bytes)
|
| 389 |
+
|
| 390 |
+
extracted_data = {}
|
| 391 |
+
full_text_from_pdf = "".join(page.get_text("text") for page in pdf_doc).strip()
|
| 392 |
+
|
| 393 |
+
if len(full_text_from_pdf) > 100:
|
| 394 |
+
print("'Text-First' successful. Using text model.")
|
| 395 |
+
try:
|
| 396 |
+
prompt = """Analyze the following text and structure it into a JSON object with keys "company_name", "contacts", and "raw_text". CRITICAL INSTRUCTIONS: 1. Extract the company name. 2. Extract ONLY contact information (names, emails, phone numbers) into the "contacts" array. 3. Extract ALL OTHER content into "raw_text". 4. DO NOT include contact details in raw_text. "contacts" should be a list of objects with "Owner Name", "Email", and "Number". DOCUMENT TEXT: --- {full_text_from_pdf} ---"""
|
| 397 |
+
result = _call_openrouter_api_text_only_with_fallback(user_api_key, selected_model_key, prompt)
|
| 398 |
+
if isinstance(result, str) and not result.startswith("All models failed"):
|
| 399 |
+
try: extracted_data = json.loads(result)
|
| 400 |
+
except json.JSONDecodeError: extracted_data = {}
|
| 401 |
+
else: extracted_data = {}
|
| 402 |
+
except Exception: extracted_data = {}
|
| 403 |
+
|
| 404 |
+
if "error" in extracted_data or not extracted_data:
|
| 405 |
+
print("Adaptive Vision: Attempting medium resolution (150 DPI)...")
|
| 406 |
+
med_res_images = [Image.open(io.BytesIO(page.get_pixmap(dpi=150).tobytes("png"))) for page in pdf_doc]
|
| 407 |
+
extracted_data = _extract_brochure_data_with_vision(med_res_images, user_api_key, selected_model_key)
|
| 408 |
+
is_poor_quality = "error" in extracted_data or (not extracted_data.get("contacts") and len(extracted_data.get("raw_text", "")) < 50)
|
| 409 |
+
if is_poor_quality:
|
| 410 |
+
print("Medium resolution failed. Retrying with high resolution (300 DPI)...")
|
| 411 |
+
high_res_images = [Image.open(io.BytesIO(page.get_pixmap(dpi=300).tobytes("png"))) for page in pdf_doc]
|
| 412 |
+
extracted_data = _extract_brochure_data_with_vision(high_res_images, user_api_key, selected_model_key)
|
| 413 |
+
|
| 414 |
+
if "error" in extracted_data: return jsonify(extracted_data), 500
|
| 415 |
+
|
| 416 |
+
final_brochure_object = {
|
| 417 |
+
"id": brochure_json_id,
|
| 418 |
+
"company_name": extracted_data.get("company_name", "Unknown Company"),
|
| 419 |
+
"contacts": extracted_data.get("contacts", []),
|
| 420 |
+
"raw_text": extracted_data.get("raw_text", ""),
|
| 421 |
+
"image_filename": pdf_filename
|
| 422 |
+
}
|
| 423 |
+
for contact in final_brochure_object["contacts"]: contact["id"] = os.urandom(8).hex()
|
| 424 |
+
|
| 425 |
+
user_brochures = _load_user_data(user_api_key, 'brochures')
|
| 426 |
+
user_brochures.insert(0, final_brochure_object)
|
| 427 |
+
_save_user_data(user_api_key, 'brochures', user_brochures)
|
| 428 |
+
|
| 429 |
+
try:
|
| 430 |
+
user_hash = hashlib.sha256(user_api_key.encode()).hexdigest()
|
| 431 |
+
new_brochure = Brochure(
|
| 432 |
+
json_id=brochure_json_id,
|
| 433 |
+
company_name=final_brochure_object.get("company_name"),
|
| 434 |
+
raw_text=final_brochure_object.get("raw_text"),
|
| 435 |
+
source_document=file.filename,
|
| 436 |
+
user_hash=user_hash
|
| 437 |
+
)
|
| 438 |
+
db.session.add(new_brochure)
|
| 439 |
+
|
| 440 |
+
for contact_data in final_brochure_object.get("contacts", []):
|
| 441 |
+
new_contact = Contact(
|
| 442 |
+
json_id=contact_data['id'],
|
| 443 |
+
owner_name=contact_data.get("Owner Name"),
|
| 444 |
+
email=contact_data.get("Email"),
|
| 445 |
+
phone_number=contact_data.get("Number"),
|
| 446 |
+
brochure=new_brochure
|
| 447 |
+
)
|
| 448 |
+
db.session.add(new_contact)
|
| 449 |
+
|
| 450 |
+
db.session.commit()
|
| 451 |
+
print(f"Successfully saved brochure '{new_brochure.company_name}' and {len(new_brochure.contacts)} contacts to the database.")
|
| 452 |
+
except Exception as e:
|
| 453 |
+
db.session.rollback()
|
| 454 |
+
print(f"DATABASE ERROR: Failed to save brochure data. Error: {e}")
|
| 455 |
+
traceback.print_exc()
|
| 456 |
+
|
| 457 |
+
print("Indexing separated and cleaned content for high-quality RAG...")
|
| 458 |
+
contacts = final_brochure_object.get("contacts", [])
|
| 459 |
+
if contacts:
|
| 460 |
+
contact_text_parts = [f"Contact information for {final_brochure_object.get('company_name', 'this company')}:"]
|
| 461 |
+
for contact in contacts:
|
| 462 |
+
name, email, number = contact.get("Owner Name"), contact.get("Email"), contact.get("Number")
|
| 463 |
+
contact_info = [f"Name: {name}"]
|
| 464 |
+
if email: contact_info.append(f"Email: {email}")
|
| 465 |
+
if number: contact_info.append(f"Phone: {number}")
|
| 466 |
+
contact_text_parts.append("- " + ", ".join(contact_info))
|
| 467 |
+
contacts_document_text = "\n".join(contact_text_parts)
|
| 468 |
+
rag_core.add_document_to_knowledge_base(user_api_key, contacts_document_text, f"{brochure_json_id}_contacts", 'brochures')
|
| 469 |
+
clean_info_text = _create_clean_info_text(final_brochure_object)
|
| 470 |
+
if clean_info_text and clean_info_text.strip():
|
| 471 |
+
rag_core.add_document_to_knowledge_base(user_api_key, clean_info_text, f"{brochure_json_id}_info", 'brochures')
|
| 472 |
+
print("RAG indexing completed successfully!")
|
| 473 |
+
|
| 474 |
+
return jsonify(final_brochure_object)
|
| 475 |
+
except Exception as e:
|
| 476 |
+
print(f"An error occurred in process_brochure endpoint: {e}")
|
| 477 |
+
traceback.print_exc()
|
| 478 |
+
return jsonify({'error': f'Server processing failed: {e}'}), 500
|
| 479 |
+
|
| 480 |
+
@app.route('/chat', methods=['POST'])
|
| 481 |
+
def chat_endpoint():
|
| 482 |
+
data = request.get_json()
|
| 483 |
+
query_text, mode, selected_model_key = data.get('query'), data.get('mode'), data.get('selectedModel')
|
| 484 |
+
user_api_key = OPENROUTER_API_KEY # Use hardcoded server-side API key
|
| 485 |
+
if not all([user_api_key, query_text, mode, selected_model_key]): return jsonify({'error': 'Query, mode, and model are required.'}), 400
|
| 486 |
+
if selected_model_key not in MODEL_MAP: return jsonify({'error': 'Invalid model selected'}), 400
|
| 487 |
+
try:
|
| 488 |
+
session['api_key'] = user_api_key
|
| 489 |
+
intent = 'synthesis' if "table" in query_text.lower() or "list all" in query_text.lower() else 'research'
|
| 490 |
+
print(f"Intent detected: {intent}")
|
| 491 |
+
if intent == 'synthesis':
|
| 492 |
+
data_source = _load_user_data(user_api_key, mode)
|
| 493 |
+
synthesis_data = []
|
| 494 |
+
if mode == 'brochures':
|
| 495 |
+
for brochure in data_source:
|
| 496 |
+
for contact in brochure.get('contacts', []):
|
| 497 |
+
synthesis_data.append({"Company Name": brochure.get("company_name"), "Owner Name": contact.get("Owner Name"), "Email": contact.get("Email"), "Number": contact.get("Number")})
|
| 498 |
+
else:
|
| 499 |
+
synthesis_data = data_source
|
| 500 |
+
synthesis_prompt = f"As a data analyst, create a markdown table based on the user's request from the following JSON data.\nJSON: {json.dumps(synthesis_data, indent=2)}\nRequest: {query_text}\nAnswer:"
|
| 501 |
+
answer = _call_openrouter_api_text_only_with_fallback(user_api_key, selected_model_key, synthesis_prompt)
|
| 502 |
+
else:
|
| 503 |
+
answer = rag_core.query_knowledge_base(user_api_key, query_text, mode, selected_model_key)
|
| 504 |
+
return jsonify({'answer': answer})
|
| 505 |
+
except Exception as e:
|
| 506 |
+
print(f"Error in /chat endpoint: {e}"); traceback.print_exc()
|
| 507 |
+
return jsonify({'error': 'An internal error occurred.'}), 500
|
| 508 |
+
|
| 509 |
+
@app.route('/load_data/<mode>', methods=['POST'])
|
| 510 |
+
def load_data_endpoint(mode):
|
| 511 |
+
user_api_key = OPENROUTER_API_KEY # Use hardcoded server-side API key
|
| 512 |
+
if not user_api_key: return jsonify({'error': 'Server API key not configured'}), 400
|
| 513 |
+
user_data = _load_user_data(user_api_key, mode)
|
| 514 |
+
return jsonify(user_data)
|
| 515 |
+
|
| 516 |
+
@app.route('/update_card/<mode>/<item_id>', methods=['POST'])
|
| 517 |
+
def update_card_endpoint(mode, item_id):
|
| 518 |
+
data = request.get_json()
|
| 519 |
+
field, value, contact_id = data.get('field'), data.get('value'), data.get('contactId')
|
| 520 |
+
user_api_key = OPENROUTER_API_KEY # Use hardcoded server-side API key
|
| 521 |
+
if not user_api_key: return jsonify({'error': 'Server API key not configured'}), 400
|
| 522 |
+
|
| 523 |
+
# Step 1: Update JSON file (Existing Logic, Unchanged)
|
| 524 |
+
user_data = _load_user_data(user_api_key, mode)
|
| 525 |
+
item_found_in_json = False
|
| 526 |
+
if mode == 'cards':
|
| 527 |
+
for card in user_data:
|
| 528 |
+
if card.get('id') == item_id:
|
| 529 |
+
card[field] = value
|
| 530 |
+
item_found_in_json = True
|
| 531 |
+
break
|
| 532 |
+
elif mode == 'brochures':
|
| 533 |
+
for brochure in user_data:
|
| 534 |
+
if brochure.get('id') == item_id and contact_id:
|
| 535 |
+
for contact in brochure.get('contacts', []):
|
| 536 |
+
if contact.get('id') == contact_id:
|
| 537 |
+
contact[field] = value
|
| 538 |
+
item_found_in_json = True
|
| 539 |
+
break
|
| 540 |
+
if item_found_in_json: break
|
| 541 |
+
if item_found_in_json:
|
| 542 |
+
_save_user_data(user_api_key, mode, user_data)
|
| 543 |
+
|
| 544 |
+
# Step 1.5: Update ChromaDB (RAG knowledge base)
|
| 545 |
+
try:
|
| 546 |
+
if mode == 'cards':
|
| 547 |
+
# Get the updated card data
|
| 548 |
+
updated_card = next((c for c in user_data if c.get('id') == item_id), None)
|
| 549 |
+
if updated_card:
|
| 550 |
+
# Remove old document and re-add with updated content
|
| 551 |
+
rag_core.remove_document_from_knowledge_base(user_api_key, item_id, mode)
|
| 552 |
+
raw_text = ' '.join(str(v) for k, v in updated_card.items() if v and k not in ['id', 'image_filename'])
|
| 553 |
+
rag_core.add_document_to_knowledge_base(user_api_key, raw_text, item_id, mode)
|
| 554 |
+
print(f"ChromaDB: Updated document {item_id} in {mode} knowledge base")
|
| 555 |
+
except Exception as e:
|
| 556 |
+
print(f"ChromaDB update warning: {e}")
|
| 557 |
+
|
| 558 |
+
# ## FINAL DATABASE CODE ##
|
| 559 |
+
# Step 2: Update Database (New Logic)
|
| 560 |
+
try:
|
| 561 |
+
user_hash = hashlib.sha256(user_api_key.encode()).hexdigest()
|
| 562 |
+
if mode == 'cards':
|
| 563 |
+
db_card = BusinessCard.query.filter_by(json_id=item_id, user_hash=user_hash).first()
|
| 564 |
+
if db_card:
|
| 565 |
+
field_map = {"Owner Name": "owner_name", "Company Name": "company_name", "Email": "email", "Number": "phone_number", "Address": "address"}
|
| 566 |
+
db_field = field_map.get(field)
|
| 567 |
+
if db_field:
|
| 568 |
+
setattr(db_card, db_field, value)
|
| 569 |
+
db.session.commit()
|
| 570 |
+
print(f"Database updated for business card json_id: {item_id}")
|
| 571 |
+
return jsonify({"success": True})
|
| 572 |
+
elif mode == 'brochures' and contact_id:
|
| 573 |
+
db_contact = Contact.query.filter_by(json_id=contact_id).first()
|
| 574 |
+
if db_contact and db_contact.brochure.user_hash == user_hash:
|
| 575 |
+
field_map = {"Owner Name": "owner_name", "Email": "email", "Number": "phone_number"}
|
| 576 |
+
db_field = field_map.get(field)
|
| 577 |
+
if db_field:
|
| 578 |
+
setattr(db_contact, db_field, value)
|
| 579 |
+
db.session.commit()
|
| 580 |
+
print(f"Database updated for brochure contact json_id: {contact_id}")
|
| 581 |
+
return jsonify({"success": True})
|
| 582 |
+
|
| 583 |
+
if not item_found_in_json:
|
| 584 |
+
return jsonify({"success": False, "message": "Item not found in JSON"}), 404
|
| 585 |
+
return jsonify({"success": True, "message": "JSON updated, but item not found in DB."})
|
| 586 |
+
|
| 587 |
+
except Exception as e:
|
| 588 |
+
db.session.rollback()
|
| 589 |
+
print(f"DATABASE ERROR: Failed to update record. Error: {e}")
|
| 590 |
+
return jsonify({"success": False, "message": "Database update failed."}), 500
|
| 591 |
+
# ## END FINAL DATABASE CODE ##
|
| 592 |
+
|
| 593 |
+
|
| 594 |
+
@app.route('/delete_card/<mode>/<item_id>', methods=['DELETE'])
|
| 595 |
+
def delete_card_endpoint(mode, item_id):
|
| 596 |
+
data = request.get_json()
|
| 597 |
+
contact_id = data.get('contactId')
|
| 598 |
+
user_api_key = OPENROUTER_API_KEY # Use hardcoded server-side API key
|
| 599 |
+
if not user_api_key: return jsonify({'error': 'Server API key not configured'}), 400
|
| 600 |
+
|
| 601 |
+
# Step 1: Delete from JSON file (Existing Logic, Unchanged)
|
| 602 |
+
user_data = _load_user_data(user_api_key, mode)
|
| 603 |
+
item_found_in_json = False
|
| 604 |
+
original_len = len(user_data)
|
| 605 |
+
if mode == 'cards':
|
| 606 |
+
user_data = [c for c in user_data if c.get('id') != item_id]
|
| 607 |
+
if len(user_data) < original_len: item_found_in_json = True
|
| 608 |
+
elif mode == 'brochures':
|
| 609 |
+
if contact_id:
|
| 610 |
+
for brochure in user_data:
|
| 611 |
+
if brochure.get('id') == item_id:
|
| 612 |
+
original_contacts_len = len(brochure.get('contacts', []))
|
| 613 |
+
brochure['contacts'] = [c for c in brochure.get('contacts', []) if c.get('id') != contact_id]
|
| 614 |
+
if len(brochure.get('contacts', [])) < original_contacts_len:
|
| 615 |
+
item_found_in_json = True
|
| 616 |
+
break
|
| 617 |
+
else: # Delete whole brochure
|
| 618 |
+
user_data = [b for b in user_data if b.get('id') != item_id]
|
| 619 |
+
if len(user_data) < original_len: item_found_in_json = True
|
| 620 |
+
if item_found_in_json:
|
| 621 |
+
_save_user_data(user_api_key, mode, user_data)
|
| 622 |
+
|
| 623 |
+
# Step 1.5: Delete from ChromaDB (RAG knowledge base)
|
| 624 |
+
try:
|
| 625 |
+
if mode == 'cards' or (mode == 'brochures' and not contact_id):
|
| 626 |
+
# Remove document vectors from ChromaDB
|
| 627 |
+
rag_core.remove_document_from_knowledge_base(user_api_key, item_id, mode)
|
| 628 |
+
print(f"ChromaDB: Removed document {item_id} from {mode} knowledge base")
|
| 629 |
+
except Exception as e:
|
| 630 |
+
print(f"ChromaDB removal warning: {e}")
|
| 631 |
+
|
| 632 |
+
# ## FINAL DATABASE CODE ##
|
| 633 |
+
# Step 2: Delete from Database (New Logic)
|
| 634 |
+
try:
|
| 635 |
+
user_hash = hashlib.sha256(user_api_key.encode()).hexdigest()
|
| 636 |
+
if mode == 'cards':
|
| 637 |
+
db_card = BusinessCard.query.filter_by(json_id=item_id, user_hash=user_hash).first()
|
| 638 |
+
if db_card:
|
| 639 |
+
db.session.delete(db_card)
|
| 640 |
+
db.session.commit()
|
| 641 |
+
print(f"Database record deleted for business card json_id: {item_id}")
|
| 642 |
+
return jsonify({"success": True})
|
| 643 |
+
elif mode == 'brochures':
|
| 644 |
+
if contact_id:
|
| 645 |
+
db_contact = Contact.query.filter_by(json_id=contact_id).first()
|
| 646 |
+
if db_contact and db_contact.brochure.user_hash == user_hash:
|
| 647 |
+
db.session.delete(db_contact)
|
| 648 |
+
db.session.commit()
|
| 649 |
+
print(f"Database record deleted for brochure contact json_id: {contact_id}")
|
| 650 |
+
return jsonify({"success": True})
|
| 651 |
+
else: # Delete whole brochure
|
| 652 |
+
db_brochure = Brochure.query.filter_by(json_id=item_id, user_hash=user_hash).first()
|
| 653 |
+
if db_brochure:
|
| 654 |
+
db.session.delete(db_brochure) # Cascading delete will handle linked contacts
|
| 655 |
+
db.session.commit()
|
| 656 |
+
print(f"Database record deleted for brochure json_id: {item_id}")
|
| 657 |
+
return jsonify({"success": True})
|
| 658 |
+
|
| 659 |
+
if not item_found_in_json:
|
| 660 |
+
return jsonify({"success": False, "message": "Item not found in JSON"}), 404
|
| 661 |
+
return jsonify({"success": True, "message": "JSON deleted, but item not found in DB."})
|
| 662 |
+
|
| 663 |
+
except Exception as e:
|
| 664 |
+
db.session.rollback()
|
| 665 |
+
print(f"DATABASE ERROR: Failed to delete record. Error: {e}")
|
| 666 |
+
return jsonify({"success": False, "message": "Database delete failed."}), 500
|
| 667 |
+
# ## END FINAL DATABASE CODE ##
|
| 668 |
+
|
| 669 |
+
@app.route('/')
|
| 670 |
+
def serve_dashboard():
|
| 671 |
+
return render_template('index.html')
|
| 672 |
+
|
| 673 |
+
@app.route('/uploads/<filename>')
|
| 674 |
+
def uploaded_file(filename):
|
| 675 |
+
return send_from_directory(UPLOAD_FOLDER, filename)
|
| 676 |
+
|
| 677 |
+
# Health check endpoint - responds immediately without waiting for model loading
|
| 678 |
+
@app.route('/health')
|
| 679 |
+
def health_check():
|
| 680 |
+
return jsonify({"status": "ok", "message": "Service is running"}), 200
|
| 681 |
+
|
| 682 |
+
# Create database tables (lightweight - runs at import time)
|
| 683 |
+
with app.app_context():
|
| 684 |
+
db.create_all()
|
| 685 |
+
print("Database tables (business_card, brochure, contact) checked and created if necessary.")
|
| 686 |
+
|
| 687 |
+
# Lazy initialization for RAG system (deferred until first request)
|
| 688 |
+
_rag_initialized = False
|
| 689 |
+
|
| 690 |
+
@app.before_request
|
| 691 |
+
def ensure_rag_initialized():
|
| 692 |
+
global _rag_initialized
|
| 693 |
+
# Skip initialization for health checks and static files
|
| 694 |
+
if request.endpoint in ('health_check', 'uploaded_file', 'static', 'serve_dashboard'):
|
| 695 |
+
return
|
| 696 |
+
if not _rag_initialized:
|
| 697 |
+
print("First request received - initializing RAG system...")
|
| 698 |
+
try:
|
| 699 |
+
success = rag_core.initialize_rag_system()
|
| 700 |
+
_rag_initialized = True
|
| 701 |
+
if success:
|
| 702 |
+
print("RAG system initialized successfully!")
|
| 703 |
+
else:
|
| 704 |
+
print("RAG system not available - OCR features will still work")
|
| 705 |
+
except Exception as e:
|
| 706 |
+
print(f"RAG initialization error (non-fatal): {e}")
|
| 707 |
+
_rag_initialized = True # Mark as attempted so we don't retry
|
| 708 |
+
|
| 709 |
+
if __name__ == "__main__":
|
| 710 |
+
# Local development - initialize immediately for better dev experience
|
| 711 |
+
try:
|
| 712 |
+
rag_core.initialize_rag_system()
|
| 713 |
+
except Exception as e:
|
| 714 |
+
print(f"RAG initialization failed: {e}")
|
| 715 |
+
print("App will start without RAG features")
|
| 716 |
+
print("--- Server is starting! ---")
|
| 717 |
+
print(f"User-specific data will be saved in '{os.path.abspath(DATA_FOLDER)}'")
|
| 718 |
+
print("To use the dashboard, open your web browser and go to: http://127.0.0.1:5000")
|
| 719 |
+
webbrowser.open_new('http://127.0.0.1:5000')
|
| 720 |
+
app.run(host='0.0.0.0', port=5000, debug=False, use_reloader=False)
|
download_nltk.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import nltk
|
| 2 |
+
import os
|
| 3 |
+
import ssl
|
| 4 |
+
|
| 5 |
+
# --- This part is crucial to bypass potential SSL certificate verification issues ---
|
| 6 |
+
try:
|
| 7 |
+
_create_unverified_https_context = ssl._create_unverified_context
|
| 8 |
+
except AttributeError:
|
| 9 |
+
pass
|
| 10 |
+
else:
|
| 11 |
+
ssl._create_default_https_context = _create_unverified_https_context
|
| 12 |
+
# --- End of SSL fix ---
|
| 13 |
+
|
| 14 |
+
# Define the local directory to store NLTK data
|
| 15 |
+
DOWNLOAD_DIR = os.path.join(os.path.dirname(__file__), 'nltk_data')
|
| 16 |
+
|
| 17 |
+
# Create the directory if it doesn't exist
|
| 18 |
+
if not os.path.exists(DOWNLOAD_DIR):
|
| 19 |
+
os.makedirs(DOWNLOAD_DIR)
|
| 20 |
+
print(f"Created directory: {DOWNLOAD_DIR}")
|
| 21 |
+
|
| 22 |
+
# Download the necessary packages to our local directory
|
| 23 |
+
print(f"Downloading NLTK packages to: {DOWNLOAD_DIR}")
|
| 24 |
+
nltk.download('punkt', download_dir=DOWNLOAD_DIR)
|
| 25 |
+
nltk.download('stopwords', download_dir=DOWNLOAD_DIR)
|
| 26 |
+
nltk.download('punkt_tab', download_dir=DOWNLOAD_DIR)
|
| 27 |
+
|
| 28 |
+
print("\n✅ All necessary NLTK packages have been downloaded successfully.")
|
| 29 |
+
|
| 30 |
+
# Pre-download sentence-transformer models for faster startup
|
| 31 |
+
# These are cached by the library and will be reused at runtime
|
| 32 |
+
print("\nPre-downloading ML models for faster startup (this may take a few minutes)...")
|
| 33 |
+
try:
|
| 34 |
+
from sentence_transformers import SentenceTransformer, CrossEncoder
|
| 35 |
+
print(" - Downloading SentenceTransformer model...")
|
| 36 |
+
SentenceTransformer('all-mpnet-base-v2')
|
| 37 |
+
print(" - Downloading CrossEncoder model...")
|
| 38 |
+
CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')
|
| 39 |
+
print("✅ ML models cached successfully!")
|
| 40 |
+
except Exception as e:
|
| 41 |
+
print(f"⚠️ Warning: Could not pre-download ML models: {e}")
|
| 42 |
+
print(" Models will be downloaded on first request.")
|
| 43 |
+
|
| 44 |
+
print("\nYou can now run your main application.")
|
gunicorn.conf.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Gunicorn configuration for Render deployment
|
| 2 |
+
# See: https://docs.gunicorn.org/en/stable/settings.html
|
| 3 |
+
|
| 4 |
+
import os
|
| 5 |
+
|
| 6 |
+
# CRITICAL: Bind to the PORT environment variable that Render provides
|
| 7 |
+
# This is the most important setting for Render deployment
|
| 8 |
+
bind = f"0.0.0.0:{os.environ.get('PORT', '10000')}"
|
| 9 |
+
|
| 10 |
+
# Allow 5 minutes for heavy model loading on first request
|
| 11 |
+
timeout = 300
|
| 12 |
+
|
| 13 |
+
# Graceful timeout for shutdown
|
| 14 |
+
graceful_timeout = 120
|
| 15 |
+
|
| 16 |
+
# Single worker for memory efficiency on free tier
|
| 17 |
+
workers = 1
|
| 18 |
+
|
| 19 |
+
# Don't preload app - defer initialization until worker starts
|
| 20 |
+
preload_app = False
|
| 21 |
+
|
| 22 |
+
# Log level for debugging
|
| 23 |
+
loglevel = "info"
|
| 24 |
+
|
| 25 |
+
# Enable access logging for debugging
|
| 26 |
+
accesslog = "-"
|
| 27 |
+
errorlog = "-"
|
| 28 |
+
|
| 29 |
+
# Print startup message
|
| 30 |
+
print(f"Gunicorn binding to: {bind}")
|
models.py
ADDED
|
@@ -0,0 +1,612 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# models.py - UPDATED
|
| 2 |
+
|
| 3 |
+
from flask_sqlalchemy import SQLAlchemy
|
| 4 |
+
from datetime import datetime, date
|
| 5 |
+
from sqlalchemy import Text
|
| 6 |
+
import json
|
| 7 |
+
|
| 8 |
+
db = SQLAlchemy()
|
| 9 |
+
|
| 10 |
+
# Business Card Model
|
| 11 |
+
class BusinessCard(db.Model):
|
| 12 |
+
__tablename__ = 'business_cards'
|
| 13 |
+
|
| 14 |
+
id = db.Column(db.Integer, primary_key=True)
|
| 15 |
+
json_id = db.Column(db.String(64), unique=True, nullable=False)
|
| 16 |
+
owner_name = db.Column(db.String(200), nullable=False)
|
| 17 |
+
company_name = db.Column(db.String(200))
|
| 18 |
+
email = db.Column(db.String(120))
|
| 19 |
+
phone_number = db.Column(db.String(50))
|
| 20 |
+
address = db.Column(db.Text)
|
| 21 |
+
source_document = db.Column(db.String(500))
|
| 22 |
+
user_hash = db.Column(db.String(64), nullable=False)
|
| 23 |
+
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
| 24 |
+
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
| 25 |
+
|
| 26 |
+
def to_dict(self):
|
| 27 |
+
return {
|
| 28 |
+
'id': self.json_id,
|
| 29 |
+
'Owner Name': self.owner_name,
|
| 30 |
+
'Company Name': self.company_name,
|
| 31 |
+
'Email': self.email,
|
| 32 |
+
'Number': self.phone_number,
|
| 33 |
+
'Address': self.address,
|
| 34 |
+
'source_document': self.source_document,
|
| 35 |
+
'image_filename': f"{self.json_id}.png",
|
| 36 |
+
'created_at': self.created_at.isoformat() if self.created_at else None,
|
| 37 |
+
'updated_at': self.updated_at.isoformat() if self.updated_at else None
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
# Brochure Model
|
| 41 |
+
class Brochure(db.Model):
|
| 42 |
+
__tablename__ = 'brochures'
|
| 43 |
+
|
| 44 |
+
id = db.Column(db.Integer, primary_key=True)
|
| 45 |
+
json_id = db.Column(db.String(64), unique=True, nullable=False)
|
| 46 |
+
company_name = db.Column(db.String(200), nullable=False)
|
| 47 |
+
raw_text = db.Column(db.Text)
|
| 48 |
+
source_document = db.Column(db.String(500))
|
| 49 |
+
user_hash = db.Column(db.String(64), nullable=False)
|
| 50 |
+
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
| 51 |
+
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
| 52 |
+
|
| 53 |
+
contacts = db.relationship('Contact', backref='brochure', lazy=True, cascade='all, delete-orphan')
|
| 54 |
+
|
| 55 |
+
def to_dict(self):
|
| 56 |
+
return {
|
| 57 |
+
'id': self.json_id,
|
| 58 |
+
'company_name': self.company_name,
|
| 59 |
+
'raw_text': self.raw_text,
|
| 60 |
+
'contacts': [contact.to_dict() for contact in self.contacts],
|
| 61 |
+
'image_filename': f"{self.json_id}.pdf",
|
| 62 |
+
'created_at': self.created_at.isoformat() if self.created_at else None,
|
| 63 |
+
'updated_at': self.updated_at.isoformat() if self.updated_at else None
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
# Contact Model (for Brochures)
|
| 67 |
+
class Contact(db.Model):
|
| 68 |
+
__tablename__ = 'brochure_contacts'
|
| 69 |
+
|
| 70 |
+
id = db.Column(db.Integer, primary_key=True)
|
| 71 |
+
json_id = db.Column(db.String(64), unique=True, nullable=False)
|
| 72 |
+
brochure_id = db.Column(db.Integer, db.ForeignKey('brochures.id'), nullable=False)
|
| 73 |
+
owner_name = db.Column(db.String(200), nullable=False)
|
| 74 |
+
email = db.Column(db.String(120))
|
| 75 |
+
phone_number = db.Column(db.String(50))
|
| 76 |
+
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
| 77 |
+
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
| 78 |
+
|
| 79 |
+
def to_dict(self):
|
| 80 |
+
return {
|
| 81 |
+
'id': self.json_id,
|
| 82 |
+
'Owner Name': self.owner_name,
|
| 83 |
+
'Email': self.email,
|
| 84 |
+
'Number': self.phone_number,
|
| 85 |
+
'brochure_id': self.brochure_id,
|
| 86 |
+
'created_at': self.created_at.isoformat() if self.created_at else None,
|
| 87 |
+
'updated_at': self.updated_at.isoformat() if self.updated_at else None
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
# User Model for CRM - ENHANCED
|
| 91 |
+
class User(db.Model):
|
| 92 |
+
__tablename__ = 'users'
|
| 93 |
+
|
| 94 |
+
id = db.Column(db.Integer, primary_key=True)
|
| 95 |
+
username = db.Column(db.String(80), unique=True, nullable=False)
|
| 96 |
+
password_hash = db.Column(db.String(255), nullable=False)
|
| 97 |
+
email = db.Column(db.String(120))
|
| 98 |
+
name = db.Column(db.String(100))
|
| 99 |
+
phone = db.Column(db.String(20))
|
| 100 |
+
department = db.Column(db.String(100))
|
| 101 |
+
role = db.Column(db.String(20), default='employee')
|
| 102 |
+
status = db.Column(db.String(20), default='active')
|
| 103 |
+
dark_mode = db.Column(db.Boolean, default=False) # NEW: Dark mode preference
|
| 104 |
+
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
| 105 |
+
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
| 106 |
+
last_login = db.Column(db.DateTime)
|
| 107 |
+
|
| 108 |
+
# Relationships
|
| 109 |
+
contacts_assigned = db.relationship('CRMContact', foreign_keys='CRMContact.assigned_to', backref='assigned_user', lazy=True)
|
| 110 |
+
deals_assigned = db.relationship('Deal', foreign_keys='Deal.assigned_to', backref='assigned_user', lazy=True)
|
| 111 |
+
tasks_assigned = db.relationship('Task', foreign_keys='Task.assigned_to', backref='assigned_user', lazy=True)
|
| 112 |
+
activities = db.relationship('Activity', backref='user', lazy=True)
|
| 113 |
+
comments = db.relationship('Comment', backref='user', lazy=True)
|
| 114 |
+
notifications = db.relationship('Notification', backref='user', lazy=True)
|
| 115 |
+
# In User model
|
| 116 |
+
task_assignments_created = db.relationship(
|
| 117 |
+
'TaskAssignment',
|
| 118 |
+
foreign_keys='TaskAssignment.assigned_by',
|
| 119 |
+
backref='assigner',
|
| 120 |
+
lazy=True
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
task_assignments_received = db.relationship(
|
| 124 |
+
'TaskAssignment',
|
| 125 |
+
foreign_keys='TaskAssignment.user_id',
|
| 126 |
+
backref='assignee',
|
| 127 |
+
lazy=True
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def to_dict(self):
|
| 132 |
+
return {
|
| 133 |
+
'id': self.id,
|
| 134 |
+
'username': self.username,
|
| 135 |
+
'email': self.email,
|
| 136 |
+
'name': self.name,
|
| 137 |
+
'phone': self.phone,
|
| 138 |
+
'department': self.department,
|
| 139 |
+
'role': self.role,
|
| 140 |
+
'status': self.status,
|
| 141 |
+
'dark_mode': self.dark_mode,
|
| 142 |
+
'created_at': self.created_at.isoformat() if self.created_at else None,
|
| 143 |
+
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
| 144 |
+
'last_login': self.last_login.isoformat() if self.last_login else None
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
# Company Model for CRM - ENHANCED
|
| 148 |
+
class Company(db.Model):
|
| 149 |
+
__tablename__ = 'companies'
|
| 150 |
+
|
| 151 |
+
id = db.Column(db.Integer, primary_key=True)
|
| 152 |
+
name = db.Column(db.String(200), unique=True, nullable=False)
|
| 153 |
+
industry = db.Column(db.String(100))
|
| 154 |
+
size = db.Column(db.String(50))
|
| 155 |
+
website = db.Column(db.String(200))
|
| 156 |
+
description = db.Column(db.Text)
|
| 157 |
+
tags = db.Column(db.String(500)) # NEW: Tags for companies
|
| 158 |
+
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
| 159 |
+
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
| 160 |
+
|
| 161 |
+
def to_dict(self):
|
| 162 |
+
return {
|
| 163 |
+
'id': self.id,
|
| 164 |
+
'name': self.name,
|
| 165 |
+
'industry': self.industry,
|
| 166 |
+
'size': self.size,
|
| 167 |
+
'website': self.website,
|
| 168 |
+
'description': self.description,
|
| 169 |
+
'tags': self.tags,
|
| 170 |
+
'created_at': self.created_at.isoformat() if self.created_at else None,
|
| 171 |
+
'updated_at': self.updated_at.isoformat() if self.updated_at else None
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
# CRM Contact Model - ENHANCED
|
| 175 |
+
class CRMContact(db.Model):
|
| 176 |
+
__tablename__ = 'crm_contacts'
|
| 177 |
+
|
| 178 |
+
id = db.Column(db.Integer, primary_key=True)
|
| 179 |
+
name = db.Column(db.String(200), nullable=False)
|
| 180 |
+
email = db.Column(db.String(120))
|
| 181 |
+
phone = db.Column(db.String(50))
|
| 182 |
+
company = db.Column(db.String(200))
|
| 183 |
+
position = db.Column(db.String(100))
|
| 184 |
+
tags = db.Column(db.String(500))
|
| 185 |
+
status = db.Column(db.String(50), nullable=True)
|
| 186 |
+
source = db.Column(db.String(100), default='manual')
|
| 187 |
+
assigned_to = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
| 188 |
+
notes = db.Column(db.Text)
|
| 189 |
+
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
| 190 |
+
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
| 191 |
+
|
| 192 |
+
# Relationships
|
| 193 |
+
deals = db.relationship('Deal', backref='contact', lazy=True)
|
| 194 |
+
tasks = db.relationship('Task', backref='contact', lazy=True)
|
| 195 |
+
activities = db.relationship('Activity', backref='contact', lazy=True)
|
| 196 |
+
comments = db.relationship('Comment', backref='contact', lazy=True)
|
| 197 |
+
attachments = db.relationship('Attachment', backref='contact', lazy=True)
|
| 198 |
+
|
| 199 |
+
def to_dict(self):
|
| 200 |
+
return {
|
| 201 |
+
'id': self.id,
|
| 202 |
+
'name': self.name,
|
| 203 |
+
'email': self.email,
|
| 204 |
+
'phone': self.phone,
|
| 205 |
+
'company': self.company,
|
| 206 |
+
'position': self.position,
|
| 207 |
+
'tags': self.tags,
|
| 208 |
+
'status': self.status,
|
| 209 |
+
'source': self.source,
|
| 210 |
+
'assigned_to': self.assigned_to,
|
| 211 |
+
'notes': self.notes,
|
| 212 |
+
'created_at': self.created_at.isoformat() if self.created_at else None,
|
| 213 |
+
'updated_at': self.updated_at.isoformat() if self.updated_at else None
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
# Deal Model - ENHANCED with tags
|
| 217 |
+
class Deal(db.Model):
|
| 218 |
+
__tablename__ = 'deals'
|
| 219 |
+
|
| 220 |
+
id = db.Column(db.Integer, primary_key=True)
|
| 221 |
+
title = db.Column(db.String(200), nullable=False)
|
| 222 |
+
company = db.Column(db.String(200))
|
| 223 |
+
value = db.Column(db.Float, default=0.0)
|
| 224 |
+
stage = db.Column(db.String(50), default='lead')
|
| 225 |
+
probability = db.Column(db.Integer, default=0)
|
| 226 |
+
contact_id = db.Column(db.Integer, db.ForeignKey('crm_contacts.id'))
|
| 227 |
+
assigned_to = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
| 228 |
+
expected_close = db.Column(db.Date)
|
| 229 |
+
tags = db.Column(db.String(500)) # ENHANCED: Tags for deals
|
| 230 |
+
description = db.Column(db.Text)
|
| 231 |
+
created_date = db.Column(db.Date, default=datetime.utcnow)
|
| 232 |
+
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
| 233 |
+
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
| 234 |
+
|
| 235 |
+
# Relationships
|
| 236 |
+
activities = db.relationship('Activity', backref='deal', lazy=True)
|
| 237 |
+
comments = db.relationship('Comment', backref='deal', lazy=True)
|
| 238 |
+
attachments = db.relationship('Attachment', backref='deal', lazy=True)
|
| 239 |
+
tasks = db.relationship('Task', backref='deal', lazy=True)
|
| 240 |
+
|
| 241 |
+
def to_dict(self):
|
| 242 |
+
return {
|
| 243 |
+
'id': self.id,
|
| 244 |
+
'title': self.title,
|
| 245 |
+
'company': self.company,
|
| 246 |
+
'value': self.value,
|
| 247 |
+
'stage': self.stage,
|
| 248 |
+
'probability': self.probability,
|
| 249 |
+
'contact_id': self.contact_id,
|
| 250 |
+
'assigned_to': self.assigned_to,
|
| 251 |
+
'expected_close': self.expected_close.isoformat() if self.expected_close else None,
|
| 252 |
+
'tags': self.tags,
|
| 253 |
+
'description': self.description,
|
| 254 |
+
'created_date': self.created_date.isoformat() if self.created_date else None,
|
| 255 |
+
'created_at': self.created_at.isoformat() if self.created_at else None,
|
| 256 |
+
'updated_at': self.updated_at.isoformat() if self.updated_at else None
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
# Task Model - ENHANCED with assignment and mentions
|
| 260 |
+
class Task(db.Model):
|
| 261 |
+
__tablename__ = 'tasks'
|
| 262 |
+
|
| 263 |
+
id = db.Column(db.Integer, primary_key=True)
|
| 264 |
+
title = db.Column(db.String(200), nullable=False)
|
| 265 |
+
description = db.Column(db.Text)
|
| 266 |
+
due_date = db.Column(db.Date)
|
| 267 |
+
due_time = db.Column(db.Time)
|
| 268 |
+
priority = db.Column(db.String(20), default='medium')
|
| 269 |
+
assigned_to = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
| 270 |
+
contact_id = db.Column(db.Integer, db.ForeignKey('crm_contacts.id'))
|
| 271 |
+
deal_id = db.Column(db.Integer, db.ForeignKey('deals.id'))
|
| 272 |
+
completed = db.Column(db.Boolean, default=False)
|
| 273 |
+
completed_date = db.Column(db.DateTime)
|
| 274 |
+
tags = db.Column(db.String(500)) # ENHANCED: Tags for tasks
|
| 275 |
+
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
| 276 |
+
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
| 277 |
+
# Calendar fields
|
| 278 |
+
event_type = db.Column(db.String(20), default='task')
|
| 279 |
+
location = db.Column(db.String(200))
|
| 280 |
+
is_all_day = db.Column(db.Boolean, default=False)
|
| 281 |
+
reminder_minutes = db.Column(db.Integer, default=30)
|
| 282 |
+
# NEW: Mentions for tagging users
|
| 283 |
+
mentions = db.Column(db.String(500)) # Store mentioned user IDs
|
| 284 |
+
|
| 285 |
+
def to_dict(self):
|
| 286 |
+
return {
|
| 287 |
+
'id': self.id,
|
| 288 |
+
'title': self.title,
|
| 289 |
+
'description': self.description,
|
| 290 |
+
'due_date': self.due_date.isoformat() if self.due_date else None,
|
| 291 |
+
'due_time': self.due_time.isoformat() if self.due_time else None,
|
| 292 |
+
'priority': self.priority,
|
| 293 |
+
'assigned_to': self.assigned_to,
|
| 294 |
+
'contact_id': self.contact_id,
|
| 295 |
+
'deal_id': self.deal_id,
|
| 296 |
+
'completed': self.completed,
|
| 297 |
+
'completed_date': self.completed_date.isoformat() if self.completed_date else None,
|
| 298 |
+
'tags': self.tags,
|
| 299 |
+
'created_at': self.created_at.isoformat() if self.created_at else None,
|
| 300 |
+
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
| 301 |
+
'event_type': self.event_type,
|
| 302 |
+
'location': self.location,
|
| 303 |
+
'is_all_day': self.is_all_day,
|
| 304 |
+
'reminder_minutes': self.reminder_minutes,
|
| 305 |
+
'mentions': self.mentions
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
# Task Assignment Model - NEW
|
| 309 |
+
class TaskAssignment(db.Model):
|
| 310 |
+
__tablename__ = 'task_assignments'
|
| 311 |
+
|
| 312 |
+
id = db.Column(db.Integer, primary_key=True)
|
| 313 |
+
task_id = db.Column(db.Integer, db.ForeignKey('tasks.id'), nullable=False)
|
| 314 |
+
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
| 315 |
+
assigned_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
| 316 |
+
due_date = db.Column(db.Date)
|
| 317 |
+
notes = db.Column(db.Text)
|
| 318 |
+
status = db.Column(db.String(20), default='assigned')
|
| 319 |
+
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
| 320 |
+
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
| 321 |
+
|
| 322 |
+
def to_dict(self):
|
| 323 |
+
return {
|
| 324 |
+
'id': self.id,
|
| 325 |
+
'task_id': self.task_id,
|
| 326 |
+
'user_id': self.user_id,
|
| 327 |
+
'assigned_by': self.assigned_by,
|
| 328 |
+
'due_date': self.due_date.isoformat() if self.due_date else None,
|
| 329 |
+
'notes': self.notes,
|
| 330 |
+
'status': self.status,
|
| 331 |
+
'created_at': self.created_at.isoformat() if self.created_at else None,
|
| 332 |
+
'updated_at': self.updated_at.isoformat() if self.updated_at else None
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
# Activity Model (Audit Log) - ENHANCED
|
| 336 |
+
class Activity(db.Model):
|
| 337 |
+
__tablename__ = 'activities'
|
| 338 |
+
|
| 339 |
+
id = db.Column(db.Integer, primary_key=True)
|
| 340 |
+
contact_id = db.Column(db.Integer, db.ForeignKey('crm_contacts.id'))
|
| 341 |
+
deal_id = db.Column(db.Integer, db.ForeignKey('deals.id'))
|
| 342 |
+
task_id = db.Column(db.Integer, db.ForeignKey('tasks.id'))
|
| 343 |
+
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
| 344 |
+
action = db.Column(db.String(100), nullable=False)
|
| 345 |
+
description = db.Column(db.Text)
|
| 346 |
+
entity_type = db.Column(db.String(50)) # NEW: contact, deal, task, company
|
| 347 |
+
entity_id = db.Column(db.Integer) # NEW: ID of the entity
|
| 348 |
+
old_values = db.Column(db.Text) # NEW: JSON string of old values
|
| 349 |
+
new_values = db.Column(db.Text) # NEW: JSON string of new values
|
| 350 |
+
timestamp = db.Column(db.DateTime, default=datetime.utcnow)
|
| 351 |
+
|
| 352 |
+
def to_dict(self):
|
| 353 |
+
return {
|
| 354 |
+
'id': self.id,
|
| 355 |
+
'contact_id': self.contact_id,
|
| 356 |
+
'deal_id': self.deal_id,
|
| 357 |
+
'task_id': self.task_id,
|
| 358 |
+
'user_id': self.user_id,
|
| 359 |
+
'action': self.action,
|
| 360 |
+
'description': self.description,
|
| 361 |
+
'entity_type': self.entity_type,
|
| 362 |
+
'entity_id': self.entity_id,
|
| 363 |
+
'old_values': json.loads(self.old_values) if self.old_values else None,
|
| 364 |
+
'new_values': json.loads(self.new_values) if self.new_values else None,
|
| 365 |
+
'timestamp': self.timestamp.isoformat() if self.timestamp else None
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
# Attachment Model - ENHANCED
|
| 369 |
+
class Attachment(db.Model):
|
| 370 |
+
__tablename__ = 'attachments'
|
| 371 |
+
|
| 372 |
+
id = db.Column(db.Integer, primary_key=True)
|
| 373 |
+
contact_id = db.Column(db.Integer, db.ForeignKey('crm_contacts.id'))
|
| 374 |
+
deal_id = db.Column(db.Integer, db.ForeignKey('deals.id'))
|
| 375 |
+
task_id = db.Column(db.Integer, db.ForeignKey('tasks.id'))
|
| 376 |
+
filename = db.Column(db.String(255), nullable=False)
|
| 377 |
+
file_path = db.Column(db.String(500), nullable=False)
|
| 378 |
+
file_size = db.Column(db.Integer)
|
| 379 |
+
file_type = db.Column(db.String(100))
|
| 380 |
+
uploaded_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
| 381 |
+
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow)
|
| 382 |
+
description = db.Column(db.Text)
|
| 383 |
+
|
| 384 |
+
def to_dict(self):
|
| 385 |
+
return {
|
| 386 |
+
'id': self.id,
|
| 387 |
+
'contact_id': self.contact_id,
|
| 388 |
+
'deal_id': self.deal_id,
|
| 389 |
+
'task_id': self.task_id,
|
| 390 |
+
'filename': self.filename,
|
| 391 |
+
'file_path': self.file_path,
|
| 392 |
+
'file_size': self.file_size,
|
| 393 |
+
'file_type': self.file_type,
|
| 394 |
+
'uploaded_by': self.uploaded_by,
|
| 395 |
+
'uploaded_at': self.uploaded_at.isoformat() if self.uploaded_at else None,
|
| 396 |
+
'description': self.description
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
# Comment Model - ENHANCED with mentions
|
| 400 |
+
class Comment(db.Model):
|
| 401 |
+
__tablename__ = 'comments'
|
| 402 |
+
|
| 403 |
+
id = db.Column(db.Integer, primary_key=True)
|
| 404 |
+
contact_id = db.Column(db.Integer, db.ForeignKey('crm_contacts.id'))
|
| 405 |
+
deal_id = db.Column(db.Integer, db.ForeignKey('deals.id'))
|
| 406 |
+
task_id = db.Column(db.Integer, db.ForeignKey('tasks.id'))
|
| 407 |
+
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
| 408 |
+
text = db.Column(db.Text, nullable=False)
|
| 409 |
+
mentions = db.Column(db.String(500)) # NEW: Store mentioned user IDs
|
| 410 |
+
timestamp = db.Column(db.DateTime, default=datetime.utcnow)
|
| 411 |
+
|
| 412 |
+
def to_dict(self):
|
| 413 |
+
return {
|
| 414 |
+
'id': self.id,
|
| 415 |
+
'contact_id': self.contact_id,
|
| 416 |
+
'deal_id': self.deal_id,
|
| 417 |
+
'task_id': self.task_id,
|
| 418 |
+
'user_id': self.user_id,
|
| 419 |
+
'text': self.text,
|
| 420 |
+
'mentions': self.mentions,
|
| 421 |
+
'timestamp': self.timestamp.isoformat() if self.timestamp else None
|
| 422 |
+
}
|
| 423 |
+
|
| 424 |
+
# Notification Model - ENHANCED
|
| 425 |
+
class Notification(db.Model):
|
| 426 |
+
__tablename__ = 'notifications'
|
| 427 |
+
|
| 428 |
+
id = db.Column(db.Integer, primary_key=True)
|
| 429 |
+
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
| 430 |
+
title = db.Column(db.String(200), nullable=False)
|
| 431 |
+
message = db.Column(db.Text, nullable=False)
|
| 432 |
+
type = db.Column(db.String(50), default='info')
|
| 433 |
+
read = db.Column(db.Boolean, default=False)
|
| 434 |
+
timestamp = db.Column(db.DateTime, default=datetime.utcnow)
|
| 435 |
+
link = db.Column(db.String(500))
|
| 436 |
+
category = db.Column(db.String(50), default='system')
|
| 437 |
+
is_urgent = db.Column(db.Boolean, default=False)
|
| 438 |
+
# NEW: For task assignments and mentions
|
| 439 |
+
related_entity_type = db.Column(db.String(50)) # task, deal, contact
|
| 440 |
+
related_entity_id = db.Column(db.Integer)
|
| 441 |
+
|
| 442 |
+
def to_dict(self):
|
| 443 |
+
return {
|
| 444 |
+
'id': self.id,
|
| 445 |
+
'user_id': self.user_id,
|
| 446 |
+
'title': self.title,
|
| 447 |
+
'message': self.message,
|
| 448 |
+
'type': self.type,
|
| 449 |
+
'read': self.read,
|
| 450 |
+
'timestamp': self.timestamp.isoformat() if self.timestamp else None,
|
| 451 |
+
'link': self.link,
|
| 452 |
+
'category': self.category,
|
| 453 |
+
'is_urgent': self.is_urgent,
|
| 454 |
+
'related_entity_type': self.related_entity_type,
|
| 455 |
+
'related_entity_id': self.related_entity_id
|
| 456 |
+
}
|
| 457 |
+
|
| 458 |
+
# Reminder Model - NEW
|
| 459 |
+
class Reminder(db.Model):
|
| 460 |
+
__tablename__ = 'reminders'
|
| 461 |
+
|
| 462 |
+
id = db.Column(db.Integer, primary_key=True)
|
| 463 |
+
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
| 464 |
+
title = db.Column(db.String(200), nullable=False)
|
| 465 |
+
description = db.Column(db.Text)
|
| 466 |
+
remind_at = db.Column(db.DateTime, nullable=False)
|
| 467 |
+
is_completed = db.Column(db.Boolean, default=False)
|
| 468 |
+
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
| 469 |
+
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
| 470 |
+
|
| 471 |
+
def to_dict(self):
|
| 472 |
+
return {
|
| 473 |
+
'id': self.id,
|
| 474 |
+
'user_id': self.user_id,
|
| 475 |
+
'title': self.title,
|
| 476 |
+
'description': self.description,
|
| 477 |
+
'remind_at': self.remind_at.isoformat() if self.remind_at else None,
|
| 478 |
+
'is_completed': self.is_completed,
|
| 479 |
+
'created_at': self.created_at.isoformat() if self.created_at else None,
|
| 480 |
+
'updated_at': self.updated_at.isoformat() if self.updated_at else None
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
+
# System Settings Model
|
| 484 |
+
class SystemSetting(db.Model):
|
| 485 |
+
__tablename__ = 'system_settings'
|
| 486 |
+
|
| 487 |
+
id = db.Column(db.Integer, primary_key=True)
|
| 488 |
+
key = db.Column(db.String(100), unique=True, nullable=False)
|
| 489 |
+
value = db.Column(db.Text)
|
| 490 |
+
description = db.Column(db.String(500))
|
| 491 |
+
category = db.Column(db.String(50), default='general')
|
| 492 |
+
updated_by = db.Column(db.Integer, db.ForeignKey('users.id'))
|
| 493 |
+
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
| 494 |
+
|
| 495 |
+
def to_dict(self):
|
| 496 |
+
return {
|
| 497 |
+
'id': self.id,
|
| 498 |
+
'key': self.key,
|
| 499 |
+
'value': self.value,
|
| 500 |
+
'description': self.description,
|
| 501 |
+
'category': self.category,
|
| 502 |
+
'updated_by': self.updated_by,
|
| 503 |
+
'updated_at': self.updated_at.isoformat() if self.updated_at else None
|
| 504 |
+
}
|
| 505 |
+
|
| 506 |
+
# AI Chat History Model
|
| 507 |
+
class AIChatHistory(db.Model):
|
| 508 |
+
__tablename__ = 'ai_chat_history'
|
| 509 |
+
|
| 510 |
+
id = db.Column(db.Integer, primary_key=True)
|
| 511 |
+
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
| 512 |
+
question = db.Column(db.Text, nullable=False)
|
| 513 |
+
answer = db.Column(db.Text, nullable=False)
|
| 514 |
+
category = db.Column(db.String(50), default='general')
|
| 515 |
+
timestamp = db.Column(db.DateTime, default=datetime.utcnow)
|
| 516 |
+
|
| 517 |
+
def to_dict(self):
|
| 518 |
+
return {
|
| 519 |
+
'id': self.id,
|
| 520 |
+
'user_id': self.user_id,
|
| 521 |
+
'question': self.question,
|
| 522 |
+
'answer': self.answer,
|
| 523 |
+
'category': self.category,
|
| 524 |
+
'timestamp': self.timestamp.isoformat() if self.timestamp else None
|
| 525 |
+
}
|
| 526 |
+
|
| 527 |
+
# AI Insights Model
|
| 528 |
+
class AIInsight(db.Model):
|
| 529 |
+
__tablename__ = 'ai_insights'
|
| 530 |
+
|
| 531 |
+
id = db.Column(db.Integer, primary_key=True)
|
| 532 |
+
insight_type = db.Column(db.String(50), nullable=False)
|
| 533 |
+
title = db.Column(db.String(200), nullable=False)
|
| 534 |
+
description = db.Column(db.Text, nullable=False)
|
| 535 |
+
data = db.Column(db.Text)
|
| 536 |
+
confidence = db.Column(db.Float, default=0.0)
|
| 537 |
+
is_active = db.Column(db.Boolean, default=True)
|
| 538 |
+
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
| 539 |
+
expires_at = db.Column(db.DateTime)
|
| 540 |
+
|
| 541 |
+
def to_dict(self):
|
| 542 |
+
return {
|
| 543 |
+
'id': self.id,
|
| 544 |
+
'insight_type': self.insight_type,
|
| 545 |
+
'title': self.title,
|
| 546 |
+
'description': self.description,
|
| 547 |
+
'data': json.loads(self.data) if self.data else {},
|
| 548 |
+
'confidence': self.confidence,
|
| 549 |
+
'is_active': self.is_active,
|
| 550 |
+
'created_at': self.created_at.isoformat() if self.created_at else None,
|
| 551 |
+
'expires_at': self.expires_at.isoformat() if self.expires_at else None
|
| 552 |
+
}
|
| 553 |
+
|
| 554 |
+
# Bulk Message Campaign Model - NEW
|
| 555 |
+
class BulkMessageCampaign(db.Model):
|
| 556 |
+
__tablename__ = 'bulk_message_campaigns'
|
| 557 |
+
|
| 558 |
+
id = db.Column(db.Integer, primary_key=True)
|
| 559 |
+
name = db.Column(db.String(200), nullable=False)
|
| 560 |
+
message_type = db.Column(db.String(20), nullable=False) # email, whatsapp
|
| 561 |
+
subject = db.Column(db.String(300))
|
| 562 |
+
message = db.Column(db.Text, nullable=False)
|
| 563 |
+
contact_ids = db.Column(db.Text) # JSON array of contact IDs
|
| 564 |
+
filters = db.Column(db.Text) # JSON filter criteria
|
| 565 |
+
sent_count = db.Column(db.Integer, default=0)
|
| 566 |
+
total_contacts = db.Column(db.Integer, default=0)
|
| 567 |
+
status = db.Column(db.String(20), default='draft') # draft, sending, completed, failed
|
| 568 |
+
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
| 569 |
+
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
| 570 |
+
sent_at = db.Column(db.DateTime)
|
| 571 |
+
|
| 572 |
+
def to_dict(self):
|
| 573 |
+
return {
|
| 574 |
+
'id': self.id,
|
| 575 |
+
'name': self.name,
|
| 576 |
+
'message_type': self.message_type,
|
| 577 |
+
'subject': self.subject,
|
| 578 |
+
'message': self.message,
|
| 579 |
+
'contact_ids': json.loads(self.contact_ids) if self.contact_ids else [],
|
| 580 |
+
'filters': json.loads(self.filters) if self.filters else {},
|
| 581 |
+
'sent_count': self.sent_count,
|
| 582 |
+
'total_contacts': self.total_contacts,
|
| 583 |
+
'status': self.status,
|
| 584 |
+
'created_by': self.created_by,
|
| 585 |
+
'created_at': self.created_at.isoformat() if self.created_at else None,
|
| 586 |
+
'sent_at': self.sent_at.isoformat() if self.sent_at else None
|
| 587 |
+
}
|
| 588 |
+
|
| 589 |
+
# Export History Model - NEW
|
| 590 |
+
class ExportHistory(db.Model):
|
| 591 |
+
__tablename__ = 'export_history'
|
| 592 |
+
|
| 593 |
+
id = db.Column(db.Integer, primary_key=True)
|
| 594 |
+
export_type = db.Column(db.String(50), nullable=False) # contacts, companies, deals, tasks, master
|
| 595 |
+
file_format = db.Column(db.String(20), nullable=False) # excel, pdf
|
| 596 |
+
file_path = db.Column(db.String(500))
|
| 597 |
+
filters = db.Column(db.Text) # JSON filter criteria
|
| 598 |
+
record_count = db.Column(db.Integer, default=0)
|
| 599 |
+
exported_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
| 600 |
+
exported_at = db.Column(db.DateTime, default=datetime.utcnow)
|
| 601 |
+
|
| 602 |
+
def to_dict(self):
|
| 603 |
+
return {
|
| 604 |
+
'id': self.id,
|
| 605 |
+
'export_type': self.export_type,
|
| 606 |
+
'file_format': self.file_format,
|
| 607 |
+
'file_path': self.file_path,
|
| 608 |
+
'filters': json.loads(self.filters) if self.filters else {},
|
| 609 |
+
'record_count': self.record_count,
|
| 610 |
+
'exported_by': self.exported_by,
|
| 611 |
+
'exported_at': self.exported_at.isoformat() if self.exported_at else None
|
| 612 |
+
}
|
rag_core.py
ADDED
|
@@ -0,0 +1,713 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# rag_core.py - Chroma Cloud Integration
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import sys
|
| 5 |
+
import numpy as np
|
| 6 |
+
import json
|
| 7 |
+
from sentence_transformers import SentenceTransformer, CrossEncoder
|
| 8 |
+
import hashlib
|
| 9 |
+
import requests
|
| 10 |
+
import re
|
| 11 |
+
from sklearn.feature_extraction.text import TfidfVectorizer
|
| 12 |
+
from sklearn.metrics.pairwise import cosine_similarity
|
| 13 |
+
import nltk
|
| 14 |
+
from nltk.corpus import stopwords
|
| 15 |
+
from nltk.tokenize import sent_tokenize, word_tokenize
|
| 16 |
+
from nltk.stem import PorterStemmer
|
| 17 |
+
from typing import List, Dict, Tuple
|
| 18 |
+
import time
|
| 19 |
+
from dotenv import load_dotenv
|
| 20 |
+
import chromadb
|
| 21 |
+
|
| 22 |
+
# Load environment variables
|
| 23 |
+
load_dotenv()
|
| 24 |
+
|
| 25 |
+
# --- ROBUST NLTK SETUP ---
|
| 26 |
+
# Point NLTK to the local 'nltk_data' directory if it exists.
|
| 27 |
+
# On Render, this is created during the build step by download_nltk.py
|
| 28 |
+
local_nltk_data_path = os.path.join(os.path.dirname(__file__), 'nltk_data')
|
| 29 |
+
if os.path.exists(local_nltk_data_path):
|
| 30 |
+
nltk.data.path.insert(0, local_nltk_data_path)
|
| 31 |
+
# If nltk_data doesn't exist locally, NLTK will use default paths or download on-demand
|
| 32 |
+
# --- END SETUP ---
|
| 33 |
+
|
| 34 |
+
# Model configuration - matching app.py
|
| 35 |
+
MODEL_MAP = {
|
| 36 |
+
'gemini': 'google/gemma-3-4b-it:free',
|
| 37 |
+
'deepseek': 'google/gemma-3-27b-it:free',
|
| 38 |
+
'qwen': 'mistralai/mistral-small-3.1-24b-instruct:free',
|
| 39 |
+
'nvidia': 'nvidia/nemotron-nano-12b-v2-vl:free',
|
| 40 |
+
'amazon': 'amazon/nova-2-lite-v1:free'
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
# Best → fallback order (OCR strength)
|
| 44 |
+
FALLBACK_ORDER = [
|
| 45 |
+
'gemini',
|
| 46 |
+
'deepseek',
|
| 47 |
+
'qwen',
|
| 48 |
+
'nvidia',
|
| 49 |
+
'amazon'
|
| 50 |
+
]
|
| 51 |
+
|
| 52 |
+
# Chroma Cloud configuration
|
| 53 |
+
CHROMA_TENANT = os.getenv("CHROMA_TENANT")
|
| 54 |
+
CHROMA_DATABASE = os.getenv("CHROMA_DATABASE")
|
| 55 |
+
CHROMA_API_KEY = os.getenv("CHROMA_API_KEY")
|
| 56 |
+
|
| 57 |
+
embedding_model = None
|
| 58 |
+
reranker_model = None
|
| 59 |
+
chroma_client = None
|
| 60 |
+
collections: Dict[str, chromadb.Collection] = {}
|
| 61 |
+
keyword_indexes: Dict[str, Dict[str, Dict]] = {}
|
| 62 |
+
|
| 63 |
+
EMBEDDING_DIM = 768
|
| 64 |
+
CHUNK_SIZE = 300
|
| 65 |
+
CHUNK_OVERLAP = 50
|
| 66 |
+
|
| 67 |
+
# Track if RAG system is properly initialized
|
| 68 |
+
_rag_system_available = False
|
| 69 |
+
|
| 70 |
+
# Initialize components
|
| 71 |
+
def initialize_rag_system():
|
| 72 |
+
"""
|
| 73 |
+
Loads the embedding model, reranker, and connects to Chroma Cloud.
|
| 74 |
+
Returns True if successful, False otherwise.
|
| 75 |
+
"""
|
| 76 |
+
global embedding_model, reranker_model, chroma_client, _rag_system_available
|
| 77 |
+
print("RAG Core: Initializing Advanced RAG System with Chroma Cloud...")
|
| 78 |
+
|
| 79 |
+
# Validate Chroma Cloud credentials - graceful handling
|
| 80 |
+
if not all([CHROMA_TENANT, CHROMA_DATABASE, CHROMA_API_KEY]):
|
| 81 |
+
print("WARNING: Chroma Cloud credentials not found. RAG system will be disabled.")
|
| 82 |
+
print(" Set CHROMA_TENANT, CHROMA_DATABASE, and CHROMA_API_KEY to enable RAG.")
|
| 83 |
+
_rag_system_available = False
|
| 84 |
+
return False
|
| 85 |
+
|
| 86 |
+
try:
|
| 87 |
+
# Connect to Chroma Cloud
|
| 88 |
+
print("RAG Core: Connecting to Chroma Cloud...")
|
| 89 |
+
chroma_client = chromadb.CloudClient(
|
| 90 |
+
tenant=CHROMA_TENANT,
|
| 91 |
+
database=CHROMA_DATABASE,
|
| 92 |
+
api_key=CHROMA_API_KEY
|
| 93 |
+
)
|
| 94 |
+
print("RAG Core: Successfully connected to Chroma Cloud!")
|
| 95 |
+
|
| 96 |
+
print("RAG Core: Loading advanced embedding model...")
|
| 97 |
+
embedding_model = SentenceTransformer('all-mpnet-base-v2')
|
| 98 |
+
|
| 99 |
+
print("RAG Core: Loading cross-encoder reranker...")
|
| 100 |
+
reranker_model = CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')
|
| 101 |
+
|
| 102 |
+
print("RAG Core: Advanced models loaded successfully.")
|
| 103 |
+
_rag_system_available = True
|
| 104 |
+
return True
|
| 105 |
+
|
| 106 |
+
except Exception as e:
|
| 107 |
+
print(f"ERROR: Failed to initialize RAG system: {e}")
|
| 108 |
+
print(" RAG system will be disabled. The app will still work for OCR.")
|
| 109 |
+
_rag_system_available = False
|
| 110 |
+
return False
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def is_rag_available():
|
| 114 |
+
"""Check if RAG system is available."""
|
| 115 |
+
return _rag_system_available
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def _call_openrouter_api_with_fallback(api_key, selected_model_key, prompt):
|
| 119 |
+
"""
|
| 120 |
+
Calls OpenRouter API with fallback support for text-only requests.
|
| 121 |
+
"""
|
| 122 |
+
# Start with the selected model, then try others in fallback order
|
| 123 |
+
models_to_try = [selected_model_key]
|
| 124 |
+
for model in FALLBACK_ORDER:
|
| 125 |
+
if model != selected_model_key:
|
| 126 |
+
models_to_try.append(model)
|
| 127 |
+
|
| 128 |
+
last_error = None
|
| 129 |
+
|
| 130 |
+
for model_key in models_to_try:
|
| 131 |
+
model_name = MODEL_MAP.get(model_key)
|
| 132 |
+
if not model_name:
|
| 133 |
+
continue
|
| 134 |
+
|
| 135 |
+
print(f"RAG: Attempting API call with model: {model_name}...")
|
| 136 |
+
|
| 137 |
+
try:
|
| 138 |
+
response = requests.post(
|
| 139 |
+
url="https://openrouter.ai/api/v1/chat/completions",
|
| 140 |
+
headers={
|
| 141 |
+
"Authorization": f"Bearer {api_key}",
|
| 142 |
+
"Content-Type": "application/json"
|
| 143 |
+
},
|
| 144 |
+
json={
|
| 145 |
+
"model": model_name,
|
| 146 |
+
"messages": [{"role": "user", "content": prompt}]
|
| 147 |
+
}
|
| 148 |
+
)
|
| 149 |
+
response.raise_for_status()
|
| 150 |
+
api_response = response.json()
|
| 151 |
+
|
| 152 |
+
if 'choices' not in api_response or not api_response['choices']:
|
| 153 |
+
print(f"RAG: Model {model_name} returned unexpected response format")
|
| 154 |
+
last_error = f"Model {model_name} returned unexpected response format"
|
| 155 |
+
continue
|
| 156 |
+
|
| 157 |
+
result = api_response['choices'][0]['message']['content']
|
| 158 |
+
print(f"RAG: Successfully processed with model: {model_name}")
|
| 159 |
+
return result
|
| 160 |
+
|
| 161 |
+
except requests.exceptions.HTTPError as http_err:
|
| 162 |
+
error_msg = f"RAG: HTTP error for model {model_name}: {http_err}"
|
| 163 |
+
if hasattr(response, 'text'):
|
| 164 |
+
error_msg += f"\nResponse: {response.text}"
|
| 165 |
+
print(error_msg)
|
| 166 |
+
last_error = f"API request failed for {model_name} with status {response.status_code}."
|
| 167 |
+
continue
|
| 168 |
+
except Exception as e:
|
| 169 |
+
print(f"RAG: Error with model {model_name}: {e}")
|
| 170 |
+
last_error = f"An unexpected error occurred with model {model_name}."
|
| 171 |
+
continue
|
| 172 |
+
|
| 173 |
+
# If all models failed, return a user-friendly error
|
| 174 |
+
return f"I'm having trouble connecting to the AI models right now. Please check your API key and try again. Last error: {last_error}"
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def _get_collection_name(user_api_key, mode):
|
| 178 |
+
"""
|
| 179 |
+
Creates a unique collection name for a user based on a hash of their API key.
|
| 180 |
+
"""
|
| 181 |
+
user_hash = hashlib.sha256(user_api_key.encode()).hexdigest()[:16]
|
| 182 |
+
return f"{user_hash}_{mode}"
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def _get_or_create_collection(user_api_key, mode):
|
| 186 |
+
"""
|
| 187 |
+
Gets or creates a ChromaDB collection for the user/mode combination.
|
| 188 |
+
"""
|
| 189 |
+
collection_name = _get_collection_name(user_api_key, mode)
|
| 190 |
+
|
| 191 |
+
if collection_name in collections:
|
| 192 |
+
return collections[collection_name]
|
| 193 |
+
|
| 194 |
+
print(f"RAG Core: Getting/creating collection '{collection_name}' in Chroma Cloud")
|
| 195 |
+
collection = chroma_client.get_or_create_collection(
|
| 196 |
+
name=collection_name,
|
| 197 |
+
metadata={"hnsw:space": "cosine"} # Use cosine similarity
|
| 198 |
+
)
|
| 199 |
+
collections[collection_name] = collection
|
| 200 |
+
|
| 201 |
+
# Load keyword index from collection if exists
|
| 202 |
+
_load_keyword_index(user_api_key, mode)
|
| 203 |
+
|
| 204 |
+
return collection
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def _load_keyword_index(user_api_key, mode):
|
| 208 |
+
"""
|
| 209 |
+
Loads keyword index from Chroma Cloud collection metadata.
|
| 210 |
+
"""
|
| 211 |
+
collection_name = _get_collection_name(user_api_key, mode)
|
| 212 |
+
|
| 213 |
+
if mode not in keyword_indexes:
|
| 214 |
+
keyword_indexes[mode] = {}
|
| 215 |
+
|
| 216 |
+
if user_api_key in keyword_indexes[mode]:
|
| 217 |
+
return
|
| 218 |
+
|
| 219 |
+
try:
|
| 220 |
+
collection = collections.get(collection_name)
|
| 221 |
+
if collection:
|
| 222 |
+
# Try to get keyword index document
|
| 223 |
+
results = collection.get(
|
| 224 |
+
ids=["__keyword_index__"],
|
| 225 |
+
include=["documents"]
|
| 226 |
+
)
|
| 227 |
+
if results and results['documents'] and results['documents'][0]:
|
| 228 |
+
keyword_indexes[mode][user_api_key] = json.loads(results['documents'][0])
|
| 229 |
+
print(f"RAG Core: Loaded keyword index from Chroma Cloud")
|
| 230 |
+
else:
|
| 231 |
+
keyword_indexes[mode][user_api_key] = {"documents": {}, "vocabulary": {}, "entities": {}}
|
| 232 |
+
else:
|
| 233 |
+
keyword_indexes[mode][user_api_key] = {"documents": {}, "vocabulary": {}, "entities": {}}
|
| 234 |
+
except Exception as e:
|
| 235 |
+
print(f"RAG Core: Could not load keyword index: {e}")
|
| 236 |
+
keyword_indexes[mode][user_api_key] = {"documents": {}, "vocabulary": {}, "entities": {}}
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def _save_keyword_index(user_api_key, mode):
|
| 240 |
+
"""
|
| 241 |
+
Saves keyword index to Chroma Cloud collection.
|
| 242 |
+
"""
|
| 243 |
+
collection_name = _get_collection_name(user_api_key, mode)
|
| 244 |
+
collection = collections.get(collection_name)
|
| 245 |
+
|
| 246 |
+
if not collection or mode not in keyword_indexes or user_api_key not in keyword_indexes[mode]:
|
| 247 |
+
return
|
| 248 |
+
|
| 249 |
+
keyword_data = json.dumps(keyword_indexes[mode][user_api_key])
|
| 250 |
+
|
| 251 |
+
try:
|
| 252 |
+
# Upsert the keyword index document
|
| 253 |
+
collection.upsert(
|
| 254 |
+
ids=["__keyword_index__"],
|
| 255 |
+
documents=[keyword_data],
|
| 256 |
+
metadatas=[{"type": "keyword_index"}]
|
| 257 |
+
)
|
| 258 |
+
print("RAG Core: Saved keyword index to Chroma Cloud")
|
| 259 |
+
except Exception as e:
|
| 260 |
+
print(f"RAG Core: Error saving keyword index: {e}")
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def _smart_chunking(text, chunk_size=CHUNK_SIZE, chunk_overlap=CHUNK_OVERLAP):
|
| 264 |
+
"""
|
| 265 |
+
Intelligent chunking that preserves context and meaning.
|
| 266 |
+
"""
|
| 267 |
+
if not isinstance(text, str) or not text.strip():
|
| 268 |
+
return []
|
| 269 |
+
|
| 270 |
+
paragraphs = [p.strip() for p in text.split('\n\n') if p.strip()]
|
| 271 |
+
|
| 272 |
+
chunks = []
|
| 273 |
+
current_chunk = ""
|
| 274 |
+
|
| 275 |
+
for paragraph in paragraphs:
|
| 276 |
+
if len(current_chunk) + len(paragraph) <= chunk_size:
|
| 277 |
+
if current_chunk:
|
| 278 |
+
current_chunk += "\n\n" + paragraph
|
| 279 |
+
else:
|
| 280 |
+
current_chunk = paragraph
|
| 281 |
+
else:
|
| 282 |
+
if current_chunk:
|
| 283 |
+
chunks.append(current_chunk.strip())
|
| 284 |
+
|
| 285 |
+
if len(paragraph) > chunk_size:
|
| 286 |
+
sentences = nltk.sent_tokenize(paragraph)
|
| 287 |
+
temp_chunk = ""
|
| 288 |
+
|
| 289 |
+
for sentence in sentences:
|
| 290 |
+
if len(temp_chunk) + len(sentence) <= chunk_size:
|
| 291 |
+
temp_chunk += " " + sentence if temp_chunk else sentence
|
| 292 |
+
else:
|
| 293 |
+
if temp_chunk:
|
| 294 |
+
chunks.append(temp_chunk.strip())
|
| 295 |
+
temp_chunk = sentence
|
| 296 |
+
|
| 297 |
+
current_chunk = temp_chunk
|
| 298 |
+
else:
|
| 299 |
+
current_chunk = paragraph
|
| 300 |
+
|
| 301 |
+
if current_chunk:
|
| 302 |
+
chunks.append(current_chunk.strip())
|
| 303 |
+
|
| 304 |
+
final_chunks = []
|
| 305 |
+
for i, chunk in enumerate(chunks):
|
| 306 |
+
if i > 0 and chunk_overlap > 0:
|
| 307 |
+
prev_words = chunks[i-1].split()[-chunk_overlap:]
|
| 308 |
+
if prev_words:
|
| 309 |
+
chunk = " ".join(prev_words) + " " + chunk
|
| 310 |
+
final_chunks.append(chunk)
|
| 311 |
+
|
| 312 |
+
return final_chunks
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
def _enhanced_query_expansion(query: str) -> List[str]:
|
| 316 |
+
"""
|
| 317 |
+
Advanced query expansion with business context awareness.
|
| 318 |
+
"""
|
| 319 |
+
query_lower = query.lower()
|
| 320 |
+
expanded_queries = {query}
|
| 321 |
+
|
| 322 |
+
business_expansions = {
|
| 323 |
+
r"\bgeneral manager\b": ["GM", "manager", "head", "director", "chief"],
|
| 324 |
+
r"\bCEO\b": ["chief executive officer", "president", "director"],
|
| 325 |
+
r"\bCFO\b": ["chief financial officer", "finance director"],
|
| 326 |
+
r"\blocation\b": ["address", "located", "office", "headquarters", "branch"],
|
| 327 |
+
r"\boffice\b": ["location", "branch", "headquarters", "situated"],
|
| 328 |
+
r"\bservices\b": ["offerings", "products", "solutions", "business"],
|
| 329 |
+
r"\bcompany\b": ["business", "organization", "firm", "corporation", "enterprise"],
|
| 330 |
+
r"\bcontact\b": ["reach", "get in touch", "communicate"],
|
| 331 |
+
r"\bbranch\b": ["office", "location", "division", "subsidiary"],
|
| 332 |
+
r"\bheadquarters\b": ["main office", "head office", "corporate office"],
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
location_patterns = {
|
| 336 |
+
r"\bhong\s*kong\b": ["HK", "hongkong"],
|
| 337 |
+
r"\bsingapore\b": ["SG", "sing"],
|
| 338 |
+
r"\bunited\s*states\b": ["USA", "US", "America"],
|
| 339 |
+
r"\bunited\s*kingdom\b": ["UK", "Britain"],
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
for pattern, replacements in business_expansions.items():
|
| 343 |
+
if re.search(pattern, query_lower):
|
| 344 |
+
for replacement in replacements:
|
| 345 |
+
expanded_query = re.sub(pattern, replacement, query, flags=re.IGNORECASE)
|
| 346 |
+
expanded_queries.add(expanded_query)
|
| 347 |
+
|
| 348 |
+
for pattern, replacements in location_patterns.items():
|
| 349 |
+
if re.search(pattern, query_lower):
|
| 350 |
+
for replacement in replacements:
|
| 351 |
+
expanded_query = re.sub(pattern, replacement, query, flags=re.IGNORECASE)
|
| 352 |
+
expanded_queries.add(expanded_query)
|
| 353 |
+
|
| 354 |
+
return list(expanded_queries)
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
def _build_enhanced_keyword_index(text, doc_id, user_api_key, mode):
|
| 358 |
+
"""
|
| 359 |
+
Build an enhanced keyword index with business context awareness.
|
| 360 |
+
"""
|
| 361 |
+
if not isinstance(text, str) or not text.strip():
|
| 362 |
+
return
|
| 363 |
+
|
| 364 |
+
if mode not in keyword_indexes:
|
| 365 |
+
keyword_indexes[mode] = {}
|
| 366 |
+
|
| 367 |
+
if user_api_key not in keyword_indexes[mode]:
|
| 368 |
+
keyword_indexes[mode][user_api_key] = {"documents": {}, "vocabulary": {}, "entities": {}}
|
| 369 |
+
|
| 370 |
+
keyword_index = keyword_indexes[mode][user_api_key]
|
| 371 |
+
|
| 372 |
+
words = re.findall(r'\b[a-zA-Z]{2,}\b', text.lower())
|
| 373 |
+
stop_words = set(stopwords.words('english'))
|
| 374 |
+
ps = PorterStemmer()
|
| 375 |
+
|
| 376 |
+
business_entities = re.findall(r'\b[A-Z][a-zA-Z&\s]{1,30}(?:Ltd|Inc|Corp|Company|Group|Holdings|Limited|Corporation|Enterprise|Solutions)\b', text)
|
| 377 |
+
locations = re.findall(r'\b[A-Z][a-zA-Z\s]{2,20}(?:Street|Road|Avenue|Lane|Drive|Plaza|Square|Center|Centre|Building|Tower|Floor)\b', text)
|
| 378 |
+
|
| 379 |
+
for word in words:
|
| 380 |
+
if word not in stop_words and len(word) > 2:
|
| 381 |
+
stemmed = ps.stem(word)
|
| 382 |
+
if stemmed not in keyword_index["vocabulary"]:
|
| 383 |
+
keyword_index["vocabulary"][stemmed] = []
|
| 384 |
+
|
| 385 |
+
if doc_id not in keyword_index["vocabulary"][stemmed]:
|
| 386 |
+
keyword_index["vocabulary"][stemmed].append(doc_id)
|
| 387 |
+
|
| 388 |
+
if "entities" not in keyword_index:
|
| 389 |
+
keyword_index["entities"] = {}
|
| 390 |
+
|
| 391 |
+
for entity in business_entities + locations:
|
| 392 |
+
entity_key = entity.lower()
|
| 393 |
+
if entity_key not in keyword_index["entities"]:
|
| 394 |
+
keyword_index["entities"][entity_key] = []
|
| 395 |
+
if doc_id not in keyword_index["entities"][entity_key]:
|
| 396 |
+
keyword_index["entities"][entity_key].append(doc_id)
|
| 397 |
+
|
| 398 |
+
keyword_index["documents"][doc_id] = {
|
| 399 |
+
"text": text,
|
| 400 |
+
"length": len(text),
|
| 401 |
+
"word_count": len(words),
|
| 402 |
+
"entities": business_entities + locations
|
| 403 |
+
}
|
| 404 |
+
|
| 405 |
+
|
| 406 |
+
def _enhanced_keyword_search(query, user_api_key, mode, top_k=10):
|
| 407 |
+
"""
|
| 408 |
+
Enhanced keyword search with business context awareness.
|
| 409 |
+
"""
|
| 410 |
+
if mode not in keyword_indexes or user_api_key not in keyword_indexes[mode]:
|
| 411 |
+
return []
|
| 412 |
+
|
| 413 |
+
keyword_index = keyword_indexes[mode][user_api_key]
|
| 414 |
+
ps = PorterStemmer()
|
| 415 |
+
|
| 416 |
+
query_terms = [ps.stem(term) for term in query.lower().split()
|
| 417 |
+
if term not in stopwords.words('english') and len(term) > 2]
|
| 418 |
+
|
| 419 |
+
entity_matches = []
|
| 420 |
+
if "entities" in keyword_index:
|
| 421 |
+
for entity, docs in keyword_index["entities"].items():
|
| 422 |
+
if any(term in entity for term in query.lower().split()):
|
| 423 |
+
entity_matches.extend(docs)
|
| 424 |
+
|
| 425 |
+
doc_scores: Dict[str, float] = {}
|
| 426 |
+
|
| 427 |
+
for term in query_terms:
|
| 428 |
+
if term in keyword_index.get("vocabulary", {}):
|
| 429 |
+
for doc_id in keyword_index["vocabulary"][term]:
|
| 430 |
+
if doc_id not in doc_scores:
|
| 431 |
+
doc_scores[doc_id] = 0
|
| 432 |
+
doc_scores[doc_id] += 1.0
|
| 433 |
+
|
| 434 |
+
for doc_id in entity_matches:
|
| 435 |
+
if doc_id not in doc_scores:
|
| 436 |
+
doc_scores[doc_id] = 0
|
| 437 |
+
doc_scores[doc_id] += 2.0
|
| 438 |
+
|
| 439 |
+
final_scores = {}
|
| 440 |
+
for doc_id, score in doc_scores.items():
|
| 441 |
+
if doc_id in keyword_index.get("documents", {}):
|
| 442 |
+
doc_length = keyword_index["documents"][doc_id].get("word_count", 1)
|
| 443 |
+
final_scores[doc_id] = score / (1 + np.log(1 + doc_length))
|
| 444 |
+
|
| 445 |
+
sorted_docs = sorted(final_scores.items(), key=lambda x: x[1], reverse=True)[:top_k]
|
| 446 |
+
return [doc_id for doc_id, score in sorted_docs]
|
| 447 |
+
|
| 448 |
+
|
| 449 |
+
def add_document_to_knowledge_base(user_api_key, document_text, document_id, mode):
|
| 450 |
+
"""
|
| 451 |
+
Processes a document's text and adds it to the knowledge base with Chroma Cloud.
|
| 452 |
+
"""
|
| 453 |
+
try:
|
| 454 |
+
print(f"\nRAG: Adding document '{document_id}' to Chroma Cloud...")
|
| 455 |
+
collection = _get_or_create_collection(user_api_key, mode)
|
| 456 |
+
|
| 457 |
+
chunks = _smart_chunking(document_text)
|
| 458 |
+
print(f"RAG: Created {len(chunks)} intelligent chunks")
|
| 459 |
+
|
| 460 |
+
_build_enhanced_keyword_index(document_text, document_id, user_api_key, mode)
|
| 461 |
+
print("RAG: Built enhanced keyword index")
|
| 462 |
+
|
| 463 |
+
if not chunks:
|
| 464 |
+
print("RAG: No chunks to vectorize, saving keyword index only")
|
| 465 |
+
_save_keyword_index(user_api_key, mode)
|
| 466 |
+
return
|
| 467 |
+
|
| 468 |
+
chunk_embeddings = embedding_model.encode(chunks, normalize_embeddings=True)
|
| 469 |
+
print("RAG: Generated embeddings")
|
| 470 |
+
|
| 471 |
+
# Prepare data for Chroma
|
| 472 |
+
ids = [f"{document_id}_chunk_{i}" for i in range(len(chunks))]
|
| 473 |
+
metadatas = [
|
| 474 |
+
{
|
| 475 |
+
"source_doc": document_id,
|
| 476 |
+
"chunk_id": i,
|
| 477 |
+
"length": len(chunk),
|
| 478 |
+
"type": "document_chunk"
|
| 479 |
+
}
|
| 480 |
+
for i, chunk in enumerate(chunks)
|
| 481 |
+
]
|
| 482 |
+
|
| 483 |
+
# Add to Chroma Cloud
|
| 484 |
+
collection.upsert(
|
| 485 |
+
ids=ids,
|
| 486 |
+
embeddings=chunk_embeddings.tolist(),
|
| 487 |
+
documents=chunks,
|
| 488 |
+
metadatas=metadatas
|
| 489 |
+
)
|
| 490 |
+
|
| 491 |
+
# Save keyword index
|
| 492 |
+
_save_keyword_index(user_api_key, mode)
|
| 493 |
+
|
| 494 |
+
print(f"RAG: Successfully indexed document to Chroma Cloud. Total chunks: {len(chunks)}")
|
| 495 |
+
|
| 496 |
+
except Exception as e:
|
| 497 |
+
print(f"CRITICAL ERROR in add_document_to_knowledge_base: {e}")
|
| 498 |
+
import traceback
|
| 499 |
+
traceback.print_exc()
|
| 500 |
+
raise e
|
| 501 |
+
|
| 502 |
+
|
| 503 |
+
def remove_document_from_knowledge_base(user_api_key, document_id, mode):
|
| 504 |
+
"""
|
| 505 |
+
Removes all chunks associated with a document from Chroma Cloud.
|
| 506 |
+
"""
|
| 507 |
+
try:
|
| 508 |
+
collection = _get_or_create_collection(user_api_key, mode)
|
| 509 |
+
|
| 510 |
+
# Delete all chunks from this document using where filter
|
| 511 |
+
collection.delete(
|
| 512 |
+
where={"source_doc": document_id}
|
| 513 |
+
)
|
| 514 |
+
|
| 515 |
+
# Update keyword index
|
| 516 |
+
if mode in keyword_indexes and user_api_key in keyword_indexes[mode]:
|
| 517 |
+
keyword_index = keyword_indexes[mode][user_api_key]
|
| 518 |
+
|
| 519 |
+
# Remove document from vocabulary
|
| 520 |
+
if "vocabulary" in keyword_index:
|
| 521 |
+
for term in list(keyword_index["vocabulary"].keys()):
|
| 522 |
+
if document_id in keyword_index["vocabulary"][term]:
|
| 523 |
+
keyword_index["vocabulary"][term].remove(document_id)
|
| 524 |
+
if not keyword_index["vocabulary"][term]:
|
| 525 |
+
del keyword_index["vocabulary"][term]
|
| 526 |
+
|
| 527 |
+
# Remove document from entities
|
| 528 |
+
if "entities" in keyword_index:
|
| 529 |
+
for entity in list(keyword_index["entities"].keys()):
|
| 530 |
+
if document_id in keyword_index["entities"][entity]:
|
| 531 |
+
keyword_index["entities"][entity].remove(document_id)
|
| 532 |
+
if not keyword_index["entities"][entity]:
|
| 533 |
+
del keyword_index["entities"][entity]
|
| 534 |
+
|
| 535 |
+
# Remove document metadata
|
| 536 |
+
if "documents" in keyword_index and document_id in keyword_index["documents"]:
|
| 537 |
+
del keyword_index["documents"][document_id]
|
| 538 |
+
|
| 539 |
+
_save_keyword_index(user_api_key, mode)
|
| 540 |
+
|
| 541 |
+
print(f"RAG: Removed document '{document_id}' from Chroma Cloud")
|
| 542 |
+
|
| 543 |
+
except Exception as e:
|
| 544 |
+
print(f"Error removing document: {e}")
|
| 545 |
+
import traceback
|
| 546 |
+
traceback.print_exc()
|
| 547 |
+
|
| 548 |
+
|
| 549 |
+
def _advanced_hybrid_search(query, user_api_key, mode, top_k=10):
|
| 550 |
+
"""
|
| 551 |
+
Advanced hybrid search using Chroma Cloud query.
|
| 552 |
+
"""
|
| 553 |
+
collection = _get_or_create_collection(user_api_key, mode)
|
| 554 |
+
|
| 555 |
+
# Check if collection has documents
|
| 556 |
+
try:
|
| 557 |
+
count = collection.count()
|
| 558 |
+
if count == 0:
|
| 559 |
+
return []
|
| 560 |
+
except:
|
| 561 |
+
return []
|
| 562 |
+
|
| 563 |
+
# Vector search with Chroma Cloud
|
| 564 |
+
expanded_queries = _enhanced_query_expansion(query)
|
| 565 |
+
all_results = {}
|
| 566 |
+
|
| 567 |
+
for q in expanded_queries[:3]: # Limit to avoid too much noise
|
| 568 |
+
query_embedding = embedding_model.encode([q], normalize_embeddings=True)
|
| 569 |
+
|
| 570 |
+
try:
|
| 571 |
+
results = collection.query(
|
| 572 |
+
query_embeddings=query_embedding.tolist(),
|
| 573 |
+
n_results=min(top_k * 2, count),
|
| 574 |
+
where={"type": "document_chunk"},
|
| 575 |
+
include=["documents", "metadatas", "distances"]
|
| 576 |
+
)
|
| 577 |
+
|
| 578 |
+
if results and results['ids'] and results['ids'][0]:
|
| 579 |
+
for i, (doc_id, doc, metadata, distance) in enumerate(zip(
|
| 580 |
+
results['ids'][0],
|
| 581 |
+
results['documents'][0],
|
| 582 |
+
results['metadatas'][0],
|
| 583 |
+
results['distances'][0]
|
| 584 |
+
)):
|
| 585 |
+
# Convert distance to similarity score (Chroma returns L2 distance for cosine)
|
| 586 |
+
score = 1 - distance if distance else 0
|
| 587 |
+
if doc_id not in all_results or all_results[doc_id]['score'] < score:
|
| 588 |
+
all_results[doc_id] = {
|
| 589 |
+
'text': doc,
|
| 590 |
+
'source_doc': metadata.get('source_doc', ''),
|
| 591 |
+
'chunk_id': metadata.get('chunk_id', 0),
|
| 592 |
+
'length': metadata.get('length', 0),
|
| 593 |
+
'score': score
|
| 594 |
+
}
|
| 595 |
+
except Exception as e:
|
| 596 |
+
print(f"RAG: Search error: {e}")
|
| 597 |
+
continue
|
| 598 |
+
|
| 599 |
+
# Enhanced keyword search boost
|
| 600 |
+
keyword_doc_ids = set(_enhanced_keyword_search(query, user_api_key, mode, top_k=top_k*2))
|
| 601 |
+
|
| 602 |
+
# Add keyword boost to scores
|
| 603 |
+
for doc_id, result in all_results.items():
|
| 604 |
+
if result.get('source_doc') in keyword_doc_ids:
|
| 605 |
+
result['score'] = result.get('score', 0) + 0.4
|
| 606 |
+
|
| 607 |
+
# Sort and return top results
|
| 608 |
+
sorted_results = sorted(all_results.items(), key=lambda x: x[1]['score'], reverse=True)[:top_k]
|
| 609 |
+
return [result for doc_id, result in sorted_results]
|
| 610 |
+
|
| 611 |
+
|
| 612 |
+
def _intelligent_rerank(query, candidate_chunks, top_k=5):
|
| 613 |
+
"""
|
| 614 |
+
Intelligent reranking that considers both relevance and context completeness.
|
| 615 |
+
"""
|
| 616 |
+
if not candidate_chunks or not reranker_model:
|
| 617 |
+
return candidate_chunks[:top_k]
|
| 618 |
+
|
| 619 |
+
# Use cross-encoder for initial scoring
|
| 620 |
+
pairs = [(query, chunk["text"]) for chunk in candidate_chunks]
|
| 621 |
+
cross_encoder_scores = reranker_model.predict(pairs)
|
| 622 |
+
|
| 623 |
+
# Additional scoring based on content completeness
|
| 624 |
+
enhanced_scores = []
|
| 625 |
+
for i, (chunk, ce_score) in enumerate(zip(candidate_chunks, cross_encoder_scores)):
|
| 626 |
+
text = chunk["text"]
|
| 627 |
+
|
| 628 |
+
# Bonus for chunks that seem to contain complete information
|
| 629 |
+
completeness_bonus = 0
|
| 630 |
+
if any(marker in text.lower() for marker in ["located", "address", "office", "branch"]):
|
| 631 |
+
completeness_bonus += 0.1
|
| 632 |
+
if any(marker in text.lower() for marker in ["manager", "director", "ceo", "head"]):
|
| 633 |
+
completeness_bonus += 0.1
|
| 634 |
+
if any(marker in text.lower() for marker in ["company", "business", "organization"]):
|
| 635 |
+
completeness_bonus += 0.05
|
| 636 |
+
|
| 637 |
+
final_score = ce_score + completeness_bonus
|
| 638 |
+
enhanced_scores.append((chunk, final_score))
|
| 639 |
+
|
| 640 |
+
# Sort by enhanced scores and return top results
|
| 641 |
+
reranked = sorted(enhanced_scores, key=lambda x: x[1], reverse=True)
|
| 642 |
+
return [chunk for chunk, score in reranked[:top_k]]
|
| 643 |
+
|
| 644 |
+
|
| 645 |
+
def query_knowledge_base(user_api_key, query_text, mode, selected_model_key):
|
| 646 |
+
"""
|
| 647 |
+
Advanced query processing with human-like response generation using selected model with fallback.
|
| 648 |
+
"""
|
| 649 |
+
collection = _get_or_create_collection(user_api_key, mode)
|
| 650 |
+
|
| 651 |
+
try:
|
| 652 |
+
count = collection.count()
|
| 653 |
+
# Exclude keyword index from count
|
| 654 |
+
if count <= 1:
|
| 655 |
+
return "I don't have any documents in my knowledge base yet. Please upload some brochures or business cards first, and I'll be happy to help you find information from them!"
|
| 656 |
+
except:
|
| 657 |
+
return "I don't have any documents in my knowledge base yet. Please upload some brochures or business cards first, and I'll be happy to help you find information from them!"
|
| 658 |
+
|
| 659 |
+
print(f"RAG: Processing query: '{query_text}' with model: {selected_model_key}")
|
| 660 |
+
|
| 661 |
+
# Advanced search with multiple strategies
|
| 662 |
+
expanded_queries = _enhanced_query_expansion(query_text)
|
| 663 |
+
print(f"RAG: Expanded to {len(expanded_queries)} query variations")
|
| 664 |
+
|
| 665 |
+
all_candidates = []
|
| 666 |
+
seen_texts = set()
|
| 667 |
+
|
| 668 |
+
for query in expanded_queries[:3]: # Use top 3 expansions
|
| 669 |
+
candidates = _advanced_hybrid_search(query, user_api_key, mode, top_k=8)
|
| 670 |
+
for candidate in candidates:
|
| 671 |
+
text = candidate.get('text', '')
|
| 672 |
+
if text and text not in seen_texts:
|
| 673 |
+
seen_texts.add(text)
|
| 674 |
+
all_candidates.append(candidate)
|
| 675 |
+
|
| 676 |
+
# Intelligent reranking
|
| 677 |
+
top_chunks = _intelligent_rerank(query_text, all_candidates, top_k=5)
|
| 678 |
+
|
| 679 |
+
if not top_chunks:
|
| 680 |
+
return f"I couldn't find specific information about '{query_text}' in the uploaded documents. Could you try rephrasing your question or check if the information might be in a document that hasn't been uploaded yet?"
|
| 681 |
+
|
| 682 |
+
# Prepare context for AI model
|
| 683 |
+
context = "\n\n---DOCUMENT SECTION---\n\n".join([chunk["text"] for chunk in top_chunks])
|
| 684 |
+
print(f"RAG: Found {len(top_chunks)} relevant sections. Generating response with {selected_model_key}...")
|
| 685 |
+
|
| 686 |
+
try:
|
| 687 |
+
prompt = f"""You are a highly knowledgeable and helpful assistant who provides natural, conversational answers based on document information.
|
| 688 |
+
|
| 689 |
+
**CRITICAL INSTRUCTIONS:**
|
| 690 |
+
1. Answer the user's question in a natural, human-like way as if you're having a conversation
|
| 691 |
+
2. Use the information from the document sections below to provide accurate, specific details
|
| 692 |
+
3. If the user asks about a company, person, or location, provide comprehensive information from the documents
|
| 693 |
+
4. Be direct and specific - if someone asks "where is X located" and you find the address, state it clearly
|
| 694 |
+
5. If someone asks about a person's role, provide their title and any relevant details
|
| 695 |
+
6. Write in a conversational tone, not like you're reading from a manual
|
| 696 |
+
7. If you can't find the specific information requested, be honest but mention what related information you did find
|
| 697 |
+
|
| 698 |
+
**USER'S QUESTION:**
|
| 699 |
+
{query_text}
|
| 700 |
+
|
| 701 |
+
**RELEVANT DOCUMENT SECTIONS:**
|
| 702 |
+
{context}
|
| 703 |
+
|
| 704 |
+
**YOUR NATURAL, CONVERSATIONAL RESPONSE:**"""
|
| 705 |
+
|
| 706 |
+
response = _call_openrouter_api_with_fallback(user_api_key, selected_model_key, prompt)
|
| 707 |
+
return response
|
| 708 |
+
|
| 709 |
+
except Exception as e:
|
| 710 |
+
print(f"RAG: An unexpected error occurred during response generation: {e}")
|
| 711 |
+
import traceback
|
| 712 |
+
traceback.print_exc()
|
| 713 |
+
return "I found relevant information but ran into an unexpected error while processing it. Please try again."
|
render.yaml
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
services:
|
| 2 |
+
- type: web
|
| 3 |
+
name: visionextract-crm
|
| 4 |
+
runtime: python
|
| 5 |
+
buildCommand: pip install -r requirements.txt && python download_nltk.py
|
| 6 |
+
startCommand: gunicorn app:app --bind=0.0.0.0:$PORT --config gunicorn.conf.py
|
| 7 |
+
healthCheckPath: /health
|
| 8 |
+
envVars:
|
| 9 |
+
- key: CHROMA_TENANT
|
| 10 |
+
sync: false
|
| 11 |
+
- key: CHROMA_DATABASE
|
| 12 |
+
sync: false
|
| 13 |
+
- key: CHROMA_API_KEY
|
| 14 |
+
sync: false
|
| 15 |
+
- key: OPENROUTER_API_KEY
|
| 16 |
+
sync: false
|
| 17 |
+
- key: SESSION_SECRET
|
| 18 |
+
generateValue: true
|
| 19 |
+
- key: PYTHON_VERSION
|
| 20 |
+
value: "3.11.0"
|
requirements.txt
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
flask
|
| 2 |
+
flask-cors
|
| 3 |
+
flask-sqlalchemy
|
| 4 |
+
python-dotenv
|
| 5 |
+
Pillow
|
| 6 |
+
PyMuPDF
|
| 7 |
+
requests
|
| 8 |
+
chromadb
|
| 9 |
+
nltk
|
| 10 |
+
gunicorn
|
| 11 |
+
sentence-transformers
|
| 12 |
+
scikit-learn
|
| 13 |
+
numpy
|
templates/index.html
ADDED
|
@@ -0,0 +1,1429 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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>VisionExtractAI | AI Command Center</title>
|
| 8 |
+
<script src="https://cdn.tailwindcss.com"></script>
|
| 9 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 10 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 11 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
| 12 |
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css">
|
| 13 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
|
| 14 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.5.23/jspdf.plugin.autotable.min.js"></script>
|
| 15 |
+
<style>
|
| 16 |
+
:root {
|
| 17 |
+
--bg-color: #0D0C14;
|
| 18 |
+
--surface-color: rgba(23, 22, 32, 0.5);
|
| 19 |
+
--border-color: rgba(255, 255, 255, 0.1);
|
| 20 |
+
--text-primary: #f0f0f5;
|
| 21 |
+
--text-secondary: #a1a1aa;
|
| 22 |
+
--accent-color: #9333ea;
|
| 23 |
+
--accent-hover: #a855f7;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
body {
|
| 27 |
+
font-family: 'Inter', sans-serif;
|
| 28 |
+
background-color: var(--bg-color);
|
| 29 |
+
color: var(--text-primary);
|
| 30 |
+
overflow: hidden;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
#hexagon-canvas {
|
| 34 |
+
position: fixed;
|
| 35 |
+
top: 0;
|
| 36 |
+
left: 0;
|
| 37 |
+
width: 100%;
|
| 38 |
+
height: 100%;
|
| 39 |
+
z-index: 1;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
.main-container {
|
| 43 |
+
position: relative;
|
| 44 |
+
z-index: 2;
|
| 45 |
+
height: 100vh;
|
| 46 |
+
overflow-y: auto;
|
| 47 |
+
overflow-x: hidden;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
.page {
|
| 51 |
+
display: none;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
.page.active {
|
| 55 |
+
display: block;
|
| 56 |
+
animation: fadeIn 0.7s ease-out forwards;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
@keyframes fadeIn {
|
| 60 |
+
from {
|
| 61 |
+
opacity: 0;
|
| 62 |
+
transform: translateY(15px);
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
to {
|
| 66 |
+
opacity: 1;
|
| 67 |
+
transform: translateY(0);
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
.glass-card {
|
| 72 |
+
background-color: var(--surface-color);
|
| 73 |
+
border: 1px solid var(--border-color);
|
| 74 |
+
backdrop-filter: blur(16px);
|
| 75 |
+
-webkit-backdrop-filter: blur(16px);
|
| 76 |
+
transition: all 0.3s ease;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
.glass-card:hover:not(.no-hover) {
|
| 80 |
+
border-color: var(--accent-color);
|
| 81 |
+
background-color: rgba(23, 22, 32, 0.7);
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
.chat-bubble,
|
| 85 |
+
.contact-card,
|
| 86 |
+
.brochure-row,
|
| 87 |
+
.model-choice-card {
|
| 88 |
+
animation: popIn 0.5s ease-out forwards;
|
| 89 |
+
opacity: 0;
|
| 90 |
+
transform: scale(0.95);
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
@keyframes popIn {
|
| 94 |
+
to {
|
| 95 |
+
opacity: 1;
|
| 96 |
+
transform: scale(1);
|
| 97 |
+
}
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
.contact-card {
|
| 101 |
+
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
.contact-card:hover {
|
| 105 |
+
transform: translateY(-6px);
|
| 106 |
+
box-shadow: 0 0 30px rgba(147, 51, 234, 0.25);
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
.editable-field:hover {
|
| 110 |
+
background-color: rgba(255, 255, 255, 0.1);
|
| 111 |
+
cursor: pointer;
|
| 112 |
+
border-radius: 4px;
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
#chat-widget {
|
| 116 |
+
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
#chat-widget:hover {
|
| 120 |
+
transform: scale(1.1);
|
| 121 |
+
box-shadow: 0 0 20px var(--accent-color);
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
#chat-panel {
|
| 125 |
+
transition: transform 0.4s cubic-bezier(0.25, 1, 0.5, 1);
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
.modal-container {
|
| 129 |
+
transition: opacity 0.3s ease;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
.modal-content {
|
| 133 |
+
transition: transform 0.3s ease, opacity 0.3s ease;
|
| 134 |
+
transform: scale(0.95);
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
.modal-container.active .modal-content {
|
| 138 |
+
transform: scale(1);
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
/* ## START: Styles for Model Selector ## */
|
| 142 |
+
input[type="radio"].model-radio {
|
| 143 |
+
display: none;
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
input[type="radio"].model-radio:checked+.model-choice-card {
|
| 147 |
+
border-color: var(--accent-color);
|
| 148 |
+
background-color: rgba(147, 51, 234, 0.1);
|
| 149 |
+
transform: translateY(-4px);
|
| 150 |
+
box-shadow: 0 0 20px rgba(147, 51, 234, 0.3);
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
.model-choice-card {
|
| 154 |
+
transition: transform 0.2s ease, border-color 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease;
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
/* ## END: Styles for Model Selector ## */
|
| 158 |
+
/* ## Camera Capture Styles ## */
|
| 159 |
+
.camera-btn {
|
| 160 |
+
background: linear-gradient(135deg, #10b981, #059669);
|
| 161 |
+
transition: all 0.3s ease;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
.camera-btn:hover {
|
| 165 |
+
transform: scale(1.05);
|
| 166 |
+
box-shadow: 0 0 20px rgba(16, 185, 129, 0.4);
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
#camera-modal video {
|
| 170 |
+
max-width: 100%;
|
| 171 |
+
max-height: 60vh;
|
| 172 |
+
border-radius: 0.75rem;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
#camera-modal .camera-controls {
|
| 176 |
+
display: flex;
|
| 177 |
+
gap: 1rem;
|
| 178 |
+
justify-content: center;
|
| 179 |
+
margin-top: 1rem;
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
/* ## END: Camera Capture Styles ## */
|
| 183 |
+
</style>
|
| 184 |
+
</head>
|
| 185 |
+
|
| 186 |
+
<body class="antialiased">
|
| 187 |
+
|
| 188 |
+
<canvas id="hexagon-canvas"></canvas>
|
| 189 |
+
|
| 190 |
+
<div class="main-container p-6 sm:p-8 lg:p-12">
|
| 191 |
+
|
| 192 |
+
<header class="mb-10 flex justify-between items-center">
|
| 193 |
+
<div>
|
| 194 |
+
<h1 class="text-4xl font-bold text-white tracking-tighter">VisionExtractAI</h1>
|
| 195 |
+
<p id="header-subtitle" class="text-md text-gray-400 mt-1">AI-Powered Document Extraction</p>
|
| 196 |
+
</div>
|
| 197 |
+
<div class="flex items-center gap-4">
|
| 198 |
+
<button id="home-btn"
|
| 199 |
+
class="hidden text-gray-400 hover:text-white transition-colors text-lg flex items-center">
|
| 200 |
+
<i class="fas fa-home mr-2"></i> Home
|
| 201 |
+
</button>
|
| 202 |
+
<button id="back-btn"
|
| 203 |
+
class="hidden text-gray-400 hover:text-white transition-colors text-lg flex items-center">
|
| 204 |
+
<i class="fas fa-arrow-left mr-2"></i> Back
|
| 205 |
+
</button>
|
| 206 |
+
</div>
|
| 207 |
+
</header>
|
| 208 |
+
|
| 209 |
+
<div id="page-api-key" class="page active">
|
| 210 |
+
<div class="max-w-2xl mx-auto mt-10 text-center">
|
| 211 |
+
<h2
|
| 212 |
+
class="text-5xl font-bold tracking-tighter text-transparent bg-clip-text bg-gradient-to-r from-purple-400 to-blue-500">
|
| 213 |
+
Welcome to VisionExtractAI</h2>
|
| 214 |
+
<p class="text-lg text-gray-400 mt-4 max-w-xl mx-auto">
|
| 215 |
+
Transform static documents into a dynamic, queryable knowledge base. This app uses the
|
| 216 |
+
<strong>OpenRouter API</strong> to provide access to state-of-the-art AI models for OCR and an
|
| 217 |
+
intelligent chat assistant that understands your content.
|
| 218 |
+
</p>
|
| 219 |
+
|
| 220 |
+
<div id="api-key-panel">
|
| 221 |
+
<div class="mt-8 glass-card p-8 rounded-xl">
|
| 222 |
+
<p class="text-gray-300 mb-4">To begin, please enter your OpenRouter API key below.</p>
|
| 223 |
+
<input type="password" id="api-key-input" placeholder="Enter your OpenRouter API Key here"
|
| 224 |
+
class="w-full bg-gray-800 border border-gray-600 rounded-lg px-4 py-3 text-white text-center focus:outline-none focus:ring-2 focus:ring-purple-500">
|
| 225 |
+
<button id="continue-with-api-key-btn"
|
| 226 |
+
class="w-full mt-6 font-bold py-2 px-4 rounded-lg transition-colors duration-300 flex items-center gap-2 justify-center bg-purple-600 hover:bg-purple-700 text-white">
|
| 227 |
+
Continue <i class="fas fa-arrow-right"></i>
|
| 228 |
+
</button>
|
| 229 |
+
<p id="api-key-error" class="text-red-400 text-sm mt-2 hidden"></p>
|
| 230 |
+
</div>
|
| 231 |
+
</div>
|
| 232 |
+
<div id="model-selector-panel" class="hidden">
|
| 233 |
+
<div class="mt-8 glass-card p-8 rounded-xl">
|
| 234 |
+
<h3 class="text-xl font-bold text-gray-200 mb-6">Select Your AI Engine</h3>
|
| 235 |
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
| 236 |
+
|
| 237 |
+
<label for="model-deepseek">
|
| 238 |
+
<input type="radio" name="model-choice" id="model-deepseek" value="deepseek"
|
| 239 |
+
class="model-radio">
|
| 240 |
+
<div
|
| 241 |
+
class="model-choice-card glass-card no-hover p-4 rounded-lg cursor-pointer text-center h-full flex flex-col items-center justify-center">
|
| 242 |
+
<i class="fas fa-brain text-3xl text-green-400 mb-3"></i>
|
| 243 |
+
<span class="font-semibold">DeepSeek</span>
|
| 244 |
+
<span class="text-xs text-gray-400">DeepSeek V2 (free)</span>
|
| 245 |
+
</div>
|
| 246 |
+
</label>
|
| 247 |
+
|
| 248 |
+
<label for="model-qwen">
|
| 249 |
+
<input type="radio" name="model-choice" id="model-qwen" value="qwen"
|
| 250 |
+
class="model-radio">
|
| 251 |
+
<div
|
| 252 |
+
class="model-choice-card glass-card no-hover p-4 rounded-lg cursor-pointer text-center h-full flex flex-col items-center justify-center">
|
| 253 |
+
<i class="fas fa-microchip text-3xl text-blue-400 mb-3"></i>
|
| 254 |
+
<span class="font-semibold">Qwen</span>
|
| 255 |
+
<span class="text-xs text-gray-400">Qwen 2.5 VL (free)(MOST PREFERRED)</span>
|
| 256 |
+
</div>
|
| 257 |
+
</label>
|
| 258 |
+
|
| 259 |
+
<label for="model-nvidia">
|
| 260 |
+
<input type="radio" name="model-choice" id="model-nvidia" value="nvidia"
|
| 261 |
+
class="model-radio">
|
| 262 |
+
<div
|
| 263 |
+
class="model-choice-card glass-card no-hover p-4 rounded-lg cursor-pointer text-center h-full flex flex-col items-center justify-center">
|
| 264 |
+
<i class="fas fa-microchip text-3xl text-blue-400 mb-3"></i>
|
| 265 |
+
<span class="font-semibold">nvidia</span>
|
| 266 |
+
<span class="text-xs text-gray-400">nvidia</span>
|
| 267 |
+
</div>
|
| 268 |
+
</label>
|
| 269 |
+
|
| 270 |
+
<label for="model-amazon">
|
| 271 |
+
<input type="radio" name="model-choice" id="model-amazon" value="amazon"
|
| 272 |
+
class="model-radio">
|
| 273 |
+
<div
|
| 274 |
+
class="model-choice-card glass-card no-hover p-4 rounded-lg cursor-pointer text-center h-full flex flex-col items-center justify-center">
|
| 275 |
+
<i class="fas fa-microchip text-3xl text-blue-400 mb-3"></i>
|
| 276 |
+
<span class="font-semibold">amazon</span>
|
| 277 |
+
<span class="text-xs text-gray-400">amazon</span>
|
| 278 |
+
</div>
|
| 279 |
+
</label>
|
| 280 |
+
|
| 281 |
+
<label for="model-gemini">
|
| 282 |
+
<input type="radio" name="model-choice" id="model-gemini" value="gemini"
|
| 283 |
+
class="model-radio">
|
| 284 |
+
<div
|
| 285 |
+
class="model-choice-card glass-card no-hover p-4 rounded-lg cursor-pointer text-center h-full flex flex-col items-center justify-center">
|
| 286 |
+
<i class="fas fa-bolt text-3xl text-yellow-400 mb-3"></i>
|
| 287 |
+
<span class="font-semibold">Gemini</span>
|
| 288 |
+
<span class="text-xs text-gray-400">Gemini 1.5 Flash</span>
|
| 289 |
+
</div>
|
| 290 |
+
</label>
|
| 291 |
+
</div>
|
| 292 |
+
<button id="continue-with-model-btn"
|
| 293 |
+
class="w-full mt-6 font-bold py-2 px-4 rounded-lg transition-colors duration-300 flex items-center gap-2 justify-center bg-purple-600 hover:bg-purple-700 text-white">
|
| 294 |
+
Unlock Command Center
|
| 295 |
+
</button>
|
| 296 |
+
<p id="model-choice-error" class="text-red-400 text-sm mt-2 hidden"></p>
|
| 297 |
+
</div>
|
| 298 |
+
</div>
|
| 299 |
+
</div>
|
| 300 |
+
</div>
|
| 301 |
+
|
| 302 |
+
<div id="page-select-mode" class="page">
|
| 303 |
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 mt-12">
|
| 304 |
+
<div id="select-cards-btn" class="glass-card p-8 rounded-xl cursor-pointer">
|
| 305 |
+
<i class="fas fa-id-card text-4xl text-blue-400 mb-4"></i>
|
| 306 |
+
<h2 class="text-2xl font-bold mb-2">Business Card Extractor</h2>
|
| 307 |
+
<p class="text-gray-400">Extract structured contact information from images of business cards.</p>
|
| 308 |
+
</div>
|
| 309 |
+
<div id="select-brochures-btn" class="glass-card p-8 rounded-xl cursor-pointer">
|
| 310 |
+
<i class="fas fa-book-open text-4xl text-purple-400 mb-4"></i>
|
| 311 |
+
<h2 class="text-2xl font-bold mb-2">Brochure Extractor</h2>
|
| 312 |
+
<p class="text-gray-400">Extract multiple contacts and general information from PDF brochures.</p>
|
| 313 |
+
</div>
|
| 314 |
+
</div>
|
| 315 |
+
</div>
|
| 316 |
+
|
| 317 |
+
<div id="page-dashboard-cards" class="page">
|
| 318 |
+
<div id="upload-section-cards" class="mb-8">
|
| 319 |
+
<input type="file" id="file-upload-cards" class="hidden" multiple accept="image/*">
|
| 320 |
+
<div class="flex gap-4 items-stretch">
|
| 321 |
+
<label for="file-upload-cards"
|
| 322 |
+
class="drop-zone glass-card flex flex-col items-center justify-center flex-1 h-48 p-6 rounded-xl cursor-pointer border-dashed border-2">
|
| 323 |
+
<div class="text-center">
|
| 324 |
+
<i class="fas fa-cloud-upload-alt text-4xl text-gray-500 mb-3"></i>
|
| 325 |
+
<p class="font-semibold">Drag & drop Business Cards or <span
|
| 326 |
+
class="text-purple-400">browse</span></p>
|
| 327 |
+
<p class="text-sm text-gray-500 mt-1">Supports PNG, JPG, or WEBP</p>
|
| 328 |
+
</div>
|
| 329 |
+
</label>
|
| 330 |
+
<button id="camera-btn-cards"
|
| 331 |
+
class="camera-btn flex flex-col items-center justify-center w-32 h-48 rounded-xl text-white"
|
| 332 |
+
style="background: linear-gradient(135deg, #10b981, #059669); min-width: 128px;">
|
| 333 |
+
<i class="fas fa-camera text-4xl mb-3"></i>
|
| 334 |
+
<span class="font-semibold text-sm">Take Photo</span>
|
| 335 |
+
</button>
|
| 336 |
+
</div>
|
| 337 |
+
</div>
|
| 338 |
+
<div id="live-activity-section-cards" class="mb-8">
|
| 339 |
+
<h3 class="font-bold text-lg mb-2 flex items-center gap-2 text-gray-400"><i class="fas fa-history"></i>
|
| 340 |
+
Live Activity</h3>
|
| 341 |
+
<div class="live-activity-feed space-y-2 text-sm"></div>
|
| 342 |
+
</div>
|
| 343 |
+
<div id="results-section-cards" class="results-section rounded-xl">
|
| 344 |
+
<div class="flex flex-wrap justify-between items-center mb-4 gap-4">
|
| 345 |
+
<h3 class="font-bold text-lg">Extracted Contacts</h3>
|
| 346 |
+
<div class="flex items-center gap-4">
|
| 347 |
+
<div class="relative">
|
| 348 |
+
<span class="absolute inset-y-0 left-0 flex items-center pl-3"><i
|
| 349 |
+
class="fas fa-search text-gray-500"></i></span>
|
| 350 |
+
<input type="text"
|
| 351 |
+
class="search-input glass-card no-hover w-full max-w-xs bg-transparent border-gray-700 rounded-lg pl-10 pr-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-purple-500"
|
| 352 |
+
placeholder="Search contacts...">
|
| 353 |
+
</div>
|
| 354 |
+
<div class="relative actions-menu-container">
|
| 355 |
+
<button
|
| 356 |
+
class="actions-menu-btn glass-card font-bold py-2 px-4 rounded-lg transition-colors duration-300 flex items-center gap-2 justify-center">
|
| 357 |
+
Actions <i class="fas fa-chevron-down text-xs ml-1"></i>
|
| 358 |
+
</button>
|
| 359 |
+
<div
|
| 360 |
+
class="actions-dropdown hidden absolute right-0 mt-2 w-48 glass-card rounded-lg shadow-lg z-10">
|
| 361 |
+
<a href="#"
|
| 362 |
+
class="export-pdf-btn block px-4 py-2 text-sm hover:bg-purple-600 rounded-t-lg">Export
|
| 363 |
+
to PDF</a>
|
| 364 |
+
<a href="#" class="export-excel-btn block px-4 py-2 text-sm hover:bg-purple-600">Export
|
| 365 |
+
to Excel</a>
|
| 366 |
+
<a href="#"
|
| 367 |
+
class="export-vcf-btn block px-4 py-2 text-sm hover:bg-purple-600 rounded-b-lg">
|
| 368 |
+
<i class="fas fa-address-book mr-1"></i>Export to Contacts (VCF)</a>
|
| 369 |
+
</div>
|
| 370 |
+
</div>
|
| 371 |
+
</div>
|
| 372 |
+
</div>
|
| 373 |
+
<div class="card-grid-container grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
| 374 |
+
</div>
|
| 375 |
+
<div class="placeholder-row text-center py-24 text-gray-500">
|
| 376 |
+
<i class="fas fa-id-card text-5xl mb-4"></i>
|
| 377 |
+
<p class="text-lg">Your extracted contacts will appear here as cards.</p>
|
| 378 |
+
</div>
|
| 379 |
+
</div>
|
| 380 |
+
</div>
|
| 381 |
+
|
| 382 |
+
<div id="page-dashboard-brochures" class="page">
|
| 383 |
+
<div id="upload-section-brochures" class="mb-8">
|
| 384 |
+
<input type="file" id="file-upload-brochures" class="hidden" multiple accept=".pdf,image/*">
|
| 385 |
+
<div class="flex gap-4 items-stretch">
|
| 386 |
+
<label for="file-upload-brochures"
|
| 387 |
+
class="drop-zone glass-card flex flex-col items-center justify-center flex-1 h-48 p-6 rounded-xl cursor-pointer border-dashed border-2">
|
| 388 |
+
<div class="text-center">
|
| 389 |
+
<i class="fas fa-cloud-upload-alt text-4xl text-gray-500 mb-3"></i>
|
| 390 |
+
<p class="font-semibold">Drag & drop PDF Brochures or <span
|
| 391 |
+
class="text-purple-400">browse</span></p>
|
| 392 |
+
<p class="text-sm text-gray-500 mt-1">Supports PDF and image files</p>
|
| 393 |
+
</div>
|
| 394 |
+
</label>
|
| 395 |
+
<button id="camera-btn-brochures"
|
| 396 |
+
class="camera-btn flex flex-col items-center justify-center w-32 h-48 rounded-xl text-white"
|
| 397 |
+
style="background: linear-gradient(135deg, #10b981, #059669); min-width: 128px;">
|
| 398 |
+
<i class="fas fa-camera text-4xl mb-3"></i>
|
| 399 |
+
<span class="font-semibold text-sm">Take Photo</span>
|
| 400 |
+
</button>
|
| 401 |
+
</div>
|
| 402 |
+
</div>
|
| 403 |
+
<div id="live-activity-section-brochures" class="mb-8">
|
| 404 |
+
<h3 class="font-bold text-lg mb-2 flex items-center gap-2 text-gray-400"><i class="fas fa-history"></i>
|
| 405 |
+
Live Activity</h3>
|
| 406 |
+
<div class="live-activity-feed space-y-2 text-sm"></div>
|
| 407 |
+
</div>
|
| 408 |
+
<div id="results-section-brochures" class="results-section rounded-xl">
|
| 409 |
+
<div class="flex flex-wrap justify-between items-center mb-4 gap-4">
|
| 410 |
+
<h3 class="font-bold text-lg">Extracted Brochures</h3>
|
| 411 |
+
</div>
|
| 412 |
+
<div class="brochure-list-container space-y-3"></div>
|
| 413 |
+
<div class="placeholder-row text-center py-24 text-gray-500">
|
| 414 |
+
<i class="fas fa-book-open text-5xl mb-4"></i>
|
| 415 |
+
<p class="text-lg">Information extracted from your brochures will appear here.</p>
|
| 416 |
+
</div>
|
| 417 |
+
</div>
|
| 418 |
+
</div>
|
| 419 |
+
|
| 420 |
+
</div>
|
| 421 |
+
|
| 422 |
+
<button id="chat-widget"
|
| 423 |
+
class="hidden fixed bottom-8 right-8 bg-purple-600 hover:bg-purple-700 text-white w-16 h-16 rounded-full flex items-center justify-center shadow-lg z-40">
|
| 424 |
+
<i class="fas fa-comments text-2xl"></i>
|
| 425 |
+
</button>
|
| 426 |
+
|
| 427 |
+
<div id="chat-panel"
|
| 428 |
+
class="fixed bottom-0 right-0 h-full w-full max-w-md glass-card no-hover flex flex-col z-50 transform translate-x-full">
|
| 429 |
+
<div class="p-4 border-b border-gray-700 flex justify-between items-center">
|
| 430 |
+
<h3 class="font-bold text-lg flex items-center gap-3"><i class="fas fa-comments text-purple-400"></i> Ask
|
| 431 |
+
About Your Documents</h3>
|
| 432 |
+
<button id="chat-close-btn" class="text-gray-400 hover:text-white"><i
|
| 433 |
+
class="fas fa-times text-xl"></i></button>
|
| 434 |
+
</div>
|
| 435 |
+
<div id="chat-messages" class="flex-1 p-4 space-y-4 overflow-y-auto">
|
| 436 |
+
<div class="flex justify-start">
|
| 437 |
+
<div class="chat-bubble bg-gray-700 rounded-lg p-3">
|
| 438 |
+
<p class="text-sm">Hello! Ask me anything about your documents.</p>
|
| 439 |
+
</div>
|
| 440 |
+
</div>
|
| 441 |
+
</div>
|
| 442 |
+
<div class="p-4 border-t border-gray-700">
|
| 443 |
+
<div class="relative">
|
| 444 |
+
<input type="text" id="chat-input" placeholder="Type your question..."
|
| 445 |
+
class="w-full bg-gray-900 border border-gray-700 rounded-full pl-4 pr-12 py-3 text-white focus:outline-none focus:ring-2 focus:ring-purple-500">
|
| 446 |
+
<button id="chat-send-btn"
|
| 447 |
+
class="absolute inset-y-0 right-0 flex items-center justify-center bg-purple-600 hover:bg-purple-700 w-10 h-10 rounded-full m-1.5 transition-colors"><i
|
| 448 |
+
class="fas fa-paper-plane"></i></button>
|
| 449 |
+
</div>
|
| 450 |
+
</div>
|
| 451 |
+
</div>
|
| 452 |
+
|
| 453 |
+
<div id="contacts-modal"
|
| 454 |
+
class="modal-container hidden fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex justify-center items-center p-4"
|
| 455 |
+
style="opacity: 0;">
|
| 456 |
+
<div class="modal-content glass-card w-full max-w-4xl rounded-xl max-h-[80vh] flex flex-col"
|
| 457 |
+
style="opacity: 0;">
|
| 458 |
+
<div class="p-4 border-b border-gray-700 flex justify-between items-center">
|
| 459 |
+
<h3 id="contacts-modal-title" class="font-bold text-lg">Contacts</h3>
|
| 460 |
+
<div class="flex items-center gap-4">
|
| 461 |
+
<button id="export-contacts-pdf-btn" class="text-sm text-gray-300 hover:text-white"><i
|
| 462 |
+
class="fas fa-file-pdf mr-1"></i> Export PDF</button>
|
| 463 |
+
<button id="export-contacts-excel-btn" class="text-sm text-gray-300 hover:text-white"><i
|
| 464 |
+
class="fas fa-file-excel mr-1"></i> Export Excel</button>
|
| 465 |
+
<button onclick="toggleModal('contacts-modal', false)"
|
| 466 |
+
class="text-gray-400 hover:text-white ml-4"><i class="fas fa-times text-xl"></i></button>
|
| 467 |
+
</div>
|
| 468 |
+
</div>
|
| 469 |
+
<div class="p-4 overflow-y-auto">
|
| 470 |
+
<div id="contacts-modal-body" class="space-y-2"></div>
|
| 471 |
+
</div>
|
| 472 |
+
</div>
|
| 473 |
+
</div>
|
| 474 |
+
|
| 475 |
+
<div id="info-modal"
|
| 476 |
+
class="modal-container hidden fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex justify-center items-center p-4"
|
| 477 |
+
style="opacity: 0;">
|
| 478 |
+
<div class="modal-content glass-card w-full max-w-4xl rounded-xl max-h-[80vh] flex flex-col"
|
| 479 |
+
style="opacity: 0;">
|
| 480 |
+
<div class="p-4 border-b border-gray-700 flex justify-between items-center">
|
| 481 |
+
<h3 id="info-modal-title" class="font-bold text-lg">Brochure Information</h3>
|
| 482 |
+
<div class="flex items-center gap-4">
|
| 483 |
+
<button id="export-info-pdf-btn" class="text-sm text-gray-300 hover:text-white"><i
|
| 484 |
+
class="fas fa-file-pdf mr-1"></i> Export PDF</button>
|
| 485 |
+
<button onclick="toggleModal('info-modal', false)" class="text-gray-400 hover:text-white ml-4"><i
|
| 486 |
+
class="fas fa-times text-xl"></i></button>
|
| 487 |
+
</div>
|
| 488 |
+
</div>
|
| 489 |
+
<div id="info-modal-body" class="p-4 overflow-y-auto text-gray-300 whitespace-pre-wrap"></div>
|
| 490 |
+
</div>
|
| 491 |
+
</div>
|
| 492 |
+
|
| 493 |
+
<!-- Camera Modal -->
|
| 494 |
+
<div id="camera-modal"
|
| 495 |
+
class="modal-container hidden fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex justify-center items-center p-4"
|
| 496 |
+
style="opacity: 0;">
|
| 497 |
+
<div class="modal-content glass-card w-full max-w-2xl rounded-xl flex flex-col" style="opacity: 0;">
|
| 498 |
+
<div class="p-4 border-b border-gray-700 flex justify-between items-center">
|
| 499 |
+
<h3 id="camera-modal-title" class="font-bold text-lg flex items-center gap-2">
|
| 500 |
+
<i class="fas fa-camera text-green-400"></i> <span>Capture Photo</span>
|
| 501 |
+
</h3>
|
| 502 |
+
<button id="camera-close-btn" class="text-gray-400 hover:text-white">
|
| 503 |
+
<i class="fas fa-times text-xl"></i>
|
| 504 |
+
</button>
|
| 505 |
+
</div>
|
| 506 |
+
<div class="p-6 flex flex-col items-center">
|
| 507 |
+
<video id="camera-video" autoplay playsinline class="hidden"></video>
|
| 508 |
+
<canvas id="camera-canvas" class="hidden"></canvas>
|
| 509 |
+
<img id="camera-preview" class="hidden max-w-full max-h-[50vh] rounded-xl" />
|
| 510 |
+
<div id="camera-loading" class="text-center py-12">
|
| 511 |
+
<i class="fas fa-spinner animate-spin text-4xl text-green-400 mb-4"></i>
|
| 512 |
+
<p class="text-gray-400">Accessing camera...</p>
|
| 513 |
+
</div>
|
| 514 |
+
<div id="camera-error" class="hidden text-center py-12">
|
| 515 |
+
<i class="fas fa-exclamation-triangle text-4xl text-yellow-400 mb-4"></i>
|
| 516 |
+
<p class="text-gray-400">Could not access camera. Please ensure you've granted camera permissions.
|
| 517 |
+
</p>
|
| 518 |
+
</div>
|
| 519 |
+
<div class="camera-controls mt-4">
|
| 520 |
+
<button id="camera-capture-btn"
|
| 521 |
+
class="hidden bg-green-600 hover:bg-green-700 text-white font-bold py-3 px-8 rounded-full transition-colors flex items-center gap-2">
|
| 522 |
+
<i class="fas fa-camera"></i> Capture
|
| 523 |
+
</button>
|
| 524 |
+
<button id="camera-retake-btn"
|
| 525 |
+
class="hidden bg-gray-600 hover:bg-gray-700 text-white font-bold py-3 px-6 rounded-full transition-colors flex items-center gap-2">
|
| 526 |
+
<i class="fas fa-redo"></i> Retake
|
| 527 |
+
</button>
|
| 528 |
+
<button id="camera-use-btn"
|
| 529 |
+
class="hidden bg-purple-600 hover:bg-purple-700 text-white font-bold py-3 px-6 rounded-full transition-colors flex items-center gap-2">
|
| 530 |
+
<i class="fas fa-check"></i> Use Photo
|
| 531 |
+
</button>
|
| 532 |
+
</div>
|
| 533 |
+
</div>
|
| 534 |
+
</div>
|
| 535 |
+
</div>
|
| 536 |
+
|
| 537 |
+
|
| 538 |
+
<script>
|
| 539 |
+
// Dynamic API URL - works from mobile and laptop
|
| 540 |
+
const API_BASE_URL = window.location.origin;
|
| 541 |
+
// ## START: New State Variables ##
|
| 542 |
+
let userApiKey = null;
|
| 543 |
+
let selectedModel = null;
|
| 544 |
+
// ## END: New State Variables ##
|
| 545 |
+
let currentMode = null;
|
| 546 |
+
let contactData = { cards: [], brochures: [] };
|
| 547 |
+
const pages = document.querySelectorAll('.page');
|
| 548 |
+
const headerSubtitle = document.getElementById('header-subtitle');
|
| 549 |
+
const backBtn = document.getElementById('back-btn');
|
| 550 |
+
const homeBtn = document.getElementById('home-btn');
|
| 551 |
+
const chatMessages = document.getElementById('chat-messages');
|
| 552 |
+
const chatInput = document.getElementById('chat-input');
|
| 553 |
+
const chatSendBtn = document.getElementById('chat-send-btn');
|
| 554 |
+
const chatWidget = document.getElementById('chat-widget');
|
| 555 |
+
const chatPanel = document.getElementById('chat-panel');
|
| 556 |
+
const chatCloseBtn = document.getElementById('chat-close-btn');
|
| 557 |
+
const canvas = document.getElementById('hexagon-canvas');
|
| 558 |
+
const ctx = canvas.getContext('2d');
|
| 559 |
+
let hexagons = [];
|
| 560 |
+
const hexSize = 25;
|
| 561 |
+
const hexWidth = Math.sqrt(3) * hexSize;
|
| 562 |
+
const hexHeight = 2 * hexSize;
|
| 563 |
+
let mouse = { x: undefined, y: undefined, radius: 150 };
|
| 564 |
+
|
| 565 |
+
function initCanvas() {
|
| 566 |
+
canvas.width = window.innerWidth;
|
| 567 |
+
canvas.height = window.innerHeight;
|
| 568 |
+
hexagons = [];
|
| 569 |
+
const cols = Math.ceil(canvas.width / hexWidth) + 1;
|
| 570 |
+
const rows = Math.ceil(canvas.height / (hexHeight * 0.75)) + 1;
|
| 571 |
+
for (let row = 0; row < rows; row++) {
|
| 572 |
+
for (let col = 0; col < cols; col++) {
|
| 573 |
+
const x = col * hexWidth + (row % 2) * (hexWidth / 2);
|
| 574 |
+
const y = row * hexHeight * 0.75;
|
| 575 |
+
hexagons.push({ x, y, size: hexSize, opacity: 0.05 });
|
| 576 |
+
}
|
| 577 |
+
}
|
| 578 |
+
}
|
| 579 |
+
|
| 580 |
+
function drawHexagon(x, y, size, opacity) {
|
| 581 |
+
ctx.beginPath();
|
| 582 |
+
for (let i = 0; i < 6; i++) {
|
| 583 |
+
const angle = (Math.PI / 3) * i + (Math.PI / 6);
|
| 584 |
+
const pointX = x + size * Math.cos(angle);
|
| 585 |
+
const pointY = y + size * Math.sin(angle);
|
| 586 |
+
if (i === 0) ctx.moveTo(pointX, pointY);
|
| 587 |
+
else ctx.lineTo(pointX, pointY);
|
| 588 |
+
}
|
| 589 |
+
ctx.closePath();
|
| 590 |
+
ctx.fillStyle = `rgba(147, 51, 234, ${opacity})`;
|
| 591 |
+
ctx.strokeStyle = `rgba(147, 51, 234, ${opacity * 1.5})`;
|
| 592 |
+
ctx.lineWidth = 1;
|
| 593 |
+
ctx.fill();
|
| 594 |
+
ctx.stroke();
|
| 595 |
+
}
|
| 596 |
+
|
| 597 |
+
function animate() {
|
| 598 |
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
| 599 |
+
hexagons.forEach(hex => {
|
| 600 |
+
const dist = Math.hypot(hex.x - mouse.x, hex.y - mouse.y);
|
| 601 |
+
const maxOpacity = 0.6;
|
| 602 |
+
const baseOpacity = 0.05;
|
| 603 |
+
let targetOpacity = baseOpacity;
|
| 604 |
+
if (dist < mouse.radius) {
|
| 605 |
+
targetOpacity = Math.max(baseOpacity, (1 - dist / mouse.radius) * maxOpacity);
|
| 606 |
+
}
|
| 607 |
+
hex.opacity += (targetOpacity - hex.opacity) * 0.1;
|
| 608 |
+
drawHexagon(hex.x, hex.y, hex.size, hex.opacity);
|
| 609 |
+
});
|
| 610 |
+
requestAnimationFrame(animate);
|
| 611 |
+
}
|
| 612 |
+
|
| 613 |
+
window.addEventListener('mousemove', e => { mouse.x = e.clientX; mouse.y = e.clientY; });
|
| 614 |
+
window.addEventListener('resize', initCanvas);
|
| 615 |
+
|
| 616 |
+
function toggleChatPanel(show) {
|
| 617 |
+
chatPanel.style.transform = show ? 'translateX(0)' : 'translateX(100%)';
|
| 618 |
+
chatWidget.style.transform = show ? 'scale(0)' : 'scale(1)';
|
| 619 |
+
}
|
| 620 |
+
|
| 621 |
+
chatWidget.addEventListener('click', () => toggleChatPanel(true));
|
| 622 |
+
chatCloseBtn.addEventListener('click', () => toggleChatPanel(false));
|
| 623 |
+
|
| 624 |
+
function renderUI(mode) {
|
| 625 |
+
const dataToRender = contactData[mode];
|
| 626 |
+
if (mode === 'cards') {
|
| 627 |
+
renderCards(dataToRender);
|
| 628 |
+
} else if (mode === 'brochures') {
|
| 629 |
+
renderBrochures(dataToRender);
|
| 630 |
+
}
|
| 631 |
+
}
|
| 632 |
+
|
| 633 |
+
function renderCards(dataToRender) {
|
| 634 |
+
const container = document.querySelector('#page-dashboard-cards .card-grid-container');
|
| 635 |
+
const placeholder = document.querySelector('#page-dashboard-cards .placeholder-row');
|
| 636 |
+
const searchInput = document.querySelector('#page-dashboard-cards .search-input');
|
| 637 |
+
|
| 638 |
+
container.innerHTML = '';
|
| 639 |
+
if (!dataToRender || dataToRender.length === 0) {
|
| 640 |
+
placeholder.classList.remove('hidden');
|
| 641 |
+
placeholder.querySelector('p').textContent = searchInput.value ? 'No cards match your search.' : 'Your extracted business cards will appear here.';
|
| 642 |
+
} else {
|
| 643 |
+
placeholder.classList.add('hidden');
|
| 644 |
+
dataToRender.forEach((data, index) => addCardToGrid(data, index));
|
| 645 |
+
}
|
| 646 |
+
}
|
| 647 |
+
|
| 648 |
+
function addCardToGrid(data, index) {
|
| 649 |
+
const container = document.querySelector('#page-dashboard-cards .card-grid-container');
|
| 650 |
+
const card = document.createElement('div');
|
| 651 |
+
card.id = `card-${data.id}`;
|
| 652 |
+
card.className = 'contact-card glass-card p-4 rounded-xl flex flex-col justify-between h-full';
|
| 653 |
+
card.style.animationDelay = `${index * 50}ms`;
|
| 654 |
+
card.innerHTML = `
|
| 655 |
+
<div class="flex-grow space-y-1">
|
| 656 |
+
<p class="font-bold text-lg truncate editable-field" data-field="Company Name">${data['Company Name'] || 'Unknown Company'}</p>
|
| 657 |
+
<p class="text-sm text-gray-400 editable-field" data-field="Owner Name">${data['Owner Name'] || 'No Name'}</p>
|
| 658 |
+
<div class="mt-3 text-xs space-y-2 text-gray-400">
|
| 659 |
+
<p class="truncate editable-field" data-field="Email"><i class="fas fa-envelope fa-fw mr-2"></i>${data['Email'] || 'N/A'}</p>
|
| 660 |
+
<p class="truncate editable-field" data-field="Number"><i class="fas fa-phone fa-fw mr-2"></i>${data['Number'] || 'N/A'}</p>
|
| 661 |
+
<p class="truncate editable-field" data-field="Address"><i class="fas fa-map-marker-alt fa-fw mr-2"></i>${data['Address'] || 'N/A'}</p>
|
| 662 |
+
</div>
|
| 663 |
+
</div>
|
| 664 |
+
<div class="flex justify-end items-center gap-2 mt-4 pt-3 border-t border-gray-700/50">
|
| 665 |
+
<a href="/uploads/${data.image_filename}" target="_blank" class="text-blue-400 hover:text-blue-300 transition-colors px-2" title="View Source"><i class="fas fa-eye"></i></a>
|
| 666 |
+
<button class="text-red-500 hover:text-red-400 transition-colors px-2" onclick="deleteItem('cards', '${data.id}')" title="Delete"><i class="fas fa-trash-alt"></i></button>
|
| 667 |
+
</div>`;
|
| 668 |
+
container.appendChild(card);
|
| 669 |
+
}
|
| 670 |
+
|
| 671 |
+
function renderBrochures(dataToRender) {
|
| 672 |
+
const container = document.querySelector('#page-dashboard-brochures .brochure-list-container');
|
| 673 |
+
const placeholder = document.querySelector('#page-dashboard-brochures .placeholder-row');
|
| 674 |
+
|
| 675 |
+
container.innerHTML = '';
|
| 676 |
+
if (!dataToRender || dataToRender.length === 0) {
|
| 677 |
+
placeholder.classList.remove('hidden');
|
| 678 |
+
} else {
|
| 679 |
+
placeholder.classList.add('hidden');
|
| 680 |
+
dataToRender.forEach((data, index) => addBrochureToList(data, index));
|
| 681 |
+
}
|
| 682 |
+
}
|
| 683 |
+
|
| 684 |
+
function addBrochureToList(data, index) {
|
| 685 |
+
const container = document.querySelector('#page-dashboard-brochures .brochure-list-container');
|
| 686 |
+
const row = document.createElement('div');
|
| 687 |
+
row.id = `brochure-${data.id}`;
|
| 688 |
+
row.className = 'brochure-row glass-card p-4 rounded-xl flex items-center justify-between';
|
| 689 |
+
row.style.animationDelay = `${index * 50}ms`;
|
| 690 |
+
row.innerHTML = `
|
| 691 |
+
<div class="flex-1 truncate font-bold">${data.company_name}</div>
|
| 692 |
+
<div class="flex items-center gap-4">
|
| 693 |
+
<button onclick="showContactsModal('${data.id}')" class="text-sm text-blue-400 hover:text-blue-300">View Contacts (${data.contacts.length})</button>
|
| 694 |
+
<button onclick="showInfoModal('${data.id}')" class="text-sm text-purple-400 hover:text-purple-300">View Info</button>
|
| 695 |
+
<div class="flex items-center gap-2">
|
| 696 |
+
<a href="/uploads/${data.image_filename}" target="_blank" class="text-gray-400 hover:text-white transition-colors px-2" title="View PDF"><i class="fas fa-eye"></i></a>
|
| 697 |
+
<button class="text-red-500 hover:text-red-400 transition-colors px-2" onclick="deleteItem('brochures', '${data.id}')" title="Delete Brochure"><i class="fas fa-trash-alt"></i></button>
|
| 698 |
+
</div>
|
| 699 |
+
</div>
|
| 700 |
+
`;
|
| 701 |
+
container.appendChild(row);
|
| 702 |
+
}
|
| 703 |
+
|
| 704 |
+
async function loadInitialData(mode) {
|
| 705 |
+
try {
|
| 706 |
+
const response = await fetch(`${API_BASE_URL}/load_data/${mode}`, {
|
| 707 |
+
method: 'POST',
|
| 708 |
+
headers: { 'Content-Type': 'application/json' },
|
| 709 |
+
// ## MODIFIED: Still only sends API key, model not needed for loading ##
|
| 710 |
+
body: JSON.stringify({ apiKey: userApiKey })
|
| 711 |
+
});
|
| 712 |
+
const data = await response.json();
|
| 713 |
+
contactData[mode] = Array.isArray(data) ? data : [];
|
| 714 |
+
renderUI(mode);
|
| 715 |
+
} catch (error) {
|
| 716 |
+
console.error(`Failed to load data for ${mode}:`, error);
|
| 717 |
+
contactData[mode] = [];
|
| 718 |
+
renderUI(mode);
|
| 719 |
+
}
|
| 720 |
+
}
|
| 721 |
+
|
| 722 |
+
function showPage(pageId) {
|
| 723 |
+
pages.forEach(p => p.classList.remove('active'));
|
| 724 |
+
document.getElementById(pageId).classList.add('active');
|
| 725 |
+
headerSubtitle.textContent = pageId.includes('dashboard') ? "AI Command Center" : "AI-Powered Document Extraction";
|
| 726 |
+
|
| 727 |
+
const isApiPage = pageId === 'page-api-key';
|
| 728 |
+
backBtn.classList.toggle('hidden', isApiPage || pageId === 'page-select-mode');
|
| 729 |
+
homeBtn.classList.toggle('hidden', isApiPage);
|
| 730 |
+
chatWidget.classList.toggle('hidden', !pageId.includes('dashboard'));
|
| 731 |
+
}
|
| 732 |
+
|
| 733 |
+
// ## START: Updated API Key and Model Selection Logic ##
|
| 734 |
+
document.getElementById('continue-with-api-key-btn').addEventListener('click', async () => {
|
| 735 |
+
const keyInput = document.getElementById('api-key-input');
|
| 736 |
+
const errorEl = document.getElementById('api-key-error');
|
| 737 |
+
const key = keyInput.value.trim();
|
| 738 |
+
if (key.length < 73) {
|
| 739 |
+
errorEl.textContent = 'Please enter a valid OpenRouter API key.';
|
| 740 |
+
errorEl.classList.remove('hidden');
|
| 741 |
+
return;
|
| 742 |
+
}
|
| 743 |
+
errorEl.classList.add('hidden');
|
| 744 |
+
userApiKey = key;
|
| 745 |
+
|
| 746 |
+
// Transition to model selector
|
| 747 |
+
document.getElementById('api-key-panel').classList.add('hidden');
|
| 748 |
+
document.getElementById('model-selector-panel').classList.remove('hidden');
|
| 749 |
+
});
|
| 750 |
+
|
| 751 |
+
document.getElementById('continue-with-model-btn').addEventListener('click', () => {
|
| 752 |
+
const selectedRadio = document.querySelector('input[name="model-choice"]:checked');
|
| 753 |
+
const errorEl = document.getElementById('model-choice-error');
|
| 754 |
+
if (!selectedRadio) {
|
| 755 |
+
errorEl.textContent = 'Please select an AI engine to continue.';
|
| 756 |
+
errorEl.classList.remove('hidden');
|
| 757 |
+
return;
|
| 758 |
+
}
|
| 759 |
+
errorEl.classList.add('hidden');
|
| 760 |
+
selectedModel = selectedRadio.value;
|
| 761 |
+
showPage('page-select-mode');
|
| 762 |
+
});
|
| 763 |
+
// ## END: Updated API Key and Model Selection Logic ##
|
| 764 |
+
|
| 765 |
+
document.getElementById('select-cards-btn').addEventListener('click', () => {
|
| 766 |
+
currentMode = 'cards';
|
| 767 |
+
showPage('page-dashboard-cards');
|
| 768 |
+
loadInitialData('cards');
|
| 769 |
+
});
|
| 770 |
+
|
| 771 |
+
document.getElementById('select-brochures-btn').addEventListener('click', () => {
|
| 772 |
+
currentMode = 'brochures';
|
| 773 |
+
showPage('page-dashboard-brochures');
|
| 774 |
+
loadInitialData('brochures');
|
| 775 |
+
});
|
| 776 |
+
|
| 777 |
+
backBtn.addEventListener('click', () => showPage('page-select-mode'));
|
| 778 |
+
|
| 779 |
+
// ## START: Updated Home Button Logic ##
|
| 780 |
+
homeBtn.addEventListener('click', () => {
|
| 781 |
+
// Reset state
|
| 782 |
+
userApiKey = null;
|
| 783 |
+
selectedModel = null;
|
| 784 |
+
document.getElementById('api-key-input').value = '';
|
| 785 |
+
const selectedRadio = document.querySelector('input[name="model-choice"]:checked');
|
| 786 |
+
if (selectedRadio) selectedRadio.checked = false;
|
| 787 |
+
|
| 788 |
+
// Reset UI
|
| 789 |
+
document.getElementById('model-selector-panel').classList.add('hidden');
|
| 790 |
+
document.getElementById('api-key-panel').classList.remove('hidden');
|
| 791 |
+
showPage('page-api-key');
|
| 792 |
+
});
|
| 793 |
+
// ## END: Updated Home Button Logic ##
|
| 794 |
+
|
| 795 |
+
['cards', 'brochures'].forEach(mode => {
|
| 796 |
+
const dashboard = document.getElementById(`page-dashboard-${mode}`);
|
| 797 |
+
if (!dashboard) return;
|
| 798 |
+
|
| 799 |
+
const dropZone = dashboard.querySelector('.drop-zone');
|
| 800 |
+
const fileUpload = dashboard.querySelector('input[type="file"]');
|
| 801 |
+
|
| 802 |
+
dropZone.addEventListener('dragover', e => e.preventDefault());
|
| 803 |
+
dropZone.addEventListener('drop', e => { e.preventDefault(); handleFiles(mode, e.dataTransfer.files); });
|
| 804 |
+
fileUpload.addEventListener('change', e => handleFiles(mode, e.target.files));
|
| 805 |
+
|
| 806 |
+
if (mode === 'cards') {
|
| 807 |
+
const searchInput = dashboard.querySelector('.search-input');
|
| 808 |
+
const actionsMenuBtn = dashboard.querySelector('.actions-menu-btn');
|
| 809 |
+
const actionsDropdown = dashboard.querySelector('.actions-dropdown');
|
| 810 |
+
const cardGridContainer = dashboard.querySelector('.card-grid-container');
|
| 811 |
+
|
| 812 |
+
searchInput.addEventListener('input', () => {
|
| 813 |
+
const searchTerm = searchInput.value.toLowerCase();
|
| 814 |
+
const filteredData = contactData.cards.filter(contact => Object.values(contact).some(value => String(value).toLowerCase().includes(searchTerm)));
|
| 815 |
+
renderCards(filteredData);
|
| 816 |
+
});
|
| 817 |
+
|
| 818 |
+
actionsMenuBtn.addEventListener('click', () => actionsDropdown.classList.toggle('hidden'));
|
| 819 |
+
|
| 820 |
+
actionsDropdown.querySelector('.export-pdf-btn').addEventListener('click', e => { e.preventDefault(); exportData('cards', 'pdf'); });
|
| 821 |
+
actionsDropdown.querySelector('.export-excel-btn').addEventListener('click', e => { e.preventDefault(); exportData('cards', 'excel'); });
|
| 822 |
+
actionsDropdown.querySelector('.export-vcf-btn').addEventListener('click', e => { e.preventDefault(); exportToVCF(); });
|
| 823 |
+
|
| 824 |
+
cardGridContainer.addEventListener('click', (e) => {
|
| 825 |
+
const fieldElement = e.target.closest('.editable-field');
|
| 826 |
+
if (!fieldElement || fieldElement.querySelector('input')) return;
|
| 827 |
+
|
| 828 |
+
// Get the text content properly - exclude icon text
|
| 829 |
+
const icon = fieldElement.querySelector('i');
|
| 830 |
+
let originalValue;
|
| 831 |
+
if (icon) {
|
| 832 |
+
// Clone the element, remove the icon, get remaining text
|
| 833 |
+
const clone = fieldElement.cloneNode(true);
|
| 834 |
+
clone.querySelector('i')?.remove();
|
| 835 |
+
originalValue = clone.textContent.trim();
|
| 836 |
+
} else {
|
| 837 |
+
originalValue = fieldElement.textContent.trim();
|
| 838 |
+
}
|
| 839 |
+
|
| 840 |
+
const fieldName = fieldElement.dataset.field;
|
| 841 |
+
fieldElement.innerHTML = `<input type="text" value="${originalValue}" class="w-full bg-gray-800 border border-purple-500 rounded px-2 py-1 text-sm">`;
|
| 842 |
+
const input = fieldElement.querySelector('input');
|
| 843 |
+
input.focus();
|
| 844 |
+
input.select();
|
| 845 |
+
|
| 846 |
+
const save = async () => {
|
| 847 |
+
const newValue = input.value.trim() || 'N/A';
|
| 848 |
+
const cardId = fieldElement.closest('.contact-card').id.replace('card-', '');
|
| 849 |
+
|
| 850 |
+
// Restore the field HTML first
|
| 851 |
+
const hasIcon = fieldName === 'Email' || fieldName === 'Number' || fieldName === 'Address';
|
| 852 |
+
fieldElement.innerHTML = hasIcon ? `<i class="fas ${getIconForField(fieldName)} fa-fw mr-2"></i>${newValue}` : newValue;
|
| 853 |
+
|
| 854 |
+
// Only save if value actually changed
|
| 855 |
+
if (newValue !== originalValue) {
|
| 856 |
+
const dataIndex = contactData.cards.findIndex(d => d.id === cardId);
|
| 857 |
+
if (dataIndex > -1) contactData.cards[dataIndex][fieldName] = newValue;
|
| 858 |
+
await saveEditToServer('cards', cardId, fieldName, newValue);
|
| 859 |
+
}
|
| 860 |
+
};
|
| 861 |
+
input.addEventListener('blur', save);
|
| 862 |
+
input.addEventListener('keydown', (e) => {
|
| 863 |
+
if (e.key === 'Enter') input.blur();
|
| 864 |
+
if (e.key === 'Escape') fieldElement.innerHTML = fieldElement.innerHTML.includes('fa-fw') ? `<i class="fas ${getIconForField(fieldName)} fa-fw mr-2"></i>${originalValue}` : originalValue;
|
| 865 |
+
});
|
| 866 |
+
});
|
| 867 |
+
}
|
| 868 |
+
});
|
| 869 |
+
|
| 870 |
+
document.addEventListener('click', (e) => {
|
| 871 |
+
document.querySelectorAll('.actions-menu-container').forEach(container => {
|
| 872 |
+
if (!container.contains(e.target)) {
|
| 873 |
+
container.querySelector('.actions-dropdown').classList.add('hidden');
|
| 874 |
+
}
|
| 875 |
+
});
|
| 876 |
+
});
|
| 877 |
+
|
| 878 |
+
function handleFiles(mode, files) {
|
| 879 |
+
if (files.length === 0) return;
|
| 880 |
+
if (!userApiKey || !selectedModel) {
|
| 881 |
+
alert("Please ensure you have entered an API key and selected a model.");
|
| 882 |
+
showPage('page-api-key');
|
| 883 |
+
return;
|
| 884 |
+
}
|
| 885 |
+
Array.from(files).forEach(file => processFile(mode, file));
|
| 886 |
+
}
|
| 887 |
+
|
| 888 |
+
async function processFile(mode, file) {
|
| 889 |
+
const fileId = `file-${Date.now()}-${Math.random()}`;
|
| 890 |
+
addFileToQueueUI(mode, fileId, file.name);
|
| 891 |
+
const formData = new FormData();
|
| 892 |
+
formData.append('file', file);
|
| 893 |
+
// ## MODIFIED: Send both API key and selected model ##
|
| 894 |
+
formData.append('apiKey', userApiKey);
|
| 895 |
+
formData.append('selectedModel', selectedModel);
|
| 896 |
+
|
| 897 |
+
const endpoint = mode === 'cards' ? '/process_card' : '/process_brochure';
|
| 898 |
+
try {
|
| 899 |
+
const response = await fetch(API_BASE_URL + endpoint, { method: 'POST', body: formData });
|
| 900 |
+
if (!response.ok) throw new Error((await response.json()).error || 'Server error');
|
| 901 |
+
const newData = await response.json();
|
| 902 |
+
|
| 903 |
+
if (mode === 'brochures') {
|
| 904 |
+
contactData.brochures.unshift(newData);
|
| 905 |
+
} else {
|
| 906 |
+
contactData.cards.unshift(newData);
|
| 907 |
+
}
|
| 908 |
+
|
| 909 |
+
renderUI(mode);
|
| 910 |
+
updateQueueUI(mode, fileId, 'success', 'Processing complete.');
|
| 911 |
+
} catch (error) { console.error('Error processing file:', error); updateQueueUI(mode, fileId, 'error', error.message); }
|
| 912 |
+
}
|
| 913 |
+
|
| 914 |
+
async function deleteItem(mode, itemId, contactId = null) {
|
| 915 |
+
const confirmMessage = contactId ? 'Are you sure you want to delete this contact?' : `Are you sure you want to delete this entire ${mode.slice(0, -1)}?`;
|
| 916 |
+
if (!confirm(confirmMessage)) return;
|
| 917 |
+
try {
|
| 918 |
+
const response = await fetch(`${API_BASE_URL}/delete_card/${mode}/${itemId}`, {
|
| 919 |
+
method: 'DELETE',
|
| 920 |
+
headers: { 'Content-Type': 'application/json' },
|
| 921 |
+
// ## MODIFIED: Send API key with delete request ##
|
| 922 |
+
body: JSON.stringify({ apiKey: userApiKey, contactId: contactId })
|
| 923 |
+
});
|
| 924 |
+
const result = await response.json();
|
| 925 |
+
if (!result.success) throw new Error(result.message);
|
| 926 |
+
|
| 927 |
+
await loadInitialData(mode);
|
| 928 |
+
if (contactId) {
|
| 929 |
+
const brochure = contactData.brochures.find(b => b.id === itemId);
|
| 930 |
+
if (brochure) {
|
| 931 |
+
showContactsModal(itemId);
|
| 932 |
+
} else {
|
| 933 |
+
toggleModal('contacts-modal', false);
|
| 934 |
+
}
|
| 935 |
+
}
|
| 936 |
+
|
| 937 |
+
addFileToQueueUI(mode, `delete-${itemId}`, `Item deleted.`, 'success');
|
| 938 |
+
} catch (error) { alert(`Failed to delete: ${error.message}`); }
|
| 939 |
+
}
|
| 940 |
+
|
| 941 |
+
function getIconForField(fieldName) {
|
| 942 |
+
const icons = { "Email": "fa-envelope", "Number": "fa-phone", "Address": "fa-map-marker-alt" };
|
| 943 |
+
return icons[fieldName] || "";
|
| 944 |
+
}
|
| 945 |
+
|
| 946 |
+
async function saveEditToServer(mode, itemId, field, value, contactId = null) {
|
| 947 |
+
try {
|
| 948 |
+
const response = await fetch(`${API_BASE_URL}/update_card/${mode}/${itemId}`, {
|
| 949 |
+
method: 'POST',
|
| 950 |
+
headers: { 'Content-Type': 'application/json' },
|
| 951 |
+
// ## MODIFIED: Send API key with update request ##
|
| 952 |
+
body: JSON.stringify({ field, value, apiKey: userApiKey, contactId })
|
| 953 |
+
});
|
| 954 |
+
const result = await response.json();
|
| 955 |
+
if (!result.success) throw new Error(result.message);
|
| 956 |
+
addFileToQueueUI(mode, Date.now(), `Saved changes to ${field}.`, 'success');
|
| 957 |
+
} catch (error) {
|
| 958 |
+
alert(`Failed to save changes: ${error.message}`);
|
| 959 |
+
loadInitialData(mode);
|
| 960 |
+
}
|
| 961 |
+
}
|
| 962 |
+
|
| 963 |
+
function addFileToQueueUI(mode, id, name, status = 'processing') {
|
| 964 |
+
const feed = document.querySelector(`#page-dashboard-${mode} .live-activity-feed`);
|
| 965 |
+
if (!feed) {
|
| 966 |
+
console.log('Live activity feed not found for mode:', mode);
|
| 967 |
+
return;
|
| 968 |
+
}
|
| 969 |
+
const el = document.createElement('div');
|
| 970 |
+
el.id = id;
|
| 971 |
+
el.className = 'glass-card p-2 rounded-lg flex items-center justify-between text-sm opacity-0 transform -translate-y-2 transition-all duration-300';
|
| 972 |
+
el.innerHTML = `<span class="truncate"></span><div class="status-icon ml-2"></div>`;
|
| 973 |
+
feed.prepend(el);
|
| 974 |
+
setTimeout(() => { el.classList.remove('opacity-0', '-translate-y-2'); }, 10);
|
| 975 |
+
updateQueueUI(mode, id, status, name);
|
| 976 |
+
}
|
| 977 |
+
|
| 978 |
+
function updateQueueUI(mode, id, status, message = '') {
|
| 979 |
+
const el = document.getElementById(id);
|
| 980 |
+
if (!el) return;
|
| 981 |
+
let iconHtml = '', textHtml = '';
|
| 982 |
+
switch (status) {
|
| 983 |
+
case 'processing': iconHtml = '<i class="fas fa-spinner animate-spin"></i>'; textHtml = `Processing ${message}...`; break;
|
| 984 |
+
case 'success': iconHtml = '<i class="fas fa-check-circle text-green-500"></i>'; textHtml = message; setTimeout(() => el.classList.add('opacity-0'), 3000); setTimeout(() => el.remove(), 3500); break;
|
| 985 |
+
case 'error': iconHtml = `<i class="fas fa-exclamation-circle text-red-500" title="${message}"></i>`; textHtml = `Error: ${message.substring(0, 50)}...`; el.querySelector('span').classList.add('line-through'); break;
|
| 986 |
+
}
|
| 987 |
+
el.querySelector('span').innerHTML = textHtml;
|
| 988 |
+
el.querySelector('.status-icon').innerHTML = iconHtml;
|
| 989 |
+
}
|
| 990 |
+
|
| 991 |
+
function exportData(mode, format, brochureId = null) {
|
| 992 |
+
let dataToExport;
|
| 993 |
+
let companyName = "Exported_Data";
|
| 994 |
+
|
| 995 |
+
if (brochureId) {
|
| 996 |
+
const brochure = contactData.brochures.find(b => b.id === brochureId);
|
| 997 |
+
dataToExport = brochure ? brochure.contacts : [];
|
| 998 |
+
companyName = brochure ? brochure.company_name : "Brochure_Contacts";
|
| 999 |
+
} else {
|
| 1000 |
+
dataToExport = contactData[mode];
|
| 1001 |
+
companyName = mode === 'cards' ? "Business_Cards" : "All_Brochures";
|
| 1002 |
+
}
|
| 1003 |
+
|
| 1004 |
+
if (!dataToExport || dataToExport.length === 0) return addFileToQueueUI(mode, Date.now(), `No data to export.`, 'error');
|
| 1005 |
+
|
| 1006 |
+
const headers = ["Company Name", "Owner Name", "Email", "Number"];
|
| 1007 |
+
|
| 1008 |
+
if (format === 'pdf') {
|
| 1009 |
+
const { jsPDF } = window.jspdf;
|
| 1010 |
+
const doc = new jsPDF();
|
| 1011 |
+
const tableRows = dataToExport.map(contact => [
|
| 1012 |
+
contact['Company Name'] || (brochureId ? companyName : 'N/A'),
|
| 1013 |
+
contact["Owner Name"] || "N/A",
|
| 1014 |
+
contact["Email"] || "N/A",
|
| 1015 |
+
contact["Number"] || "N/A"
|
| 1016 |
+
]);
|
| 1017 |
+
doc.text(`Extracted Contacts: ${companyName}`, 14, 15);
|
| 1018 |
+
doc.autoTable({ head: [headers], body: tableRows, startY: 20 });
|
| 1019 |
+
doc.save(`${companyName}_contacts.pdf`);
|
| 1020 |
+
addFileToQueueUI(mode, Date.now(), `Exported to PDF.`, 'success');
|
| 1021 |
+
} else if (format === 'excel') {
|
| 1022 |
+
const csvRows = dataToExport.map(row => headers.map(fieldName => {
|
| 1023 |
+
const value = (row[fieldName] || (fieldName === 'Company Name' && brochureId ? companyName : 'N/A')).toString();
|
| 1024 |
+
return value.includes(',') ? `"${value}"` : value;
|
| 1025 |
+
}).join(','));
|
| 1026 |
+
const csvContent = [headers.join(','), ...csvRows].join('\n');
|
| 1027 |
+
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
| 1028 |
+
const link = document.createElement('a');
|
| 1029 |
+
const url = URL.createObjectURL(blob);
|
| 1030 |
+
link.setAttribute('href', url);
|
| 1031 |
+
link.setAttribute('download', `${companyName}_contacts.csv`);
|
| 1032 |
+
document.body.appendChild(link);
|
| 1033 |
+
link.click();
|
| 1034 |
+
document.body.removeChild(link);
|
| 1035 |
+
addFileToQueueUI(mode, Date.now(), `Exported to Excel (CSV).`, 'success');
|
| 1036 |
+
}
|
| 1037 |
+
}
|
| 1038 |
+
|
| 1039 |
+
// Export contacts as VCF (vCard) - works on iOS and Android
|
| 1040 |
+
function exportToVCF() {
|
| 1041 |
+
const dataToExport = contactData.cards;
|
| 1042 |
+
if (!dataToExport || dataToExport.length === 0) {
|
| 1043 |
+
addFileToQueueUI('cards', Date.now(), 'No contacts to export.', 'error');
|
| 1044 |
+
return;
|
| 1045 |
+
}
|
| 1046 |
+
|
| 1047 |
+
// Helper to escape special characters in vCard fields
|
| 1048 |
+
function escapeVCard(str) {
|
| 1049 |
+
if (!str) return '';
|
| 1050 |
+
return str.replace(/\\/g, '\\\\')
|
| 1051 |
+
.replace(/;/g, '\\;')
|
| 1052 |
+
.replace(/,/g, '\\,')
|
| 1053 |
+
.replace(/\n/g, '\\n');
|
| 1054 |
+
}
|
| 1055 |
+
|
| 1056 |
+
let vcfContent = '';
|
| 1057 |
+
|
| 1058 |
+
dataToExport.forEach(contact => {
|
| 1059 |
+
// Get the contact name - use Owner Name, fall back to Company Name
|
| 1060 |
+
const fullName = (contact['Owner Name'] || contact['Company Name'] || 'Unknown Contact').trim();
|
| 1061 |
+
const nameParts = fullName.split(/\s+/);
|
| 1062 |
+
const firstName = nameParts[0] || '';
|
| 1063 |
+
const lastName = nameParts.slice(1).join(' ') || '';
|
| 1064 |
+
|
| 1065 |
+
// Get phone numbers (may have multiple separated by comma or semicolon)
|
| 1066 |
+
const phones = (contact['Number'] || '')
|
| 1067 |
+
.split(/[,;]/)
|
| 1068 |
+
.map(p => p.trim())
|
| 1069 |
+
.filter(p => p && p !== 'N/A' && p !== 'null');
|
| 1070 |
+
|
| 1071 |
+
// Build vCard 3.0 format (most compatible)
|
| 1072 |
+
vcfContent += 'BEGIN:VCARD\r\n';
|
| 1073 |
+
vcfContent += 'VERSION:3.0\r\n';
|
| 1074 |
+
vcfContent += `N:${escapeVCard(lastName)};${escapeVCard(firstName)};;;\r\n`;
|
| 1075 |
+
vcfContent += `FN:${escapeVCard(fullName)}\r\n`;
|
| 1076 |
+
|
| 1077 |
+
// Add organization/company
|
| 1078 |
+
const company = contact['Company Name'];
|
| 1079 |
+
if (company && company !== 'N/A' && company !== 'null' && company !== 'Unknown Company') {
|
| 1080 |
+
vcfContent += `ORG:${escapeVCard(company)}\r\n`;
|
| 1081 |
+
}
|
| 1082 |
+
|
| 1083 |
+
// Add email
|
| 1084 |
+
const email = contact['Email'];
|
| 1085 |
+
if (email && email !== 'N/A' && email !== 'null') {
|
| 1086 |
+
vcfContent += `EMAIL;TYPE=WORK:${email}\r\n`;
|
| 1087 |
+
}
|
| 1088 |
+
|
| 1089 |
+
// Add all phone numbers
|
| 1090 |
+
phones.forEach(phone => {
|
| 1091 |
+
vcfContent += `TEL;TYPE=CELL:${phone.replace(/\s+/g, '')}\r\n`;
|
| 1092 |
+
});
|
| 1093 |
+
|
| 1094 |
+
// Add address
|
| 1095 |
+
const address = contact['Address'];
|
| 1096 |
+
if (address && address !== 'N/A' && address !== 'null') {
|
| 1097 |
+
vcfContent += `ADR;TYPE=WORK:;;${escapeVCard(address)};;;;\r\n`;
|
| 1098 |
+
}
|
| 1099 |
+
|
| 1100 |
+
vcfContent += 'END:VCARD\r\n';
|
| 1101 |
+
});
|
| 1102 |
+
|
| 1103 |
+
// Create and download the VCF file with readable name
|
| 1104 |
+
const blob = new Blob([vcfContent], { type: 'text/vcard;charset=utf-8' });
|
| 1105 |
+
const link = document.createElement('a');
|
| 1106 |
+
const url = URL.createObjectURL(blob);
|
| 1107 |
+
link.setAttribute('href', url);
|
| 1108 |
+
link.setAttribute('download', `BusinessCards_${dataToExport.length}_contacts.vcf`);
|
| 1109 |
+
document.body.appendChild(link);
|
| 1110 |
+
link.click();
|
| 1111 |
+
document.body.removeChild(link);
|
| 1112 |
+
URL.revokeObjectURL(url);
|
| 1113 |
+
|
| 1114 |
+
addFileToQueueUI('cards', Date.now(), `Exported ${dataToExport.length} contacts to VCF.`, 'success');
|
| 1115 |
+
}
|
| 1116 |
+
|
| 1117 |
+
async function handleChatSubmit() {
|
| 1118 |
+
const query = chatInput.value.trim();
|
| 1119 |
+
if (!query) return;
|
| 1120 |
+
addChatMessage(query, 'user');
|
| 1121 |
+
chatInput.value = '';
|
| 1122 |
+
chatSendBtn.disabled = true;
|
| 1123 |
+
addChatMessage('...', 'ai_typing');
|
| 1124 |
+
try {
|
| 1125 |
+
const response = await fetch(`${API_BASE_URL}/chat`, {
|
| 1126 |
+
method: 'POST',
|
| 1127 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1128 |
+
// ## MODIFIED: Send both API key and selected model ##
|
| 1129 |
+
body: JSON.stringify({ query: query, apiKey: userApiKey, mode: currentMode, selectedModel: selectedModel })
|
| 1130 |
+
});
|
| 1131 |
+
document.querySelector('.ai_typing')?.parentElement.parentElement.remove();
|
| 1132 |
+
if (!response.ok) throw new Error((await response.json()).error || 'Server error');
|
| 1133 |
+
const data = await response.json();
|
| 1134 |
+
addChatMessage(data.answer, 'ai');
|
| 1135 |
+
} catch (error) {
|
| 1136 |
+
document.querySelector('.ai_typing')?.parentElement.parentElement.remove();
|
| 1137 |
+
addChatMessage(`Error: ${error.message}`, 'ai');
|
| 1138 |
+
} finally {
|
| 1139 |
+
chatSendBtn.disabled = false;
|
| 1140 |
+
}
|
| 1141 |
+
}
|
| 1142 |
+
|
| 1143 |
+
function addChatMessage(message, type) {
|
| 1144 |
+
const bubble = document.createElement('div');
|
| 1145 |
+
bubble.className = `chat-bubble rounded-lg p-3 max-w-xs break-words ${type === 'user' ? 'bg-purple-600 self-end' : 'bg-gray-700 self-start'}`;
|
| 1146 |
+
if (type === 'ai_typing') {
|
| 1147 |
+
bubble.innerHTML = `<p class="text-sm italic ai_typing">AI is thinking...</p>`;
|
| 1148 |
+
} else {
|
| 1149 |
+
// Basic Markdown to HTML conversion
|
| 1150 |
+
let formattedMessage = message.replace(/\n/g, '<br>');
|
| 1151 |
+
// Bold
|
| 1152 |
+
formattedMessage = formattedMessage.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
|
| 1153 |
+
// Tables
|
| 1154 |
+
formattedMessage = formattedMessage.replace(/\|(.+)\|/g, (match, row) => {
|
| 1155 |
+
const cells = row.split('|').map(c => c.trim()).filter(c => c);
|
| 1156 |
+
if (cells.length > 1) {
|
| 1157 |
+
if (row.includes('---')) {
|
| 1158 |
+
return ''; // Skip header separator line
|
| 1159 |
+
}
|
| 1160 |
+
const tag = row.includes('Company Name') ? 'th' : 'td'; // Simple header detection
|
| 1161 |
+
return `<tr>${cells.map(c => `<${tag} class="border border-gray-600 px-2 py-1">${c}</${tag}>`).join('')}</tr>`;
|
| 1162 |
+
}
|
| 1163 |
+
return match;
|
| 1164 |
+
});
|
| 1165 |
+
if (formattedMessage.includes('<tr>')) {
|
| 1166 |
+
formattedMessage = `<table class="table-auto w-full text-left my-2 border-collapse">${formattedMessage}</table>`;
|
| 1167 |
+
}
|
| 1168 |
+
|
| 1169 |
+
bubble.innerHTML = `<p class="text-sm">${formattedMessage}</p>`;
|
| 1170 |
+
}
|
| 1171 |
+
const messageWrapper = document.createElement('div');
|
| 1172 |
+
messageWrapper.className = `flex w-full ${type === 'user' ? 'justify-end' : 'justify-start'}`;
|
| 1173 |
+
messageWrapper.appendChild(bubble);
|
| 1174 |
+
chatMessages.appendChild(messageWrapper);
|
| 1175 |
+
chatMessages.scrollTop = chatMessages.scrollHeight;
|
| 1176 |
+
}
|
| 1177 |
+
|
| 1178 |
+
function toggleModal(modalId, show) {
|
| 1179 |
+
const modal = document.getElementById(modalId);
|
| 1180 |
+
if (show) {
|
| 1181 |
+
modal.classList.remove('hidden');
|
| 1182 |
+
setTimeout(() => {
|
| 1183 |
+
modal.style.opacity = '1';
|
| 1184 |
+
modal.querySelector('.modal-content').style.opacity = '1';
|
| 1185 |
+
modal.querySelector('.modal-content').style.transform = 'scale(1)';
|
| 1186 |
+
}, 10);
|
| 1187 |
+
} else {
|
| 1188 |
+
modal.style.opacity = '0';
|
| 1189 |
+
modal.querySelector('.modal-content').style.opacity = '0';
|
| 1190 |
+
modal.querySelector('.modal-content').style.transform = 'scale(0.95)';
|
| 1191 |
+
setTimeout(() => modal.classList.add('hidden'), 300);
|
| 1192 |
+
}
|
| 1193 |
+
}
|
| 1194 |
+
|
| 1195 |
+
function showContactsModal(brochureId) {
|
| 1196 |
+
const brochure = contactData.brochures.find(b => b.id === brochureId);
|
| 1197 |
+
if (!brochure) return;
|
| 1198 |
+
|
| 1199 |
+
const modalTitle = document.getElementById('contacts-modal-title');
|
| 1200 |
+
const modalBody = document.getElementById('contacts-modal-body');
|
| 1201 |
+
modalTitle.textContent = `Contacts for ${brochure.company_name}`;
|
| 1202 |
+
modalBody.innerHTML = '';
|
| 1203 |
+
|
| 1204 |
+
document.getElementById('export-contacts-pdf-btn').onclick = () => exportData('brochures', 'pdf', brochureId);
|
| 1205 |
+
document.getElementById('export-contacts-excel-btn').onclick = () => exportData('brochures', 'excel', brochureId);
|
| 1206 |
+
|
| 1207 |
+
if (brochure.contacts.length === 0) {
|
| 1208 |
+
modalBody.innerHTML = '<p class="text-gray-400">No individual contacts were found in this brochure.</p>';
|
| 1209 |
+
} else {
|
| 1210 |
+
brochure.contacts.forEach(contact => {
|
| 1211 |
+
const contactEl = document.createElement('div');
|
| 1212 |
+
contactEl.className = 'glass-card p-3 rounded-lg flex items-center justify-between';
|
| 1213 |
+
contactEl.innerHTML = `
|
| 1214 |
+
<div class="flex-1 grid grid-cols-3 gap-4 text-sm">
|
| 1215 |
+
<div class="editable-field" data-brochure-id="${brochure.id}" data-contact-id="${contact.id}" data-field="Owner Name">${contact['Owner Name'] || 'N/A'}</div>
|
| 1216 |
+
<div class="editable-field" data-brochure-id="${brochure.id}" data-contact-id="${contact.id}" data-field="Email">${contact['Email'] || 'N/A'}</div>
|
| 1217 |
+
<div class="editable-field" data-brochure-id="${brochure.id}" data-contact-id="${contact.id}" data-field="Number">${contact['Number'] || 'N/A'}</div>
|
| 1218 |
+
</div>
|
| 1219 |
+
<button class="text-red-500 hover:text-red-400 ml-4" onclick="deleteItem('brochures', '${brochure.id}', '${contact.id}')"><i class="fas fa-trash-alt"></i></button>
|
| 1220 |
+
`;
|
| 1221 |
+
modalBody.appendChild(contactEl);
|
| 1222 |
+
});
|
| 1223 |
+
}
|
| 1224 |
+
toggleModal('contacts-modal', true);
|
| 1225 |
+
}
|
| 1226 |
+
|
| 1227 |
+
function showInfoModal(brochureId) {
|
| 1228 |
+
const brochure = contactData.brochures.find(b => b.id === brochureId);
|
| 1229 |
+
if (!brochure) return;
|
| 1230 |
+
document.getElementById('info-modal-title').textContent = `Information for ${brochure.company_name}`;
|
| 1231 |
+
document.getElementById('info-modal-body').textContent = brochure.raw_text || "No additional information was extracted.";
|
| 1232 |
+
|
| 1233 |
+
document.getElementById('export-info-pdf-btn').onclick = () => {
|
| 1234 |
+
const { jsPDF } = window.jspdf;
|
| 1235 |
+
const doc = new jsPDF();
|
| 1236 |
+
doc.text(`Information for ${brochure.company_name}`, 14, 15);
|
| 1237 |
+
doc.text(brochure.raw_text, 14, 25, { maxWidth: 180 });
|
| 1238 |
+
doc.save(`${brochure.company_name}_info.pdf`);
|
| 1239 |
+
addFileToQueueUI('brochures', Date.now(), 'Exported info to PDF.', 'success');
|
| 1240 |
+
};
|
| 1241 |
+
|
| 1242 |
+
toggleModal('info-modal', true);
|
| 1243 |
+
}
|
| 1244 |
+
|
| 1245 |
+
document.getElementById('contacts-modal-body').addEventListener('click', (e) => {
|
| 1246 |
+
const fieldElement = e.target.closest('.editable-field');
|
| 1247 |
+
if (!fieldElement || fieldElement.querySelector('input')) return;
|
| 1248 |
+
|
| 1249 |
+
const originalValue = fieldElement.textContent;
|
| 1250 |
+
const brochureId = fieldElement.dataset.brochureId;
|
| 1251 |
+
const contactId = fieldElement.dataset.contactId;
|
| 1252 |
+
const fieldName = fieldElement.dataset.field;
|
| 1253 |
+
|
| 1254 |
+
fieldElement.innerHTML = `<input type="text" value="${originalValue}" class="w-full bg-gray-800 border border-purple-500 rounded px-2 py-1 text-sm">`;
|
| 1255 |
+
const input = fieldElement.querySelector('input');
|
| 1256 |
+
input.focus();
|
| 1257 |
+
input.select();
|
| 1258 |
+
|
| 1259 |
+
const save = async () => {
|
| 1260 |
+
const newValue = input.value.trim() || 'N/A';
|
| 1261 |
+
fieldElement.textContent = newValue;
|
| 1262 |
+
|
| 1263 |
+
const brochure = contactData.brochures.find(b => b.id === brochureId);
|
| 1264 |
+
const contact = brochure.contacts.find(c => c.id === contactId);
|
| 1265 |
+
contact[fieldName] = newValue;
|
| 1266 |
+
|
| 1267 |
+
await saveEditToServer('brochures', brochureId, fieldName, newValue, contactId);
|
| 1268 |
+
};
|
| 1269 |
+
input.addEventListener('blur', save);
|
| 1270 |
+
input.addEventListener('keydown', (e) => {
|
| 1271 |
+
if (e.key === 'Enter') input.blur();
|
| 1272 |
+
if (e.key === 'Escape') fieldElement.textContent = originalValue;
|
| 1273 |
+
});
|
| 1274 |
+
});
|
| 1275 |
+
|
| 1276 |
+
|
| 1277 |
+
chatSendBtn.addEventListener('click', handleChatSubmit);
|
| 1278 |
+
chatInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); handleChatSubmit(); } });
|
| 1279 |
+
|
| 1280 |
+
// ## START: Camera Capture Functionality ##
|
| 1281 |
+
let cameraStream = null;
|
| 1282 |
+
let cameraCaptureMode = null; // 'cards' or 'brochures'
|
| 1283 |
+
let capturedImageBlob = null;
|
| 1284 |
+
|
| 1285 |
+
const cameraModal = document.getElementById('camera-modal');
|
| 1286 |
+
const cameraVideo = document.getElementById('camera-video');
|
| 1287 |
+
const cameraCanvas = document.getElementById('camera-canvas');
|
| 1288 |
+
const cameraPreview = document.getElementById('camera-preview');
|
| 1289 |
+
const cameraLoading = document.getElementById('camera-loading');
|
| 1290 |
+
const cameraError = document.getElementById('camera-error');
|
| 1291 |
+
const cameraCaptureBtn = document.getElementById('camera-capture-btn');
|
| 1292 |
+
const cameraRetakeBtn = document.getElementById('camera-retake-btn');
|
| 1293 |
+
const cameraUseBtn = document.getElementById('camera-use-btn');
|
| 1294 |
+
const cameraCloseBtn = document.getElementById('camera-close-btn');
|
| 1295 |
+
|
| 1296 |
+
// Open camera modal
|
| 1297 |
+
async function openCamera(mode) {
|
| 1298 |
+
cameraCaptureMode = mode;
|
| 1299 |
+
capturedImageBlob = null;
|
| 1300 |
+
|
| 1301 |
+
// Reset UI state
|
| 1302 |
+
cameraVideo.classList.add('hidden');
|
| 1303 |
+
cameraPreview.classList.add('hidden');
|
| 1304 |
+
cameraLoading.classList.remove('hidden');
|
| 1305 |
+
cameraError.classList.add('hidden');
|
| 1306 |
+
cameraCaptureBtn.classList.add('hidden');
|
| 1307 |
+
cameraRetakeBtn.classList.add('hidden');
|
| 1308 |
+
cameraUseBtn.classList.add('hidden');
|
| 1309 |
+
|
| 1310 |
+
// Update modal title
|
| 1311 |
+
const titleText = mode === 'cards' ? 'Capture Business Card' : 'Capture Brochure Page';
|
| 1312 |
+
document.querySelector('#camera-modal-title span').textContent = titleText;
|
| 1313 |
+
|
| 1314 |
+
// Show modal
|
| 1315 |
+
toggleModal('camera-modal', true);
|
| 1316 |
+
|
| 1317 |
+
try {
|
| 1318 |
+
// Request camera access (prefer back camera for documents)
|
| 1319 |
+
cameraStream = await navigator.mediaDevices.getUserMedia({
|
| 1320 |
+
video: { facingMode: 'environment', width: { ideal: 1920 }, height: { ideal: 1080 } },
|
| 1321 |
+
audio: false
|
| 1322 |
+
});
|
| 1323 |
+
|
| 1324 |
+
cameraVideo.srcObject = cameraStream;
|
| 1325 |
+
await cameraVideo.play();
|
| 1326 |
+
|
| 1327 |
+
// Show video and capture button
|
| 1328 |
+
cameraLoading.classList.add('hidden');
|
| 1329 |
+
cameraVideo.classList.remove('hidden');
|
| 1330 |
+
cameraCaptureBtn.classList.remove('hidden');
|
| 1331 |
+
|
| 1332 |
+
} catch (err) {
|
| 1333 |
+
console.error('Camera access error:', err);
|
| 1334 |
+
cameraLoading.classList.add('hidden');
|
| 1335 |
+
cameraError.classList.remove('hidden');
|
| 1336 |
+
}
|
| 1337 |
+
}
|
| 1338 |
+
|
| 1339 |
+
// Stop camera stream
|
| 1340 |
+
function stopCamera() {
|
| 1341 |
+
if (cameraStream) {
|
| 1342 |
+
cameraStream.getTracks().forEach(track => track.stop());
|
| 1343 |
+
cameraStream = null;
|
| 1344 |
+
}
|
| 1345 |
+
cameraVideo.srcObject = null;
|
| 1346 |
+
}
|
| 1347 |
+
|
| 1348 |
+
// Capture photo from video
|
| 1349 |
+
function capturePhoto() {
|
| 1350 |
+
cameraCanvas.width = cameraVideo.videoWidth;
|
| 1351 |
+
cameraCanvas.height = cameraVideo.videoHeight;
|
| 1352 |
+
const ctx = cameraCanvas.getContext('2d');
|
| 1353 |
+
ctx.drawImage(cameraVideo, 0, 0);
|
| 1354 |
+
|
| 1355 |
+
// Convert to blob
|
| 1356 |
+
cameraCanvas.toBlob((blob) => {
|
| 1357 |
+
capturedImageBlob = blob;
|
| 1358 |
+
|
| 1359 |
+
// Show preview
|
| 1360 |
+
cameraPreview.src = URL.createObjectURL(blob);
|
| 1361 |
+
cameraVideo.classList.add('hidden');
|
| 1362 |
+
cameraPreview.classList.remove('hidden');
|
| 1363 |
+
|
| 1364 |
+
// Toggle buttons
|
| 1365 |
+
cameraCaptureBtn.classList.add('hidden');
|
| 1366 |
+
cameraRetakeBtn.classList.remove('hidden');
|
| 1367 |
+
cameraUseBtn.classList.remove('hidden');
|
| 1368 |
+
}, 'image/jpeg', 0.92);
|
| 1369 |
+
}
|
| 1370 |
+
|
| 1371 |
+
// Retake photo
|
| 1372 |
+
function retakePhoto() {
|
| 1373 |
+
capturedImageBlob = null;
|
| 1374 |
+
cameraPreview.classList.add('hidden');
|
| 1375 |
+
cameraVideo.classList.remove('hidden');
|
| 1376 |
+
|
| 1377 |
+
cameraRetakeBtn.classList.add('hidden');
|
| 1378 |
+
cameraUseBtn.classList.add('hidden');
|
| 1379 |
+
cameraCaptureBtn.classList.remove('hidden');
|
| 1380 |
+
}
|
| 1381 |
+
|
| 1382 |
+
// Use captured photo
|
| 1383 |
+
async function useCapturedPhoto() {
|
| 1384 |
+
if (!capturedImageBlob || !cameraCaptureMode) {
|
| 1385 |
+
console.error('No captured image or mode');
|
| 1386 |
+
return;
|
| 1387 |
+
}
|
| 1388 |
+
|
| 1389 |
+
// Save values before closing (closeCamera resets them)
|
| 1390 |
+
const imageBlob = capturedImageBlob;
|
| 1391 |
+
const mode = cameraCaptureMode;
|
| 1392 |
+
|
| 1393 |
+
// Close modal
|
| 1394 |
+
closeCamera();
|
| 1395 |
+
|
| 1396 |
+
// Create file from blob
|
| 1397 |
+
const fileName = `camera_${Date.now()}.jpg`;
|
| 1398 |
+
const file = new File([imageBlob], fileName, { type: 'image/jpeg' });
|
| 1399 |
+
|
| 1400 |
+
console.log('Processing camera photo:', fileName, 'Mode:', mode);
|
| 1401 |
+
|
| 1402 |
+
// Process using existing function
|
| 1403 |
+
processFile(mode, file);
|
| 1404 |
+
}
|
| 1405 |
+
|
| 1406 |
+
// Close camera modal
|
| 1407 |
+
function closeCamera() {
|
| 1408 |
+
stopCamera();
|
| 1409 |
+
toggleModal('camera-modal', false);
|
| 1410 |
+
cameraCaptureMode = null;
|
| 1411 |
+
capturedImageBlob = null;
|
| 1412 |
+
}
|
| 1413 |
+
|
| 1414 |
+
// Event listeners for camera buttons
|
| 1415 |
+
document.getElementById('camera-btn-cards').addEventListener('click', () => openCamera('cards'));
|
| 1416 |
+
document.getElementById('camera-btn-brochures').addEventListener('click', () => openCamera('brochures'));
|
| 1417 |
+
cameraCaptureBtn.addEventListener('click', capturePhoto);
|
| 1418 |
+
cameraRetakeBtn.addEventListener('click', retakePhoto);
|
| 1419 |
+
cameraUseBtn.addEventListener('click', useCapturedPhoto);
|
| 1420 |
+
cameraCloseBtn.addEventListener('click', closeCamera);
|
| 1421 |
+
// ## END: Camera Capture Functionality ##
|
| 1422 |
+
|
| 1423 |
+
initCanvas();
|
| 1424 |
+
animate();
|
| 1425 |
+
showPage('page-api-key');
|
| 1426 |
+
</script>
|
| 1427 |
+
</body>
|
| 1428 |
+
|
| 1429 |
+
</html>
|