jashdoshi77 commited on
Commit
1a63864
·
0 Parent(s):

Deploy VisionExtract to HF Spaces

Browse files
Files changed (13) hide show
  1. .dockerignore +46 -0
  2. .env.example +9 -0
  3. .gitignore +30 -0
  4. Dockerfile +51 -0
  5. README.md +36 -0
  6. app.py +720 -0
  7. download_nltk.py +44 -0
  8. gunicorn.conf.py +30 -0
  9. models.py +612 -0
  10. rag_core.py +713 -0
  11. render.yaml +20 -0
  12. requirements.txt +13 -0
  13. 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>